Make displays configurable, with a revert countdown
Resolution, refresh rate, scale, and rotation, applied through
hl.monitor{} and stored per output.
This is the only setting in Panama where a wrong value can leave the
user unable to SEE the screen well enough to undo it: a mode the panel
cannot show, or a scale that makes everything unreadable, is not
recoverable through the UI that caused it. So a change is never applied
irreversibly. It is applied, then reverted automatically after fifteen
seconds unless confirmed, and confirming is what writes it to the
settings store -- letting the countdown run leaves nothing behind.
The contract tests that property specifically: it applies a scale, waits
out the countdown, and asserts the display came back and that nothing
was stored. A regression there is not a broken feature, it is a user
staring at a blank monitor.
Modes are grouped by resolution with refresh rates beside them. The
panel reports 35, many differing only in refresh-rate rounding -- 60.00
and 59.94 -- which as a flat list of buttons is noise rather than
choice; equal rounded pairs collapse, leaving 21.
Only mode, scale, and transform are configurable. Colour management and
bit depth stay in monitors.lua because they carry a documented screencopy
tradeoff that a settings page cannot explain at the moment you would be
changing it.
Also replaces the display policy rows with the schema-bound ones, so the
page no longer restates labels that PreferenceSchema already holds.
Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
This commit is contained in:
@@ -17,11 +17,35 @@
|
|||||||
-- overrides.lua) and read the notes in that file first.
|
-- overrides.lua) and read the notes in that file first.
|
||||||
-- ─────────────────────────────────────────────────────────────────────────────
|
-- ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
local prefs = require("prefs")
|
||||||
|
|
||||||
|
-- Per-output overrides written by Panama Settings, keyed by output name:
|
||||||
|
-- { ["DP-2"] = { mode = "3840x2160@60", scale = 2, transform = 0 } }
|
||||||
|
--
|
||||||
|
-- Only mode, scale, and transform are read. Colour management and bit depth
|
||||||
|
-- stay here, because those are the settings with a documented reason attached
|
||||||
|
-- (see the header) rather than preferences, and a settings page has no way to
|
||||||
|
-- explain the screencopy tradeoff at the moment you would be changing it.
|
||||||
|
local displays = prefs.get("displays", {})
|
||||||
|
|
||||||
|
local function override(output, field, fallback)
|
||||||
|
local entry = displays[output]
|
||||||
|
if type(entry) ~= "table" then
|
||||||
|
return fallback
|
||||||
|
end
|
||||||
|
local value = entry[field]
|
||||||
|
if value == nil or type(value) ~= type(fallback) then
|
||||||
|
return fallback
|
||||||
|
end
|
||||||
|
return value
|
||||||
|
end
|
||||||
|
|
||||||
hl.monitor({
|
hl.monitor({
|
||||||
output = "DP-2",
|
output = "DP-2",
|
||||||
mode = "4500x3000@60",
|
mode = override("DP-2", "mode", "4500x3000@60"),
|
||||||
position = "0x0",
|
position = "0x0",
|
||||||
scale = 1.5,
|
scale = override("DP-2", "scale", 1.5),
|
||||||
|
transform = override("DP-2", "transform", 0),
|
||||||
|
|
||||||
-- 10-bit output. 4500x3000@60 at 10bpc is ~24 Gbps, right at the edge of
|
-- 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
|
-- DP 1.4 HBR3, so this relies on DSC. If the display fails to light up or
|
||||||
|
|||||||
@@ -508,6 +508,19 @@ Singleton {
|
|||||||
detail: "Shortcuts you have moved from their shipped chord"
|
detail: "Shortcuts you have moved from their shipped chord"
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ── Display configuration ───────────────────────────────────────────
|
||||||
|
// { "<output>": { mode, scale, transform } }, applied by
|
||||||
|
// hypr/monitors.lua on top of the shipped values. Colour management and
|
||||||
|
// bit depth are deliberately not here: those carry a documented
|
||||||
|
// screencopy tradeoff that a settings page cannot explain at the moment
|
||||||
|
// you would be changing it.
|
||||||
|
{
|
||||||
|
key: "displays", type: "json", def: ({}), group: "display",
|
||||||
|
internal: true,
|
||||||
|
label: "Display configuration",
|
||||||
|
detail: "Resolution, scale, and rotation per connected display"
|
||||||
|
},
|
||||||
|
|
||||||
// ── Internal ────────────────────────────────────────────────────────
|
// ── Internal ────────────────────────────────────────────────────────
|
||||||
{
|
{
|
||||||
key: "lastPage", type: "string", def: "home", group: "internal",
|
key: "lastPage", type: "string", def: "home", group: "internal",
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import Quickshell
|
||||||
|
import Quickshell.Io
|
||||||
|
import QtQuick
|
||||||
|
|
||||||
|
import qs.config
|
||||||
|
import qs.services
|
||||||
|
|
||||||
|
ShellRoot {
|
||||||
|
IpcHandler {
|
||||||
|
target: "displays-test"
|
||||||
|
|
||||||
|
function status(): string {
|
||||||
|
const monitor = Displays.monitors.length > 0 ? Displays.monitors[0] : null;
|
||||||
|
return JSON.stringify({
|
||||||
|
count: Displays.monitors.length,
|
||||||
|
name: monitor ? monitor.name : "",
|
||||||
|
width: monitor ? monitor.width : 0,
|
||||||
|
height: monitor ? monitor.height : 0,
|
||||||
|
refresh: monitor ? Math.round(monitor.refreshRate) : 0,
|
||||||
|
scale: monitor ? monitor.scale : 0,
|
||||||
|
transform: monitor ? monitor.transform : -1,
|
||||||
|
modes: monitor ? monitor.modes.length : 0,
|
||||||
|
awaiting: Displays.awaitingConfirmation,
|
||||||
|
secondsLeft: Displays.secondsLeft,
|
||||||
|
lastError: Displays.lastError,
|
||||||
|
overridden: monitor ? Displays.isOverridden(monitor.name) : false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyScale(scale: real): bool {
|
||||||
|
const monitor = Displays.monitors[0];
|
||||||
|
if (!monitor) return false;
|
||||||
|
const mode = monitor.width + "x" + monitor.height + "@" + Math.round(monitor.refreshRate);
|
||||||
|
return Displays.apply(monitor.name, mode, scale, monitor.transform);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyBad(kind: string): bool {
|
||||||
|
const monitor = Displays.monitors[0];
|
||||||
|
if (!monitor) return false;
|
||||||
|
const mode = monitor.width + "x" + monitor.height + "@" + Math.round(monitor.refreshRate);
|
||||||
|
if (kind === "mode") return Displays.apply(monitor.name, "9999x9999@240", monitor.scale, monitor.transform);
|
||||||
|
if (kind === "scale") return Displays.apply(monitor.name, mode, 1.37, monitor.transform);
|
||||||
|
if (kind === "transform") return Displays.apply(monitor.name, mode, monitor.scale, 9);
|
||||||
|
if (kind === "output") return Displays.apply("NOPE-1", mode, monitor.scale, monitor.transform);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmChange(): void { Displays.confirm(); }
|
||||||
|
function revertChange(): void { Displays.revert(); }
|
||||||
|
function forget(): void {
|
||||||
|
const monitor = Displays.monitors[0];
|
||||||
|
if (monitor) Displays.forget(monitor.name);
|
||||||
|
}
|
||||||
|
function refresh(): void { Displays.refresh(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
// A row of choices that wraps, for options that do not fit a segmented control.
|
||||||
|
//
|
||||||
|
// ChoiceRow puts two or three options on one line. Scales and rotations are
|
||||||
|
// more numerous and their labels are wider, so they wrap into a grid rather
|
||||||
|
// than shrinking to illegibility on a narrow, tiled window.
|
||||||
|
//
|
||||||
|
// Unlike ChoiceRow this is not schema-bound: it reports a value and lets the
|
||||||
|
// caller decide what to do with it, because a display change has to go through
|
||||||
|
// an apply-then-confirm cycle rather than straight into the store.
|
||||||
|
|
||||||
|
import QtQuick
|
||||||
|
import qs.config
|
||||||
|
|
||||||
|
Column {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
property string label: ""
|
||||||
|
property string detail: ""
|
||||||
|
property var options: []
|
||||||
|
property var current: null
|
||||||
|
property bool enabled: true
|
||||||
|
property bool divider: true
|
||||||
|
|
||||||
|
signal picked(var value)
|
||||||
|
|
||||||
|
spacing: 9
|
||||||
|
bottomPadding: 12
|
||||||
|
|
||||||
|
Column {
|
||||||
|
width: parent.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
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
width: parent.width
|
||||||
|
visible: root.detail !== ""
|
||||||
|
text: root.detail
|
||||||
|
color: Theme.fgDim
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
wrapMode: Text.WordWrap
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Flow {
|
||||||
|
width: parent.width
|
||||||
|
spacing: 7
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: root.options
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
id: option
|
||||||
|
|
||||||
|
required property var modelData
|
||||||
|
|
||||||
|
readonly property bool selected: root.current === option.modelData.value
|
||||||
|
|
||||||
|
implicitWidth: Math.max(78, caption.implicitWidth + 26)
|
||||||
|
implicitHeight: 32
|
||||||
|
radius: 9
|
||||||
|
opacity: root.enabled ? 1 : 0.45
|
||||||
|
color: option.selected ? "transparent" : Theme.alpha(Theme.fg, hover.hovered && root.enabled ? 0.11 : 0.06)
|
||||||
|
border.width: option.selected ? 1 : 0
|
||||||
|
border.color: Theme.alpha(Theme.accent, 0.5)
|
||||||
|
|
||||||
|
// The prism marks the selection here as everywhere else.
|
||||||
|
Rectangle {
|
||||||
|
anchors.fill: parent
|
||||||
|
radius: parent.radius
|
||||||
|
visible: option.selected
|
||||||
|
border.width: 0
|
||||||
|
gradient: Gradient {
|
||||||
|
orientation: Gradient.Horizontal
|
||||||
|
GradientStop { position: 0.0; color: Theme.alpha(Theme.accent, 0.28) }
|
||||||
|
GradientStop { position: 1.0; color: Theme.alpha(Theme.accentSecondary, 0.28) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
id: caption
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: option.modelData.label
|
||||||
|
color: option.selected ? Theme.fg : Theme.fgDim
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.features: Theme.tabularFigures
|
||||||
|
font.pixelSize: Theme.fontSize
|
||||||
|
font.weight: option.selected ? Font.DemiBold : Font.Normal
|
||||||
|
}
|
||||||
|
|
||||||
|
HoverHandler {
|
||||||
|
id: hover
|
||||||
|
enabled: root.enabled
|
||||||
|
cursorShape: Qt.PointingHandCursor
|
||||||
|
}
|
||||||
|
|
||||||
|
TapHandler {
|
||||||
|
enabled: root.enabled && !option.selected
|
||||||
|
onTapped: root.picked(option.modelData.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
width: parent.width
|
||||||
|
height: 1
|
||||||
|
visible: root.divider
|
||||||
|
color: Theme.alpha(Theme.fg, 0.065)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
// The resolution list for one display.
|
||||||
|
//
|
||||||
|
// Grouped by resolution with refresh rates beside it, rather than a flat list
|
||||||
|
// of "[email protected]" strings: this panel reports 35 modes, many of which
|
||||||
|
// differ only in refresh-rate rounding, and a flat list of those is a wall of
|
||||||
|
// near-identical text rather than a choice.
|
||||||
|
|
||||||
|
import QtQuick
|
||||||
|
import qs.config
|
||||||
|
import qs.services
|
||||||
|
|
||||||
|
Column {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
property var monitor: null
|
||||||
|
property bool enabled: true
|
||||||
|
|
||||||
|
spacing: 0
|
||||||
|
|
||||||
|
readonly property var grouped: {
|
||||||
|
if (!root.monitor)
|
||||||
|
return [];
|
||||||
|
const buckets = {};
|
||||||
|
const order = [];
|
||||||
|
for (const mode of root.monitor.modes) {
|
||||||
|
const key = mode.label;
|
||||||
|
if (!buckets[key]) {
|
||||||
|
buckets[key] = { label: key, width: mode.width, height: mode.height, rates: [] };
|
||||||
|
order.push(key);
|
||||||
|
}
|
||||||
|
buckets[key].rates.push(mode);
|
||||||
|
}
|
||||||
|
return order.map(key => buckets[key]);
|
||||||
|
}
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: root.grouped
|
||||||
|
|
||||||
|
SettingRow {
|
||||||
|
id: resolution
|
||||||
|
|
||||||
|
required property var modelData
|
||||||
|
required property int index
|
||||||
|
|
||||||
|
readonly property bool isCurrent: root.monitor
|
||||||
|
&& root.monitor.width === resolution.modelData.width
|
||||||
|
&& root.monitor.height === resolution.modelData.height
|
||||||
|
|
||||||
|
label: resolution.modelData.label
|
||||||
|
detail: resolution.isCurrent ? "Current resolution" : ""
|
||||||
|
controlWidth: Math.max(120, resolution.modelData.rates.length * 84)
|
||||||
|
divider: resolution.index < root.grouped.length - 1
|
||||||
|
|
||||||
|
Row {
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
spacing: 6
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: resolution.modelData.rates
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
id: rate
|
||||||
|
|
||||||
|
required property var modelData
|
||||||
|
|
||||||
|
readonly property bool selected: root.monitor
|
||||||
|
&& resolution.isCurrent
|
||||||
|
&& Math.round(root.monitor.refreshRate) === rate.modelData.refresh
|
||||||
|
|
||||||
|
implicitWidth: Math.max(74, rateCaption.implicitWidth + 22)
|
||||||
|
implicitHeight: 30
|
||||||
|
radius: 9
|
||||||
|
opacity: root.enabled ? 1 : 0.45
|
||||||
|
color: rate.selected ? "transparent" : Theme.alpha(Theme.fg, rateHover.hovered && root.enabled ? 0.11 : 0.06)
|
||||||
|
border.width: rate.selected ? 1 : 0
|
||||||
|
border.color: Theme.alpha(Theme.accent, 0.5)
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
anchors.fill: parent
|
||||||
|
radius: parent.radius
|
||||||
|
visible: rate.selected
|
||||||
|
border.width: 0
|
||||||
|
gradient: Gradient {
|
||||||
|
orientation: Gradient.Horizontal
|
||||||
|
GradientStop { position: 0.0; color: Theme.alpha(Theme.accent, 0.28) }
|
||||||
|
GradientStop { position: 1.0; color: Theme.alpha(Theme.accentSecondary, 0.28) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
id: rateCaption
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: rate.modelData.refreshLabel
|
||||||
|
color: rate.selected ? Theme.fg : Theme.fgDim
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.features: Theme.tabularFigures
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
font.weight: rate.selected ? Font.DemiBold : Font.Normal
|
||||||
|
}
|
||||||
|
|
||||||
|
HoverHandler {
|
||||||
|
id: rateHover
|
||||||
|
enabled: root.enabled
|
||||||
|
cursorShape: Qt.PointingHandCursor
|
||||||
|
}
|
||||||
|
|
||||||
|
TapHandler {
|
||||||
|
enabled: root.enabled && !rate.selected
|
||||||
|
onTapped: Displays.apply(
|
||||||
|
root.monitor.name,
|
||||||
|
rate.modelData.mode,
|
||||||
|
root.monitor.scale,
|
||||||
|
root.monitor.transform)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,81 +1,177 @@
|
|||||||
|
// Displays.
|
||||||
|
//
|
||||||
|
// Resolution, refresh rate, scale, and rotation, plus the gaming display
|
||||||
|
// policy that was already here.
|
||||||
|
//
|
||||||
|
// Every geometry change goes through an apply-then-confirm countdown. This is
|
||||||
|
// the one page where a wrong value can leave the screen unreadable or blank,
|
||||||
|
// and no other control in the app can undo it once that happens. Confirming is
|
||||||
|
// what writes the choice to the settings store; letting the countdown run
|
||||||
|
// leaves nothing behind.
|
||||||
|
|
||||||
import QtQuick
|
import QtQuick
|
||||||
import qs.config
|
import qs.config
|
||||||
import qs.services
|
import qs.services
|
||||||
import qs.widgets
|
import qs.widgets
|
||||||
|
|
||||||
SettingsPage {
|
SettingsPage {
|
||||||
|
id: root
|
||||||
|
|
||||||
title: "Displays"
|
title: "Displays"
|
||||||
lede: SystemSettings.monitorDescription || "Reading the active display…"
|
lede: SystemSettings.monitorDescription || "Reading the active display…"
|
||||||
|
|
||||||
SettingsCard {
|
readonly property var monitor: Displays.monitors.length > 0 ? Displays.monitors[0] : null
|
||||||
title: SystemSettings.monitorName || "Active display"
|
|
||||||
subtitle: `${SystemSettings.monitorWidth} × ${SystemSettings.monitorHeight} at ${SystemSettings.monitorRefreshRate.toFixed(0)} Hz · ${SystemSettings.monitorScale.toFixed(1)}× scale · ${SystemSettings.monitorFormat}`
|
|
||||||
TextRow { label: "Color mode"; detail: "Wide-gamut SDR desktop at 10-bit"; value: SystemSettings.colorPreset || "wide" }
|
|
||||||
TextRow { label: "Variable refresh"; detail: SystemSettings.monitorVrrActive ? "Active for current fullscreen content" : "Ready when game or video content requests it"; value: SystemSettings.monitorVrrActive ? "Active" : "Standby"; divider: false }
|
|
||||||
}
|
|
||||||
|
|
||||||
SettingsCard {
|
|
||||||
title: "Gaming display policy"
|
|
||||||
subtitle: "These values apply immediately and are restored when Panama starts."
|
|
||||||
SettingRow {
|
|
||||||
label: "Game-aware HDR"
|
|
||||||
detail: "Enter HDR only for fullscreen content that requests it"
|
|
||||||
controlWidth: 48
|
|
||||||
SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: SystemSettings.autoHdr; onToggled: value => SystemSettings.setAutoHdr(value) }
|
|
||||||
}
|
|
||||||
SettingRow {
|
|
||||||
label: "Content-aware VRR"
|
|
||||||
detail: "Enable variable refresh only for game and video content"
|
|
||||||
controlWidth: 48
|
|
||||||
SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: SystemSettings.vrrPolicy === 3; onToggled: value => SystemSettings.setVrrPolicy(value ? 3 : 0) }
|
|
||||||
}
|
|
||||||
SettingRow {
|
|
||||||
label: "Direct scanout for games"
|
|
||||||
detail: "Bypass compositing only for windows classified as games"
|
|
||||||
divider: false
|
|
||||||
controlWidth: 48
|
|
||||||
SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: SystemSettings.directScanoutPolicy === 2; onToggled: value => SystemSettings.setDirectScanoutPolicy(value ? 2 : 0) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
SettingsCard {
|
|
||||||
title: "Night Light"
|
|
||||||
SettingRow {
|
|
||||||
label: "Warm display colors"
|
|
||||||
detail: NightLight.automatic ? "Following the evening schedule" : "Manual control"
|
|
||||||
controlWidth: 48
|
|
||||||
SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: NightLight.active; onToggled: NightLight.toggle() }
|
|
||||||
}
|
|
||||||
SettingRow {
|
|
||||||
label: "Color temperature"
|
|
||||||
detail: `${NightLight.temperature} K`
|
|
||||||
divider: false
|
|
||||||
controlWidth: 230
|
|
||||||
ValueSlider {
|
|
||||||
anchors.fill: parent
|
|
||||||
value: (6500 - NightLight.temperature) / 4000
|
|
||||||
icon: "weather-clear-night-symbolic"
|
|
||||||
onMoved: value => NightLight.temperature = Math.round((6500 - value * 4000) / 50) * 50
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// The confirmation sits above everything, because while it is counting down
|
||||||
|
// it is the only thing that matters on this page.
|
||||||
|
header: Component {
|
||||||
Rectangle {
|
Rectangle {
|
||||||
width: parent.width
|
visible: Displays.awaitingConfirmation
|
||||||
height: warningText.implicitHeight + 30
|
implicitHeight: visible ? confirmRow.implicitHeight + 28 : 0
|
||||||
radius: Theme.cardRadius
|
radius: Theme.cardRadius
|
||||||
color: Theme.alpha(Theme.warn, 0.085)
|
color: Theme.mix(Theme.bgPanel, Theme.warn, 0.12)
|
||||||
border.width: 1
|
border.width: 1
|
||||||
border.color: Theme.alpha(Theme.warn, 0.22)
|
border.color: Theme.alpha(Theme.warn, 0.4)
|
||||||
|
|
||||||
|
Row {
|
||||||
|
id: confirmRow
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
anchors.margins: 16
|
||||||
|
spacing: 14
|
||||||
|
|
||||||
|
Column {
|
||||||
|
width: parent.width - keepButton.width - revertButton.width - 28
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
spacing: 3
|
||||||
|
|
||||||
Text {
|
Text {
|
||||||
id: warningText
|
width: parent.width
|
||||||
anchors.fill: parent
|
text: "Keep this display setting?"
|
||||||
anchors.margins: 15
|
color: Theme.fg
|
||||||
text: "Full-time desktop HDR stays unavailable here because the current compositor path can break screenshots, OBS, Sunshine, and lock-screen capture. Game-aware HDR keeps the desktop dependable without giving up HDR games."
|
|
||||||
color: Theme.mix(Theme.fg, Theme.warn, 0.25)
|
|
||||||
font.family: Theme.fontFamily
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSize
|
||||||
|
font.weight: Font.DemiBold
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
width: parent.width
|
||||||
|
text: "Reverting in " + Displays.secondsLeft + (Displays.secondsLeft === 1 ? " second" : " seconds")
|
||||||
|
+ " if you do nothing. If you cannot read this, just wait."
|
||||||
|
color: Theme.fgDim
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.features: Theme.tabularFigures
|
||||||
font.pixelSize: Theme.fontSizeSmall
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
wrapMode: Text.WordWrap
|
wrapMode: Text.WordWrap
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SettingsButton {
|
||||||
|
id: revertButton
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
text: "Revert now"
|
||||||
|
onClicked: Displays.revert()
|
||||||
|
}
|
||||||
|
SettingsButton {
|
||||||
|
id: keepButton
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
text: "Keep"
|
||||||
|
onClicked: Displays.confirm()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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: SystemSettings.colorPreset || "wide"
|
||||||
|
}
|
||||||
|
TextRow {
|
||||||
|
label: "Variable refresh"
|
||||||
|
detail: SystemSettings.monitorVrrActive
|
||||||
|
? "Active for current fullscreen content"
|
||||||
|
: "Ready when game or video content requests it"
|
||||||
|
value: SystemSettings.monitorVrrActive ? "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 resolution and scale Panama ships"
|
||||||
|
action: "Forget"
|
||||||
|
divider: false
|
||||||
|
onTriggered: Displays.forget(root.monitor.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsCard {
|
||||||
|
visible: root.monitor !== null
|
||||||
|
title: "Resolution"
|
||||||
|
subtitle: "Applied straight away, then reverted automatically unless you confirm."
|
||||||
|
|
||||||
|
DisplayModePicker {
|
||||||
|
width: parent.width
|
||||||
|
monitor: root.monitor
|
||||||
|
enabled: !Displays.awaitingConfirmation && !Displays.busy
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsCard {
|
||||||
|
visible: root.monitor !== null
|
||||||
|
title: "Scale and rotation"
|
||||||
|
|
||||||
|
ChoiceGrid {
|
||||||
|
width: parent.width
|
||||||
|
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.scales.map(scale => ({ value: scale, label: scale.toFixed(2) + "×" }))
|
||||||
|
current: root.monitor ? root.monitor.scale : 1
|
||||||
|
enabled: !Displays.awaitingConfirmation && !Displays.busy
|
||||||
|
onPicked: value => root.applyWith({ scale: value })
|
||||||
|
}
|
||||||
|
|
||||||
|
ChoiceGrid {
|
||||||
|
width: parent.width
|
||||||
|
label: "Rotation"
|
||||||
|
options: Displays.transforms
|
||||||
|
current: root.monitor ? root.monitor.transform : 0
|
||||||
|
enabled: !Displays.awaitingConfirmation && !Displays.busy
|
||||||
|
divider: false
|
||||||
|
onPicked: value => root.applyWith({ transform: value })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsCard {
|
||||||
|
title: "Gaming display policy"
|
||||||
|
subtitle: "Applied immediately and restored when Panama starts."
|
||||||
|
|
||||||
|
ToggleRow { setting: "autoHdr" }
|
||||||
|
ChoiceRow { setting: "vrrPolicy" }
|
||||||
|
ChoiceRow { setting: "directScanoutPolicy"; divider: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsCard {
|
||||||
|
visible: Displays.lastError !== ""
|
||||||
|
title: "Display problem"
|
||||||
|
subtitle: Displays.lastError
|
||||||
|
}
|
||||||
|
|
||||||
|
// Applies a change to one field, keeping the others at what is in effect.
|
||||||
|
function applyWith(change: var): void {
|
||||||
|
if (!root.monitor)
|
||||||
|
return;
|
||||||
|
const current = `${root.monitor.width}x${root.monitor.height}@${Math.round(root.monitor.refreshRate)}`;
|
||||||
|
Displays.apply(
|
||||||
|
root.monitor.name,
|
||||||
|
change.mode ?? current,
|
||||||
|
change.scale ?? root.monitor.scale,
|
||||||
|
change.transform ?? root.monitor.transform);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,3 +35,5 @@ ApplicationsPage 1.0 ApplicationsPage.qml
|
|||||||
DockPinsEditor 1.0 DockPinsEditor.qml
|
DockPinsEditor 1.0 DockPinsEditor.qml
|
||||||
DockAppPicker 1.0 DockAppPicker.qml
|
DockAppPicker 1.0 DockAppPicker.qml
|
||||||
ShortcutCapture 1.0 ShortcutCapture.qml
|
ShortcutCapture 1.0 ShortcutCapture.qml
|
||||||
|
ChoiceGrid 1.0 ChoiceGrid.qml
|
||||||
|
DisplayModePicker 1.0 DisplayModePicker.qml
|
||||||
|
|||||||
@@ -0,0 +1,239 @@
|
|||||||
|
pragma Singleton
|
||||||
|
|
||||||
|
// Display configuration: resolution, refresh rate, scale, and rotation.
|
||||||
|
//
|
||||||
|
// 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
|
||||||
|
// show, or a scale that makes everything unreadable, is not recoverable through
|
||||||
|
// the same UI that caused it.
|
||||||
|
//
|
||||||
|
// So a change is never applied irreversibly. It is applied, then reverted
|
||||||
|
// automatically after a countdown unless confirmed -- the same contract every
|
||||||
|
// desktop uses for this one setting, and for the same reason. Confirming is
|
||||||
|
// what writes it to the settings store; letting the countdown run leaves
|
||||||
|
// nothing behind.
|
||||||
|
//
|
||||||
|
// Applied with `hyprctl eval` and hl.monitor{}. As everywhere else in Panama,
|
||||||
|
// success means the value was read back from the compositor and matched, never
|
||||||
|
// that a command exited zero.
|
||||||
|
|
||||||
|
import Quickshell
|
||||||
|
import Quickshell.Io
|
||||||
|
import QtQuick
|
||||||
|
import qs.config
|
||||||
|
|
||||||
|
Singleton {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
// [{ name, description, width, height, refreshRate, scale, transform,
|
||||||
|
// modes: [{ label, mode, width, height, refresh }] }]
|
||||||
|
property var monitors: []
|
||||||
|
property string lastError: ""
|
||||||
|
|
||||||
|
// Set while a change is applied but not yet confirmed.
|
||||||
|
property string pendingOutput: ""
|
||||||
|
property var pendingPrevious: null
|
||||||
|
property int secondsLeft: 0
|
||||||
|
|
||||||
|
readonly property bool awaitingConfirmation: root.pendingOutput !== ""
|
||||||
|
readonly property bool busy: query.running || applyRun.running
|
||||||
|
|
||||||
|
readonly property int confirmSeconds: 15
|
||||||
|
|
||||||
|
readonly property var transforms: [
|
||||||
|
{ value: 0, label: "Landscape" },
|
||||||
|
{ value: 1, label: "Portrait" },
|
||||||
|
{ value: 2, label: "Landscape (flipped)" },
|
||||||
|
{ value: 3, label: "Portrait (flipped)" }
|
||||||
|
]
|
||||||
|
|
||||||
|
// Scales that divide this desktop's common resolutions into whole pixels.
|
||||||
|
// Hyprland rejects a fractional scale that does not, and the message it
|
||||||
|
// gives is not something to put in front of a user.
|
||||||
|
readonly property var scales: [1.0, 1.25, 1.5, 1.75, 2.0, 2.5, 3.0]
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: query
|
||||||
|
command: ["hyprctl", "-j", "monitors"]
|
||||||
|
stdout: StdioCollector {
|
||||||
|
onStreamFinished: root.parse(this.text)
|
||||||
|
}
|
||||||
|
onExited: (exitCode, exitStatus) => {
|
||||||
|
if (exitCode !== 0)
|
||||||
|
root.lastError = "Could not read the connected displays.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: applyRun
|
||||||
|
onExited: (exitCode, exitStatus) => root.refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
Component.onCompleted: root.refresh()
|
||||||
|
|
||||||
|
function refresh(): void {
|
||||||
|
if (!query.running)
|
||||||
|
query.running = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parse(text: string): void {
|
||||||
|
try {
|
||||||
|
const raw = JSON.parse(text);
|
||||||
|
root.monitors = raw.map(monitor => ({
|
||||||
|
name: monitor.name ?? "",
|
||||||
|
description: monitor.description ?? monitor.model ?? "Display",
|
||||||
|
width: monitor.width ?? 0,
|
||||||
|
height: monitor.height ?? 0,
|
||||||
|
refreshRate: monitor.refreshRate ?? 0,
|
||||||
|
scale: monitor.scale ?? 1,
|
||||||
|
transform: monitor.transform ?? 0,
|
||||||
|
modes: root.normaliseModes(monitor.availableModes ?? [])
|
||||||
|
}));
|
||||||
|
root.lastError = "";
|
||||||
|
} catch (error) {
|
||||||
|
root.lastError = "The display list could not be read.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// "[email protected]" -> a sortable record. The compositor reports the same
|
||||||
|
// resolution several times at refresh rates that differ only in rounding
|
||||||
|
// (60.00 and 59.94), which as a list of buttons is noise rather than
|
||||||
|
// choice, so equal rounded pairs collapse to one.
|
||||||
|
function normaliseModes(raw: var): var {
|
||||||
|
const seen = {};
|
||||||
|
const out = [];
|
||||||
|
for (const entry of raw) {
|
||||||
|
const match = String(entry).match(/^(\d+)x(\d+)@([\d.]+)Hz$/);
|
||||||
|
if (!match)
|
||||||
|
continue;
|
||||||
|
const width = Number(match[1]);
|
||||||
|
const height = Number(match[2]);
|
||||||
|
const refresh = Math.round(Number(match[3]));
|
||||||
|
const key = `${width}x${height}@${refresh}`;
|
||||||
|
if (seen[key])
|
||||||
|
continue;
|
||||||
|
seen[key] = true;
|
||||||
|
out.push({
|
||||||
|
label: `${width} × ${height}`,
|
||||||
|
refreshLabel: `${refresh} Hz`,
|
||||||
|
mode: key,
|
||||||
|
width: width,
|
||||||
|
height: height,
|
||||||
|
refresh: refresh
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out.sort((a, b) => (b.width * b.height) - (a.width * a.height) || b.refresh - a.refresh);
|
||||||
|
}
|
||||||
|
|
||||||
|
function monitorNamed(name: string): var {
|
||||||
|
return root.monitors.find(monitor => monitor.name === name) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Applies immediately and starts the countdown. Nothing is stored yet: the
|
||||||
|
// settings file is only written by confirm().
|
||||||
|
function apply(output: string, mode: string, scale: real, transform: int): bool {
|
||||||
|
if (root.awaitingConfirmation) {
|
||||||
|
root.lastError = "Finish the current display change first.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const monitor = root.monitorNamed(output);
|
||||||
|
if (!monitor) {
|
||||||
|
root.lastError = "That display is not connected.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!monitor.modes.some(candidate => candidate.mode === mode)) {
|
||||||
|
root.lastError = "That display does not offer that mode.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (root.scales.indexOf(scale) < 0) {
|
||||||
|
root.lastError = "That scale is not one Panama offers.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!root.transforms.some(candidate => candidate.value === transform)) {
|
||||||
|
root.lastError = "That rotation is not one Panama offers.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
root.pendingPrevious = {
|
||||||
|
output: output,
|
||||||
|
mode: `${monitor.width}x${monitor.height}@${Math.round(monitor.refreshRate)}`,
|
||||||
|
scale: monitor.scale,
|
||||||
|
transform: monitor.transform
|
||||||
|
};
|
||||||
|
root.pendingOutput = output;
|
||||||
|
root.secondsLeft = root.confirmSeconds;
|
||||||
|
countdown.restart();
|
||||||
|
|
||||||
|
root.push(output, mode, scale, transform);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function push(output: string, mode: string, scale: real, transform: int): void {
|
||||||
|
// Values are validated above and the output name comes from the
|
||||||
|
// compositor's own list, so nothing user-authored reaches the payload.
|
||||||
|
applyRun.exec(["hyprctl", "eval",
|
||||||
|
`hl.monitor({ output = "${output}", mode = "${mode}", scale = ${scale}, transform = ${transform} })`]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirm(): void {
|
||||||
|
if (!root.awaitingConfirmation)
|
||||||
|
return;
|
||||||
|
const monitor = root.monitorNamed(root.pendingOutput);
|
||||||
|
countdown.stop();
|
||||||
|
|
||||||
|
if (monitor) {
|
||||||
|
const stored = DesktopPreferences.get("displays");
|
||||||
|
const next = Object.assign({}, (stored && typeof stored === "object") ? stored : {});
|
||||||
|
next[root.pendingOutput] = {
|
||||||
|
mode: `${monitor.width}x${monitor.height}@${Math.round(monitor.refreshRate)}`,
|
||||||
|
scale: monitor.scale,
|
||||||
|
transform: monitor.transform
|
||||||
|
};
|
||||||
|
DesktopPreferences.set("displays", next);
|
||||||
|
}
|
||||||
|
|
||||||
|
root.pendingOutput = "";
|
||||||
|
root.pendingPrevious = null;
|
||||||
|
root.secondsLeft = 0;
|
||||||
|
root.lastError = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function revert(): void {
|
||||||
|
if (!root.awaitingConfirmation)
|
||||||
|
return;
|
||||||
|
const previous = root.pendingPrevious;
|
||||||
|
countdown.stop();
|
||||||
|
root.pendingOutput = "";
|
||||||
|
root.pendingPrevious = null;
|
||||||
|
root.secondsLeft = 0;
|
||||||
|
if (previous)
|
||||||
|
root.push(previous.output, previous.mode, previous.scale, previous.transform);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clears any stored override for an output so it returns to the value
|
||||||
|
// shipped in hypr/monitors.lua on the next start.
|
||||||
|
function forget(output: string): void {
|
||||||
|
const stored = DesktopPreferences.get("displays");
|
||||||
|
if (!stored || typeof stored !== "object" || stored[output] === undefined)
|
||||||
|
return;
|
||||||
|
const next = Object.assign({}, stored);
|
||||||
|
delete next[output];
|
||||||
|
DesktopPreferences.set("displays", next);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isOverridden(output: string): bool {
|
||||||
|
const stored = DesktopPreferences.get("displays");
|
||||||
|
return !!(stored && typeof stored === "object" && stored[output] !== undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
id: countdown
|
||||||
|
interval: 1000
|
||||||
|
repeat: true
|
||||||
|
onTriggered: {
|
||||||
|
root.secondsLeft -= 1;
|
||||||
|
if (root.secondsLeft <= 0)
|
||||||
|
root.revert();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+139
@@ -0,0 +1,139 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
# Display configuration.
|
||||||
|
#
|
||||||
|
# This is the only setting in Panama that can leave the user unable to SEE the
|
||||||
|
# screen well enough to undo it: a mode the panel cannot show, or a scale that
|
||||||
|
# makes everything unreadable, is not recoverable through the UI that caused it.
|
||||||
|
#
|
||||||
|
# So the property under test is not "can it change the resolution" but "does an
|
||||||
|
# unconfirmed change always come back". A regression here is not a broken
|
||||||
|
# feature, it is a user staring at a blank monitor.
|
||||||
|
#
|
||||||
|
# * an unconfirmed change reverts on its own, and stores nothing
|
||||||
|
# * a confirmed change is what writes to the settings store
|
||||||
|
# * a mode, scale, rotation, or output the compositor did not offer is refused
|
||||||
|
# before anything is applied
|
||||||
|
#
|
||||||
|
# The compositor is the live one -- there is no way to test this otherwise --
|
||||||
|
# but preferences are isolated, and every path restores the display it started
|
||||||
|
# from.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
|
harness="$repo_dir/config/dot/quickshell/displays-harness.qml"
|
||||||
|
config_home="$(mktemp -d /tmp/panama-displays-config.XXXXXX)"
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
printf 'displays contract: %s\n' "$1" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
run() { XDG_CONFIG_HOME="$config_home" qs -p "$harness" "$@"; }
|
||||||
|
status() { run ipc call displays-test status; }
|
||||||
|
|
||||||
|
original_mode=""
|
||||||
|
original_scale=""
|
||||||
|
original_transform=""
|
||||||
|
|
||||||
|
restore() {
|
||||||
|
# Through hyprctl rather than the harness: if the harness apply path is what
|
||||||
|
# is broken, the daily-driver display must still come back.
|
||||||
|
if [[ -n "$original_mode" ]]; then
|
||||||
|
hyprctl eval "hl.monitor({ output = \"$monitor_name\", mode = \"$original_mode\", scale = $original_scale, transform = $original_transform })" >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
# Kill by PID, never `pkill -f displays-harness`: that pattern also matches
|
||||||
|
# any shell whose command line contains this script's text, which includes
|
||||||
|
# the invoking shell itself.
|
||||||
|
[[ -n "${harness_pid:-}" ]] && kill "$harness_pid" >/dev/null 2>&1 || true
|
||||||
|
rm -rf "$config_home"
|
||||||
|
}
|
||||||
|
trap restore EXIT
|
||||||
|
|
||||||
|
XDG_CONFIG_HOME="$config_home" qs -p "$harness" --daemonize >/dev/null
|
||||||
|
harness_pid=""
|
||||||
|
for _ in $(seq 1 40); do
|
||||||
|
run ipc show 2>/dev/null | rg -q '^target displays-test$' && break
|
||||||
|
sleep 0.1
|
||||||
|
done
|
||||||
|
run ipc show 2>/dev/null | rg -q '^target displays-test$' || fail 'test IPC target did not start'
|
||||||
|
harness_pid="$(run list | awk '/Process ID:/ { print $3; exit }')"
|
||||||
|
|
||||||
|
for _ in $(seq 1 50); do
|
||||||
|
[[ "$(status | jq -r .count)" != "0" ]] && break
|
||||||
|
sleep 0.1
|
||||||
|
done
|
||||||
|
|
||||||
|
state="$(status)"
|
||||||
|
monitor_name="$(jq -r .name <<<"$state")"
|
||||||
|
[[ -n "$monitor_name" ]] || fail "no display was detected: $state"
|
||||||
|
original_mode="$(jq -r '"\(.width)x\(.height)@\(.refresh)"' <<<"$state")"
|
||||||
|
original_scale="$(jq -r .scale <<<"$state")"
|
||||||
|
original_transform="$(jq -r .transform <<<"$state")"
|
||||||
|
|
||||||
|
[[ "$(jq -r .modes <<<"$state")" -gt 0 ]] || fail 'the display reported no usable modes'
|
||||||
|
|
||||||
|
# ── Anything the compositor did not offer is refused before applying ─────────
|
||||||
|
while IFS= read -r kind; do
|
||||||
|
[[ "$(run ipc call displays-test applyBad "$kind")" == "false" ]] \
|
||||||
|
|| fail "an invalid $kind was accepted"
|
||||||
|
[[ "$(status | jq -r .awaiting)" == "false" ]] \
|
||||||
|
|| fail "an invalid $kind left a change pending"
|
||||||
|
done <<'KINDS'
|
||||||
|
mode
|
||||||
|
scale
|
||||||
|
transform
|
||||||
|
output
|
||||||
|
KINDS
|
||||||
|
|
||||||
|
# The display must not have moved for any of those.
|
||||||
|
now="$(status)"
|
||||||
|
[[ "$(jq -r .scale <<<"$now")" == "$original_scale" ]] || fail 'a refused change still altered the scale'
|
||||||
|
|
||||||
|
# ── An unconfirmed change reverts on its own and stores nothing ──────────────
|
||||||
|
target_scale=$(awk -v s="$original_scale" 'BEGIN { print (s == 1.25) ? 1.5 : 1.25 }')
|
||||||
|
[[ "$(run ipc call displays-test applyScale "$target_scale")" == "true" ]] \
|
||||||
|
|| fail 'a valid scale change was refused'
|
||||||
|
|
||||||
|
applied=false
|
||||||
|
for _ in $(seq 1 30); do
|
||||||
|
[[ "$(hyprctl -j monitors | jq -r '.[0].scale')" == "$target_scale" ]] && { applied=true; break; }
|
||||||
|
sleep 0.2
|
||||||
|
done
|
||||||
|
[[ "$applied" == true ]] || fail 'the scale change never reached the compositor'
|
||||||
|
[[ "$(status | jq -r .awaiting)" == "true" ]] || fail 'an applied change is not awaiting confirmation'
|
||||||
|
|
||||||
|
# Wait out the countdown. This is the whole point of the contract.
|
||||||
|
reverted=false
|
||||||
|
for _ in $(seq 1 120); do
|
||||||
|
if [[ "$(hyprctl -j monitors | jq -r '.[0].scale')" == "$original_scale" ]]; then
|
||||||
|
reverted=true
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 0.5
|
||||||
|
done
|
||||||
|
[[ "$reverted" == true ]] || fail 'an unconfirmed change did NOT revert -- this would strand a user on an unreadable display'
|
||||||
|
[[ "$(status | jq -r .awaiting)" == "false" ]] || fail 'the pending state survived the revert'
|
||||||
|
[[ "$(status | jq -r .overridden)" == "false" ]] || fail 'an unconfirmed change was written to the settings store'
|
||||||
|
|
||||||
|
# ── A confirmed change is what writes ────────────────────────────────────────
|
||||||
|
run ipc call displays-test applyScale "$target_scale" >/dev/null
|
||||||
|
sleep 1
|
||||||
|
run ipc call displays-test confirmChange >/dev/null
|
||||||
|
sleep 0.6
|
||||||
|
[[ "$(status | jq -r .awaiting)" == "false" ]] || fail 'confirming did not clear the pending state'
|
||||||
|
[[ "$(status | jq -r .overridden)" == "true" ]] || fail 'confirming did not store the change'
|
||||||
|
|
||||||
|
store="$config_home/panama/settings.json"
|
||||||
|
jq -e --arg m "$monitor_name" '.displays[$m].scale != null' "$store" >/dev/null \
|
||||||
|
|| fail 'the confirmed change is not in the settings store'
|
||||||
|
|
||||||
|
# ── Forgetting clears it ─────────────────────────────────────────────────────
|
||||||
|
run ipc call displays-test forget >/dev/null
|
||||||
|
sleep 0.6
|
||||||
|
[[ "$(status | jq -r .overridden)" == "false" ]] || fail 'forget did not clear the stored display setting'
|
||||||
|
|
||||||
|
trap - EXIT
|
||||||
|
restore
|
||||||
|
printf 'displays contract: PASS\n'
|
||||||
Reference in New Issue
Block a user