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_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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 = () => {
|
||||
))}
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user