Build the settings vocabulary and generate the keymap
Stage 3 and 4 of docs/superpowers/plans/2026-08-17-panama-cohesion.md. Add SettingsPage plus ToggleRow, SliderRow, ChoiceRow, ActionRow, and TextRow. A row names a schema key and needs nothing else: label, detail, range, and unit come from PreferenceSchema, and writes go through SystemSettings.commitPreference, which routes compositor-backed keys through apply-and-verify and local keys straight to the store. The page scaffold that was copy-pasted eleven times is now one component. Rebuild Appearance around a live preview of the real desktop, scaled by the ratio between the preview and the actual monitor so a 10px gap on a 4500px display looks as small as it is. Rebuild Desktop & Dock and Input & Shortcuts on the shared rows, replacing the read-only text that stood in for controls that were merely expensive to add. Generate the shortcut list from hyprctl binds. The page held a hand-typed nineteen entries against a real keymap of a hundred and thirteen; it could not show the rest and went stale whenever a bind changed. Every bind now carries its own description -- backfilled for the twenty-nine that lacked one -- and keybinds-contract.sh fails if any bind lacks one, since undescribed binds are dropped from the page. Make Restore defaults span every store Panama owns. Resetting only the schema store left the Home accessory arrangement customised while claiming to restore defaults, which is worse than no reset because it is silent. Done through HomePreferences' existing public aliases rather than a new API. Four defects found while building: cursor:inactive_timeout is answered by getoption as float, not int. A wrong readAs does not fail loudly; it makes every write to that key look rejected, and the user saw an error for a change that worked. schema-hypr-shape-contract.sh now checks all 23 mapped options against the running compositor. The Settings window is tiled, so implicitWidth is only a hint and rows must survive roughly 400px. SliderRow stacks its control under the label below 520px. Binding an anchor to undefined to switch layouts does not reliably release it. Both row layouts are positioned explicitly. Concurrent compositor writes are queued and merged rather than refused. The startup replay of every compositor-backed preference routinely overlaps a UI change, and refusing left the store and the compositor disagreeing. Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
This commit is contained in:
@@ -86,12 +86,14 @@ Singleton {
|
||||
},
|
||||
{
|
||||
key: "dockRevealDelayMs", type: "int", def: 0, min: 0, max: 1000, step: 25,
|
||||
unit: "ms",
|
||||
group: "dock",
|
||||
label: "Reveal delay",
|
||||
detail: "Zero reveals the Dock the instant the pointer reaches the edge"
|
||||
},
|
||||
{
|
||||
key: "dockHideDelayMs", type: "int", def: 250, min: 0, max: 2000, step: 25,
|
||||
unit: "ms",
|
||||
group: "dock",
|
||||
label: "Hide delay",
|
||||
detail: "Prevents flicker when crossing between icons"
|
||||
@@ -100,6 +102,7 @@ Singleton {
|
||||
// ── Focus ───────────────────────────────────────────────────────────
|
||||
{
|
||||
key: "focusDurationMinutes", type: "int", def: 45, min: 5, max: 180, step: 5,
|
||||
unit: "min",
|
||||
group: "focus",
|
||||
label: "Focus session length",
|
||||
detail: "How long a focus session runs before it ends itself"
|
||||
@@ -143,6 +146,7 @@ Singleton {
|
||||
// the Hyprland config still stands on its own.
|
||||
{
|
||||
key: "gapsIn", type: "int", def: 5, min: 0, max: 40, step: 1,
|
||||
unit: "px",
|
||||
group: "windows",
|
||||
label: "Inner gaps",
|
||||
detail: "Space between neighbouring tiled windows",
|
||||
@@ -150,6 +154,7 @@ Singleton {
|
||||
},
|
||||
{
|
||||
key: "gapsOut", type: "int", def: 10, min: 0, max: 80, step: 1,
|
||||
unit: "px",
|
||||
group: "windows",
|
||||
label: "Outer gaps",
|
||||
detail: "Space between the tiled area and the screen edge",
|
||||
@@ -157,6 +162,7 @@ Singleton {
|
||||
},
|
||||
{
|
||||
key: "borderSize", type: "int", def: 2, min: 0, max: 10, step: 1,
|
||||
unit: "px",
|
||||
group: "windows",
|
||||
label: "Border width",
|
||||
detail: "Thickness of the gradient border on the focused window",
|
||||
@@ -164,6 +170,7 @@ Singleton {
|
||||
},
|
||||
{
|
||||
key: "windowRounding", type: "int", def: 18, min: 0, max: 40, step: 1,
|
||||
unit: "px",
|
||||
group: "windows",
|
||||
label: "Corner radius",
|
||||
detail: "Matches the shell's popover radius so windows and panels agree",
|
||||
@@ -206,6 +213,7 @@ Singleton {
|
||||
},
|
||||
{
|
||||
key: "shadowRange", type: "int", def: 20, min: 0, max: 60, step: 1,
|
||||
unit: "px",
|
||||
group: "effects",
|
||||
label: "Shadow size",
|
||||
detail: "How far the shadow spreads from the window edge",
|
||||
@@ -219,6 +227,7 @@ Singleton {
|
||||
},
|
||||
{
|
||||
key: "glowRange", type: "int", def: 8, min: 0, max: 30, step: 1,
|
||||
unit: "px",
|
||||
group: "effects",
|
||||
label: "Glow size",
|
||||
detail: "Kept small deliberately: the gradient border is the signature",
|
||||
@@ -249,6 +258,7 @@ Singleton {
|
||||
},
|
||||
{
|
||||
key: "keyRepeatDelay", type: "int", def: 500, min: 150, max: 1000, step: 25,
|
||||
unit: "ms",
|
||||
group: "input",
|
||||
label: "Repeat delay",
|
||||
detail: "How long a key is held before it starts repeating",
|
||||
@@ -256,6 +266,7 @@ Singleton {
|
||||
},
|
||||
{
|
||||
key: "keyRepeatRate", type: "int", def: 33, min: 5, max: 100, step: 1,
|
||||
unit: "/s",
|
||||
group: "input",
|
||||
label: "Repeat rate",
|
||||
detail: "How many characters a second a held key produces",
|
||||
@@ -281,10 +292,15 @@ Singleton {
|
||||
},
|
||||
{
|
||||
key: "cursorInactiveTimeout", type: "int", def: 4, min: 0, max: 60, step: 1,
|
||||
unit: "s",
|
||||
group: "input",
|
||||
label: "Hide pointer after",
|
||||
detail: "Seconds of stillness before the pointer fades out; 0 never hides it",
|
||||
hypr: { path: ["cursor", "inactive_timeout"], option: "cursor:inactive_timeout", readAs: "int" }
|
||||
// Reported as a float even though it is only ever set to whole
|
||||
// seconds. `readAs` describes what getoption answers with, not what
|
||||
// the setting means -- getting this wrong makes every write to it
|
||||
// look rejected.
|
||||
hypr: { path: ["cursor", "inactive_timeout"], option: "cursor:inactive_timeout", readAs: "float" }
|
||||
},
|
||||
|
||||
// ── Night light ─────────────────────────────────────────────────────
|
||||
@@ -300,6 +316,7 @@ Singleton {
|
||||
},
|
||||
{
|
||||
key: "nightLightTemperature", type: "int", def: 3500, min: 2000, max: 6500, step: 100,
|
||||
unit: "K",
|
||||
group: "nightLight",
|
||||
label: "Color temperature",
|
||||
detail: "Lower is warmer"
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import QtQuick
|
||||
|
||||
import qs.services
|
||||
|
||||
ShellRoot {
|
||||
IpcHandler {
|
||||
target: "keybinds-test"
|
||||
|
||||
function status(): string {
|
||||
const grouped = Keybinds.grouped();
|
||||
let groupedCount = 0;
|
||||
for (const group of grouped)
|
||||
groupedCount += group.binds.length;
|
||||
|
||||
const superT = Keybinds.binds.find(bind => bind.description === "Terminal");
|
||||
|
||||
return JSON.stringify({
|
||||
loaded: Keybinds.loaded,
|
||||
count: Keybinds.binds.length,
|
||||
groupedCount: groupedCount,
|
||||
groups: grouped.map(group => group.name),
|
||||
sample: superT ? superT.chord : "",
|
||||
emptyDescriptions: Keybinds.binds.filter(bind => !bind.description).length,
|
||||
lastError: Keybinds.lastError
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// A row whose trailing control is a button rather than a setting.
|
||||
//
|
||||
// ActionRow {
|
||||
// label: "Keyboard"
|
||||
// detail: "Use Fedora's hardware-backed input panels"
|
||||
// action: "Open keyboard"
|
||||
// onTriggered: SystemSettings.openGnomePanel("keyboard")
|
||||
// }
|
||||
//
|
||||
// Not schema-bound: these hand off to GNOME, launch an application, or run a
|
||||
// one-shot like restoring defaults. There is no key to name.
|
||||
|
||||
import QtQuick
|
||||
import qs.config
|
||||
|
||||
SettingRow {
|
||||
id: root
|
||||
|
||||
property string action: ""
|
||||
property bool enabled: true
|
||||
|
||||
signal triggered
|
||||
|
||||
controlWidth: Math.max(110, button.implicitWidth + 8)
|
||||
|
||||
SettingsButton {
|
||||
id: button
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: root.action
|
||||
enabled: root.enabled
|
||||
onClicked: root.triggered()
|
||||
}
|
||||
}
|
||||
@@ -1,61 +1,94 @@
|
||||
// Appearance — the page the rest of the settings vocabulary was built for.
|
||||
//
|
||||
// Before Stage 3 this page adjusted a clock format and three vitals toggles,
|
||||
// and its "Theme" card was two rows of text pretending to be controls. It now
|
||||
// drives the compositor directly: every row here is applied through
|
||||
// `hyprctl eval` and confirmed by reading the value back before it is stored.
|
||||
//
|
||||
// The preview is pinned above the controls rather than sitting inline, because
|
||||
// the numbers are meaningless on their own -- "outer gaps 24" only means
|
||||
// something once you have watched the windows move apart by that much, at the
|
||||
// scale of the display you actually use.
|
||||
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
Item {
|
||||
Flickable {
|
||||
anchors.fill: parent
|
||||
clip: true
|
||||
contentWidth: width
|
||||
contentHeight: content.implicitHeight + 64
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
SettingsPage {
|
||||
id: root
|
||||
|
||||
title: "Appearance"
|
||||
lede: "Drag anything below. The preview above is your real geometry, to scale."
|
||||
|
||||
header: Component {
|
||||
Column {
|
||||
id: content
|
||||
width: parent.width - 68
|
||||
x: 34
|
||||
y: 30
|
||||
spacing: 16
|
||||
spacing: 9
|
||||
|
||||
Text { text: "Appearance"; color: Theme.fg; font.family: Theme.fontFamily; font.pixelSize: 27; font.weight: Font.DemiBold }
|
||||
Text { text: "Tokyo Night Moon, tuned for clarity and quiet motion."; color: Theme.fgDim; font.family: Theme.fontFamily; font.pixelSize: Theme.fontSize; bottomPadding: 6 }
|
||||
|
||||
SettingsCard {
|
||||
title: "Theme"
|
||||
subtitle: "Panama has one curated visual identity rather than a matrix of partially compatible themes."
|
||||
SettingRow { label: "Color palette"; detail: "Tokyo Night Moon"; value: "Prism" }
|
||||
SettingRow { label: "Interface type"; detail: "Adwaita Sans"; value: "System"; divider: false }
|
||||
DesktopPreview {
|
||||
width: parent.width
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Clock"
|
||||
SettingRow {
|
||||
label: "24-hour time"
|
||||
detail: "Use 18:30 instead of 6:30 PM"
|
||||
controlWidth: 48
|
||||
SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: DesktopPreferences.get("use24Hour"); onToggled: value => DesktopPreferences.set("use24Hour", value) }
|
||||
}
|
||||
SettingRow {
|
||||
label: "Show seconds"
|
||||
detail: "Keep a precise clock in the center of the bar"
|
||||
controlWidth: 48
|
||||
SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: DesktopPreferences.get("showSeconds"); onToggled: value => DesktopPreferences.set("showSeconds", value) }
|
||||
}
|
||||
SettingRow {
|
||||
label: "Show weekday"
|
||||
detail: "Include the abbreviated weekday before the date"
|
||||
divider: false
|
||||
controlWidth: 48
|
||||
SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: DesktopPreferences.get("showWeekday"); onToggled: value => DesktopPreferences.set("showWeekday", value) }
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "System vitals"
|
||||
subtitle: "Choose what appears beside the workspace indicator."
|
||||
SettingRow { label: "Processor"; controlWidth: 48; SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: DesktopPreferences.get("showCpu"); onToggled: value => DesktopPreferences.set("showCpu", value) } }
|
||||
SettingRow { label: "Memory"; controlWidth: 48; SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: DesktopPreferences.get("showMemory"); onToggled: value => DesktopPreferences.set("showMemory", value) } }
|
||||
SettingRow { label: "Graphics"; divider: false; controlWidth: 48; SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: DesktopPreferences.get("showGpu"); onToggled: value => DesktopPreferences.set("showGpu", value) } }
|
||||
Text {
|
||||
width: parent.width
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
text: SystemSettings.lastError !== ""
|
||||
? SystemSettings.lastError
|
||||
: "Live preview — the focused window wears the prism border"
|
||||
color: SystemSettings.lastError !== "" ? Theme.warn : Theme.fgMuted
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Windows"
|
||||
subtitle: "Spacing and shape of tiled windows. Each change is applied to the compositor and confirmed before it is saved."
|
||||
|
||||
SliderRow { setting: "windowRounding" }
|
||||
SliderRow { setting: "gapsIn" }
|
||||
SliderRow { setting: "gapsOut" }
|
||||
SliderRow { setting: "borderSize"; zeroLabel: "None" }
|
||||
SliderRow { setting: "inactiveOpacity"; divider: false }
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Effects"
|
||||
subtitle: "Each of these costs frame time. Turning one off is a legitimate way to buy it back while gaming."
|
||||
|
||||
ToggleRow { setting: "blurEnabled" }
|
||||
SliderRow { setting: "blurSize" }
|
||||
SliderRow { setting: "blurPasses" }
|
||||
ToggleRow { setting: "shadowEnabled" }
|
||||
SliderRow { setting: "shadowRange"; zeroLabel: "None" }
|
||||
ToggleRow { setting: "glowEnabled" }
|
||||
SliderRow { setting: "glowRange"; zeroLabel: "None" }
|
||||
ToggleRow { setting: "animationsEnabled"; divider: false }
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Clock"
|
||||
|
||||
ToggleRow { setting: "use24Hour" }
|
||||
ToggleRow { setting: "showSeconds" }
|
||||
ToggleRow { setting: "showWeekday"; divider: false }
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "System vitals"
|
||||
subtitle: "Choose what appears beside the workspace indicator."
|
||||
|
||||
ToggleRow { setting: "showCpu" }
|
||||
ToggleRow { setting: "showMemory" }
|
||||
ToggleRow { setting: "showGpu"; divider: false }
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Theme"
|
||||
subtitle: "Panama has one curated visual identity rather than a matrix of partially compatible themes. The controls above adjust its parameters — how much space, how soft, how much motion — without replacing it."
|
||||
|
||||
TextRow { label: "Color palette"; detail: "Tokyo Night Moon"; value: "Prism" }
|
||||
TextRow { label: "Interface type"; detail: "Adwaita Sans"; value: "System"; divider: false }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
// An enum setting, bound to a schema key by name.
|
||||
//
|
||||
// ChoiceRow { setting: "vrrPolicy" }
|
||||
//
|
||||
// The options are the schema's, so a row can never offer a value the store
|
||||
// would reject. Rendered as a segmented control rather than a dropdown: every
|
||||
// choice here has two or three options, and showing them all is both faster to
|
||||
// use and self-documenting.
|
||||
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
SettingRow {
|
||||
id: root
|
||||
|
||||
required property string setting
|
||||
|
||||
readonly property var spec: PreferenceSchema.spec(root.setting)
|
||||
readonly property var options: root.spec && root.spec.options ? root.spec.options : []
|
||||
readonly property var current: DesktopPreferences.get(root.setting)
|
||||
|
||||
label: root.spec ? root.spec.label : root.setting
|
||||
detail: root.spec ? root.spec.detail : ""
|
||||
controlWidth: Math.max(120, root.options.length * 92)
|
||||
|
||||
Rectangle {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
implicitWidth: segments.implicitWidth + 4
|
||||
implicitHeight: 30
|
||||
radius: 9
|
||||
color: Theme.alpha(Theme.fg, 0.07)
|
||||
border.width: 0
|
||||
|
||||
Row {
|
||||
id: segments
|
||||
anchors.centerIn: parent
|
||||
spacing: 0
|
||||
|
||||
Repeater {
|
||||
model: root.options
|
||||
|
||||
Rectangle {
|
||||
id: segment
|
||||
|
||||
required property var modelData
|
||||
|
||||
readonly property bool selected: root.current === segment.modelData.value
|
||||
|
||||
implicitWidth: Math.max(70, caption.implicitWidth + 24)
|
||||
implicitHeight: 26
|
||||
radius: 7
|
||||
border.width: 0
|
||||
color: "transparent"
|
||||
|
||||
// The selected segment is the only place the prism appears
|
||||
// in a row: blue leads into orchid, never orchid alone.
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
radius: parent.radius
|
||||
visible: segment.selected
|
||||
border.width: 0
|
||||
gradient: Gradient {
|
||||
orientation: Gradient.Horizontal
|
||||
GradientStop { position: 0.0; color: Theme.alpha(Theme.accent, 0.30) }
|
||||
GradientStop { position: 1.0; color: Theme.alpha(Theme.accentSecondary, 0.30) }
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
radius: parent.radius
|
||||
border.width: 0
|
||||
visible: !segment.selected && hover.hovered
|
||||
color: Theme.alpha(Theme.fg, 0.06)
|
||||
}
|
||||
|
||||
Text {
|
||||
id: caption
|
||||
anchors.centerIn: parent
|
||||
text: segment.modelData.label
|
||||
color: segment.selected ? Theme.fg : Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
font.weight: segment.selected ? Font.DemiBold : Font.Normal
|
||||
}
|
||||
|
||||
HoverHandler { id: hover }
|
||||
|
||||
TapHandler {
|
||||
onTapped: SystemSettings.commitPreference(root.setting, segment.modelData.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,55 +1,69 @@
|
||||
// Desktop & Dock.
|
||||
//
|
||||
// The dock timings used to be shown here as text -- "Instant", "250 ms" -- even
|
||||
// though they were already stored, mutable integers. They are controls now.
|
||||
// The window-layout card keeps text rows because those really are facts about
|
||||
// how Panama tiles rather than settings: the adjustable parts of window
|
||||
// appearance live on the Appearance page, next to the preview that explains
|
||||
// them.
|
||||
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
Item {
|
||||
Flickable {
|
||||
anchors.fill: parent
|
||||
clip: true
|
||||
contentWidth: width
|
||||
contentHeight: content.implicitHeight + 64
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
SettingsPage {
|
||||
id: root
|
||||
|
||||
Column {
|
||||
id: content
|
||||
width: parent.width - 68
|
||||
x: 34
|
||||
y: 30
|
||||
spacing: 16
|
||||
title: "Desktop & Dock"
|
||||
lede: "Keep the shell instant, spatial, and out of your way."
|
||||
|
||||
Text { text: "Desktop & Dock"; color: Theme.fg; font.family: Theme.fontFamily; font.pixelSize: 27; font.weight: Font.DemiBold }
|
||||
Text { text: "Keep the shell instant, spatial, and out of your way."; color: Theme.fgDim; font.family: Theme.fontFamily; font.pixelSize: Theme.fontSize; bottomPadding: 6 }
|
||||
SettingsCard {
|
||||
title: "Dock"
|
||||
|
||||
SettingsCard {
|
||||
title: "Dock"
|
||||
SettingRow {
|
||||
label: "Automatically hide the Dock"
|
||||
detail: "Reveal it at the bottom edge when a workspace is occupied"
|
||||
controlWidth: 48
|
||||
SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: DesktopPreferences.get("dockAutohide"); onToggled: value => DesktopPreferences.set("dockAutohide", value) }
|
||||
}
|
||||
SettingRow { label: "Reveal response"; detail: "The Dock appears as soon as the pointer reaches the edge"; value: DesktopPreferences.get("dockRevealDelayMs") === 0 ? "Instant" : `${DesktopPreferences.get("dockRevealDelayMs")} ms` }
|
||||
SettingRow { label: "Hide delay"; detail: "Prevents flicker when crossing icons"; value: `${DesktopPreferences.get("dockHideDelayMs")} ms`; divider: false }
|
||||
}
|
||||
ToggleRow { setting: "dockAutohide" }
|
||||
SliderRow { setting: "dockRevealDelayMs"; zeroLabel: "Instant" }
|
||||
SliderRow { setting: "dockHideDelayMs"; zeroLabel: "Instant"; divider: false }
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Window layout"
|
||||
subtitle: "Panama follows the Forge mental model with native Hyprland tiling."
|
||||
SettingRow { label: "Layout"; detail: "Dwindle with preserved split direction"; value: "Tiling" }
|
||||
SettingRow { label: "Window corners"; detail: "Shared with Panama glass surfaces"; value: "18 px" }
|
||||
SettingRow { label: "Workspace movement"; detail: "Alt+H / Alt+L, add Shift to move a window"; value: "Dynamic"; divider: false }
|
||||
}
|
||||
SettingsCard {
|
||||
title: "Window layout"
|
||||
subtitle: "Panama follows the Forge mental model with native Hyprland tiling."
|
||||
|
||||
SettingsCard {
|
||||
title: "Reset"
|
||||
subtitle: "Restore Panama's curated clock, vitals, dock, focus, and display-policy defaults."
|
||||
SettingRow {
|
||||
label: "Desktop preferences"
|
||||
detail: "Your pinned applications and files are not changed"
|
||||
divider: false
|
||||
controlWidth: 122
|
||||
SettingsButton { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; text: "Restore defaults"; onClicked: DesktopPreferences.resetDesktopDefaults() }
|
||||
}
|
||||
}
|
||||
TextRow {
|
||||
label: "Layout"
|
||||
detail: "Dwindle with preserved split direction"
|
||||
value: "Tiling"
|
||||
}
|
||||
TextRow {
|
||||
label: "Workspace movement"
|
||||
detail: "Alt+H / Alt+L, add Shift to move a window"
|
||||
value: "Dynamic"
|
||||
}
|
||||
ActionRow {
|
||||
label: "Gaps, corners, and effects"
|
||||
detail: "Adjusted on the Appearance page, beside a live preview"
|
||||
action: "Open Appearance"
|
||||
divider: false
|
||||
onTriggered: ShellState.openSettings("appearance")
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Focus"
|
||||
|
||||
SliderRow { setting: "focusDurationMinutes"; divider: false }
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Reset"
|
||||
subtitle: "Restores Panama's appearance, dock, clock, focus, and display policy, and clears your Home accessory arrangement. Pinned applications, files, and paired devices are not changed."
|
||||
|
||||
ActionRow {
|
||||
label: "Restore Panama defaults"
|
||||
detail: "Applies immediately, including to the compositor"
|
||||
action: "Restore defaults"
|
||||
divider: false
|
||||
onTriggered: SystemSettings.restoreDefaults()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
// A miniature of the real desktop that redraws as you change Appearance.
|
||||
//
|
||||
// The point is that "outer gaps 24" and "radius 9" mean nothing as numbers. This
|
||||
// shows two tiled windows at the settings currently in effect: the focused one
|
||||
// wearing the prism border and its glow, the unfocused one carrying the
|
||||
// inactive-opacity setting, both inside real gaps at a real corner radius.
|
||||
//
|
||||
// Geometry is scaled by the ratio between this preview's width and the actual
|
||||
// monitor's, so proportions are honest rather than decorative -- a 10px gap on
|
||||
// a 4500px display genuinely is almost invisible, and the preview says so.
|
||||
//
|
||||
// Everything here is driven by bindings on DesktopPreferences, so the preview
|
||||
// shows what is actually stored and applied. It has no state of its own and
|
||||
// nothing animates while idle.
|
||||
|
||||
import QtQuick
|
||||
import QtQuick.Effects
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
Rectangle {
|
||||
id: root
|
||||
|
||||
implicitHeight: 232
|
||||
radius: Theme.cardRadius
|
||||
clip: true
|
||||
border.width: 1
|
||||
border.color: Theme.alpha(Theme.fg, 0.09)
|
||||
|
||||
// Falls back to a sane width if the monitor has not been read yet, so the
|
||||
// preview is never wrong by a factor of ten on first paint.
|
||||
readonly property real monitorWidth: SystemSettings.monitorWidth > 0 ? SystemSettings.monitorWidth : 3000
|
||||
readonly property real scale: width > 0 ? width / root.monitorWidth : 0.25
|
||||
|
||||
readonly property int gapsIn: DesktopPreferences.get("gapsIn")
|
||||
readonly property int gapsOut: DesktopPreferences.get("gapsOut")
|
||||
readonly property int borderSize: DesktopPreferences.get("borderSize")
|
||||
readonly property int rounding: DesktopPreferences.get("windowRounding")
|
||||
readonly property real inactiveOpacity: DesktopPreferences.get("inactiveOpacity")
|
||||
readonly property bool shadowOn: DesktopPreferences.get("shadowEnabled")
|
||||
readonly property int shadowRange: DesktopPreferences.get("shadowRange")
|
||||
readonly property bool glowOn: DesktopPreferences.get("glowEnabled")
|
||||
readonly property int glowRange: DesktopPreferences.get("glowRange")
|
||||
|
||||
// Scaled geometry, floored so a small-but-nonzero setting stays visible
|
||||
// rather than rounding away to nothing.
|
||||
function px(value: real): real {
|
||||
return value <= 0 ? 0 : Math.max(1, value * root.scale);
|
||||
}
|
||||
|
||||
// Stands in for the wallpaper. Static: an animated gradient here would
|
||||
// repaint forever behind a settings page.
|
||||
gradient: Gradient {
|
||||
orientation: Gradient.Vertical
|
||||
GradientStop { position: 0.0; color: "#2b3050" }
|
||||
GradientStop { position: 1.0; color: "#241f33" }
|
||||
}
|
||||
|
||||
// The bar, so the preview reads as this desktop and not a generic one.
|
||||
Rectangle {
|
||||
id: miniBar
|
||||
anchors.top: parent.top
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
height: 20
|
||||
color: Theme.alpha(Theme.bgDark, 0.4)
|
||||
border.width: 0
|
||||
|
||||
Row {
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 9
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 4
|
||||
|
||||
Repeater {
|
||||
model: 3
|
||||
Rectangle {
|
||||
required property int index
|
||||
width: index === 0 ? 12 : 5
|
||||
height: 5
|
||||
radius: 2.5
|
||||
border.width: 0
|
||||
color: index === 0 ? Theme.accent : Theme.alpha(Theme.fg, 0.3)
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: "Panama"
|
||||
color: Theme.alpha(Theme.fg, 0.45)
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: 9
|
||||
}
|
||||
}
|
||||
|
||||
Row {
|
||||
id: tiles
|
||||
anchors.top: miniBar.bottom
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.margins: root.px(root.gapsOut)
|
||||
spacing: root.px(root.gapsIn) * 2
|
||||
|
||||
MiniWindow {
|
||||
width: (tiles.width - tiles.spacing) / 2
|
||||
height: tiles.height
|
||||
focused: true
|
||||
}
|
||||
|
||||
MiniWindow {
|
||||
width: (tiles.width - tiles.spacing) / 2
|
||||
height: tiles.height
|
||||
focused: false
|
||||
}
|
||||
}
|
||||
|
||||
component MiniWindow: Item {
|
||||
id: win
|
||||
|
||||
required property bool focused
|
||||
|
||||
// The gradient border is drawn as a filled rounded rect with the window
|
||||
// body inset on top of it: QML's Rectangle border takes a colour, not a
|
||||
// gradient, and the prism border is the whole signature here.
|
||||
Rectangle {
|
||||
id: frame
|
||||
anchors.fill: parent
|
||||
radius: root.px(root.rounding)
|
||||
border.width: 0
|
||||
visible: win.focused && root.borderSize > 0
|
||||
|
||||
gradient: Gradient {
|
||||
orientation: Gradient.Horizontal
|
||||
GradientStop { position: 0.0; color: Theme.accent }
|
||||
GradientStop { position: 1.0; color: Theme.accentSecondary }
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: body
|
||||
anchors.fill: parent
|
||||
anchors.margins: win.focused ? root.px(root.borderSize) : 0
|
||||
radius: Math.max(0, root.px(root.rounding) - (win.focused ? root.px(root.borderSize) : 0))
|
||||
color: Theme.alpha(Theme.bgDark, 0.92)
|
||||
opacity: win.focused ? 1.0 : root.inactiveOpacity
|
||||
border.width: win.focused ? 0 : 1
|
||||
border.color: Theme.alpha(Theme.gutter, 0.6)
|
||||
clip: true
|
||||
|
||||
Rectangle {
|
||||
anchors.top: parent.top
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
height: 13
|
||||
color: Theme.alpha(Theme.fg, 0.05)
|
||||
border.width: 0
|
||||
}
|
||||
|
||||
Column {
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.top: parent.top
|
||||
anchors.topMargin: 22
|
||||
anchors.margins: 10
|
||||
spacing: 5
|
||||
|
||||
Repeater {
|
||||
model: [1.0, 0.74, 0.52]
|
||||
Rectangle {
|
||||
required property real modelData
|
||||
width: parent.width * modelData
|
||||
height: 4
|
||||
radius: 2
|
||||
border.width: 0
|
||||
color: Theme.alpha(Theme.fg, win.focused ? 0.2 : 0.13)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.left: parent.left
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.margins: 8
|
||||
text: win.focused ? "focused" : "unfocused"
|
||||
color: Theme.fgMuted
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: 9
|
||||
}
|
||||
}
|
||||
|
||||
// Shadow and glow both come from MultiEffect. Only the focused window
|
||||
// gets the glow, matching looks.lua where glow.color_inactive is fully
|
||||
// transparent.
|
||||
MultiEffect {
|
||||
anchors.fill: body
|
||||
source: body
|
||||
visible: root.shadowOn || (win.focused && root.glowOn)
|
||||
z: -1
|
||||
|
||||
shadowEnabled: true
|
||||
shadowColor: win.focused && root.glowOn
|
||||
? Theme.alpha(Theme.accent, 0.5)
|
||||
: Theme.alpha("#15161e", 0.85)
|
||||
shadowBlur: win.focused && root.glowOn
|
||||
? Math.min(1.0, root.px(root.glowRange) / 12)
|
||||
: Math.min(1.0, root.px(root.shadowRange) / 24)
|
||||
shadowVerticalOffset: win.focused && root.glowOn ? 0 : root.px(4)
|
||||
shadowHorizontalOffset: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
// The scaffold every settings page shares: a scrolling column with a title, a
|
||||
// one-line lede, and consistent margins.
|
||||
//
|
||||
// This existed eleven times as copy-pasted Flickable/Column/x:34/y:30 blocks,
|
||||
// which is a large part of why several pages settled for read-only text instead
|
||||
// of real controls -- adding a page was expensive enough that the cheap thing
|
||||
// won. Pages are now just their content:
|
||||
//
|
||||
// SettingsPage {
|
||||
// title: "Appearance"
|
||||
// lede: "Tokyo Night Moon, tuned for clarity and quiet motion."
|
||||
//
|
||||
// SettingsCard { title: "Windows"; ToggleRow { setting: "blurEnabled" } }
|
||||
// }
|
||||
|
||||
import QtQuick
|
||||
import qs.config
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
default property alias content: column.data
|
||||
|
||||
property string title: ""
|
||||
property string lede: ""
|
||||
|
||||
// Anything that should sit above the title and stay put while the rest
|
||||
// scrolls -- the Appearance page pins its live preview here.
|
||||
property Component header: null
|
||||
|
||||
Flickable {
|
||||
anchors.fill: parent
|
||||
clip: true
|
||||
contentWidth: width
|
||||
contentHeight: layout.implicitHeight + 64
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
|
||||
Column {
|
||||
id: layout
|
||||
|
||||
width: parent.width - 68
|
||||
x: 34
|
||||
y: 30
|
||||
spacing: 16
|
||||
|
||||
Loader {
|
||||
width: parent.width
|
||||
active: root.header !== null
|
||||
sourceComponent: root.header
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
visible: root.title !== ""
|
||||
text: root.title
|
||||
color: Theme.fg
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: 27
|
||||
font.weight: Font.DemiBold
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
visible: root.lede !== ""
|
||||
text: root.lede
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
wrapMode: Text.WordWrap
|
||||
bottomPadding: 6
|
||||
}
|
||||
|
||||
Column {
|
||||
id: column
|
||||
width: parent.width
|
||||
spacing: 16
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,73 +1,101 @@
|
||||
// Input & Shortcuts.
|
||||
//
|
||||
// The shortcut list is generated from `hyprctl binds -j` rather than typed out
|
||||
// here. The previous version was a hand-maintained array of nineteen entries
|
||||
// against a real keymap of a hundred and thirteen: it could not show the rest,
|
||||
// and it went stale the moment a bind changed. Every bind now carries its own
|
||||
// description in hypr/keybinds.lua, and this page just groups and renders them.
|
||||
//
|
||||
// The hardware settings above the list are real controls. Keyboard layout,
|
||||
// repeat behaviour, and pointer response are Hyprland's, so Panama owns them;
|
||||
// device-specific configuration stays with GNOME.
|
||||
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
Item {
|
||||
SettingsPage {
|
||||
id: root
|
||||
readonly property var shortcuts: [
|
||||
{ key: "Super + T", action: "Terminal" },
|
||||
{ key: "Super + N", action: "Neovim in the current directory" },
|
||||
{ key: "Super + W", action: "Web browser" },
|
||||
{ key: "Super + F", action: "Files" },
|
||||
{ key: "Super + I", action: "Panama Settings" },
|
||||
{ key: "Super + Space", action: "Launcher" },
|
||||
{ key: "Super + Grave", action: "Continuum overview" },
|
||||
{ key: "Super + V", action: "Clipboard history" },
|
||||
{ key: "Super + S", action: "Quick Settings" },
|
||||
{ key: "Super + B", action: "Notifications" },
|
||||
{ key: "Super + Shift + S", action: "Screen Intelligence" },
|
||||
{ key: "Super + Shift + F", action: "Focus session" },
|
||||
{ key: "Alt + H / L", action: "Previous / next workspace" },
|
||||
{ key: "Alt + Shift + H / L", action: "Move window between workspaces" },
|
||||
{ key: "Super + H / J / K / L", action: "Focus a tiled window" },
|
||||
{ key: "Super + Shift + H / J / K / L", action: "Move a tiled window" },
|
||||
{ key: "Super + Shift + X", action: "Send window to scratchpad" },
|
||||
{ key: "Super + X", action: "Toggle scratchpad" },
|
||||
{ key: "Print", action: "Capture, record, or read" }
|
||||
]
|
||||
|
||||
Flickable {
|
||||
anchors.fill: parent
|
||||
clip: true
|
||||
contentWidth: width
|
||||
contentHeight: content.implicitHeight + 64
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
title: "Input & Shortcuts"
|
||||
lede: "The Forge mental model, carried forward into native tiling."
|
||||
|
||||
Column {
|
||||
id: content
|
||||
width: parent.width - 68
|
||||
x: 34
|
||||
y: 30
|
||||
spacing: 16
|
||||
SettingsCard {
|
||||
title: "Keyboard"
|
||||
|
||||
Text { text: "Input & Shortcuts"; color: Theme.fg; font.family: Theme.fontFamily; font.pixelSize: 27; font.weight: Font.DemiBold }
|
||||
Text { text: "The Forge mental model, carried forward into native tiling."; color: Theme.fgDim; font.family: Theme.fontFamily; font.pixelSize: Theme.fontSize; bottomPadding: 6 }
|
||||
SliderRow { setting: "keyRepeatDelay" }
|
||||
SliderRow { setting: "keyRepeatRate" }
|
||||
ToggleRow { setting: "numlockByDefault"; divider: false }
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Panama shortcuts"
|
||||
Repeater {
|
||||
model: root.shortcuts
|
||||
SettingRow {
|
||||
required property var modelData
|
||||
required property int index
|
||||
label: modelData.action
|
||||
value: modelData.key
|
||||
controlWidth: 210
|
||||
divider: index < root.shortcuts.length - 1
|
||||
}
|
||||
}
|
||||
}
|
||||
SettingsCard {
|
||||
title: "Pointer"
|
||||
|
||||
SettingsCard {
|
||||
title: "Hardware input"
|
||||
SettingRow {
|
||||
label: "Mouse, touchpad, and keyboard devices"
|
||||
detail: "Use Fedora's hardware-backed input panels"
|
||||
divider: false
|
||||
controlWidth: 126
|
||||
SettingsButton { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; text: "Open keyboard"; onClicked: SystemSettings.openGnomePanel("keyboard") }
|
||||
ChoiceRow { setting: "followMouse" }
|
||||
SliderRow { setting: "pointerSensitivity" }
|
||||
SliderRow { setting: "cursorInactiveTimeout"; zeroLabel: "Never"; divider: false }
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Hardware input"
|
||||
|
||||
ActionRow {
|
||||
label: "Mouse, touchpad, and keyboard devices"
|
||||
detail: "Device-specific settings stay with Fedora's hardware-backed panels"
|
||||
action: "Open keyboard"
|
||||
divider: false
|
||||
onTriggered: SystemSettings.openGnomePanel("keyboard")
|
||||
}
|
||||
}
|
||||
|
||||
// One card per group, built from what the compositor actually has bound.
|
||||
//
|
||||
// Both Repeaters address their model through an explicit id. Inside a
|
||||
// SettingsCard the surrounding `parent` is the card's internal Column, not
|
||||
// the card, so `parent.modelData` is undefined there and the rows silently
|
||||
// never appear -- the cards render with their heading and nothing under it.
|
||||
Repeater {
|
||||
model: Keybinds.grouped()
|
||||
|
||||
SettingsCard {
|
||||
id: groupCard
|
||||
|
||||
required property var modelData
|
||||
|
||||
title: groupCard.modelData.name
|
||||
subtitle: groupCard.modelData.binds.length === 1
|
||||
? "1 shortcut"
|
||||
: `${groupCard.modelData.binds.length} shortcuts`
|
||||
|
||||
Repeater {
|
||||
id: bindRows
|
||||
|
||||
model: groupCard.modelData.binds
|
||||
|
||||
TextRow {
|
||||
required property var modelData
|
||||
required property int index
|
||||
|
||||
label: modelData.description
|
||||
value: modelData.chord
|
||||
controlWidth: 230
|
||||
divider: index < bindRows.count - 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
visible: Keybinds.lastError !== ""
|
||||
title: "Shortcuts unavailable"
|
||||
subtitle: Keybinds.lastError
|
||||
|
||||
ActionRow {
|
||||
label: "Read the keymap again"
|
||||
detail: "Shortcuts are read from the running compositor"
|
||||
action: "Retry"
|
||||
divider: false
|
||||
onTriggered: Keybinds.refresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
// A numeric setting, bound to a schema key by name.
|
||||
//
|
||||
// SliderRow { setting: "windowRounding" }
|
||||
//
|
||||
// Range, step, label, explanation, and unit all come from PreferenceSchema.
|
||||
//
|
||||
// Three behaviours worth knowing:
|
||||
//
|
||||
// * The row is responsive. The Settings window is a normal tiled window, so
|
||||
// its width is whatever the layout gives it -- anywhere from a half-screen
|
||||
// split to the full 4500px display, and `implicitWidth` is only a hint.
|
||||
// Below a usable inline width the slider moves onto its own line under the
|
||||
// label instead of squeezing the explanation into a five-line column.
|
||||
// * The readout follows the drag immediately, but the value is only committed
|
||||
// after a short quiet period. Compositor-backed settings are applied and
|
||||
// verified one batch at a time, and a slider fires dozens of changes per
|
||||
// second -- committing each would spend the whole drag rejecting
|
||||
// overlapping writes.
|
||||
// * Between commits the row shows what you are dragging; once settled it
|
||||
// shows what is actually stored. If the compositor refuses a value the row
|
||||
// falls back to the stored one rather than displaying a value nothing
|
||||
// accepted.
|
||||
//
|
||||
// This does not extend SettingRow: that component fixes the control to a
|
||||
// trailing column of a set width, which is the layout this row needs to be able
|
||||
// to abandon. The label, explanation, and divider match it exactly.
|
||||
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.services
|
||||
import qs.widgets
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
required property string setting
|
||||
|
||||
property bool divider: true
|
||||
property string zeroLabel: ""
|
||||
|
||||
readonly property var spec: PreferenceSchema.spec(root.setting)
|
||||
readonly property string label: root.spec ? root.spec.label : root.setting
|
||||
readonly property string detail: root.spec ? root.spec.detail : ""
|
||||
readonly property real minimum: root.spec && root.spec.min !== undefined ? root.spec.min : 0
|
||||
readonly property real maximum: root.spec && root.spec.max !== undefined ? root.spec.max : 100
|
||||
readonly property real step: root.spec && root.spec.step !== undefined ? root.spec.step : 1
|
||||
readonly property string unit: root.spec && root.spec.unit !== undefined ? root.spec.unit : ""
|
||||
|
||||
readonly property real stored: {
|
||||
const value = DesktopPreferences.get(root.setting);
|
||||
return typeof value === "number" ? value : root.minimum;
|
||||
}
|
||||
|
||||
// Shown while dragging; -1 means "nothing pending, show what is stored".
|
||||
property real pending: -1
|
||||
readonly property real shown: root.pending >= 0 ? root.pending : root.stored
|
||||
|
||||
// Below this the label and a usable slider cannot share a line without one
|
||||
// of them becoming useless.
|
||||
readonly property bool inline: width >= 520
|
||||
|
||||
// A fixed trailing width rather than a share of the row. A proportional
|
||||
// control looks reasonable at 900px and absurd at 4500px, where the slider
|
||||
// would be a metre long next to a two-word label -- and this window is
|
||||
// tiled, so it really can be that wide.
|
||||
readonly property int controlSpan: 300
|
||||
|
||||
width: parent ? parent.width : 620
|
||||
implicitHeight: root.inline
|
||||
? Math.max(56, copy.implicitHeight + 20)
|
||||
: copy.implicitHeight + 32 + 30
|
||||
|
||||
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));
|
||||
// Steps below 1 are fractional (opacity, pointer speed); rounding to two
|
||||
// places keeps 0.8500000000000001 out of the readout and the store.
|
||||
return root.step < 1 ? Math.round(clamped * 100) / 100 : clamped;
|
||||
}
|
||||
|
||||
function display(value: real): string {
|
||||
if (value === 0 && root.zeroLabel !== "")
|
||||
return root.zeroLabel;
|
||||
const text = root.step < 1 ? value.toFixed(2) : String(value);
|
||||
return root.unit === "" ? text : `${text} ${root.unit}`;
|
||||
}
|
||||
|
||||
// Both children are positioned explicitly rather than by anchors. Binding
|
||||
// an anchor to `undefined` to switch layouts does not reliably release it,
|
||||
// which left the slider anchored to both edges and the label squeezed into
|
||||
// whatever was left.
|
||||
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
|
||||
|
||||
ValueSlider {
|
||||
id: slider
|
||||
|
||||
anchors.left: parent.left
|
||||
anchors.right: readout.left
|
||||
anchors.rightMargin: 12
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
|
||||
value: root.maximum > root.minimum
|
||||
? (root.shown - root.minimum) / (root.maximum - root.minimum)
|
||||
: 0
|
||||
|
||||
onMoved: ratio => {
|
||||
root.pending = root.quantise(ratio);
|
||||
commitTimer.restart();
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
id: readout
|
||||
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: 58
|
||||
horizontalAlignment: Text.AlignRight
|
||||
text: root.display(root.shown)
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
// The readout changes digit by digit under the pointer; tabular
|
||||
// figures stop it twitching sideways as it does.
|
||||
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: 140
|
||||
onTriggered: {
|
||||
if (root.pending < 0)
|
||||
return;
|
||||
SystemSettings.commitPreference(root.setting, root.pending);
|
||||
// Hand the display back to the stored value. If the write was
|
||||
// refused, the row snaps back to what is really in effect.
|
||||
releaseTimer.restart();
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: releaseTimer
|
||||
interval: 160
|
||||
onTriggered: root.pending = -1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
// A row that only reports something.
|
||||
//
|
||||
// TextRow { label: "Graphics"; detail: "Rendering device"; value: "AMD Radeon" }
|
||||
//
|
||||
// Genuinely read-only facts -- the active display's identity, a version string,
|
||||
// a detected capability -- belong here. A setting the user could reasonably
|
||||
// change does NOT: before Stage 3 more than half of Panama's settings rows were
|
||||
// static text standing in for a control that was simply expensive to add, and
|
||||
// this component exists for the honest remainder rather than to make that easy
|
||||
// to do again.
|
||||
|
||||
import QtQuick
|
||||
import qs.config
|
||||
|
||||
SettingRow {
|
||||
id: root
|
||||
|
||||
controlWidth: 210
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
// A boolean setting, bound to a schema key by name.
|
||||
//
|
||||
// ToggleRow { setting: "blurEnabled" }
|
||||
//
|
||||
// Label, explanation, and validation all come from PreferenceSchema, so a row
|
||||
// cannot drift from the setting it edits, and the writing side does not care
|
||||
// whether the key is stored locally or applied to the compositor first.
|
||||
// Override `label` or `detail` only when a page needs different wording than
|
||||
// the schema's default.
|
||||
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
SettingRow {
|
||||
id: root
|
||||
|
||||
required property string setting
|
||||
|
||||
readonly property var spec: PreferenceSchema.spec(root.setting)
|
||||
readonly property bool checked: DesktopPreferences.get(root.setting) === true
|
||||
|
||||
label: root.spec ? root.spec.label : root.setting
|
||||
detail: root.spec ? root.spec.detail : ""
|
||||
controlWidth: 48
|
||||
|
||||
SettingsToggle {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
checked: root.checked
|
||||
onToggled: value => SystemSettings.commitPreference(root.setting, value)
|
||||
}
|
||||
}
|
||||
@@ -20,3 +20,10 @@ SettingsToggle 1.0 SettingsToggle.qml
|
||||
SettingsWindow 1.0 SettingsWindow.qml
|
||||
ShortcutsPage 1.0 ShortcutsPage.qml
|
||||
SoundPage 1.0 SoundPage.qml
|
||||
SettingsPage 1.0 SettingsPage.qml
|
||||
ToggleRow 1.0 ToggleRow.qml
|
||||
SliderRow 1.0 SliderRow.qml
|
||||
ChoiceRow 1.0 ChoiceRow.qml
|
||||
ActionRow 1.0 ActionRow.qml
|
||||
TextRow 1.0 TextRow.qml
|
||||
DesktopPreview 1.0 DesktopPreview.qml
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
pragma Singleton
|
||||
|
||||
// The keymap, read from the compositor rather than restated.
|
||||
//
|
||||
// The Shortcuts page used to hold a hand-typed array of nineteen entries while
|
||||
// keybinds.lua produced a hundred and thirteen. It could not show the other
|
||||
// ninety-four, and it drifted the moment a bind was edited. `hyprctl binds -j`
|
||||
// is the only description of the keymap that cannot be wrong, so this reads
|
||||
// that and every bind carries its own human label (see the `description`
|
||||
// argument in hypr/keybinds.lua).
|
||||
//
|
||||
// Refreshed on demand, not polled: binds only change when the config is
|
||||
// reloaded, and nothing in this shell should wake up to re-read something that
|
||||
// has not moved.
|
||||
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import QtQuick
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
// [{ chord, description, group, mouse, repeating, locked }], ordered as
|
||||
// Hyprland reports them, which follows the order they appear in the config.
|
||||
property var binds: []
|
||||
property bool loaded: false
|
||||
property string lastError: ""
|
||||
|
||||
readonly property bool busy: query.running
|
||||
|
||||
// Hyprland's modmask bits. SUPER is the Panama modifier.
|
||||
readonly property var modifierBits: [
|
||||
{ bit: 64, name: "Super" },
|
||||
{ bit: 4, name: "Ctrl" },
|
||||
{ bit: 8, name: "Alt" },
|
||||
{ bit: 1, name: "Shift" }
|
||||
]
|
||||
|
||||
// Keysyms whose raw names would be noise in a shortcuts list.
|
||||
readonly property var keyNames: ({
|
||||
"mouse_up": "Scroll up",
|
||||
"mouse_down": "Scroll down",
|
||||
"mouse:272": "Left click",
|
||||
"mouse:273": "Right click",
|
||||
"mouse:274": "Middle click",
|
||||
"bracketleft": "[",
|
||||
"bracketright": "]",
|
||||
"grave": "`",
|
||||
"Print": "Print Screen",
|
||||
"left": "←",
|
||||
"right": "→",
|
||||
"up": "↑",
|
||||
"down": "↓"
|
||||
})
|
||||
|
||||
Process {
|
||||
id: query
|
||||
command: ["hyprctl", "-j", "binds"]
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: root.parse(this.text)
|
||||
}
|
||||
onExited: (exitCode, exitStatus) => {
|
||||
if (exitCode !== 0)
|
||||
root.lastError = "Could not read the keymap from Hyprland.";
|
||||
}
|
||||
}
|
||||
|
||||
Component.onCompleted: root.refresh()
|
||||
|
||||
function refresh(): void {
|
||||
if (query.running)
|
||||
return;
|
||||
root.lastError = "";
|
||||
query.running = true;
|
||||
}
|
||||
|
||||
function parse(text: string): void {
|
||||
try {
|
||||
const raw = JSON.parse(text);
|
||||
const out = [];
|
||||
for (const bind of raw) {
|
||||
const description = String(bind.description ?? "").trim();
|
||||
// A bind with no description cannot be presented usefully --
|
||||
// the dispatcher is "__lua" and the argument is a bytecode
|
||||
// offset. Showing the chord alone would be worse than omitting
|
||||
// it, and tests/quickshell/keybinds-contract.sh fails the build
|
||||
// if any exist, so this should never be reached in practice.
|
||||
if (description === "")
|
||||
continue;
|
||||
out.push({
|
||||
chord: root.formatChord(bind),
|
||||
description: description,
|
||||
group: root.groupFor(description, bind),
|
||||
mouse: bind.mouse === true,
|
||||
repeating: bind.repeat === true,
|
||||
locked: bind.locked === true
|
||||
});
|
||||
}
|
||||
root.binds = out;
|
||||
root.loaded = true;
|
||||
root.lastError = "";
|
||||
} catch (error) {
|
||||
root.lastError = "The keymap could not be read.";
|
||||
}
|
||||
}
|
||||
|
||||
function formatChord(bind: var): string {
|
||||
const parts = [];
|
||||
for (const modifier of root.modifierBits) {
|
||||
if ((bind.modmask & modifier.bit) !== 0)
|
||||
parts.push(modifier.name);
|
||||
}
|
||||
const key = String(bind.key ?? "");
|
||||
parts.push(root.keyNames[key] ?? (key.length === 1 ? key.toUpperCase() : key));
|
||||
return parts.join(" + ");
|
||||
}
|
||||
|
||||
// Grouping is by what the shortcut does, taken from its own description,
|
||||
// so adding a bind puts it in the right section without touching this file.
|
||||
function groupFor(description: string, bind: var): string {
|
||||
const text = description.toLowerCase();
|
||||
if (bind.key && String(bind.key).indexOf("XF86") === 0)
|
||||
return "Media & hardware keys";
|
||||
if (text.indexOf("workspace") >= 0)
|
||||
return "Workspaces";
|
||||
if (text.indexOf("window") >= 0 || text.indexOf("focus") >= 0
|
||||
|| text.indexOf("swap") >= 0 || text.indexOf("split") >= 0
|
||||
|| text.indexOf("wider") >= 0 || text.indexOf("narrower") >= 0
|
||||
|| text.indexOf("taller") >= 0 || text.indexOf("shorter") >= 0
|
||||
|| text.indexOf("shrink") >= 0 || text.indexOf("grow") >= 0
|
||||
|| text.indexOf("float") >= 0 || text.indexOf("fullscreen") >= 0
|
||||
|| text.indexOf("close") >= 0 || text.indexOf("scratchpad") >= 0)
|
||||
return "Windows";
|
||||
if (text.indexOf("volume") >= 0 || text.indexOf("mute") >= 0
|
||||
|| text.indexOf("track") >= 0 || text.indexOf("play") >= 0
|
||||
|| text.indexOf("brightness") >= 0)
|
||||
return "Media & hardware keys";
|
||||
return "Applications & shell";
|
||||
}
|
||||
|
||||
// Section order for the page. Anything a future bind invents lands at the
|
||||
// end rather than being dropped.
|
||||
readonly property var groupOrder: ["Windows", "Workspaces", "Applications & shell", "Media & hardware keys"]
|
||||
|
||||
function grouped(): var {
|
||||
const buckets = {};
|
||||
for (const bind of root.binds) {
|
||||
buckets[bind.group] = buckets[bind.group] ?? [];
|
||||
buckets[bind.group].push(bind);
|
||||
}
|
||||
const names = Object.keys(buckets).sort((a, b) => {
|
||||
const ia = root.groupOrder.indexOf(a);
|
||||
const ib = root.groupOrder.indexOf(b);
|
||||
return (ia < 0 ? 999 : ia) - (ib < 0 ? 999 : ib);
|
||||
});
|
||||
return names.map(name => ({ name: name, binds: buckets[name] }));
|
||||
}
|
||||
}
|
||||
@@ -216,14 +216,38 @@ Singleton {
|
||||
}
|
||||
if (Object.keys(requested).length === 0)
|
||||
return false;
|
||||
|
||||
// A write in flight is queued rather than refused. Options are applied
|
||||
// and verified one batch at a time, but the callers are a settings UI
|
||||
// and a startup replay of every compositor-backed preference -- they
|
||||
// overlap routinely, and dropping a change on the floor would leave the
|
||||
// stored value and the compositor disagreeing. Later values for the
|
||||
// same key win.
|
||||
if (configWrite.running || configVerify.running) {
|
||||
root.lastError = "Another change is still being applied.";
|
||||
return false;
|
||||
root.queued = Object.assign({}, root.queued, requested);
|
||||
return true;
|
||||
}
|
||||
|
||||
root.startWrite(requested);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Merged batches waiting for the current write to finish.
|
||||
property var queued: ({})
|
||||
|
||||
function startWrite(requested: var): void {
|
||||
configWrite.pending = requested;
|
||||
configWrite.exec(["hyprctl", "eval", root.buildConfigPayload(requested)]);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Called when a write settles, however it settled. A failed batch must not
|
||||
// strand whatever queued up behind it.
|
||||
function drainQueue(): void {
|
||||
const next = root.queued;
|
||||
if (Object.keys(next).length === 0)
|
||||
return;
|
||||
root.queued = ({});
|
||||
root.startWrite(next);
|
||||
}
|
||||
|
||||
// The value as Hyprland stores it. Several options are a toggle in the UI
|
||||
@@ -308,6 +332,7 @@ Singleton {
|
||||
}
|
||||
|
||||
root.lastError = rejected.length === 0 ? "" : `Hyprland did not apply ${rejected.join(" or ")}.`;
|
||||
root.drainQueue();
|
||||
}
|
||||
|
||||
function matchesObserved(entry: var, value: var, answer: var): bool {
|
||||
@@ -334,6 +359,57 @@ Singleton {
|
||||
function reportWriteFailure(requested: var, text: string): void {
|
||||
const labels = Object.keys(requested).map(key => PreferenceSchema.spec(key).label);
|
||||
root.lastError = `Hyprland rejected ${labels.join(" and ")}.`;
|
||||
root.drainQueue();
|
||||
}
|
||||
|
||||
// The one entry point the settings UI uses to change any preference.
|
||||
//
|
||||
// A compositor-backed setting must be applied and verified before it is
|
||||
// stored, so that preferences never claim a value Hyprland refused.
|
||||
// Everything else is a direct write. Rows bind a schema key and call this;
|
||||
// they never need to know which kind they are holding.
|
||||
function commitPreference(key: string, value: var): bool {
|
||||
const entry = PreferenceSchema.spec(key);
|
||||
if (!entry) {
|
||||
root.lastError = "That setting is not part of Panama.";
|
||||
return false;
|
||||
}
|
||||
if (entry.hypr) {
|
||||
const batch = {};
|
||||
batch[key] = value;
|
||||
return root.applyOptions(batch);
|
||||
}
|
||||
return DesktopPreferences.set(key, value);
|
||||
}
|
||||
|
||||
// Restores shipped defaults across every store Panama owns, not just the
|
||||
// schema. Panama keeps user state in more than one file -- the schema store,
|
||||
// the focus session, and the Home accessory arrangement -- and a reset that
|
||||
// silently skipped one would be worse than no reset at all.
|
||||
//
|
||||
// Compositor-backed values are re-applied afterwards, since resetting the
|
||||
// stored value does not by itself tell Hyprland anything.
|
||||
function restoreDefaults(): void {
|
||||
DesktopPreferences.resetDesktopDefaults();
|
||||
|
||||
// Home accessories keep their own store (panama-home.json), so a reset
|
||||
// that only cleared the schema store would silently leave a customised
|
||||
// favourites list behind while claiming to restore Panama's defaults.
|
||||
//
|
||||
// Done through HomePreferences' public writable aliases rather than a
|
||||
// reset function of its own: clearing `favorites` and returning
|
||||
// `initialized` to false is exactly the state a fresh install has, and
|
||||
// it lets initialize() seed the list again on next use.
|
||||
HomePreferences.favorites = [];
|
||||
HomePreferences.initialized = false;
|
||||
|
||||
resettleTimer.restart();
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: resettleTimer
|
||||
interval: 60
|
||||
onTriggered: root.applyPersistedDisplayPolicy()
|
||||
}
|
||||
|
||||
function setAutoHdr(enabled: bool): void {
|
||||
|
||||
@@ -29,6 +29,31 @@ ShellRoot {
|
||||
return SystemSettings.applyOptions(JSON.parse(payload));
|
||||
}
|
||||
|
||||
// The routing entry point the settings rows use: a compositor-backed
|
||||
// key must be applied and verified before it is stored, a local one is
|
||||
// written directly. The contract checks both halves.
|
||||
function commit(key: string, payload: string): bool {
|
||||
return SystemSettings.commitPreference(key, JSON.parse(payload));
|
||||
}
|
||||
|
||||
function stored(key: string): string {
|
||||
return JSON.stringify(DesktopPreferences.get(key));
|
||||
}
|
||||
|
||||
function seedHome(): void {
|
||||
HomePreferences.favorites = [{ id: "light.contract_probe", alias: "Probe" }];
|
||||
HomePreferences.initialized = true;
|
||||
}
|
||||
|
||||
function homeState(): string {
|
||||
return JSON.stringify({
|
||||
count: HomePreferences.favorites.length,
|
||||
initialized: HomePreferences.initialized
|
||||
});
|
||||
}
|
||||
|
||||
function restoreDefaults(): void { SystemSettings.restoreDefaults(); }
|
||||
|
||||
function panelAllowed(panel: string): bool {
|
||||
return SystemSettings.isGnomePanelAllowed(panel);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user