From a7ff1cf312c0f0dd8dcff885a620771348e0b86f Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sun, 5 Jul 2026 13:32:02 +0000 Subject: [PATCH] =?UTF-8?q?feat(web):=20display=20arrangement=20table=20?= =?UTF-8?q?=E2=80=94=20the=20Stage=205=20console=20x/y=20editor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- web/messages/de.json | 5 +- web/messages/en.json | 5 +- web/src/sections/Host/DisplayCard.tsx | 90 +++++++++++++++++++++++++++ 3 files changed, 98 insertions(+), 2 deletions(-) diff --git a/web/messages/de.json b/web/messages/de.json index 215788f..19fb54b 100644 --- a/web/messages/de.json +++ b/web/messages/de.json @@ -67,7 +67,7 @@ "display_max": "Max. Displays", "display_save": "Speichern", "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_none_live": "Derzeit keine virtuellen Displays.", "display_state_active": "Aktiv", @@ -77,6 +77,9 @@ "display_release_all": "Alle gehaltenen freigeben", "display_expires_in": "Abbau in {sec}s", "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_empty": "Noch keine gekoppelten Geräte.", "clients_name": "Name", diff --git a/web/messages/en.json b/web/messages/en.json index f4c4399..e0e50e0 100644 --- a/web/messages/en.json +++ b/web/messages/en.json @@ -67,7 +67,7 @@ "display_max": "Max displays", "display_save": "Save", "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_none_live": "No virtual displays right now.", "display_state_active": "Active", @@ -77,6 +77,9 @@ "display_release_all": "Release all kept", "display_expires_in": "tears down in {sec}s", "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_empty": "No paired clients yet.", "clients_name": "Name", diff --git a/web/src/sections/Host/DisplayCard.tsx b/web/src/sections/Host/DisplayCard.tsx index 4ba51b0..13e50d6 100644 --- a/web/src/sections/Host/DisplayCard.tsx +++ b/web/src/sections/Host/DisplayCard.tsx @@ -7,6 +7,7 @@ import { useGetDisplaySettings, useGetDisplayState, useReleaseDisplay, + useSetDisplayLayout, useSetDisplaySettings, } from "@/api/gen/display/display"; import type { ApiDisplayInfo } from "@/api/gen/model"; @@ -126,6 +127,95 @@ const LiveDisplays: FC = () => { ))} )} + + + ); +}; + +/** + * 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 | null>(null); + useEffect(() => { + if (pos === null && arrangeable.length > 0) { + const seed: Record = {}; + 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 ( +
+

{m.display_arrange()}

+

{m.display_arrange_help()}

+
+ {arrangeable.map((d) => { + const slot = d.identity_slot as number; + const p = cur[String(slot)] ?? { x: d.x, y: d.y }; + return ( +
+ + {d.mode}{" "} + #{slot} + + + setXY(slot, "x", Math.trunc(Number(e.target.value) || 0))} + /> + + setXY(slot, "y", Math.trunc(Number(e.target.value) || 0))} + /> +
+ ); + })} +
+ {saveLayout.error && ( +

+ {apiErrorMessage(saveLayout.error)} +

+ )} +
); };