Rebuild Displays around the canvas, and let the transaction keep color
Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
@@ -1583,16 +1583,16 @@ Singleton {
|
||||
},
|
||||
|
||||
// ── Display configuration ───────────────────────────────────────────
|
||||
// { "<output>": { mode, scale, transform, x, y, primary } }, applied by
|
||||
// hypr/monitors.lua on top of the shipped values. Color management and
|
||||
// bit depth are deliberately not here: those carry a documented
|
||||
// screencopy tradeoff that a settings page cannot explain at the moment
|
||||
// you would be changing it.
|
||||
// { "<output>": { mode, scale, transform, x, y, primary, vrrMode,
|
||||
// colorProfile, bitdepth, sdrBrightness, sdrSaturation, mirrorOf } },
|
||||
// applied by hypr/monitors.lua on top of the shipped values. Everything
|
||||
// past `primary` is optional, so records written before those fields
|
||||
// existed still load and the shipped defaults stand for them.
|
||||
{
|
||||
key: "displays", type: "json", def: ({}), group: "display",
|
||||
internal: true,
|
||||
label: "Display configuration",
|
||||
detail: "Resolution, scale, rotation, position, and primary display"
|
||||
detail: "Resolution, scale, rotation, position, primary display, color, VRR override, and mirroring"
|
||||
},
|
||||
|
||||
// ── Per-application notification rules ──────────────────────────────
|
||||
|
||||
@@ -75,6 +75,36 @@ ShellRoot {
|
||||
})));
|
||||
}
|
||||
|
||||
// One display is the common case on a laptop, and the canvas is the
|
||||
// page's hero now: it renders that display rather than disappearing and
|
||||
// leaving a picker for a list of one. Only dragging goes away, because
|
||||
// there is nothing to arrange it against.
|
||||
function soloFixture(): string {
|
||||
const previous = fixtureService.monitors;
|
||||
fixtureService.monitors = [previous[0]];
|
||||
arrangement.resetDraft();
|
||||
const snapshot = arrangement.canvasSnapshot();
|
||||
fixtureService.monitors = previous;
|
||||
arrangement.resetDraft();
|
||||
return JSON.stringify(snapshot);
|
||||
}
|
||||
|
||||
// A mirrored display has no position of its own -- the compositor puts
|
||||
// it on top of its target -- so the canvas stacks it there and says so
|
||||
// rather than drawing it wherever its stale coordinates point.
|
||||
function mirrorFixture(): string {
|
||||
const previous = fixtureService.monitors;
|
||||
fixtureService.monitors = [
|
||||
previous[0],
|
||||
Object.assign({}, previous[1], { mirrorOf: "DP-2" })
|
||||
];
|
||||
arrangement.resetDraft();
|
||||
const snapshot = arrangement.canvasSnapshot();
|
||||
fixtureService.monitors = previous;
|
||||
arrangement.resetDraft();
|
||||
return JSON.stringify(snapshot);
|
||||
}
|
||||
|
||||
function identify(): void { Displays.identify(); }
|
||||
function identifying(): bool { return Displays.identifying; }
|
||||
}
|
||||
|
||||
@@ -10,9 +10,53 @@ ShellRoot {
|
||||
{ name: "HDMI-A-1", width: 2560, height: 1440, scale: 1, transform: 1, x: 3140, y: 80, primary: false }
|
||||
]
|
||||
|
||||
// The same two displays, with the second mirroring the first. A mirrored
|
||||
// display has no position of its own: the compositor puts it on top of its
|
||||
// target, so its stored coordinates are stale the moment mirroring is on.
|
||||
readonly property var mirrored: [
|
||||
{ name: "DP-2", width: 4500, height: 3000, scale: 1.5, transform: 0, x: 140, y: 80, primary: true, mirrorOf: "" },
|
||||
{ name: "HDMI-A-1", width: 2560, height: 1440, scale: 1, transform: 0, x: 3140, y: 80, primary: false, mirrorOf: "DP-2" }
|
||||
]
|
||||
|
||||
IpcHandler {
|
||||
target: "display-layout-test"
|
||||
|
||||
function mirror(): string {
|
||||
const normalized = DisplayLayout.normalize(mirrored);
|
||||
const canvas = DisplayLayout.canvasRects(normalized, 800, 500, 20);
|
||||
return JSON.stringify({
|
||||
valid: DisplayLayout.validate(mirrored),
|
||||
normalized: normalized.map(record => ({
|
||||
name: record.name, x: record.x, y: record.y,
|
||||
primary: record.primary, mirrorOf: record.mirrorOf ?? ""
|
||||
})),
|
||||
bounds: canvas.bounds,
|
||||
rects: canvas.rects
|
||||
});
|
||||
}
|
||||
|
||||
function invalidMirrors(): string {
|
||||
const base = mirrored.map(record => Object.assign({}, record));
|
||||
const cases = [];
|
||||
const add = layout => cases.push(DisplayLayout.validate(layout));
|
||||
// Mirroring itself.
|
||||
add([base[0], Object.assign({}, base[1], { mirrorOf: "HDMI-A-1" })]);
|
||||
// Mirroring an output that is not in the layout.
|
||||
add([base[0], Object.assign({}, base[1], { mirrorOf: "NOPE-1" })]);
|
||||
// The primary may not mirror: the desktop is anchored on it.
|
||||
add([Object.assign({}, base[0], { mirrorOf: "HDMI-A-1" }), base[1]]);
|
||||
// No chains. Hyprland resolves a mirror to one target, and a chain
|
||||
// is a question nobody can answer from the canvas.
|
||||
add([
|
||||
base[0],
|
||||
Object.assign({}, base[1], { mirrorOf: "DP-3" }),
|
||||
{ name: "DP-3", width: 1920, height: 1080, scale: 1, transform: 0, x: 6000, y: 0, primary: false, mirrorOf: "DP-2" }
|
||||
]);
|
||||
// Not a name at all.
|
||||
add([base[0], Object.assign({}, base[1], { mirrorOf: 5 })]);
|
||||
return JSON.stringify(cases);
|
||||
}
|
||||
|
||||
function status(): string {
|
||||
const normalized = DisplayLayout.normalize(fixture);
|
||||
const canvas = DisplayLayout.canvasRects(normalized, 800, 500, 20);
|
||||
|
||||
@@ -112,6 +112,30 @@ ShellRoot {
|
||||
});
|
||||
}
|
||||
|
||||
// The extended record — vrr override, colour profile, bit depth, SDR
|
||||
// trim, mirroring — merged one field at a time, which is how every
|
||||
// control on the page changes it. Base64 for the reason restorePlan
|
||||
// documents above: `qs ipc call` splits JSON that looks like an array
|
||||
// of objects into one argument per object.
|
||||
function applyRecordFixture(output: string, patchB64: string): bool {
|
||||
return Displays.applyRecord(output, JSON.parse(Qt.atob(patchB64)));
|
||||
}
|
||||
|
||||
// matchesLayout as a pure function. The readback carve-outs — a
|
||||
// mirrored output's position, a framebuffer format Panama does not
|
||||
// recognise — are decisions about what NOT to assert, and proving them
|
||||
// through a compositor would mean owning a compositor that mirrors.
|
||||
function layoutMatch(monitorsB64: string, layoutB64: string): bool {
|
||||
return Displays.matchesLayout(JSON.parse(Qt.atob(monitorsB64)),
|
||||
JSON.parse(Qt.atob(layoutB64)));
|
||||
}
|
||||
|
||||
// A settings.json written before any of the new fields existed is the
|
||||
// common case on every machine that has this installed today.
|
||||
function persistedEntryValid(entryB64: string): bool {
|
||||
return Displays.isPersistedLayoutEntry(JSON.parse(Qt.atob(entryB64)));
|
||||
}
|
||||
|
||||
function expireApplyVerification(): void {
|
||||
Displays.verificationTimedOut();
|
||||
}
|
||||
|
||||
@@ -28,10 +28,13 @@ to load. `Hyprland --verify-config` says why without touching your session.
|
||||
|
||||
## The screen resolution is wrong
|
||||
|
||||
Settings has a Displays page. Every change there reverts itself after fifteen
|
||||
seconds unless you confirm it, so a mode your monitor cannot show cannot
|
||||
strand you. If you are already stranded, `hyprctl monitors` from a terminal
|
||||
shows what is applied.
|
||||
Settings has a Displays page. It opens on a picture of what is connected —
|
||||
one display or several — and everything under that picture belongs to
|
||||
whichever one you have selected. Every change there reverts itself after
|
||||
fifteen seconds unless you confirm it, so a mode your monitor cannot show
|
||||
cannot strand you, and that covers colour, bit depth and mirroring as well as
|
||||
resolution, scale and rotation. If you are already stranded, `hyprctl
|
||||
monitors` from a terminal shows what is applied.
|
||||
|
||||
## Something asked for a password and I do not know why
|
||||
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
// The four color profiles Hyprland's `cm` accepts, as swatches.
|
||||
//
|
||||
// A dropdown reading "srgb / wide / hdr" tells someone who already knows what
|
||||
// they mean which one is set. The swatch is the difference itself: the same
|
||||
// three primaries drawn narrow, drawn wide, and drawn against a range no SDR
|
||||
// screen can show.
|
||||
//
|
||||
// The values come from Displays.colorProfiles so this can never offer one the
|
||||
// service would refuse; the swatch and the sentence under each name live here,
|
||||
// because they are how the choice is presented rather than what it is.
|
||||
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
Flow {
|
||||
id: root
|
||||
|
||||
property string current: "auto"
|
||||
property bool enabled: true
|
||||
|
||||
signal picked(string value)
|
||||
|
||||
width: parent ? parent.width : 620
|
||||
spacing: 10
|
||||
bottomPadding: 12
|
||||
|
||||
readonly property var descriptions: ({
|
||||
"auto": "Let Hyprland choose from what the display reports",
|
||||
"srgb": "Standard color, the safe default",
|
||||
"wide": "The panel's full gamut, for photo and color work",
|
||||
"hdr": "High dynamic range, where the display supports it"
|
||||
})
|
||||
|
||||
// Four across when they fit, two across when they do not, and one across in
|
||||
// a window narrow enough that two would be unreadable.
|
||||
readonly property int columns: root.width >= 620 ? 4 : (root.width >= 340 ? 2 : 1)
|
||||
readonly property real tileWidth:
|
||||
(root.width - root.spacing * (root.columns - 1)) / root.columns
|
||||
|
||||
Repeater {
|
||||
model: Displays.colorProfiles
|
||||
|
||||
Rectangle {
|
||||
id: tile
|
||||
|
||||
required property var modelData
|
||||
|
||||
readonly property string value: String(tile.modelData.value)
|
||||
readonly property bool selected: tile.value === root.current
|
||||
|
||||
width: root.tileWidth
|
||||
implicitHeight: body.implicitHeight + 22
|
||||
radius: Theme.cardRadius
|
||||
opacity: root.enabled ? 1 : 0.5
|
||||
color: tile.selected
|
||||
? Theme.alpha(Theme.accent, 0.09)
|
||||
: Theme.alpha(Theme.fg, tileHover.hovered && root.enabled ? 0.08 : 0.04)
|
||||
border.width: tile.selected || tile.activeFocus ? 2 : 1
|
||||
border.color: tile.activeFocus
|
||||
? Theme.accentSecondary
|
||||
: (tile.selected ? Theme.alpha(Theme.accent, 0.6) : Theme.alpha(Theme.fg, 0.08))
|
||||
activeFocusOnTab: root.enabled
|
||||
|
||||
Accessible.role: Accessible.RadioButton
|
||||
Accessible.name: String(tile.modelData.label ?? "")
|
||||
Accessible.checked: tile.selected
|
||||
|
||||
function choose(): void {
|
||||
if (root.enabled && !tile.selected)
|
||||
root.picked(tile.value);
|
||||
}
|
||||
|
||||
Keys.onReturnPressed: tile.choose()
|
||||
Keys.onSpacePressed: tile.choose()
|
||||
|
||||
Column {
|
||||
id: body
|
||||
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.top: parent.top
|
||||
anchors.margins: 11
|
||||
spacing: 7
|
||||
|
||||
// Automatic is the prism, because it is Panama deciding rather
|
||||
// than a color space; sRGB is the same primaries pulled toward
|
||||
// grey; wide is them at full strength; HDR runs from black
|
||||
// through a highlight no SDR screen reaches.
|
||||
Rectangle {
|
||||
width: parent.width
|
||||
height: 22
|
||||
radius: 6
|
||||
border.width: 1
|
||||
border.color: Theme.alpha(Theme.fg, 0.12)
|
||||
|
||||
gradient: Gradient {
|
||||
orientation: Gradient.Horizontal
|
||||
GradientStop {
|
||||
position: 0.0
|
||||
color: {
|
||||
if (tile.value === "auto") return Theme.accent;
|
||||
if (tile.value === "srgb") return Theme.mix(Theme.red, Theme.fgMuted, 0.4);
|
||||
if (tile.value === "wide") return Theme.red;
|
||||
return Theme.bgDark;
|
||||
}
|
||||
}
|
||||
GradientStop {
|
||||
position: 0.5
|
||||
color: {
|
||||
if (tile.value === "auto") return Theme.accentSecondary;
|
||||
if (tile.value === "srgb") return Theme.mix(Theme.green, Theme.fgMuted, 0.4);
|
||||
if (tile.value === "wide") return Theme.green;
|
||||
return Theme.orange;
|
||||
}
|
||||
}
|
||||
GradientStop {
|
||||
position: 1.0
|
||||
color: {
|
||||
if (tile.value === "auto") return Theme.teal;
|
||||
if (tile.value === "srgb") return Theme.mix(Theme.accent, Theme.fgMuted, 0.4);
|
||||
if (tile.value === "wide") return Theme.accent;
|
||||
return Theme.fg;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
text: String(tile.modelData.label ?? "")
|
||||
color: Theme.fg
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
font.weight: Font.DemiBold
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
text: String(root.descriptions[tile.value] ?? "")
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Math.max(9, Theme.fontSizeSmall - 1)
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
}
|
||||
|
||||
HoverHandler {
|
||||
id: tileHover
|
||||
enabled: root.enabled
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
}
|
||||
|
||||
TapHandler {
|
||||
enabled: root.enabled && !tile.selected
|
||||
onTapped: {
|
||||
tile.choose();
|
||||
tile.forceActiveFocus();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
// The fifteen seconds a display change has left, drawn as a ring.
|
||||
//
|
||||
// Repainted once per second, when `secondsLeft` changes, and never otherwise:
|
||||
// this shell has no idle animation, and a countdown that redraws every frame
|
||||
// would be one -- on a 240 Hz panel it is 240 repaints to move a number that
|
||||
// changes once.
|
||||
|
||||
import QtQuick
|
||||
import qs.config
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property int secondsLeft: 0
|
||||
property int totalSeconds: 15
|
||||
|
||||
implicitWidth: 40
|
||||
implicitHeight: 40
|
||||
|
||||
onSecondsLeftChanged: ring.requestPaint()
|
||||
onTotalSecondsChanged: ring.requestPaint()
|
||||
onVisibleChanged: if (root.visible) ring.requestPaint()
|
||||
|
||||
Canvas {
|
||||
id: ring
|
||||
|
||||
anchors.fill: parent
|
||||
|
||||
onPaint: {
|
||||
const context = ring.getContext("2d");
|
||||
context.reset();
|
||||
|
||||
const stroke = 3.5;
|
||||
const radius = Math.min(ring.width, ring.height) / 2 - stroke / 2 - 1;
|
||||
const centreX = ring.width / 2;
|
||||
const centreY = ring.height / 2;
|
||||
if (radius <= 0)
|
||||
return;
|
||||
|
||||
context.lineWidth = stroke;
|
||||
context.lineCap = "round";
|
||||
|
||||
context.strokeStyle = Theme.alpha(Theme.warn, 0.18);
|
||||
context.beginPath();
|
||||
context.arc(centreX, centreY, radius, 0, Math.PI * 2);
|
||||
context.stroke();
|
||||
|
||||
const remaining = root.totalSeconds > 0
|
||||
? Math.max(0, Math.min(1, root.secondsLeft / root.totalSeconds))
|
||||
: 0;
|
||||
if (remaining <= 0)
|
||||
return;
|
||||
|
||||
// Twelve o'clock, clockwise, so the arc empties the way a clock
|
||||
// face does rather than unwinding backwards.
|
||||
context.strokeStyle = Theme.warn;
|
||||
context.beginPath();
|
||||
context.arc(centreX, centreY, radius,
|
||||
-Math.PI / 2, -Math.PI / 2 + remaining * Math.PI * 2);
|
||||
context.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: String(Math.max(0, root.secondsLeft))
|
||||
color: Theme.warn
|
||||
font.family: Theme.fontFamily
|
||||
font.features: Theme.tabularFigures
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
font.weight: Font.DemiBold
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
// The connected displays, as chips under the arrangement canvas.
|
||||
//
|
||||
// This replaces the "Connected display" picker card. Selecting a display is not
|
||||
// a setting -- it decides which display everything below the canvas is talking
|
||||
// about -- so it reads as a row of tabs rather than as a preference with a
|
||||
// value. Clicking a tile on the canvas does the same thing; this is the same
|
||||
// choice for anyone who would rather read names than shapes.
|
||||
//
|
||||
// Hidden for a single display, because a chooser offering one option is not a
|
||||
// choice.
|
||||
|
||||
import QtQuick
|
||||
import qs.config
|
||||
|
||||
Flow {
|
||||
id: root
|
||||
|
||||
// [{ value, label, detail, primary }]
|
||||
property var options: []
|
||||
property string current: ""
|
||||
property bool enabled: true
|
||||
|
||||
signal picked(string value)
|
||||
|
||||
visible: root.options.length > 1
|
||||
width: parent ? parent.width : 620
|
||||
height: root.visible ? implicitHeight : 0
|
||||
spacing: 10
|
||||
|
||||
// Chips share the width evenly while they fit; below that they wrap into
|
||||
// rows of whatever does fit, so a dock with four outputs is still legible
|
||||
// in a half-screen window.
|
||||
readonly property int columns: Math.max(1,
|
||||
Math.min(root.options.length, Math.floor(root.width / 210)))
|
||||
readonly property real chipWidth: root.columns > 0
|
||||
? (root.width - root.spacing * (root.columns - 1)) / root.columns
|
||||
: root.width
|
||||
|
||||
Repeater {
|
||||
model: root.options
|
||||
|
||||
Rectangle {
|
||||
id: chip
|
||||
|
||||
required property var modelData
|
||||
required property int index
|
||||
|
||||
readonly property bool selected: String(chip.modelData.value) === root.current
|
||||
|
||||
width: root.chipWidth
|
||||
implicitHeight: 52
|
||||
radius: Theme.cardRadius
|
||||
opacity: root.enabled ? 1 : 0.5
|
||||
color: chip.selected
|
||||
? Theme.alpha(Theme.accent, 0.1)
|
||||
: Theme.alpha(Theme.bgPanel, chipHover.hovered && root.enabled ? 0.9 : 0.72)
|
||||
border.width: chip.selected || chip.activeFocus ? 2 : 1
|
||||
border.color: chip.activeFocus
|
||||
? Theme.accentSecondary
|
||||
: (chip.selected ? Theme.alpha(Theme.accent, 0.55) : Theme.alpha(Theme.fg, 0.07))
|
||||
activeFocusOnTab: root.enabled
|
||||
|
||||
Accessible.role: Accessible.Button
|
||||
Accessible.name: "Select " + String(chip.modelData.label ?? "")
|
||||
Accessible.focusable: true
|
||||
Accessible.focused: chip.activeFocus
|
||||
|
||||
function choose(): void {
|
||||
if (root.enabled)
|
||||
root.picked(String(chip.modelData.value));
|
||||
}
|
||||
|
||||
Keys.onReturnPressed: chip.choose()
|
||||
Keys.onSpacePressed: chip.choose()
|
||||
|
||||
// A screen, small. The second display gets the other end of the
|
||||
// palette so the two chips are told apart at a glance rather than
|
||||
// by reading them.
|
||||
Rectangle {
|
||||
id: thumbnail
|
||||
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 13
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: 34
|
||||
height: 24
|
||||
radius: 5
|
||||
border.width: 1
|
||||
border.color: Theme.alpha(Theme.fg, 0.25)
|
||||
|
||||
gradient: Gradient {
|
||||
orientation: Gradient.Horizontal
|
||||
GradientStop {
|
||||
position: 0.0
|
||||
color: Theme.alpha(chip.index % 2 === 0 ? Theme.accent : Theme.teal, 0.5)
|
||||
}
|
||||
GradientStop {
|
||||
position: 1.0
|
||||
color: Theme.alpha(chip.index % 2 === 0 ? Theme.accentSecondary : Theme.cyan, 0.4)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
anchors.left: thumbnail.right
|
||||
anchors.leftMargin: 11
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 12
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 1
|
||||
|
||||
Row {
|
||||
width: parent.width
|
||||
spacing: 5
|
||||
|
||||
Text {
|
||||
width: Math.max(0, parent.width - (star.visible ? star.width + 5 : 0))
|
||||
text: String(chip.modelData.label ?? "")
|
||||
color: Theme.fg
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
font.weight: Font.DemiBold
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
Text {
|
||||
id: star
|
||||
visible: chip.modelData.primary === true
|
||||
width: visible ? implicitWidth : 0
|
||||
text: "★"
|
||||
color: Theme.warn
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
visible: text !== ""
|
||||
text: String(chip.modelData.detail ?? "")
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.features: Theme.tabularFigures
|
||||
font.pixelSize: Math.max(9, Theme.fontSizeSmall - 1)
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
|
||||
HoverHandler {
|
||||
id: chipHover
|
||||
enabled: root.enabled
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
}
|
||||
|
||||
TapHandler {
|
||||
enabled: root.enabled && !chip.selected
|
||||
onTapped: {
|
||||
chip.choose();
|
||||
chip.forceActiveFocus();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,21 @@
|
||||
// The resolution list for one display.
|
||||
//
|
||||
// Grouped by resolution with refresh rates beside it, rather than a flat list
|
||||
// of "[email protected]" strings: this panel reports 35 modes, many of which
|
||||
// differ only in refresh-rate rounding, and a flat list of those is a wall of
|
||||
// near-identical text rather than a choice.
|
||||
// One line per resolution, not per mode: this panel reports 35 modes, most of
|
||||
// which differ only in refresh-rate rounding, and a flat list of
|
||||
// "[email protected]" strings is a wall of near-identical text rather than a
|
||||
// choice. The rates live in their own row on the page, where changing only the
|
||||
// rate does not mean going back through the resolution you already had.
|
||||
//
|
||||
// Each line carries the two facts that decide the choice: the shape of the
|
||||
// picture, and whether this is the one the panel was built for.
|
||||
//
|
||||
// Applying is the page's job. Every per-display edit on the Displays page goes
|
||||
// through one funnel so the whole record -- position, colour, mirror state --
|
||||
// rides the same keep-or-revert transaction; this reports which resolution was
|
||||
// asked for and lets that funnel do the rest.
|
||||
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
Column {
|
||||
id: root
|
||||
@@ -15,9 +23,10 @@ Column {
|
||||
property var monitor: null
|
||||
property bool enabled: true
|
||||
|
||||
// Emitted once a mode has been asked for, so a container can put the list
|
||||
// away. Applying is still this component's job; closing is not.
|
||||
signal picked
|
||||
// The mode string for the resolution that was chosen, at the rate closest
|
||||
// to the one in use.
|
||||
signal picked(string mode)
|
||||
|
||||
spacing: 0
|
||||
|
||||
readonly property var grouped: {
|
||||
@@ -36,6 +45,44 @@ Column {
|
||||
return order.map(key => buckets[key]);
|
||||
}
|
||||
|
||||
// Modes arrive sorted by area, so the first is the largest the panel
|
||||
// advertises -- which is the one it was built for.
|
||||
readonly property string nativeLabel: root.grouped.length > 0 ? root.grouped[0].label : ""
|
||||
|
||||
function aspectLabel(width: int, height: int): string {
|
||||
if (!width || !height)
|
||||
return "";
|
||||
const ratio = width / height;
|
||||
const named = [
|
||||
{ ratio: 1, label: "1:1" },
|
||||
{ ratio: 5 / 4, label: "5:4" },
|
||||
{ ratio: 4 / 3, label: "4:3" },
|
||||
{ ratio: 3 / 2, label: "3:2" },
|
||||
{ ratio: 16 / 10, label: "16:10" },
|
||||
{ ratio: 16 / 9, label: "16:9" },
|
||||
{ ratio: 21 / 9, label: "21:9" },
|
||||
{ ratio: 32 / 9, label: "32:9" }
|
||||
];
|
||||
for (const entry of named) {
|
||||
if (Math.abs(ratio - entry.ratio) < 0.05)
|
||||
return entry.label;
|
||||
}
|
||||
const reduce = (a, b) => b === 0 ? a : reduce(b, a % b);
|
||||
const divisor = reduce(width, height) || 1;
|
||||
return `${Math.round(width / divisor)}:${Math.round(height / divisor)}`;
|
||||
}
|
||||
|
||||
// The rate nearest the one in use, so changing the resolution does not
|
||||
// quietly change the refresh rate as well when the panel offers the same
|
||||
// one at the new size.
|
||||
function modeFor(group: var): string {
|
||||
const target = root.monitor ? root.monitor.refreshRate : 0;
|
||||
const rates = group.rates.slice().sort((left, right) =>
|
||||
Math.abs(left.refresh - target) - Math.abs(right.refresh - target)
|
||||
|| right.refresh - left.refresh);
|
||||
return rates.length > 0 ? rates[0].mode : "";
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: root.grouped
|
||||
|
||||
@@ -45,79 +92,66 @@ Column {
|
||||
required property var modelData
|
||||
required property int index
|
||||
|
||||
readonly property bool isCurrent: root.monitor
|
||||
readonly property bool isCurrent: !!root.monitor
|
||||
&& root.monitor.width === resolution.modelData.width
|
||||
&& root.monitor.height === resolution.modelData.height
|
||||
readonly property bool isNative: resolution.modelData.label === root.nativeLabel
|
||||
readonly property string tag: {
|
||||
if (resolution.isCurrent && resolution.isNative)
|
||||
return "Current · Native";
|
||||
if (resolution.isCurrent)
|
||||
return "Current";
|
||||
return resolution.isNative ? "Native" : "";
|
||||
}
|
||||
|
||||
width: parent.width
|
||||
label: resolution.modelData.label
|
||||
detail: resolution.isCurrent ? "Current resolution" : ""
|
||||
controlWidth: Math.max(120, resolution.modelData.rates.length * 84)
|
||||
controlWidth: 170
|
||||
divider: resolution.index < root.grouped.length - 1
|
||||
opacity: root.enabled ? 1 : 0.45
|
||||
activatable: root.enabled && !resolution.isCurrent
|
||||
onActivated: {
|
||||
const mode = root.modeFor(resolution.modelData);
|
||||
if (mode !== "")
|
||||
root.picked(mode);
|
||||
}
|
||||
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 6
|
||||
spacing: 10
|
||||
|
||||
Repeater {
|
||||
model: resolution.modelData.rates
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: root.aspectLabel(resolution.modelData.width, resolution.modelData.height)
|
||||
color: Theme.fgMuted
|
||||
font.family: Theme.fontFamily
|
||||
font.features: Theme.tabularFigures
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: rate
|
||||
Rectangle {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: resolution.tag !== ""
|
||||
width: visible ? tagLabel.implicitWidth + 18 : 0
|
||||
height: 21
|
||||
radius: Theme.pillRadius
|
||||
color: resolution.isCurrent
|
||||
? Theme.alpha(Theme.accent, 0.1)
|
||||
: Theme.alpha(Theme.fg, 0.06)
|
||||
border.width: 1
|
||||
border.color: resolution.isCurrent
|
||||
? Theme.alpha(Theme.accent, 0.25)
|
||||
: Theme.alpha(Theme.fg, 0.1)
|
||||
|
||||
required property var modelData
|
||||
|
||||
readonly property bool selected: resolution.isCurrent
|
||||
&& Displays.modeIsCurrent(root.monitor, rate.modelData)
|
||||
|
||||
implicitWidth: Math.max(74, rateCaption.implicitWidth + 22)
|
||||
implicitHeight: 30
|
||||
radius: 9
|
||||
opacity: root.enabled ? 1 : 0.45
|
||||
color: rate.selected ? "transparent" : Theme.alpha(Theme.fg, rateHover.hovered && root.enabled ? 0.11 : 0.06)
|
||||
border.width: rate.selected ? 1 : 0
|
||||
border.color: Theme.alpha(Theme.accent, 0.5)
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
radius: parent.radius
|
||||
visible: rate.selected
|
||||
border.width: 0
|
||||
gradient: Gradient {
|
||||
orientation: Gradient.Horizontal
|
||||
GradientStop { position: 0.0; color: Theme.alpha(Theme.accent, 0.28) }
|
||||
GradientStop { position: 1.0; color: Theme.alpha(Theme.accentSecondary, 0.28) }
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
id: rateCaption
|
||||
anchors.centerIn: parent
|
||||
text: rate.modelData.refreshLabel
|
||||
color: rate.selected ? Theme.fg : Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.features: Theme.tabularFigures
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
font.weight: rate.selected ? Font.DemiBold : Font.Normal
|
||||
}
|
||||
|
||||
HoverHandler {
|
||||
id: rateHover
|
||||
enabled: root.enabled
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
}
|
||||
|
||||
TapHandler {
|
||||
enabled: root.enabled && !rate.selected
|
||||
onTapped: {
|
||||
Displays.apply(
|
||||
root.monitor.name,
|
||||
rate.modelData.mode,
|
||||
Displays.nearestCleanScale(rate.modelData.mode, root.monitor.scale),
|
||||
root.monitor.transform);
|
||||
root.picked();
|
||||
}
|
||||
}
|
||||
Text {
|
||||
id: tagLabel
|
||||
anchors.centerIn: parent
|
||||
text: resolution.tag
|
||||
color: resolution.isCurrent ? Theme.accentAlt : Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Math.max(9, Theme.fontSizeSmall - 1)
|
||||
font.weight: Font.DemiBold
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
// The identity of the display everything below it belongs to.
|
||||
//
|
||||
// A card title would say the same words in the same place, but this panel has
|
||||
// to carry four facts at once -- which display, on which connector, what it is
|
||||
// showing right now, and whether Panama is overriding it -- and a title with a
|
||||
// subtitle can only carry two of them.
|
||||
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import qs.config
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property string title: ""
|
||||
property string connector: ""
|
||||
property string meta: ""
|
||||
property bool overridden: false
|
||||
property bool enabled: true
|
||||
|
||||
signal forgetRequested
|
||||
|
||||
width: parent ? parent.width : 620
|
||||
implicitHeight: Math.max(44, copy.implicitHeight) + 14
|
||||
|
||||
// A screen on a stand. Drawn rather than iconified: no symbolic icon in the
|
||||
// set reads as "this particular display" beside its own name.
|
||||
Rectangle {
|
||||
id: glyph
|
||||
|
||||
anchors.left: parent.left
|
||||
anchors.top: parent.top
|
||||
anchors.topMargin: 2
|
||||
width: 44
|
||||
height: 33
|
||||
radius: 7
|
||||
border.width: 1
|
||||
border.color: Theme.alpha(Theme.fg, 0.3)
|
||||
|
||||
gradient: Gradient {
|
||||
orientation: Gradient.Horizontal
|
||||
GradientStop { position: 0.0; color: Theme.alpha(Theme.accent, 0.45) }
|
||||
GradientStop { position: 1.0; color: Theme.alpha(Theme.accentSecondary, 0.35) }
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
anchors.top: parent.bottom
|
||||
anchors.topMargin: 2
|
||||
width: 16
|
||||
height: 3
|
||||
radius: 2
|
||||
border.width: 0
|
||||
color: Theme.alpha(Theme.fg, 0.25)
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
id: copy
|
||||
|
||||
anchors.left: glyph.right
|
||||
anchors.leftMargin: 13
|
||||
anchors.right: actions.left
|
||||
anchors.rightMargin: 12
|
||||
anchors.top: parent.top
|
||||
spacing: 3
|
||||
|
||||
Row {
|
||||
width: parent.width
|
||||
spacing: 8
|
||||
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: Math.max(0, parent.width - (connectorChip.visible ? connectorChip.width + 8 : 0))
|
||||
text: root.title
|
||||
color: Theme.fg
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeLarge
|
||||
font.weight: Font.DemiBold
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: connectorChip
|
||||
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: root.connector !== ""
|
||||
width: visible ? connectorLabel.implicitWidth + 16 : 0
|
||||
height: 19
|
||||
radius: Theme.pillRadius
|
||||
color: Theme.alpha(Theme.fg, 0.08)
|
||||
border.width: 1
|
||||
border.color: Theme.alpha(Theme.fg, 0.12)
|
||||
|
||||
Text {
|
||||
id: connectorLabel
|
||||
anchors.centerIn: parent
|
||||
text: root.connector
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Math.max(9, Theme.fontSizeSmall - 1)
|
||||
font.weight: Font.DemiBold
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
visible: root.meta !== ""
|
||||
text: root.meta
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.features: Theme.tabularFigures
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
|
||||
Row {
|
||||
id: actions
|
||||
|
||||
anchors.right: parent.right
|
||||
anchors.top: parent.top
|
||||
anchors.topMargin: 4
|
||||
spacing: 8
|
||||
|
||||
Rectangle {
|
||||
id: customPill
|
||||
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: root.overridden
|
||||
width: visible ? customLabel.implicitWidth + 20 : 0
|
||||
height: 24
|
||||
radius: Theme.pillRadius
|
||||
color: Theme.alpha(Theme.accent, 0.1)
|
||||
border.width: 1
|
||||
border.color: Theme.alpha(Theme.accent, 0.25)
|
||||
|
||||
ToolTip.visible: customHover.hovered
|
||||
ToolTip.delay: 400
|
||||
ToolTip.text: "This display uses a setting you chose. Forget returns it to the one Panama ships."
|
||||
|
||||
Text {
|
||||
id: customLabel
|
||||
anchors.centerIn: parent
|
||||
text: "Custom setting"
|
||||
color: Theme.accentAlt
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Math.max(9, Theme.fontSizeSmall - 1)
|
||||
font.weight: Font.DemiBold
|
||||
}
|
||||
|
||||
HoverHandler { id: customHover }
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: root.overridden
|
||||
width: visible ? implicitWidth : 0
|
||||
text: "Forget"
|
||||
enabled: root.enabled
|
||||
onClicked: root.forgetRequested()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,25 +1,31 @@
|
||||
// Displays.
|
||||
//
|
||||
// Resolution, refresh rate, scale, and rotation, plus panel brightness and the
|
||||
// gaming display policy that was already here.
|
||||
// The arrangement canvas is the page. Everything under it belongs to the
|
||||
// display selected in it -- resolution, scale, rotation, color, hardware
|
||||
// brightness, variable refresh, mirroring -- and the settings that belong to no
|
||||
// display in particular are gathered at the bottom under "All displays".
|
||||
//
|
||||
// Every geometry change goes through an apply-then-confirm countdown. This is
|
||||
// the one page where a wrong value can leave the screen unreadable or blank,
|
||||
// and no other control in the app can undo it once that happens. Confirming is
|
||||
// what writes the choice to the settings store; letting the countdown run
|
||||
// leaves nothing behind.
|
||||
// Every per-display change goes through one funnel, applyWith, and therefore
|
||||
// through the apply-then-confirm countdown. This is the one page where a wrong
|
||||
// value can leave the screen unreadable or blank, and no other control in the
|
||||
// app can undo it once that happens. Confirming is what writes the choice to
|
||||
// the settings store; letting the countdown run leaves nothing behind.
|
||||
//
|
||||
// The one deliberate exception is brightness. It is hardware state rather than
|
||||
// a stored preference: the monitor remembers it, the bezel buttons change it
|
||||
// behind Panama's back, and there is nothing to read back and verify, so it is
|
||||
// written straight to the panel and never enters the transaction.
|
||||
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.services
|
||||
import qs.modules.quicksettings
|
||||
import qs.widgets
|
||||
|
||||
SettingsPage {
|
||||
id: root
|
||||
|
||||
title: "Displays"
|
||||
lede: SystemSettings.monitorDescription || "Reading the active display…"
|
||||
lede: "Changes apply to every display together and revert on their own in 15 seconds unless you keep them."
|
||||
|
||||
property string selectedOutput: ""
|
||||
readonly property var monitor: Displays.monitorNamed(root.selectedOutput)
|
||||
@@ -28,6 +34,18 @@ SettingsPage {
|
||||
? root.monitor.mode
|
||||
: ""
|
||||
|
||||
// The complete record for the selected display, resolved exactly the way an
|
||||
// apply resolves it: live readback for everything the compositor reports,
|
||||
// and the stored entry for vrrMode, which it does not report. Reading it
|
||||
// from the service rather than rebuilding it here is what stops the page
|
||||
// from showing one thing while an apply carries another.
|
||||
readonly property var record: {
|
||||
const name = root.monitor ? root.monitor.name : "";
|
||||
if (name === "" || Displays.monitors.length === 0)
|
||||
return null;
|
||||
return Displays.currentLayout().find(entry => entry.name === name) ?? null;
|
||||
}
|
||||
|
||||
// Every mode at the resolution in use, which is what a refresh-rate choice
|
||||
// actually is: the same width and height at a different rate.
|
||||
readonly property var ratesForCurrentResolution: {
|
||||
@@ -37,6 +55,71 @@ SettingsPage {
|
||||
&& mode.height === root.monitor.height);
|
||||
}
|
||||
|
||||
readonly property var otherMonitors: Displays.monitors.filter(candidate =>
|
||||
!!root.monitor && candidate.name !== root.monitor.name)
|
||||
|
||||
// Brightness is keyed by connector, the same name Hyprland uses, so a
|
||||
// display either has a DDC entry or has no hardware brightness at all.
|
||||
readonly property var brightnessEntry: root.monitor
|
||||
? Brightness.displayFor(root.monitor.name)
|
||||
: null
|
||||
|
||||
readonly property bool hdrSelected: !!root.record && root.record.colorProfile === "hdr"
|
||||
readonly property bool mirrorPossible: Displays.monitors.length > 1
|
||||
&& !!root.monitor && root.monitor.primary !== true
|
||||
|
||||
readonly property string vrrPolicyLabel: {
|
||||
const spec = PreferenceSchema.spec("vrrPolicy");
|
||||
const options = spec && spec.options ? spec.options : [];
|
||||
const option = options.find(candidate => candidate.value === DesktopPreferences.get("vrrPolicy"));
|
||||
return option ? String(option.label) : "the gaming policy";
|
||||
}
|
||||
|
||||
function profileLabel(value: string): string {
|
||||
const option = Displays.colorProfiles.find(candidate => candidate.value === value);
|
||||
return option ? String(option.label) : "Automatic";
|
||||
}
|
||||
|
||||
// What the display is showing right now, from readback -- not what was
|
||||
// asked for. "auto" resolves to a concrete preset in the compositor, so
|
||||
// this is the only place that says which one it landed on.
|
||||
function liveColorSummary(): string {
|
||||
if (!root.monitor)
|
||||
return "Detecting";
|
||||
const preset = String(root.monitor.colorPreset || "");
|
||||
const pieces = [preset === "" ? "Color unreported" : root.profileLabel(preset)];
|
||||
if (root.monitor.bitdepth > 0)
|
||||
pieces.push(root.monitor.bitdepth + "-bit");
|
||||
const summary = pieces.join(" · ");
|
||||
return root.monitor.currentFormat !== ""
|
||||
? `${summary} (${root.monitor.currentFormat})`
|
||||
: summary;
|
||||
}
|
||||
|
||||
function metaLine(): string {
|
||||
if (!root.monitor)
|
||||
return "Reading the active display…";
|
||||
const pieces = [
|
||||
`${root.monitor.width} × ${root.monitor.height} at ${Math.round(root.monitor.refreshRate)} Hz`,
|
||||
`${Math.round(root.monitor.scale * 100)}% scale`
|
||||
];
|
||||
const profile = root.profileLabel(root.record ? root.record.colorProfile : "auto");
|
||||
pieces.push(root.monitor.bitdepth > 0
|
||||
? `${profile} ${root.monitor.bitdepth}-bit`
|
||||
: profile);
|
||||
if (root.record && root.record.mirrorOf !== "")
|
||||
pieces.push(`mirroring ${root.record.mirrorOf}`);
|
||||
return pieces.join(" · ");
|
||||
}
|
||||
|
||||
function mirrorDetail(): string {
|
||||
if (Displays.monitors.length < 2)
|
||||
return "Mirroring needs a second connected display";
|
||||
if (root.monitor && root.monitor.primary === true)
|
||||
return "The primary display cannot mirror another. Make a different display primary first.";
|
||||
return "Extend the desktop, or show the same picture as another display";
|
||||
}
|
||||
|
||||
function syncSelectedOutput(): void {
|
||||
if (!Displays.monitorNamed(root.selectedOutput))
|
||||
root.selectedOutput = Displays.primaryFirstMonitors.length > 0
|
||||
@@ -56,13 +139,15 @@ SettingsPage {
|
||||
function onMonitorsChanged(): void { root.syncSelectedOutput(); }
|
||||
}
|
||||
|
||||
// The confirmation sits above everything, because while it is counting down
|
||||
// it is the only thing that matters on this page.
|
||||
// The confirmation sits above everything, pinned outside the scrolling
|
||||
// surface, because while it is counting down it is the only thing on this
|
||||
// page that matters -- and scrolling it away is exactly what someone does
|
||||
// when they are looking for the setting that broke their screen.
|
||||
header: Component {
|
||||
Rectangle {
|
||||
visible: Displays.awaitingConfirmation
|
||||
implicitHeight: visible ? confirmRow.implicitHeight + 28 : 0
|
||||
radius: Theme.cardRadius
|
||||
implicitHeight: visible ? Math.max(44, confirmRow.implicitHeight) + 26 : 0
|
||||
radius: Theme.cardRadius + 2
|
||||
color: Theme.mix(Theme.bgPanel, Theme.warn, 0.12)
|
||||
border.width: 1
|
||||
border.color: Theme.alpha(Theme.warn, 0.4)
|
||||
@@ -75,14 +160,21 @@ SettingsPage {
|
||||
anchors.margins: 16
|
||||
spacing: 14
|
||||
|
||||
CountdownRing {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
secondsLeft: Displays.secondsLeft
|
||||
totalSeconds: Displays.confirmSeconds
|
||||
}
|
||||
|
||||
Column {
|
||||
width: parent.width - keepButton.width - revertButton.width - 28
|
||||
width: Math.max(140, parent.width - 40 - keepButton.width
|
||||
- revertButton.width - 42)
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 3
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
text: "Keep this display setting?"
|
||||
text: "Keep these display settings?"
|
||||
color: Theme.fg
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
@@ -90,8 +182,11 @@ SettingsPage {
|
||||
}
|
||||
Text {
|
||||
width: parent.width
|
||||
text: "Reverting in " + Displays.secondsLeft + (Displays.secondsLeft === 1 ? " second" : " seconds")
|
||||
+ " if you do nothing. If you cannot read this, just wait."
|
||||
text: Displays.canConfirm
|
||||
? "Reverting in " + Displays.secondsLeft
|
||||
+ (Displays.secondsLeft === 1 ? " second" : " seconds")
|
||||
+ " if you do nothing. If you cannot read this, just wait."
|
||||
: "Verifying with the compositor… If you cannot read this, just wait."
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.features: Theme.tabularFigures
|
||||
@@ -110,6 +205,7 @@ SettingsPage {
|
||||
id: keepButton
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: "Keep"
|
||||
tone: "accent"
|
||||
enabled: Displays.canConfirm
|
||||
onClicked: Displays.confirm()
|
||||
}
|
||||
@@ -117,194 +213,395 @@ SettingsPage {
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
visible: Displays.monitors.length > 1
|
||||
title: "Arrange displays"
|
||||
subtitle: "Drag the screens into place. The primary display anchors the desktop at 0,0."
|
||||
|
||||
DisplayArrangement {
|
||||
width: parent.width
|
||||
displayService: Displays
|
||||
selectedOutput: root.selectedOutput
|
||||
interactionEnabled: !Displays.awaitingConfirmation && !Displays.busy
|
||||
onSelectionRequested: output => root.selectedOutput = output
|
||||
}
|
||||
// ── The canvas ──────────────────────────────────────────────────────────
|
||||
DisplayArrangement {
|
||||
width: parent.width
|
||||
displayService: Displays
|
||||
selectedOutput: root.selectedOutput
|
||||
interactionEnabled: !Displays.awaitingConfirmation && !Displays.busy
|
||||
onSelectionRequested: output => root.selectedOutput = output
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
visible: Displays.monitors.length > 1
|
||||
title: "Connected display"
|
||||
subtitle: "Choose the output whose resolution, scale, and rotation you want to adjust."
|
||||
|
||||
ChoiceGrid {
|
||||
width: parent.width
|
||||
label: "Display"
|
||||
options: Displays.primaryFirstMonitors.map(monitor => ({
|
||||
value: monitor.name,
|
||||
label: monitor.description || monitor.name
|
||||
}))
|
||||
current: root.monitor ? root.monitor.name : ""
|
||||
enabled: !Displays.awaitingConfirmation && !Displays.busy
|
||||
divider: false
|
||||
onPicked: value => root.selectedOutput = value
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: root.monitor ? root.monitor.name : (SystemSettings.monitorName || "Active display")
|
||||
subtitle: root.monitor
|
||||
? `${root.monitor.description} · ${root.monitor.width} × ${root.monitor.height} at ${Math.round(root.monitor.refreshRate)} Hz · ${root.monitor.scale.toFixed(2)}× scale`
|
||||
: "Reading the active display…"
|
||||
|
||||
TextRow {
|
||||
label: "Color mode"
|
||||
detail: "Wide-gamut SDR at 10-bit. Full-time HDR is left to the Hyprland config: it currently breaks screenshots, OBS, and the lock screen's blurred background."
|
||||
value: root.monitor
|
||||
? `${root.monitor.colorPreset || "standard"} · ${root.monitor.currentFormat || "detecting format"}`
|
||||
: "Detecting"
|
||||
}
|
||||
TextRow {
|
||||
label: "Variable refresh"
|
||||
detail: root.monitor && root.monitor.vrr
|
||||
? "Active on this output for current fullscreen content"
|
||||
: "This output is ready when game or video content requests it"
|
||||
value: root.monitor && root.monitor.vrr ? "Active" : "Standby"
|
||||
divider: Displays.isOverridden(root.monitor ? root.monitor.name : "")
|
||||
}
|
||||
ActionRow {
|
||||
visible: Displays.isOverridden(root.monitor ? root.monitor.name : "")
|
||||
label: "Using a custom display setting"
|
||||
detail: "Forget it to go back to the shipped resolution and scale"
|
||||
action: "Forget"
|
||||
divider: false
|
||||
onTriggered: Displays.forget(root.monitor.name)
|
||||
}
|
||||
DisplayChips {
|
||||
width: parent.width
|
||||
options: Displays.primaryFirstMonitors.map(monitor => ({
|
||||
value: monitor.name,
|
||||
label: monitor.description || monitor.name,
|
||||
detail: `${monitor.name} · ${monitor.width} × ${monitor.height} at ${Math.round(monitor.refreshRate)} Hz`,
|
||||
primary: monitor.primary === true
|
||||
}))
|
||||
current: root.monitor ? root.monitor.name : ""
|
||||
enabled: !Displays.awaitingConfirmation && !Displays.busy
|
||||
onPicked: value => root.selectedOutput = value
|
||||
}
|
||||
|
||||
// ── The selected display ────────────────────────────────────────────────
|
||||
SettingsCard {
|
||||
visible: root.monitor !== null
|
||||
title: "Resolution"
|
||||
subtitle: "Applied straight away, then reverted automatically unless you confirm."
|
||||
|
||||
DisplayPanelHeader {
|
||||
width: parent.width
|
||||
title: root.monitor ? (root.monitor.description || root.monitor.name) : ""
|
||||
connector: root.monitor ? root.monitor.name : ""
|
||||
meta: root.metaLine()
|
||||
overridden: Displays.isOverridden(root.monitor ? root.monitor.name : "")
|
||||
enabled: !Displays.awaitingConfirmation && !Displays.busy
|
||||
onForgetRequested: {
|
||||
if (root.monitor)
|
||||
Displays.forget(root.monitor.name);
|
||||
}
|
||||
}
|
||||
|
||||
PickerRow {
|
||||
id: modePicker
|
||||
|
||||
label: "Resolution"
|
||||
detail: root.monitor
|
||||
? root.monitor.width + " × " + root.monitor.height + " native"
|
||||
: "No display selected"
|
||||
detail: "The picture is sharpest at the resolution the panel was built for"
|
||||
value: root.monitor
|
||||
? root.monitor.width + " × " + root.monitor.height
|
||||
: ""
|
||||
enabled: !Displays.awaitingConfirmation && !Displays.busy
|
||||
divider: false
|
||||
|
||||
DisplayModePicker {
|
||||
width: parent.width
|
||||
monitor: root.monitor
|
||||
enabled: !Displays.awaitingConfirmation && !Displays.busy
|
||||
onPicked: modePicker.collapse()
|
||||
onPicked: mode => {
|
||||
root.applyWith({ mode: mode });
|
||||
modePicker.collapse();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh rate on its own, because the rates for a resolution used to be
|
||||
// reachable only by opening the resolution list -- which is now
|
||||
// collapsed, so changing only the rate meant going through the mode you
|
||||
// already had.
|
||||
PickerRow {
|
||||
id: ratePicker
|
||||
|
||||
// reachable only by opening the resolution list -- so changing only the
|
||||
// rate meant going back through the resolution you already had.
|
||||
SettingRow {
|
||||
label: "Refresh rate"
|
||||
detail: "Rates this display offers at " + (root.monitor
|
||||
? root.monitor.width + " × " + root.monitor.height
|
||||
: "the current resolution")
|
||||
value: root.monitor ? root.monitor.refreshRate.toFixed(2) + " Hz" : ""
|
||||
detail: root.monitor
|
||||
? "Rates this panel offers at " + root.monitor.width + " × " + root.monitor.height
|
||||
: ""
|
||||
visible: root.ratesForCurrentResolution.length > 1
|
||||
enabled: !Displays.awaitingConfirmation && !Displays.busy
|
||||
divider: false
|
||||
controlWidth: Math.max(150, root.ratesForCurrentResolution.length * 76)
|
||||
|
||||
Repeater {
|
||||
model: root.ratesForCurrentResolution
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 6
|
||||
opacity: !Displays.awaitingConfirmation && !Displays.busy ? 1 : 0.45
|
||||
|
||||
delegate: TextRow {
|
||||
required property var modelData
|
||||
required property int index
|
||||
width: parent.width
|
||||
label: String(modelData.refreshLabel ?? "")
|
||||
value: Displays.modeIsCurrent(root.monitor, modelData) ? "Current" : ""
|
||||
controlWidth: 90
|
||||
divider: index < root.ratesForCurrentResolution.length - 1
|
||||
activatable: !Displays.modeIsCurrent(root.monitor, modelData)
|
||||
onActivated: {
|
||||
root.applyWith({ mode: modelData.mode });
|
||||
ratePicker.collapse();
|
||||
Repeater {
|
||||
model: root.ratesForCurrentResolution
|
||||
|
||||
Rectangle {
|
||||
id: rate
|
||||
|
||||
required property var modelData
|
||||
|
||||
readonly property bool selected: Displays.modeIsCurrent(root.monitor, rate.modelData)
|
||||
|
||||
implicitWidth: Math.max(70, rateCaption.implicitWidth + 22)
|
||||
implicitHeight: 30
|
||||
radius: 9
|
||||
color: rate.selected
|
||||
? "transparent"
|
||||
: Theme.alpha(Theme.fg, rateHover.hovered ? 0.11 : 0.06)
|
||||
border.width: rate.selected ? 1 : 0
|
||||
border.color: Theme.alpha(Theme.accent, 0.5)
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
radius: parent.radius
|
||||
visible: rate.selected
|
||||
border.width: 0
|
||||
gradient: Gradient {
|
||||
orientation: Gradient.Horizontal
|
||||
GradientStop { position: 0.0; color: Theme.alpha(Theme.accent, 0.28) }
|
||||
GradientStop { position: 1.0; color: Theme.alpha(Theme.accentSecondary, 0.28) }
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
id: rateCaption
|
||||
anchors.centerIn: parent
|
||||
text: String(rate.modelData.refreshLabel ?? "")
|
||||
color: rate.selected ? Theme.fg : Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.features: Theme.tabularFigures
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
font.weight: rate.selected ? Font.DemiBold : Font.Medium
|
||||
}
|
||||
|
||||
HoverHandler {
|
||||
id: rateHover
|
||||
enabled: !Displays.awaitingConfirmation && !Displays.busy
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
}
|
||||
|
||||
TapHandler {
|
||||
enabled: !Displays.awaitingConfirmation && !Displays.busy && !rate.selected
|
||||
onTapped: root.applyWith({ mode: rate.modelData.mode })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
visible: root.monitor !== null
|
||||
title: "Scale and rotation"
|
||||
|
||||
ChoiceGrid {
|
||||
width: parent.width
|
||||
SegmentRow {
|
||||
label: "Scale"
|
||||
detail: "Fractional scales that do not divide the resolution into whole pixels are rejected by the compositor, so only clean ones are offered."
|
||||
detail: "Only scales that divide the resolution into whole pixels are offered — the compositor refuses the rest"
|
||||
options: Displays.scalesForMode(root.currentMode)
|
||||
.map(scale => ({ value: scale, label: scale.toFixed(2) + "×" }))
|
||||
current: root.monitor ? root.monitor.scale : 1
|
||||
.map(scale => ({ value: scale, label: Math.round(scale * 100) + "%" }))
|
||||
value: root.monitor ? root.monitor.scale : 1
|
||||
enabled: !Displays.awaitingConfirmation && !Displays.busy
|
||||
onPicked: value => root.applyWith({ scale: value })
|
||||
onSelected: value => root.applyWith({ scale: value })
|
||||
}
|
||||
|
||||
ChoiceGrid {
|
||||
width: parent.width
|
||||
RotationRow {
|
||||
label: "Rotation"
|
||||
options: Displays.transforms
|
||||
current: root.monitor ? root.monitor.transform : 0
|
||||
value: root.monitor ? root.monitor.transform : 0
|
||||
enabled: !Displays.awaitingConfirmation && !Displays.busy
|
||||
onSelected: value => root.applyWith({ transform: value })
|
||||
}
|
||||
|
||||
// Outside the transaction, deliberately: see the note at the top.
|
||||
SettingRow {
|
||||
visible: root.brightnessEntry !== null
|
||||
label: "Brightness"
|
||||
detail: Brightness.lastError !== ""
|
||||
? Brightness.lastError
|
||||
: "Hardware brightness over DDC — the same dial as the monitor's buttons"
|
||||
controlWidth: 250
|
||||
|
||||
Text {
|
||||
id: brightnessReadout
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: 44
|
||||
horizontalAlignment: Text.AlignRight
|
||||
text: (root.brightnessEntry ? root.brightnessEntry.value : 0) + "%"
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.features: Theme.tabularFigures
|
||||
font.pixelSize: Theme.fontSize
|
||||
}
|
||||
|
||||
ValueSlider {
|
||||
anchors.left: parent.left
|
||||
anchors.right: brightnessReadout.left
|
||||
anchors.rightMargin: 12
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
value: root.brightnessEntry ? root.brightnessEntry.value / 100 : 0
|
||||
onMoved: ratio => {
|
||||
if (root.brightnessEntry)
|
||||
Brightness.set(root.brightnessEntry.bus, Math.round(ratio * 100));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
OptionPickerRow {
|
||||
label: "Variable refresh rate"
|
||||
detail: root.record && root.record.vrrMode === -1
|
||||
? `Following the gaming policy below, which is ${root.vrrPolicyLabel}`
|
||||
: "This display overrides the gaming policy below"
|
||||
options: Displays.vrrModes.map(mode => ({
|
||||
value: mode.value,
|
||||
label: mode.label,
|
||||
detail: root.vrrOptionDetail(mode.value)
|
||||
}))
|
||||
current: root.record ? root.record.vrrMode : -1
|
||||
enabled: !Displays.awaitingConfirmation && !Displays.busy
|
||||
onPicked: value => root.applyWith({ vrrMode: value })
|
||||
}
|
||||
|
||||
OptionPickerRow {
|
||||
label: "Use as"
|
||||
detail: root.mirrorDetail()
|
||||
options: [{
|
||||
value: "",
|
||||
label: "Extended display",
|
||||
detail: "This display shows its own part of the desktop"
|
||||
}].concat(root.otherMonitors.map(other => ({
|
||||
value: other.name,
|
||||
label: "Mirror of " + (other.description || other.name),
|
||||
detail: `Shows the same picture as ${other.name}, which the compositor places`
|
||||
})))
|
||||
current: root.record ? root.record.mirrorOf : ""
|
||||
enabled: !Displays.awaitingConfirmation && !Displays.busy && root.mirrorPossible
|
||||
divider: false
|
||||
onPicked: value => root.applyWith({ transform: value })
|
||||
onPicked: value => root.applyWith({ mirrorOf: value })
|
||||
}
|
||||
}
|
||||
|
||||
// ── Color ───────────────────────────────────────────────────────────────
|
||||
SettingsCard {
|
||||
title: "Night Light"
|
||||
subtitle: NightLight.active
|
||||
? "On now, warming the display to reduce blue light."
|
||||
: "Warms the display in the evening to reduce blue light."
|
||||
visible: root.monitor !== null
|
||||
title: "Color"
|
||||
subtitle: "Color rides the same keep-or-revert transaction as resolution, so a profile the display refuses restores itself."
|
||||
|
||||
ToggleRow { setting: "nightLightEnabled" }
|
||||
ToggleRow { setting: "nightLightAutomatic" }
|
||||
TimeOfDayRow { setting: "nightLightFrom" }
|
||||
TimeOfDayRow { setting: "nightLightTo" }
|
||||
SliderRow { setting: "nightLightTemperature"; divider: false }
|
||||
Text {
|
||||
width: parent.width
|
||||
horizontalAlignment: Text.AlignRight
|
||||
text: "Now: " + root.liveColorSummary()
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
elide: Text.ElideRight
|
||||
bottomPadding: 11
|
||||
}
|
||||
|
||||
ColorProfileTiles {
|
||||
width: parent.width
|
||||
current: root.record ? root.record.colorProfile : "auto"
|
||||
enabled: !Displays.awaitingConfirmation && !Displays.busy
|
||||
onPicked: value => root.applyWith({ colorProfile: value })
|
||||
}
|
||||
|
||||
SegmentRow {
|
||||
label: "Bit depth"
|
||||
detail: "10-bit reduces gradient banding, but some screen capture and recording tools can't read a 10-bit framebuffer"
|
||||
options: Displays.bitdepths.map(depth => ({ value: depth, label: depth + "-bit" }))
|
||||
value: root.record ? root.record.bitdepth : 8
|
||||
enabled: !Displays.awaitingConfirmation && !Displays.busy
|
||||
divider: root.hdrSelected
|
||||
onSelected: value => root.applyWith({ bitdepth: value })
|
||||
}
|
||||
|
||||
GradientSliderRow {
|
||||
id: sdrBrightnessRow
|
||||
|
||||
visible: root.hdrSelected
|
||||
label: "SDR brightness"
|
||||
detail: "How bright regular, non-HDR content appears next to HDR content"
|
||||
minimum: Displays.sdrBrightnessMin
|
||||
maximum: Displays.sdrBrightnessMax
|
||||
step: 0.05
|
||||
value: root.record ? root.record.sdrBrightness : 1
|
||||
readout: sdrBrightnessRow.shown.toFixed(2) + "×"
|
||||
enabled: !Displays.awaitingConfirmation && !Displays.busy
|
||||
onCommitted: amount => root.applyWith({ sdrBrightness: amount })
|
||||
}
|
||||
|
||||
GradientSliderRow {
|
||||
id: sdrSaturationRow
|
||||
|
||||
visible: root.hdrSelected
|
||||
label: "SDR saturation"
|
||||
detail: "Compensates for washed-out colors in SDR content under HDR"
|
||||
minimum: Displays.sdrSaturationMin
|
||||
maximum: Displays.sdrSaturationMax
|
||||
step: 0.05
|
||||
value: root.record ? root.record.sdrSaturation : 1
|
||||
readout: sdrSaturationRow.shown.toFixed(2) + "×"
|
||||
enabled: !Displays.awaitingConfirmation && !Displays.busy
|
||||
divider: false
|
||||
onCommitted: amount => root.applyWith({ sdrSaturation: amount })
|
||||
}
|
||||
}
|
||||
|
||||
// Panel brightness, over DDC/CI.
|
||||
//
|
||||
// This is hardware state rather than a stored preference: the monitor
|
||||
// remembers it, the bezel buttons change it behind Panama's back, and
|
||||
// writing it into settings.json would mean restoring a value the panel had
|
||||
// already moved on from. So there is no schema key here and no SliderRow --
|
||||
// the rows read and write the display directly.
|
||||
// One brightness surface for both kinds this machine may have, shared
|
||||
// with the quick-settings panel so the two can never disagree. This card
|
||||
// was DDC/CI-only for a while, which on a laptop meant Settings showed
|
||||
// an error about external-monitor brightness while the panel's backlight
|
||||
// worked fine one panel over.
|
||||
SettingsCard {
|
||||
visible: pageBrightness.visible || Brightness.lastError !== ""
|
||||
title: "Brightness"
|
||||
subtitle: pageBrightness.visible
|
||||
? "The built-in panel through its backlight; external monitors over DDC/CI, the same channel their buttons use."
|
||||
: Brightness.lastError
|
||||
// ── Everything that belongs to no display in particular ─────────────────
|
||||
Text {
|
||||
width: parent.width
|
||||
text: "All displays"
|
||||
color: Theme.fgMuted
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
font.weight: Font.DemiBold
|
||||
font.capitalization: Font.AllUppercase
|
||||
font.letterSpacing: 0.7
|
||||
topPadding: 6
|
||||
}
|
||||
|
||||
BrightnessControl {
|
||||
id: pageBrightness
|
||||
width: parent.width
|
||||
// Two cards side by side while there is room for two, and one above the
|
||||
// other when there is not. This window is tiled: its width is anywhere from
|
||||
// a half-screen split to the whole 4500px display.
|
||||
Item {
|
||||
id: globalGrid
|
||||
|
||||
width: parent.width
|
||||
readonly property bool twoUp: globalGrid.width >= 780
|
||||
readonly property real cardWidth: globalGrid.twoUp
|
||||
? (globalGrid.width - 16) / 2
|
||||
: globalGrid.width
|
||||
implicitHeight: globalGrid.twoUp
|
||||
? Math.max(nightLightCard.implicitHeight, gamingCard.implicitHeight)
|
||||
: nightLightCard.implicitHeight + 16 + gamingCard.implicitHeight
|
||||
|
||||
SettingsCard {
|
||||
id: nightLightCard
|
||||
|
||||
width: globalGrid.cardWidth
|
||||
title: "Night Light"
|
||||
subtitle: NightLight.active
|
||||
? "On now, warming the display to reduce blue light."
|
||||
: "Warms the display in the evening to reduce blue light."
|
||||
|
||||
ToggleRow { setting: "nightLightEnabled" }
|
||||
ToggleRow { setting: "nightLightAutomatic" }
|
||||
TimeOfDayRow { setting: "nightLightFrom" }
|
||||
TimeOfDayRow { setting: "nightLightTo" }
|
||||
|
||||
GradientSliderRow {
|
||||
id: temperatureRow
|
||||
|
||||
readonly property var spec: PreferenceSchema.spec("nightLightTemperature")
|
||||
|
||||
label: temperatureRow.spec ? temperatureRow.spec.label : "Color temperature"
|
||||
detail: temperatureRow.spec ? temperatureRow.spec.detail : ""
|
||||
minimum: temperatureRow.spec ? temperatureRow.spec.min : 2000
|
||||
maximum: temperatureRow.spec ? temperatureRow.spec.max : 6500
|
||||
step: temperatureRow.spec ? temperatureRow.spec.step : 100
|
||||
value: DesktopPreferences.get("nightLightTemperature")
|
||||
readout: Math.round(temperatureRow.shown) + " K"
|
||||
// The track is the setting: warm at the low end, daylight at
|
||||
// the high one, so the number is a label rather than a riddle.
|
||||
fullTrack: true
|
||||
trackColors: [
|
||||
Theme.orange,
|
||||
Theme.mix(Theme.yellow, Theme.fg, 0.35),
|
||||
Theme.mix(Theme.accent, Theme.fg, 0.45)
|
||||
]
|
||||
divider: false
|
||||
onCommitted: kelvin => SystemSettings.commitPreference("nightLightTemperature", kelvin)
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
id: gamingCard
|
||||
|
||||
width: globalGrid.cardWidth
|
||||
x: globalGrid.twoUp ? globalGrid.cardWidth + 16 : 0
|
||||
y: globalGrid.twoUp ? 0 : nightLightCard.implicitHeight + 16
|
||||
title: "Gaming"
|
||||
subtitle: "Applied immediately and restored when the session starts."
|
||||
|
||||
ToggleRow { setting: "autoHdr" }
|
||||
|
||||
OptionPickerRow {
|
||||
id: vrrPolicyRow
|
||||
|
||||
readonly property var spec: PreferenceSchema.spec("vrrPolicy")
|
||||
|
||||
label: vrrPolicyRow.spec ? vrrPolicyRow.spec.label : "Variable refresh rate"
|
||||
detail: "The policy every display follows unless it overrides it above"
|
||||
options: vrrPolicyRow.spec && vrrPolicyRow.spec.options ? vrrPolicyRow.spec.options : []
|
||||
current: DesktopPreferences.get("vrrPolicy")
|
||||
onPicked: value => SystemSettings.commitPreference("vrrPolicy", value)
|
||||
}
|
||||
|
||||
OptionPickerRow {
|
||||
id: scanoutRow
|
||||
|
||||
readonly property var spec: PreferenceSchema.spec("directScanoutPolicy")
|
||||
|
||||
label: scanoutRow.spec ? scanoutRow.spec.label : "Direct scanout"
|
||||
detail: scanoutRow.spec ? scanoutRow.spec.detail : ""
|
||||
options: scanoutRow.spec && scanoutRow.spec.options ? scanoutRow.spec.options : []
|
||||
current: DesktopPreferences.get("directScanoutPolicy")
|
||||
divider: false
|
||||
onPicked: value => SystemSettings.commitPreference("directScanoutPolicy", value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -313,7 +610,7 @@ SettingsPage {
|
||||
SettingsCard {
|
||||
visible: Displays.monitors.length >= 2
|
||||
title: "Workspaces"
|
||||
subtitle: "GNOME asked this too. Off, every screen has its own workspaces and switching moves the one you are looking at; on, workspaces belong to the primary display and the others keep a screen of their own."
|
||||
subtitle: "Off, every screen has its own workspaces and switching moves the one you are looking at; on, workspaces belong to the primary display and the others keep a screen of their own."
|
||||
|
||||
SegmentRow {
|
||||
label: "Where workspaces live"
|
||||
@@ -353,33 +650,51 @@ SettingsPage {
|
||||
subtitle: Workspaces.lastError
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Gaming display policy"
|
||||
subtitle: "Applied immediately and restored when the session starts."
|
||||
|
||||
ToggleRow { setting: "autoHdr" }
|
||||
ChoiceRow { setting: "vrrPolicy" }
|
||||
ChoiceRow { setting: "directScanoutPolicy"; divider: false }
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
visible: Displays.lastError !== ""
|
||||
title: "Display problem"
|
||||
subtitle: Displays.lastError
|
||||
}
|
||||
|
||||
// Applies a change to one field, keeping the others at what is in effect.
|
||||
// The brightness helper's explanation, which is usually the udev command
|
||||
// that grants I2C access -- the difference between "no brightness control"
|
||||
// and "brightness is one command away". It has nowhere else to go once the
|
||||
// slider belongs to a display that does not answer over DDC.
|
||||
SettingsCard {
|
||||
visible: Brightness.lastError !== "" && Brightness.displays.length === 0
|
||||
title: "Brightness problem"
|
||||
subtitle: Brightness.lastError
|
||||
}
|
||||
|
||||
function vrrOptionDetail(mode: int): string {
|
||||
if (mode === -1)
|
||||
return `Whatever the gaming policy below says, which is ${root.vrrPolicyLabel}`;
|
||||
if (mode === 0)
|
||||
return "This display runs at a fixed refresh rate";
|
||||
if (mode === 1)
|
||||
return "Best on panels that handle low refresh rates without flicker";
|
||||
return "Any fullscreen window on this display, including video";
|
||||
}
|
||||
|
||||
// The one funnel every per-display edit goes through.
|
||||
//
|
||||
// The service merges the change into the complete live record, so a change
|
||||
// to one field carries every other field unchanged -- that is what stops an
|
||||
// apply from dropping the color settings it never asked about. The only
|
||||
// thing decided here is the scale, because a resolution and a scale are not
|
||||
// independent: a scale that does not divide the new mode into whole pixels
|
||||
// is refused by the compositor, so it moves to the nearest one that does.
|
||||
function applyWith(change: var): void {
|
||||
if (!root.monitor)
|
||||
return;
|
||||
const mode = change.mode ?? root.currentMode;
|
||||
const requestedScale = change.scale ?? root.monitor.scale;
|
||||
Displays.apply(
|
||||
root.monitor.name,
|
||||
mode,
|
||||
Displays.isScaleClean(mode, requestedScale)
|
||||
? requestedScale
|
||||
: Displays.nearestCleanScale(mode, requestedScale),
|
||||
change.transform ?? root.monitor.transform);
|
||||
const partial = Object.assign({}, change);
|
||||
if (partial.mode !== undefined || partial.scale !== undefined) {
|
||||
const mode = partial.mode ?? root.currentMode;
|
||||
const requested = partial.scale ?? root.monitor.scale;
|
||||
partial.scale = Displays.isScaleClean(mode, requested)
|
||||
? requested
|
||||
: Displays.nearestCleanScale(mode, requested);
|
||||
}
|
||||
Displays.applyRecord(root.monitor.name, partial);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
// A slider whose track carries a meaning, and whose value is committed once the
|
||||
// drag settles rather than on every pixel.
|
||||
//
|
||||
// Two rows on the Displays page need something SliderRow cannot give them:
|
||||
//
|
||||
// * The night light temperature, whose track should BE the warmth it sets --
|
||||
// a neutral track with a prism fill says nothing about what 2700 K looks
|
||||
// like, and this is the one slider whose numbers most people cannot picture.
|
||||
// * The SDR trims, which write through the display transaction. A transaction
|
||||
// per pixel of drag would arm a fifteen-second countdown dozens of times;
|
||||
// the value is applied once, when the pointer stops moving.
|
||||
//
|
||||
// Not schema-bound and not a SettingRow: the caller says what the value is and
|
||||
// what to do with a new one, and the row stacks its control below the label in
|
||||
// a narrow window exactly as SliderRow does.
|
||||
|
||||
import QtQuick
|
||||
import qs.config
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property string label: ""
|
||||
property string detail: ""
|
||||
property bool divider: true
|
||||
property bool enabled: true
|
||||
|
||||
property real minimum: 0
|
||||
property real maximum: 100
|
||||
property real step: 1
|
||||
|
||||
// What is in effect. While a drag is in flight the row shows `pending`
|
||||
// instead, and hands the display back once the commit has been made.
|
||||
property real value: 0
|
||||
property var pending: null
|
||||
readonly property real shown: root.pending !== null ? root.pending : root.value
|
||||
|
||||
// The caller formats the number, because "1.15×", "3500 K" and "72%" have
|
||||
// nothing in common but the digits.
|
||||
property string readout: ""
|
||||
|
||||
// Two or three colours, left to right. Painted across the whole track when
|
||||
// `fullTrack` is set -- for a value whose range is the point, like colour
|
||||
// temperature -- and as the fill alone otherwise.
|
||||
property var trackColors: [Theme.accent, Theme.accentSecondary]
|
||||
property bool fullTrack: false
|
||||
|
||||
property int commitDelay: 320
|
||||
|
||||
signal committed(real value)
|
||||
|
||||
readonly property bool inline: root.width >= 520
|
||||
readonly property int controlSpan: 300
|
||||
|
||||
function stopColor(index: int): color {
|
||||
const colors = root.trackColors;
|
||||
if (!colors || colors.length === 0)
|
||||
return Theme.accent;
|
||||
return colors[Math.min(index, colors.length - 1)];
|
||||
}
|
||||
|
||||
function quantise(ratio: real): real {
|
||||
const raw = root.minimum + ratio * (root.maximum - root.minimum);
|
||||
const snapped = Math.round(raw / root.step) * root.step;
|
||||
const clamped = Math.max(root.minimum, Math.min(root.maximum, snapped));
|
||||
return root.step < 1 ? Math.round(clamped * 100) / 100 : clamped;
|
||||
}
|
||||
|
||||
width: parent ? parent.width : 620
|
||||
implicitHeight: root.inline
|
||||
? Math.max(56, copy.implicitHeight + 20)
|
||||
: copy.implicitHeight + 32 + 30
|
||||
opacity: root.enabled ? 1 : 0.45
|
||||
|
||||
Column {
|
||||
id: copy
|
||||
|
||||
x: 0
|
||||
y: root.inline ? (root.height - height) / 2 : 10
|
||||
width: root.inline ? root.width - root.controlSpan - 20 : root.width
|
||||
spacing: 3
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
text: root.label
|
||||
color: Theme.fg
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
font.weight: Font.Medium
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
visible: root.detail !== ""
|
||||
text: root.detail
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
}
|
||||
|
||||
Item {
|
||||
id: control
|
||||
|
||||
width: root.inline ? root.controlSpan : root.width
|
||||
height: 32
|
||||
x: root.inline ? root.width - width : 0
|
||||
y: root.inline ? (root.height - height) / 2 : copy.y + copy.height + 10
|
||||
|
||||
Rectangle {
|
||||
id: track
|
||||
|
||||
anchors.left: parent.left
|
||||
anchors.right: valueLabel.left
|
||||
anchors.rightMargin: 12
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
height: 10
|
||||
radius: 5
|
||||
border.width: 0
|
||||
color: Theme.alpha(Theme.fg, 0.12)
|
||||
|
||||
readonly property real ratio: root.maximum > root.minimum
|
||||
? Math.max(0, Math.min(1, (root.shown - root.minimum) / (root.maximum - root.minimum)))
|
||||
: 0
|
||||
|
||||
Rectangle {
|
||||
anchors.left: parent.left
|
||||
anchors.top: parent.top
|
||||
anchors.bottom: parent.bottom
|
||||
width: root.fullTrack ? parent.width : parent.width * track.ratio
|
||||
radius: parent.radius
|
||||
border.width: 0
|
||||
|
||||
gradient: Gradient {
|
||||
orientation: Gradient.Horizontal
|
||||
GradientStop { position: 0.0; color: root.stopColor(0) }
|
||||
GradientStop { position: 0.5; color: root.stopColor(1) }
|
||||
GradientStop { position: 1.0; color: root.stopColor(2) }
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
width: 16
|
||||
height: 16
|
||||
radius: 8
|
||||
border.width: 0
|
||||
color: Theme.fg
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
x: Math.max(0, Math.min(parent.width - width, parent.width * track.ratio - width / 2))
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: drag
|
||||
|
||||
anchors.fill: parent
|
||||
anchors.margins: -8
|
||||
enabled: root.enabled
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
|
||||
// Local x is 8px ahead of the track's own origin because of the
|
||||
// negative margin above; subtract it before turning the
|
||||
// position into a fraction of the track.
|
||||
function move(positionX: real): void {
|
||||
root.pending = root.quantise(
|
||||
Math.max(0, Math.min(1, (positionX - 8) / track.width)));
|
||||
commitTimer.restart();
|
||||
}
|
||||
|
||||
onPressed: event => drag.move(event.x)
|
||||
onPositionChanged: event => {
|
||||
if (drag.pressed)
|
||||
drag.move(event.x);
|
||||
}
|
||||
onWheel: event => {
|
||||
const direction = event.angleDelta.y > 0 ? 1 : -1;
|
||||
root.pending = Math.max(root.minimum,
|
||||
Math.min(root.maximum, root.shown + direction * root.step));
|
||||
commitTimer.restart();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
id: valueLabel
|
||||
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: 58
|
||||
horizontalAlignment: Text.AlignRight
|
||||
text: root.readout
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.features: Theme.tabularFigures
|
||||
font.pixelSize: Theme.fontSize
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.bottom: parent.bottom
|
||||
height: 1
|
||||
visible: root.divider
|
||||
color: Theme.alpha(Theme.fg, 0.065)
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: commitTimer
|
||||
interval: root.commitDelay
|
||||
onTriggered: {
|
||||
if (root.pending === null)
|
||||
return;
|
||||
root.committed(root.pending);
|
||||
releaseTimer.restart();
|
||||
}
|
||||
}
|
||||
|
||||
// Hands the readout back to whatever is really in effect. If the change was
|
||||
// refused -- an unclean value, a busy transaction -- the row snaps back to
|
||||
// the value it had rather than showing one nothing accepted.
|
||||
Timer {
|
||||
id: releaseTimer
|
||||
interval: 400
|
||||
onTriggered: root.pending = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// A choice with more options, or wider labels, than a segmented control can
|
||||
// carry: collapsed to its current value, expanding to the list.
|
||||
//
|
||||
// ChoiceRow puts every option on one line, which works for two or three short
|
||||
// ones and falls apart at four with sentences under them -- and on this page
|
||||
// two cards sit side by side, so a row has half the width it used to.
|
||||
//
|
||||
// Not schema-bound. Some of these choices are Panama settings and some are
|
||||
// display state that has to go through a keep-or-revert transaction, so the
|
||||
// caller says what to do with the value rather than this writing it.
|
||||
|
||||
import QtQuick
|
||||
import qs.config
|
||||
|
||||
PickerRow {
|
||||
id: root
|
||||
|
||||
// [{ value, label, detail }]
|
||||
property var options: []
|
||||
property var current: null
|
||||
|
||||
signal picked(var value)
|
||||
|
||||
readonly property var currentOption:
|
||||
root.options.find(option => option.value === root.current) ?? null
|
||||
|
||||
value: root.currentOption ? String(root.currentOption.label ?? "") : ""
|
||||
|
||||
Repeater {
|
||||
model: root.options
|
||||
|
||||
TextRow {
|
||||
required property var modelData
|
||||
required property int index
|
||||
|
||||
width: parent.width
|
||||
label: String(modelData.label ?? "")
|
||||
detail: String(modelData.detail ?? "")
|
||||
value: modelData.value === root.current ? "Current" : ""
|
||||
controlWidth: 90
|
||||
divider: index < root.options.length - 1
|
||||
activatable: modelData.value !== root.current
|
||||
onActivated: {
|
||||
root.picked(modelData.value);
|
||||
root.collapse();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
// Rotation, as four little screens rather than four names.
|
||||
//
|
||||
// "Landscape (flipped)" and "Portrait (flipped)" are the two settings on this
|
||||
// page nobody reads correctly the first time: the words describe the result,
|
||||
// but what someone is looking for is the shape. The glyph is the shape, and the
|
||||
// full name is a hover away for anyone who wants it spelled out.
|
||||
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import qs.config
|
||||
|
||||
SettingRow {
|
||||
id: root
|
||||
|
||||
// [{ value, label }] -- Displays.transforms, in its own order.
|
||||
property var options: []
|
||||
property int value: 0
|
||||
property bool enabled: true
|
||||
|
||||
signal selected(int value)
|
||||
|
||||
controlWidth: Math.max(150, root.options.length * 48)
|
||||
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 6
|
||||
opacity: root.enabled ? 1 : 0.45
|
||||
|
||||
Repeater {
|
||||
model: root.options
|
||||
|
||||
Rectangle {
|
||||
id: segment
|
||||
|
||||
required property var modelData
|
||||
|
||||
readonly property bool current: root.value === segment.modelData.value
|
||||
readonly property int orientation: Number(segment.modelData.value)
|
||||
// 1 and 3 are the portrait transforms; 2 and 3 are the flipped
|
||||
// ones. The glyph is those two facts drawn.
|
||||
readonly property bool portrait: segment.orientation === 1 || segment.orientation === 3
|
||||
readonly property bool flipped: segment.orientation >= 2
|
||||
|
||||
width: 42
|
||||
height: 32
|
||||
radius: 9
|
||||
color: segment.current
|
||||
? Theme.alpha(Theme.accent, 0.22)
|
||||
: Theme.alpha(Theme.fg, segmentHover.hovered && root.enabled ? 0.12 : 0.06)
|
||||
border.width: 1
|
||||
border.color: segment.current
|
||||
? Theme.alpha(Theme.accent, 0.5)
|
||||
: Theme.alpha(Theme.fg, 0.08)
|
||||
|
||||
Accessible.role: Accessible.Button
|
||||
Accessible.name: String(segment.modelData.label ?? "")
|
||||
|
||||
ToolTip.visible: segmentHover.hovered
|
||||
ToolTip.delay: 400
|
||||
ToolTip.text: String(segment.modelData.label ?? "")
|
||||
|
||||
// The screen itself: portrait is the same rectangle stood up.
|
||||
Rectangle {
|
||||
anchors.centerIn: parent
|
||||
width: segment.portrait ? 12 : 17
|
||||
height: segment.portrait ? 17 : 12
|
||||
radius: 3
|
||||
color: "transparent"
|
||||
border.width: 2
|
||||
border.color: segment.current ? Theme.fg : Theme.fgDim
|
||||
|
||||
// The thick edge is the bottom of the picture, so a flipped
|
||||
// screen is one whose bottom is somewhere unexpected. Two
|
||||
// rectangles rather than one with switched anchors: an
|
||||
// anchor bound to undefined is not reliably released.
|
||||
Rectangle {
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.margins: 2
|
||||
height: 3
|
||||
radius: 1
|
||||
border.width: 0
|
||||
visible: segment.flipped && !segment.portrait
|
||||
color: segment.current ? Theme.fg : Theme.fgDim
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.top: parent.top
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.right: parent.right
|
||||
anchors.margins: 2
|
||||
width: 3
|
||||
radius: 1
|
||||
border.width: 0
|
||||
visible: segment.flipped && segment.portrait
|
||||
color: segment.current ? Theme.fg : Theme.fgDim
|
||||
}
|
||||
}
|
||||
|
||||
HoverHandler {
|
||||
id: segmentHover
|
||||
enabled: root.enabled
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
}
|
||||
|
||||
TapHandler {
|
||||
enabled: root.enabled && !segment.current
|
||||
onTapped: root.selected(segment.orientation)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -66,6 +66,13 @@ ChoiceGrid 1.0 ChoiceGrid.qml
|
||||
DisplayModePicker 1.0 DisplayModePicker.qml
|
||||
DisplayArrangement 1.0 DisplayArrangement.qml
|
||||
DisplayIdentify 1.0 DisplayIdentify.qml
|
||||
DisplayChips 1.0 DisplayChips.qml
|
||||
DisplayPanelHeader 1.0 DisplayPanelHeader.qml
|
||||
CountdownRing 1.0 CountdownRing.qml
|
||||
RotationRow 1.0 RotationRow.qml
|
||||
ColorProfileTiles 1.0 ColorProfileTiles.qml
|
||||
GradientSliderRow 1.0 GradientSliderRow.qml
|
||||
OptionPickerRow 1.0 OptionPickerRow.qml
|
||||
WifiPanel 1.0 WifiPanel.qml
|
||||
BluetoothPanel 1.0 BluetoothPanel.qml
|
||||
PasswordField 1.0 PasswordField.qml
|
||||
|
||||
@@ -1,3 +1,46 @@
|
||||
// Fields beyond mode/scale/transform/x/y/primary are optional on a record.
|
||||
// Absent means "this layout says nothing about it", which every check below
|
||||
// treats as valid: arrangements stored before the color and mirror fields
|
||||
// existed must keep validating.
|
||||
const colorProfiles = ["auto", "srgb", "wide", "hdr"];
|
||||
const vrrModes = [-1, 0, 1, 2];
|
||||
const bitdepths = [8, 10];
|
||||
const sdrBrightnessRange = { min: 0.8, max: 2.0 };
|
||||
const sdrSaturationRange = { min: 0.8, max: 1.2 };
|
||||
|
||||
function validColorProfile(value) {
|
||||
return colorProfiles.indexOf(value) >= 0;
|
||||
}
|
||||
|
||||
function validVrrMode(value) {
|
||||
return Number.isInteger(value) && vrrModes.indexOf(value) >= 0;
|
||||
}
|
||||
|
||||
function validBitdepth(value) {
|
||||
return Number.isInteger(value) && bitdepths.indexOf(value) >= 0;
|
||||
}
|
||||
|
||||
function inRange(value, range) {
|
||||
return Number.isFinite(value) && value >= range.min && value <= range.max;
|
||||
}
|
||||
|
||||
function validSdrBrightness(value) {
|
||||
return inRange(value, sdrBrightnessRange);
|
||||
}
|
||||
|
||||
function validSdrSaturation(value) {
|
||||
return inRange(value, sdrSaturationRange);
|
||||
}
|
||||
|
||||
// "" for a display that is not mirroring, so callers never branch on undefined.
|
||||
function mirrorTarget(record) {
|
||||
return record && typeof record.mirrorOf === "string" ? record.mirrorOf : "";
|
||||
}
|
||||
|
||||
function isMirrored(record) {
|
||||
return mirrorTarget(record) !== "";
|
||||
}
|
||||
|
||||
function logicalSize(record) {
|
||||
if (!record)
|
||||
return { width: 0, height: 0 };
|
||||
@@ -22,6 +65,22 @@ function validCoordinate(value) {
|
||||
&& value >= -100000 && value <= 100000;
|
||||
}
|
||||
|
||||
function validOptionalFields(record) {
|
||||
if (record.vrrMode !== undefined && !validVrrMode(record.vrrMode))
|
||||
return false;
|
||||
if (record.colorProfile !== undefined && !validColorProfile(record.colorProfile))
|
||||
return false;
|
||||
if (record.bitdepth !== undefined && !validBitdepth(record.bitdepth))
|
||||
return false;
|
||||
if (record.sdrBrightness !== undefined && !validSdrBrightness(record.sdrBrightness))
|
||||
return false;
|
||||
if (record.sdrSaturation !== undefined && !validSdrSaturation(record.sdrSaturation))
|
||||
return false;
|
||||
if (record.mirrorOf !== undefined && typeof record.mirrorOf !== "string")
|
||||
return false;
|
||||
return !isMirrored(record) || /^[A-Za-z0-9_.-]+$/.test(record.mirrorOf);
|
||||
}
|
||||
|
||||
function validate(layout) {
|
||||
if (!Array.isArray(layout) || layout.length === 0)
|
||||
return false;
|
||||
@@ -41,7 +100,8 @@ function validate(layout) {
|
||||
|| !Number.isInteger(record.transform)
|
||||
|| record.transform < 0 || record.transform > 3
|
||||
|| !validCoordinate(record.x) || !validCoordinate(record.y)
|
||||
|| typeof record.primary !== "boolean")
|
||||
|| typeof record.primary !== "boolean"
|
||||
|| !validOptionalFields(record))
|
||||
return false;
|
||||
|
||||
const size = logicalSize(record);
|
||||
@@ -51,6 +111,21 @@ function validate(layout) {
|
||||
if (record.primary)
|
||||
primaryCount += 1;
|
||||
}
|
||||
|
||||
// Mirroring is checked once every name is known. A mirror needs a target
|
||||
// that is part of this same layout and is not itself a mirror, because
|
||||
// Hyprland has no chain to follow; and the primary may not mirror, since
|
||||
// the arrangement is anchored on it and a mirror has no position of its own.
|
||||
for (const record of layout) {
|
||||
const target = mirrorTarget(record);
|
||||
if (target === "")
|
||||
continue;
|
||||
if (record.primary === true || target === record.name)
|
||||
return false;
|
||||
const other = layout.find(candidate => candidate.name === target);
|
||||
if (!other || isMirrored(other))
|
||||
return false;
|
||||
}
|
||||
return primaryCount === 1;
|
||||
}
|
||||
|
||||
@@ -58,6 +133,14 @@ function cloneLayout(layout) {
|
||||
return (layout || []).map(record => Object.assign({}, record));
|
||||
}
|
||||
|
||||
// A mirrored output is placed by the compositor, not by us, so it is left out
|
||||
// of every geometry pass: shifting it, measuring the desktop by it, or snapping
|
||||
// to it would all be arithmetic on a position nothing reads back.
|
||||
function placedRecords(layout) {
|
||||
const placed = (layout || []).filter(record => !isMirrored(record));
|
||||
return placed.length > 0 ? placed : (layout || []);
|
||||
}
|
||||
|
||||
function normalize(layout) {
|
||||
const result = cloneLayout(layout);
|
||||
const primary = result.find(record => record.primary === true);
|
||||
@@ -66,9 +149,24 @@ function normalize(layout) {
|
||||
const anchorX = primary.x;
|
||||
const anchorY = primary.y;
|
||||
for (const record of result) {
|
||||
if (isMirrored(record))
|
||||
continue;
|
||||
record.x -= anchorX;
|
||||
record.y -= anchorY;
|
||||
}
|
||||
// A mirror sits on its target, so it is given the target's place rather
|
||||
// than a shifted version of coordinates that stopped meaning anything the
|
||||
// moment mirroring was turned on. Nothing asserts them -- the compositor
|
||||
// chooses -- but a stale pair would still be drawn on the canvas.
|
||||
for (const record of result) {
|
||||
if (!isMirrored(record))
|
||||
continue;
|
||||
const target = result.find(candidate => candidate.name === record.mirrorOf);
|
||||
if (!target)
|
||||
continue;
|
||||
record.x = target.x;
|
||||
record.y = target.y;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -80,7 +178,7 @@ function bounds(layout) {
|
||||
let top = Infinity;
|
||||
let right = -Infinity;
|
||||
let bottom = -Infinity;
|
||||
for (const record of layout) {
|
||||
for (const record of placedRecords(layout)) {
|
||||
const size = logicalSize(record);
|
||||
left = Math.min(left, record.x);
|
||||
top = Math.min(top, record.y);
|
||||
@@ -93,7 +191,7 @@ function bounds(layout) {
|
||||
function snap(layout, movingName, threshold) {
|
||||
const result = cloneLayout(layout);
|
||||
const moving = result.find(record => record.name === movingName);
|
||||
if (!moving)
|
||||
if (!moving || isMirrored(moving))
|
||||
return result;
|
||||
|
||||
const limit = Number.isFinite(threshold) && threshold >= 0 ? threshold : 16;
|
||||
@@ -101,7 +199,7 @@ function snap(layout, movingName, threshold) {
|
||||
const movingXEdges = [moving.x, moving.x + movingSize.width];
|
||||
const movingYEdges = [moving.y, moving.y + movingSize.height];
|
||||
const stationary = result
|
||||
.filter(record => record.name !== movingName)
|
||||
.filter(record => record.name !== movingName && !isMirrored(record))
|
||||
.sort((left, right) => left.name.localeCompare(right.name));
|
||||
|
||||
let bestX = null;
|
||||
@@ -148,19 +246,35 @@ function canvasRects(layout, canvasWidth, canvasHeight, padding) {
|
||||
const contentHeight = desktopBounds.height * scale;
|
||||
const originX = inset + (availableWidth - contentWidth) / 2;
|
||||
const originY = inset + (availableHeight - contentHeight) / 2;
|
||||
return {
|
||||
bounds: desktopBounds,
|
||||
scale,
|
||||
rects: (layout || []).map(record => {
|
||||
const size = logicalSize(record);
|
||||
return {
|
||||
name: record.name,
|
||||
x: originX + (record.x - desktopBounds.x) * scale,
|
||||
y: originY + (record.y - desktopBounds.y) * scale,
|
||||
width: size.width * scale,
|
||||
height: size.height * scale,
|
||||
primary: record.primary === true
|
||||
};
|
||||
})
|
||||
};
|
||||
const rects = (layout || []).map(record => {
|
||||
const size = logicalSize(record);
|
||||
return {
|
||||
name: record.name,
|
||||
x: originX + (record.x - desktopBounds.x) * scale,
|
||||
y: originY + (record.y - desktopBounds.y) * scale,
|
||||
width: size.width * scale,
|
||||
height: size.height * scale,
|
||||
primary: record.primary === true,
|
||||
mirrorOf: mirrorTarget(record),
|
||||
mirrored: isMirrored(record)
|
||||
};
|
||||
});
|
||||
|
||||
// A mirror shows the same picture in the same place as its target, so it is
|
||||
// drawn stacked on it with a badge rather than at coordinates of its own.
|
||||
// Its target is guaranteed present by validate(); an unvalidated layout that
|
||||
// names a missing one keeps its own rectangle instead of vanishing.
|
||||
for (const rect of rects) {
|
||||
if (!rect.mirrored)
|
||||
continue;
|
||||
const target = rects.find(candidate => candidate.name === rect.mirrorOf);
|
||||
if (!target)
|
||||
continue;
|
||||
rect.x = target.x;
|
||||
rect.y = target.y;
|
||||
rect.width = target.width;
|
||||
rect.height = target.height;
|
||||
}
|
||||
|
||||
return { bounds: desktopBounds, scale, rects };
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
pragma Singleton
|
||||
|
||||
// Display configuration: resolution, refresh rate, scale, and rotation.
|
||||
// Display configuration: resolution, refresh rate, scale, rotation, position,
|
||||
// colour management, variable refresh rate, and mirroring.
|
||||
//
|
||||
// This is the only page in Panama Settings where a wrong value can leave you
|
||||
// unable to SEE the screen well enough to undo it. A mode the display cannot
|
||||
@@ -27,7 +28,14 @@ Singleton {
|
||||
id: root
|
||||
|
||||
// [{ name, description, width, height, refreshRate, scale, transform,
|
||||
// x, y, primary, currentFormat, colorPreset, bitdepth, sdrBrightness,
|
||||
// sdrSaturation, mirrorOf, vrr,
|
||||
// modes: [{ label, mode, width, height, refresh }] }]
|
||||
//
|
||||
// bitdepth, sdrBrightness and sdrSaturation are 0 when the compositor does
|
||||
// not report them, which is not the same as a value: 0 is outside every
|
||||
// valid range, so verification skips a field it cannot read rather than
|
||||
// treating "unknown" as a mismatch.
|
||||
property var monitors: []
|
||||
// Quickshell.screens is the topology authority. The override is only the
|
||||
// isolated harness model; normal sessions always observe Quickshell.
|
||||
@@ -77,6 +85,33 @@ Singleton {
|
||||
{ value: 3, label: "Portrait (flipped)" }
|
||||
]
|
||||
|
||||
// Colour management presets Hyprland accepts as `cm`. "auto" is a policy,
|
||||
// not a state: the compositor resolves it to a concrete preset and reports
|
||||
// that one back, so it is never verified against readback.
|
||||
readonly property var colorProfiles: [
|
||||
{ value: "auto", label: "Automatic" },
|
||||
{ value: "srgb", label: "sRGB" },
|
||||
{ value: "wide", label: "Wide gamut" },
|
||||
{ value: "hdr", label: "HDR" }
|
||||
]
|
||||
|
||||
// Per-display override of the global VRR policy. -1 means the display has
|
||||
// no opinion and follows misc.vrr, which is expressed by leaving `vrr` out
|
||||
// of the monitor rule entirely. 3 (fullscreen games only) is deliberately
|
||||
// absent: it belongs to the global policy, not to one display.
|
||||
readonly property var vrrModes: [
|
||||
{ value: -1, label: "Follow gaming policy" },
|
||||
{ value: 0, label: "Off" },
|
||||
{ value: 1, label: "Always on" },
|
||||
{ value: 2, label: "Fullscreen only" }
|
||||
]
|
||||
|
||||
readonly property var bitdepths: [8, 10]
|
||||
readonly property real sdrBrightnessMin: 0.8
|
||||
readonly property real sdrBrightnessMax: 2.0
|
||||
readonly property real sdrSaturationMin: 0.8
|
||||
readonly property real sdrSaturationMax: 1.2
|
||||
|
||||
// Scales that divide this desktop's common resolutions into whole pixels.
|
||||
// Hyprland rejects a fractional scale that does not, and the message it
|
||||
// gives is not something to put in front of a user.
|
||||
@@ -197,7 +232,8 @@ Singleton {
|
||||
|| Math.abs(entry.scale - record.scale) >= 0.001
|
||||
|| entry.transform !== record.transform
|
||||
|| (entry.x !== undefined && entry.x !== record.x)
|
||||
|| (entry.y !== undefined && entry.y !== record.y))
|
||||
|| (entry.y !== undefined && entry.y !== record.y)
|
||||
|| root.storedFieldsDiffer(entry, record))
|
||||
changed = true;
|
||||
record.mode = entry.mode;
|
||||
record.width = parts.width;
|
||||
@@ -208,6 +244,7 @@ Singleton {
|
||||
if (entry.x !== undefined) record.x = entry.x;
|
||||
if (entry.y !== undefined) record.y = entry.y;
|
||||
record.primary = entry.primary === true;
|
||||
root.overlayStoredFields(record, entry);
|
||||
}
|
||||
|
||||
if (!changed)
|
||||
@@ -313,6 +350,11 @@ Singleton {
|
||||
primary: monitor.name === primaryName,
|
||||
currentFormat: monitor.currentFormat ?? "",
|
||||
colorPreset: monitor.colorManagementPreset ?? "",
|
||||
bitdepth: root.formatBitdepth(monitor.currentFormat ?? ""),
|
||||
sdrBrightness: Number.isFinite(monitor.sdrBrightness) ? monitor.sdrBrightness : 0,
|
||||
sdrSaturation: Number.isFinite(monitor.sdrSaturation) ? monitor.sdrSaturation : 0,
|
||||
// Hyprland reports "none" for an output that is not mirroring.
|
||||
mirrorOf: (monitor.mirrorOf ?? "none") === "none" ? "" : String(monitor.mirrorOf),
|
||||
vrr: monitor.vrr === true,
|
||||
modes: modes
|
||||
};
|
||||
@@ -384,6 +426,55 @@ Singleton {
|
||||
return root.monitors.find(monitor => monitor.name === name) ?? null;
|
||||
}
|
||||
|
||||
// The framebuffer format is the only honest report of the bit depth in
|
||||
// effect: asking for 10-bit and getting it are different things, and a
|
||||
// panel that cannot carry the link rate quietly stays at 8. Formats outside
|
||||
// this map are read as "unknown", never as a mismatch.
|
||||
function formatBitdepth(format: string): int {
|
||||
if (format === "XRGB8888")
|
||||
return 8;
|
||||
if (format === "XRGB2101010")
|
||||
return 10;
|
||||
return 0;
|
||||
}
|
||||
|
||||
function persistedDisplays(): var {
|
||||
const stored = DesktopPreferences.get("displays");
|
||||
return stored && typeof stored === "object" ? stored : {};
|
||||
}
|
||||
|
||||
// The stored entry for an output, or null when nothing valid is stored.
|
||||
function savedEntry(output: string): var {
|
||||
const entry = root.persistedDisplays()[output];
|
||||
return root.isPersistedLayoutEntry(entry) ? entry : null;
|
||||
}
|
||||
|
||||
// Whether a stored entry asks for something the live record does not
|
||||
// already have. A field the entry does not carry is not a difference: it
|
||||
// predates that field, and the compositor's current value stands.
|
||||
function storedFieldsDiffer(entry: var, record: var): bool {
|
||||
return (entry.vrrMode !== undefined && entry.vrrMode !== record.vrrMode)
|
||||
|| (entry.colorProfile !== undefined && entry.colorProfile !== record.colorProfile)
|
||||
|| (entry.bitdepth !== undefined && entry.bitdepth !== record.bitdepth)
|
||||
|| (entry.mirrorOf !== undefined && entry.mirrorOf !== record.mirrorOf)
|
||||
|| (entry.sdrBrightness !== undefined
|
||||
&& Math.abs(entry.sdrBrightness - record.sdrBrightness) >= 0.001)
|
||||
|| (entry.sdrSaturation !== undefined
|
||||
&& Math.abs(entry.sdrSaturation - record.sdrSaturation) >= 0.001);
|
||||
}
|
||||
|
||||
function overlayStoredFields(record: var, entry: var): void {
|
||||
for (const field of ["vrrMode", "colorProfile", "bitdepth",
|
||||
"sdrBrightness", "sdrSaturation", "mirrorOf"]) {
|
||||
if (entry[field] !== undefined)
|
||||
record[field] = entry[field];
|
||||
}
|
||||
}
|
||||
|
||||
// Everything past `primary` is optional so that arrangements stored before
|
||||
// the colour and mirror fields existed still load. Present but invalid is
|
||||
// not optional: a half-written record is one Panama refuses rather than
|
||||
// guesses at, exactly as it treats a half-written position.
|
||||
function isPersistedLayoutEntry(entry: var): bool {
|
||||
return !!entry && typeof entry === "object"
|
||||
&& root.modeParts(entry.mode) !== null
|
||||
@@ -392,7 +483,14 @@ Singleton {
|
||||
&& entry.transform >= 0 && entry.transform <= 3
|
||||
&& Number.isInteger(entry.x) && entry.x >= -100000 && entry.x <= 100000
|
||||
&& Number.isInteger(entry.y) && entry.y >= -100000 && entry.y <= 100000
|
||||
&& typeof entry.primary === "boolean";
|
||||
&& typeof entry.primary === "boolean"
|
||||
&& (entry.vrrMode === undefined || DisplayLayout.validVrrMode(entry.vrrMode))
|
||||
&& (entry.colorProfile === undefined || DisplayLayout.validColorProfile(entry.colorProfile))
|
||||
&& (entry.bitdepth === undefined || DisplayLayout.validBitdepth(entry.bitdepth))
|
||||
&& (entry.sdrBrightness === undefined || DisplayLayout.validSdrBrightness(entry.sdrBrightness))
|
||||
&& (entry.sdrSaturation === undefined || DisplayLayout.validSdrSaturation(entry.sdrSaturation))
|
||||
&& (entry.mirrorOf === undefined || (typeof entry.mirrorOf === "string"
|
||||
&& (entry.mirrorOf === "" || /^[A-Za-z0-9_.-]+$/.test(entry.mirrorOf))));
|
||||
}
|
||||
|
||||
function modeParts(mode: string): var {
|
||||
@@ -429,19 +527,49 @@ Singleton {
|
||||
choices[0]);
|
||||
}
|
||||
|
||||
// The complete state of every connected display, read from the compositor.
|
||||
//
|
||||
// Every field is read live, because a Hyprland monitor rule replaces the
|
||||
// previous rule for that output wholesale: a change to one field that did
|
||||
// not carry the others would drop them back to compositor defaults. That is
|
||||
// the clobber this reads against.
|
||||
//
|
||||
// vrrMode is the exception. The compositor's `vrr` readback is whether
|
||||
// variable refresh is active right now, not which policy was configured, so
|
||||
// it comes from the stored record and defaults to -1 (follow the global
|
||||
// policy). Reverting to a record with -1 is still correct: the rule pushed
|
||||
// for it carries no `vrr` key, and the display falls back to misc.vrr.
|
||||
function currentLayout(): var {
|
||||
return root.monitors.map(monitor => ({
|
||||
name: monitor.name,
|
||||
width: monitor.width,
|
||||
height: monitor.height,
|
||||
refreshRate: monitor.refreshRate,
|
||||
mode: monitor.mode,
|
||||
scale: monitor.scale,
|
||||
transform: monitor.transform,
|
||||
x: monitor.x,
|
||||
y: monitor.y,
|
||||
primary: monitor.primary === true
|
||||
}));
|
||||
return root.monitors.map(monitor => {
|
||||
const saved = root.savedEntry(monitor.name);
|
||||
const live = DisplayLayout.validColorProfile(monitor.colorPreset)
|
||||
? monitor.colorPreset : "auto";
|
||||
return {
|
||||
name: monitor.name,
|
||||
width: monitor.width,
|
||||
height: monitor.height,
|
||||
refreshRate: monitor.refreshRate,
|
||||
mode: monitor.mode,
|
||||
scale: monitor.scale,
|
||||
transform: monitor.transform,
|
||||
x: monitor.x,
|
||||
y: monitor.y,
|
||||
primary: monitor.primary === true,
|
||||
vrrMode: saved && DisplayLayout.validVrrMode(saved.vrrMode) ? saved.vrrMode : -1,
|
||||
// A display set to "auto" reads back as the preset auto chose.
|
||||
// Keeping the stored policy stops one apply from pinning the
|
||||
// display to whatever automatic happened to pick today.
|
||||
colorProfile: saved && saved.colorProfile === "auto" ? "auto" : live,
|
||||
bitdepth: monitor.bitdepth !== 0
|
||||
? monitor.bitdepth
|
||||
: (saved && DisplayLayout.validBitdepth(saved.bitdepth) ? saved.bitdepth : 8),
|
||||
sdrBrightness: DisplayLayout.validSdrBrightness(monitor.sdrBrightness)
|
||||
? monitor.sdrBrightness : 1.0,
|
||||
sdrSaturation: DisplayLayout.validSdrSaturation(monitor.sdrSaturation)
|
||||
? monitor.sdrSaturation : 1.0,
|
||||
mirrorOf: typeof monitor.mirrorOf === "string" ? monitor.mirrorOf : ""
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function matchesLayout(monitors: var, layout: var): bool {
|
||||
@@ -459,13 +587,72 @@ Singleton {
|
||||
|| monitor.height !== parts.height
|
||||
|| Math.abs(monitor.refreshRate - parts.refresh) >= 0.01
|
||||
|| Math.abs(monitor.scale - requested.scale) >= 0.001
|
||||
|| monitor.transform !== requested.transform
|
||||
|| monitor.x !== requested.x || monitor.y !== requested.y)
|
||||
|| monitor.transform !== requested.transform)
|
||||
return false;
|
||||
|
||||
const mirrorOf = typeof requested.mirrorOf === "string" ? requested.mirrorOf : "";
|
||||
if (root.readbackMirror(monitor) !== mirrorOf)
|
||||
return false;
|
||||
// A mirror is placed by the compositor on top of what it copies, so
|
||||
// its coordinates are not ours to assert. Every other display still
|
||||
// has to land exactly where it was asked to.
|
||||
if (mirrorOf === "" && (monitor.x !== requested.x || monitor.y !== requested.y))
|
||||
return false;
|
||||
|
||||
if (!root.matchesColor(monitor, requested))
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Readback accessors. They take either a parsed record from root.monitors
|
||||
// or a raw `hyprctl -j monitors` object, because verification is also
|
||||
// exercised against captured compositor output, and a comparison that only
|
||||
// understood one of the two shapes would pass for the wrong reason.
|
||||
function readbackMirror(monitor: var): string {
|
||||
const value = typeof monitor.mirrorOf === "string" ? monitor.mirrorOf : "";
|
||||
return value === "none" ? "" : value;
|
||||
}
|
||||
|
||||
function readbackPreset(monitor: var): string {
|
||||
if (typeof monitor.colorPreset === "string" && monitor.colorPreset !== "")
|
||||
return monitor.colorPreset;
|
||||
return typeof monitor.colorManagementPreset === "string"
|
||||
? monitor.colorManagementPreset : "";
|
||||
}
|
||||
|
||||
function readbackBitdepth(monitor: var): int {
|
||||
if (Number.isInteger(monitor.bitdepth))
|
||||
return monitor.bitdepth;
|
||||
return root.formatBitdepth(String(monitor.currentFormat ?? ""));
|
||||
}
|
||||
|
||||
// The colour half of the readback comparison. Each field is asserted only
|
||||
// where the compositor reports something to compare against; vrrMode is
|
||||
// absent by design, since `vrr` reads back live state rather than policy.
|
||||
function matchesColor(monitor: var, requested: var): bool {
|
||||
// "auto" is resolved by the compositor into srgb or wide before it is
|
||||
// reported, so there is no value it could equal. It is applied, and the
|
||||
// preset it resolved to is what the page shows.
|
||||
const preset = root.readbackPreset(monitor);
|
||||
if (requested.colorProfile !== undefined && requested.colorProfile !== "auto"
|
||||
&& preset !== "" && preset !== requested.colorProfile)
|
||||
return false;
|
||||
const bitdepth = root.readbackBitdepth(monitor);
|
||||
if (requested.bitdepth !== undefined && bitdepth !== 0
|
||||
&& bitdepth !== requested.bitdepth)
|
||||
return false;
|
||||
if (requested.sdrBrightness !== undefined
|
||||
&& DisplayLayout.validSdrBrightness(monitor.sdrBrightness)
|
||||
&& Math.abs(monitor.sdrBrightness - requested.sdrBrightness) >= 0.01)
|
||||
return false;
|
||||
if (requested.sdrSaturation !== undefined
|
||||
&& DisplayLayout.validSdrSaturation(monitor.sdrSaturation)
|
||||
&& Math.abs(monitor.sdrSaturation - requested.sdrSaturation) >= 0.01)
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function modeIsCurrent(monitor: var, candidate: var): bool {
|
||||
return !!monitor && !!candidate
|
||||
&& monitor.width === candidate.width
|
||||
@@ -483,34 +670,66 @@ Singleton {
|
||||
return layout.every(record => {
|
||||
const monitor = root.monitorNamed(record.name);
|
||||
const parts = root.modeParts(record.mode);
|
||||
const mirrorOf = typeof record.mirrorOf === "string" ? record.mirrorOf : "";
|
||||
return !!monitor && !!parts
|
||||
&& record.width === parts.width && record.height === parts.height
|
||||
&& monitor.modes.some(candidate => candidate.mode === record.mode)
|
||||
&& root.isScaleClean(record.mode, record.scale)
|
||||
&& root.transforms.some(candidate => candidate.value === record.transform);
|
||||
&& root.transforms.some(candidate => candidate.value === record.transform)
|
||||
// DisplayLayout.validate already refuses chains and a mirroring
|
||||
// primary; this is the connected-hardware half of the same rule.
|
||||
&& (mirrorOf === "" || !!root.monitorNamed(mirrorOf));
|
||||
});
|
||||
}
|
||||
|
||||
// One-field controls remain callers of the complete-layout transaction.
|
||||
// Their edit is cloned into the current layout so every output's position
|
||||
// participates in apply, verification, and rollback.
|
||||
function apply(output: string, mode: string, scale: real, transform: int): bool {
|
||||
// Their edit is cloned into the current layout so every output's position,
|
||||
// colour and mirror state participates in apply, verification, and
|
||||
// rollback. `partial` carries only the fields being changed; anything it
|
||||
// leaves out keeps the value currentLayout just read from the compositor.
|
||||
function applyRecord(output: string, partial: var): bool {
|
||||
const layout = root.currentLayout();
|
||||
const record = layout.find(candidate => candidate.name === output);
|
||||
const parts = root.modeParts(mode);
|
||||
if (!record || !parts) {
|
||||
root.lastError = record ? "That display does not offer that mode." : "That display is not connected.";
|
||||
if (!record) {
|
||||
root.lastError = "That display is not connected.";
|
||||
return false;
|
||||
}
|
||||
record.mode = mode;
|
||||
record.width = parts.width;
|
||||
record.height = parts.height;
|
||||
record.refreshRate = parts.refresh;
|
||||
record.scale = scale;
|
||||
record.transform = transform;
|
||||
const changes = (partial && typeof partial === "object") ? partial : {};
|
||||
if (changes.mode !== undefined) {
|
||||
const parts = root.modeParts(changes.mode);
|
||||
if (!parts) {
|
||||
root.lastError = "That display does not offer that mode.";
|
||||
return false;
|
||||
}
|
||||
record.mode = changes.mode;
|
||||
record.width = parts.width;
|
||||
record.height = parts.height;
|
||||
record.refreshRate = parts.refresh;
|
||||
}
|
||||
for (const field of ["scale", "transform", "vrrMode", "colorProfile",
|
||||
"bitdepth", "sdrBrightness", "sdrSaturation", "mirrorOf"]) {
|
||||
if (changes[field] !== undefined)
|
||||
record[field] = changes[field];
|
||||
}
|
||||
|
||||
// The arrangement is anchored on the primary display, and a mirror has
|
||||
// no position of its own to anchor to. Said plainly here rather than
|
||||
// left to the layout validator's one generic message.
|
||||
if (record.primary === true && typeof record.mirrorOf === "string"
|
||||
&& record.mirrorOf !== "") {
|
||||
root.lastError = "The primary display cannot mirror another display. Make a different display primary first.";
|
||||
return false;
|
||||
}
|
||||
|
||||
return root.applyLayout(layout);
|
||||
}
|
||||
|
||||
// The original four-field entry point, kept so existing callers and the
|
||||
// harness keep working.
|
||||
function apply(output: string, mode: string, scale: real, transform: int): bool {
|
||||
return root.applyRecord(output, { mode: mode, scale: scale, transform: transform });
|
||||
}
|
||||
|
||||
// Applies immediately and starts the countdown. Nothing is stored yet: the
|
||||
// complete connected layout is only written by confirm().
|
||||
function applyLayout(layout: var, protectedOperation: bool): bool {
|
||||
@@ -554,19 +773,66 @@ Singleton {
|
||||
|
||||
function makePrimary(output: string): bool {
|
||||
const layout = root.currentLayout();
|
||||
if (!layout.some(record => record.name === output)) {
|
||||
const target = layout.find(record => record.name === output);
|
||||
if (!target) {
|
||||
root.lastError = "That display is not connected.";
|
||||
return false;
|
||||
}
|
||||
// Refused rather than silently un-mirrored: the arrangement is anchored
|
||||
// on the primary, and a mirror has no position of its own. Turning the
|
||||
// mirror off is a change to what is on screen, and the user makes it.
|
||||
if (typeof target.mirrorOf === "string" && target.mirrorOf !== "") {
|
||||
root.lastError = "A mirrored display cannot be the primary. Set it back to an extended display first.";
|
||||
return false;
|
||||
}
|
||||
for (const record of layout)
|
||||
record.primary = record.name === output;
|
||||
return root.applyLayout(DisplayLayout.normalize(layout));
|
||||
}
|
||||
|
||||
// A monitor rule replaces the previous rule for that output entirely, so
|
||||
// every field the record knows is emitted every time. Omission is not
|
||||
// "leave it alone", it is "go back to the compositor's default", which is
|
||||
// exactly what the omission rules below rely on:
|
||||
//
|
||||
// * vrr is left out for vrrMode -1, which returns the display to the
|
||||
// global misc.vrr policy. Emitting the key at all IS the override.
|
||||
// * mirror is left out unless the record names a target.
|
||||
// * sdrbrightness / sdrsaturation are left out at their neutral 1.0.
|
||||
// Naming the neutral value pins the display to it, which is not what
|
||||
// "no opinion" means.
|
||||
// * position is "auto" for a mirror, whose place is the compositor's to
|
||||
// choose and ours to read back, never to ask for.
|
||||
function monitorRule(record: var): string {
|
||||
const mirrorOf = typeof record.mirrorOf === "string" ? record.mirrorOf : "";
|
||||
const fields = [
|
||||
`output = "${record.name}"`,
|
||||
`mode = "${record.mode}"`,
|
||||
mirrorOf === ""
|
||||
? `position = "${record.x}x${record.y}"`
|
||||
: `position = "auto"`,
|
||||
`scale = ${record.scale}`,
|
||||
`transform = ${record.transform}`
|
||||
];
|
||||
if (mirrorOf !== "")
|
||||
fields.push(`mirror = "${mirrorOf}"`);
|
||||
if (DisplayLayout.validVrrMode(record.vrrMode) && record.vrrMode >= 0)
|
||||
fields.push(`vrr = ${record.vrrMode}`);
|
||||
if (DisplayLayout.validBitdepth(record.bitdepth))
|
||||
fields.push(`bitdepth = ${record.bitdepth}`);
|
||||
if (DisplayLayout.validColorProfile(record.colorProfile))
|
||||
fields.push(`cm = "${record.colorProfile}"`);
|
||||
if (DisplayLayout.validSdrBrightness(record.sdrBrightness)
|
||||
&& Math.abs(record.sdrBrightness - 1.0) >= 0.001)
|
||||
fields.push(`sdrbrightness = ${record.sdrBrightness}`);
|
||||
if (DisplayLayout.validSdrSaturation(record.sdrSaturation)
|
||||
&& Math.abs(record.sdrSaturation - 1.0) >= 0.001)
|
||||
fields.push(`sdrsaturation = ${record.sdrSaturation}`);
|
||||
return `hl.monitor({ ${fields.join(", ")} })`;
|
||||
}
|
||||
|
||||
function pushLayout(layout: var, runner: var): void {
|
||||
const payload = layout.map(record =>
|
||||
`hl.monitor({ output = "${record.name}", mode = "${record.mode}", position = "${record.x}x${record.y}", scale = ${record.scale}, transform = ${record.transform} })`
|
||||
).join("; ");
|
||||
const payload = layout.map(record => root.monitorRule(record)).join("; ");
|
||||
runner.exec(["hyprctl", "eval", payload]);
|
||||
}
|
||||
|
||||
@@ -587,7 +853,13 @@ Singleton {
|
||||
transform: record.transform,
|
||||
x: record.x,
|
||||
y: record.y,
|
||||
primary: record.primary
|
||||
primary: record.primary,
|
||||
vrrMode: record.vrrMode,
|
||||
colorProfile: record.colorProfile,
|
||||
bitdepth: record.bitdepth,
|
||||
sdrBrightness: record.sdrBrightness,
|
||||
sdrSaturation: record.sdrSaturation,
|
||||
mirrorOf: record.mirrorOf
|
||||
};
|
||||
}
|
||||
if (!DesktopPreferences.set("displays", next)) {
|
||||
@@ -637,6 +909,14 @@ Singleton {
|
||||
const previous = (root.pendingPreviousLayout || [])
|
||||
.filter(record => connected[record.name])
|
||||
.map(record => Object.assign({}, record));
|
||||
// A mirror whose target was unplugged has nothing left to copy, and a
|
||||
// rule pointing at a missing output is one the compositor ignores
|
||||
// silently. Give it back its own picture instead.
|
||||
for (const record of previous) {
|
||||
if (typeof record.mirrorOf === "string" && record.mirrorOf !== ""
|
||||
&& !connected[record.mirrorOf])
|
||||
record.mirrorOf = "";
|
||||
}
|
||||
if (previous.length > 0 && !previous.some(record => record.primary)) {
|
||||
const origin = previous.find(record => record.x === 0 && record.y === 0);
|
||||
(origin || previous[0]).primary = true;
|
||||
|
||||
@@ -161,6 +161,18 @@ Singleton {
|
||||
{ label: "Arrange displays", detail: "Drag connected displays into their physical positions", page: "displays" },
|
||||
{ label: "Monitor position", detail: "Set where each display sits in the desktop", page: "displays" },
|
||||
{ label: "Primary display", detail: "Choose the display that anchors the desktop", page: "displays" },
|
||||
{ label: "Resolution", detail: "The mode each display runs, with its refresh rate", page: "displays" },
|
||||
{ label: "Refresh rate", detail: "How many times a second the display redraws", page: "displays" },
|
||||
{ label: "Scale", detail: "How large everything is drawn on a display", page: "displays" },
|
||||
{ label: "Rotation", detail: "Turn a display a quarter, half, or three-quarter turn", page: "displays" },
|
||||
{ label: "HDR", detail: "High dynamic range for a display that supports it", page: "displays" },
|
||||
{ label: "Color profile", detail: "Automatic, sRGB, wide gamut, or HDR per display", page: "displays" },
|
||||
{ label: "Bit depth", detail: "8-bit or 10-bit colour, per display", page: "displays" },
|
||||
{ label: "10-bit", detail: "Deeper colour with less gradient banding", page: "displays" },
|
||||
{ label: "SDR brightness", detail: "How bright ordinary content sits inside HDR", page: "displays" },
|
||||
{ label: "Mirror displays", detail: "Show the same picture on a second display", page: "displays" },
|
||||
{ label: "Variable refresh rate", detail: "Override the gaming policy for one display", page: "displays" },
|
||||
{ label: "Monitor brightness", detail: "The monitor's own backlight, over DDC", page: "displays" },
|
||||
{ label: "Theme profiles", detail: "Switch between Moon, Moon Rose, Day, and saved themes", page: "appearance" },
|
||||
{ label: "Themes", detail: "Light and dark mode, and the theme for each", page: "appearance" },
|
||||
{ label: "Dark mode", detail: "Flip the desktop to your dark theme", page: "appearance" },
|
||||
|
||||
Reference in New Issue
Block a user