473 lines
20 KiB
QML
473 lines
20 KiB
QML
// 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 Quickshell
|
||
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 {
|
||
// Keyed on the output name rather than fed the array directly.
|
||
// Every drag step and every arrow key rewrites draftLayout,
|
||
// which rebuilds `tiles` into a fresh array of fresh objects --
|
||
// a new model, which resets the Repeater and destroys the tile
|
||
// being dragged on its first millimetre of travel. The drag
|
||
// ended there, silently, and nothing was ever applied; the
|
||
// arrow keys lost focus after a single step for the same
|
||
// reason. Keyed, the same rebuild is "this row moved", and the
|
||
// tile under the pointer keeps its handler and its focus.
|
||
model: ScriptModel {
|
||
values: root.tiles
|
||
objectProp: "name"
|
||
comparisonMode: ObjectComparison.Structure
|
||
}
|
||
|
||
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
|
||
}
|
||
}
|
||
}
|
||
}
|