Files
Panama/config/dot/quickshell/modules/settings/DisplayArrangement.qml
T

473 lines
20 KiB
QML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
}
}
}
}