Make Shell a category, the bar legible, and the dock a real dock

Desktop & Dock becomes Shell — Bar, Dock, Control Center, Tiling,
Workspaces — the home for everything Quickshell draws. The settings-
management cluster moves to System as Sync & Backup, Appearance's
Shell tab dissolves, and 24-hour time finally lives on Date & Time,
which always owned it.

The bar gets what it never had: a way to survive the wallpaper. A
second neutral text family (follow theme, or forced light or dark),
a one-layer shadow under every glyph, and a gradient scrim for
wallpapers nothing else survives — all off by default, pixel-identical
until asked. Widgets earn toggles (weather, media, clipboard, calendar
countdown), the vitals cluster stops leaving a dead pill behind, and
Control Center's sections learn to step aside.

The dock graduates from MVP: a context menu with window rows, pin,
unpin, quit and new-window; scroll an icon to cycle its windows; drag
to reorder on the dock itself; hover previews with one-shot captures;
and "Add App to Dock" in the launcher. Three real bugs died en route —
menus that slid away with the autohide, a readonly-property crash on
every menu open, and a drag that drifted half a slot per icon on side
docks. The pinned-apps editor in Settings becomes a drag strip.

166 contracts; the full suite is green except two live display and
switcher tests that cannot run behind a locked session — re-verified
on unlock.

Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
Gabriel Brown
2026-08-24 04:28:20 -04:00
parent 4d7a194300
commit f8f5b25510
77 changed files with 2982 additions and 800 deletions
@@ -73,7 +73,6 @@ SettingsPage {
{ value: "background", label: "Background" },
{ value: "type", label: "Typography" },
{ value: "windows", label: "Windows" },
{ value: "shell", label: "Shell" },
]
current: root.tab
onSelected: value => root.tab = value
@@ -581,48 +580,4 @@ SettingsPage {
ToggleRow { setting: "animationsEnabled"; divider: false }
}
SettingsCard {
visible: root.tab === "shell"
title: "Clock"
ToggleRow { setting: "use24Hour" }
ToggleRow { setting: "showSeconds" }
ToggleRow { setting: "showWeekday"; divider: false }
}
SettingsCard {
visible: root.tab === "shell"
title: "System vitals"
subtitle: "Choose what appears beside the workspace indicator."
ToggleRow { setting: "showCpu" }
ToggleRow { setting: "showMemory" }
ToggleRow { setting: "showGpu" }
// Only where there is a battery to report on. A desktop should not be
// offered a switch for a readout it can never show.
ToggleRow { setting: "showBattery"; visible: Battery.available }
ToggleRow { setting: "showBatteryPercent"; visible: Battery.available && Settings.showBattery }
ToggleRow { setting: "showAgentUsage"; divider: true }
// Refresh interval was on the Home page, which split one concept across
// two pages -- what the vitals show here, how often they update there.
SliderRow { setting: "vitalsIntervalMs"; divider: GraphicsDevices.devices.length > 1 || GraphicsDevices.selectionMissing }
// Only worth asking when there is a choice to make.
ChoiceGrid {
visible: GraphicsDevices.devices.length > 1 || GraphicsDevices.selectionMissing
width: parent.width
label: "Graphics device"
detail: GraphicsDevices.selectionMissing
? "The stored device is not present on this machine, so the graphics readout is hidden. Choose one below."
: "Which GPU the graphics readout measures."
options: GraphicsDevices.devices.map(device => ({
value: device.path,
label: GraphicsDevices.shortName(device.name)
}))
current: GraphicsDevices.selectedPath
divider: false
onPicked: value => GraphicsDevices.select(value)
}
}
}
@@ -0,0 +1,83 @@
// The bar.
//
// Everything Panama draws along the top edge: how legible it stays, what earns
// a place in it, and how often the readouts refresh. The bar floats directly on
// the wallpaper, so legibility is a real setting and not a theme detail -- a
// theme that reads perfectly against the panel background can disappear
// entirely over a bright photograph.
//
// The Clock and System vitals cards came from Appearance's Shell tab, which was
// the wrong home for them: Appearance is about how surfaces look, and these
// decide what the bar contains.
import QtQuick
import qs.config
import qs.services
SettingsPage {
id: root
title: "Bar"
lede: "The bar sits on whatever wallpaper you chose. These keep it legible on all of them."
SettingsCard {
title: "Visibility"
subtitle: "The real bar above this window is the preview — every change here lands on it immediately."
ChoiceRow { setting: "barTextTone" }
ToggleRow { setting: "barTextShadow" }
ToggleRow { setting: "barBackdrop"; divider: false }
}
SettingsCard {
title: "Widgets"
subtitle: "What earns a place in the bar. Indicators that carry state — health, activity, focus, video wallpaper — appear on their own and leave when they are done."
ToggleRow { setting: "showWeatherWidget" }
ToggleRow { setting: "showMediaWidget" }
ToggleRow { setting: "showClipboardButton" }
ToggleRow { setting: "showCalendarCountdown" }
ToggleRow { setting: "showCpu" }
ToggleRow { setting: "showMemory" }
ToggleRow { setting: "showGpu" }
// Only where there is a battery to report on. A desktop should not be
// offered a switch for a readout it can never show.
ToggleRow { setting: "showBattery"; visible: Battery.available }
ToggleRow { setting: "showBatteryPercent"; visible: Battery.available && Settings.showBattery }
ToggleRow { setting: "showAgentUsage"; divider: false }
}
SettingsCard {
title: "Clock"
subtitle: "24-hour time lives in System Date & Time — it drives the date menu, notifications, and the lock screen too, so it was never just the bar's."
ToggleRow { setting: "showSeconds" }
ToggleRow { setting: "showWeekday"; divider: false }
}
SettingsCard {
title: "Vitals"
SliderRow {
setting: "vitalsIntervalMs"
divider: GraphicsDevices.devices.length > 1 || GraphicsDevices.selectionMissing
}
// Only worth asking when there is a choice to make.
ChoiceGrid {
visible: GraphicsDevices.devices.length > 1 || GraphicsDevices.selectionMissing
width: parent.width
label: "Graphics device"
detail: GraphicsDevices.selectionMissing
? "The stored device is not present on this machine, so the graphics readout is hidden. Choose one below."
: "Which GPU the graphics readout measures."
options: GraphicsDevices.devices.map(device => ({
value: device.path,
label: GraphicsDevices.shortName(device.name)
}))
current: GraphicsDevices.selectedPath
divider: false
onPicked: value => GraphicsDevices.select(value)
}
}
}
@@ -0,0 +1,42 @@
// Control Center.
//
// The panel behind the bar's right corner. Its sections are real surfaces with
// real cost -- Home talks to Home Assistant, Phone to KDE Connect -- so a
// machine that has neither should be able to say so once instead of scrolling
// past two empty shelves every time the panel opens.
//
// The accessories themselves are not configured here. Which lights appear,
// what they are called, and what order they sit in is one arrangement shared
// with the Home page, and it is edited there.
import QtQuick
import qs.config
import qs.services
SettingsPage {
id: root
title: "Control Center"
lede: "What the panel offers when it opens."
SettingsCard {
title: "Sections"
subtitle: "Turning one off hides it from the panel. Nothing stops working — the settings pages behind each still do."
ToggleRow { setting: "ccShowFocus" }
ToggleRow { setting: "ccShowHome" }
ToggleRow { setting: "ccShowPhone"; divider: false }
}
SettingsCard {
title: "Accessories"
ActionRow {
label: "Control Center shelf"
detail: "Which lights and scenes appear, their names and their order — arranged on Home My Home"
action: "Open My Home"
divider: false
onTriggered: ShellState.openSettings("my-home")
}
}
}
@@ -3,8 +3,10 @@
// These belong to the machine rather than to Panama, so nothing here is stored
// in Panama's settings file -- it would be a second answer to a question the
// system already answers. Timezone and network time are read from and written
// to timedatectl directly; the clock's presentation lives on Appearance,
// because that genuinely is a Panama preference.
// to timedatectl directly. 24-hour time is the one presentation choice that
// belongs here rather than on Shell Bar: it drives the date menu,
// notification timestamps and the lock screen as well as the bar. The rest of
// the bar clock's presentation -- seconds, weekday -- stays with the bar.
//
// Changing the timezone or network time needs privilege. timedatectl asks
// polkit, and a canceled dialog surfaces as an error rather than as a value
@@ -30,6 +32,11 @@ SettingsPage {
detail: DateTime.timezone === "" ? "Reading the system clock" : DateTime.timezone
value: Qt.formatDateTime(clock.date, Settings.use24Hour ? "ddd d MMM HH:mm" : "ddd d MMM h:mm AP")
}
// Not just the bar's. The same choice reads out in the date menu,
// every notification's timestamp, and the lock screen, so it belongs
// beside the clock the whole machine shares rather than on a page
// about one surface.
ToggleRow { setting: "use24Hour" }
SettingRow {
label: "Set automatically"
detail: DateTime.ntpEnabled
@@ -1,395 +0,0 @@
// 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 Quickshell
import qs.config
import qs.services
SettingsPage {
id: root
// Which project is one click from being forgotten. Same in-place confirm the
// SSH Keys and Snapshots pages use.
property string confirmingProject: ""
// Turning the last screen off would leave no dock anywhere and no obvious
// way back, so the final one cannot be removed -- it collapses to "every
// screen" instead, which is the same thing on one display and recoverable
// on several.
function toggleDockScreen(name: string): void {
const all = Quickshell.screens.map(screen => String(screen.name));
const current = Settings.dockScreens.length === 0
? all.slice()
: Settings.dockScreens.map(String);
const at = current.indexOf(name);
let next = current.slice();
if (at >= 0)
next.splice(at, 1);
else
next.push(name);
if (next.length === 0 || next.length === all.length)
next = [];
DesktopPreferences.set("dockScreens", next);
}
title: "Desktop & Dock"
lede: "Keep the shell instant, spatial, and out of your way."
SettingsCard {
title: "Dock"
ChoiceRow { setting: "dockPosition" }
// One row per connected screen. Nothing selected means every screen,
// which is stated rather than left as an empty list somebody has to
// interpret -- and it is what a single-monitor machine should do
// without being configured at all.
SettingRow {
label: "Screens"
detail: Settings.dockScreens.length === 0
? "On every display"
: "On " + Settings.dockScreens.length + " of "
+ Quickshell.screens.length + " displays"
visible: Quickshell.screens.length > 1
controlWidth: 260
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 7
Repeater {
model: Quickshell.screens
delegate: Rectangle {
id: screenPill
required property var modelData
readonly property string screenName: String(screenPill.modelData.name ?? "")
// An empty list means all, so every pill reads as on.
readonly property bool on: Settings.dockScreens.length === 0
|| Settings.dockScreens.indexOf(screenPill.screenName) >= 0
width: pillLabel.implicitWidth + 20
height: 28
radius: 8
color: screenPill.on ? Theme.alpha(Theme.accent, 0.22)
: Theme.alpha(Theme.fg, 0.06)
border.width: screenPill.on ? 1 : 0
border.color: Theme.alpha(Theme.accent, 0.5)
Text {
id: pillLabel
anchors.centerIn: parent
text: screenPill.screenName
color: screenPill.on ? Theme.fg : Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
HoverHandler { cursorShape: Qt.PointingHandCursor }
TapHandler { onTapped: root.toggleDockScreen(screenPill.screenName) }
}
}
}
}
ToggleRow { setting: "dockAutohide" }
SliderRow { setting: "dockRevealDelayMs"; zeroLabel: "Instant" }
SliderRow { setting: "dockHideDelayMs"; zeroLabel: "Instant" }
SliderRow { setting: "dockIconSize"; divider: false }
}
SettingsCard {
title: "Pinned applications"
subtitle: "What sits in the Dock whether or not it is running. Order here is the order on screen."
DockPinsEditor {
id: pins
width: parent.width
}
}
SettingsCard {
title: "Pin another application"
DockAppPicker {
width: parent.width
pinned: pins.pinned
onPicked: id => pins.add(id)
}
}
SettingsCard {
title: "Window layout"
subtitle: "Follows the Forge mental model, with native Hyprland tiling."
// These were two read-only rows reporting "Tiling" and "Dynamic", which
// described settings rather than facts -- both are ordinary Hyprland
// options that simply had no controls. TextRow's own documentation says
// a setting the user could reasonably change does not belong in it.
ChoiceRow { setting: "windowLayout" }
ToggleRow { setting: "preserveSplit" }
ChoiceRow { setting: "forceSplit" }
ToggleRow { setting: "windowSnapping" }
ActionRow {
label: "Gaps, corners, and effects"
detail: "Adjusted on the Appearance page, beside a live preview"
action: "Open Appearance"
divider: false
onTriggered: ShellState.openSettingsSection("appearance", "windows")
}
}
// GNOME's Multitasking panel, in Hyprland's terms.
// Only meaningful when the layout above is Master and stack. Hidden
// otherwise, because a card of settings that do nothing under the layout
// you are actually running is worse than not offering the layout at all.
SettingsCard {
visible: DesktopPreferences.get("windowLayout") === "master"
title: "Master and stack"
subtitle: "How the master area behaves. These apply only while the tiling layout above is Master and stack."
SliderRow { setting: "masterFactor" }
ChoiceRow { setting: "masterOrientation" }
ChoiceRow { setting: "masterNewStatus" }
ToggleRow { setting: "masterNewOnTop"; divider: false }
}
SettingsCard {
title: "Window edges"
subtitle: "How the pointer grabs a window's border, and how floating windows behave near each other and the screen edge."
ToggleRow { setting: "resizeOnBorder" }
SliderRow { setting: "borderGrabArea"; zeroLabel: "Border only" }
ToggleRow { setting: "hoverIconOnBorder" }
SliderRow { setting: "snapWindowGap"; zeroLabel: "Touching" }
SliderRow { setting: "snapMonitorGap"; zeroLabel: "Touching" }
ToggleRow { setting: "snapRespectGaps"; divider: false }
}
// Hyprland's own interruptions. Panama turns all four off, which is a
// defensible default and was not previously a decision anyone could
// reverse without editing looks.lua.
SettingsCard {
title: "Hyprland notices"
subtitle: "Panama hides all of these by default. They are the compositor's own, not Panama's."
ToggleRow { setting: "hyprlandLogo" }
ToggleRow { setting: "hyprlandSplash" }
ToggleRow { setting: "hyprlandUpdateNews" }
ToggleRow { setting: "hyprlandDonationNag"; divider: false }
}
// Saved here, not created here. A layout is worth recording at the moment
// you have it right, so saving is a launcher command; this is where the
// ones you kept are reviewed and the ones you did not are removed.
SettingsCard {
title: "Projects"
subtitle: Projects.projects.length > 0
? "Saved window layouts. Opening one claims free workspaces, so it never lands on top of what you are doing."
: "No saved layouts yet. Arrange your windows, then run \"Save Layout as Project\" from the launcher."
Repeater {
model: Projects.projects
delegate: SettingRow {
id: projectRow
required property var modelData
required property int index
readonly property string projectName: String(projectRow.modelData.name ?? "")
readonly property bool confirming: root.confirmingProject === projectRow.projectName
label: projectRow.projectName
detail: projectRow.confirming
? "Forgetting this only removes the layout. Nothing that is open closes."
: projectRow.modelData.windows + " windows across "
+ projectRow.modelData.workspaces + " workspaces — "
+ (projectRow.modelData.applications ?? []).join(", ")
divider: projectRow.index < Projects.projects.length - 1
controlWidth: 210
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 8
SettingsButton {
anchors.verticalCenter: parent.verticalCenter
visible: !projectRow.confirming
text: "Open"
enabled: !Projects.busy
onClicked: Projects.open(projectRow.projectName)
}
SettingsButton {
anchors.verticalCenter: parent.verticalCenter
visible: projectRow.confirming
text: "Forget it"
tone: "danger"
enabled: !Projects.busy
onClicked: {
root.confirmingProject = "";
Projects.remove(projectRow.projectName);
}
}
SettingsButton {
anchors.verticalCenter: parent.verticalCenter
text: projectRow.confirming ? "Keep" : "Forget"
enabled: !Projects.busy
onClicked: root.confirmingProject =
projectRow.confirming ? "" : projectRow.projectName
}
}
}
}
TextRow {
visible: Projects.lastError !== ""
label: "Problem"
detail: Projects.lastError
divider: false
}
}
SettingsCard {
title: "Workspaces & focus"
subtitle: "Hyprland's workspaces are created and destroyed as you use them, so there is no fixed count to set."
ToggleRow { setting: "workspaceBackAndForth" }
ToggleRow { setting: "allowWorkspaceCycles" }
ToggleRow { setting: "focusOnActivate" }
ToggleRow { setting: "mouseMoveFocusesMonitor" }
ToggleRow { setting: "windowSwallow"; divider: false }
}
SettingsCard {
title: "Focus"
SliderRow { setting: "focusDurationMinutes"; divider: false }
}
// Distinct from the snapshots below, which put THIS machine back as it
// was. This carries settings to a different one, and deliberately leaves
// behind anything that describes hardware.
SettingsCard {
title: "Carry settings to another machine"
subtitle: SettingsSync.lastError !== ""
? SettingsSync.lastError
: "Everything except what describes this machine: the display arrangement stays here."
ActionRow {
label: "Export"
detail: SettingsSync.lastAction === "export" && SettingsSync.carried > 0
? SettingsSync.carried + " settings written to " + SettingsSync.defaultPath
: "Writes " + SettingsSync.defaultPath
action: "Export"
enabled: !SettingsSync.busy
onTriggered: SettingsSync.exportTo(SettingsSync.defaultPath)
}
ActionRow {
label: "See what an import would change"
detail: SettingsSync.previewed
? SettingsSync.changes.length + " would change, "
+ SettingsSync.skipped.length + " skipped"
: "Reads " + SettingsSync.defaultPath + " without applying anything"
action: "Preview"
enabled: !SettingsSync.busy
onTriggered: SettingsSync.preview(SettingsSync.defaultPath)
}
// Only offered once a preview has said what it would do. Importing
// settings sight unseen is how somebody ends up wondering why their
// desktop changed.
ActionRow {
visible: SettingsSync.previewed && SettingsSync.changes.length > 0
label: "Apply those " + SettingsSync.changes.length + " changes"
detail: "Settings the file does not mention are left alone"
action: "Import"
enabled: !SettingsSync.busy
onTriggered: SettingsSync.importFrom(SettingsSync.defaultPath)
}
Repeater {
model: SettingsSync.previewed ? SettingsSync.skipped : []
delegate: TextRow {
required property var modelData
width: parent.width
label: String(modelData.key ?? "")
detail: "Skipped: " + String(modelData.reason ?? "")
value: ""
}
}
TextRow {
visible: SettingsSync.lastAction === "import" && SettingsSync.lastError === ""
label: SettingsSync.applied === 0
? "Nothing needed changing"
: SettingsSync.applied + " settings applied"
detail: "From " + (SettingsSync.exportedFrom || "the export")
value: ""
divider: false
}
}
SettingsCard {
title: "Snapshots"
subtitle: SettingsBackup.lastError !== ""
? SettingsBackup.lastError
: "Your whole desktop configuration is one file, so a snapshot is a copy of it. Restoring also snapshots what it replaces, so it is itself undoable."
ActionRow {
label: "Back up current settings"
detail: SettingsBackup.snapshots.length === 0
? "No snapshots yet"
: SettingsBackup.snapshots.length + (SettingsBackup.snapshots.length === 1 ? " snapshot kept" : " snapshots kept") + ", newest first"
action: "Back up now"
enabled: !SettingsBackup.busy
divider: SettingsBackup.snapshots.length > 0
onTriggered: SettingsBackup.save()
}
Repeater {
id: snapshotRows
model: SettingsBackup.snapshots
ActionRow {
required property var modelData
required property int index
label: modelData.when
detail: modelData.keys + " settings"
action: "Restore"
enabled: !SettingsBackup.busy
divider: index < snapshotRows.count - 1
onTriggered: SettingsBackup.restore(modelData.name)
}
}
}
SettingsCard {
title: "Reset"
subtitle: "Restores the 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 defaults"
detail: "Applies immediately, including to the compositor"
action: "Restore defaults"
divider: false
onTriggered: SystemSettings.restoreDefaults()
}
}
}
@@ -19,6 +19,12 @@ Column {
signal picked(string id)
// For surfaces that open straight into this picker instead of scrolling to
// it, so nothing has to be clicked before typing works.
function grab(): void {
search.grab();
}
readonly property var matches: {
const needle = search.text.trim().toLowerCase();
if (needle === "")
@@ -0,0 +1,122 @@
// Dock.
//
// The pinned applications come first because they are what the Dock is; the
// behaviour card below is how it gets out of the way. 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.
import QtQuick
import Quickshell
import qs.config
import qs.services
SettingsPage {
id: root
// Turning the last screen off would leave no dock anywhere and no obvious
// way back, so the final one cannot be removed -- it collapses to "every
// screen" instead, which is the same thing on one display and recoverable
// on several.
function toggleDockScreen(name: string): void {
const all = Quickshell.screens.map(screen => String(screen.name));
const current = Settings.dockScreens.length === 0
? all.slice()
: Settings.dockScreens.map(String);
const at = current.indexOf(name);
let next = current.slice();
if (at >= 0)
next.splice(at, 1);
else
next.push(name);
if (next.length === 0 || next.length === all.length)
next = [];
DesktopPreferences.set("dockScreens", next);
}
title: "Dock"
lede: "What sits in the Dock, and how it behaves when you are not using it."
SettingsCard {
title: "Pinned applications"
subtitle: "Drag an icon to reorder it, hover for the unpin button. Order here is the order on screen; these stay whether or not the application is running."
DockPinsStrip {
id: pins
width: parent.width
}
Item { width: 1; height: 13 }
DockAppPicker {
width: parent.width
pinned: pins.pinned
onPicked: id => pins.add(id)
}
}
SettingsCard {
title: "Behavior"
ChoiceRow { setting: "dockPosition" }
// One row per connected screen. Nothing selected means every screen,
// which is stated rather than left as an empty list somebody has to
// interpret -- and it is what a single-monitor machine should do
// without being configured at all.
SettingRow {
label: "Screens"
detail: Settings.dockScreens.length === 0
? "On every display"
: "On " + Settings.dockScreens.length + " of "
+ Quickshell.screens.length + " displays"
visible: Quickshell.screens.length > 1
controlWidth: 260
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 7
Repeater {
model: Quickshell.screens
delegate: Rectangle {
id: screenPill
required property var modelData
readonly property string screenName: String(screenPill.modelData.name ?? "")
// An empty list means all, so every pill reads as on.
readonly property bool on: Settings.dockScreens.length === 0
|| Settings.dockScreens.indexOf(screenPill.screenName) >= 0
width: pillLabel.implicitWidth + 20
height: 28
radius: 8
color: screenPill.on ? Theme.alpha(Theme.accent, 0.22)
: Theme.alpha(Theme.fg, 0.06)
border.width: screenPill.on ? 1 : 0
border.color: Theme.alpha(Theme.accent, 0.5)
Text {
id: pillLabel
anchors.centerIn: parent
text: screenPill.screenName
color: screenPill.on ? Theme.fg : Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
HoverHandler { cursorShape: Qt.PointingHandCursor }
TapHandler { onTapped: root.toggleDockScreen(screenPill.screenName) }
}
}
}
}
ToggleRow { setting: "dockAutohide" }
SliderRow { setting: "dockRevealDelayMs"; zeroLabel: "Instant" }
SliderRow { setting: "dockHideDelayMs"; zeroLabel: "Instant" }
SliderRow { setting: "dockIconSize"; divider: false }
}
}
@@ -1,212 +0,0 @@
// The Dock's pinned applications: reorder, remove, and add.
//
// The list was a sixteen-entry literal in Settings.qml, so changing what sits
// in the Dock meant editing a QML file and reloading the shell. It is a plain
// ordered list of desktop entry ids, stored in the shared settings file, which
// means it is covered by Restore defaults like everything else.
//
// Move up / move down rather than drag-and-drop. Dragging inside a Flickable
// that is itself inside a scrolling page is a genuinely hard interaction to get
// right, and it fails in a way the user reads as the app being broken; two
// buttons are unambiguous and keyboard-reachable.
import QtQuick
import Quickshell
import qs.config
import qs.services
import qs.widgets
Column {
id: root
spacing: 0
readonly property var pinned: {
const stored = DesktopPreferences.get("dockPinned");
return Array.isArray(stored) ? stored : [];
}
// DesktopEntries populates asynchronously, so this must be read as a
// binding rather than looked up inside one -- byId() called during
// evaluation registers no dependency and answers from an empty list.
readonly property var entriesById: {
const index = {};
for (const entry of DesktopEntries.applications.values)
index[entry.id] = entry;
return index;
}
function nameFor(id: string): string {
const entry = root.entriesById[id];
return entry ? entry.name : id;
}
function commit(next: var): void {
DesktopPreferences.set("dockPinned", next);
}
// ── Dragging ────────────────────────────────────────────────────────────
//
// By a grip rather than the whole row. The objection this file used to
// record -- that dragging inside a Flickable inside a scrolling page is
// hard to get right and fails in a way that reads as breakage -- is real,
// and the answer is preventStealing on the grip: the Flickable cannot take
// a gesture that started there, so a vertical drag reorders instead of
// scrolling the page out from under it. The arrow buttons stay, because
// they are the keyboard-reachable path and a grip is not.
//
// The order is held here while the drag runs and written once on release.
// Committing on every slot crossed would rewrite settings.json a dozen
// times for one gesture.
property int draggingIndex: -1
property var workingOrder: []
readonly property var displayed: root.draggingIndex >= 0 ? root.workingOrder : root.pinned
function beginDrag(index: int): void {
root.workingOrder = root.pinned.slice();
root.draggingIndex = index;
}
function dragTo(target: int): void {
if (root.draggingIndex < 0 || target === root.draggingIndex)
return;
if (target < 0 || target >= root.workingOrder.length)
return;
const next = root.workingOrder.slice();
const moved = next.splice(root.draggingIndex, 1)[0];
next.splice(target, 0, moved);
root.workingOrder = next;
root.draggingIndex = target;
}
function endDrag(): void {
if (root.draggingIndex < 0)
return;
const next = root.workingOrder.slice();
root.draggingIndex = -1;
root.workingOrder = [];
root.commit(next);
}
function move(from: int, to: int): void {
if (to < 0 || to >= root.pinned.length)
return;
const next = root.pinned.slice();
const moved = next.splice(from, 1)[0];
next.splice(to, 0, moved);
root.commit(next);
}
function remove(index: int): void {
const next = root.pinned.slice();
next.splice(index, 1);
root.commit(next);
}
function add(id: string): void {
if (root.pinned.indexOf(id) >= 0)
return;
root.commit(root.pinned.concat([id]));
}
Repeater {
model: root.displayed
SettingRow {
id: pin
required property var modelData
required property int index
label: root.nameFor(pin.modelData)
// The desktop id is shown only when the name alone would not say
// which entry this is. It is developer text, and repeating it under
// fifteen recognisable application names is noise that makes the
// list harder to scan, not easier.
detail: root.pinned.filter(other =>
root.nameFor(other) === root.nameFor(pin.modelData)).length > 1
? pin.modelData
: ""
divider: pin.index < root.pinned.length - 1
controlWidth: 132
// Lifted while dragging so the row being moved is the one that
// looks moved.
z: root.draggingIndex === pin.index ? 2 : 0
opacity: root.draggingIndex === pin.index ? 0.85 : 1
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 4
// The grip. preventStealing is the whole reason this works
// inside a scrolling page: without it the Flickable claims the
// vertical gesture and the row never moves.
Item {
width: 26
height: 26
anchors.verticalCenter: parent.verticalCenter
Text {
anchors.centerIn: parent
text: "\u2261"
color: root.draggingIndex === pin.index ? Theme.accent : Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: 15
}
MouseArea {
id: grip
anchors.fill: parent
preventStealing: true
cursorShape: Qt.SizeVerCursor
property real pressY: 0
onPressed: mouse => {
grip.pressY = mouse.y;
root.beginDrag(pin.index);
}
onPositionChanged: mouse => {
if (root.draggingIndex < 0 || pin.height <= 0)
return;
// How many whole rows the pointer has travelled from
// where it started. Rounded, so the swap happens as
// the grip passes the midpoint of the next row.
const travelled = (mouse.y - grip.pressY);
const slots = Math.round(travelled / pin.height);
if (slots !== 0)
root.dragTo(root.draggingIndex + slots);
}
onReleased: root.endDrag()
onCanceled: root.endDrag()
}
}
SettingsButton {
text: "↑"
enabled: pin.index > 0
onClicked: root.move(pin.index, pin.index - 1)
}
SettingsButton {
text: "↓"
enabled: pin.index < root.pinned.length - 1
onClicked: root.move(pin.index, pin.index + 1)
}
SettingsButton {
text: "Unpin"
onClicked: root.remove(pin.index)
}
}
}
}
SettingRow {
visible: root.pinned.length === 0
label: "Nothing is pinned"
detail: "The Dock will only show running applications"
divider: false
}
}
@@ -0,0 +1,341 @@
// The Dock's pinned applications, shown as the dock shows them.
//
// This replaces a sixteen-row list of names. The list was honest but it was not
// the thing being edited: the Dock is a horizontal row of icons, and the order
// of that row was being decided in a vertical column of text. Editing a
// picture of the result is faster than editing a description of it, and it
// removes the translation step where you count rows to work out which icon ends
// up third.
//
// Icons resolve exactly the way the Dock resolves them, so what is drawn here
// is what will be drawn there -- including the initial-letter fallback for an
// application whose icon the theme cannot find.
//
// Reordering is a drag, with two things that make a drag safe inside a
// scrolling page:
//
// * preventStealing on the grabbing MouseArea. Without it the page's own
// Flickable claims the gesture and the icon never moves, which reads as
// breakage rather than as a page that scrolls.
// * The order is held here while the drag runs and written once on release.
// Committing on every cell crossed would rewrite settings.json a dozen
// times for one gesture.
//
// Every icon is also a tab stop: Left and Right move it, Delete unpins it. A
// drag is not reachable from the keyboard, so the keyboard gets its own path
// rather than a pair of arrow buttons bolted to each cell.
import QtQuick
import Quickshell
import Quickshell.Widgets
import qs.config
import qs.services
Column {
id: root
spacing: 0
// The cell is the icon plus the room its unpin button needs. Fixed, because
// the drag arithmetic below counts cells rather than measuring them.
readonly property int iconSize: 44
readonly property int cellSize: 52
readonly property int cellSpacing: 10
readonly property int stride: root.cellSize + root.cellSpacing
// How many cells fit on one line. The drag turns a pointer offset into an
// index, and on a wrapped strip moving down a line is a jump of this many.
readonly property int perRow: Math.max(1,
Math.floor((flow.width + root.cellSpacing) / root.stride))
readonly property var pinned: {
const stored = DesktopPreferences.get("dockPinned");
return Array.isArray(stored) ? stored : [];
}
// DesktopEntries populates asynchronously, so this must be read as a
// binding rather than looked up inside one -- byId() called during
// evaluation registers no dependency and answers from an empty list.
readonly property var entriesById: {
const index = {};
for (const entry of DesktopEntries.applications.values)
index[entry.id] = entry;
return index;
}
function nameFor(id: string): string {
const entry = root.entriesById[id];
return entry ? entry.name : id;
}
// Desktop entries usually carry a freedesktop icon *name*, but some ship an
// absolute path. iconPath() only understands names, so branch on it. The
// `true` argument makes a missing icon return "" instead of a placeholder
// that renders as a black square in some themes.
function iconFor(id: string): string {
const entry = root.entriesById[id];
const name = entry && entry.icon ? entry.icon : id;
if (!name)
return "";
if (name.startsWith("/"))
return "file://" + name;
return Quickshell.iconPath(name, true);
}
function commit(next: var): void {
DesktopPreferences.set("dockPinned", next);
}
// ── Dragging ────────────────────────────────────────────────────────────
property int draggingIndex: -1
property var workingOrder: []
readonly property var displayed: root.draggingIndex >= 0 ? root.workingOrder : root.pinned
function beginDrag(index: int): void {
root.workingOrder = root.pinned.slice();
root.draggingIndex = index;
}
function dragTo(target: int): void {
if (root.draggingIndex < 0 || target === root.draggingIndex)
return;
if (target < 0 || target >= root.workingOrder.length)
return;
const next = root.workingOrder.slice();
const moved = next.splice(root.draggingIndex, 1)[0];
next.splice(target, 0, moved);
root.workingOrder = next;
root.draggingIndex = target;
}
function endDrag(): void {
if (root.draggingIndex < 0)
return;
const next = root.workingOrder.slice();
root.draggingIndex = -1;
root.workingOrder = [];
root.commit(next);
}
// ── Keyboard ────────────────────────────────────────────────────────────
//
// The Repeater's model is a plain array, so committing a move rebuilds
// every delegate and the focused one is destroyed mid-keystroke. The
// focused position is remembered here instead of in the delegate, and the
// cell that lands on it takes focus back as it is created.
property int keyboardIndex: -1
function move(from: int, to: int): void {
if (to < 0 || to >= root.pinned.length)
return;
const next = root.pinned.slice();
const moved = next.splice(from, 1)[0];
next.splice(to, 0, moved);
root.keyboardIndex = to;
root.commit(next);
}
function remove(index: int): void {
const next = root.pinned.slice();
next.splice(index, 1);
root.keyboardIndex = Math.min(index, next.length - 1);
root.commit(next);
}
function add(id: string): void {
if (root.pinned.indexOf(id) >= 0)
return;
root.commit(root.pinned.concat([id]));
}
Flow {
id: flow
width: parent.width
visible: root.displayed.length > 0
spacing: root.cellSpacing
Repeater {
model: root.displayed
delegate: Item {
id: cell
required property var modelData
required property int index
readonly property bool dragging: root.draggingIndex === cell.index
readonly property string appName: root.nameFor(cell.modelData)
readonly property string iconSource: root.iconFor(cell.modelData)
width: root.cellSize
height: root.cellSize
// The dragged cell rides above its neighbours so the icon being
// moved is the one that looks moved.
z: cell.dragging ? 2 : 0
activeFocusOnTab: true
Accessible.role: Accessible.Button
Accessible.name: cell.appName
Accessible.description: "Pinned to the Dock, position "
+ (cell.index + 1) + " of " + root.displayed.length
+ ". Left and Right move it, Delete unpins it."
Accessible.focusable: true
Accessible.focused: cell.activeFocus
Keys.onLeftPressed: root.move(cell.index, cell.index - 1)
Keys.onRightPressed: root.move(cell.index, cell.index + 1)
Keys.onDeletePressed: root.remove(cell.index)
onActiveFocusChanged: if (cell.activeFocus) root.keyboardIndex = cell.index
Component.onCompleted: if (root.keyboardIndex === cell.index) cell.forceActiveFocus()
Connections {
target: root
function onKeyboardIndexChanged(): void {
if (root.keyboardIndex === cell.index)
cell.forceActiveFocus();
}
}
// Where the icon would land if the drag ended now. It sits in
// the gap to the left of the cell rather than under it, because
// the question a drop indicator answers is "between which two".
Rectangle {
visible: cell.dragging
anchors.right: parent.left
anchors.rightMargin: Math.round(root.cellSpacing / 2) - 2
anchors.verticalCenter: parent.verticalCenter
width: 4
height: root.iconSize
radius: 2
border.width: 0
color: Theme.accent
}
Rectangle {
id: tile
anchors.centerIn: parent
width: root.iconSize
height: root.iconSize
radius: Theme.cardRadius
color: cell.dragging
? Theme.alpha(Theme.accent, 0.18)
: Theme.alpha(Theme.fg, grab.containsMouse || cell.activeFocus ? 0.09 : 0.05)
border.width: cell.dragging || cell.activeFocus ? 2 : 1
border.color: cell.dragging
? Theme.accent
: (cell.activeFocus ? Theme.accentSecondary : Theme.alpha(Theme.fg, 0.1))
IconImage {
anchors.fill: parent
anchors.margins: 5
visible: cell.iconSource !== ""
source: cell.iconSource
asynchronous: true
mipmap: true
}
// Last resort for an app with no resolvable icon: an
// initial, which is still recognizable, unlike the theme's
// broken-icon placeholder.
Text {
anchors.centerIn: parent
visible: cell.iconSource === ""
text: cell.appName ? cell.appName.charAt(0).toUpperCase() : "?"
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Math.round(root.iconSize * 0.5)
}
}
// The whole tile is the grip. preventStealing is the reason a
// drag works at all inside a scrolling page.
MouseArea {
id: grab
anchors.fill: parent
hoverEnabled: true
preventStealing: true
cursorShape: cell.dragging ? Qt.ClosedHandCursor : Qt.OpenHandCursor
property real pressX: 0
property real pressY: 0
onPressed: mouse => {
grab.pressX = mouse.x;
grab.pressY = mouse.y;
cell.forceActiveFocus();
root.beginDrag(cell.index);
}
onPositionChanged: mouse => {
if (root.draggingIndex < 0)
return;
// How many whole cells the pointer has travelled from
// where it started, across and down. Rounded, so the
// swap happens as the icon passes the midpoint of its
// neighbour; a line down is a jump of one full row.
const columns = Math.round((mouse.x - grab.pressX) / root.stride);
const rows = Math.round((mouse.y - grab.pressY) / root.stride);
const slots = rows * root.perRow + columns;
if (slots !== 0)
root.dragTo(root.draggingIndex + slots);
}
onReleased: root.endDrag()
onCanceled: root.endDrag()
}
// Unpin. Hidden until the icon is hovered or focused, because
// eleven permanent × badges read as an error state rather than
// as eleven applications you chose.
Rectangle {
id: unpin
anchors.right: parent.right
anchors.top: parent.top
width: 18
height: 18
radius: 9
z: 3
visible: grab.containsMouse || unpinMouse.containsMouse
|| cell.activeFocus
color: Theme.danger
border.width: 0
Text {
anchors.centerIn: parent
text: "×"
color: Theme.bgDark
font.family: Theme.fontFamily
font.pixelSize: 13
font.weight: Font.Bold
}
MouseArea {
id: unpinMouse
anchors.fill: parent
hoverEnabled: true
preventStealing: true
cursorShape: Qt.PointingHandCursor
onClicked: root.remove(cell.index)
}
}
}
}
}
Text {
width: parent.width
visible: root.pinned.length === 0
text: "Nothing is pinned — the Dock will only show running applications. Search below to add one."
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
wrapMode: Text.WordWrap
}
}
@@ -80,12 +80,15 @@ Settings because Fedora's system services own those areas.
## Appearance
Six tabs, in the order the questions are actually asked: **Themes**, **Theme
editor**, **Background**, **Typography**, **Windows**, **Shell**. Themes leads
because light and dark is the control reached most often, and it used to be the
third section down, under a wallpaper grid and the whole lock screen. The old
`theme` section id still resolves to it, so deep links and IPC calls keep
working.
Five tabs, in the order the questions are actually asked: **Themes**, **Theme
editor**, **Background**, **Typography**, **Windows**. Themes leads because
light and dark is the control reached most often, and it used to be the third
section down, under a wallpaper grid and the whole lock screen. The old `theme`
section id still resolves to it, so deep links and IPC calls keep working.
The sixth tab, **Shell**, is gone: its two cards moved to **Shell Bar**, which
is where somebody who wants to change the bar now looks. Appearance answers what
the desktop looks like; Shell answers what the desktop *is*.
### Themes
@@ -145,6 +148,109 @@ and Panama's own Settings titlebar shows one button. `panamaTitlebar` turns
that bar off entirely, leaving the window pure Hyprland: Super+Q closes,
Super+drag moves, Escape still works.
## Shell
Everything Quickshell itself draws, in five tabs: **Bar**, **Dock**, **Control
Center**, **Tiling**, **Workspaces**. The category used to be **Desktop & Dock**,
a single page that held the dock alongside window layout, workspaces, and the
settings-management cluster — three unrelated subjects and a filing cabinet. Its
`desktop` id is retired to `bar`, so old deep links land on the first tab.
The split is by *surface*. If you can point at it on screen, it has a tab.
### Bar
The first settings surface the bar has ever had. Three cards.
**Visibility** exists because the bar is the one thing the shell draws that has
no ground of its own — it floats on the wallpaper, which the theme has never
seen. So a palette that is correct everywhere else can still be unreadable
exactly where the clock is. `barTextTone` (`theme`/`light`/`dark`) drives a
second neutral family in `Theme.qml``barFg`, `barFgDim`, `barFgMuted` — that
thirteen bar widgets bind to instead of `fg`/`fgDim`/`fgMuted`. Left on `theme`
the family *is* the fg family by identity, so nothing changes for anyone who
never asks, and a custom palette still reaches the bar. Semantic tones (warn,
danger, accent, ok) never became bar tones: a battery at 4% is red whatever the
neutrals were forced to.
`barTextShadow` and `barBackdrop` are the two escape hatches for a wallpaper no
tone wins against. The shadow is **one** `layer.effect` MultiEffect over the
whole of `Bar.qml`'s content rather than one per widget, so a widget added
tomorrow picks it up without opting in; the backdrop is a static top-down
gradient Rectangle behind the content rows. Both default off and both are read
from the preference — during the build they briefly landed as literal `true`,
which forced a dark band and an extra compositing layer on everyone.
`tests/quickshell/bar-visibility-contract` pins both bindings for that reason,
along with every widget's use of the bar tones.
**Widgets** is one toggle per thing that earns a place: weather, media,
clipboard, calendar countdown, the three vitals readouts, battery, agent usage.
Each toggle is **ANDed** with the widget's own state condition rather than
replacing it, so switching one on never conjures a pill with nothing in it.
State-driven indicators — health, activity, focus, video wallpaper — get no
toggle: they appear when they have something to say and leave when they are
done. `VitalsWidget` answers for its own pill as well as its three fields,
because an invisible child still occupies its Row and gating the fields alone
left a padded, empty pill in the bar.
**Clock** keeps `showSeconds` and `showWeekday`. `use24Hour` is *not* here: it
drives the date menu, notification timestamps, and the lock screen too, so it
moved to group `datetime` and lives on **System Date & Time**. The card says
so rather than leaving its absence looking like an oversight.
Right-clicking the vitals or agent-usage pill opens this page.
### Dock
Behavior — position, screens, autohide, reveal and hide delays, icon size —
unchanged. **Pinned applications** is now `DockPinsStrip.qml`: the dock's own
row of icons, resolved exactly the way the dock resolves them, drag to reorder,
hover for the unpin ×, `DockAppPicker` search underneath. It replaces a sixteen-
row list of names, which was honest but was not the thing being edited — the
order of a horizontal row of icons was being decided in a vertical column of
text.
Two details make the drag safe: `preventStealing` on the grabbing MouseArea, or
the page's Flickable claims the gesture and the icon never moves; and the order
is held in the strip while the drag runs and written **once** on release, rather
than rewriting `settings.json` a dozen times for one gesture. Every icon is also
a tab stop — Left and Right move it, Delete unpins it — because a drag is not
reachable from the keyboard.
The live dock gained the matching gestures: drag to reorder on the dock itself,
a right-click menu (window rows, pin, unpin, quit, new window, and a **Dock
settings** entry that deep-links here), scroll an icon to cycle its windows, and
hover previews from `DockPreviews.qml`. `DockPickOverlay.qml` plus the `dock`
IPC target back the launcher's **Add App to Dock** command, so pinning never
requires opening Settings at all.
### Control Center
The quick settings panel's first settings surface. **Sections** is one
`ccShow*` bool per real section of `QuickSettingsPanel` — Focus, Home, Phone —
and only for sections that actually exist; a toggle for a section the panel does
not draw is a control that writes a preference nothing reads. Turning one off
hides it from the panel and stops nothing: the page behind it still works.
**Accessories** is a labeled handoff to Home My Home rather than a second copy
of the shelf editor.
### Tiling and Workspaces
Rows moved unchanged. Tiling holds window layout (with the handoff to Appearance
Windows for gaps, corners, and effects), master and stack, window edges, and
the Hyprland notices. Workspaces holds the workspace and focus toggles, focus
session length, and Projects.
## System Sync & Backup
`SyncPage.qml`, the tenth tab of System: **Carry settings to another machine**
(`SettingsSync`), **Settings backups** (`SettingsBackup`), and **Reset**. It was
the settings-management cluster on the old Desktop page, which is the one thing
on that page that was never a surface. Backups is titled that way deliberately
so it stops colliding with the btrfs **Snapshots** tab one along — the two mean
different things and used to share a word. The search index's **Restore
defaults** entry points here.
## Adding a setting
One schema entry. That is the whole job.
@@ -166,7 +166,12 @@ Rectangle {
case "connectivity": return connectivityPage;
case "my-home": return myHomePage;
case "phone": return phonePage;
case "desktop": return desktopPage;
case "bar": return barPage;
case "dock": return dockPage;
case "control-center": return controlCenterPage;
case "tiling": return tilingPage;
case "workspaces": return workspacesPage;
case "sync": return syncPage;
case "sound": return soundPage;
case "gaming": return gamingPage;
case "notifications": return notificationsPage;
@@ -246,7 +251,12 @@ Rectangle {
Component { id: connectivityPage; ConnectivityPage {} }
Component { id: myHomePage; MyHomePage {} }
Component { id: phonePage; PhonePage {} }
Component { id: desktopPage; DesktopPage {} }
Component { id: barPage; BarPage {} }
Component { id: dockPage; DockPage {} }
Component { id: controlCenterPage; ControlCenterPage {} }
Component { id: tilingPage; TilingPage {} }
Component { id: workspacesPage; WorkspacesPage {} }
Component { id: syncPage; SyncPage {} }
Component { id: soundPage; SoundPage {} }
Component { id: gamingPage; GamingPage {} }
Component { id: notificationsPage; NotificationsPage {} }
@@ -0,0 +1,134 @@
// Sync & Backup.
//
// Three answers to three different questions, which is why they sit together:
// carrying settings to a different machine, putting this machine's settings
// back as they were, and throwing them away.
//
// The backups here are copies of the preferences file. They are not the btrfs
// snapshots one tab over in System Snapshots, which put the whole filesystem
// back -- the card says so, because the two used to share the word "Snapshots"
// and nothing distinguished them.
import QtQuick
import qs.config
import qs.services
SettingsPage {
id: root
title: "Sync & Backup"
lede: "Carry your settings to another machine, keep copies of them, or start over."
// Distinct from the backups below, which put THIS machine back as it was.
// This carries settings to a different one, and deliberately leaves behind
// anything that describes hardware.
SettingsCard {
title: "Carry settings to another machine"
subtitle: SettingsSync.lastError !== ""
? SettingsSync.lastError
: "Everything except what describes this machine: the display arrangement stays here."
ActionRow {
label: "Export"
detail: SettingsSync.lastAction === "export" && SettingsSync.carried > 0
? SettingsSync.carried + " settings written to " + SettingsSync.defaultPath
: "Writes " + SettingsSync.defaultPath
action: "Export"
enabled: !SettingsSync.busy
onTriggered: SettingsSync.exportTo(SettingsSync.defaultPath)
}
ActionRow {
label: "See what an import would change"
detail: SettingsSync.previewed
? SettingsSync.changes.length + " would change, "
+ SettingsSync.skipped.length + " skipped"
: "Reads " + SettingsSync.defaultPath + " without applying anything"
action: "Preview"
enabled: !SettingsSync.busy
onTriggered: SettingsSync.preview(SettingsSync.defaultPath)
}
// Only offered once a preview has said what it would do. Importing
// settings sight unseen is how somebody ends up wondering why their
// desktop changed.
ActionRow {
visible: SettingsSync.previewed && SettingsSync.changes.length > 0
label: "Apply those " + SettingsSync.changes.length + " changes"
detail: "Settings the file does not mention are left alone"
action: "Import"
enabled: !SettingsSync.busy
onTriggered: SettingsSync.importFrom(SettingsSync.defaultPath)
}
Repeater {
model: SettingsSync.previewed ? SettingsSync.skipped : []
delegate: TextRow {
required property var modelData
width: parent.width
label: String(modelData.key ?? "")
detail: "Skipped: " + String(modelData.reason ?? "")
value: ""
}
}
TextRow {
visible: SettingsSync.lastAction === "import" && SettingsSync.lastError === ""
label: SettingsSync.applied === 0
? "Nothing needed changing"
: SettingsSync.applied + " settings applied"
detail: "From " + (SettingsSync.exportedFrom || "the export")
value: ""
divider: false
}
}
SettingsCard {
title: "Settings backups"
subtitle: SettingsBackup.lastError !== ""
? SettingsBackup.lastError
: "Copies of your preferences, not of the filesystem — System Snapshots keeps the btrfs ones. Your whole desktop configuration is a single file, so a backup is a copy of it. Restoring also backs up what it replaces, so it is itself undoable."
ActionRow {
label: "Back up current settings"
detail: SettingsBackup.snapshots.length === 0
? "No backups yet"
: SettingsBackup.snapshots.length + (SettingsBackup.snapshots.length === 1 ? " backup kept" : " backups kept") + ", newest first"
action: "Back up now"
enabled: !SettingsBackup.busy
divider: SettingsBackup.snapshots.length > 0
onTriggered: SettingsBackup.save()
}
Repeater {
id: snapshotRows
model: SettingsBackup.snapshots
ActionRow {
required property var modelData
required property int index
label: modelData.when
detail: modelData.keys + " settings"
action: "Restore"
enabled: !SettingsBackup.busy
divider: index < snapshotRows.count - 1
onTriggered: SettingsBackup.restore(modelData.name)
}
}
}
SettingsCard {
title: "Reset"
subtitle: "Restores the 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 defaults"
detail: "Applies immediately, including to the compositor"
action: "Restore defaults"
divider: false
onTriggered: SystemSettings.restoreDefaults()
}
}
}
@@ -0,0 +1,79 @@
// Tiling.
//
// How windows share the space. The window-layout card keeps its handoff row
// because the adjustable parts of window *appearance* -- gaps, corners,
// borders, effects -- live on the Appearance page next to the preview that
// explains them. Splitting them would put two halves of one idea on two pages;
// pointing at the other half keeps one.
import QtQuick
import qs.config
import qs.services
SettingsPage {
id: root
title: "Tiling"
lede: "How windows share the space, and where their edges are."
SettingsCard {
title: "Window layout"
subtitle: "Follows the Forge mental model, with native Hyprland tiling."
// These were two read-only rows reporting "Tiling" and "Dynamic", which
// described settings rather than facts -- both are ordinary Hyprland
// options that simply had no controls. TextRow's own documentation says
// a setting the user could reasonably change does not belong in it.
ChoiceRow { setting: "windowLayout" }
ToggleRow { setting: "preserveSplit" }
ChoiceRow { setting: "forceSplit" }
ToggleRow { setting: "windowSnapping" }
ActionRow {
label: "Gaps, corners, and effects"
detail: "Adjusted on the Appearance page, beside a live preview"
action: "Open Appearance"
divider: false
onTriggered: ShellState.openSettingsSection("appearance", "windows")
}
}
// GNOME's Multitasking panel, in Hyprland's terms.
// Only meaningful when the layout above is Master and stack. Hidden
// otherwise, because a card of settings that do nothing under the layout
// you are actually running is worse than not offering the layout at all.
SettingsCard {
visible: DesktopPreferences.get("windowLayout") === "master"
title: "Master and stack"
subtitle: "How the master area behaves. These apply only while the tiling layout above is Master and stack."
SliderRow { setting: "masterFactor" }
ChoiceRow { setting: "masterOrientation" }
ChoiceRow { setting: "masterNewStatus" }
ToggleRow { setting: "masterNewOnTop"; divider: false }
}
SettingsCard {
title: "Window edges"
subtitle: "How the pointer grabs a window's border, and how floating windows behave near each other and the screen edge."
ToggleRow { setting: "resizeOnBorder" }
SliderRow { setting: "borderGrabArea"; zeroLabel: "Border only" }
ToggleRow { setting: "hoverIconOnBorder" }
SliderRow { setting: "snapWindowGap"; zeroLabel: "Touching" }
SliderRow { setting: "snapMonitorGap"; zeroLabel: "Touching" }
ToggleRow { setting: "snapRespectGaps"; divider: false }
}
// Hyprland's own interruptions. Panama turns all four off, which is a
// defensible default and was not previously a decision anyone could
// reverse without editing looks.lua.
SettingsCard {
title: "Hyprland notices"
subtitle: "Panama hides all of these by default. They are the compositor's own, not Panama's."
ToggleRow { setting: "hyprlandLogo" }
ToggleRow { setting: "hyprlandSplash" }
ToggleRow { setting: "hyprlandUpdateNews" }
ToggleRow { setting: "hyprlandDonationNag"; divider: false }
}
}
@@ -36,7 +36,13 @@ Item {
// Videos join the grid only in single mode — slideshow and per-display
// are hyprpaper's modes and stills-only.
readonly property var catalog: root.mode === "single"
// The test seam: a harness that supplies its own stills sets this false,
// and the picker then never touches the VideoWallpaper singleton at all —
// merely instantiating it would fire its startup restore inside the
// harness and play the machine's real wallpaper in a test instance.
property bool videoAware: true
readonly property var catalog: root.mode === "single" && root.videoAware
? Wallpaper.available.concat(VideoWallpaper.candidates)
: Wallpaper.available
@@ -50,7 +56,7 @@ Item {
readonly property real cellWidth: columns > 0 ? (width - (columns - 1) * 10) / columns : 160
function isCurrent(path: string): bool {
if (VideoWallpaper.active)
if (root.videoAware && VideoWallpaper.active)
return VideoWallpaper.path === path;
return root.activeByOutput[root.selectedOutput] === path;
}
@@ -88,7 +94,7 @@ Item {
required property var modelData
readonly property bool current: root.isCurrent(tile.modelData)
readonly property bool video: VideoWallpaper.isVideo(tile.modelData)
readonly property bool video: root.videoAware && VideoWallpaper.isVideo(tile.modelData)
readonly property bool member: root.mode === "slideshow" && root.selected(tile.modelData)
width: root.cellWidth
@@ -0,0 +1,112 @@
// Workspaces.
//
// Where windows go, how long a focus session runs, and the layouts you saved.
// Projects are saved from the launcher rather than created here: a layout is
// worth recording at the moment you have it right, not from a settings page
// where you would have to describe it.
import QtQuick
import qs.config
import qs.services
SettingsPage {
id: root
// Which project is one click from being forgotten. Same in-place confirm the
// SSH Keys and Snapshots pages use.
property string confirmingProject: ""
title: "Workspaces"
lede: "Workspaces, focus, and the projects that restore them."
SettingsCard {
title: "Workspaces & focus"
subtitle: "Hyprland's workspaces are created and destroyed as you use them, so there is no fixed count to set."
ToggleRow { setting: "workspaceBackAndForth" }
ToggleRow { setting: "allowWorkspaceCycles" }
ToggleRow { setting: "focusOnActivate" }
ToggleRow { setting: "mouseMoveFocusesMonitor" }
ToggleRow { setting: "windowSwallow"; divider: false }
}
SettingsCard {
title: "Focus"
SliderRow { setting: "focusDurationMinutes"; divider: false }
}
// Saved here, not created here. A layout is worth recording at the moment
// you have it right, so saving is a launcher command; this is where the
// ones you kept are reviewed and the ones you did not are removed.
SettingsCard {
title: "Projects"
subtitle: Projects.projects.length > 0
? "Saved window layouts. Opening one claims free workspaces, so it never lands on top of what you are doing."
: "No saved layouts yet. Arrange your windows, then run \"Save Layout as Project\" from the launcher."
Repeater {
model: Projects.projects
delegate: SettingRow {
id: projectRow
required property var modelData
required property int index
readonly property string projectName: String(projectRow.modelData.name ?? "")
readonly property bool confirming: root.confirmingProject === projectRow.projectName
label: projectRow.projectName
detail: projectRow.confirming
? "Forgetting this only removes the layout. Nothing that is open closes."
: projectRow.modelData.windows + " windows across "
+ projectRow.modelData.workspaces + " workspaces — "
+ (projectRow.modelData.applications ?? []).join(", ")
divider: projectRow.index < Projects.projects.length - 1
controlWidth: 210
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 8
SettingsButton {
anchors.verticalCenter: parent.verticalCenter
visible: !projectRow.confirming
text: "Open"
enabled: !Projects.busy
onClicked: Projects.open(projectRow.projectName)
}
SettingsButton {
anchors.verticalCenter: parent.verticalCenter
visible: projectRow.confirming
text: "Forget it"
tone: "danger"
enabled: !Projects.busy
onClicked: {
root.confirmingProject = "";
Projects.remove(projectRow.projectName);
}
}
SettingsButton {
anchors.verticalCenter: parent.verticalCenter
text: projectRow.confirming ? "Keep" : "Forget"
enabled: !Projects.busy
onClicked: root.confirmingProject =
projectRow.confirming ? "" : projectRow.projectName
}
}
}
}
TextRow {
visible: Projects.lastError !== ""
label: "Problem"
detail: Projects.lastError
divider: false
}
}
}
@@ -13,7 +13,12 @@ SettingsTabs 1.0 SettingsTabs.qml
GamingPage 1.0 GamingPage.qml
HomeFavoriteCard 1.0 HomeFavoriteCard.qml
AvailableLightRow 1.0 AvailableLightRow.qml
DesktopPage 1.0 DesktopPage.qml
BarPage 1.0 BarPage.qml
DockPage 1.0 DockPage.qml
ControlCenterPage 1.0 ControlCenterPage.qml
TilingPage 1.0 TilingPage.qml
WorkspacesPage 1.0 WorkspacesPage.qml
SyncPage 1.0 SyncPage.qml
DisplaysPage 1.0 DisplaysPage.qml
HomePage 1.0 HomePage.qml
NotificationsPage 1.0 NotificationsPage.qml
@@ -54,7 +59,7 @@ WallpaperPicker 1.0 WallpaperPicker.qml
WallpaperControls 1.0 WallpaperControls.qml
ApplicationsPage 1.0 ApplicationsPage.qml
AutostartAppPicker 1.0 AutostartAppPicker.qml
DockPinsEditor 1.0 DockPinsEditor.qml
DockPinsStrip 1.0 DockPinsStrip.qml
DockAppPicker 1.0 DockAppPicker.qml
ShortcutCapture 1.0 ShortcutCapture.qml
ChoiceGrid 1.0 ChoiceGrid.qml