Rebuild Displays around the canvas, and let the transaction keep color
Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
@@ -80,6 +80,21 @@ and remote desktop. The allow-list in
|
|||||||
[`services/SystemSettings.qml`](config/dot/quickshell/services/SystemSettings.qml)
|
[`services/SystemSettings.qml`](config/dot/quickshell/services/SystemSettings.qml)
|
||||||
is what decides; anything not on it is a panel Panama owns itself.
|
is what decides; anything not on it is a panel Panama owns itself.
|
||||||
|
|
||||||
|
Displays is one of the panels it owns, and the only one where a wrong answer
|
||||||
|
can leave you unable to see well enough to undo it. So every change there is
|
||||||
|
applied as one complete layout, read back from the compositor, and reverted
|
||||||
|
after fifteen seconds unless you keep it — resolution, scale, rotation,
|
||||||
|
position and primary display, and now colour profile, bit depth, SDR
|
||||||
|
brightness and saturation, and mirroring with them. Two of those opt out of
|
||||||
|
part of that, for reasons rather than convenience. A per-display variable
|
||||||
|
refresh rate override is applied but never verified, because `hyprctl` reports
|
||||||
|
whether adaptive sync is live this instant rather than what was asked for. And
|
||||||
|
a mirrored display's position is not asserted at all: the compositor stacks it
|
||||||
|
on the display it mirrors and ignores the coordinates the rule carried, so
|
||||||
|
holding it to them would make Keep permanently unavailable. Monitor brightness
|
||||||
|
sits outside the transaction entirely — it is the panel's own backlight over
|
||||||
|
DDC, and the buttons on the bezel change it behind our back.
|
||||||
|
|
||||||
| Piece | What it is |
|
| Piece | What it is |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `config/dot/hypr/` | Compositor config. **Lua, not hyprlang** — see its README |
|
| `config/dot/hypr/` | Compositor config. **Lua, not hyprlang** — see its README |
|
||||||
|
|||||||
+112
-18
@@ -13,8 +13,9 @@
|
|||||||
-- than an HDR desktop, the desktop runs SDR at 10-bit and HDR is handed to
|
-- than an HDR desktop, the desktop runs SDR at 10-bit and HDR is handed to
|
||||||
-- fullscreen games only, via render.cm_auto_hdr in looks.lua.
|
-- fullscreen games only, via render.cm_auto_hdr in looks.lua.
|
||||||
--
|
--
|
||||||
-- To try full-time HDR anyway, set cm = "hdr" below (or override it in
|
-- To try full-time HDR anyway, set shipped_cm = "hdr" below (or pick HDR for
|
||||||
-- overrides.lua) and read the notes in that file first.
|
-- this display in Settings, which saves it as the display's colorProfile and
|
||||||
|
-- wins over the shipped value) and read the notes in overrides.lua first.
|
||||||
-- ─────────────────────────────────────────────────────────────────────────────
|
-- ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
local prefs = require("prefs")
|
local prefs = require("prefs")
|
||||||
@@ -23,12 +24,21 @@ local prefs = require("prefs")
|
|||||||
-- { ["DP-2"] = {
|
-- { ["DP-2"] = {
|
||||||
-- mode = "3840x2160@60", scale = 2, transform = 0,
|
-- mode = "3840x2160@60", scale = 2, transform = 0,
|
||||||
-- x = 0, y = 0, primary = true,
|
-- x = 0, y = 0, primary = true,
|
||||||
|
-- vrrMode = -1, colorProfile = "auto", bitdepth = 10,
|
||||||
|
-- sdrBrightness = 1, sdrSaturation = 1, mirrorOf = "",
|
||||||
-- } }
|
-- } }
|
||||||
--
|
--
|
||||||
-- Only mode, scale, and transform are read. Color management and bit depth
|
-- The fields past `primary` are optional: records written before they existed
|
||||||
-- stay here, because those are the settings with a documented reason attached
|
-- carry none of them, and the shipped values below stand instead. That is the
|
||||||
-- (see the header) rather than preferences, and a settings page has no way to
|
-- relationship in both directions -- what is written here is what a saved
|
||||||
-- explain the screencopy tradeoff at the moment you would be changing it.
|
-- record inherits, and what Settings saves is what overrides it, so the two
|
||||||
|
-- stop fighting over the same monitor rule.
|
||||||
|
--
|
||||||
|
-- Geometry and colour fail differently on purpose. A half-written position is
|
||||||
|
-- refused outright (below), because guessing one can strand an output where
|
||||||
|
-- nothing can reach it. An unreadable colour, VRR or mirror value is dropped
|
||||||
|
-- on its own and the shipped default stands: the worst it costs is a wrong
|
||||||
|
-- shade, and taking the whole record down with it would cost the arrangement.
|
||||||
local displays = prefs.get("displays", {})
|
local displays = prefs.get("displays", {})
|
||||||
if type(displays) ~= "table" then
|
if type(displays) ~= "table" then
|
||||||
displays = {}
|
displays = {}
|
||||||
@@ -125,19 +135,102 @@ local function display_position(entry, fallback)
|
|||||||
return fallback
|
return fallback
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local color_profiles = { auto = true, srgb = true, wide = true, hdr = true }
|
||||||
|
|
||||||
|
local function color_profile(entry, fallback)
|
||||||
|
local value = entry ~= nil and entry.colorProfile or nil
|
||||||
|
if type(value) == "string" and color_profiles[value] then
|
||||||
|
return value
|
||||||
|
end
|
||||||
|
return fallback
|
||||||
|
end
|
||||||
|
|
||||||
|
local function bitdepth_value(entry, fallback)
|
||||||
|
local value = entry ~= nil and entry.bitdepth or nil
|
||||||
|
if value == 8 or value == 10 then
|
||||||
|
return value
|
||||||
|
end
|
||||||
|
return fallback
|
||||||
|
end
|
||||||
|
|
||||||
|
-- -1 means the display follows the global misc.vrr policy, which is said by
|
||||||
|
-- leaving the key out. 3 is the global policy's own value and not a per-display
|
||||||
|
-- choice, so it is not accepted here either.
|
||||||
|
local function vrr_value(entry)
|
||||||
|
local value = entry ~= nil and entry.vrrMode or nil
|
||||||
|
if type(value) ~= "number" or value ~= math.floor(value)
|
||||||
|
or value < 0 or value > 2 then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
return value
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Neutral is 1.0, and the neutral value is left out rather than written: a rule
|
||||||
|
-- that names it pins the display to it, which is not the same as leaving the
|
||||||
|
-- trim alone.
|
||||||
|
local function sdr_value(value, minimum, maximum)
|
||||||
|
if type(value) ~= "number" or value ~= value
|
||||||
|
or value < minimum or value > maximum
|
||||||
|
or math.abs(value - 1) < 0.001 then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
return value
|
||||||
|
end
|
||||||
|
|
||||||
|
-- A mirror needs a target that is not itself and not another mirror -- Hyprland
|
||||||
|
-- has no chain to follow -- and the primary may not mirror at all, since the
|
||||||
|
-- arrangement is anchored on it.
|
||||||
|
local function mirror_value(entry, output)
|
||||||
|
local value = entry ~= nil and entry.mirrorOf or nil
|
||||||
|
if type(value) ~= "string" or value == "" or value == output
|
||||||
|
or value:match("^[%w_.-]+$") == nil
|
||||||
|
or entry.primary == true then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local target = displays[value]
|
||||||
|
if type(target) == "table" and type(target.mirrorOf) == "string"
|
||||||
|
and target.mirrorOf ~= "" then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
return value
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Colour, VRR and mirroring layered onto a rule that already carries
|
||||||
|
-- mode/position/scale/transform. A monitor rule replaces the previous rule for
|
||||||
|
-- that output whole, so the shipped defaults are passed in here rather than
|
||||||
|
-- written in a rule of their own. `connector` is the output name the record was
|
||||||
|
-- saved under, which is not always the rule's own output: the Kuycon rule
|
||||||
|
-- matches by description.
|
||||||
|
local function with_display_fields(rule, entry, connector, default_bitdepth, default_cm)
|
||||||
|
rule.bitdepth = bitdepth_value(entry, default_bitdepth)
|
||||||
|
rule.cm = color_profile(entry, default_cm)
|
||||||
|
rule.vrr = vrr_value(entry)
|
||||||
|
rule.sdrbrightness = entry ~= nil and sdr_value(entry.sdrBrightness, 0.8, 2.0) or nil
|
||||||
|
rule.sdrsaturation = entry ~= nil and sdr_value(entry.sdrSaturation, 0.8, 1.2) or nil
|
||||||
|
|
||||||
|
-- A mirror shows its target's picture in its target's place, so the saved
|
||||||
|
-- position is not the compositor's to honour or ours to ask for.
|
||||||
|
local mirror = mirror_value(entry, connector)
|
||||||
|
if mirror ~= nil then
|
||||||
|
rule.mirror = mirror
|
||||||
|
rule.position = "auto"
|
||||||
|
end
|
||||||
|
return rule
|
||||||
|
end
|
||||||
|
|
||||||
-- Every connected output uses the same validated per-output store. Automatic
|
-- Every connected output uses the same validated per-output store. Automatic
|
||||||
-- placement and the compositor's normal color policy unless the entry says
|
-- placement and the compositor's normal color policy unless the entry says
|
||||||
-- otherwise.
|
-- otherwise.
|
||||||
for output, _ in pairs(displays) do
|
for output, _ in pairs(displays) do
|
||||||
local entry = display_entry(output)
|
local entry = display_entry(output)
|
||||||
if entry ~= nil then
|
if entry ~= nil then
|
||||||
hl.monitor({
|
hl.monitor(with_display_fields({
|
||||||
output = output,
|
output = output,
|
||||||
mode = entry.mode,
|
mode = entry.mode,
|
||||||
position = display_position(entry, "auto"),
|
position = display_position(entry, "auto"),
|
||||||
scale = entry.scale,
|
scale = entry.scale,
|
||||||
transform = entry.transform,
|
transform = entry.transform,
|
||||||
})
|
}, entry, output, nil, nil))
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -150,22 +243,23 @@ end
|
|||||||
local shipped_mode = "4500x3000@60"
|
local shipped_mode = "4500x3000@60"
|
||||||
local shipped_scale = 1.5
|
local shipped_scale = 1.5
|
||||||
local shipped_transform = 0
|
local shipped_transform = 0
|
||||||
|
|
||||||
|
-- 10-bit output. 4500x3000@60 at 10bpc is ~24 Gbps, right at the edge of DP 1.4
|
||||||
|
-- HBR3, so this relies on DSC. If the display fails to light up or falls back to
|
||||||
|
-- a lower mode, drop this to 8 first.
|
||||||
|
local shipped_bitdepth = 10
|
||||||
|
|
||||||
|
-- "auto" = sRGB at 8bpc, wide gamut at 10bpc. Not HDR; see header.
|
||||||
|
local shipped_cm = "auto"
|
||||||
|
|
||||||
local kuycon = display_entry("DP-2")
|
local kuycon = display_entry("DP-2")
|
||||||
hl.monitor({
|
hl.monitor(with_display_fields({
|
||||||
output = "desc:GVT Kuycon P20",
|
output = "desc:GVT Kuycon P20",
|
||||||
mode = kuycon and kuycon.mode or shipped_mode,
|
mode = kuycon and kuycon.mode or shipped_mode,
|
||||||
position = display_position(kuycon, "0x0"),
|
position = display_position(kuycon, "0x0"),
|
||||||
scale = kuycon and kuycon.scale or shipped_scale,
|
scale = kuycon and kuycon.scale or shipped_scale,
|
||||||
transform = kuycon and kuycon.transform or shipped_transform,
|
transform = kuycon and kuycon.transform or shipped_transform,
|
||||||
|
}, kuycon, "DP-2", shipped_bitdepth, shipped_cm))
|
||||||
-- 10-bit output. 4500x3000@60 at 10bpc is ~24 Gbps, right at the edge of
|
|
||||||
-- DP 1.4 HBR3, so this relies on DSC. If the display fails to light up or
|
|
||||||
-- falls back to a lower mode, drop this line first.
|
|
||||||
bitdepth = 10,
|
|
||||||
|
|
||||||
-- "auto" = sRGB at 8bpc, wide gamut at 10bpc. Not HDR; see header.
|
|
||||||
cm = "auto",
|
|
||||||
})
|
|
||||||
|
|
||||||
-- Any monitor not named above: sane defaults rather than nothing.
|
-- Any monitor not named above: sane defaults rather than nothing.
|
||||||
hl.monitor({
|
hl.monitor({
|
||||||
|
|||||||
@@ -1583,16 +1583,16 @@ Singleton {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// ── Display configuration ───────────────────────────────────────────
|
// ── Display configuration ───────────────────────────────────────────
|
||||||
// { "<output>": { mode, scale, transform, x, y, primary } }, applied by
|
// { "<output>": { mode, scale, transform, x, y, primary, vrrMode,
|
||||||
// hypr/monitors.lua on top of the shipped values. Color management and
|
// colorProfile, bitdepth, sdrBrightness, sdrSaturation, mirrorOf } },
|
||||||
// bit depth are deliberately not here: those carry a documented
|
// applied by hypr/monitors.lua on top of the shipped values. Everything
|
||||||
// screencopy tradeoff that a settings page cannot explain at the moment
|
// past `primary` is optional, so records written before those fields
|
||||||
// you would be changing it.
|
// existed still load and the shipped defaults stand for them.
|
||||||
{
|
{
|
||||||
key: "displays", type: "json", def: ({}), group: "display",
|
key: "displays", type: "json", def: ({}), group: "display",
|
||||||
internal: true,
|
internal: true,
|
||||||
label: "Display configuration",
|
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 ──────────────────────────────
|
// ── 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 identify(): void { Displays.identify(); }
|
||||||
function identifying(): bool { return Displays.identifying; }
|
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 }
|
{ 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 {
|
IpcHandler {
|
||||||
target: "display-layout-test"
|
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 {
|
function status(): string {
|
||||||
const normalized = DisplayLayout.normalize(fixture);
|
const normalized = DisplayLayout.normalize(fixture);
|
||||||
const canvas = DisplayLayout.canvasRects(normalized, 800, 500, 20);
|
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 {
|
function expireApplyVerification(): void {
|
||||||
Displays.verificationTimedOut();
|
Displays.verificationTimedOut();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,10 +28,13 @@ to load. `Hyprland --verify-config` says why without touching your session.
|
|||||||
|
|
||||||
## The screen resolution is wrong
|
## The screen resolution is wrong
|
||||||
|
|
||||||
Settings has a Displays page. Every change there reverts itself after fifteen
|
Settings has a Displays page. It opens on a picture of what is connected —
|
||||||
seconds unless you confirm it, so a mode your monitor cannot show cannot
|
one display or several — and everything under that picture belongs to
|
||||||
strand you. If you are already stranded, `hyprctl monitors` from a terminal
|
whichever one you have selected. Every change there reverts itself after
|
||||||
shows what is applied.
|
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
|
## 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 QtQuick
|
||||||
import qs.config
|
import qs.config
|
||||||
import qs.widgets
|
import qs.widgets
|
||||||
@@ -17,6 +30,34 @@ Item {
|
|||||||
readonly property var canvasData: DisplayLayout.canvasRects(
|
readonly property var canvasData: DisplayLayout.canvasRects(
|
||||||
root.draftLayout, canvas.width, canvas.height, 18)
|
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 {
|
function copied(layout): var {
|
||||||
return (layout || []).map(record => Object.assign({}, record));
|
return (layout || []).map(record => Object.assign({}, record));
|
||||||
}
|
}
|
||||||
@@ -55,10 +96,13 @@ Item {
|
|||||||
return root.applyDraft();
|
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 {
|
function canvasSnapshot(): var {
|
||||||
return {
|
return {
|
||||||
bounds: root.canvasData.bounds,
|
bounds: root.canvasData.bounds,
|
||||||
scale: root.canvasData.scale,
|
scale: root.canvasData.scale,
|
||||||
|
draggable: root.draggable,
|
||||||
rects: root.canvasData.rects.map(record => Object.assign({}, record))
|
rects: root.canvasData.rects.map(record => Object.assign({}, record))
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -82,28 +126,79 @@ Item {
|
|||||||
Rectangle {
|
Rectangle {
|
||||||
id: canvas
|
id: canvas
|
||||||
width: parent.width
|
width: parent.width
|
||||||
height: root.width >= 620 ? 232 : 190
|
height: root.width >= 620 ? 250 : 200
|
||||||
radius: Theme.cardRadius
|
radius: Theme.cardRadius + 2
|
||||||
color: Theme.alpha(Theme.bgDark, 0.76)
|
color: Theme.alpha(Theme.bgDark, 0.76)
|
||||||
border.width: 1
|
border.width: 1
|
||||||
border.color: Theme.alpha(Theme.fg, 0.07)
|
border.color: Theme.alpha(Theme.fg, 0.07)
|
||||||
clip: true
|
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
|
// A restrained coordinate field makes the topology feel like a
|
||||||
// precision instrument without turning it into a technical graph.
|
// precision instrument without turning it into a technical graph.
|
||||||
Repeater {
|
Repeater {
|
||||||
model: 4
|
model: Math.max(1, Math.ceil(canvas.height / 44))
|
||||||
Rectangle {
|
Rectangle {
|
||||||
required property int index
|
required property int index
|
||||||
y: (index + 1) * canvas.height / 5
|
y: (index + 1) * 44
|
||||||
width: canvas.width
|
width: canvas.width
|
||||||
height: 1
|
height: 1
|
||||||
color: Theme.alpha(Theme.fg, 0.025)
|
color: Theme.alpha(Theme.fg, 0.03)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Repeater {
|
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 {
|
Rectangle {
|
||||||
id: tile
|
id: tile
|
||||||
@@ -113,26 +208,44 @@ Item {
|
|||||||
readonly property var draft: root.draftLayout.find(
|
readonly property var draft: root.draftLayout.find(
|
||||||
record => record.name === modelData.name)
|
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
|
x: modelData.x
|
||||||
y: modelData.y
|
y: modelData.y
|
||||||
|
z: tile.mirrored ? 1 : 0
|
||||||
width: Math.max(64, modelData.width)
|
width: Math.max(64, modelData.width)
|
||||||
height: Math.max(48, modelData.height)
|
height: Math.max(48, modelData.height)
|
||||||
radius: 11
|
radius: 11
|
||||||
color: tile.selected
|
color: tile.selected
|
||||||
? Theme.alpha(Theme.bgHighlight, 0.92)
|
? 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.width: tile.selected || activeFocus ? 2 : 1
|
||||||
border.color: activeFocus
|
border.color: activeFocus
|
||||||
? Theme.accentSecondary
|
? 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
|
opacity: root.interactionEnabled ? 1 : 0.5
|
||||||
activeFocusOnTab: root.interactionEnabled
|
activeFocusOnTab: root.interactionEnabled
|
||||||
|
|
||||||
Accessible.role: Accessible.Button
|
Accessible.role: Accessible.Button
|
||||||
Accessible.name: "Move " + tile.modelData.name
|
Accessible.name: "Move " + tile.modelData.name
|
||||||
Accessible.description: tile.draft && tile.draft.primary
|
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."
|
? "Primary display. Drag or use the arrow keys to move it."
|
||||||
: "Drag or use the arrow keys to move this display."
|
: "Drag or use the arrow keys to move this display.";
|
||||||
|
}
|
||||||
|
|
||||||
Rectangle {
|
Rectangle {
|
||||||
anchors.fill: parent
|
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 {
|
Column {
|
||||||
anchors.left: parent.left
|
anchors.left: parent.left
|
||||||
anchors.right: parent.right
|
anchors.right: parent.right
|
||||||
@@ -165,9 +293,13 @@ Item {
|
|||||||
}
|
}
|
||||||
Text {
|
Text {
|
||||||
width: parent.width
|
width: parent.width
|
||||||
text: tile.draft
|
text: {
|
||||||
? `${Math.round(tile.draft.width / tile.draft.scale)} × ${Math.round(tile.draft.height / tile.draft.scale)}`
|
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
|
color: Theme.fgDim
|
||||||
font.family: Theme.fontFamily
|
font.family: Theme.fontFamily
|
||||||
font.features: Theme.tabularFigures
|
font.features: Theme.tabularFigures
|
||||||
@@ -176,21 +308,38 @@ Item {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Rectangle {
|
Text {
|
||||||
anchors.top: parent.top
|
anchors.top: parent.top
|
||||||
anchors.right: parent.right
|
anchors.right: parent.right
|
||||||
anchors.margins: 7
|
anchors.topMargin: 7
|
||||||
width: primaryText.implicitWidth + 12
|
anchors.rightMargin: 9
|
||||||
height: 20
|
|
||||||
radius: Theme.pillRadius
|
|
||||||
visible: tile.draft && tile.draft.primary
|
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 {
|
Text {
|
||||||
id: primaryText
|
id: mirrorLabel
|
||||||
anchors.centerIn: parent
|
anchors.centerIn: parent
|
||||||
text: "Primary"
|
text: "Mirrors " + tile.mirrorOf
|
||||||
color: Theme.accentAlt
|
color: Theme.cyan
|
||||||
font.family: Theme.fontFamily
|
font.family: Theme.fontFamily
|
||||||
font.pixelSize: Math.max(9, Theme.fontSizeSmall - 1)
|
font.pixelSize: Math.max(9, Theme.fontSizeSmall - 1)
|
||||||
font.weight: Font.DemiBold
|
font.weight: Font.DemiBold
|
||||||
@@ -200,7 +349,7 @@ Item {
|
|||||||
HoverHandler {
|
HoverHandler {
|
||||||
id: hover
|
id: hover
|
||||||
enabled: root.interactionEnabled
|
enabled: root.interactionEnabled
|
||||||
cursorShape: Qt.OpenHandCursor
|
cursorShape: tile.tileDraggable ? Qt.OpenHandCursor : Qt.PointingHandCursor
|
||||||
}
|
}
|
||||||
|
|
||||||
TapHandler {
|
TapHandler {
|
||||||
@@ -214,7 +363,7 @@ Item {
|
|||||||
DragHandler {
|
DragHandler {
|
||||||
id: drag
|
id: drag
|
||||||
target: null
|
target: null
|
||||||
enabled: root.interactionEnabled
|
enabled: tile.tileDraggable
|
||||||
property real initialX: 0
|
property real initialX: 0
|
||||||
property real initialY: 0
|
property real initialY: 0
|
||||||
property bool moved: false
|
property bool moved: false
|
||||||
@@ -250,7 +399,7 @@ Item {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Keys.onPressed: event => {
|
Keys.onPressed: event => {
|
||||||
if (!root.interactionEnabled)
|
if (!tile.tileDraggable)
|
||||||
return;
|
return;
|
||||||
const step = event.modifiers & Qt.ShiftModifier ? 100 : 10;
|
const step = event.modifiers & Qt.ShiftModifier ? 100 : 10;
|
||||||
let handled = true;
|
let handled = true;
|
||||||
@@ -278,19 +427,6 @@ Item {
|
|||||||
width: parent.width
|
width: parent.width
|
||||||
spacing: 8
|
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 {
|
SettingsButton {
|
||||||
id: identifyButton
|
id: identifyButton
|
||||||
text: "Identify"
|
text: "Identify"
|
||||||
@@ -301,17 +437,21 @@ Item {
|
|||||||
SettingsButton {
|
SettingsButton {
|
||||||
id: primaryButton
|
id: primaryButton
|
||||||
text: "Make primary"
|
text: "Make primary"
|
||||||
enabled: root.interactionEnabled && root.selectedOutput !== ""
|
enabled: root.interactionEnabled && !root.solo && root.selectedOutput !== ""
|
||||||
&& !root.draftLayout.find(record =>
|
&& !root.draftLayout.find(record =>
|
||||||
record.name === root.selectedOutput)?.primary
|
record.name === root.selectedOutput)?.primary
|
||||||
onClicked: root.makePrimary(root.selectedOutput)
|
onClicked: root.makePrimary(root.selectedOutput)
|
||||||
}
|
}
|
||||||
|
|
||||||
SettingsButton {
|
Text {
|
||||||
id: applyButton
|
width: Math.max(0, parent.width - identifyButton.width - primaryButton.width - 16)
|
||||||
text: "Apply"
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
enabled: root.interactionEnabled
|
horizontalAlignment: Text.AlignRight
|
||||||
onClicked: root.applyDraft()
|
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.
|
// The resolution list for one display.
|
||||||
//
|
//
|
||||||
// Grouped by resolution with refresh rates beside it, rather than a flat list
|
// One line per resolution, not per mode: this panel reports 35 modes, most of
|
||||||
// of "[email protected]" strings: this panel reports 35 modes, many of which
|
// which differ only in refresh-rate rounding, and a flat list of
|
||||||
// differ only in refresh-rate rounding, and a flat list of those is a wall of
|
// "[email protected]" strings is a wall of near-identical text rather than a
|
||||||
// near-identical text rather than a choice.
|
// 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 QtQuick
|
||||||
import qs.config
|
import qs.config
|
||||||
import qs.services
|
|
||||||
|
|
||||||
Column {
|
Column {
|
||||||
id: root
|
id: root
|
||||||
@@ -15,9 +23,10 @@ Column {
|
|||||||
property var monitor: null
|
property var monitor: null
|
||||||
property bool enabled: true
|
property bool enabled: true
|
||||||
|
|
||||||
// Emitted once a mode has been asked for, so a container can put the list
|
// The mode string for the resolution that was chosen, at the rate closest
|
||||||
// away. Applying is still this component's job; closing is not.
|
// to the one in use.
|
||||||
signal picked
|
signal picked(string mode)
|
||||||
|
|
||||||
spacing: 0
|
spacing: 0
|
||||||
|
|
||||||
readonly property var grouped: {
|
readonly property var grouped: {
|
||||||
@@ -36,6 +45,44 @@ Column {
|
|||||||
return order.map(key => buckets[key]);
|
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 {
|
Repeater {
|
||||||
model: root.grouped
|
model: root.grouped
|
||||||
|
|
||||||
@@ -45,79 +92,66 @@ Column {
|
|||||||
required property var modelData
|
required property var modelData
|
||||||
required property int index
|
required property int index
|
||||||
|
|
||||||
readonly property bool isCurrent: root.monitor
|
readonly property bool isCurrent: !!root.monitor
|
||||||
&& root.monitor.width === resolution.modelData.width
|
&& root.monitor.width === resolution.modelData.width
|
||||||
&& root.monitor.height === resolution.modelData.height
|
&& 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
|
label: resolution.modelData.label
|
||||||
detail: resolution.isCurrent ? "Current resolution" : ""
|
controlWidth: 170
|
||||||
controlWidth: Math.max(120, resolution.modelData.rates.length * 84)
|
|
||||||
divider: resolution.index < root.grouped.length - 1
|
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 {
|
Row {
|
||||||
anchors.right: parent.right
|
anchors.right: parent.right
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
spacing: 6
|
spacing: 10
|
||||||
|
|
||||||
Repeater {
|
|
||||||
model: resolution.modelData.rates
|
|
||||||
|
|
||||||
Rectangle {
|
|
||||||
id: rate
|
|
||||||
|
|
||||||
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 {
|
Text {
|
||||||
id: rateCaption
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
anchors.centerIn: parent
|
text: root.aspectLabel(resolution.modelData.width, resolution.modelData.height)
|
||||||
text: rate.modelData.refreshLabel
|
color: Theme.fgMuted
|
||||||
color: rate.selected ? Theme.fg : Theme.fgDim
|
|
||||||
font.family: Theme.fontFamily
|
font.family: Theme.fontFamily
|
||||||
font.features: Theme.tabularFigures
|
font.features: Theme.tabularFigures
|
||||||
font.pixelSize: Theme.fontSizeSmall
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
font.weight: rate.selected ? Font.DemiBold : Font.Normal
|
|
||||||
}
|
}
|
||||||
|
|
||||||
HoverHandler {
|
Rectangle {
|
||||||
id: rateHover
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
enabled: root.enabled
|
visible: resolution.tag !== ""
|
||||||
cursorShape: Qt.PointingHandCursor
|
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)
|
||||||
|
|
||||||
TapHandler {
|
Text {
|
||||||
enabled: root.enabled && !rate.selected
|
id: tagLabel
|
||||||
onTapped: {
|
anchors.centerIn: parent
|
||||||
Displays.apply(
|
text: resolution.tag
|
||||||
root.monitor.name,
|
color: resolution.isCurrent ? Theme.accentAlt : Theme.fgDim
|
||||||
rate.modelData.mode,
|
font.family: Theme.fontFamily
|
||||||
Displays.nearestCleanScale(rate.modelData.mode, root.monitor.scale),
|
font.pixelSize: Math.max(9, Theme.fontSizeSmall - 1)
|
||||||
root.monitor.transform);
|
font.weight: Font.DemiBold
|
||||||
root.picked();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
// Displays.
|
||||||
//
|
//
|
||||||
// Resolution, refresh rate, scale, and rotation, plus panel brightness and the
|
// The arrangement canvas is the page. Everything under it belongs to the
|
||||||
// gaming display policy that was already here.
|
// 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
|
// Every per-display change goes through one funnel, applyWith, and therefore
|
||||||
// the one page where a wrong value can leave the screen unreadable or blank,
|
// through the apply-then-confirm countdown. This is the one page where a wrong
|
||||||
// and no other control in the app can undo it once that happens. Confirming is
|
// value can leave the screen unreadable or blank, and no other control in the
|
||||||
// what writes the choice to the settings store; letting the countdown run
|
// app can undo it once that happens. Confirming is what writes the choice to
|
||||||
// leaves nothing behind.
|
// 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 QtQuick
|
||||||
import qs.config
|
import qs.config
|
||||||
import qs.services
|
import qs.services
|
||||||
import qs.modules.quicksettings
|
|
||||||
import qs.widgets
|
import qs.widgets
|
||||||
|
|
||||||
SettingsPage {
|
SettingsPage {
|
||||||
id: root
|
id: root
|
||||||
|
|
||||||
title: "Displays"
|
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: ""
|
property string selectedOutput: ""
|
||||||
readonly property var monitor: Displays.monitorNamed(root.selectedOutput)
|
readonly property var monitor: Displays.monitorNamed(root.selectedOutput)
|
||||||
@@ -28,6 +34,18 @@ SettingsPage {
|
|||||||
? root.monitor.mode
|
? 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
|
// 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.
|
// actually is: the same width and height at a different rate.
|
||||||
readonly property var ratesForCurrentResolution: {
|
readonly property var ratesForCurrentResolution: {
|
||||||
@@ -37,6 +55,71 @@ SettingsPage {
|
|||||||
&& mode.height === root.monitor.height);
|
&& 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 {
|
function syncSelectedOutput(): void {
|
||||||
if (!Displays.monitorNamed(root.selectedOutput))
|
if (!Displays.monitorNamed(root.selectedOutput))
|
||||||
root.selectedOutput = Displays.primaryFirstMonitors.length > 0
|
root.selectedOutput = Displays.primaryFirstMonitors.length > 0
|
||||||
@@ -56,13 +139,15 @@ SettingsPage {
|
|||||||
function onMonitorsChanged(): void { root.syncSelectedOutput(); }
|
function onMonitorsChanged(): void { root.syncSelectedOutput(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
// The confirmation sits above everything, because while it is counting down
|
// The confirmation sits above everything, pinned outside the scrolling
|
||||||
// it is the only thing that matters on this page.
|
// 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 {
|
header: Component {
|
||||||
Rectangle {
|
Rectangle {
|
||||||
visible: Displays.awaitingConfirmation
|
visible: Displays.awaitingConfirmation
|
||||||
implicitHeight: visible ? confirmRow.implicitHeight + 28 : 0
|
implicitHeight: visible ? Math.max(44, confirmRow.implicitHeight) + 26 : 0
|
||||||
radius: Theme.cardRadius
|
radius: Theme.cardRadius + 2
|
||||||
color: Theme.mix(Theme.bgPanel, Theme.warn, 0.12)
|
color: Theme.mix(Theme.bgPanel, Theme.warn, 0.12)
|
||||||
border.width: 1
|
border.width: 1
|
||||||
border.color: Theme.alpha(Theme.warn, 0.4)
|
border.color: Theme.alpha(Theme.warn, 0.4)
|
||||||
@@ -75,14 +160,21 @@ SettingsPage {
|
|||||||
anchors.margins: 16
|
anchors.margins: 16
|
||||||
spacing: 14
|
spacing: 14
|
||||||
|
|
||||||
|
CountdownRing {
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
secondsLeft: Displays.secondsLeft
|
||||||
|
totalSeconds: Displays.confirmSeconds
|
||||||
|
}
|
||||||
|
|
||||||
Column {
|
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
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
spacing: 3
|
spacing: 3
|
||||||
|
|
||||||
Text {
|
Text {
|
||||||
width: parent.width
|
width: parent.width
|
||||||
text: "Keep this display setting?"
|
text: "Keep these display settings?"
|
||||||
color: Theme.fg
|
color: Theme.fg
|
||||||
font.family: Theme.fontFamily
|
font.family: Theme.fontFamily
|
||||||
font.pixelSize: Theme.fontSize
|
font.pixelSize: Theme.fontSize
|
||||||
@@ -90,8 +182,11 @@ SettingsPage {
|
|||||||
}
|
}
|
||||||
Text {
|
Text {
|
||||||
width: parent.width
|
width: parent.width
|
||||||
text: "Reverting in " + Displays.secondsLeft + (Displays.secondsLeft === 1 ? " second" : " seconds")
|
text: Displays.canConfirm
|
||||||
|
? "Reverting in " + Displays.secondsLeft
|
||||||
|
+ (Displays.secondsLeft === 1 ? " second" : " seconds")
|
||||||
+ " if you do nothing. If you cannot read this, just wait."
|
+ " 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
|
color: Theme.fgDim
|
||||||
font.family: Theme.fontFamily
|
font.family: Theme.fontFamily
|
||||||
font.features: Theme.tabularFigures
|
font.features: Theme.tabularFigures
|
||||||
@@ -110,6 +205,7 @@ SettingsPage {
|
|||||||
id: keepButton
|
id: keepButton
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
text: "Keep"
|
text: "Keep"
|
||||||
|
tone: "accent"
|
||||||
enabled: Displays.canConfirm
|
enabled: Displays.canConfirm
|
||||||
onClicked: Displays.confirm()
|
onClicked: Displays.confirm()
|
||||||
}
|
}
|
||||||
@@ -117,11 +213,7 @@ SettingsPage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
SettingsCard {
|
// ── The canvas ──────────────────────────────────────────────────────────
|
||||||
visible: Displays.monitors.length > 1
|
|
||||||
title: "Arrange displays"
|
|
||||||
subtitle: "Drag the screens into place. The primary display anchors the desktop at 0,0."
|
|
||||||
|
|
||||||
DisplayArrangement {
|
DisplayArrangement {
|
||||||
width: parent.width
|
width: parent.width
|
||||||
displayService: Displays
|
displayService: Displays
|
||||||
@@ -129,148 +221,317 @@ SettingsPage {
|
|||||||
interactionEnabled: !Displays.awaitingConfirmation && !Displays.busy
|
interactionEnabled: !Displays.awaitingConfirmation && !Displays.busy
|
||||||
onSelectionRequested: output => root.selectedOutput = output
|
onSelectionRequested: output => root.selectedOutput = output
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
SettingsCard {
|
DisplayChips {
|
||||||
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
|
width: parent.width
|
||||||
label: "Display"
|
|
||||||
options: Displays.primaryFirstMonitors.map(monitor => ({
|
options: Displays.primaryFirstMonitors.map(monitor => ({
|
||||||
value: monitor.name,
|
value: monitor.name,
|
||||||
label: monitor.description || 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 : ""
|
current: root.monitor ? root.monitor.name : ""
|
||||||
enabled: !Displays.awaitingConfirmation && !Displays.busy
|
enabled: !Displays.awaitingConfirmation && !Displays.busy
|
||||||
divider: false
|
|
||||||
onPicked: value => root.selectedOutput = value
|
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// ── The selected display ────────────────────────────────────────────────
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
visible: root.monitor !== null
|
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 {
|
PickerRow {
|
||||||
id: modePicker
|
id: modePicker
|
||||||
|
|
||||||
label: "Resolution"
|
label: "Resolution"
|
||||||
detail: root.monitor
|
detail: "The picture is sharpest at the resolution the panel was built for"
|
||||||
? root.monitor.width + " × " + root.monitor.height + " native"
|
|
||||||
: "No display selected"
|
|
||||||
value: root.monitor
|
value: root.monitor
|
||||||
? root.monitor.width + " × " + root.monitor.height
|
? root.monitor.width + " × " + root.monitor.height
|
||||||
: ""
|
: ""
|
||||||
enabled: !Displays.awaitingConfirmation && !Displays.busy
|
enabled: !Displays.awaitingConfirmation && !Displays.busy
|
||||||
divider: false
|
|
||||||
|
|
||||||
DisplayModePicker {
|
DisplayModePicker {
|
||||||
width: parent.width
|
width: parent.width
|
||||||
monitor: root.monitor
|
monitor: root.monitor
|
||||||
enabled: !Displays.awaitingConfirmation && !Displays.busy
|
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
|
// Refresh rate on its own, because the rates for a resolution used to be
|
||||||
// reachable only by opening the resolution list -- which is now
|
// reachable only by opening the resolution list -- so changing only the
|
||||||
// collapsed, so changing only the rate meant going through the mode you
|
// rate meant going back through the resolution you already had.
|
||||||
// already had.
|
SettingRow {
|
||||||
PickerRow {
|
|
||||||
id: ratePicker
|
|
||||||
|
|
||||||
label: "Refresh rate"
|
label: "Refresh rate"
|
||||||
detail: "Rates this display offers at " + (root.monitor
|
detail: root.monitor
|
||||||
? root.monitor.width + " × " + root.monitor.height
|
? "Rates this panel offers at " + root.monitor.width + " × " + root.monitor.height
|
||||||
: "the current resolution")
|
: ""
|
||||||
value: root.monitor ? root.monitor.refreshRate.toFixed(2) + " Hz" : ""
|
|
||||||
visible: root.ratesForCurrentResolution.length > 1
|
visible: root.ratesForCurrentResolution.length > 1
|
||||||
enabled: !Displays.awaitingConfirmation && !Displays.busy
|
controlWidth: Math.max(150, root.ratesForCurrentResolution.length * 76)
|
||||||
divider: false
|
|
||||||
|
Row {
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
spacing: 6
|
||||||
|
opacity: !Displays.awaitingConfirmation && !Displays.busy ? 1 : 0.45
|
||||||
|
|
||||||
Repeater {
|
Repeater {
|
||||||
model: root.ratesForCurrentResolution
|
model: root.ratesForCurrentResolution
|
||||||
|
|
||||||
delegate: TextRow {
|
Rectangle {
|
||||||
|
id: rate
|
||||||
|
|
||||||
required property var modelData
|
required property var modelData
|
||||||
required property int index
|
|
||||||
width: parent.width
|
readonly property bool selected: Displays.modeIsCurrent(root.monitor, rate.modelData)
|
||||||
label: String(modelData.refreshLabel ?? "")
|
|
||||||
value: Displays.modeIsCurrent(root.monitor, modelData) ? "Current" : ""
|
implicitWidth: Math.max(70, rateCaption.implicitWidth + 22)
|
||||||
controlWidth: 90
|
implicitHeight: 30
|
||||||
divider: index < root.ratesForCurrentResolution.length - 1
|
radius: 9
|
||||||
activatable: !Displays.modeIsCurrent(root.monitor, modelData)
|
color: rate.selected
|
||||||
onActivated: {
|
? "transparent"
|
||||||
root.applyWith({ mode: modelData.mode });
|
: Theme.alpha(Theme.fg, rateHover.hovered ? 0.11 : 0.06)
|
||||||
ratePicker.collapse();
|
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) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
SettingsCard {
|
Text {
|
||||||
visible: root.monitor !== null
|
id: rateCaption
|
||||||
title: "Scale and rotation"
|
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
|
||||||
|
}
|
||||||
|
|
||||||
ChoiceGrid {
|
HoverHandler {
|
||||||
width: parent.width
|
id: rateHover
|
||||||
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."
|
|
||||||
options: Displays.scalesForMode(root.currentMode)
|
|
||||||
.map(scale => ({ value: scale, label: scale.toFixed(2) + "×" }))
|
|
||||||
current: root.monitor ? root.monitor.scale : 1
|
|
||||||
enabled: !Displays.awaitingConfirmation && !Displays.busy
|
enabled: !Displays.awaitingConfirmation && !Displays.busy
|
||||||
onPicked: value => root.applyWith({ scale: value })
|
cursorShape: Qt.PointingHandCursor
|
||||||
}
|
}
|
||||||
|
|
||||||
ChoiceGrid {
|
TapHandler {
|
||||||
width: parent.width
|
enabled: !Displays.awaitingConfirmation && !Displays.busy && !rate.selected
|
||||||
|
onTapped: root.applyWith({ mode: rate.modelData.mode })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SegmentRow {
|
||||||
|
label: "Scale"
|
||||||
|
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: Math.round(scale * 100) + "%" }))
|
||||||
|
value: root.monitor ? root.monitor.scale : 1
|
||||||
|
enabled: !Displays.awaitingConfirmation && !Displays.busy
|
||||||
|
onSelected: value => root.applyWith({ scale: value })
|
||||||
|
}
|
||||||
|
|
||||||
|
RotationRow {
|
||||||
label: "Rotation"
|
label: "Rotation"
|
||||||
options: Displays.transforms
|
options: Displays.transforms
|
||||||
current: root.monitor ? root.monitor.transform : 0
|
value: root.monitor ? root.monitor.transform : 0
|
||||||
enabled: !Displays.awaitingConfirmation && !Displays.busy
|
enabled: !Displays.awaitingConfirmation && !Displays.busy
|
||||||
divider: false
|
onSelected: value => root.applyWith({ transform: value })
|
||||||
onPicked: 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({ mirrorOf: value })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Color ───────────────────────────────────────────────────────────────
|
||||||
SettingsCard {
|
SettingsCard {
|
||||||
|
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."
|
||||||
|
|
||||||
|
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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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"
|
title: "Night Light"
|
||||||
subtitle: NightLight.active
|
subtitle: NightLight.active
|
||||||
? "On now, warming the display to reduce blue light."
|
? "On now, warming the display to reduce blue light."
|
||||||
@@ -280,31 +541,67 @@ SettingsPage {
|
|||||||
ToggleRow { setting: "nightLightAutomatic" }
|
ToggleRow { setting: "nightLightAutomatic" }
|
||||||
TimeOfDayRow { setting: "nightLightFrom" }
|
TimeOfDayRow { setting: "nightLightFrom" }
|
||||||
TimeOfDayRow { setting: "nightLightTo" }
|
TimeOfDayRow { setting: "nightLightTo" }
|
||||||
SliderRow { setting: "nightLightTemperature"; divider: false }
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 {
|
SettingsCard {
|
||||||
visible: pageBrightness.visible || Brightness.lastError !== ""
|
id: gamingCard
|
||||||
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
|
|
||||||
|
|
||||||
BrightnessControl {
|
width: globalGrid.cardWidth
|
||||||
id: pageBrightness
|
x: globalGrid.twoUp ? globalGrid.cardWidth + 16 : 0
|
||||||
width: parent.width
|
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 {
|
SettingsCard {
|
||||||
visible: Displays.monitors.length >= 2
|
visible: Displays.monitors.length >= 2
|
||||||
title: "Workspaces"
|
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 {
|
SegmentRow {
|
||||||
label: "Where workspaces live"
|
label: "Where workspaces live"
|
||||||
@@ -353,33 +650,51 @@ SettingsPage {
|
|||||||
subtitle: Workspaces.lastError
|
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 {
|
SettingsCard {
|
||||||
visible: Displays.lastError !== ""
|
visible: Displays.lastError !== ""
|
||||||
title: "Display problem"
|
title: "Display problem"
|
||||||
subtitle: Displays.lastError
|
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 {
|
function applyWith(change: var): void {
|
||||||
if (!root.monitor)
|
if (!root.monitor)
|
||||||
return;
|
return;
|
||||||
const mode = change.mode ?? root.currentMode;
|
const partial = Object.assign({}, change);
|
||||||
const requestedScale = change.scale ?? root.monitor.scale;
|
if (partial.mode !== undefined || partial.scale !== undefined) {
|
||||||
Displays.apply(
|
const mode = partial.mode ?? root.currentMode;
|
||||||
root.monitor.name,
|
const requested = partial.scale ?? root.monitor.scale;
|
||||||
mode,
|
partial.scale = Displays.isScaleClean(mode, requested)
|
||||||
Displays.isScaleClean(mode, requestedScale)
|
? requested
|
||||||
? requestedScale
|
: Displays.nearestCleanScale(mode, requested);
|
||||||
: Displays.nearestCleanScale(mode, requestedScale),
|
}
|
||||||
change.transform ?? root.monitor.transform);
|
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
|
DisplayModePicker 1.0 DisplayModePicker.qml
|
||||||
DisplayArrangement 1.0 DisplayArrangement.qml
|
DisplayArrangement 1.0 DisplayArrangement.qml
|
||||||
DisplayIdentify 1.0 DisplayIdentify.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
|
WifiPanel 1.0 WifiPanel.qml
|
||||||
BluetoothPanel 1.0 BluetoothPanel.qml
|
BluetoothPanel 1.0 BluetoothPanel.qml
|
||||||
PasswordField 1.0 PasswordField.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) {
|
function logicalSize(record) {
|
||||||
if (!record)
|
if (!record)
|
||||||
return { width: 0, height: 0 };
|
return { width: 0, height: 0 };
|
||||||
@@ -22,6 +65,22 @@ function validCoordinate(value) {
|
|||||||
&& value >= -100000 && value <= 100000;
|
&& 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) {
|
function validate(layout) {
|
||||||
if (!Array.isArray(layout) || layout.length === 0)
|
if (!Array.isArray(layout) || layout.length === 0)
|
||||||
return false;
|
return false;
|
||||||
@@ -41,7 +100,8 @@ function validate(layout) {
|
|||||||
|| !Number.isInteger(record.transform)
|
|| !Number.isInteger(record.transform)
|
||||||
|| record.transform < 0 || record.transform > 3
|
|| record.transform < 0 || record.transform > 3
|
||||||
|| !validCoordinate(record.x) || !validCoordinate(record.y)
|
|| !validCoordinate(record.x) || !validCoordinate(record.y)
|
||||||
|| typeof record.primary !== "boolean")
|
|| typeof record.primary !== "boolean"
|
||||||
|
|| !validOptionalFields(record))
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
const size = logicalSize(record);
|
const size = logicalSize(record);
|
||||||
@@ -51,6 +111,21 @@ function validate(layout) {
|
|||||||
if (record.primary)
|
if (record.primary)
|
||||||
primaryCount += 1;
|
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;
|
return primaryCount === 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,6 +133,14 @@ function cloneLayout(layout) {
|
|||||||
return (layout || []).map(record => Object.assign({}, record));
|
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) {
|
function normalize(layout) {
|
||||||
const result = cloneLayout(layout);
|
const result = cloneLayout(layout);
|
||||||
const primary = result.find(record => record.primary === true);
|
const primary = result.find(record => record.primary === true);
|
||||||
@@ -66,9 +149,24 @@ function normalize(layout) {
|
|||||||
const anchorX = primary.x;
|
const anchorX = primary.x;
|
||||||
const anchorY = primary.y;
|
const anchorY = primary.y;
|
||||||
for (const record of result) {
|
for (const record of result) {
|
||||||
|
if (isMirrored(record))
|
||||||
|
continue;
|
||||||
record.x -= anchorX;
|
record.x -= anchorX;
|
||||||
record.y -= anchorY;
|
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;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,7 +178,7 @@ function bounds(layout) {
|
|||||||
let top = Infinity;
|
let top = Infinity;
|
||||||
let right = -Infinity;
|
let right = -Infinity;
|
||||||
let bottom = -Infinity;
|
let bottom = -Infinity;
|
||||||
for (const record of layout) {
|
for (const record of placedRecords(layout)) {
|
||||||
const size = logicalSize(record);
|
const size = logicalSize(record);
|
||||||
left = Math.min(left, record.x);
|
left = Math.min(left, record.x);
|
||||||
top = Math.min(top, record.y);
|
top = Math.min(top, record.y);
|
||||||
@@ -93,7 +191,7 @@ function bounds(layout) {
|
|||||||
function snap(layout, movingName, threshold) {
|
function snap(layout, movingName, threshold) {
|
||||||
const result = cloneLayout(layout);
|
const result = cloneLayout(layout);
|
||||||
const moving = result.find(record => record.name === movingName);
|
const moving = result.find(record => record.name === movingName);
|
||||||
if (!moving)
|
if (!moving || isMirrored(moving))
|
||||||
return result;
|
return result;
|
||||||
|
|
||||||
const limit = Number.isFinite(threshold) && threshold >= 0 ? threshold : 16;
|
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 movingXEdges = [moving.x, moving.x + movingSize.width];
|
||||||
const movingYEdges = [moving.y, moving.y + movingSize.height];
|
const movingYEdges = [moving.y, moving.y + movingSize.height];
|
||||||
const stationary = result
|
const stationary = result
|
||||||
.filter(record => record.name !== movingName)
|
.filter(record => record.name !== movingName && !isMirrored(record))
|
||||||
.sort((left, right) => left.name.localeCompare(right.name));
|
.sort((left, right) => left.name.localeCompare(right.name));
|
||||||
|
|
||||||
let bestX = null;
|
let bestX = null;
|
||||||
@@ -148,10 +246,7 @@ function canvasRects(layout, canvasWidth, canvasHeight, padding) {
|
|||||||
const contentHeight = desktopBounds.height * scale;
|
const contentHeight = desktopBounds.height * scale;
|
||||||
const originX = inset + (availableWidth - contentWidth) / 2;
|
const originX = inset + (availableWidth - contentWidth) / 2;
|
||||||
const originY = inset + (availableHeight - contentHeight) / 2;
|
const originY = inset + (availableHeight - contentHeight) / 2;
|
||||||
return {
|
const rects = (layout || []).map(record => {
|
||||||
bounds: desktopBounds,
|
|
||||||
scale,
|
|
||||||
rects: (layout || []).map(record => {
|
|
||||||
const size = logicalSize(record);
|
const size = logicalSize(record);
|
||||||
return {
|
return {
|
||||||
name: record.name,
|
name: record.name,
|
||||||
@@ -159,8 +254,27 @@ function canvasRects(layout, canvasWidth, canvasHeight, padding) {
|
|||||||
y: originY + (record.y - desktopBounds.y) * scale,
|
y: originY + (record.y - desktopBounds.y) * scale,
|
||||||
width: size.width * scale,
|
width: size.width * scale,
|
||||||
height: size.height * scale,
|
height: size.height * scale,
|
||||||
primary: record.primary === true
|
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
|
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
|
// 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
|
// unable to SEE the screen well enough to undo it. A mode the display cannot
|
||||||
@@ -27,7 +28,14 @@ Singleton {
|
|||||||
id: root
|
id: root
|
||||||
|
|
||||||
// [{ name, description, width, height, refreshRate, scale, transform,
|
// [{ name, description, width, height, refreshRate, scale, transform,
|
||||||
|
// x, y, primary, currentFormat, colorPreset, bitdepth, sdrBrightness,
|
||||||
|
// sdrSaturation, mirrorOf, vrr,
|
||||||
// modes: [{ label, mode, width, height, refresh }] }]
|
// 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: []
|
property var monitors: []
|
||||||
// Quickshell.screens is the topology authority. The override is only the
|
// Quickshell.screens is the topology authority. The override is only the
|
||||||
// isolated harness model; normal sessions always observe Quickshell.
|
// isolated harness model; normal sessions always observe Quickshell.
|
||||||
@@ -77,6 +85,33 @@ Singleton {
|
|||||||
{ value: 3, label: "Portrait (flipped)" }
|
{ 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.
|
// Scales that divide this desktop's common resolutions into whole pixels.
|
||||||
// Hyprland rejects a fractional scale that does not, and the message it
|
// Hyprland rejects a fractional scale that does not, and the message it
|
||||||
// gives is not something to put in front of a user.
|
// gives is not something to put in front of a user.
|
||||||
@@ -197,7 +232,8 @@ Singleton {
|
|||||||
|| Math.abs(entry.scale - record.scale) >= 0.001
|
|| Math.abs(entry.scale - record.scale) >= 0.001
|
||||||
|| entry.transform !== record.transform
|
|| entry.transform !== record.transform
|
||||||
|| (entry.x !== undefined && entry.x !== record.x)
|
|| (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;
|
changed = true;
|
||||||
record.mode = entry.mode;
|
record.mode = entry.mode;
|
||||||
record.width = parts.width;
|
record.width = parts.width;
|
||||||
@@ -208,6 +244,7 @@ Singleton {
|
|||||||
if (entry.x !== undefined) record.x = entry.x;
|
if (entry.x !== undefined) record.x = entry.x;
|
||||||
if (entry.y !== undefined) record.y = entry.y;
|
if (entry.y !== undefined) record.y = entry.y;
|
||||||
record.primary = entry.primary === true;
|
record.primary = entry.primary === true;
|
||||||
|
root.overlayStoredFields(record, entry);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!changed)
|
if (!changed)
|
||||||
@@ -313,6 +350,11 @@ Singleton {
|
|||||||
primary: monitor.name === primaryName,
|
primary: monitor.name === primaryName,
|
||||||
currentFormat: monitor.currentFormat ?? "",
|
currentFormat: monitor.currentFormat ?? "",
|
||||||
colorPreset: monitor.colorManagementPreset ?? "",
|
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,
|
vrr: monitor.vrr === true,
|
||||||
modes: modes
|
modes: modes
|
||||||
};
|
};
|
||||||
@@ -384,6 +426,55 @@ Singleton {
|
|||||||
return root.monitors.find(monitor => monitor.name === name) ?? null;
|
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 {
|
function isPersistedLayoutEntry(entry: var): bool {
|
||||||
return !!entry && typeof entry === "object"
|
return !!entry && typeof entry === "object"
|
||||||
&& root.modeParts(entry.mode) !== null
|
&& root.modeParts(entry.mode) !== null
|
||||||
@@ -392,7 +483,14 @@ Singleton {
|
|||||||
&& entry.transform >= 0 && entry.transform <= 3
|
&& entry.transform >= 0 && entry.transform <= 3
|
||||||
&& Number.isInteger(entry.x) && entry.x >= -100000 && entry.x <= 100000
|
&& Number.isInteger(entry.x) && entry.x >= -100000 && entry.x <= 100000
|
||||||
&& Number.isInteger(entry.y) && entry.y >= -100000 && entry.y <= 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 {
|
function modeParts(mode: string): var {
|
||||||
@@ -429,8 +527,24 @@ Singleton {
|
|||||||
choices[0]);
|
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 {
|
function currentLayout(): var {
|
||||||
return root.monitors.map(monitor => ({
|
return root.monitors.map(monitor => {
|
||||||
|
const saved = root.savedEntry(monitor.name);
|
||||||
|
const live = DisplayLayout.validColorProfile(monitor.colorPreset)
|
||||||
|
? monitor.colorPreset : "auto";
|
||||||
|
return {
|
||||||
name: monitor.name,
|
name: monitor.name,
|
||||||
width: monitor.width,
|
width: monitor.width,
|
||||||
height: monitor.height,
|
height: monitor.height,
|
||||||
@@ -440,8 +554,22 @@ Singleton {
|
|||||||
transform: monitor.transform,
|
transform: monitor.transform,
|
||||||
x: monitor.x,
|
x: monitor.x,
|
||||||
y: monitor.y,
|
y: monitor.y,
|
||||||
primary: monitor.primary === true
|
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 {
|
function matchesLayout(monitors: var, layout: var): bool {
|
||||||
@@ -459,13 +587,72 @@ Singleton {
|
|||||||
|| monitor.height !== parts.height
|
|| monitor.height !== parts.height
|
||||||
|| Math.abs(monitor.refreshRate - parts.refresh) >= 0.01
|
|| Math.abs(monitor.refreshRate - parts.refresh) >= 0.01
|
||||||
|| Math.abs(monitor.scale - requested.scale) >= 0.001
|
|| Math.abs(monitor.scale - requested.scale) >= 0.001
|
||||||
|| monitor.transform !== requested.transform
|
|| monitor.transform !== requested.transform)
|
||||||
|| monitor.x !== requested.x || monitor.y !== requested.y)
|
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 false;
|
||||||
}
|
}
|
||||||
return true;
|
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 {
|
function modeIsCurrent(monitor: var, candidate: var): bool {
|
||||||
return !!monitor && !!candidate
|
return !!monitor && !!candidate
|
||||||
&& monitor.width === candidate.width
|
&& monitor.width === candidate.width
|
||||||
@@ -483,34 +670,66 @@ Singleton {
|
|||||||
return layout.every(record => {
|
return layout.every(record => {
|
||||||
const monitor = root.monitorNamed(record.name);
|
const monitor = root.monitorNamed(record.name);
|
||||||
const parts = root.modeParts(record.mode);
|
const parts = root.modeParts(record.mode);
|
||||||
|
const mirrorOf = typeof record.mirrorOf === "string" ? record.mirrorOf : "";
|
||||||
return !!monitor && !!parts
|
return !!monitor && !!parts
|
||||||
&& record.width === parts.width && record.height === parts.height
|
&& record.width === parts.width && record.height === parts.height
|
||||||
&& monitor.modes.some(candidate => candidate.mode === record.mode)
|
&& monitor.modes.some(candidate => candidate.mode === record.mode)
|
||||||
&& root.isScaleClean(record.mode, record.scale)
|
&& 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.
|
// One-field controls remain callers of the complete-layout transaction.
|
||||||
// Their edit is cloned into the current layout so every output's position
|
// Their edit is cloned into the current layout so every output's position,
|
||||||
// participates in apply, verification, and rollback.
|
// colour and mirror state participates in apply, verification, and
|
||||||
function apply(output: string, mode: string, scale: real, transform: int): bool {
|
// 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 layout = root.currentLayout();
|
||||||
const record = layout.find(candidate => candidate.name === output);
|
const record = layout.find(candidate => candidate.name === output);
|
||||||
const parts = root.modeParts(mode);
|
if (!record) {
|
||||||
if (!record || !parts) {
|
root.lastError = "That display is not connected.";
|
||||||
root.lastError = record ? "That display does not offer that mode." : "That display is not connected.";
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
record.mode = mode;
|
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.width = parts.width;
|
||||||
record.height = parts.height;
|
record.height = parts.height;
|
||||||
record.refreshRate = parts.refresh;
|
record.refreshRate = parts.refresh;
|
||||||
record.scale = scale;
|
}
|
||||||
record.transform = transform;
|
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);
|
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
|
// Applies immediately and starts the countdown. Nothing is stored yet: the
|
||||||
// complete connected layout is only written by confirm().
|
// complete connected layout is only written by confirm().
|
||||||
function applyLayout(layout: var, protectedOperation: bool): bool {
|
function applyLayout(layout: var, protectedOperation: bool): bool {
|
||||||
@@ -554,19 +773,66 @@ Singleton {
|
|||||||
|
|
||||||
function makePrimary(output: string): bool {
|
function makePrimary(output: string): bool {
|
||||||
const layout = root.currentLayout();
|
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.";
|
root.lastError = "That display is not connected.";
|
||||||
return false;
|
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)
|
for (const record of layout)
|
||||||
record.primary = record.name === output;
|
record.primary = record.name === output;
|
||||||
return root.applyLayout(DisplayLayout.normalize(layout));
|
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 {
|
function pushLayout(layout: var, runner: var): void {
|
||||||
const payload = layout.map(record =>
|
const payload = layout.map(record => root.monitorRule(record)).join("; ");
|
||||||
`hl.monitor({ output = "${record.name}", mode = "${record.mode}", position = "${record.x}x${record.y}", scale = ${record.scale}, transform = ${record.transform} })`
|
|
||||||
).join("; ");
|
|
||||||
runner.exec(["hyprctl", "eval", payload]);
|
runner.exec(["hyprctl", "eval", payload]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -587,7 +853,13 @@ Singleton {
|
|||||||
transform: record.transform,
|
transform: record.transform,
|
||||||
x: record.x,
|
x: record.x,
|
||||||
y: record.y,
|
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)) {
|
if (!DesktopPreferences.set("displays", next)) {
|
||||||
@@ -637,6 +909,14 @@ Singleton {
|
|||||||
const previous = (root.pendingPreviousLayout || [])
|
const previous = (root.pendingPreviousLayout || [])
|
||||||
.filter(record => connected[record.name])
|
.filter(record => connected[record.name])
|
||||||
.map(record => Object.assign({}, record));
|
.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)) {
|
if (previous.length > 0 && !previous.some(record => record.primary)) {
|
||||||
const origin = previous.find(record => record.x === 0 && record.y === 0);
|
const origin = previous.find(record => record.x === 0 && record.y === 0);
|
||||||
(origin || previous[0]).primary = true;
|
(origin || previous[0]).primary = true;
|
||||||
|
|||||||
@@ -161,6 +161,18 @@ Singleton {
|
|||||||
{ label: "Arrange displays", detail: "Drag connected displays into their physical positions", page: "displays" },
|
{ 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: "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: "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: "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: "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" },
|
{ label: "Dark mode", detail: "Flip the desktop to your dark theme", page: "appearance" },
|
||||||
|
|||||||
@@ -5,6 +5,6 @@
|
|||||||
# @vicinae.mode silent
|
# @vicinae.mode silent
|
||||||
# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg
|
# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg
|
||||||
# @vicinae.description Open Workspaces in Settings.
|
# @vicinae.description Open Workspaces in Settings.
|
||||||
# @vicinae.keywords ["settings", "focus modes", "focus session length"]
|
# @vicinae.keywords ["settings", "focus modes", "focus session length", "switch back and forth", "wrap around at the ends", "let applications take focus", "hide the terminal that launched a window", "pointer changes active display"]
|
||||||
|
|
||||||
exec "$HOME/.config/quickshell/scripts/panama-action" settings-page workspaces
|
exec "$HOME/.config/quickshell/scripts/panama-action" settings-page workspaces
|
||||||
|
|||||||
+13
-6
@@ -4,7 +4,7 @@
|
|||||||
Do not edit this file. Run `quickshell/scripts/panama-settings-docs`
|
Do not edit this file. Run `quickshell/scripts/panama-settings-docs`
|
||||||
after changing the schema; a contract fails when this copy is stale.
|
after changing the schema; a contract fails when this copy is stale.
|
||||||
|
|
||||||
162 settings across 33 groups. 70 of them are applied to the compositor and confirmed by reading the value back.
|
162 settings across 34 groups. 70 of them are applied to the compositor and confirmed by reading the value back.
|
||||||
|
|
||||||
## accessibility
|
## accessibility
|
||||||
|
|
||||||
@@ -233,11 +233,6 @@ Found on **Shell › Tiling**.
|
|||||||
| **Keep split direction**<br>`preserveSplit` `dwindle:preserve_split` | true | New windows follow the split of the window they replace, instead of always halving the longer side |
|
| **Keep split direction**<br>`preserveSplit` `dwindle:preserve_split` | true | New windows follow the split of the window they replace, instead of always halving the longer side |
|
||||||
| **New windows open**<br>`forceSplit` `dwindle:force_split` | 0 | Where a new window lands relative to the one that was focused Choices: Where the pointer is, Always left or above, Always right or below. |
|
| **New windows open**<br>`forceSplit` `dwindle:force_split` | 0 | Where a new window lands relative to the one that was focused Choices: Where the pointer is, Always left or above, Always right or below. |
|
||||||
| **Snap floating windows**<br>`windowSnapping` `general:snap:enabled` | true | Floating windows stick to screen edges and to each other as you drag them |
|
| **Snap floating windows**<br>`windowSnapping` `general:snap:enabled` | true | Floating windows stick to screen edges and to each other as you drag them |
|
||||||
| **Switch back and forth**<br>`workspaceBackAndForth` `binds:workspace_back_and_forth` | false | Selecting the workspace you are already on returns you to the previous one |
|
|
||||||
| **Wrap around at the ends**<br>`allowWorkspaceCycles` `binds:allow_workspace_cycles` | false | Moving past the last workspace continues from the first |
|
|
||||||
| **Let applications take focus**<br>`focusOnActivate` `misc:focus_on_activate` | false | An application asking for attention is switched to, rather than only highlighted |
|
|
||||||
| **Hide the terminal that launched a window**<br>`windowSwallow` `misc:enable_swallow` | false | A terminal disappears while an application started from it is open, and returns when it closes |
|
|
||||||
| **Pointer changes active display**<br>`mouseMoveFocusesMonitor` `misc:mouse_move_focuses_monitor` | true | Moving the pointer to another display makes it the active one |
|
|
||||||
|
|
||||||
## nightLight
|
## nightLight
|
||||||
|
|
||||||
@@ -399,3 +394,15 @@ Found on **Appearance**.
|
|||||||
| **Fullscreen opacity**<br>`fullscreenOpacity` `decoration:fullscreen_opacity` | 1.0 | Applied instead of the focused opacity when a window is fullscreen. Range 0.5–1.0. |
|
| **Fullscreen opacity**<br>`fullscreenOpacity` `decoration:fullscreen_opacity` | 1.0 | Applied instead of the focused opacity when a window is fullscreen. Range 0.5–1.0. |
|
||||||
| **Corner shape**<br>`roundingPower` `decoration:rounding_power` | 2.0 | 2 is a circular corner; higher values approach a squircle. Range 2.0–10.0. |
|
| **Corner shape**<br>`roundingPower` `decoration:rounding_power` | 2.0 | 2 is a circular corner; higher values approach a squircle. Range 2.0–10.0. |
|
||||||
|
|
||||||
|
## workspaces
|
||||||
|
|
||||||
|
Found on **Shell › Workspaces**.
|
||||||
|
|
||||||
|
| Setting | Default | What it does |
|
||||||
|
|---|---|---|
|
||||||
|
| **Switch back and forth**<br>`workspaceBackAndForth` `binds:workspace_back_and_forth` | false | Selecting the workspace you are already on returns you to the previous one |
|
||||||
|
| **Wrap around at the ends**<br>`allowWorkspaceCycles` `binds:allow_workspace_cycles` | false | Moving past the last workspace continues from the first |
|
||||||
|
| **Let applications take focus**<br>`focusOnActivate` `misc:focus_on_activate` | false | An application asking for attention is switched to, rather than only highlighted |
|
||||||
|
| **Hide the terminal that launched a window**<br>`windowSwallow` `misc:enable_swallow` | false | A terminal disappears while an application started from it is open, and returns when it closes |
|
||||||
|
| **Pointer changes active display**<br>`mouseMoveFocusesMonitor` `misc:mouse_move_focuses_monitor` | true | Moving the pointer to another display makes it the active one |
|
||||||
|
|
||||||
|
|||||||
@@ -187,4 +187,65 @@ phase-3 flake above: that failure only appeared under a storming full-suite run.
|
|||||||
- 2026-08-24 full-suite run: 166 contracts, all green except `displays-contract`
|
- 2026-08-24 full-suite run: 166 contracts, all green except `displays-contract`
|
||||||
and `switcher-contract`, which are live interactive tests that cannot run
|
and `switcher-contract`, which are live interactive tests that cannot run
|
||||||
behind hyprlock (both passed in the same day's unlocked run; neither
|
behind hyprlock (both passed in the same day's unlocked run; neither
|
||||||
subsystem changed in phase 4). Re-verify after unlock.
|
subsystem changed in phase 4). Re-verify after unlock. **Still pending** —
|
||||||
|
and `displays-contract` has since changed (phase 5), so this run is now the
|
||||||
|
first one that exercises the new material as well.
|
||||||
|
|
||||||
|
## Phase 5 (Displays) — append below
|
||||||
|
|
||||||
|
Spec: `2026-08-24-displays-redesign.md`. The Displays page became canvas-first,
|
||||||
|
the per-monitor record grew VRR / colour profile / bit depth / SDR trim /
|
||||||
|
mirroring, and `pushLayout` stopped clobbering `monitors.lua`'s colour values.
|
||||||
|
|
||||||
|
**Nothing in this wave was run.** The test window was closed while it was
|
||||||
|
written: these are live harnesses that drive the real compositor through
|
||||||
|
display transactions, and three agents were editing the tree concurrently. What
|
||||||
|
*was* verified is listed as static below — evaluated directly against the
|
||||||
|
implementation files without a harness, by loading `monitors.lua` under a stub
|
||||||
|
`prefs` and by evaluating `DisplayLayout.js` in node. Everything else is
|
||||||
|
deferred to the sweep.
|
||||||
|
|
||||||
|
### Contracts changed (4)
|
||||||
|
|
||||||
|
| Contract | What it now pins | Verified |
|
||||||
|
|---|---|---|
|
||||||
|
| `quickshell/display-transaction-contract` | The extended record end to end: `applyRecord` merging one field at a time; `vrr` **omitted** when `vrrMode === -1` and emitted when it is not; neutral `sdrsaturation` omitted rather than written; a mirrored rule asking for `position = "auto"`; the mirror x/y carve-out in `matchesLayout`, and that it does not leak to an unmirrored record; a framebuffer format with no 8/10 mapping skipping the bit-depth assertion instead of blocking Keep; `confirm()` persisting the whole record; old-shape stored blobs still validating and an impossible `vrrMode` not; and seven refusals (self-mirror, primary mirroring, absent target, out-of-range vrr/profile/depth/SDR). | Bash and Python syntax; every new static grep checked against the landed `Displays.qml` (including the `vrrMode >= 0` omission branch in `monitorRule`) and against the extended harness; the fake compositor's rule parser dry-run on a real `monitorRule` payload, covering the `position = "auto"` and `mirror =` branches and the `mirrorOf: "none"` readback spelling. Every IPC assertion is **deferred**. |
|
||||||
|
| `quickshell/display-arrangement-contract` | **Flipped**: the canvas is no longer hidden below two displays. The `visible: … monitors.length` gate is now asserted *absent* from the `DisplayArrangement` element (by AST-free block scan, so the selector chips and Workspaces card may keep theirs), with a solo hint string, a `draggable` flag, and an `enabled:` binding on the `DragHandler`. Plus the mirror badge: `Mirrors ` in the component, `mirrorOf` read off the rects, and two new harness fixtures — solo renders one rect with `draggable == false`, mirrored stacks rect 1 on rect 0 and flags it. | **Statically verified** against the rebuilt `DisplayArrangement.qml`: every grep hits, the `DragHandler`'s `enabled:` is found by the brace-matching helper, and the flip check was mutation-tested both ways — it fails on the pre-redesign page (where the gate sat on the enclosing card, not on the canvas) and passes once the gate is gone. The two new harness fixtures are **deferred**. |
|
||||||
|
| `quickshell/display-layout-contract` | Mirror geometry in `DisplayLayout.js`: a valid mirror validates; the mirrored record keeps its stored coordinates through `normalize` (the primary's anchor does not apply to a position nothing reads back); it contributes nothing to `bounds`; `canvasRects` stacks its rect on its target's and carries `mirrorOf`/`mirrored`. Five refusals: self, absent target, mirroring primary, a two-hop chain, and a non-string. | **Statically verified** in node against the real `DisplayLayout.js` — every expected value in the two new `jq` filters came from that run, including the 3140/80 the mirrored record keeps. Harness plumbing deferred. |
|
||||||
|
| `quickshell/displays-contract` | The Lua consumer half: `color_profile` / `bitdepth_value` / `vrr_value` / `sdr_value` / `mirror_value` present, `cm`/`bitdepth`/`sdrbrightness`/`vrr`/`mirror` emitted under Hyprland's own key names, a mirrored entry's `position` forced to `auto`, neutral SDR saturation and `vrrMode = -1` written as absence rather than as a value, and an entry with every new field impossible surviving with its geometry while each bad field drops. Plus the extended-record greps on `Displays.qml`. | **Statically verified**: the whole `LUA` block was run against `config/dot/hypr/monitors.lua` with a stub `prefs` and passes. The `Displays.qml` greps were checked by hand and all hit. The live compositor half is deferred. |
|
||||||
|
|
||||||
|
### Cross-agent shapes these contracts now pin
|
||||||
|
|
||||||
|
Written from the spec's pinned API while agents A and B worked in parallel, and
|
||||||
|
re-checked against their files as those landed:
|
||||||
|
|
||||||
|
- `DisplayArrangement.canvasSnapshot()` exposes `draggable` alongside
|
||||||
|
`rects`/`scale`, and passes `canvasRects`' `mirrorOf`/`mirrored` through — it
|
||||||
|
returns `canvasData.rects`, not the solo-shrunk `tiles`, which is what the
|
||||||
|
new fixtures assert against. Confirmed in the landed component.
|
||||||
|
- The solo hint is pinned as the prefix `One display connected` rather than the
|
||||||
|
full sentence, so the em dash cannot break the grep.
|
||||||
|
- The mirror badge is pinned as `Mirrors ` in `DisplayArrangement.qml`.
|
||||||
|
- `soloFixture`/`mirrorFixture` mutate `fixtureService.monitors` and call
|
||||||
|
`resetDraft()`, which is what the component's own `onMonitorsChanged` does.
|
||||||
|
Whether that ordering settles before `canvasSnapshot()` reads back is the one
|
||||||
|
thing only a run can answer.
|
||||||
|
|
||||||
|
### Still open before the run
|
||||||
|
|
||||||
|
- ~~`PreferenceSchema.qml`'s stale `displays` detail string~~ — resolved: the
|
||||||
|
detail now names color, VRR override, and mirroring, and the matching grep in
|
||||||
|
`displays-contract` was updated in the same commit.
|
||||||
|
- `displays-contract` also greps `DisplaysPage.qml` for `selectedOutput`,
|
||||||
|
`scalesForMode(`, `primaryFirstMonitors.map(` and `enabled: Displays.canConfirm`.
|
||||||
|
All four still hit, but the page was still the pre-redesign one when this was
|
||||||
|
written — re-check them once the rebuilt page lands, particularly
|
||||||
|
`primaryFirstMonitors.map(`, since the spec replaces the "Connected display"
|
||||||
|
picker card with selector chips.
|
||||||
|
- No contract file was added or removed, so the README count line stays at
|
||||||
|
**166** and `setup/readme-contract` needs nothing.
|
||||||
|
- Run order for the sweep: `display-layout-contract` first (pure geometry, no
|
||||||
|
compositor), then `display-transaction-contract` (fake compositor on `PATH`),
|
||||||
|
then `display-arrangement-contract`, and `displays-contract` last — it is the
|
||||||
|
only one that drives the physical display, and it refuses to start from a
|
||||||
|
scale that does not match what `monitors.lua` ships.
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
# Displays redesign — canvas-first, transaction-complete
|
||||||
|
|
||||||
|
Approved mock: `home-mocks/displays.html` (scratchpad, served on :8642). This spec is the
|
||||||
|
implementation contract; where the mock and this file disagree, this file wins.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
1. **Canvas-first page.** The arrangement canvas is the hero and renders even with a single
|
||||||
|
display. Everything below it belongs to the selected display; global settings (Night Light,
|
||||||
|
Gaming, Workspaces) sit in an "All displays" section at the bottom.
|
||||||
|
2. **More complete.** Per-display VRR override, full color section (profile / bit depth / SDR
|
||||||
|
trim), hardware (DDC) brightness in the display panel, and mirroring — all riding the existing
|
||||||
|
keep-or-revert transaction where they are verifiable.
|
||||||
|
3. **Fix the clobber bug.** `pushLayout` currently emits only five keys, and a Hyprland monitor
|
||||||
|
rule replaces the previous rule wholesale — so every Settings apply drops `monitors.lua`'s
|
||||||
|
`bitdepth = 10` / `cm` values (live compositor is 8-bit sRGB today while the page claims
|
||||||
|
otherwise). Once color fields are part of the record, applies preserve them.
|
||||||
|
|
||||||
|
Non-goals: disabling outputs, reserved areas, tearing controls, HDR capability probing from EDID,
|
||||||
|
per-display wallpaper. The verify/revert machinery keeps its shape: 15 s countdown, exact readback,
|
||||||
|
generation tags, hotplug revert.
|
||||||
|
|
||||||
|
## Extended display record (service API — pinned)
|
||||||
|
|
||||||
|
`Displays.qml` per-monitor records gain, alongside `mode/scale/transform/x/y/primary`:
|
||||||
|
|
||||||
|
| Field | Type | Values | Hyprland key | Verified via readback? |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `vrrMode` | int | `-1` follow global policy (default) / `0` off / `1` always / `2` fullscreen | `vrr` — **omitted when -1** | **No** — readback `vrr` is live state, not config. Applied, not verified. |
|
||||||
|
| `colorProfile` | string | `"auto" \| "srgb" \| "wide" \| "hdr"` | `cm` | Yes — `colorManagementPreset` |
|
||||||
|
| `bitdepth` | int | `8 \| 10` | `bitdepth` | Yes, conservatively — `currentFormat` `XRGB8888`→8, `XRGB2101010`→10; any other format skips this field's assertion |
|
||||||
|
| `sdrBrightness` | real | 0.8–2.0, default 1.0 | `sdrbrightness` | Yes — `sdrBrightness` |
|
||||||
|
| `sdrSaturation` | real | 0.8–1.2, default 1.0 | `sdrSaturation` readback / `sdrsaturation` key | Yes |
|
||||||
|
| `mirrorOf` | string | `""` none (default) / another connected output name | `mirror` | Yes — `mirrorOf`; **and the x/y assertions in `matchesLayout` are skipped for a mirrored record** (position is compositor-chosen) |
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- `currentLayout()` reads all of these from the live `hyprctl -j monitors` readback (`vrrMode`
|
||||||
|
cannot be read back; it comes from the persisted record, defaulting to -1), so an apply that
|
||||||
|
changes one field carries the rest unchanged — that is the clobber fix.
|
||||||
|
- `pushLayout` emits every field with the omission rules above. If the `hl.monitor` Lua bridge
|
||||||
|
only serializes the current five keys, extend the bridge (find it under `config/dot/hypr/`);
|
||||||
|
keep its serialization literal-shaped for whatever parses it.
|
||||||
|
- Validation (`validRequestedLayout` / `DisplayLayout.validate`): `mirrorOf` must name a
|
||||||
|
*different* connected output that is not itself mirrored (no chains); the primary display may
|
||||||
|
not mirror; enum/range checks per the table. A mirrored record still carries mode/scale/
|
||||||
|
transform (Hyprland applies them) but is excluded from `bounds`/`normalize` geometry and from
|
||||||
|
overlap/position concerns in `DisplayLayout` — `canvasRects` stacks it on its target with a
|
||||||
|
badge flag instead.
|
||||||
|
- New API: `applyRecord(output, partial)` — merges a partial change object into the cloned
|
||||||
|
layout and funnels into `applyLayout()`. The existing `apply(output, mode, scale, transform)`
|
||||||
|
stays as a thin wrapper so nothing external breaks.
|
||||||
|
- `confirm()` persists the extended record; `isPersistedLayoutEntry` accepts the new fields as
|
||||||
|
**optional** (old stored blobs must remain valid); `forget()` unchanged.
|
||||||
|
- `monitors.lua`'s `display_entry` consumer emits the new fields when present and validates them;
|
||||||
|
invalid values fall back exactly like invalid geometry does today. Its hardcoded Kuycon
|
||||||
|
`bitdepth = 10` / `cm = "auto"` become the *defaults* the record inherits rather than values
|
||||||
|
the Settings path fights with.
|
||||||
|
- VRR override options deliberately exclude "fullscreen games" (that is the global policy's
|
||||||
|
value 3); the override menu is Follow / Off / Always on / Fullscreen only.
|
||||||
|
|
||||||
|
## Page layout (top to bottom)
|
||||||
|
|
||||||
|
`DisplaysPage.qml`, rebuilt. Page lede: "Changes apply to every display together and revert on
|
||||||
|
their own in 15 seconds unless you keep them."
|
||||||
|
|
||||||
|
1. **Confirmation banner** (pinned `header:` component — must stay outside the Flickable, the
|
||||||
|
displays-contract asserts this). Restyled: warn-tinted card with a **countdown ring** (SVG-like
|
||||||
|
Canvas or two arcs; updates once per second — no continuous animation), seconds numeral,
|
||||||
|
title "Keep these display settings?", body keeps the "If you cannot read this, just wait."
|
||||||
|
sentence, Revert now + Keep (Keep still disabled until `canConfirm`).
|
||||||
|
2. **Arrangement canvas** — always visible, single display included. Solo: the tile renders
|
||||||
|
centered with primary star, logical size caption, and the toolbar hint reads "One display
|
||||||
|
connected — plug in another to arrange"; drag disabled. Multi: existing drag/snap/nudge
|
||||||
|
behavior unchanged. Mirrored displays render stacked on their target with a "Mirrors <name>"
|
||||||
|
badge. Toolbar: Identify · Make primary (disabled for the primary/solo) · hint text. The
|
||||||
|
separate "Connected display" picker card is **replaced** by selector chips under the canvas
|
||||||
|
(one chip per display: thumbnail, name, primary star, connector + mode summary), shown only
|
||||||
|
with >1 display.
|
||||||
|
3. **Selected display panel** — one card. Header: display glyph, description, connector chip,
|
||||||
|
meta line ("4500 × 3000 at 60 Hz · 150% scale · sRGB 8-bit"), and the Custom setting pill +
|
||||||
|
Forget button when `isOverridden()`. Rows:
|
||||||
|
- **Resolution** — expandable mode list (existing `DisplayModePicker`, restyled: aspect-ratio
|
||||||
|
captions, "Current"/"Native" tags).
|
||||||
|
- **Refresh rate** — pill row (existing behavior), only when >1 rate at the current resolution.
|
||||||
|
- **Scale** — segmented control over `scalesForMode`, percent labels (100% / 150% / …).
|
||||||
|
- **Rotation** — segmented control with orientation glyphs, tooltips carry the full names.
|
||||||
|
- **Brightness** — DDC slider for this display (`Brightness.displays` matched by connector),
|
||||||
|
detail "Hardware brightness over DDC — the same dial as the monitor's buttons". Hidden when
|
||||||
|
the connector has no DDC entry; `Brightness.lastError` degrades the detail text exactly as
|
||||||
|
today. The quicksettings `BrightnessControl` is untouched.
|
||||||
|
- **Variable refresh rate** — dropdown: Follow gaming policy (shows the policy's current label
|
||||||
|
in the detail) / Off / Always on / Fullscreen only → `vrrMode`.
|
||||||
|
- **Use as** — dropdown: Extended display / Mirror of <each other display> → `mirrorOf`.
|
||||||
|
Disabled with one display, detail "Mirroring needs a second connected display".
|
||||||
|
4. **Color card** (per selected display). Subtitle notes color rides the same keep-or-revert
|
||||||
|
transaction. Right-aligned live readout "Now: sRGB · 8-bit (XRGB8888)" from readback.
|
||||||
|
- Profile tiles: Automatic / sRGB / Wide gamut / HDR (gradient swatches per the mock) →
|
||||||
|
`colorProfile`.
|
||||||
|
- Bit depth: 8-bit / 10-bit segmented, caption: "10-bit reduces gradient banding, but some
|
||||||
|
screen capture and recording tools can't read a 10-bit framebuffer."
|
||||||
|
- SDR brightness + SDR saturation sliders, visible only while the profile is `hdr`.
|
||||||
|
5. **"All displays" section**: Night Light card (existing rows; the temperature slider gets a
|
||||||
|
warm→cool gradient track) and Gaming card (Auto HDR, global VRR policy — detail notes displays
|
||||||
|
can override above — and Direct scanout) side by side in a 2-column grid; Workspaces card
|
||||||
|
(existing rows) below, ≥2 displays only. Error cards keep their current behavior.
|
||||||
|
|
||||||
|
Every per-display edit funnels through `applyRecord` and therefore the full transaction —
|
||||||
|
including brightness? **No**: brightness stays outside the transaction (hardware state, no
|
||||||
|
readback verification, bezel buttons change it behind our back) exactly as today.
|
||||||
|
|
||||||
|
## Search & docs
|
||||||
|
|
||||||
|
- `SettingsSearch.qml` hand-written entries (all `page: "displays"`): Resolution, Refresh rate,
|
||||||
|
Scale, Rotation, HDR, Color profile, Bit depth / 10-bit, SDR brightness, Mirror displays,
|
||||||
|
Variable refresh rate, Monitor brightness. Keep the existing three arrangement entries.
|
||||||
|
- README "Displays" ownership paragraph extends to name the new transaction fields and the
|
||||||
|
mirroring position carve-out. Keep the contract count line accurate if contracts are added.
|
||||||
|
- Settings docs / manual regeneration follows the generators; schema comments stay above entry
|
||||||
|
braces.
|
||||||
|
|
||||||
|
## Contracts (write, do NOT run — test window is closed)
|
||||||
|
|
||||||
|
- `display-transaction-contract`: extend fixtures for the new record fields; assert vrr omission
|
||||||
|
when `vrrMode === -1`, mirror x/y carve-out, format-unknown bitdepth skip, and that old-shape
|
||||||
|
persisted blobs still validate.
|
||||||
|
- `display-arrangement-contract`: **flip** the "canvas hidden for a single display" pin to
|
||||||
|
"canvas rendered for a single display, drag disabled"; add the mirror badge pin.
|
||||||
|
- `displays-contract` / `display-layout-contract`: update for validate/normalize mirror rules.
|
||||||
|
- Add every new/changed contract to the test backlog spec for the end-of-redesign sweep, along
|
||||||
|
with the still-pending unlocked re-run of `displays-contract` and `switcher-contract`.
|
||||||
|
|
||||||
|
## Agent ownership (parallel)
|
||||||
|
|
||||||
|
- **A — transaction**: `services/Displays.qml`, `services/DisplayLayout.js`,
|
||||||
|
`config/dot/hypr/monitors.lua` + the `hl.monitor` bridge.
|
||||||
|
- **B — UI**: `modules/settings/DisplaysPage.qml`, `DisplayArrangement.qml`,
|
||||||
|
`DisplayModePicker.qml`, any new components in `modules/settings/`.
|
||||||
|
- **C — periphery**: `services/SettingsSearch.qml`, `tests/quickshell/display*`, README section,
|
||||||
|
test-backlog spec, manual/docs regeneration.
|
||||||
|
|
||||||
|
B programs against the record/API table above; A must not change it without updating this spec.
|
||||||
@@ -31,8 +31,106 @@ for contract in \
|
|||||||
done
|
done
|
||||||
rg -Fq 'DisplayArrangement {' "$page" \
|
rg -Fq 'DisplayArrangement {' "$page" \
|
||||||
|| fail 'Displays page does not expose the arrangement canvas'
|
|| fail 'Displays page does not expose the arrangement canvas'
|
||||||
rg -Fq 'visible: Displays.monitors.length > 1' "$page" \
|
|
||||||
|| fail 'arrangement is shown for a single display'
|
# Flipped by the displays redesign, deliberately and in this direction.
|
||||||
|
#
|
||||||
|
# The canvas used to be hidden below two displays. That left a laptop opening
|
||||||
|
# its Displays page on a picker offering a list of one, and made the page's
|
||||||
|
# most legible surface the one thing a single-display machine never saw. It is
|
||||||
|
# the hero of the page now: a solo display is rendered in it, and only dragging
|
||||||
|
# goes away, because there is nothing to arrange that display against.
|
||||||
|
# The bindings of one QML element, found by matching its braces. A fixed-size
|
||||||
|
# window around the element would either miss a binding or catch a neighbour's,
|
||||||
|
# and both of those are the wrong answer here: the page's selector chips and its
|
||||||
|
# Workspaces card are legitimately gated on having two displays.
|
||||||
|
element_binding() { # file, element, pattern, present|absent
|
||||||
|
python3 - "$@" <<'PY'
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
path, opener, pattern, expectation = sys.argv[1:5]
|
||||||
|
text = open(path, encoding="utf-8").read()
|
||||||
|
start = text.find(opener)
|
||||||
|
if start < 0:
|
||||||
|
raise SystemExit(1)
|
||||||
|
depth = 0
|
||||||
|
end = len(text)
|
||||||
|
for index in range(start + len(opener) - 1, len(text)):
|
||||||
|
if text[index] == "{":
|
||||||
|
depth += 1
|
||||||
|
elif text[index] == "}":
|
||||||
|
depth -= 1
|
||||||
|
if depth == 0:
|
||||||
|
end = index
|
||||||
|
break
|
||||||
|
found = re.search(pattern, text[start:end]) is not None
|
||||||
|
raise SystemExit(0 if found == (expectation == "present") else 1)
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
|
python3 - "$page" <<'PY' \
|
||||||
|
|| fail 'the arrangement canvas is gated on a monitor count again -- on the canvas itself or on the card holding it -- so a single display sees no canvas'
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
text = open(sys.argv[1], encoding="utf-8").read()
|
||||||
|
start = text.find("DisplayArrangement {")
|
||||||
|
if start < 0:
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def own_bindings(open_brace, stop):
|
||||||
|
"""The block's own bindings, with nested elements left out: a sibling
|
||||||
|
card's gate is its business, and this is only about the canvas."""
|
||||||
|
kept = []
|
||||||
|
depth = 0
|
||||||
|
for char in text[open_brace + 1:stop]:
|
||||||
|
if char == "{":
|
||||||
|
depth += 1
|
||||||
|
elif char == "}":
|
||||||
|
depth -= 1
|
||||||
|
if depth < 0:
|
||||||
|
break
|
||||||
|
elif depth == 0:
|
||||||
|
kept.append(char)
|
||||||
|
return "".join(kept)
|
||||||
|
|
||||||
|
|
||||||
|
# The canvas's own bindings, and those of whatever element holds it: a gate on
|
||||||
|
# either one is a canvas a single display never sees. This lived on the card
|
||||||
|
# rather than on the canvas before the redesign, which is exactly why the
|
||||||
|
# element alone is not enough to look at.
|
||||||
|
stack = []
|
||||||
|
for index, char in enumerate(text[:start]):
|
||||||
|
if char == "{":
|
||||||
|
stack.append(index)
|
||||||
|
elif char == "}" and stack:
|
||||||
|
stack.pop()
|
||||||
|
if not stack:
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
regions = [own_bindings(start + len("DisplayArrangement {") - 1, len(text)),
|
||||||
|
own_bindings(stack[-1], start)]
|
||||||
|
gate = re.compile(r"visible:.*monitors\.length")
|
||||||
|
raise SystemExit(1 if any(gate.search(region) for region in regions) else 0)
|
||||||
|
PY
|
||||||
|
|
||||||
|
for contract in \
|
||||||
|
'One display connected' \
|
||||||
|
'draggable'; do
|
||||||
|
rg -Fq "$contract" "$component" \
|
||||||
|
|| fail "a solo display is not explained or not protected from dragging: $contract"
|
||||||
|
done
|
||||||
|
element_binding "$component" 'DragHandler {' 'enabled:' present \
|
||||||
|
|| fail 'the drag handler is unconditional, so a solo display can be dragged around a canvas with nothing to arrange against'
|
||||||
|
|
||||||
|
# Mirroring has no position of its own: the compositor stacks a mirrored output
|
||||||
|
# on its target. The canvas says which display it is mirroring rather than
|
||||||
|
# drawing it wherever its stale coordinates happen to point.
|
||||||
|
rg -Fq 'Mirrors ' "$component" \
|
||||||
|
|| fail 'a mirrored display is drawn with no badge saying what it mirrors'
|
||||||
|
rg -Fq 'mirrorOf' "$component" \
|
||||||
|
|| fail 'the canvas does not read the mirror flag off its rects'
|
||||||
[[ -f "$identify" ]] || fail 'DisplayIdentify.qml is missing'
|
[[ -f "$identify" ]] || fail 'DisplayIdentify.qml is missing'
|
||||||
for contract in \
|
for contract in \
|
||||||
'model: Quickshell.screens' \
|
'model: Quickshell.screens' \
|
||||||
@@ -94,6 +192,17 @@ keyboard="$(ipc keyboardFixture)"
|
|||||||
jq -e '.afterArrow == 2990 and .afterShiftArrow == 2890' <<<"$keyboard" >/dev/null \
|
jq -e '.afterArrow == 2990 and .afterShiftArrow == 2890' <<<"$keyboard" >/dev/null \
|
||||||
|| fail "keyboard movement did not use 10/100 logical-pixel steps: $keyboard"
|
|| fail "keyboard movement did not use 10/100 logical-pixel steps: $keyboard"
|
||||||
|
|
||||||
|
solo="$(ipc soloFixture)"
|
||||||
|
jq -e '(.rects | length) == 1 and .scale > 0 and .draggable == false' <<<"$solo" >/dev/null \
|
||||||
|
|| fail "a single display did not render solo with dragging disabled: $solo"
|
||||||
|
|
||||||
|
mirror="$(ipc mirrorFixture)"
|
||||||
|
jq -e '(.rects | length) == 2
|
||||||
|
and (.rects[1].mirrorOf == "DP-2")
|
||||||
|
and (.rects[1].x == .rects[0].x) and (.rects[1].y == .rects[0].y)' \
|
||||||
|
<<<"$mirror" >/dev/null \
|
||||||
|
|| fail "a mirrored display was not stacked on its target and flagged: $mirror"
|
||||||
|
|
||||||
primary="$(ipc primaryFixture)"
|
primary="$(ipc primaryFixture)"
|
||||||
jq -e '. == [
|
jq -e '. == [
|
||||||
{"name":"DP-2","x":-3000,"y":0,"primary":false},
|
{"name":"DP-2","x":-3000,"y":0,"primary":false},
|
||||||
|
|||||||
@@ -54,4 +54,34 @@ jq -e '.valid == true
|
|||||||
invalid="$(qs_for_harness ipc call display-layout-test invalid)"
|
invalid="$(qs_for_harness ipc call display-layout-test invalid)"
|
||||||
jq -e 'all(. == false)' <<<"$invalid" >/dev/null || fail "an invalid layout was accepted: $invalid"
|
jq -e 'all(. == false)' <<<"$invalid" >/dev/null || fail "an invalid layout was accepted: $invalid"
|
||||||
|
|
||||||
|
# Mirroring is the one arrangement where the position on file is not the
|
||||||
|
# position on the desk. Hyprland stacks a mirrored output on its target and
|
||||||
|
# ignores whatever the rule asked for, so the geometry here has to agree:
|
||||||
|
# a mirrored display contributes nothing to the desktop's bounds, and the
|
||||||
|
# canvas draws it on top of what it mirrors rather than at stale coordinates.
|
||||||
|
mirror="$(qs_for_harness ipc call display-layout-test mirror)"
|
||||||
|
#
|
||||||
|
# The mirrored record keeps the coordinates it was stored with -- normalize
|
||||||
|
# shifts everything by the primary's anchor, and applying that to a position
|
||||||
|
# nothing reads back would be arithmetic for its own sake. It contributes
|
||||||
|
# nothing to the bounds, so the desktop measures 3000 x 2000: one display.
|
||||||
|
jq -e '.valid == true
|
||||||
|
and .normalized == [
|
||||||
|
{"name":"DP-2","x":0,"y":0,"primary":true,"mirrorOf":""},
|
||||||
|
{"name":"HDMI-A-1","x":3140,"y":80,"primary":false,"mirrorOf":"DP-2"}]
|
||||||
|
and .bounds == {"x":0,"y":0,"width":3000,"height":2000}
|
||||||
|
and (.rects | length) == 2
|
||||||
|
and .rects[1].mirrorOf == "DP-2" and .rects[1].mirrored == true
|
||||||
|
and .rects[0].mirrored == false
|
||||||
|
and .rects[1].x == .rects[0].x and .rects[1].y == .rects[0].y
|
||||||
|
and .rects[1].width == .rects[0].width' \
|
||||||
|
<<<"$mirror" >/dev/null || fail "mirrored geometry was wrong: $mirror"
|
||||||
|
|
||||||
|
# A mirror that names itself, an absent output, or a display that is itself
|
||||||
|
# mirroring is not a layout with a cosmetic problem -- it is one the compositor
|
||||||
|
# will answer in a way nobody predicted.
|
||||||
|
invalid_mirrors="$(qs_for_harness ipc call display-layout-test invalidMirrors)"
|
||||||
|
jq -e 'all(. == false)' <<<"$invalid_mirrors" >/dev/null \
|
||||||
|
|| fail "an impossible mirror was accepted: $invalid_mirrors"
|
||||||
|
|
||||||
printf 'display layout contract: PASS\n'
|
printf 'display layout contract: PASS\n'
|
||||||
|
|||||||
@@ -28,10 +28,34 @@ rg -Fq 'position = "${record.x}x${record.y}"' "$service" \
|
|||||||
|| fail 'the compositor payload does not include explicit positions'
|
|| fail 'the compositor payload does not include explicit positions'
|
||||||
rg -Fq 'generation === root.operationGeneration' "$service" \
|
rg -Fq 'generation === root.operationGeneration' "$service" \
|
||||||
|| fail 'stale monitor readback can settle a newer transaction'
|
|| fail 'stale monitor readback can settle a newer transaction'
|
||||||
|
|
||||||
|
# The extended record. Every one of these is carried through currentLayout by
|
||||||
|
# readback, which is the whole clobber fix: an apply that changes the scale
|
||||||
|
# must not silently drop the bit depth monitors.lua asked for.
|
||||||
|
for contract in \
|
||||||
|
'function applyRecord(output:' \
|
||||||
|
'vrrMode' \
|
||||||
|
'colorProfile' \
|
||||||
|
'bitdepth' \
|
||||||
|
'sdrBrightness' \
|
||||||
|
'sdrSaturation' \
|
||||||
|
'mirrorOf'; do
|
||||||
|
rg -Fq "$contract" "$service" \
|
||||||
|
|| fail "the extended display record is missing: $contract"
|
||||||
|
done
|
||||||
|
rg -q 'vrrMode\s*(!==\s*-1|>\s*-1|>=\s*0)' "$service" \
|
||||||
|
|| fail 'nothing in the service decides when the vrr key is omitted, so following the global policy would be written as an override'
|
||||||
|
|
||||||
rg -Fq 'function applyLayoutFixture(' "$harness" \
|
rg -Fq 'function applyLayoutFixture(' "$harness" \
|
||||||
|| fail 'the fixture cannot exercise complete layout transactions'
|
|| fail 'the fixture cannot exercise complete layout transactions'
|
||||||
rg -Fq 'function injectReadback(' "$harness" \
|
rg -Fq 'function injectReadback(' "$harness" \
|
||||||
|| fail 'the fixture cannot prove stale readback isolation'
|
|| fail 'the fixture cannot prove stale readback isolation'
|
||||||
|
rg -Fq 'function applyRecordFixture(' "$harness" \
|
||||||
|
|| fail 'the fixture cannot exercise a partial record change'
|
||||||
|
rg -Fq 'function layoutMatch(' "$harness" \
|
||||||
|
|| fail 'the fixture cannot probe the readback carve-outs'
|
||||||
|
rg -Fq 'function persistedEntryValid(' "$harness" \
|
||||||
|
|| fail 'the fixture cannot prove an old stored blob still validates'
|
||||||
|
|
||||||
fixture="$(mktemp -d /tmp/panama-display-transaction.XXXXXX)"
|
fixture="$(mktemp -d /tmp/panama-display-transaction.XXXXXX)"
|
||||||
state_home="$fixture/state-home"
|
state_home="$fixture/state-home"
|
||||||
@@ -47,11 +71,15 @@ cat >"$monitor_state" <<'JSON'
|
|||||||
{
|
{
|
||||||
"name":"DP-2","description":"Primary fixture","width":4500,"height":3000,
|
"name":"DP-2","description":"Primary fixture","width":4500,"height":3000,
|
||||||
"refreshRate":60,"scale":1.5,"transform":0,"x":0,"y":0,
|
"refreshRate":60,"scale":1.5,"transform":0,"x":0,"y":0,
|
||||||
|
"currentFormat":"XRGB8888","colorManagementPreset":"auto",
|
||||||
|
"sdrBrightness":1.0,"sdrSaturation":1.0,"mirrorOf":"none","vrr":false,
|
||||||
"availableModes":["[email protected]"]
|
"availableModes":["[email protected]"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name":"HDMI-A-1","description":"Second fixture","width":2560,"height":1440,
|
"name":"HDMI-A-1","description":"Second fixture","width":2560,"height":1440,
|
||||||
"refreshRate":60,"scale":1,"transform":0,"x":3000,"y":0,
|
"refreshRate":60,"scale":1,"transform":0,"x":3000,"y":0,
|
||||||
|
"currentFormat":"XRGB8888","colorManagementPreset":"auto",
|
||||||
|
"sdrBrightness":1.0,"sdrSaturation":1.0,"mirrorOf":"none","vrr":false,
|
||||||
"availableModes":["[email protected]"]
|
"availableModes":["[email protected]"]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -78,7 +106,7 @@ if [[ -f "$fixture/fail-once" ]]; then
|
|||||||
fi
|
fi
|
||||||
[[ -f "$fixture/no-apply" ]] && exit 0
|
[[ -f "$fixture/no-apply" ]] && exit 0
|
||||||
|
|
||||||
python3 - "$fixture/monitors.json" "$payload" "$fixture/wrong-y" <<'PY'
|
python3 - "$fixture/monitors.json" "$payload" "$fixture/wrong-y" "$fixture/odd-format" <<'PY'
|
||||||
import json
|
import json
|
||||||
import pathlib
|
import pathlib
|
||||||
import re
|
import re
|
||||||
@@ -87,6 +115,7 @@ import sys
|
|||||||
state_path = pathlib.Path(sys.argv[1])
|
state_path = pathlib.Path(sys.argv[1])
|
||||||
payload = sys.argv[2]
|
payload = sys.argv[2]
|
||||||
wrong_y = pathlib.Path(sys.argv[3]).exists()
|
wrong_y = pathlib.Path(sys.argv[3]).exists()
|
||||||
|
odd_format = pathlib.Path(sys.argv[4]).exists()
|
||||||
monitors = json.loads(state_path.read_text(encoding="utf-8"))
|
monitors = json.loads(state_path.read_text(encoding="utf-8"))
|
||||||
by_name = {monitor["name"]: monitor for monitor in monitors}
|
by_name = {monitor["name"]: monitor for monitor in monitors}
|
||||||
|
|
||||||
@@ -97,10 +126,17 @@ for block in re.findall(r"hl\.monitor\(\{([^}]*)\}\)", payload):
|
|||||||
raise SystemExit(f"missing field {pattern}: {block}")
|
raise SystemExit(f"missing field {pattern}: {block}")
|
||||||
return match.group(1)
|
return match.group(1)
|
||||||
|
|
||||||
|
def optional(pattern: str):
|
||||||
|
match = re.search(pattern, block)
|
||||||
|
return match.group(1) if match else None
|
||||||
|
|
||||||
name = field(r'output\s*=\s*"([A-Za-z0-9_.-]+)"')
|
name = field(r'output\s*=\s*"([A-Za-z0-9_.-]+)"')
|
||||||
mode = field(r'mode\s*=\s*"(\d+x\d+@\d+(?:\.\d+)?)"')
|
mode = field(r'mode\s*=\s*"(\d+x\d+@\d+(?:\.\d+)?)"')
|
||||||
position = re.search(r'position\s*=\s*"(-?\d+)x(-?\d+)"', block)
|
# A mirrored rule asks for "auto" instead of coordinates, because the
|
||||||
if not position or name not in by_name:
|
# compositor is the one that decides where a mirror lands.
|
||||||
|
placement = field(r'position\s*=\s*"([^"]+)"')
|
||||||
|
position = re.match(r"^(-?\d+)x(-?\d+)$", placement)
|
||||||
|
if name not in by_name or (position is None and placement != "auto"):
|
||||||
raise SystemExit(f"bad output or position: {block}")
|
raise SystemExit(f"bad output or position: {block}")
|
||||||
width, height, refresh = re.match(r"(\d+)x(\d+)@(\d+(?:\.\d+)?)", mode).groups()
|
width, height, refresh = re.match(r"(\d+)x(\d+)@(\d+(?:\.\d+)?)", mode).groups()
|
||||||
monitor = by_name[name]
|
monitor = by_name[name]
|
||||||
@@ -110,9 +146,44 @@ for block in re.findall(r"hl\.monitor\(\{([^}]*)\}\)", payload):
|
|||||||
"refreshRate": float(refresh),
|
"refreshRate": float(refresh),
|
||||||
"scale": float(field(r"scale\s*=\s*([0-9.]+)")),
|
"scale": float(field(r"scale\s*=\s*([0-9.]+)")),
|
||||||
"transform": int(field(r"transform\s*=\s*(\d+)")),
|
"transform": int(field(r"transform\s*=\s*(\d+)")),
|
||||||
"x": int(position.group(1)),
|
|
||||||
"y": int(position.group(2)),
|
|
||||||
})
|
})
|
||||||
|
if position is not None:
|
||||||
|
monitor["x"] = int(position.group(1))
|
||||||
|
monitor["y"] = int(position.group(2))
|
||||||
|
|
||||||
|
# The compositor answers about colour with the names Hyprland uses in
|
||||||
|
# `hyprctl -j monitors`, not with the keys the rule was written in.
|
||||||
|
preset = optional(r'cm\s*=\s*"([a-z]+)"')
|
||||||
|
if preset is not None:
|
||||||
|
monitor["colorManagementPreset"] = preset
|
||||||
|
depth = optional(r"bitdepth\s*=\s*(\d+)")
|
||||||
|
if depth is not None:
|
||||||
|
monitor["currentFormat"] = "XRGB2101010" if depth == "10" else "XRGB8888"
|
||||||
|
if odd_format:
|
||||||
|
# A format Panama has no mapping for. The request is not wrong; the
|
||||||
|
# readback is simply not evidence either way.
|
||||||
|
monitor["currentFormat"] = "XBGR16161616F"
|
||||||
|
sdr_brightness = optional(r"sdrbrightness\s*=\s*([0-9.]+)")
|
||||||
|
if sdr_brightness is not None:
|
||||||
|
monitor["sdrBrightness"] = float(sdr_brightness)
|
||||||
|
sdr_saturation = optional(r"sdrsaturation\s*=\s*([0-9.]+)")
|
||||||
|
if sdr_saturation is not None:
|
||||||
|
monitor["sdrSaturation"] = float(sdr_saturation)
|
||||||
|
|
||||||
|
# Mirroring is where the compositor stops taking instructions: a mirrored
|
||||||
|
# output lands on top of its target whatever position the rule carried.
|
||||||
|
# Panama must not hold the requested x/y against it.
|
||||||
|
# Hyprland says "none" rather than an empty string, and Panama has to know
|
||||||
|
# that is not the name of a monitor.
|
||||||
|
mirror = optional(r'mirror\s*=\s*"([A-Za-z0-9_.-]*)"')
|
||||||
|
monitor["mirrorOf"] = mirror or "none"
|
||||||
|
if mirror and mirror in by_name:
|
||||||
|
monitor["x"] = by_name[mirror]["x"]
|
||||||
|
monitor["y"] = by_name[mirror]["y"]
|
||||||
|
|
||||||
|
# vrr is deliberately not reflected here: the readback field is live
|
||||||
|
# adaptive-sync state, and nothing in the service may verify against it.
|
||||||
|
|
||||||
if wrong_y and name == "HDMI-A-1":
|
if wrong_y and name == "HDMI-A-1":
|
||||||
monitor["y"] += 1
|
monitor["y"] += 1
|
||||||
|
|
||||||
@@ -251,6 +322,133 @@ mv "$fixture/reconnected.json" "$monitor_state"
|
|||||||
run ipc --pid "$harness_pid" call displays-test refresh >/dev/null
|
run ipc --pid "$harness_pid" call displays-test refresh >/dev/null
|
||||||
wait_for '.layout | length == 2' >/dev/null
|
wait_for '.layout | length == 2' >/dev/null
|
||||||
|
|
||||||
|
# ── The extended record ──────────────────────────────────────────────────────
|
||||||
|
#
|
||||||
|
# Colour, bit depth, the SDR trim and mirroring ride the same keep-or-revert
|
||||||
|
# transaction as geometry. vrr cannot: `hyprctl -j monitors` reports whether
|
||||||
|
# adaptive sync is live at this instant, not what the config asked for, so it
|
||||||
|
# is applied and never verified -- and a display that follows the global gaming
|
||||||
|
# policy must not emit the key at all, because emitting it IS the override.
|
||||||
|
|
||||||
|
b64() { printf '%s' "$1" | base64 -w0; }
|
||||||
|
apply_record() {
|
||||||
|
run ipc --pid "$harness_pid" call displays-test applyRecordFixture "$1" "$(b64 "$2")"
|
||||||
|
}
|
||||||
|
probe() {
|
||||||
|
run ipc --pid "$harness_pid" call displays-test "$1" "$(b64 "$2")" "$(b64 "$3")"
|
||||||
|
}
|
||||||
|
settle() { wait_for '.busy == false and .awaiting == false and .reverting == null' >/dev/null; }
|
||||||
|
|
||||||
|
[[ "$(apply_record HDMI-A-1 '{"colorProfile":"srgb","bitdepth":10,"sdrBrightness":1.4,"sdrSaturation":1.0,"vrrMode":-1}')" == "true" ]] \
|
||||||
|
|| fail 'an extended display record was refused'
|
||||||
|
wait_for '.canConfirm == true' >/dev/null
|
||||||
|
color_payload="$(tail -1 "$eval_log")"
|
||||||
|
if rg -q 'vrr\s*=' <<<"$color_payload"; then
|
||||||
|
fail "a display following the global VRR policy emitted a vrr override anyway: $color_payload"
|
||||||
|
fi
|
||||||
|
# Neutral saturation is the absence of a request. A rule that names 1.0 pins
|
||||||
|
# the display to it, which is not the same thing as leaving it alone.
|
||||||
|
if rg -q 'sdrsaturation\s*=' <<<"$color_payload"; then
|
||||||
|
fail "neutral SDR saturation was written as a rule: $color_payload"
|
||||||
|
fi
|
||||||
|
rg -Fq 'cm = "srgb"' <<<"$color_payload" \
|
||||||
|
&& rg -q 'bitdepth\s*=\s*10' <<<"$color_payload" \
|
||||||
|
&& rg -q 'sdrbrightness\s*=\s*1\.4\b' <<<"$color_payload" \
|
||||||
|
|| fail "the colour fields never reached the compositor payload: $color_payload"
|
||||||
|
# The clobber this exists for: an apply that changes one field must carry the
|
||||||
|
# rest of the record, or monitors.lua's 10-bit request dies on the next apply.
|
||||||
|
rg -Fq 'output = "DP-2"' <<<"$color_payload" && rg -q 'bitdepth\s*=' <<<"$color_payload" \
|
||||||
|
|| fail "the untouched display lost its colour fields on somebody else's apply: $color_payload"
|
||||||
|
|
||||||
|
[[ "$(run ipc --pid "$harness_pid" call displays-test confirmChange)" == "true" ]] \
|
||||||
|
|| fail 'a verified colour change could not be kept'
|
||||||
|
jq -e '.displays["HDMI-A-1"] | .colorProfile == "srgb" and .bitdepth == 10
|
||||||
|
and .vrrMode == -1 and .mirrorOf == "" and (.sdrBrightness - 1.4 | fabs) < 0.001' \
|
||||||
|
"$store" >/dev/null \
|
||||||
|
|| fail 'confirmation did not persist the extended record'
|
||||||
|
|
||||||
|
# vrr applied, never verified: Keep must still become available.
|
||||||
|
[[ "$(apply_record HDMI-A-1 '{"vrrMode":2}')" == "true" ]] \
|
||||||
|
|| fail 'a VRR override was refused'
|
||||||
|
wait_for '.canConfirm == true' >/dev/null
|
||||||
|
rg -q 'vrr\s*=\s*2' <<<"$(tail -1 "$eval_log")" \
|
||||||
|
|| fail "an explicit VRR override did not emit the vrr key: $(tail -1 "$eval_log")"
|
||||||
|
run ipc --pid "$harness_pid" call displays-test revertChange >/dev/null
|
||||||
|
settle
|
||||||
|
|
||||||
|
# A framebuffer format with no 8/10 mapping is not evidence against the
|
||||||
|
# request. Asserting it anyway would leave Keep permanently unavailable on
|
||||||
|
# hardware that reports a format Panama has never heard of.
|
||||||
|
touch "$fixture/odd-format"
|
||||||
|
[[ "$(apply_record HDMI-A-1 '{"bitdepth":8}')" == "true" ]] \
|
||||||
|
|| fail 'a bit depth change was refused'
|
||||||
|
wait_for '.canConfirm == true' >/dev/null
|
||||||
|
rm -f "$fixture/odd-format"
|
||||||
|
run ipc --pid "$harness_pid" call displays-test revertChange >/dev/null
|
||||||
|
settle
|
||||||
|
|
||||||
|
# Mirroring: the compositor puts a mirrored output on top of its target and
|
||||||
|
# ignores the position the rule carried, so x/y are the two assertions that
|
||||||
|
# must be skipped for that record -- and only for that record.
|
||||||
|
[[ "$(apply_record HDMI-A-1 '{"mirrorOf":"DP-2"}')" == "true" ]] \
|
||||||
|
|| fail 'a valid mirror request was refused'
|
||||||
|
wait_for '.canConfirm == true' >/dev/null
|
||||||
|
mirror_payload="$(tail -1 "$eval_log")"
|
||||||
|
rg -Fq 'mirror = "DP-2"' <<<"$mirror_payload" \
|
||||||
|
|| fail "the mirror key never reached the compositor: $mirror_payload"
|
||||||
|
# The mirrored rule asks for "auto", not for the coordinates on file: the
|
||||||
|
# position it was stored with is not one the compositor will honour.
|
||||||
|
rg -q 'output = "HDMI-A-1", mode = "[^"]+", position = "auto"' <<<"$mirror_payload" \
|
||||||
|
|| fail "a mirrored display still asked for a position of its own: $mirror_payload"
|
||||||
|
jq -e '.[1].mirrorOf == "DP-2" and .[1].x == .[0].x and .[1].y == .[0].y' \
|
||||||
|
"$monitor_state" >/dev/null \
|
||||||
|
|| fail 'the fixture compositor did not stack the mirrored output on its target'
|
||||||
|
run ipc --pid "$harness_pid" call displays-test revertChange >/dev/null
|
||||||
|
settle
|
||||||
|
|
||||||
|
# The carve-out is exactly one record wide. Same readback, same layout, with
|
||||||
|
# the mirror flag removed: now the position disagreement is a real one.
|
||||||
|
#
|
||||||
|
# Both fixtures are in the shape the service holds after parsing a readback,
|
||||||
|
# not in hyprctl's own: this probes the comparison, not the parser.
|
||||||
|
mirrored_readback='[{"name":"DP-2","width":4500,"height":3000,"refreshRate":60,"scale":1.5,"transform":0,"x":0,"y":0,"primary":true,"colorPreset":"auto","bitdepth":8,"sdrBrightness":1.0,"sdrSaturation":1.0,"mirrorOf":""},{"name":"HDMI-A-1","width":2560,"height":1440,"refreshRate":60,"scale":1,"transform":0,"x":0,"y":0,"primary":false,"colorPreset":"auto","bitdepth":8,"sdrBrightness":1.0,"sdrSaturation":1.0,"mirrorOf":"DP-2"}]'
|
||||||
|
mirrored_request='[{"name":"DP-2","mode":"[email protected]","scale":1.5,"transform":0,"x":0,"y":0,"primary":true,"colorProfile":"auto","bitdepth":8,"sdrBrightness":1.0,"sdrSaturation":1.0,"mirrorOf":"","vrrMode":-1},{"name":"HDMI-A-1","mode":"[email protected]","scale":1,"transform":0,"x":3000,"y":100,"primary":false,"colorProfile":"auto","bitdepth":8,"sdrBrightness":1.0,"sdrSaturation":1.0,"mirrorOf":"DP-2","vrrMode":-1}]'
|
||||||
|
unmirror='(.[] | select(.name == "HDMI-A-1") | .mirrorOf) = ""'
|
||||||
|
[[ "$(probe layoutMatch "$mirrored_readback" "$mirrored_request")" == "true" ]] \
|
||||||
|
|| fail 'a mirrored output was held to the position the compositor overrode'
|
||||||
|
[[ "$(probe layoutMatch \
|
||||||
|
"$(jq -c "$unmirror" <<<"$mirrored_readback")" \
|
||||||
|
"$(jq -c "$unmirror" <<<"$mirrored_request")")" == "false" ]] \
|
||||||
|
|| fail 'the position carve-out leaked to a record that is not mirrored'
|
||||||
|
|
||||||
|
# Anything the table does not allow is refused before the desktop moves.
|
||||||
|
while IFS='|' read -r output patch reason; do
|
||||||
|
[[ "$(apply_record "$output" "$patch")" == "false" ]] || fail "$reason"
|
||||||
|
[[ "$(transaction_status | jq -r .awaiting)" == "false" ]] \
|
||||||
|
|| fail "$reason (and it left a change pending)"
|
||||||
|
done <<'CASES'
|
||||||
|
HDMI-A-1|{"mirrorOf":"HDMI-A-1"}|a display was allowed to mirror itself
|
||||||
|
DP-2|{"mirrorOf":"HDMI-A-1"}|the primary display was allowed to mirror another
|
||||||
|
HDMI-A-1|{"mirrorOf":"NOPE-1"}|a display was allowed to mirror an output that is not connected
|
||||||
|
HDMI-A-1|{"vrrMode":7}|an out-of-range VRR mode was accepted
|
||||||
|
HDMI-A-1|{"colorProfile":"neon"}|an unknown colour profile was accepted
|
||||||
|
HDMI-A-1|{"bitdepth":12}|an unsupported bit depth was accepted
|
||||||
|
HDMI-A-1|{"sdrBrightness":4}|an out-of-range SDR brightness was accepted
|
||||||
|
CASES
|
||||||
|
|
||||||
|
# Every machine that has this installed already has a settings.json with none
|
||||||
|
# of the new fields in it. Those entries must keep validating, or a docking
|
||||||
|
# station restores nothing on the next start.
|
||||||
|
old_shape='{"mode":"[email protected]","scale":1,"transform":0,"x":3000,"y":100,"primary":false}'
|
||||||
|
new_shape='{"mode":"[email protected]","scale":1,"transform":0,"x":3000,"y":100,"primary":false,"vrrMode":-1,"colorProfile":"srgb","bitdepth":10,"sdrBrightness":1.0,"sdrSaturation":1.0,"mirrorOf":""}'
|
||||||
|
bad_shape='{"mode":"[email protected]","scale":1,"transform":0,"x":3000,"y":100,"primary":false,"vrrMode":7}'
|
||||||
|
[[ "$(run ipc --pid "$harness_pid" call displays-test persistedEntryValid "$(b64 "$old_shape")")" == "true" ]] \
|
||||||
|
|| fail 'a settings.json written before the colour fields existed stopped validating'
|
||||||
|
[[ "$(run ipc --pid "$harness_pid" call displays-test persistedEntryValid "$(b64 "$new_shape")")" == "true" ]] \
|
||||||
|
|| fail 'a stored entry carrying the extended record was rejected'
|
||||||
|
[[ "$(run ipc --pid "$harness_pid" call displays-test persistedEntryValid "$(b64 "$bad_shape")")" == "false" ]] \
|
||||||
|
|| fail 'a stored entry with an impossible VRR mode was accepted'
|
||||||
|
|
||||||
# A revert that exits zero but reads back wrong remains an explicit manual
|
# A revert that exits zero but reads back wrong remains an explicit manual
|
||||||
# recovery error rather than pretending the desktop was restored.
|
# recovery error rather than pretending the desktop was restored.
|
||||||
[[ "$(run ipc --pid "$harness_pid" call displays-test applyLayoutFixture 3000 400)" == "true" ]] \
|
[[ "$(run ipc --pid "$harness_pid" call displays-test applyLayoutFixture 3000 400)" == "true" ]] \
|
||||||
|
|||||||
@@ -48,6 +48,15 @@ for contract in \
|
|||||||
'primary: monitor.name === primaryName'; do
|
'primary: monitor.name === primaryName'; do
|
||||||
rg -Fq "$contract" "$service" || fail "display service contract is missing: $contract"
|
rg -Fq "$contract" "$service" || fail "display service contract is missing: $contract"
|
||||||
done
|
done
|
||||||
|
# The rest of the record rides the same transaction. Colour and mirroring are
|
||||||
|
# verified by readback like geometry; the VRR override is applied and never
|
||||||
|
# verified, because the readback field is live adaptive-sync state rather than
|
||||||
|
# the configured policy. Either way they are part of currentLayout, which is
|
||||||
|
# what stops one apply from clobbering another field's value.
|
||||||
|
for contract in 'colorProfile' 'bitdepth' 'sdrBrightness' 'mirrorOf' 'vrrMode'; do
|
||||||
|
rg -Fq "$contract" "$service" || fail "the extended display record is missing: $contract"
|
||||||
|
done
|
||||||
|
|
||||||
rg -Fq 'enabled: Displays.canConfirm' "$page" \
|
rg -Fq 'enabled: Displays.canConfirm' "$page" \
|
||||||
|| fail 'Keep is enabled before the display change is verified'
|
|| fail 'Keep is enabled before the display change is verified'
|
||||||
rg -Fq 'options: Displays.scalesForMode(' "$page" \
|
rg -Fq 'options: Displays.scalesForMode(' "$page" \
|
||||||
@@ -67,7 +76,8 @@ rg -Fq 'if (root.busy)' "$service" \
|
|||||||
|
|
||||||
# Stored JSON is untyped at field level, so the Lua startup consumer is the
|
# Stored JSON is untyped at field level, so the Lua startup consumer is the
|
||||||
# final validation boundary and must support every named output it accepts.
|
# final validation boundary and must support every named output it accepts.
|
||||||
for contract in 'valid_mode' 'valid_scale' 'valid_transform' 'valid_position' 'valid_primary' 'pairs(displays)'; do
|
for contract in 'valid_mode' 'valid_scale' 'valid_transform' 'valid_position' 'valid_primary' \
|
||||||
|
'color_profile' 'bitdepth_value' 'vrr_value' 'sdr_value' 'mirror_value' 'pairs(displays)'; do
|
||||||
rg -Fq "$contract" "$monitors_lua" || fail "monitor startup validation is missing: $contract"
|
rg -Fq "$contract" "$monitors_lua" || fail "monitor startup validation is missing: $contract"
|
||||||
done
|
done
|
||||||
|
|
||||||
@@ -93,6 +103,7 @@ package.preload["prefs"] = function()
|
|||||||
["HDMI-A-1"] = {
|
["HDMI-A-1"] = {
|
||||||
mode = "2560x1440@60", scale = 1, transform = 1,
|
mode = "2560x1440@60", scale = 1, transform = 1,
|
||||||
x = 3000, y = 0, primary = false,
|
x = 3000, y = 0, primary = false,
|
||||||
|
vrrMode = -1,
|
||||||
},
|
},
|
||||||
["LEGACY-1"] = { mode = "1920x1080@60", scale = 1.5, transform = 0 },
|
["LEGACY-1"] = { mode = "1920x1080@60", scale = 1.5, transform = 0 },
|
||||||
["PARTIAL-1"] = {
|
["PARTIAL-1"] = {
|
||||||
@@ -107,6 +118,24 @@ package.preload["prefs"] = function()
|
|||||||
mode = "1920x1080@60", scale = 1, transform = 0,
|
mode = "1920x1080@60", scale = 1, transform = 0,
|
||||||
x = 4440, y = 0, primary = false,
|
x = 4440, y = 0, primary = false,
|
||||||
},
|
},
|
||||||
|
-- The extended record, as Settings stores it.
|
||||||
|
["DP-3"] = {
|
||||||
|
mode = "1920x1080@60", scale = 1, transform = 0,
|
||||||
|
x = 4440, y = 0, primary = false,
|
||||||
|
colorProfile = "srgb", bitdepth = 10,
|
||||||
|
sdrBrightness = 1.2, sdrSaturation = 1.0,
|
||||||
|
vrrMode = 2, mirrorOf = "DP-2",
|
||||||
|
},
|
||||||
|
-- Every new field impossible at once. Geometry is fine, so the
|
||||||
|
-- display is still configured; the bad fields drop out one by
|
||||||
|
-- one exactly as an invalid position does.
|
||||||
|
["DP-4"] = {
|
||||||
|
mode = "1920x1080@60", scale = 1, transform = 0,
|
||||||
|
x = 6360, y = 0, primary = false,
|
||||||
|
colorProfile = "neon", bitdepth = 12,
|
||||||
|
sdrBrightness = 9, sdrSaturation = -1,
|
||||||
|
vrrMode = 7, mirrorOf = "BAD OUTPUT",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
end,
|
end,
|
||||||
}
|
}
|
||||||
@@ -139,9 +168,38 @@ assert(by_output["PARTIAL-1"] == nil)
|
|||||||
assert(by_output["BAD-PRIMARY"] == nil)
|
assert(by_output["BAD-PRIMARY"] == nil)
|
||||||
assert(by_output["BAD OUTPUT"] == nil)
|
assert(by_output["BAD OUTPUT"] == nil)
|
||||||
assert(by_output[""] ~= nil)
|
assert(by_output[""] ~= nil)
|
||||||
|
|
||||||
|
-- The extended record reaches the compositor under Hyprland's own key names.
|
||||||
|
assert(by_output["DP-3"].cm == "srgb")
|
||||||
|
assert(by_output["DP-3"].bitdepth == 10)
|
||||||
|
assert(by_output["DP-3"].sdrbrightness == 1.2)
|
||||||
|
assert(by_output["DP-3"].vrr == 2)
|
||||||
|
assert(by_output["DP-3"].mirror == "DP-2")
|
||||||
|
-- A mirrored output shows its target's picture in its target's place, so the
|
||||||
|
-- saved position is not ours to ask for.
|
||||||
|
assert(by_output["DP-3"].position == "auto")
|
||||||
|
-- Neutral SDR saturation is left out rather than written: a rule that names it
|
||||||
|
-- pins the display to it.
|
||||||
|
assert(by_output["DP-3"].sdrsaturation == nil)
|
||||||
|
|
||||||
|
-- -1 means "follow the global policy", which is the absence of an override,
|
||||||
|
-- not an override with a special value. Writing a vrr key here would silently
|
||||||
|
-- take this display out of the gaming policy it is meant to follow.
|
||||||
|
assert(by_output["HDMI-A-1"].vrr == nil)
|
||||||
|
|
||||||
|
-- Bad fields drop, the display survives. This is the same fallback invalid
|
||||||
|
-- geometry gets, and it matters more here: refusing the whole entry over an
|
||||||
|
-- unreadable colour profile would lose the resolution too.
|
||||||
|
assert(by_output["DP-4"] ~= nil)
|
||||||
|
assert(by_output["DP-4"].position == "6360x0")
|
||||||
|
assert(by_output["DP-4"].cm == nil)
|
||||||
|
assert(by_output["DP-4"].bitdepth == nil)
|
||||||
|
assert(by_output["DP-4"].sdrbrightness == nil)
|
||||||
|
assert(by_output["DP-4"].vrr == nil)
|
||||||
|
assert(by_output["DP-4"].mirror == nil)
|
||||||
LUA
|
LUA
|
||||||
|
|
||||||
rg -Fq 'Resolution, scale, rotation, position, and primary display' \
|
rg -Fq 'Resolution, scale, rotation, position, primary display, color, VRR override, and mirroring' \
|
||||||
"$repo_dir/config/dot/quickshell/config/PreferenceSchema.qml" \
|
"$repo_dir/config/dot/quickshell/config/PreferenceSchema.qml" \
|
||||||
|| fail 'the display preference does not document complete layout persistence'
|
|| fail 'the display preference does not document complete layout persistence'
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user