feat(web): display arrangement table — the Stage 5 console x/y editor
Completes Stage 5's web piece (design/display-management.md §6.2): a `DisplayArrangement` editor in the Virtual displays card. For a ≥2-display group, it renders an x/y table over the live displays that carry a stable identity slot (the manual-layout key), seeded from the current computed positions; Save writes `PUT /display/layout` (via the generated `useSetDisplayLayout`), which switches the host to a manual layout applied from the next connect. Shared/anonymous displays (no identity slot) are omitted (they can't be pinned). Also refreshes the now-stale `display_pending_note` copy (conflict/identity/layout ARE enforced as of Stages 3-5) in en + de. web tsc + vite build green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -67,7 +67,7 @@
|
|||||||
"display_max": "Max. Displays",
|
"display_max": "Max. Displays",
|
||||||
"display_save": "Speichern",
|
"display_save": "Speichern",
|
||||||
"display_effective": "Aktiv",
|
"display_effective": "Aktiv",
|
||||||
"display_pending_note": "Konfliktbehandlung, Identität und Layout werden gespeichert, aber noch nicht angewendet — sie folgen in späteren Versionen.",
|
"display_pending_note": "Änderungen greifen ab der nächsten Verbindung — eine laufende Sitzung behält die Anzeige, mit der sie gestartet ist.",
|
||||||
"display_live": "Aktive Displays",
|
"display_live": "Aktive Displays",
|
||||||
"display_none_live": "Derzeit keine virtuellen Displays.",
|
"display_none_live": "Derzeit keine virtuellen Displays.",
|
||||||
"display_state_active": "Aktiv",
|
"display_state_active": "Aktiv",
|
||||||
@@ -77,6 +77,9 @@
|
|||||||
"display_release_all": "Alle gehaltenen freigeben",
|
"display_release_all": "Alle gehaltenen freigeben",
|
||||||
"display_expires_in": "Abbau in {sec}s",
|
"display_expires_in": "Abbau in {sec}s",
|
||||||
"display_sessions": "{count} streamend",
|
"display_sessions": "{count} streamend",
|
||||||
|
"display_arrange": "Anzeigen anordnen",
|
||||||
|
"display_arrange_help": "Legen Sie fest, wo jede gestreamte Anzeige auf dem Desktop sitzt (in Pixeln). Beim Speichern wird auf ein manuelles Layout umgeschaltet; es greift ab der nächsten Verbindung.",
|
||||||
|
"display_arrange_save": "Anordnung speichern",
|
||||||
"clients_title": "Gekoppelte Geräte",
|
"clients_title": "Gekoppelte Geräte",
|
||||||
"clients_empty": "Noch keine gekoppelten Geräte.",
|
"clients_empty": "Noch keine gekoppelten Geräte.",
|
||||||
"clients_name": "Name",
|
"clients_name": "Name",
|
||||||
|
|||||||
@@ -67,7 +67,7 @@
|
|||||||
"display_max": "Max displays",
|
"display_max": "Max displays",
|
||||||
"display_save": "Save",
|
"display_save": "Save",
|
||||||
"display_effective": "In effect",
|
"display_effective": "In effect",
|
||||||
"display_pending_note": "Conflict handling, identity, and layout are stored but not enforced yet — they arrive in later releases.",
|
"display_pending_note": "Changes apply from the next connection — a streaming session keeps the display it opened on.",
|
||||||
"display_live": "Live displays",
|
"display_live": "Live displays",
|
||||||
"display_none_live": "No virtual displays right now.",
|
"display_none_live": "No virtual displays right now.",
|
||||||
"display_state_active": "Active",
|
"display_state_active": "Active",
|
||||||
@@ -77,6 +77,9 @@
|
|||||||
"display_release_all": "Release all kept",
|
"display_release_all": "Release all kept",
|
||||||
"display_expires_in": "tears down in {sec}s",
|
"display_expires_in": "tears down in {sec}s",
|
||||||
"display_sessions": "{count} streaming",
|
"display_sessions": "{count} streaming",
|
||||||
|
"display_arrange": "Arrange displays",
|
||||||
|
"display_arrange_help": "Set where each streamed display sits on the desktop, in pixels. Saving switches to a manual layout; it applies from the next connect.",
|
||||||
|
"display_arrange_save": "Save arrangement",
|
||||||
"clients_title": "Paired clients",
|
"clients_title": "Paired clients",
|
||||||
"clients_empty": "No paired clients yet.",
|
"clients_empty": "No paired clients yet.",
|
||||||
"clients_name": "Name",
|
"clients_name": "Name",
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
useGetDisplaySettings,
|
useGetDisplaySettings,
|
||||||
useGetDisplayState,
|
useGetDisplayState,
|
||||||
useReleaseDisplay,
|
useReleaseDisplay,
|
||||||
|
useSetDisplayLayout,
|
||||||
useSetDisplaySettings,
|
useSetDisplaySettings,
|
||||||
} from "@/api/gen/display/display";
|
} from "@/api/gen/display/display";
|
||||||
import type { ApiDisplayInfo } from "@/api/gen/model";
|
import type { ApiDisplayInfo } from "@/api/gen/model";
|
||||||
@@ -126,6 +127,95 @@ const LiveDisplays: FC = () => {
|
|||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
|
<DisplayArrangement displays={displays} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The multi-monitor **arrangement** editor (design/display-management.md §6.2): an x/y table over the
|
||||||
|
* live displays that carry a stable identity slot (the manual-layout key). Saving writes
|
||||||
|
* `PUT /display/layout`, which switches the host to a manual layout and applies from the next connect.
|
||||||
|
* Shown only for a ≥2-display group — arranging a single display is moot.
|
||||||
|
*/
|
||||||
|
const DisplayArrangement: FC<{ displays: ApiDisplayInfo[] }> = ({ displays }) => {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const saveLayout = useSetDisplayLayout();
|
||||||
|
// Only displays with a stable identity slot can be pinned (shared/anonymous ones have no key).
|
||||||
|
const arrangeable = displays.filter((d) => d.identity_slot != null);
|
||||||
|
|
||||||
|
// Local edit buffer keyed by identity-slot string → {x, y}, seeded once from the current positions.
|
||||||
|
const [pos, setPos] = useState<Record<string, { x: number; y: number }> | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (pos === null && arrangeable.length > 0) {
|
||||||
|
const seed: Record<string, { x: number; y: number }> = {};
|
||||||
|
for (const d of arrangeable) seed[String(d.identity_slot)] = { x: d.x, y: d.y };
|
||||||
|
setPos(seed);
|
||||||
|
}
|
||||||
|
}, [arrangeable, pos]);
|
||||||
|
|
||||||
|
if (arrangeable.length < 2) return null;
|
||||||
|
const cur = pos ?? {};
|
||||||
|
|
||||||
|
const setXY = (slot: number, key: "x" | "y", val: number) => {
|
||||||
|
const k = String(slot);
|
||||||
|
setPos({ ...cur, [k]: { ...(cur[k] ?? { x: 0, y: 0 }), [key]: val } });
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSave = () =>
|
||||||
|
saveLayout.mutate(
|
||||||
|
{ data: { positions: cur } },
|
||||||
|
{ onSuccess: () => qc.invalidateQueries({ queryKey: getGetDisplayStateQueryKey() }) },
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2 border-t pt-4">
|
||||||
|
<h4 className="text-sm font-medium">{m.display_arrange()}</h4>
|
||||||
|
<p className="text-xs text-muted-foreground">{m.display_arrange_help()}</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{arrangeable.map((d) => {
|
||||||
|
const slot = d.identity_slot as number;
|
||||||
|
const p = cur[String(slot)] ?? { x: d.x, y: d.y };
|
||||||
|
return (
|
||||||
|
<div key={d.slot} className="flex flex-wrap items-center gap-2 text-sm">
|
||||||
|
<span className="w-44 truncate">
|
||||||
|
{d.mode}{" "}
|
||||||
|
<code className="text-xs text-muted-foreground">#{slot}</code>
|
||||||
|
</span>
|
||||||
|
<Label className="text-xs" htmlFor={`disp-x-${slot}`}>
|
||||||
|
X
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id={`disp-x-${slot}`}
|
||||||
|
type="number"
|
||||||
|
className="w-24"
|
||||||
|
value={p.x}
|
||||||
|
disabled={saveLayout.isPending}
|
||||||
|
onChange={(e) => setXY(slot, "x", Math.trunc(Number(e.target.value) || 0))}
|
||||||
|
/>
|
||||||
|
<Label className="text-xs" htmlFor={`disp-y-${slot}`}>
|
||||||
|
Y
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id={`disp-y-${slot}`}
|
||||||
|
type="number"
|
||||||
|
className="w-24"
|
||||||
|
value={p.y}
|
||||||
|
disabled={saveLayout.isPending}
|
||||||
|
onChange={(e) => setXY(slot, "y", Math.trunc(Number(e.target.value) || 0))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{saveLayout.error && (
|
||||||
|
<p className="text-sm text-amber-600 dark:text-amber-500">
|
||||||
|
{apiErrorMessage(saveLayout.error)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<Button size="sm" onClick={onSave} disabled={saveLayout.isPending}>
|
||||||
|
{m.display_arrange_save()}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user