Rebuild Displays around the canvas, and let the transaction keep color

Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
Gabriel Brown
2026-08-24 10:44:11 -04:00
parent 1f694b00b6
commit 9bc68ba358
29 changed files with 3065 additions and 388 deletions
@@ -1,3 +1,16 @@
// Where the displays are, drawn to scale.
//
// The hero of the Displays page, and it renders whatever is connected --
// including one display. A laptop used to open this page on a picker offering a
// list of one and never saw the canvas at all, which made the page's clearest
// surface the one thing a single-display machine could not have. Solo, the
// display is drawn centred and dragging goes away: there is nothing to arrange
// it against.
//
// A mirrored display has no position of its own. The compositor puts it on top
// of the display it mirrors, so the canvas stacks it there and says what it is
// mirroring, rather than drawing it wherever its stale coordinates point.
import QtQuick
import qs.config
import qs.widgets
@@ -17,6 +30,34 @@ Item {
readonly property var canvasData: DisplayLayout.canvasRects(
root.draftLayout, canvas.width, canvas.height, 18)
readonly property bool solo: root.draftLayout.length <= 1
readonly property bool draggable: root.interactionEnabled && !root.solo
// What is drawn, which is not quite what the layout resolved to: a single
// display scaled to fill the whole canvas reads as a wall rather than as a
// screen on a desk, so it is drawn at a size that leaves it room to sit in.
readonly property real soloFactor: 0.62
readonly property var tiles: {
const rects = root.canvasData.rects;
if (rects.length !== 1)
return rects;
const rect = rects[0];
return [Object.assign({}, rect, {
x: rect.x + rect.width * (1 - root.soloFactor) / 2,
y: rect.y + rect.height * (1 - root.soloFactor) / 2,
width: rect.width * root.soloFactor,
height: rect.height * root.soloFactor
})];
}
readonly property string hint: {
if (root.solo)
return "One display connected — plug in another to arrange";
return root.width >= 600
? "Drag to arrange · arrows move 10 px · Shift moves 100 px"
: "Drag or use the arrow keys";
}
function copied(layout): var {
return (layout || []).map(record => Object.assign({}, record));
}
@@ -55,10 +96,13 @@ Item {
return root.applyDraft();
}
// The layout as it resolved, not as it is drawn: this is what the
// arrangement math produced, which is the thing worth asserting about.
function canvasSnapshot(): var {
return {
bounds: root.canvasData.bounds,
scale: root.canvasData.scale,
draggable: root.draggable,
rects: root.canvasData.rects.map(record => Object.assign({}, record))
};
}
@@ -82,28 +126,79 @@ Item {
Rectangle {
id: canvas
width: parent.width
height: root.width >= 620 ? 232 : 190
radius: Theme.cardRadius
height: root.width >= 620 ? 250 : 200
radius: Theme.cardRadius + 2
color: Theme.alpha(Theme.bgDark, 0.76)
border.width: 1
border.color: Theme.alpha(Theme.fg, 0.07)
clip: true
PrismEdge {
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
inset: parent.radius
opacity: 0.36
z: 2
}
// Light from above, so the screens read as objects standing on a
// surface rather than as boxes floating in a field.
Rectangle {
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
height: parent.height * 0.55
border.width: 0
gradient: Gradient {
GradientStop { position: 0.0; color: Theme.alpha(Theme.accent, 0.05) }
GradientStop { position: 1.0; color: Theme.alpha(Theme.accent, 0.0) }
}
}
// A restrained coordinate field makes the topology feel like a
// precision instrument without turning it into a technical graph.
Repeater {
model: 4
model: Math.max(1, Math.ceil(canvas.height / 44))
Rectangle {
required property int index
y: (index + 1) * canvas.height / 5
y: (index + 1) * 44
width: canvas.width
height: 1
color: Theme.alpha(Theme.fg, 0.025)
color: Theme.alpha(Theme.fg, 0.03)
}
}
Repeater {
model: root.canvasData.rects
model: Math.max(1, Math.ceil(canvas.width / 44))
Rectangle {
required property int index
x: (index + 1) * 44
width: 1
height: canvas.height
color: Theme.alpha(Theme.fg, 0.03)
}
}
Rectangle {
anchors.left: parent.left
anchors.right: parent.right
anchors.leftMargin: parent.width * 0.1
anchors.rightMargin: parent.width * 0.1
anchors.bottom: parent.bottom
anchors.bottomMargin: 26
height: 1
border.width: 0
gradient: Gradient {
orientation: Gradient.Horizontal
GradientStop { position: 0.0; color: Theme.alpha(Theme.fg, 0.0) }
GradientStop { position: 0.5; color: Theme.alpha(Theme.fg, 0.14) }
GradientStop { position: 1.0; color: Theme.alpha(Theme.fg, 0.0) }
}
}
Repeater {
model: root.tiles
Rectangle {
id: tile
@@ -113,26 +208,44 @@ Item {
readonly property var draft: root.draftLayout.find(
record => record.name === modelData.name)
// The rect carries the mirror flag; the draft record is the
// fallback for a layout that has not been through
// canvasRects yet.
readonly property string mirrorOf: {
if (tile.modelData.mirrorOf)
return String(tile.modelData.mirrorOf);
return tile.draft && tile.draft.mirrorOf ? String(tile.draft.mirrorOf) : "";
}
readonly property bool mirrored: tile.mirrorOf !== ""
readonly property bool tileDraggable: root.draggable && !tile.mirrored
x: modelData.x
y: modelData.y
z: tile.mirrored ? 1 : 0
width: Math.max(64, modelData.width)
height: Math.max(48, modelData.height)
radius: 11
color: tile.selected
? Theme.alpha(Theme.bgHighlight, 0.92)
: Theme.alpha(Theme.bgPanel, hover.hovered ? 0.94 : 0.78)
: Theme.alpha(Theme.bgPanel, hover.hovered ? 0.94 : 0.82)
border.width: tile.selected || activeFocus ? 2 : 1
border.color: activeFocus
? Theme.accentSecondary
: (tile.selected ? Theme.accent : Theme.alpha(Theme.fg, 0.14))
: (tile.selected ? Theme.accent : Theme.alpha(Theme.fg, 0.18))
opacity: root.interactionEnabled ? 1 : 0.5
activeFocusOnTab: root.interactionEnabled
Accessible.role: Accessible.Button
Accessible.name: "Move " + tile.modelData.name
Accessible.description: tile.draft && tile.draft.primary
? "Primary display. Drag or use the arrow keys to move it."
: "Drag or use the arrow keys to move this display."
Accessible.description: {
if (tile.mirrored)
return "Mirroring " + tile.mirrorOf + ". Its position is chosen by the compositor.";
if (!tile.tileDraggable)
return "The only connected display. There is nothing to arrange it against.";
return tile.draft && tile.draft.primary
? "Primary display. Drag or use the arrow keys to move it."
: "Drag or use the arrow keys to move this display.";
}
Rectangle {
anchors.fill: parent
@@ -147,6 +260,21 @@ Item {
}
}
// The strip a bar would occupy, so the top of the picture is
// the top of the tile without anything having to say so.
Rectangle {
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
anchors.topMargin: 5
anchors.leftMargin: 8
anchors.rightMargin: 8
height: 3
radius: 2
border.width: 0
color: Theme.alpha(Theme.fg, 0.14)
}
Column {
anchors.left: parent.left
anchors.right: parent.right
@@ -165,9 +293,13 @@ Item {
}
Text {
width: parent.width
text: tile.draft
? `${Math.round(tile.draft.width / tile.draft.scale)} × ${Math.round(tile.draft.height / tile.draft.scale)}`
: ""
text: {
if (!tile.draft)
return "";
const size = DisplayLayout.logicalSize(tile.draft);
const caption = `${Math.round(size.width)} × ${Math.round(size.height)}`;
return tile.width >= 150 ? caption + " points" : caption;
}
color: Theme.fgDim
font.family: Theme.fontFamily
font.features: Theme.tabularFigures
@@ -176,21 +308,38 @@ Item {
}
}
Rectangle {
Text {
anchors.top: parent.top
anchors.right: parent.right
anchors.margins: 7
width: primaryText.implicitWidth + 12
height: 20
radius: Theme.pillRadius
anchors.topMargin: 7
anchors.rightMargin: 9
visible: tile.draft && tile.draft.primary
color: Theme.alpha(Theme.accent, 0.2)
text: "★"
color: Theme.warn
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
Accessible.role: Accessible.StaticText
Accessible.name: "Primary display"
}
Rectangle {
anchors.horizontalCenter: parent.horizontalCenter
anchors.bottom: parent.bottom
anchors.bottomMargin: 7
visible: tile.mirrored
width: visible ? mirrorLabel.implicitWidth + 14 : 0
height: 19
radius: Theme.pillRadius
color: Theme.alpha(Theme.cyan, 0.1)
border.width: 1
border.color: Theme.alpha(Theme.cyan, 0.3)
Text {
id: primaryText
id: mirrorLabel
anchors.centerIn: parent
text: "Primary"
color: Theme.accentAlt
text: "Mirrors " + tile.mirrorOf
color: Theme.cyan
font.family: Theme.fontFamily
font.pixelSize: Math.max(9, Theme.fontSizeSmall - 1)
font.weight: Font.DemiBold
@@ -200,7 +349,7 @@ Item {
HoverHandler {
id: hover
enabled: root.interactionEnabled
cursorShape: Qt.OpenHandCursor
cursorShape: tile.tileDraggable ? Qt.OpenHandCursor : Qt.PointingHandCursor
}
TapHandler {
@@ -214,7 +363,7 @@ Item {
DragHandler {
id: drag
target: null
enabled: root.interactionEnabled
enabled: tile.tileDraggable
property real initialX: 0
property real initialY: 0
property bool moved: false
@@ -250,7 +399,7 @@ Item {
}
Keys.onPressed: event => {
if (!root.interactionEnabled)
if (!tile.tileDraggable)
return;
const step = event.modifiers & Qt.ShiftModifier ? 100 : 10;
let handled = true;
@@ -278,19 +427,6 @@ Item {
width: parent.width
spacing: 8
Text {
width: Math.max(0, parent.width - identifyButton.width
- primaryButton.width - applyButton.width - 24)
anchors.verticalCenter: parent.verticalCenter
text: root.width >= 600
? "Drag to arrange · arrows move 10 px · Shift moves 100 px"
: "Drag or use the arrow keys"
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
elide: Text.ElideRight
}
SettingsButton {
id: identifyButton
text: "Identify"
@@ -301,17 +437,21 @@ Item {
SettingsButton {
id: primaryButton
text: "Make primary"
enabled: root.interactionEnabled && root.selectedOutput !== ""
enabled: root.interactionEnabled && !root.solo && root.selectedOutput !== ""
&& !root.draftLayout.find(record =>
record.name === root.selectedOutput)?.primary
onClicked: root.makePrimary(root.selectedOutput)
}
SettingsButton {
id: applyButton
text: "Apply"
enabled: root.interactionEnabled
onClicked: root.applyDraft()
Text {
width: Math.max(0, parent.width - identifyButton.width - primaryButton.width - 16)
anchors.verticalCenter: parent.verticalCenter
horizontalAlignment: Text.AlignRight
text: root.hint
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
elide: Text.ElideRight
}
}
}