Skip to content

Commit 0bff106

Browse files
Add monitor management workspace (#181)
* feat(app): ✨ add monitor management workspace - add stable monitor topology identities and CLI topology labels - add profile editing, layout rules, tray runtime controls, and hotkey diagnostics - add image-model design plan and mockups for the app pages and tray flyout * fix(app): 🐛 address monitor workspace review feedback - build monitor topology labels from the full connected snapshot - preserve stable monitor bindings and guard unsafe exact-rectangle edits - keep recovery profiles protected and invalid entries removable
1 parent 77ac570 commit 0bff106

51 files changed

Lines changed: 2558 additions & 78 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

Docs/DesktopManager.App.Design.md

Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
# DesktopManager App Design Plan
2+
3+
This document captures the image-model design direction for the DesktopManager desktop app and tray experience. The mockups are intentionally treated as design references, not exact pixel contracts.
4+
5+
## Design Goals
6+
7+
- Make monitor topology the center of the product, not a secondary settings field.
8+
- Keep hotkey and rule editing fast enough for daily power-user use.
9+
- Make tray interaction useful without opening the full app.
10+
- Prefer clear operational surfaces over marketing-style presentation.
11+
- Keep diagnostics first-class so broken hooks, permissions, and monitor identity issues can be understood quickly.
12+
13+
## Image Model References
14+
15+
| Surface | Mockup |
16+
| --- | --- |
17+
| Main app control center | ![Main DesktopManager window](Mockups/DesktopManager.App.ImageModel.MainWindow.png) |
18+
| Tray flyout | ![DesktopManager tray flyout](Mockups/DesktopManager.App.ImageModel.TrayFlyout.png) |
19+
| Hotkeys page | ![DesktopManager hotkeys page](Mockups/DesktopManager.App.ImageModel.Hotkeys.png) |
20+
| Layout rules page | ![DesktopManager layout rules page](Mockups/DesktopManager.App.ImageModel.LayoutRules.png) |
21+
| Diagnostics page | ![DesktopManager diagnostics page](Mockups/DesktopManager.App.ImageModel.Diagnostics.png) |
22+
| Startup page | ![DesktopManager startup page](Mockups/DesktopManager.App.ImageModel.Startup.png) |
23+
| Earlier control-center exploration | ![Earlier DesktopManager control center exploration](Mockups/DesktopManager.App.ImageModel.ControlCenter.png) |
24+
25+
## Application Shell
26+
27+
The app should use a compact left navigation rail and keep all product surfaces in one window:
28+
29+
- `Monitors`
30+
- `Hotkeys`
31+
- `Layout Rules`
32+
- `Diagnostics`
33+
- `Startup`
34+
35+
The top area should include runtime status, hotkey status, active profile, and quick actions:
36+
37+
- `Apply Rules`
38+
- `Pause Hotkeys` / `Resume Hotkeys`
39+
- `Open Diagnostics`
40+
- profile selector
41+
42+
The shell should avoid a landing page. Opening the app should land on the active management surface, with `Monitors` as the default.
43+
44+
## Monitors Page
45+
46+
The `Monitors` page is the primary workspace.
47+
48+
It should contain:
49+
50+
- a large topology canvas with physical monitor positions
51+
- stable topology labels such as `Top Left`, `Top Right`, `Bottom Left`, `Bottom Right`
52+
- resolution, DPI, primary, and connected status badges
53+
- selected-monitor details panel
54+
- quick `Move Active Window` controls
55+
- buttons for `Center`, `Maximize`, `Restore`, and move-to-monitor
56+
- visual indicators for disconnected or changed monitors
57+
- a small topology refresh and diagnostics link
58+
59+
Expected behavior:
60+
61+
- Selecting a monitor highlights it in the topology and details panel.
62+
- Topology labels should remain stable when device names change.
63+
- Disconnected monitors should not disappear silently; they should show as unavailable when still referenced by rules or hotkeys.
64+
- The active window movement commands should be testable directly from this page.
65+
66+
## Hotkeys Page
67+
68+
The `Hotkeys` page should be a real editor, not just a list of current bindings.
69+
70+
It should contain:
71+
72+
- searchable action list
73+
- enabled toggle per action
74+
- hotkey text and recorder control
75+
- action type selector
76+
- placement selector
77+
- target monitor selector using topology names
78+
- verification toggle
79+
- conflict warning surface
80+
- `Test`, `Save`, `Delete`, and `New Action`
81+
- recent hotkey activity
82+
83+
Expected behavior:
84+
85+
- Recording a hotkey should show captured modifiers and key before saving.
86+
- Conflicts should be detected before save.
87+
- Testing a hotkey action should execute the same path as the runtime hotkey handler.
88+
- Failed test results should link to `Diagnostics`.
89+
90+
## Layout Rules Page
91+
92+
The `Layout Rules` page should support DisplayFusion-style window placement rules.
93+
94+
It should contain:
95+
96+
- rule table with priority, enabled state, match summary, action summary, and last matched time
97+
- `New Rule`, `Apply Rules`, and `Preview Matches`
98+
- import/export profile actions
99+
- rule builder for process name, window title, window class, and current monitor
100+
- action builder for target monitor, placement, restore/maximize behavior, and delay
101+
- apply-on-startup toggle
102+
- safe delete and duplicate actions
103+
104+
Expected behavior:
105+
106+
- `Preview Matches` should show which currently open windows match each rule.
107+
- `Apply Rules` should report moved, skipped, and failed windows.
108+
- Rule priority should be explicit and reorderable.
109+
- Rules should use stable monitor identity/topology, not fragile display numbers alone.
110+
111+
## Diagnostics Page
112+
113+
The `Diagnostics` page should make broken hotkeys and monitor issues explainable.
114+
115+
It should contain:
116+
117+
- runtime status strips for app, tray, hotkey hook, startup, profile validation, and monitor topology
118+
- `Diagnose Hotkey` recorder panel
119+
- last pressed hotkey, captured/not captured, foreground process, focused window, matched action, and failure reason
120+
- monitor probe section
121+
- recent events table
122+
- `Run Self-Test`
123+
- `Export Diagnostics`
124+
- `Copy Support Bundle`
125+
126+
Expected behavior:
127+
128+
- The page should distinguish "not captured" from "captured but no action matched".
129+
- Foreground-window filtering and elevated-window limitations should be visible.
130+
- Profile validation errors should link directly to the affected hotkey or rule.
131+
- Exported diagnostics should include profile, topology, recent events, and runtime status.
132+
133+
## Startup Page
134+
135+
The `Startup` page should collect lifecycle, tray, and profile behavior.
136+
137+
It should contain:
138+
139+
- `Start with Windows`
140+
- `Start minimized to tray`
141+
- `Enable hotkeys on launch`
142+
- `Apply layout rules on launch`
143+
- `Show tray flyout on click`
144+
- `Require confirmation before moving many windows`
145+
- active profile selector
146+
- import/export/reset profile actions
147+
- startup registration status
148+
- tray behavior preview
149+
150+
Expected behavior:
151+
152+
- Startup registration should show the exact mechanism and status.
153+
- Profile import should validate before replacing the active profile.
154+
- Reset should offer scoped reset options: hotkeys only, rules only, or full profile.
155+
- Settings that affect runtime behavior should apply immediately where possible.
156+
157+
## Tray Flyout
158+
159+
The tray should support two layers:
160+
161+
- left click: compact flyout for fast use
162+
- right click: classic context menu fallback
163+
164+
The flyout should contain:
165+
166+
- status header: app running, hotkeys active/paused, active profile
167+
- miniature monitor topology map
168+
- quick `Move Active Window` buttons for each monitor
169+
- `Center`, `Maximize`, and `Restore`
170+
- `Apply Rules`
171+
- `Pause Hotkeys` / `Resume Hotkeys`
172+
- `Open DesktopManager`
173+
- `Diagnostics`
174+
- recent activity list
175+
176+
Expected behavior:
177+
178+
- Tray flyout commands should execute quickly and close only when appropriate.
179+
- The flyout should show clear failure feedback when the active window cannot be moved.
180+
- Right-click menu should keep reliable basics: open, pause/resume hotkeys, apply rules, reload profile, exit.
181+
182+
## Implementation Phases
183+
184+
### Phase 1: Structure
185+
186+
- Introduce shell navigation and page separation.
187+
- Move current controls into `Monitors`, `Hotkeys`, `Layout Rules`, `Diagnostics`, and `Startup` views.
188+
- Keep behavior equivalent while improving layout.
189+
190+
### Phase 2: Monitor Canvas
191+
192+
- Build a topology canvas from current monitor topology data.
193+
- Add selectable monitor elements and topology labels.
194+
- Wire `Move Active Window` commands to selected monitor actions.
195+
196+
### Phase 3: Editors
197+
198+
- Replace ad-hoc editing with dedicated hotkey and rule editor components.
199+
- Add conflict detection and profile validation feedback.
200+
- Add rule preview and apply result summaries.
201+
202+
### Phase 4: Tray Flyout
203+
204+
- Add a real tray flyout window.
205+
- Keep the right-click menu as fallback.
206+
- Add quick movement, rule execution, and diagnostics commands.
207+
208+
### Phase 5: Diagnostics
209+
210+
- Expand hotkey diagnostics into a structured live panel.
211+
- Add support bundle export.
212+
- Add monitor identity/topology warnings and profile validation links.
213+
214+
## Open Design Decisions
215+
216+
- Whether the app should default to a dark, light, or system-following theme.
217+
- Whether topology labels should be user-overridable.
218+
- Whether rule priority should use drag-and-drop, up/down buttons, or both.
219+
- Whether profile import/export should remain JSON-only or include a friendlier package format later.
220+
- Whether tray flyout should pin open during multi-window placement sessions.
1.35 MB
Loading
1.27 MB
Loading
1.26 MB
Loading
1.35 MB
Loading
1.35 MB
Loading
1.27 MB
Loading
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
using System.Text.Json;
2+
3+
namespace DesktopManager.App.Core;
4+
5+
/// <summary>
6+
/// Parses hotkey runtime and execution JSONL diagnostics into concise operator summaries.
7+
/// </summary>
8+
public static class HotkeyDiagnosticLineParser {
9+
/// <summary>
10+
/// Attempts to parse one JSONL diagnostic line for the requested hotkey or function name.
11+
/// </summary>
12+
/// <param name="json">One JSON diagnostic line.</param>
13+
/// <param name="hotkey">The hotkey gesture to match.</param>
14+
/// <param name="functionName">The function name to match.</param>
15+
/// <param name="summary">The parsed summary when a matching line is found.</param>
16+
/// <returns>True when the line is valid and matches the requested function or hotkey.</returns>
17+
public static bool TryParse(
18+
string json,
19+
string? hotkey,
20+
string? functionName,
21+
out HotkeyDiagnosticSummary summary) {
22+
summary = new HotkeyDiagnosticSummary();
23+
if (string.IsNullOrWhiteSpace(json)) {
24+
return false;
25+
}
26+
27+
try {
28+
using JsonDocument document = JsonDocument.Parse(json);
29+
JsonElement root = document.RootElement;
30+
string lineHotkey = GetString(root, "Hotkey");
31+
string lineFunction = GetString(root, "FunctionName");
32+
if (!Matches(lineHotkey, hotkey) && !Matches(lineFunction, functionName)) {
33+
return false;
34+
}
35+
36+
string eventName = GetString(root, "EventName");
37+
summary = new HotkeyDiagnosticSummary {
38+
Found = true,
39+
Timestamp = GetTimestamp(root),
40+
EventName = string.IsNullOrWhiteSpace(eventName) ? "execution" : eventName
41+
};
42+
43+
if (string.IsNullOrWhiteSpace(eventName)) {
44+
PopulateExecutionSummary(root, summary, lineFunction, lineHotkey);
45+
} else {
46+
PopulateRuntimeSummary(root, summary, lineFunction, lineHotkey, eventName);
47+
}
48+
49+
return true;
50+
} catch (JsonException) {
51+
return false;
52+
}
53+
}
54+
55+
private static void PopulateRuntimeSummary(
56+
JsonElement root,
57+
HotkeyDiagnosticSummary summary,
58+
string functionName,
59+
string hotkey,
60+
string eventName) {
61+
JsonElement details = TryGetObject(root, "Details");
62+
string source = GetString(details, "Source");
63+
string backend = GetString(details, "Backend");
64+
string message = GetString(root, "Message");
65+
string functionLabel = GetFunctionLabel(functionName, hotkey);
66+
67+
summary.Summary = eventName switch {
68+
"registered" => $"{functionLabel} registered with {NonEmpty(backend, "unknown backend")}.",
69+
"registration-failed" or "low-level-registration-failed" => $"{functionLabel} registration failed: {NonEmpty(message, "no error captured")}.",
70+
"queued" => $"{functionLabel} queued from {NonEmpty(source, "unknown source")}.",
71+
"started" => $"{functionLabel} started from {NonEmpty(source, "unknown source")}.",
72+
"completed" => $"{functionLabel} completed from {NonEmpty(source, "unknown source")}.",
73+
"failed" => $"{functionLabel} failed from {NonEmpty(source, "unknown source")}: {NonEmpty(message, "no error captured")}.",
74+
"dropped" => $"{functionLabel} was dropped: {NonEmpty(message, "no reason captured")}.",
75+
_ => $"{functionLabel} event '{eventName}'."
76+
};
77+
78+
string capturedHandle = NonEmpty(GetString(details, "CapturedHandle"), GetString(details, "WindowHandle"));
79+
string diagnosticPath = GetString(details, "DiagnosticPath");
80+
string verified = GetString(details, "Verified");
81+
string attempts = GetString(details, "Attempts");
82+
summary.Details = JoinDetails(
83+
Pair("source", source),
84+
Pair("backend", backend),
85+
Pair("handle", capturedHandle),
86+
Pair("verified", verified),
87+
Pair("attempts", attempts),
88+
Pair("diagnostic", diagnosticPath),
89+
Pair("message", message));
90+
}
91+
92+
private static void PopulateExecutionSummary(
93+
JsonElement root,
94+
HotkeyDiagnosticSummary summary,
95+
string functionName,
96+
string hotkey) {
97+
string functionLabel = GetFunctionLabel(functionName, hotkey);
98+
string verified = GetString(root, "Verified");
99+
string attempt = GetString(root, "Attempt");
100+
string error = GetString(root, "Error");
101+
string resolvedHandle = GetString(root, "ResolvedHandle");
102+
103+
summary.Summary = string.IsNullOrWhiteSpace(error)
104+
? $"{functionLabel} execution diagnostic captured."
105+
: $"{functionLabel} execution failed: {error}.";
106+
summary.Details = JoinDetails(
107+
Pair("verified", verified),
108+
Pair("attempt", attempt),
109+
Pair("handle", resolvedHandle),
110+
Pair("error", error));
111+
}
112+
113+
private static string GetFunctionLabel(string functionName, string hotkey) {
114+
if (!string.IsNullOrWhiteSpace(functionName) && !string.IsNullOrWhiteSpace(hotkey)) {
115+
return $"{functionName} ({hotkey})";
116+
}
117+
118+
return NonEmpty(functionName, NonEmpty(hotkey, "Hotkey"));
119+
}
120+
121+
private static string Pair(string name, string value) {
122+
return string.IsNullOrWhiteSpace(value) ? string.Empty : $"{name}: {value}";
123+
}
124+
125+
private static string JoinDetails(params string[] values) {
126+
return string.Join(" | ", values.Where(value => !string.IsNullOrWhiteSpace(value)));
127+
}
128+
129+
private static string NonEmpty(string value, string fallback) {
130+
return string.IsNullOrWhiteSpace(value) ? fallback : value;
131+
}
132+
133+
private static bool Matches(string value, string? expected) {
134+
return !string.IsNullOrWhiteSpace(value) &&
135+
!string.IsNullOrWhiteSpace(expected) &&
136+
string.Equals(value.Trim(), expected.Trim(), StringComparison.OrdinalIgnoreCase);
137+
}
138+
139+
private static DateTimeOffset? GetTimestamp(JsonElement root) {
140+
string value = GetString(root, "Timestamp");
141+
return DateTimeOffset.TryParse(value, out DateTimeOffset timestamp) ? timestamp : null;
142+
}
143+
144+
private static JsonElement TryGetObject(JsonElement root, string propertyName) {
145+
if (root.ValueKind == JsonValueKind.Object &&
146+
root.TryGetProperty(propertyName, out JsonElement value) &&
147+
value.ValueKind == JsonValueKind.Object) {
148+
return value;
149+
}
150+
151+
return default;
152+
}
153+
154+
private static string GetString(JsonElement root, string propertyName) {
155+
if (root.ValueKind != JsonValueKind.Object ||
156+
!root.TryGetProperty(propertyName, out JsonElement value)) {
157+
return string.Empty;
158+
}
159+
160+
return value.ValueKind switch {
161+
JsonValueKind.String => value.GetString() ?? string.Empty,
162+
JsonValueKind.Number => value.ToString(),
163+
JsonValueKind.True => "true",
164+
JsonValueKind.False => "false",
165+
_ => string.Empty
166+
};
167+
}
168+
}

0 commit comments

Comments
 (0)