// 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 import "../../services/DisplayLayout.js" as DisplayLayout Item { id: root required property var displayService property string selectedOutput: "" property var draftLayout: [] property bool interactionEnabled: true signal selectionRequested(string output) implicitHeight: content.implicitHeight 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)); } function resetDraft(): void { root.draftLayout = root.copied(root.displayService.currentLayout()); } function setDraftPosition(output: string, x: real, y: real, snapToEdges: bool): bool { const next = root.copied(root.draftLayout); const record = next.find(candidate => candidate.name === output); if (!record) return false; record.x = Math.round(x); record.y = Math.round(y); root.draftLayout = snapToEdges ? DisplayLayout.snap(next, output, 16) : next; return true; } function nudge(output: string, dx: int, dy: int): bool { const record = root.draftLayout.find(candidate => candidate.name === output); return !!record && root.setDraftPosition(output, record.x + dx, record.y + dy, false); } function applyDraft(): bool { return root.displayService.applyLayout(root.copied(root.draftLayout)); } function makePrimary(output: string): bool { const next = root.copied(root.draftLayout); if (!next.some(record => record.name === output)) return false; for (const record of next) record.primary = record.name === output; root.draftLayout = DisplayLayout.normalize(next); 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)) }; } Component.onCompleted: root.resetDraft() Connections { target: root.displayService ignoreUnknownSignals: true function onMonitorsChanged(): void { if (!root.displayService.awaitingConfirmation) root.resetDraft(); } } Column { id: content width: parent.width spacing: 11 Rectangle { id: canvas width: parent.width 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: Math.max(1, Math.ceil(canvas.height / 44)) Rectangle { required property int index y: (index + 1) * 44 width: canvas.width height: 1 color: Theme.alpha(Theme.fg, 0.03) } } Repeater { 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 required property var modelData readonly property bool selected: root.selectedOutput === modelData.name 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.82) border.width: tile.selected || activeFocus ? 2 : 1 border.color: activeFocus ? Theme.accentSecondary : (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: { 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 anchors.margins: 2 radius: parent.radius - 2 visible: tile.selected opacity: 0.24 gradient: Gradient { orientation: Gradient.Horizontal GradientStop { position: 0; color: Theme.accent } GradientStop { position: 1; color: Theme.accentSecondary } } } // 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 anchors.verticalCenter: parent.verticalCenter anchors.margins: 11 spacing: 2 Text { width: parent.width text: tile.modelData.name color: Theme.fg font.family: Theme.fontFamily font.pixelSize: Theme.fontSize font.weight: Font.DemiBold elide: Text.ElideRight } Text { width: parent.width 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 font.pixelSize: Theme.fontSizeSmall elide: Text.ElideRight } } Text { anchors.top: parent.top anchors.right: parent.right anchors.topMargin: 7 anchors.rightMargin: 9 visible: tile.draft && tile.draft.primary 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: mirrorLabel anchors.centerIn: parent text: "Mirrors " + tile.mirrorOf color: Theme.cyan font.family: Theme.fontFamily font.pixelSize: Math.max(9, Theme.fontSizeSmall - 1) font.weight: Font.DemiBold } } HoverHandler { id: hover enabled: root.interactionEnabled cursorShape: tile.tileDraggable ? Qt.OpenHandCursor : Qt.PointingHandCursor } TapHandler { enabled: root.interactionEnabled onTapped: { root.selectionRequested(tile.modelData.name); tile.forceActiveFocus(); } } DragHandler { id: drag target: null enabled: tile.tileDraggable property real initialX: 0 property real initialY: 0 property bool moved: false onActiveChanged: { if (active) { const record = root.draftLayout.find( candidate => candidate.name === tile.modelData.name); initialX = record ? record.x : 0; initialY = record ? record.y : 0; moved = false; root.selectionRequested(tile.modelData.name); tile.forceActiveFocus(); } else if (moved) { const record = root.draftLayout.find( candidate => candidate.name === tile.modelData.name); if (record) { root.setDraftPosition(tile.modelData.name, record.x, record.y, true); root.applyDraft(); } } } onTranslationChanged: { if (!active || root.canvasData.scale <= 0) return; moved = true; root.setDraftPosition( tile.modelData.name, initialX + translation.x / root.canvasData.scale, initialY + translation.y / root.canvasData.scale, false); } } Keys.onPressed: event => { if (!tile.tileDraggable) return; const step = event.modifiers & Qt.ShiftModifier ? 100 : 10; let handled = true; if (event.key === Qt.Key_Left) root.nudge(tile.modelData.name, -step, 0); else if (event.key === Qt.Key_Right) root.nudge(tile.modelData.name, step, 0); else if (event.key === Qt.Key_Up) root.nudge(tile.modelData.name, 0, -step); else if (event.key === Qt.Key_Down) root.nudge(tile.modelData.name, 0, step); else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) root.applyDraft(); else if (event.key === Qt.Key_Escape) root.resetDraft(); else handled = false; event.accepted = handled; } } } } Row { width: parent.width spacing: 8 SettingsButton { id: identifyButton text: "Identify" enabled: root.interactionEnabled onClicked: root.displayService.identify() } SettingsButton { id: primaryButton text: "Make primary" enabled: root.interactionEnabled && !root.solo && root.selectedOutput !== "" && !root.draftLayout.find(record => record.name === root.selectedOutput)?.primary onClicked: root.makePrimary(root.selectedOutput) } 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 } } } }