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:
2026-07-05 13:32:02 +00:00
parent 87435e6547
commit a7ff1cf312
3 changed files with 98 additions and 2 deletions
+4 -1
View File
@@ -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",
+4 -1
View File
@@ -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",
+90
View File
@@ -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>
); );
}; };