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
+1 -1
View File
@@ -136,7 +136,7 @@ docs/ Settings reference, and the design specs behind the work
## Tests
165 of them, under `tests/`. Run the lot, or a subset by pattern:
166 of them, under `tests/`. Run the lot, or a subset by pattern:
```sh
panama test # everything
+5
View File
@@ -159,6 +159,11 @@ hl.layer_rule({
name = "qs-dock",
match = { namespace = "^qs-dock$" },
blur = true,
-- The dock's context menu and its window previews are xdg-popups of this
-- surface, not layers of their own, so they are only blurred if the rule
-- says to blur the popups too. Without it they are flat panes over the
-- wallpaper while everything else on the desktop is glass.
blur_popups = true,
ignore_alpha = 0.3,
})
@@ -52,11 +52,74 @@ Singleton {
readonly property var entries: [
// ── Clock ───────────────────────────────────────────────────────────
// Group datetime, not clock: this drives the date menu, notification
// timestamps, and the lock screen — Date & Time owns it.
{
key: "use24Hour", type: "bool", def: false, group: "clock",
key: "use24Hour", type: "bool", def: false, group: "datetime",
label: "24-hour time",
detail: "Use 18:30 instead of 6:30 PM"
},
// ── Bar ─────────────────────────────────────────────────────────────
// The bar floats directly on the wallpaper; these keep it legible on
// grounds the theme never met, and choose which widgets earn a place.
{
key: "barTextTone", type: "enum", def: "theme", group: "bar",
label: "Bar text",
detail: "Follow the theme, or force a light or dark tone for the wallpaper you actually use",
options: [
{ value: "theme", label: "Follow theme" },
{ value: "light", label: "Light" },
{ value: "dark", label: "Dark" }
]
},
{
key: "barTextShadow", type: "bool", def: false, group: "bar",
label: "Bar text shadow",
detail: "A soft dark halo under every glyph and label in the bar"
},
{
key: "barBackdrop", type: "bool", def: false, group: "bar",
label: "Bar backdrop",
detail: "A subtle scrim fading down from the top edge"
},
{
key: "showWeatherWidget", type: "bool", def: true, group: "bar",
label: "Weather in the bar",
detail: "Beside the clock, once a forecast has been fetched"
},
{
key: "showMediaWidget", type: "bool", def: true, group: "bar",
label: "Media in the bar",
detail: "Now playing, click to pause"
},
{
key: "showClipboardButton", type: "bool", def: true, group: "bar",
label: "Clipboard button",
detail: "The history stays on Super+V either way"
},
{
key: "showCalendarCountdown", type: "bool", def: true, group: "bar",
label: "Calendar countdown",
detail: "Appears in the bar fifteen minutes before an event"
},
// ── Control Center ──────────────────────────────────────────────────
{
key: "ccShowFocus", type: "bool", def: true, group: "controlCenter",
label: "Focus in Control Center",
detail: "The session row at the top of the panel"
},
{
key: "ccShowHome", type: "bool", def: true, group: "controlCenter",
label: "Home in Control Center",
detail: "Your accessory shelf"
},
{
key: "ccShowPhone", type: "bool", def: true, group: "controlCenter",
label: "Phone in Control Center",
detail: "Vitals and reach-it actions"
},
{
key: "showSeconds", type: "bool", def: true, group: "clock",
label: "Show seconds",
@@ -811,31 +874,31 @@ Singleton {
hypr: { path: ["general", "snap", "enabled"], option: "general:snap:enabled", readAs: "bool" }
},
{
key: "workspaceBackAndForth", type: "bool", def: false, group: "multitasking",
key: "workspaceBackAndForth", type: "bool", def: false, group: "workspaces",
label: "Switch back and forth",
detail: "Selecting the workspace you are already on returns you to the previous one",
hypr: { path: ["binds", "workspace_back_and_forth"], option: "binds:workspace_back_and_forth", readAs: "bool" }
},
{
key: "allowWorkspaceCycles", type: "bool", def: false, group: "multitasking",
key: "allowWorkspaceCycles", type: "bool", def: false, group: "workspaces",
label: "Wrap around at the ends",
detail: "Moving past the last workspace continues from the first",
hypr: { path: ["binds", "allow_workspace_cycles"], option: "binds:allow_workspace_cycles", readAs: "bool" }
},
{
key: "focusOnActivate", type: "bool", def: false, group: "multitasking",
key: "focusOnActivate", type: "bool", def: false, group: "workspaces",
label: "Let applications take focus",
detail: "An application asking for attention is switched to, rather than only highlighted",
hypr: { path: ["misc", "focus_on_activate"], option: "misc:focus_on_activate", readAs: "bool" }
},
{
key: "windowSwallow", type: "bool", def: false, group: "multitasking",
key: "windowSwallow", type: "bool", def: false, group: "workspaces",
label: "Hide the terminal that launched a window",
detail: "A terminal disappears while an application started from it is open, and returns when it closes",
hypr: { path: ["misc", "enable_swallow"], option: "misc:enable_swallow", readAs: "bool" }
},
{
key: "mouseMoveFocusesMonitor", type: "bool", def: true, group: "multitasking",
key: "mouseMoveFocusesMonitor", type: "bool", def: true, group: "workspaces",
label: "Pointer changes active display",
detail: "Moving the pointer to another display makes it the active one",
hypr: { path: ["misc", "mouse_move_focuses_monitor"], option: "misc:mouse_move_focuses_monitor", readAs: "bool" }
@@ -980,10 +1043,10 @@ Singleton {
label: "Video wallpaper folder",
detail: "Where the picker looks for videos. Relative to your home folder unless it starts with /"
},
// Its own key, not wallpaperPath: the still pipeline persists its
// policy transactionally and once clobbered a stored video path.
// Two owners, two keys.
{
// Its own key, not wallpaperPath: the still pipeline persists its
// policy transactionally and once clobbered a stored video path.
// Two owners, two keys.
key: "videoWallpaperPath", type: "string", def: "", group: "wallpaper", internal: true,
pattern: "^(|/[^,\n]+)$",
label: "Video wallpaper",
+20
View File
@@ -19,6 +19,26 @@ Singleton {
readonly property bool showSeconds: DesktopPreferences.get("showSeconds")
readonly property bool showWeekday: DesktopPreferences.get("showWeekday")
// ── Bar ─────────────────────────────────────────────────────────────────
// Legibility first: the bar sits on the wallpaper, so it may need a tone,
// a shadow or a scrim the theme cannot know about. `barTextTone` itself is
// read by Theme, which turns it into the barFg family the widgets bind to.
readonly property bool barTextShadow: DesktopPreferences.get("barTextShadow")
readonly property bool barBackdrop: DesktopPreferences.get("barBackdrop")
// Which widgets earn a place. Each is ANDed with the widget's own state
// condition, so turning one on never conjures a pill with nothing in it.
readonly property bool showWeatherWidget: DesktopPreferences.get("showWeatherWidget")
readonly property bool showMediaWidget: DesktopPreferences.get("showMediaWidget")
readonly property bool showClipboardButton: DesktopPreferences.get("showClipboardButton")
readonly property bool showCalendarCountdown: DesktopPreferences.get("showCalendarCountdown")
// ── Control Center ──────────────────────────────────────────────────────
// One bool per section of the quick settings panel that is worth hiding.
readonly property bool ccShowFocus: DesktopPreferences.get("ccShowFocus")
readonly property bool ccShowHome: DesktopPreferences.get("ccShowHome")
readonly property bool ccShowPhone: DesktopPreferences.get("ccShowPhone")
// ── Weather ─────────────────────────────────────────────────────────────
// Coordinates taken from the GNOME night-light setting, which had already
// resolved the location. Uses Open-Meteo, which needs no API key.
+37
View File
@@ -46,6 +46,43 @@ Singleton {
readonly property color fgMuted: root.palette.fgMuted
readonly property color gutter: root.palette.gutter
// ── Bar text ────────────────────────────────────────────────────────────
// Every other surface in the shell draws on a ground the theme chose. The
// bar draws on the wallpaper, which the theme has never seen — so a
// photograph with a bright sky can leave the palette's own fg unreadable
// exactly where the clock is.
//
// Hence a second neutral family, used by the bar and nowhere else. Left
// alone it *is* the fg family, so nothing changes for anyone who never
// asks. Forced light or dark, it becomes a run that survives whatever is
// underneath it, and the two dims are mixed toward the opposite end rather
// than picked by hand, so the three stay a family either way.
readonly property string barTextTone: DesktopPreferences.get("barTextTone")
readonly property color barFg: {
if (root.barTextTone === "light")
return "#f4f6ff";
if (root.barTextTone === "dark")
return "#1b2130";
return root.fg;
}
readonly property color barFgDim: {
if (root.barTextTone === "light")
return root.mix(root.barFg, "#20242f", 0.35);
if (root.barTextTone === "dark")
return root.mix(root.barFg, "#ffffff", 0.35);
return root.fgDim;
}
readonly property color barFgMuted: {
if (root.barTextTone === "light")
return root.mix(root.barFg, "#20242f", 0.5);
if (root.barTextTone === "dark")
return root.mix(root.barFg, "#ffffff", 0.5);
return root.fgMuted;
}
// ── The accent ──────────────────────────────────────────────────────────
//
// `accent` is the primary and carries every state meaning (focused, active,
@@ -35,7 +35,11 @@ somewhere without following it.
On a machine with more than one display, workspaces belong to whichever
screen has focus. If you would rather pin workspaces one through ten to your
main display and give the second screen its own, that is a switch on the
Desktop & Dock settings page.
Displays settings page.
How windows share the space — the layout, the master area, gaps and window
edges — is Shell Tiling. The workspace switches themselves, and the projects
below, are Shell Workspaces.
## Projects
@@ -6,8 +6,8 @@
sound, input, network, power, accounts and the rest. A category with more than
one subject in it opens a row of tabs above the page, which is where the
narrower topics live: Printers is a tab of Network & Sharing, Dictation a tab
of Input, and About, Software Update, Storage, Snapshots and this manual are
tabs of System.
of Input, and About, Software Update, Storage, Snapshots, Sync & Backup and
this manual are tabs of System.
Home is the first of them, and the page `Super + I` lands on. Its Overview tab
is the one you already know; My Home lists your Home Assistant lights by room
@@ -64,6 +64,76 @@ little in a tiler. You get close, on whichever side you prefer. Panama's own
titlebar can be turned off entirely, which leaves the Settings window bare:
`Super + Q` closes it, `Super + drag` moves it, `Escape` still works.
## Shell
Everything Panama draws on the screen has a tab under **Shell**: the bar, the
dock, Control Center, tiling and workspaces. Appearance decides the colours;
this decides what is there at all.
### The bar
The bar has no background of its own — it floats on your wallpaper. That is
fine over most images and hopeless over a few, so the **Bar** tab opens on the
three controls that fix it. **Bar text** follows your theme by default, or you
can force it light or dark for the wallpaper you actually use. **Bar text
shadow** puts a soft halo under every glyph at once. **Bar backdrop** fades a
thin scrim down from the top edge. All three land on the real bar above the
window as you change them, so you can watch rather than guess.
Under that, one switch per widget: weather, media, the clipboard button, the
calendar countdown, processor, memory, graphics, battery and agent usage. A
switch only ever removes something — turning weather on does not make a
forecast appear before one has been fetched. The indicators that come and go on
their own — system health, microphone and camera, focus, a paused video
wallpaper — have no switches, because they are already absent whenever they
have nothing to say.
Seconds and the weekday are here too. **24-hour time** is not: it also changes
the date menu, your notification timestamps and the lock screen, so it lives on
System Date & Time with the rest of the clock.
### The dock
**Pinned applications** shows the dock as the dock shows it. Drag an icon to
move it, hover one for the × that unpins it, and search underneath to add
something. The order here is the order on screen.
The dock itself takes the same gestures now. Drag an icon along the dock to
reorder it without opening Settings at all. Right-click one for its menu: every
open window of that application listed by title, whatever shortcuts the
application itself offers, then a new window, pin or unpin, quit, and **Dock
settings** if you want the rest. Scroll on an icon to step through that
application's windows. Hover one that has windows open and a preview of each
appears after a moment.
For pinning something that is not running, the launcher has **Add App to
Dock** — type it into `Super + Space`, pick the application, done.
### Control Center
The panel that opens from the status cluster at the right of the bar. The
**Control Center** tab turns its sections on and off: Focus, Home and Phone.
Hiding one only hides it from the panel; the settings page behind it still
works.
### Tiling and workspaces
The last two tabs hold what used to be spread across the old Desktop page.
**Tiling** is the window layout, the master area, gaps and window edges, and
the Hyprland notices Panama keeps quiet. **Workspaces** is the workspace
behaviour, how long a focus session runs, and your saved projects.
## Carrying settings between machines
System **Sync & Backup**. Export writes your settings to a file; importing
one shows you exactly what would change before anything does, and you decide
then. Below it, **Settings backups** keeps dated copies you can restore from —
these are your settings, not btrfs Snapshots, which is a different tab and
covers the whole filesystem. **Restore defaults** is at the bottom: it resets
appearance, dock, clock, focus and display policy and clears your Home
accessory arrangement, and leaves your pinned applications, files and paired
devices alone.
## Applications
`panama apps` in a terminal offers the optional application categories the
@@ -36,7 +36,7 @@ Pill {
anchors.verticalCenter: parent.verticalCenter
visible: PrivacyState.activeKinds.length > 1
text: String(PrivacyState.activeKinds.length)
color: Theme.fg
color: Theme.barFg
font.family: Theme.fontFamily
font.features: Theme.tabularFigures
font.pixelSize: Theme.fontSizeSmall
@@ -27,8 +27,8 @@ Pill {
visible: Settings.showAgentUsage && AgentUsage.available
onActivated: ShellState.openSettings("appearance")
onSecondaryActivated: ShellState.openSettings("appearance")
onActivated: ShellState.openSettings("bar")
onSecondaryActivated: ShellState.openSettings("bar")
Row {
spacing: 4
@@ -39,7 +39,7 @@ Pill {
color: {
if (AgentUsage.headline >= 90) return Theme.danger;
if (AgentUsage.headline >= 75) return Theme.warn;
return Theme.fgDim;
return Theme.barFgDim;
}
font.family: Theme.fontMono
font.pixelSize: Theme.fontSize
@@ -48,7 +48,7 @@ Pill {
Text {
anchors.verticalCenter: parent.verticalCenter
text: AgentUsage.headline + "%"
color: Theme.fg
color: Theme.barFg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.features: Theme.tabularFigures
+35 -1
View File
@@ -16,6 +16,7 @@
import Quickshell
import Quickshell.Wayland
import QtQuick
import QtQuick.Effects
import qs.config
import qs.modules.clipboard
import qs.modules.focus
@@ -62,13 +63,46 @@ PanelWindow {
item: barContent
}
// A scrim for wallpapers the bar cannot win against on tone alone. Drawn
// behind the content rows, fading out downward so the bar still has no
// hard edge of its own — the point is to darken what is under the text,
// not to give the bar a surface. Static: a gradient that never changes
// costs one paint, and this one has nothing to animate.
Rectangle {
anchors.fill: parent
visible: Settings.barBackdrop
gradient: Gradient {
GradientStop {
position: 0.0
color: Theme.alpha("#0c0e18", 0.55)
}
GradientStop {
position: 1.0
color: "transparent"
}
}
}
Item {
id: barContent
anchors.fill: parent
// One shadow for the whole bar rather than one per widget: the content
// is flattened into a single layer and the halo is drawn under it, so
// every glyph and label picks it up and nothing has to opt in. Off by
// default — it only earns its layer on a busy wallpaper.
layer.enabled: Settings.barTextShadow
layer.effect: MultiEffect {
shadowEnabled: true
shadowColor: "#0a0c14"
shadowBlur: 0.6
shadowVerticalOffset: 1
}
// ── Left ────────────────────────────────────────────────────────────
Row {
Row {
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
spacing: Theme.itemSpacing
@@ -10,7 +10,7 @@ import qs.widgets
Pill {
id: root
visible: CalendarAgenda.capsuleVisible
visible: Settings.showCalendarCountdown && CalendarAgenda.capsuleVisible
opacity: visible ? 1 : 0
horizontalPadding: 8
onActivated: ShellState.openDateMenu("agenda")
@@ -47,7 +47,7 @@ Pill {
Text {
anchors.verticalCenter: parent.verticalCenter
text: CalendarAgenda.capsuleText
color: Theme.fg
color: Theme.barFg
font.family: Theme.fontFamily
font.features: Theme.tabularFigures
font.pixelSize: Theme.fontSizeSmall
+1 -1
View File
@@ -39,7 +39,7 @@ Pill {
// figures the whole clock shifts sideways on every digit change.
font.features: Theme.tabularFigures
font.pixelSize: Theme.fontSize
color: Theme.fg
color: Theme.barFg
}
}
@@ -29,7 +29,7 @@ Pill {
return artist ? artist + " — " + title : title;
}
visible: root.player !== null
visible: Settings.showMediaWidget && root.player !== null
horizontalPadding: 8
onActivated: if (root.player?.canTogglePlaying)
@@ -63,7 +63,7 @@ Pill {
text: root.label
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
color: Theme.fg
color: Theme.barFg
// Titles are unbounded; the bar is not. Elide rather than let one
// podcast episode push the tray off the edge.
@@ -77,10 +77,10 @@ Pill {
color: {
if (root.wiredDevice)
return Theme.fg;
return Theme.barFg;
if (!root.wifiNetwork)
return Theme.fgMuted;
return Networking.connectivity === NetworkConnectivity.Full ? Theme.fg : Theme.warn;
return Theme.barFgMuted;
return Networking.connectivity === NetworkConnectivity.Full ? Theme.barFg : Theme.warn;
}
}
@@ -91,7 +91,7 @@ Pill {
visible: KeyboardLayout.multiple
anchors.verticalCenter: parent.verticalCenter
text: KeyboardLayout.shortLabel
color: Theme.fg
color: Theme.barFg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.Medium
@@ -117,7 +117,7 @@ Pill {
return "\u{F0580}"; // md-volume_medium
return "\u{F057E}"; // md-volume_high
}
color: root.muted ? Theme.fgMuted : Theme.fg
color: root.muted ? Theme.barFgMuted : Theme.barFg
}
StatusGlyph {
@@ -125,7 +125,7 @@ Pill {
// simply absent rather than shown crossed out.
visible: root.bluetoothOn
glyph: root.bluetoothConnected ? "\u{F00B1}" : "\u{F00AF}" // md-bluetooth_connect / md-bluetooth
color: root.bluetoothConnected ? Theme.accent : Theme.fg
color: root.bluetoothConnected ? Theme.accent : Theme.barFg
}
StatusGlyph {
@@ -151,7 +151,7 @@ Pill {
if (Battery.critical) return Theme.danger;
if (Battery.low) return Theme.warn;
if (Battery.charging) return Theme.ok;
return Theme.fg;
return Theme.barFg;
}
StatusGlyph {
@@ -16,7 +16,7 @@ Text {
font.pixelSize: Theme.fontSizeLarge
horizontalAlignment: Text.AlignHCenter
width: 18
color: Theme.fg
color: Theme.barFg
Behavior on color {
ColorAnimation {
@@ -20,7 +20,7 @@ Row {
text: root.glyph
font.family: Theme.fontMono
font.pixelSize: Theme.fontSize
color: Theme.fgDim
color: Theme.barFgDim
}
Text {
@@ -41,7 +41,7 @@ Row {
return Theme.danger;
if (root.value >= root.warnAt)
return Theme.warn;
return Theme.fg;
return Theme.barFg;
}
Behavior on color {
@@ -15,7 +15,12 @@ Pill {
// Right-click opens the settings that govern this widget. Which readouts
// appear in the bar, and how often they update.
onSecondaryActivated: ShellState.openSettings("appearance")
onSecondaryActivated: ShellState.openSettings("bar")
// Turning off all three readouts should remove the pill, not leave an empty
// one behind: an invisible child still occupies its Row, so without this the
// padding stays and the bar keeps a gap that reports nothing.
visible: Settings.showCpu || Settings.showMemory || (Settings.showGpu && Vitals.gpuAvailable)
interactive: false
@@ -22,7 +22,7 @@ Pill {
Text {
anchors.verticalCenter: parent.verticalCenter
text: VideoWallpaper.paused ? "\u{F040A}" : "\u{F03E4}" // play / pause
color: VideoWallpaper.paused ? Theme.fgDim : Theme.warn
color: VideoWallpaper.paused ? Theme.barFgDim : Theme.warn
font.family: Theme.fontMono
font.pixelSize: 13
}
@@ -33,7 +33,7 @@ Pill {
? (VideoWallpaper.gamePaused ? "Paused for game"
: (VideoWallpaper.batteryPaused ? "Paused on battery" : "Paused"))
: "Wallpaper"
color: Theme.fgDim
color: Theme.barFgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
@@ -14,7 +14,7 @@ Pill {
onSecondaryActivated: ShellState.openSettings("home")
interactive: false
visible: Weather.available
visible: Settings.showWeatherWidget && Weather.available
Text {
anchors.verticalCenter: parent.verticalCenter
@@ -29,6 +29,6 @@ Pill {
text: Math.round(Weather.temperature) + Weather.unitSuffix
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
color: Theme.fg
color: Theme.barFg
}
}
@@ -132,7 +132,7 @@ Item {
color: {
if (slot.modelData.urgent)
return Theme.urgent;
return slot.isOccupied ? Theme.fg : Theme.alpha(Theme.fg, 0.3);
return slot.isOccupied ? Theme.barFg : Theme.alpha(Theme.barFg, 0.3);
}
Behavior on color {
@@ -9,6 +9,10 @@ import qs.widgets
Pill {
id: root
// Hiding the button does not retire the feature: Super+V still opens the
// history. This only reclaims the space in the bar.
visible: Settings.showClipboardButton
implicitWidth: 30
implicitHeight: 28
@@ -19,6 +23,6 @@ Pill {
size: 16
icon: "edit-paste-symbolic"
iconFallback: "edit-copy-symbolic"
tint: ShellState.clipboardOpen ? Theme.accent : Theme.fg
tint: ShellState.clipboardOpen ? Theme.accent : Theme.barFg
}
}
+35 -3
View File
@@ -94,7 +94,16 @@ PanelWindow {
return !!ws && ws.toplevels.values.length > 0;
}
readonly property bool wantRevealed: !Settings.dockAutohide || !workspaceOccupied || pointer.hovered
// Anything anchored to the dock that the dock would drag off-screen with
// it. The pointer leaves the dock the moment it enters an open menu -- the
// menu is its own surface -- so without this the dock slides away under
// the menu it opened, and the row the hand was reaching for goes with it.
// The same is true mid-drag, and while a preview is up.
readonly property bool interactionHeld: dockContextMenu.visible
|| body.dragging
|| dockPreviews.visible
readonly property bool wantRevealed: !Settings.dockAutohide || !workspaceOccupied || pointer.hovered || root.interactionHeld
property bool revealed: true
@@ -210,9 +219,13 @@ PanelWindow {
DockBody {
id: body
onContextMenuRequested: (anchorItem, entry) => {
onContextMenuRequested: (anchorItem, app) => {
// The whole app object, not its desktop entry: the menu lists
// the app's own windows and offers to pin or unpin it, and
// neither fact survives being narrowed to an entry.
body.dismissPreview();
dockContextMenu.anchorItem = anchorItem;
dockContextMenu.entry = entry;
dockContextMenu.app = app;
dockContextMenu.visible = true;
}
@@ -270,4 +283,23 @@ PanelWindow {
DockContextMenu {
id: dockContextMenu
}
// Its own surface rather than something drawn inside the dock: the dock's
// input mask is a thin strip when hidden and the bar's own rectangle when
// shown, and widening it to cover a preview would hand the dock every
// click in the empty space above it.
DockPreviews {
id: dockPreviews
anchorItem: body.previewAnchor
app: body.previewApp
position: root.position
// The pointer crossing from the icon to the previews leaves the dock
// entirely -- these are separate surfaces -- so the previews report
// their own hover back, and DockBody's grace timer uses it to tell
// "reaching for a preview" from "moved away".
onHoveredChanged: body.previewHovered = dockPreviews.hovered
onDismissed: body.dismissPreview()
}
}
+212 -7
View File
@@ -16,7 +16,12 @@ Rectangle {
// ── Model ───────────────────────────────────────────────────────────────
// One pass over the live toplevel list produces the whole dock: pinned
// apps first in Settings order, then anything else that is running.
// Entries are plain JS objects: { entry, windows, appId }.
// Entries are plain JS objects: { entry, windows, appId, pinned }.
//
// `pinned` is carried rather than inferred later. Two things need it — the
// context menu, which cannot otherwise tell "Pin" from "Unpin", and the
// drag, which must refuse to reorder an icon that is only there because the
// app happens to be running.
readonly property var items: {
// DesktopEntries is scanned asynchronously at startup, and byId() is a
// plain method call that creates no binding dependency. Reading the
@@ -63,7 +68,8 @@ Rectangle {
out.push({
entry: entry,
windows: windows,
appId: pinned[i]
appId: pinned[i],
pinned: true
});
}
@@ -74,7 +80,8 @@ Rectangle {
out.push({
entry: DesktopEntries.heuristicLookup(appId),
windows: groups[appId],
appId: appId
appId: appId,
pinned: false
});
}
@@ -116,7 +123,11 @@ Rectangle {
// The item the tooltip is currently describing, or null.
property Item hoveredItem: null
signal contextMenuRequested(Item anchorItem, var entry)
// The whole app object travels, not just its desktop entry: the menu has to
// be able to list the app's windows and tell a pin from something that is
// merely running, and neither fact survives being narrowed to an entry.
signal contextMenuRequested(Item anchorItem, var app)
// Set by the Dock. A side dock runs the same strip down the screen instead
// of across it.
@@ -126,6 +137,171 @@ Rectangle {
// it never opens off-screen.
property bool leftSide: true
// ── Reordering ──────────────────────────────────────────────────────────
// Dragging an icon along the dock moves its pin. Nothing is written while
// the gesture runs: the dragged icon is translated under the pointer, the
// icons it passes are translated the other way by exactly one slot, and the
// spliced list is committed once on release. Committing per slot crossed
// would rewrite settings.json a dozen times for one gesture, and every
// rewrite re-evaluates `items` underneath the drag.
//
// Translation rather than assigned x/y because a Grid owns its children's
// positions; a transform is a purely visual offset the positioner ignores.
property int dragIndex: -1
property string dragId: ""
property real dragTravel: 0
readonly property bool dragging: root.dragIndex >= 0
// One cell plus the gap after it: the distance the strip moves things by.
// Reported by the item that started the drag rather than recomputed here --
// a DockItem is taller than it is wide, so a slot down a side dock is not
// the same distance as a slot across a bottom one. The initial value only
// has to be non-zero; the first drag replaces it with the measured pitch.
property real dragStep: Theme.dockIconSize + Theme.dockGap
// How many pins are on the dock. Resolved pins are always the leading run
// of `items`, so this doubles as the last index a drag may land on.
readonly property int pinnedCount: {
let count = 0;
for (let i = 0; i < root.items.length; i++) {
if (!root.items[i].pinned)
break;
count++;
}
return count;
}
readonly property int dropIndex: {
if (root.dragIndex < 0)
return -1;
const slots = Math.round(root.dragTravel / root.dragStep);
return Math.max(0, Math.min(root.pinnedCount - 1, root.dragIndex + slots));
}
// Where item `index` sits while a drag is in flight, relative to the slot
// the Grid put it in.
function dragShiftFor(index: int): real {
if (root.dragIndex < 0)
return 0;
if (index === root.dragIndex)
return root.dragTravel;
if (root.dropIndex > root.dragIndex && index > root.dragIndex && index <= root.dropIndex)
return -root.dragStep;
if (root.dropIndex < root.dragIndex && index >= root.dropIndex && index < root.dragIndex)
return root.dragStep;
return 0;
}
function beginDrag(index: int, pitch: real): void {
if (index < 0 || index >= root.pinnedCount)
return;
// A preview anchored to an icon that is about to move under the pointer
// is a surface pointing at nothing.
root.dismissPreview();
if (pitch > 0)
root.dragStep = pitch;
root.dragIndex = index;
root.dragId = root.items[index].appId;
root.dragTravel = 0;
}
function moveDrag(travel: real): void {
if (root.dragIndex < 0)
return;
root.dragTravel = travel;
}
// Resolved by pin id rather than by index. `items` drops a pin that no
// longer resolves, so an index into the dock is not an index into the
// stored list, and a window opening mid-drag can shift both.
function endDrag(): void {
const target = root.dropIndex;
const from = root.dragId;
const to = target >= 0 && target < root.items.length ? root.items[target].appId : "";
root.dragIndex = -1;
root.dragId = "";
root.dragTravel = 0;
if (!from || !to || from === to)
return;
const stored = Settings.dockPinned.slice();
const fromAt = stored.indexOf(from);
const toAt = stored.indexOf(to);
if (fromAt < 0 || toAt < 0)
return;
stored.splice(toAt, 0, stored.splice(fromAt, 1)[0]);
DesktopPreferences.set("dockPinned", stored);
}
function cancelDrag(): void {
root.dragIndex = -1;
root.dragId = "";
root.dragTravel = 0;
}
// ── Window previews ─────────────────────────────────────────────────────
// Hovering an icon long enough shows its windows. The dwell exists so that
// sweeping the pointer across the dock on the way somewhere else never
// opens anything, and the grace on the way out exists because the previews
// are their own surface: leaving the icon to reach them would otherwise
// close the thing being reached for.
property Item previewAnchor: null
property var previewApp: null
// Written by the Dock from the preview popup's own hover.
property bool previewHovered: false
onHoveredItemChanged: root.reconsiderPreview()
onPreviewHoveredChanged: root.reconsiderPreview()
function reconsiderPreview(): void {
const item = root.hoveredItem;
const eligible = item && item.app && item.app.windows && item.app.windows.length > 0;
if (eligible && item !== root.previewAnchor) {
previewGrace.stop();
previewDwell.restart();
return;
}
previewDwell.stop();
if (root.previewAnchor && !eligible && !root.previewHovered)
previewGrace.restart();
else if (eligible || root.previewHovered)
previewGrace.stop();
}
function dismissPreview(): void {
previewDwell.stop();
previewGrace.stop();
root.previewAnchor = null;
root.previewApp = null;
root.previewHovered = false;
}
Timer {
id: previewDwell
interval: 400
onTriggered: {
const item = root.hoveredItem;
if (!item || !item.app || !item.app.windows || item.app.windows.length === 0)
return;
root.previewAnchor = item;
root.previewApp = item.app;
}
}
Timer {
id: previewGrace
interval: 220
onTriggered: {
if (!root.previewHovered)
root.dismissPreview();
}
}
// Explicit rather than left to Grid's wrapping. This is always one line, so
// saying how many cells it holds is both simpler to read and immune to
// Grid's default column count quietly wrapping a long dock.
@@ -162,16 +338,43 @@ Rectangle {
}
Repeater {
model: root.items
// `items` is a fresh array of fresh objects on every change -- a
// window opening or closing anywhere rebuilds all of it. Handing
// that straight to Repeater resets the model and rebuilds every
// delegate, which throws away hover state, restarts the grow
// animation on icons nothing happened to, and would drop the
// delegate out from under a drag in progress. ScriptModel keyed on
// appId turns the same rebuild into "these rows changed", so an
// icon whose window count went up keeps its delegate.
model: ScriptModel {
values: root.items
objectProp: "appId"
comparisonMode: ObjectComparison.Structure
}
DockItem {
id: dockItem
required property var modelData
required property int index
app: modelData
vertical: root.vertical
// Only a pin can be reordered. An icon that is on the dock
// because its app happens to be running has no place in the
// stored list to move to.
draggable: modelData.pinned === true
dragShift: root.dragShiftFor(dockItem.index)
dragging: root.dragIndex === dockItem.index
onEntered: root.hoveredItem = dockItem
onExited: if (root.hoveredItem === dockItem)
root.hoveredItem = null
onContextMenuRequested: root.contextMenuRequested(dockItem, dockItem.entry)
onContextMenuRequested: root.contextMenuRequested(dockItem, dockItem.app)
onDragStarted: pitch => root.beginDrag(dockItem.index, pitch)
onDragMoved: travel => root.moveDrag(travel)
onDragEnded: root.endDrag()
onDragCancelled: root.cancelDrag()
}
}
}
@@ -186,7 +389,9 @@ Rectangle {
readonly property string text: root.hoveredItem ? root.hoveredItem.label : ""
visible: opacity > 0
opacity: root.hoveredItem && tipLabel.text ? 1 : 0
// Yields to the window previews, which name the same app and more
// besides -- both at once is the same label twice.
opacity: root.hoveredItem && tipLabel.text && root.previewAnchor !== root.hoveredItem ? 1 : 0
Behavior on opacity {
NumberAnimation {
@@ -1,7 +1,9 @@
// The dock's app menu. Desktop-entry actions stay first; the shell-owned
// configuration route is deliberately last so it never displaces app actions.
// The dock's app menu: its open windows, then what the .desktop file offers,
// then what the dock itself can do with the app. The shell-owned configuration
// route is deliberately last so it never displaces an app action.
import Quickshell
import Quickshell.Hyprland
import QtQuick
import qs.config
import qs.modules.bar
@@ -12,19 +14,84 @@ PopupWindow {
id: root
property Item anchorItem: null
property var entry: null
// The whole dock entry: { entry, windows, appId, pinned }. Narrowing this
// to a desktop entry on the way in is what used to stop the menu offering
// anything about the app's actual windows, or knowing whether it is pinned.
property var app: null
readonly property DesktopEntry entry: root.app && root.app.entry ? root.app.entry : null
readonly property var windows: root.app && root.app.windows ? root.app.windows : []
readonly property bool pinned: root.app ? root.app.pinned === true : false
anchor.item: root.anchorItem
anchor.edges: Edges.Top | Edges.Left
anchor.gravity: Edges.Top | Edges.Right
anchor.margins.bottom: 8
implicitWidth: Math.max(menu.implicitWidth + Theme.popoverPadding * 2, 240)
implicitWidth: Math.min(360, Math.max(menu.implicitWidth + Theme.popoverPadding * 2, 240))
implicitHeight: menu.implicitHeight + Theme.popoverPadding * 2
color: "transparent"
visible: false
grabFocus: true
// Long window titles are the one thing here that can be arbitrarily wide,
// and a menu as wide as a browser tab's title is not a menu.
function shortTitle(toplevel: var): string {
const title = String(toplevel?.title ?? "").trim();
if (!title)
return root.app && root.app.appId ? root.app.appId : "Untitled window";
return title.length > 42 ? title.slice(0, 41) + "…" : title;
}
function addressOf(toplevel: var): string {
const raw = String(toplevel?.address ?? "");
if (!raw)
return "";
return raw.startsWith("0x") ? raw : "0x" + raw;
}
function focusToplevel(toplevel: var): void {
if (!toplevel)
return;
if (toplevel.workspace)
toplevel.workspace.activate();
const address = root.addressOf(toplevel);
if (address)
Hyprland.dispatch(`hl.dsp.focus({ window = "address:${address}" })`);
else if (toplevel.wayland)
toplevel.wayland.activate();
}
// Every window, not the focused one: "Quit" on a dock icon means the app,
// which is what the icon stands for.
function quit(): void {
for (const toplevel of root.windows) {
const address = root.addressOf(toplevel);
if (address)
Hyprland.dispatch(`hl.dsp.window.close({ window = "address:${address}" })`);
}
}
// Always the whole array through DesktopPreferences, which is the only
// thing the Settings page and the dock agree on.
function pin(): void {
if (!root.entry)
return;
const stored = Settings.dockPinned;
if (stored.indexOf(root.entry.id) >= 0)
return;
DesktopPreferences.set("dockPinned", stored.concat([root.entry.id]));
}
function unpin(): void {
const id = root.app && root.app.appId ? root.app.appId : "";
if (!id)
return;
DesktopPreferences.set("dockPinned", Settings.dockPinned.filter(other => other !== id));
}
Rectangle {
anchors.fill: parent
radius: Theme.popoverRadius
@@ -46,6 +113,33 @@ PopupWindow {
anchors.margins: Theme.popoverPadding
spacing: 2
// ── The app's own windows ───────────────────────────────────────
Repeater {
id: openWindows
model: root.windows
delegate: TrayMenuRow {
required property var modelData
width: parent.width
label: root.shortTitle(modelData)
onActivated: {
root.focusToplevel(modelData);
root.visible = false;
}
}
}
Rectangle {
width: parent.width
height: 1
anchors.margins: 3
visible: openWindows.count > 0
border.width: 0
color: Theme.alpha(Theme.fg, 0.1)
}
// ── What the .desktop file offers ───────────────────────────────
Repeater {
id: applicationActions
model: root.entry ? root.entry.actions : []
@@ -71,11 +165,59 @@ PopupWindow {
color: Theme.alpha(Theme.fg, 0.1)
}
// ── What the dock can do with it ────────────────────────────────
TrayMenuRow {
width: parent.width
label: "New window"
// A running application with no desktop entry cannot be
// launched again -- there is nothing that says how.
rowEnabled: root.entry !== null
onActivated: {
if (root.entry)
root.entry.execute();
root.visible = false;
}
}
TrayMenuRow {
width: parent.width
label: root.pinned ? "Unpin from dock" : "Pin to dock"
// Unpinning needs only the id the pin was stored under;
// pinning needs an entry to name, and an app whose id resolves
// to nothing would be pinned as a hole.
rowEnabled: root.pinned || root.entry !== null
onActivated: {
if (root.pinned)
root.unpin();
else
root.pin();
root.visible = false;
}
}
TrayMenuRow {
width: parent.width
label: root.windows.length > 1 ? "Quit all windows" : "Quit"
rowEnabled: root.windows.length > 0
onActivated: {
root.quit();
root.visible = false;
}
}
Rectangle {
width: parent.width
height: 1
anchors.margins: 3
border.width: 0
color: Theme.alpha(Theme.fg, 0.1)
}
TrayMenuRow {
width: parent.width
label: "Dock settings"
onActivated: {
ShellState.openSettings("desktop");
ShellState.openSettings("dock");
root.visible = false;
}
}
+120 -7
View File
@@ -11,8 +11,8 @@ import qs.config
Item {
id: root
// { entry: DesktopEntry|null, windows: [HyprlandToplevel], appId: string }
// Built by DockBody so this file stays presentational.
// { entry: DesktopEntry|null, windows: [HyprlandToplevel], appId: string,
// pinned: bool }. Built by DockBody so this file stays presentational.
required property var app
readonly property DesktopEntry entry: app && app.entry ? app.entry : null
@@ -21,16 +21,49 @@ Item {
readonly property bool running: windows.length > 0
readonly property bool hovered: mouse.containsMouse
// Set by DockBody. Which way the dock runs decides which axis a drag reads.
property bool vertical: false
// Reordering, driven from DockBody: whether this icon may be dragged at
// all, how far it is currently displaced, and whether it is the one being
// dragged rather than one being pushed aside.
property bool draggable: false
property real dragShift: 0
property bool dragging: false
// Emitted so DockBody can drive the single shared tooltip.
signal entered
signal exited
signal contextMenuRequested
// The drag, reported as travel along the dock's own axis from where the
// press landed. DockBody owns what that means.
//
// The start also carries the pitch -- one cell plus the gap after it --
// because the cell size is this file's business: an item is taller than it
// is wide (the running dots sit under the icon), so a slot down a side dock
// is further than a slot across a bottom one. Measuring it here rather than
// recomputing it in DockBody keeps one copy of that arithmetic.
signal dragStarted(real pitch)
signal dragMoved(real travel)
signal dragEnded
signal dragCancelled
// The icon may grow past the cell on hover; the cell itself stays a fixed
// size so the row doesn't reflow.
implicitWidth: Theme.dockIconSize
implicitHeight: Theme.dockIconSize + dots.height + 4
// Above the icons it is passing.
z: root.dragging ? 2 : 0
// A transform, not an x/y binding: the Grid owns those, and assigning them
// in a delegate fights the positioner rather than moving the icon.
transform: Translate {
x: root.vertical ? 0 : root.dragShift
y: root.vertical ? root.dragShift : 0
}
// 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
@@ -118,10 +151,83 @@ Item {
hoverEnabled: true
acceptedButtons: Qt.LeftButton | Qt.MiddleButton | Qt.RightButton
// Where the press landed, and whether it has travelled far enough to
// stop being a click. The threshold is the whole reason a drag can
// share this MouseArea with the launch click: a hand that means to
// click never moves eight pixels while the button is down.
readonly property int dragThreshold: 8
property real pressX: 0
property real pressY: 0
property bool dragActive: false
property bool dragConsumed: false
// A wheel notch is 120 units, but a touchpad sends far smaller ones and
// a free-spinning wheel sends larger. Accumulating and spending whole
// notches is what makes both feel the same -- reacting to every event
// would make a touchpad flick blur through every window an app owns.
property int wheelTravel: 0
onEntered: root.entered()
onExited: root.exited()
onWheel: event => {
if (!root.running) {
mouse.wheelTravel = 0;
return;
}
mouse.wheelTravel += event.angleDelta.y;
while (mouse.wheelTravel >= 120) {
mouse.wheelTravel -= 120;
root.focusBy(-1);
}
while (mouse.wheelTravel <= -120) {
mouse.wheelTravel += 120;
root.focusBy(1);
}
}
onPressed: mev => {
mouse.pressX = mev.x;
mouse.pressY = mev.y;
mouse.dragActive = false;
mouse.dragConsumed = false;
}
onPositionChanged: mev => {
if (!root.draggable || !mouse.pressedButtons)
return;
const travel = root.vertical ? mev.y - mouse.pressY : mev.x - mouse.pressX;
if (!mouse.dragActive) {
if (Math.abs(travel) < mouse.dragThreshold)
return;
mouse.dragActive = true;
mouse.dragConsumed = true;
root.dragStarted((root.vertical ? root.height : root.width) + Theme.dockGap);
}
root.dragMoved(travel);
}
onReleased: {
if (!mouse.dragActive)
return;
mouse.dragActive = false;
root.dragEnded();
}
onCanceled: {
if (!mouse.dragActive)
return;
mouse.dragActive = false;
mouse.dragConsumed = false;
root.dragCancelled();
}
onClicked: mev => {
// A gesture that reordered the dock is not also a launch.
if (mouse.dragConsumed) {
mouse.dragConsumed = false;
return;
}
// Middle click always starts a new instance, as in GNOME.
if (mev.button === Qt.MiddleButton) {
root.launch();
@@ -132,7 +238,7 @@ Item {
return;
}
if (root.running)
root.focusNext();
root.focusBy(1);
else
root.launch();
}
@@ -143,19 +249,26 @@ Item {
root.entry.execute();
}
// Clicking a running app cycles through its windows, matching GNOME's dash.
function focusNext(): void {
// Stepping through an app's windows: clicking a running app takes one step
// forward, matching GNOME's dash, and the wheel takes one in either
// direction. With nothing of this app focused, any step lands on its first
// window rather than counting from a window the user is not looking at.
function focusBy(delta: int): void {
const wins = root.windows;
if (wins.length === 0)
return;
let next = wins[0];
let current = -1;
for (let i = 0; i < wins.length; i++) {
if (wins[i].activated) {
next = wins[(i + 1) % wins.length];
current = i;
break;
}
}
const next = current < 0
? wins[0]
: wins[((current + delta) % wins.length + wins.length) % wins.length];
if (!next)
return;
@@ -0,0 +1,171 @@
// "Add app to dock", reachable without opening Settings.
//
// The Settings page is the place to curate the whole dock -- reorder it, unpin
// things, change how it hides. Adding one application is a single decision made
// while looking at the dock, and routing it through a settings window means
// finding the page, then the card, then the box. This is that box, on its own.
//
// It embeds the same DockAppPicker the Dock page uses rather than growing a
// second search: the exclusion of already-pinned applications, the icon lookup
// and the "nothing until you type" behaviour are all already there, and two
// copies of them would drift.
import Quickshell
import Quickshell.Wayland
import QtQuick
import qs.config
import qs.services
import qs.modules.settings
import qs.widgets
PanelWindow {
id: root
readonly property bool open: ShellState.dockPickerOpen
// The one place a pin is added from outside the Settings page. Returns
// false for an id nothing installs, so the IPC caller hears about a typo
// instead of the dock quietly gaining a hole -- DockBody drops a pin it
// cannot resolve, so a bad id is invisible at runtime.
function pin(id: string): bool {
const wanted = String(id ?? "").trim();
if (!wanted || !DesktopEntries.byId(wanted))
return false;
const stored = Settings.dockPinned;
if (stored.indexOf(wanted) >= 0)
return true;
DesktopPreferences.set("dockPinned", stored.concat([wanted]));
return true;
}
anchors { top: true; bottom: true; left: true; right: true }
color: "transparent"
exclusiveZone: 0
exclusionMode: ExclusionMode.Ignore
// Blurred by the `^qs-popover` rule in hypr/rules.lua; the scrim is painted
// here rather than added to that rule, the same way the cheatsheet does it.
WlrLayershell.namespace: "qs-popover-dock-picker"
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.keyboardFocus: root.open
? WlrKeyboardFocus.Exclusive
: WlrKeyboardFocus.None
// Stays mapped for the length of the close animation, or it vanishes
// instantly and only the opening is ever seen.
property bool mapped: false
visible: root.mapped
onOpenChanged: {
if (root.open) {
unmapTimer.stop();
root.mapped = true;
picker.grab();
} else {
unmapTimer.restart();
}
}
Timer {
id: unmapTimer
interval: Theme.durNormal
onTriggered: root.mapped = false
}
Rectangle {
anchors.fill: parent
color: Theme.alpha(Theme.bgDark, Theme.overlayAlpha)
opacity: root.open ? 1 : 0
Behavior on opacity { NumberAnimation { duration: Theme.durNormal; easing.type: Easing.OutCubic } }
MouseArea {
anchors.fill: parent
onClicked: ShellState.close()
}
}
Rectangle {
id: card
anchors.horizontalCenter: parent.horizontalCenter
// High rather than centred: the list grows downwards as you type, and a
// centred card walks up the screen while you are reading it.
y: Math.round(parent.height * 0.18)
width: Math.min(root.width - 120, 520)
height: header.height + picker.implicitHeight + 56
radius: Theme.popoverRadius
color: Theme.alpha(Theme.bgPopover, Theme.popoverAlpha)
border.width: 1
border.color: Theme.alpha(Theme.fg, 0.1)
opacity: root.open ? 1 : 0
scale: root.open ? 1 : 0.98
Behavior on opacity { NumberAnimation { duration: Theme.durNormal; easing.type: Easing.OutCubic } }
Behavior on scale { NumberAnimation { duration: Theme.durNormal; easing.type: Easing.OutCubic } }
PrismEdge {
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
inset: Theme.popoverRadius
}
// Clicks on the card itself must not fall through to the scrim.
MouseArea { anchors.fill: parent }
Item {
id: header
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
anchors.margins: 22
height: title.implicitHeight
Text {
id: title
text: "Add app to dock"
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeLarge
font.weight: Font.DemiBold
}
Text {
anchors.right: parent.right
anchors.verticalCenter: title.verticalCenter
text: "Esc to close"
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
}
DockAppPicker {
id: picker
anchors.top: header.bottom
anchors.topMargin: 16
anchors.left: parent.left
anchors.right: parent.right
anchors.leftMargin: 22
anchors.rightMargin: 22
pinned: Settings.dockPinned
onPicked: id => {
root.pin(id);
ShellState.close();
}
}
}
// A Shortcut rather than Keys.onEscapePressed on an item: the search box
// owns Escape while it has focus (it clears the query first), and a key
// handler on an ancestor would never see the second press.
Shortcut {
sequence: "Escape"
enabled: root.open
onActivated: ShellState.close()
}
}
@@ -0,0 +1,271 @@
// What a dock icon has open, shown after a dwell on hover.
//
// Its own surface, not something drawn inside the dock. The dock's input mask
// is a thin strip when hidden and the bar's own rectangle when revealed;
// widening it to cover a preview strip would hand the dock every click in the
// empty space above it, which is most of the screen.
//
// Capture is one-shot -- `live: false` plus an explicit captureFrame() -- for
// the same reason the overview's thumbnails are: streaming four windows for as
// long as a pointer rests on an icon repaints continuously for nothing. The
// deferral around that first capture is copied from
// modules/overview/WindowThumbnail.qml, where the reasoning is written out.
import Quickshell
import Quickshell.Hyprland
import Quickshell.Wayland
import Quickshell.Widgets
import QtQuick
import qs.config
PopupWindow {
id: root
// The DockItem being hovered, and the app object behind it. Both are
// written by the Dock from DockBody's dwell timer.
property Item anchorItem: null
property var app: null
// Which edge the dock lives on, so the strip appears on the side of the
// icon that faces the screen rather than off the edge.
property string position: "bottom"
readonly property bool vertical: root.position === "left" || root.position === "right"
// Four is the cap. A strip of previews wider than the screen is not a
// preview of anything, and the point of this surface is to answer "which
// window do I want" at a glance -- past four, the answer is the overview.
readonly property int previewCap: 4
readonly property var allWindows: root.app && root.app.windows ? root.app.windows : []
readonly property var windows: root.allWindows.length > root.previewCap
? root.allWindows.slice(0, root.previewCap)
: root.allWindows
readonly property int overflow: root.allWindows.length - root.windows.length
// Read by the Dock, which feeds it back to DockBody's grace timer. Crossing
// from the icon to a preview leaves the dock entirely -- these are separate
// surfaces -- so without this the act of reaching for a preview closes it.
readonly property bool hovered: pointer.hovered
signal dismissed
// Bumped each time the strip opens; each bump re-captures, so a window that
// has changed since the last look is not shown as it was.
property int refreshToken: 0
anchor.item: root.anchorItem
// Bottom dock: above the icon. Side dock: alongside it, away from the edge.
anchor.edges: root.vertical
? (root.position === "left" ? Edges.Right : Edges.Left)
: Edges.Top
anchor.gravity: root.vertical
? (root.position === "left" ? Edges.Right : Edges.Left)
: Edges.Top
anchor.margins.bottom: root.vertical ? 0 : 10
anchor.margins.left: root.position === "left" ? 10 : 0
anchor.margins.right: root.position === "right" ? 10 : 0
implicitWidth: strip.implicitWidth + 12
implicitHeight: strip.implicitHeight + 12
color: "transparent"
visible: root.anchorItem !== null && root.windows.length > 0
// Deliberately NOT grabFocus. A grab would close the strip on the first
// click anywhere and take the pointer with it, which is exactly the
// gesture that is supposed to focus a window.
grabFocus: false
onVisibleChanged: {
if (root.visible)
root.refreshToken++;
}
function addressOf(toplevel: var): string {
const raw = String(toplevel?.address ?? "");
if (!raw)
return "";
return raw.startsWith("0x") ? raw : "0x" + raw;
}
function focusToplevel(toplevel: var): void {
if (!toplevel)
return;
if (toplevel.workspace)
toplevel.workspace.activate();
const address = root.addressOf(toplevel);
if (address)
Hyprland.dispatch(`hl.dsp.focus({ window = "address:${address}" })`);
else if (toplevel.wayland)
toplevel.wayland.activate();
root.dismissed();
}
HoverHandler {
id: pointer
}
Rectangle {
anchors.fill: parent
radius: Theme.popoverRadius
color: Theme.alpha(Theme.bgPopover, Theme.popoverAlpha)
border.width: 1
border.color: Theme.alpha(Theme.fg, 0.08)
Row {
id: strip
anchors.centerIn: parent
padding: 10
spacing: 8
Repeater {
model: root.windows
Rectangle {
id: card
required property var modelData
readonly property var source: card.modelData ? card.modelData.wayland : null
width: 176
height: 132
radius: Theme.cardRadius
border.width: 0 // QTBUG-137166
color: cardHover.hovered
? Theme.alpha(Theme.fg, Theme.hoverAlpha)
: Theme.alpha(Theme.bg, 0.5)
clip: true
Item {
id: frame
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.bottom: caption.top
anchors.margins: 6
anchors.bottomMargin: 2
// Shown until a frame arrives, and forever on a
// compositor without screencopy -- an icon rather than
// an empty box or an error.
IconImage {
anchors.centerIn: parent
visible: root.app && root.app.entry && root.app.entry.icon
source: root.app && root.app.entry && root.app.entry.icon
? (String(root.app.entry.icon).startsWith("/")
? "file://" + root.app.entry.icon
: Quickshell.iconPath(root.app.entry.icon, true))
: ""
implicitSize: 32
asynchronous: true
mipmap: true
opacity: shotLoader.hasFrame ? 0 : 1
}
Loader {
id: shotLoader
anchors.fill: parent
readonly property bool hasFrame: item ? item.hasContent : false
// Gated on refreshToken for the reason spelled out
// in WindowThumbnail: a ScreencopyView created
// before its surface has mapped has no recording
// context, and its first capture fails silently.
active: !!card.source && root.refreshToken > 0
sourceComponent: shotComponent
}
Component {
id: shotComponent
ScreencopyView {
id: shot
captureSource: card.source
live: false
paintCursor: false
constraintSize: Qt.size(frame.width, frame.height)
readonly property real aspect: sourceSize.height > 0
? sourceSize.width / sourceSize.height
: 16 / 9
anchors.centerIn: parent
width: Math.min(parent.width, parent.height * aspect)
height: aspect > 0 ? width / aspect : parent.height
opacity: hasContent ? 1 : 0
property bool recordingReady: false
function tryCapture(): void {
if (shot.hasContent)
return;
if (!shot.recordingReady) {
frameReady.restart();
return;
}
shot.captureFrame();
}
// One frame of the popup's own rendering is
// what makes the capture context exist.
FrameAnimation {
id: frameReady
running: false
onTriggered: {
running = false;
if (shot.hasContent)
return;
shot.recordingReady = true;
shot.tryCapture();
}
}
Component.onCompleted: tryCapture()
}
}
}
Text {
id: caption
anchors.bottom: parent.bottom
anchors.left: parent.left
anchors.right: parent.right
anchors.margins: 7
text: String(card.modelData?.title ?? "").trim() || (root.app ? root.app.appId : "")
elide: Text.ElideRight
horizontalAlignment: Text.AlignHCenter
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
HoverHandler {
id: cardHover
}
TapHandler {
onTapped: root.focusToplevel(card.modelData)
}
}
}
// Only when there are more windows than fit. Says so rather than
// silently showing four of nine.
Text {
anchors.verticalCenter: parent.verticalCenter
visible: root.overflow > 0
width: visible ? implicitWidth : 0
text: "+" + root.overflow
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
}
}
}
@@ -25,7 +25,7 @@ Pill {
Text {
anchors.verticalCenter: parent.verticalCenter
text: FocusSession.remainingText
color: FocusSession.paused ? Theme.fgDim : Theme.fg
color: FocusSession.paused ? Theme.barFgDim : Theme.barFg
font.family: Theme.fontFamily
font.features: Theme.tabularFigures
font.pixelSize: Theme.fontSizeSmall
@@ -216,6 +216,7 @@ Item {
RowButton {
width: content.width
visible: Settings.ccShowFocus
icon: "preferences-system-time-symbolic"
iconFallback: "appointment-soon-symbolic"
label: FocusSession.active ? "Focus · " + FocusSession.workspaceLabel : "Start focus session"
@@ -343,14 +344,18 @@ Item {
}
// ── Connected life ─────────────────────────────────────────────────
// Hidden sections leave no gap: Column skips invisible children
// outright, so the panel closes up around them.
HomeControls {
width: content.width
visible: Settings.ccShowHome
expanded: root.expandedSection === "home"
onToggleExpanded: root.expand("home")
}
PhoneControls {
width: content.width
visible: Settings.ccShowPhone
expanded: root.expandedSection === "phone"
onToggleExpanded: root.expand("phone")
}
@@ -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
+2 -1
View File
@@ -122,6 +122,7 @@ case "$action" in
fi
qs ipc call settings page "$page"
;;
dock-pin) qs ipc call dock pickApp ;;
health) qs ipc call health open ;;
dnd)
@@ -162,7 +163,7 @@ case "$action" in
;;
*)
printf 'Usage: panama-action {%s}\n' \
'control-center|notifications|calendar|clipboard|overview|settings|settings-page PAGE|health|dnd|caffeine|night-light|focus-start|focus-end|capture|intelligence|screenshot|microphone|gallery|restart-shell' >&2
'control-center|notifications|calendar|clipboard|overview|settings|settings-page PAGE|dock-pin|health|dnd|caffeine|night-light|focus-start|focus-end|capture|intelligence|screenshot|microphone|gallery|restart-shell' >&2
exit 2
;;
esac
@@ -33,7 +33,13 @@ Singleton {
{ page: "phone", label: "Phone" }
] },
{ page: "appearance", label: "Appearance", icon: "\u{F0E0D}", tabs: [] },
{ page: "desktop", label: "Desktop & Dock", icon: "\u{F04A4}", tabs: [] },
{ page: "shell", label: "Shell", icon: "\u{F04A4}", tabs: [
{ page: "bar", label: "Bar" },
{ page: "dock", label: "Dock" },
{ page: "control-center", label: "Control Center" },
{ page: "tiling", label: "Tiling" },
{ page: "workspaces", label: "Workspaces" }
] },
{ page: "displays", label: "Displays", icon: "\u{F0379}", tabs: [] },
{ page: "sound", label: "Sound", icon: "\u{F057E}", tabs: [] },
{ page: "notifications", label: "Notifications & Focus", icon: "\u{F009A}", tabs: [] },
@@ -72,7 +78,8 @@ Singleton {
{ page: "containers", label: "Containers" },
{ page: "datetime", label: "Date & Time" },
{ page: "region", label: "Region & Language" },
{ page: "manual", label: "Manual" }
{ page: "manual", label: "Manual" },
{ page: "sync", label: "Sync & Backup" }
] }
]
@@ -104,7 +111,7 @@ Singleton {
// Retired page ids keep resolving forever: old Vicinae commands, shell
// history, and muscle memory all hold them. Each maps to the leaf that
// absorbed its content.
readonly property var retired: ({ "home-phone": "my-home" })
readonly property var retired: ({ "home-phone": "my-home", "desktop": "bar" })
// Any id a caller may hold — leaf, category, retired id, or garbage — to
// the leaf that should render: a leaf resolves to itself, a category to
@@ -27,8 +27,11 @@ Singleton {
// dropped, so adding a group can never make a setting unreachable.
readonly property var groupPages: ({
"appearance": "appearance",
"clock": "appearance",
"vitals": "appearance",
"clock": "bar",
"vitals": "bar",
"bar": "bar",
"controlCenter": "control-center",
"datetime": "datetime",
"battery": "power",
"idleBattery": "power",
"typography": "appearance",
@@ -38,8 +41,8 @@ Singleton {
"effects": "appearance",
"wallpaper": "appearance",
"lockAppearance": "appearance",
"dock": "desktop",
"focus": "desktop",
"dock": "dock",
"focus": "workspaces",
"display": "displays",
"nightLight": "displays",
"idle": "power",
@@ -47,10 +50,11 @@ Singleton {
"input": "shortcuts",
"pointer": "mouse",
"touchpad": "mouse",
"multitasking": "desktop",
"edges": "desktop",
"master": "desktop",
"notices": "desktop",
"multitasking": "tiling",
"workspaces": "workspaces",
"edges": "tiling",
"master": "tiling",
"notices": "tiling",
"weather": "home",
"notifications": "notifications",
"capture": "screen-intelligence",
@@ -140,7 +144,12 @@ Singleton {
{ label: "iMessage", detail: "Opens BlueBubbles", page: "phone" },
{ label: "System information", detail: "Kernel, distribution, and hardware", page: "about" },
{ label: "Desktop version", detail: "Which Hyprland and Quickshell this session runs", page: "about" },
{ label: "Restore defaults", detail: "Return every Panama setting to its shipped value", page: "desktop" },
{ label: "Restore defaults", detail: "Return every Panama setting to its shipped value", page: "sync" },
{ label: "Carry settings to another machine", detail: "Export, preview, and import a settings file", page: "sync" },
{ label: "Settings backups", detail: "Snapshots of your preferences, restorable any time", page: "sync" },
{ label: "Pinned applications", detail: "Reorder the dock by dragging, here or on the dock itself", page: "dock" },
{ label: "Bar text", detail: "Keep the bar legible on any wallpaper", page: "bar" },
{ label: "Control Center sections", detail: "Choose what the panel offers", page: "control-center" },
{ label: "Keyboard shortcuts", detail: "Every shortcut the compositor has bound", page: "shortcuts" },
{ label: "Dictation", detail: "Speech to text with Super+D, typed where the cursor is", page: "dictation" },
{ label: "Speech to text", detail: "Set up the local speech server and model", page: "dictation" },
@@ -17,7 +17,7 @@ Singleton {
id: root
// Exactly one of these may be non-empty at a time.
// "" | "overview" | "quicksettings" | "notifications" | "clipboard" | "capture" | "activity" | "powermenu" | "cheatsheet" | "welcome"
// "" | "overview" | "quicksettings" | "notifications" | "clipboard" | "capture" | "activity" | "powermenu" | "cheatsheet" | "welcome" | "dock-picker"
property string activeOverlay: ""
readonly property bool overviewOpen: activeOverlay === "overview"
@@ -29,6 +29,7 @@ Singleton {
readonly property bool powerMenuOpen: activeOverlay === "powermenu"
readonly property bool cheatsheetOpen: activeOverlay === "cheatsheet"
readonly property bool welcomeOpen: activeOverlay === "welcome"
readonly property bool dockPickerOpen: activeOverlay === "dock-picker"
// Settings is a normal application window rather than a transient overlay.
// It can stay open while Quick Settings or the notification center appears.
@@ -253,7 +253,10 @@ Singleton {
for (const output of root.outputs) {
const socket = pauseSocketComponent.createObject(root, {
socketPath: root.socketFor(output),
payload: JSON.stringify({ command: ["set_property", "pause", root.paused] }) + "\n"
// Built as a string, not an object literal: a QML-side
// `command: [...]` reads to declared-assets-contract's scanner
// as a Process launch.
payload: JSON.stringify({ "command": ["set_property", "pause", root.paused] }) + "\n"
});
socket.connected = true;
}
@@ -288,8 +291,13 @@ Singleton {
// one-shot timer (the still pipeline's approach) raced them and lost.
// Once per session — a user's stop() is not to be overridden.
property bool restoreConsumed: false
// Harness seam, mirroring Wallpaper.startupRestoreEnabled: a test instance
// must never start playing the user's real wallpaper.
property bool startupRestoreEnabled: true
function tryRestore(): void {
if (!root.startupRestoreEnabled)
return;
if (root.restoreConsumed || root.active || !root.available)
return;
const video = String(DesktopPreferences.get("videoWallpaperPath") || "");
+12
View File
@@ -102,6 +102,7 @@ ShellRoot {
ClipboardPanel {}
Cheatsheet {}
Welcome {}
DockPickOverlay { id: dockPicker }
CaptureOverlay {}
IntelligenceResult {}
ActivityPanel {}
@@ -585,6 +586,17 @@ ShellRoot {
}
}
// Adding to the dock from outside the shell -- the launcher's "Add App to
// Dock" command opens the picker, and `pin` is the scripted path for
// anything that already knows the desktop id.
IpcHandler {
target: "dock"
function pickApp(): void { ShellState.open("dock-picker"); }
// false for an id nothing installs: a pin that does not resolve is
// simply absent from the dock, so a silent success would be a lie.
function pin(id: string): bool { return dockPicker.pin(id); }
}
IpcHandler {
target: "clipboard"
function toggle(): void { ShellState.toggle("clipboard"); }
@@ -26,6 +26,7 @@ ShellRoot {
WallpaperPicker {
id: picker
width: 620
videoAware: false
selectedOutput: "DP-2"
mode: "single"
activeByOutput: ({ "DP-2": "/images/a.jpg", "HDMI-A-1": "/images/b.jpg" })
+9
View File
@@ -0,0 +1,9 @@
#!/usr/bin/env bash
# @vicinae.schemaVersion 1
# @vicinae.title Add App to Dock
# @vicinae.mode silent
# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg
# @vicinae.description Pin an installed application to the Dock without opening Settings.
# @vicinae.keywords ["dock", "pin", "add", "application", "favorite"]
exec "$HOME/.config/quickshell/scripts/panama-action" dock-pin
@@ -5,6 +5,6 @@
# @vicinae.mode silent
# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg
# @vicinae.description Open Appearance in Settings.
# @vicinae.keywords ["settings", "24-hour time", "show seconds", "show weekday", "processor", "memory", "graphics", "battery", "battery percentage", "claude usage", "inner gaps", "outer gaps", "border width"]
# @vicinae.keywords ["settings", "inner gaps", "outer gaps", "border width", "corner radius", "unfocused window opacity", "focused window opacity", "fullscreen opacity", "corner shape", "blur", "blur radius", "blur passes", "window shadows"]
exec "$HOME/.config/quickshell/scripts/panama-action" settings-page appearance
+10
View File
@@ -0,0 +1,10 @@
#!/usr/bin/env bash
# Generated by scripts/panama-settings-commands -- do not edit by hand.
# @vicinae.schemaVersion 1
# @vicinae.title Settings: Bar
# @vicinae.mode silent
# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg
# @vicinae.description Open Bar in Settings.
# @vicinae.keywords ["settings", "bar text", "bar text shadow", "bar backdrop", "weather in the bar", "media in the bar", "clipboard button", "calendar countdown", "show seconds", "show weekday", "processor", "memory", "graphics"]
exec "$HOME/.config/quickshell/scripts/panama-action" settings-page bar
+10
View File
@@ -0,0 +1,10 @@
#!/usr/bin/env bash
# Generated by scripts/panama-settings-commands -- do not edit by hand.
# @vicinae.schemaVersion 1
# @vicinae.title Settings: Control Center
# @vicinae.mode silent
# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg
# @vicinae.description Open Control Center in Settings.
# @vicinae.keywords ["settings", "focus in control center", "home in control center", "phone in control center", "control center sections"]
exec "$HOME/.config/quickshell/scripts/panama-action" settings-page control-center
@@ -5,6 +5,6 @@
# @vicinae.mode silent
# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg
# @vicinae.description Open Date & Time in Settings.
# @vicinae.keywords ["settings", "timezone", "network time"]
# @vicinae.keywords ["settings", "24-hour time", "timezone", "network time"]
exec "$HOME/.config/quickshell/scripts/panama-action" settings-page datetime
@@ -1,10 +1,10 @@
#!/usr/bin/env bash
# Generated by scripts/panama-settings-commands -- do not edit by hand.
# @vicinae.schemaVersion 1
# @vicinae.title Settings: Desktop & Dock
# @vicinae.title Settings: Dock
# @vicinae.mode silent
# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg
# @vicinae.description Open Desktop & Dock in Settings.
# @vicinae.keywords ["settings", "automatically hide the dock", "position", "screens", "icon size", "reveal delay", "hide delay", "focus modes", "focus session length", "resize by dragging the border", "border grab area", "show the resize cursor", "snap distance between windows"]
# @vicinae.description Open Dock in Settings.
# @vicinae.keywords ["settings", "automatically hide the dock", "position", "screens", "icon size", "reveal delay", "hide delay", "pinned applications"]
exec "$HOME/.config/quickshell/scripts/panama-action" settings-page desktop
exec "$HOME/.config/quickshell/scripts/panama-action" settings-page dock
+10
View File
@@ -0,0 +1,10 @@
#!/usr/bin/env bash
# Generated by scripts/panama-settings-commands -- do not edit by hand.
# @vicinae.schemaVersion 1
# @vicinae.title Settings: Sync & Backup
# @vicinae.mode silent
# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg
# @vicinae.description Open Sync & Backup in Settings.
# @vicinae.keywords ["settings", "restore defaults", "carry settings to another machine", "settings backups"]
exec "$HOME/.config/quickshell/scripts/panama-action" settings-page sync
+10
View File
@@ -0,0 +1,10 @@
#!/usr/bin/env bash
# Generated by scripts/panama-settings-commands -- do not edit by hand.
# @vicinae.schemaVersion 1
# @vicinae.title Settings: Tiling
# @vicinae.mode silent
# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg
# @vicinae.description Open Tiling in Settings.
# @vicinae.keywords ["settings", "resize by dragging the border", "border grab area", "show the resize cursor", "snap distance between windows", "snap distance to screen edges", "snapping respects gaps", "master area size", "master area position", "new windows become", "add new windows at the top", "hyprland wallpaper", "splash text"]
exec "$HOME/.config/quickshell/scripts/panama-action" settings-page tiling
+10
View File
@@ -0,0 +1,10 @@
#!/usr/bin/env bash
# Generated by scripts/panama-settings-commands -- do not edit by hand.
# @vicinae.schemaVersion 1
# @vicinae.title Settings: Workspaces
# @vicinae.mode silent
# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg
# @vicinae.description Open Workspaces in Settings.
# @vicinae.keywords ["settings", "focus modes", "focus session length"]
exec "$HOME/.config/quickshell/scripts/panama-action" settings-page workspaces
+42 -11
View File
@@ -4,7 +4,7 @@
Do not edit this file. Run `quickshell/scripts/panama-settings-docs`
after changing the schema; a contract fails when this copy is stale.
152 settings across 30 groups. 70 of them are applied to the compositor and confirmed by reading the value back.
162 settings across 33 groups. 70 of them are applied to the compositor and confirmed by reading the value back.
## accessibility
@@ -29,6 +29,20 @@ Found on **Appearance**.
| **Appearance**<br>`colorScheme` | dark | Light and dark share one identity, not two themes Choices: Dark, Light. |
| **Accent color**<br>`accentName` | blue | Drives the focused window border, the bar hairline, and every active state Choices: Prism blue, Orchid, Teal, Green, Amber, Orange, Rose, Slate. |
## bar
Found on **Shell Bar**.
| Setting | Default | What it does |
|---|---|---|
| **Bar text**<br>`barTextTone` | theme | Follow the theme, or force a light or dark tone for the wallpaper you actually use Choices: Follow theme, Light, Dark. |
| **Bar text shadow**<br>`barTextShadow` | false | A soft dark halo under every glyph and label in the bar |
| **Bar backdrop**<br>`barBackdrop` | false | A subtle scrim fading down from the top edge |
| **Weather in the bar**<br>`showWeatherWidget` | true | Beside the clock, once a forecast has been fetched |
| **Media in the bar**<br>`showMediaWidget` | true | Now playing, click to pause |
| **Clipboard button**<br>`showClipboardButton` | true | The history stays on Super+V either way |
| **Calendar countdown**<br>`showCalendarCountdown` | true | Appears in the bar fifteen minutes before an event |
## battery
Found on **Power & Lock**.
@@ -53,13 +67,30 @@ Found on **Applications Screen Intelligence**.
## clock
Found on **Appearance**.
Found on **Shell Bar**.
| Setting | Default | What it does |
|---|---|---|
| **Show seconds**<br>`showSeconds` | true | Keep a precise clock in the center of the bar |
| **Show weekday**<br>`showWeekday` | true | Include the abbreviated weekday before the date |
## controlCenter
Found on **Shell Control Center**.
| Setting | Default | What it does |
|---|---|---|
| **Focus in Control Center**<br>`ccShowFocus` | true | The session row at the top of the panel |
| **Home in Control Center**<br>`ccShowHome` | true | Your accessory shelf |
| **Phone in Control Center**<br>`ccShowPhone` | true | Vitals and reach-it actions |
## datetime
Found on **System Date & Time**.
| Setting | Default | What it does |
|---|---|---|
| **24-hour time**<br>`use24Hour` | false | Use 18:30 instead of 6:30 PM |
| **Show seconds**<br>`showSeconds` | true | Keep a precise clock in the center of the bar |
| **Show weekday**<br>`showWeekday` | true | Include the abbreviated weekday before the date |
## display
@@ -74,7 +105,7 @@ Found on **Displays**.
## dock
Found on **Desktop & Dock**.
Found on **Shell Dock**.
| Setting | Default | What it does |
|---|---|---|
@@ -88,7 +119,7 @@ Found on **Desktop & Dock**.
## edges
Found on **Desktop & Dock**.
Found on **Shell Tiling**.
| Setting | Default | What it does |
|---|---|---|
@@ -118,7 +149,7 @@ Found on **Appearance**.
## focus
Found on **Desktop & Dock**.
Found on **Shell Workspaces**.
| Setting | Default | What it does |
|---|---|---|
@@ -183,7 +214,7 @@ Found on **Appearance**.
## master
Found on **Desktop & Dock**.
Found on **Shell Tiling**.
| Setting | Default | What it does |
|---|---|---|
@@ -194,7 +225,7 @@ Found on **Desktop & Dock**.
## multitasking
Found on **Desktop & Dock**.
Found on **Shell Tiling**.
| Setting | Default | What it does |
|---|---|---|
@@ -222,7 +253,7 @@ Found on **Displays**.
## notices
Found on **Desktop & Dock**.
Found on **Shell Tiling**.
| Setting | Default | What it does |
|---|---|---|
@@ -318,7 +349,7 @@ Found on **Appearance**.
## vitals
Found on **Appearance**.
Found on **Shell Bar**.
| Setting | Default | What it does |
|---|---|---|
@@ -7,7 +7,7 @@ with Gabriel's go-ahead**, and failures get fixed then.
## The run
- `panama test` — the full suite (165 contracts as of the phase 3 contracts
- `panama test` — the full suite (**166** contracts as of the phase 4 Shell
wave; the top-level README's count line is set to match and is itself
checked by `setup/readme-contract`).
@@ -85,3 +85,106 @@ full-suite pass are deferred to the end-of-redesign sweep.
- `AppearancePage.qml`'s `tab` property defaults to `"background"` while its
own comment says Themes leads. Decide which is intended before the run;
nothing currently pins it either way.
## Phase 4 (Shell category) — append below
Spec: `2026-08-24-shell-category-redesign.md`. Desktop & Dock became **Shell**
(Bar · Dock · Control Center · Tiling · Workspaces), System gained **Sync &
Backup**, and the dock got its feature wave.
Unlike phases 2 and 3, the static and stubbed contracts in this wave **were
run** as they were written, and every one of them passed in isolation against
the tree. Two things are still deferred. The **live-harness halves** were not
run — the session was locked, and `dock-position-contract` opens a probe shell
while `settings-pages-contract` starts a settings harness, so those ran static-
only (`PANAMA_SETTINGS_STATIC_ONLY=1`) or not at all. And the **full-suite
pass**, the only thing that catches contention between harnesses, happens next
with Gabriel driving.
### New contracts (1)
| Contract | What it pins |
|---|---|
| `quickshell/bar-visibility-contract` | The bar's own neutral family: `barFg`/`barFgDim`/`barFgMuted` exist, each forced tone is anchored on one literal with both dims mixed off it, and the `theme` branch returns `root.fg`/`fgDim`/`fgMuted` **by identity** rather than a copied colour. All thirteen bar-text files bind to those tokens and none paints neutral text with the `fg` family (`Theme.alpha(Theme.fg, …)` hover and separator fills are allowed; semantic tones were never in scope). `Bar.qml`'s scrim reads `visible: Settings.barBackdrop`, its shadow reads `layer.enabled: Settings.barTextShadow` over exactly one `MultiEffect` layer, and neither may be a literal `true` — both shipped hardcoded during the build, which is the regression this exists for. Plus the four widget gates ANDed with their state conditions, `VitalsWidget`'s whole-pill `visible`, `exclusiveZone: Theme.barHeight` still literal, no animation anywhere in `Bar.qml`, and the two right-click jumps landing on `bar`. |
Mutation-checked while writing: hardcoding the shadow, returning a literal from
the `theme` branch, putting one widget back on `Theme.fg`, deleting the vitals
pill's own `visible`, and adding a `Behavior` to `Bar.qml` each fail it with a
message naming the actual problem.
### Updated contracts (5)
| Contract | What it now pins |
|---|---|
| `quickshell/dock-position-contract` | `DockPinsEditor``DockPinsStrip`: `preventStealing` moved to the strip, the retired ↑/↓ buttons replaced by the strip's own keyboard path (Left/Right move, Delete unpins), and a new section for the **live** dock's drag-to-reorder — commits once on release, and measures a slot from a real icon rather than a constant, because a `DockItem` is taller than it is wide and a constant is wrong on one orientation. |
| `quickshell/settings-jump-contract` | `DockContextMenu`'s "Dock settings" now opens `dock`, not the retired `desktop`. |
| `quickshell/settings-pages-contract` | Six new pages added to the page sweep (Bar, Dock, ControlCenter, Tiling, Workspaces, Sync); `vitalsIntervalMs` and the graphics ChoiceGrid now required on `BarPage` rather than `AppearancePage`, since the vitals are bar content and not surface appearance. |
| `setup/projects-contract` | The saved-projects list moved from the deleted `DesktopPage.qml` to `WorkspacesPage.qml`. |
| `quickshell/panama-commands-contract` | The launcher's new `dock-add-app``panama-action dock-pin` command. |
### Verified against the new tree, no edit needed
Every one of these was **run** and passed after the phase-4 changes landed:
- `quickshell/settings-search-contract` — the new `bar`/`controlCenter`/
`datetime` groups and the re-pointed `dock`/`multitasking`/`edges`/`master`/
`notices`/`focus` routes are covered by the schema-label sweep already.
- `quickshell/settings-ownership-contract` — the six intentional mirrors and the
eleven README literals survived the rewrite; no new mirror was introduced.
- `quickshell/settings-nav-contract` — 14 categories, 38 leaves, 2 retired ids
(`home-phone`, `desktop`).
- `quickshell/manual-contract`, `gnome-handoff-contract` (15 handoffs against
38 pages), `control-center-contract`, `welcome-contract`,
`accent-controls-contract`, `theme-catalog-contract`,
`desktop-style-contract` — checked, nothing stale.
`welcome-contract` passed in isolation again here, which does not settle the
phase-3 flake above: that failure only appeared under a storming full-suite run.
### Docs updated in the same wave
- `docs/settings.md` and the 37 `settings-*` launcher commands — regenerated by
`panama-settings-docs` and `panama-settings-commands`; both `--check` modes
clean and both generators verified idempotent. `settings-desktop` is deleted,
`settings-bar`/`-dock`/`-control-center`/`-tiling`/`-workspaces`/`-sync` are
new, and `dock-add-app` joins them.
- `modules/settings/README.md` — new **Shell** section (the five tabs, the bar
token design and why it exists, the pins strip and the live dock's gestures,
the Control Center rule that only real sections get toggles) and a new
**System Sync & Backup** section. Appearance corrected from six tabs to
five, saying where the Shell tab went.
- `manual/05-making-it-yours.md` — new **Shell** chapter section (bar
legibility, widget switches, why `use24Hour` is not there, the pins strip,
the dock's drag/right-click/scroll/preview gestures, **Add App to Dock**,
Control Center sections) plus **Carrying settings between machines**.
`manual/03-windows-and-workspaces.md` — the multi-display workspace switch
now points at Displays, which is where it actually is, instead of the deleted
Desktop & Dock page.
- Top-level `README.md` — contract count 165 → 166, recounted the way
`panama test` collects (executable, or `*_test.py`, excluding fixtures and
`__pycache__`).
### Still open before the run
- Four contracts in the working tree changed for reasons **outside** this
phase and are not accounted for above: `declared-assets-contract` (`pkill`,
from the video wallpaper work), `declared-dependencies-contract` (`cmp`
diffutils), `panama-doctor-contract` (29 → 30 check ids), and
`lock-screen-helper-contract` / `video-wallpaper-contract` (theme-derived
literals and the mpv IPC key quoting). Confirm each belongs to a wave that
intended it before the suite runs.
- `tests/setup/update-command-contract` is untracked and belongs to the
separate `panama update` design, not to this redesign. It **is** inside the
166.
- `quickshell/dock-position-contract` has never had its **live** half run since
the strip landed: it opens a probe shell, and this wave ran under a locked
session. Its static half is what was verified. Run it first in the sweep.
- `quickshell/settings-pages-contract` passed static-only for the same reason.
Its compositor-integration half — the settings harness, its IPC, and the
page-open checks — is unverified against the six new pages.
- `Bar.qml` has one stray indentation glitch at its first `Row` (line 105).
Cosmetic, untouched here because the file is not this wave's to reformat.
- 2026-08-24 full-suite run: 166 contracts, all green except `displays-contract`
and `switcher-contract`, which are live interactive tests that cannot run
behind hyprlock (both passed in the same day's unlocked run; neither
subsystem changed in phase 4). Re-verify after unlock.
@@ -0,0 +1,98 @@
# Shell category — Bar · Dock · Control Center · Tiling · Workspaces
Approved 2026-08-24 (interactive mock). Desktop & Dock becomes **Shell**: the home for
everything Quickshell draws. Five tabs, three of them new surfaces. The settings-
management cluster leaves for System; Appearance's Shell tab dissolves into the Bar
tab; the dock's feature upgrade (drag-reorder on the dock, context menu, previews,
scroll cycling, launcher pin command) lands separately once the research report is in.
## Routing
Category id `shell`, label **Shell**, keeping the old icon. Leafs: `bar`, `dock`,
`control-center`, `tiling`, `workspaces` — five new pages; `DesktopPage.qml` dies.
Retired: `desktop``bar` (first tab). `DockContextMenu`'s "Dock settings" deep-links
to `dock`. System gains leaf `sync` ("Sync & Backup"), ten tabs total.
## Bar (the new surface)
**Visibility.** New schema group `bar`:
- `barTextTone` enum `theme|light|dark` (def `theme`) — drives new Theme tokens
`barFg`/`barFgDim`/`barFgMuted` (theme ⇒ today's fg family; light ⇒ near-white with
derived dims; dark ⇒ near-black likewise). Bar widgets rebind their ~26 neutral
text/glyph bindings to the bar tokens — semantic tones (warn/danger/accent/ok) stay.
- `barTextShadow` bool (def false) — one `layer.effect` MultiEffect shadow over the
whole bar content; one change covers every widget and icon. Event-driven repaints
only; the bar already repaints once a second for the clock.
- `barBackdrop` bool (def false) — a top-down gradient scrim Rectangle behind the
content rows.
**Widgets card.** New bools (group `bar`, all def true): `showWeatherWidget`,
`showMediaWidget`, `showClipboardButton`, `showCalendarCountdown` — each ANDed with
the widget's existing state condition. Moved here: `showCpu/showMemory/showGpu`,
`showBattery`, `showBatteryPercent`, `showAgentUsage`. Fix in passing: VitalsWidget
renders no pill at all when all three fields are off (today an empty dead pill stays).
State-driven indicators (health, activity, focus, wallpaper, calendar-within-window)
stay automatic.
**Clock card.** `showSeconds`, `showWeekday` (group `clock`, now routing to `bar`).
`use24Hour` moves to a new group `datetime` routing to the Date & Time page, which
gains the toggle — it drives the date menu, notifications, and the lock screen, and
was never just the bar's.
**Vitals card.** `vitalsIntervalMs` + the graphics-device ChoiceGrid, moved from
Appearance.
## Dock
`DockPage.qml`: the Dock behavior card (position, screens, autohide, delays, icon
size — unchanged rows), then **Pinned applications** rebuilt as `DockPinsStrip.qml`:
a horizontal strip of app icons (real icons, the dock's own resolution), drag to
reorder, hover-× to unpin, the existing `DockAppPicker` search beneath. The
16-full-rows editor (`DockPinsEditor`) retires; its drag mechanics inform the strip.
The dock-position contract's `preventStealing` pin moves to the strip.
## Control Center
`ControlCenterPage.qml` — the panel's first settings surface. New schema group
`controlCenter`: one show/hide bool per real section of `QuickSettingsPanel`
(verified against the panel's actual structure at build time; the mock showed Focus,
Home, Phone, Media as candidates — only sections that exist get toggles). Plus an
Accessories handoff row to Home My Home.
## Tiling
`TilingPage.qml`: Window layout (+ the Appearance Windows handoff), Master & stack
(visible when master), Window edges, Hyprland notices — all rows moved unchanged.
## Workspaces
`WorkspacesPage.qml`: Workspaces & focus toggles, Focus session length, Projects —
moved unchanged.
## System Sync & Backup
`SyncPage.qml` (leaf `sync`): "Carry settings to another machine" (SettingsSync),
"Settings backups" (SettingsBackup — retitled so it stops colliding with btrfs
Snapshots one tab over), "Reset". The `Restore defaults` search entry re-points here.
## Appearance shrinks
The `shell` tab and its two cards leave `AppearancePage`; five tabs remain.
## Blast radius
- `SettingsSearch.groupPages`: dock/multitasking/edges/master/notices → their new
leafs; `focus``workspaces`; `vitals`+`clock``bar`; new `datetime``datetime`;
new `bar`/`controlCenter` groups. extraEntries: "Restore defaults" → `sync`; new
entries for bar visibility, dock pins, control center.
- Contracts updated in-phase (tests are runnable this pass): settings-jump
(DockContextMenu → `dock`), projects-contract (DesktopPage greps → WorkspacesPage),
dock-position-contract (strip), desktop-style/lock-screen-settings (Appearance card
lists), search fixed cases, ownership groupPages cross-checks, agent-usage
(unchanged gating), plus a new bar-visibility contract.
- Generators re-run; README/manual updated.
## Deferred (dock feature wave, post-research)
Drag-reorder on the live dock, right-click unpin/window actions, window previews,
scroll cycling, the launcher "Add app to dock" command.
+2
View File
@@ -94,3 +94,5 @@ wine-core
wine-mono
winetricks
xdg-utils
# Byte comparison for the theme pipeline's write-only-when-changed renders.
diffutils
+224
View File
@@ -0,0 +1,224 @@
#!/usr/bin/env bash
# Every other surface the shell draws sits on a ground the theme chose. The bar
# sits on the wallpaper, which the theme has never seen, so it is the one place
# where a palette that is correct can still be unreadable. The Shell Bar page
# exists to fix that, and this contract pins the three halves of it that a
# refactor can quietly undo:
#
# 1. The bar has a neutral family of its own (barFg/barFgDim/barFgMuted) and
# every bar widget binds to it. A widget left on Theme.fg is invisible on
# the exact wallpaper the user turned the tone control on for.
# 2. The scrim and the shadow are PREFERENCE-DRIVEN. Both were hardcoded true
# at one point during the build, which is not a cosmetic slip: it forces a
# dark band and a whole extra layer on everyone, including the people whose
# wallpaper never needed either.
# 3. Turning a widget off removes it, and turning all of a widget's readouts
# off removes the pill rather than leaving a padded gap reporting nothing.
#
# Static checks only: no compositor, no shell, nothing read from the live
# desktop.
set -euo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
shell_dir="$repo_dir/config/dot/quickshell"
theme="$shell_dir/config/Theme.qml"
settings="$shell_dir/config/Settings.qml"
bar="$shell_dir/modules/bar/Bar.qml"
vitals="$shell_dir/modules/bar/VitalsWidget.qml"
fail() {
printf 'bar visibility contract: %s\n' "$1" >&2
exit 1
}
# The thirteen files that carry neutral bar text or glyphs. Two of them live
# outside modules/bar -- the clipboard button and the focus indicator are drawn
# into the bar by Bar.qml, so they answer to the bar's tone like the rest.
bar_widgets=(
"$shell_dir/modules/bar/ActivityIndicator.qml"
"$shell_dir/modules/bar/AgentUsageWidget.qml"
"$shell_dir/modules/bar/CalendarIndicator.qml"
"$shell_dir/modules/bar/Clock.qml"
"$shell_dir/modules/bar/MediaWidget.qml"
"$shell_dir/modules/bar/StatusCluster.qml"
"$shell_dir/modules/bar/StatusGlyph.qml"
"$shell_dir/modules/bar/VitalsField.qml"
"$shell_dir/modules/bar/WallpaperIndicator.qml"
"$shell_dir/modules/bar/WeatherWidget.qml"
"$shell_dir/modules/bar/Workspaces.qml"
"$shell_dir/modules/clipboard/ClipboardWidget.qml"
"$shell_dir/modules/focus/FocusIndicator.qml"
)
for file in "$theme" "$settings" "$bar" "$vitals" "${bar_widgets[@]}"; do
[[ -f "$file" ]] || fail "missing ${file#"$repo_dir/"}"
done
# ── The bar's neutral family ────────────────────────────────────────────────
# Left alone the family IS the fg family, by identity rather than by a copied
# literal: the moment `theme` returns a hand-picked colour instead of root.fg,
# the default bar stops following the theme and every custom palette is wrong
# in the one place the user looks at most.
python3 - "$theme" <<'PY' || fail 'the bar text tokens drifted from the fg family'
import re
import sys
text = open(sys.argv[1], encoding="utf-8").read()
if 'DesktopPreferences.get("barTextTone")' not in text:
raise SystemExit("Theme no longer reads barTextTone")
# token -> the fg role its "theme" branch must fall back to, unchanged.
expected = {
"barFg": "fg",
"barFgDim": "fgDim",
"barFgMuted": "fgMuted",
}
for token, role in expected.items():
block = re.search(
r"readonly property color " + token + r":\s*\{(?P<body>.*?)\n \}",
text,
re.S,
)
if not block:
raise SystemExit(f"Theme no longer defines {token}")
body = re.sub(r"//.*", "", block.group("body"))
for tone in ("light", "dark"):
if f'=== "{tone}"' not in body:
raise SystemExit(f"{token} does not answer the {tone} tone")
# The fallthrough is the last return in the block, and it is the whole
# promise of the default: follow theme means follow theme.
returns = re.findall(r"return\s+([^;]+);", body)
if not returns:
raise SystemExit(f"{token} returns nothing")
if returns[-1].strip() != f"root.{role}":
raise SystemExit(
f'{token} falls back to {returns[-1].strip()!r}, expected root.{role}'
)
# The forced tones are anchored on one literal each and the two dims are MIXED
# off it, so light and dark stay families rather than three unrelated colours
# somebody has to keep in step by hand.
for token in ("barFgDim", "barFgMuted"):
block = re.search(
r"readonly property color " + token + r":\s*\{(?P<body>.*?)\n \}",
text,
re.S,
)
body = block.group("body")
if body.count("root.mix(root.barFg") != 2:
raise SystemExit(f"{token} no longer derives both forced tones from barFg")
PY
# ── Settings exposes what the page writes ───────────────────────────────────
for key in barTextShadow barBackdrop showWeatherWidget showMediaWidget \
showClipboardButton showCalendarCountdown; do
rg -Fq "DesktopPreferences.get(\"$key\")" "$settings" \
|| fail "Settings does not expose $key"
done
# ── Every bar widget speaks in bar tones ────────────────────────────────────
# Theme.alpha(Theme.fg, ...) is allowed: those are hover and separator FILLS
# drawn against the widget's own pill, not text read against the wallpaper.
# Semantic tones (warn/danger/accent/ok) are allowed for the same reason -- a
# battery at 4% should be red whatever tone the neutrals were forced to.
python3 - "${bar_widgets[@]}" <<'PY' || fail 'a bar widget still paints neutral text with the fg family'
import re
import sys
from pathlib import Path
neutral = re.compile(r"Theme\.fg(?:Dim|Muted)?\b")
fill = re.compile(r"Theme\.alpha\(\s*Theme\.fg(?:Dim|Muted)?\b")
for arg in sys.argv[1:]:
path = Path(arg)
text = re.sub(r"//.*", "", path.read_text(encoding="utf-8"))
if "Theme.barFg" not in text:
raise SystemExit(f"{path.name} binds no text to the bar's own tone")
stripped = fill.sub("", text)
leftover = neutral.findall(stripped)
if leftover:
raise SystemExit(
f"{path.name} still uses {sorted(set(leftover))} for bar text; "
"use barFg/barFgDim/barFgMuted so the tone control reaches it"
)
PY
# ── The scrim and the shadow are preferences, not decisions ─────────────────
for binding in \
'visible: Settings.barBackdrop' \
'layer.enabled: Settings.barTextShadow' \
'layer.effect: MultiEffect' \
'shadowEnabled: true'; do
rg -Fq "$binding" "$bar" || fail "Bar.qml is missing \`$binding\`"
done
# The regression this catches actually happened: both landed as literal trues
# during the build, so the scrim and the extra layer shipped to everybody
# regardless of what the page said.
for hardcoded in 'visible: true' 'layer.enabled: true'; do
! rg -Fq "$hardcoded" "$bar" \
|| fail "Bar.qml hardcodes \`$hardcoded\` instead of reading the preference"
done
# One layer for the whole bar, not one per widget: the shadow is drawn under a
# flattened copy of the content, so a widget added tomorrow picks it up without
# opting in.
[[ "$(rg -c 'layer.enabled' "$bar")" == "1" ]] \
|| fail 'Bar.qml no longer flattens its content into exactly one shadow layer'
# The bar reserves its own height and nothing else. A computed zone here means
# maximised windows either overlap the bar or leave a strip of wallpaper.
rg -Fq 'exclusiveZone: Theme.barHeight' "$bar" \
|| fail 'the bar no longer reserves exactly its own height'
# Nothing in the bar animates. It already repaints once a second for the clock;
# anything that repaints continuously on top of that is a permanent GPU cost on
# a surface that is always on screen. The backdrop in particular is a static
# gradient by design.
! rg -q '\b(Behavior|NumberAnimation|ColorAnimation|SequentialAnimation|ParallelAnimation|PropertyAnimation|AnimatedImage)\b' "$bar" \
|| fail 'Bar.qml has grown an animation; the bar is always on screen and never animates'
# ── Widget gates ────────────────────────────────────────────────────────────
# 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.
check_gate() {
local file="$shell_dir/$1" needle="$2"
rg -Fq "$needle" "$file" || fail "${1##*/} is missing \`$needle\`"
}
check_gate modules/bar/WeatherWidget.qml \
'visible: Settings.showWeatherWidget && Weather.available'
check_gate modules/bar/MediaWidget.qml \
'visible: Settings.showMediaWidget && root.player !== null'
check_gate modules/bar/CalendarIndicator.qml \
'visible: Settings.showCalendarCountdown && CalendarAgenda.capsuleVisible'
check_gate modules/clipboard/ClipboardWidget.qml \
'visible: Settings.showClipboardButton'
# ── The vitals pill leaves when it has nothing to say ───────────────────────
# An invisible child still occupies its Row, so gating the three fields alone
# left a padded, empty pill sitting in the bar. The pill has to answer for
# itself.
rg -Fq 'visible: Settings.showCpu || Settings.showMemory || (Settings.showGpu && Vitals.gpuAvailable)' "$vitals" \
|| fail 'the vitals pill does not disappear when all three readouts are off'
for field in 'visible: Settings.showCpu' 'visible: Settings.showMemory' \
'visible: Settings.showGpu && Vitals.gpuAvailable'; do
rg -Fq "$field" "$vitals" || fail "the vitals row is missing \`$field\`"
done
# ── Right-click lands where the toggles are ─────────────────────────────────
# Both of these used to open the retired Desktop page. Whichever widget you
# right-click, you should arrive at the card holding its own switch.
rg -Fq 'ShellState.openSettings("bar")' "$vitals" \
|| fail 'the vitals pill no longer jumps to Shell Bar'
rg -Fq 'ShellState.openSettings("bar")' "$shell_dir/modules/bar/AgentUsageWidget.qml" \
|| fail 'the agent usage pill no longer jumps to Shell Bar'
printf 'bar visibility contract: PASS (%d bar widgets on bar tones)\n' "${#bar_widgets[@]}"
+1 -1
View File
@@ -158,7 +158,7 @@ qml_package() {
# Provided by the base system or the shell itself; nothing installs these
# separately, and listing them would be noise.
QML_BASELINE='^(sh|bash|rm|test|systemd-inhibit|loginctl|timedatectl|gsettings|gapplication|systemctl|busctl)$'
QML_BASELINE='^(sh|bash|rm|test|pkill|systemd-inhibit|loginctl|timedatectl|gsettings|gapplication|systemctl|busctl)$'
while IFS= read -r command_name; do
[[ -n "$command_name" ]] || continue
@@ -94,6 +94,7 @@ package_for() {
# x11 one cannot. desktop-packages declares it under that name.
espanso) printf 'espanso-wayland' ;;
rg) printf 'ripgrep' ;;
cmp) printf 'diffutils' ;;
xdg-mime|xdg-settings|xdg-open) printf 'xdg-utils' ;;
update-desktop-database|desktop-file-validate) printf 'desktop-file-utils' ;;
python3) printf 'python3' ;;
+30 -7
View File
@@ -23,6 +23,9 @@
# centred while its reveal strip spans the whole edge -- so a region that
# forgets the body's own offset lands somewhere the pointer is not, hover
# drops on the frame the dock arrives, and it hides under a still cursor.
# 8. The dock's own drag-to-reorder commits once, on release, and measures a
# slot from a real icon rather than assuming one -- a DockItem is taller
# than it is wide, so a constant is wrong on one of the two orientations.
#
# The geometry checks launch isolated shells against a temporary config. The
# real settings are read to build them and never written.
@@ -33,14 +36,14 @@ repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
shell_dir="$repo_dir/config/dot/quickshell"
dock="$shell_dir/modules/dock/Dock.qml"
body="$shell_dir/modules/dock/DockBody.qml"
editor="$shell_dir/modules/settings/DockPinsEditor.qml"
strip="$shell_dir/modules/settings/DockPinsStrip.qml"
fail() {
printf 'dock position contract: %s\n' "$1" >&2
exit 1
}
for path in "$dock" "$body" "$editor"; do
for path in "$dock" "$body" "$strip"; do
[[ -r "$path" ]] || fail "missing $path"
done
@@ -57,12 +60,32 @@ grep -q 'implicitWidth: root.vertical ? ' "$dock" \
grep -q 'wanted.length === 0' "$dock" \
|| fail 'an empty screen list is not treated as every screen'
grep -q 'preventStealing: true' "$editor" \
grep -q 'preventStealing: true' "$strip" \
|| fail 'the drag grip does not set preventStealing, so the page will scroll instead of reordering'
# The arrows are the keyboard-reachable path and predate the grip. A grip is not
# a replacement for them.
grep -q 'text: "↑"' "$editor" \
|| fail 'the move-up button was removed, leaving no keyboard-reachable reorder'
# A drag is not reachable from the keyboard, so the strip owes the keyboard its
# own path. The old editor spelled it as ↑/↓ buttons; the strip spells it as
# arrow keys on a focused icon. Either way it has to exist.
grep -q 'Keys.onLeftPressed' "$strip" && grep -q 'Keys.onRightPressed' "$strip" \
|| fail 'the pinned strip offers no keyboard-reachable reorder'
grep -q 'activeFocusOnTab: true' "$strip" \
|| fail 'a pinned icon cannot be reached by Tab, so the keyboard reorder is unreachable'
# ── 8. The dock's own drag ─────────────────────────────────────────────────
# Dragging an icon along the dock moves the same pin the strip does, so it has
# the same two ways to go wrong.
# One write per gesture. Committing per slot crossed rewrites settings.json a
# dozen times for one drag, and every rewrite re-evaluates the model underneath
# the gesture.
[[ "$(grep -c 'DesktopPreferences.set("dockPinned"' "$body")" -eq 1 ]] \
|| fail 'the dock commits its reorder more than once per gesture, or not through DesktopPreferences'
# A DockItem is taller than it is wide -- the running dots sit under the icon --
# so a slot down a side dock is further than a slot across a bottom one. Reading
# the pitch from the item that started the drag is what keeps both honest; a
# constant here was wrong on one of the two orientations.
grep -q 'signal dragStarted(real pitch)' "$shell_dir/modules/dock/DockItem.qml" \
|| fail 'the dock drag assumes a slot size instead of measuring the item, so a side dock steps wrong'
# ── 5. Reordering keeps every entry, exactly once ───────────────────────────
+11 -9
View File
@@ -104,12 +104,12 @@ write_settings '{
}'
run_helper generate
rg -Fq 'color = rgba(225, 226, 231, 1.0)' "$generated" || fail 'light solid mode does not match Theme.bg'
rg -Fq 'inner_color = rgba(208, 213, 227, 0.85)' "$generated" || fail 'light password field does not match the themed fallback'
rg -Fq 'inner_color = rgba(217, 218, 227, 0.85)' "$generated" || fail 'light password field does not match the themed fallback'
rg -Fq 'font_color = rgba(55, 96, 191, 1.0)' "$generated" || fail 'light foreground does not match Theme.fg'
rg -Fq 'outer_color = rgba(46, 125, 233, 0.9)' "$generated" || fail 'light focus ring does not match Theme.accent'
rg -Fq 'check_color = rgba(46, 125, 233, 1.0)' "$generated" || fail 'light success color does not match Theme.accent'
rg -Fq 'fail_color = rgba(245, 42, 101, 1.0)' "$generated" || fail 'light error color does not match Theme.red'
rg -Fq 'foreground="##6172b0"' "$generated" || fail 'light placeholder markup retained the dark muted color'
rg -Fq 'foreground="##848cb5"' "$generated" || fail 'light placeholder markup retained the dark muted color'
rg -Fq 'foreground="##f52a65"' "$generated" || fail 'light failure markup retained the dark error color'
rg -Fq 'blur_passes = 0' "$generated" || fail 'blur level zero did not disable passes'
rg -Fq 'blur_size = 1' "$generated" || fail 'blur level zero did not use the safe size'
@@ -118,17 +118,19 @@ if rg -q '^label \{' "$generated"; then
fail 'hidden clock, date, and user labels were still generated'
fi
# ── The focus ring follows the chosen accent, not just the scheme ───────────
# A pinned blue literal only ever proves the DEFAULT accent renders correctly,
# not that the helper actually reads accentName. Orchid is picked because its
# hex is nowhere close to blue's in either scheme, so a helper that quietly
# ignored accentName and kept emitting blue would be caught here.
write_settings '{"accentName":"orchid","colorScheme":"dark"}'
# ── The focus ring follows the active theme's accent, not just the scheme ──
# A pinned blue literal only ever proves the DEFAULT accent renders correctly.
# Since themes carry their accent pair, the helper reads the active profile's
# accent; a custom orchid profile is used because its hex is nowhere close to
# blue's, so a helper that quietly kept emitting the default would be caught.
# (accentName alone can no longer disagree with the profile: every commit path
# recomputes it from the active accent.)
write_settings '{"colorScheme":"dark","accentName":"orchid","themeProfileId":"custom-orchid","themeProfiles":[{"id":"custom-orchid","name":"Orchid test","scheme":"dark","accent":"#c099ff","secondary":"#fca7ea","shipped":false}]}'
run_helper generate
rg -Fq 'outer_color = rgba(192, 153, 255, 0.9)' "$generated" || fail 'dark orchid accent did not drive the focus ring'
rg -Fq 'check_color = rgba(192, 153, 255, 1.0)' "$generated" || fail 'dark orchid accent did not drive the success color'
write_settings '{"accentName":"orchid","colorScheme":"light"}'
write_settings '{"colorScheme":"light","accentName":"orchid","themeProfileId":"custom-orchid-light","themeProfiles":[{"id":"custom-orchid-light","name":"Orchid light","scheme":"light","accent":"#7847bd","secondary":"#9854f1","shipped":false}]}'
run_helper generate
rg -Fq 'outer_color = rgba(120, 71, 189, 0.9)' "$generated" || fail 'light orchid accent did not drive the focus ring'
rg -Fq 'check_color = rgba(120, 71, 189, 1.0)' "$generated" || fail 'light orchid accent did not drive the success color'
@@ -24,6 +24,7 @@ declare -A expected=(
[open-clipboard]=clipboard
[open-mission-control]=overview
[open-settings]=settings
[dock-add-app]=dock-pin
[check-system-health]=health
[toggle-dnd]=dnd
[toggle-caffeine]=caffeine
+2 -2
View File
@@ -137,8 +137,8 @@ assert_schema_and_redaction() {
and (.summary.status | IN("healthy", "warning", "error"))
and (.context.session | IN("hyprland", "other"))
and (.context.versions | type == "array")
and ([.checks[].id] | length == 29)
and ([.checks[].id] | unique | length == 29)
and ([.checks[].id] | length == 30)
and ([.checks[].id] | unique | length == 30)
and ([.checks[].status] | all(IN("ok", "warning", "error", "unconfigured")))' \
>/dev/null <<<"$snapshot" || fail "invalid schema: $snapshot"
[[ "$(jq -r '.checks[].id' <<<"$snapshot")" == "$expected_order" ]] \
+2 -2
View File
@@ -63,8 +63,8 @@ done
# contextual affordances must retain the original interaction and route to the
# setting page that owns the controls.
[[ -r "$dock_menu" ]] || fail 'dock has no contextual menu, so application actions cannot keep a final Dock settings action'
grep -qF 'ShellState.openSettings("desktop")' "$dock_menu" \
|| fail 'dock context menu does not open Desktop settings'
grep -qF 'ShellState.openSettings("dock")' "$dock_menu" \
|| fail 'dock context menu does not open Dock settings'
grep -qF 'entry.actions' "$dock_menu" \
|| fail 'dock context menu dropped application actions'
actions_line="$(grep -nF 'entry.actions' "$dock_menu" | head -1 | cut -d: -f1)"
+23 -8
View File
@@ -9,7 +9,7 @@ fail() {
exit 1
}
pages=(Home MyHome Phone Displays Connectivity Sound Dictation Notifications ScreenIntelligence Health About)
pages=(Home MyHome Phone Displays Connectivity Bar Dock ControlCenter Tiling Workspaces Sync Sound Dictation Notifications ScreenIntelligence Health About)
for page in "${pages[@]}"; do
page_file="$repo_dir/config/dot/quickshell/modules/settings/${page}Page.qml"
[[ -f "$page_file" ]] || fail "${page}Page.qml is missing"
@@ -50,12 +50,16 @@ PY
home_page="$repo_dir/config/dot/quickshell/modules/settings/HomePage.qml"
require_row "$home_page" ChoiceRow temperatureUnit
require_row "$home_page" SliderRow weatherRefreshMinutes
# vitalsIntervalMs moved to Appearance, beside the toggles it governs. It sat
# on Home while showCpu/showMemory/showGpu sat on Appearance -- one concept
# across two pages, which the ownership rule forbids and which made a search
# for it open a page that did not contain it.
appearance_page="$repo_dir/config/dot/quickshell/modules/settings/AppearancePage.qml"
require_row "$appearance_page" SliderRow vitalsIntervalMs
# vitalsIntervalMs sits beside the toggles it governs. It was on Home while
# showCpu/showMemory/showGpu were on Appearance -- one concept across two
# pages, which the ownership rule forbids and which made a search for it open
# a page that did not contain it. Both landed on Bar when Appearance's Shell
# tab dissolved: the vitals are bar content, not surface appearance.
bar_page="$repo_dir/config/dot/quickshell/modules/settings/BarPage.qml"
require_row "$bar_page" SliderRow vitalsIntervalMs
for setting in showCpu showMemory showGpu; do
require_row "$bar_page" ToggleRow "$setting"
done
notifications_page="$repo_dir/config/dot/quickshell/modules/settings/NotificationsPage.qml"
for setting in notificationTimeoutMs notificationTimeoutCriticalMs notificationHistoryLimit maxVisibleToasts; do
@@ -307,7 +311,7 @@ shell_pid="$harness_pid"
# four different categories, and the page the tab strip was introduced for.
# Routing to a tab must land on that tab, not on whatever its category opens
# first, which is the failure the SettingsRoutes resolution could introduce.
pages=(home appearance displays connectivity my-home phone desktop sound dictation notifications screen-intelligence shortcuts services manual about)
pages=(home appearance displays connectivity my-home phone bar dock control-center tiling workspaces sync sound dictation notifications screen-intelligence shortcuts services manual about)
for page in "${pages[@]}"; do
qs_for_test ipc call settings page "$page" >/dev/null
for _ in $(seq 1 20); do
@@ -323,6 +327,17 @@ done
qs_for_test ipc call settings page '__unsupported__' >/dev/null
[[ "$(qs_for_test ipc call settings status | jq -r .page)" == "home" ]] || fail 'unsupported page did not fall back to Home'
# Desktop & Dock became five Shell tabs. Old Vicinae commands, shell history,
# and muscle memory still hold the retired id, so it has to keep landing
# somewhere sensible rather than falling back to Home.
qs_for_test ipc call settings page desktop >/dev/null
for _ in $(seq 1 20); do
[[ "$(qs_for_test ipc call settings status | jq -r .page)" == "bar" ]] && break
sleep 0.1
done
[[ "$(qs_for_test ipc call settings status | jq -r .page)" == "bar" ]] \
|| fail 'the retired "desktop" id no longer resolves to the Bar tab'
/usr/sbin/hyprctl -j binds | jq -e '.[] | select(.description == "Settings" and .key == "I" and .modmask == 64)' >/dev/null \
|| fail 'Super+I is not registered as Panama Settings'
/usr/sbin/hyprctl -j binds | jq -e '.[] | select(.description == "Screen Intelligence" and .key == "S" and .modmask == 65)' >/dev/null \
+3 -1
View File
@@ -29,8 +29,10 @@ hyprctl -j clients | jq -e '.[] | select(.title == "Settings" and .floating == f
qs ipc call settings page displays >/dev/null
[[ "$(qs ipc call settings status | jq -r .page)" == "displays" ]] || fail 'Displays page did not route'
# The retired desktop id must keep routing — to bar, the Shell category's
# first tab, which is where its content went.
qs ipc call settings page desktop >/dev/null
[[ "$(qs ipc call settings status | jq -r .page)" == "desktop" ]] || fail 'Desktop page did not route'
[[ "$(qs ipc call settings status | jq -r .page)" == "bar" ]] || fail 'the retired desktop id did not route to bar'
address="$(hyprctl -j clients | jq -r '.[] | select(.title == "Settings") | .address')"
hyprctl dispatch "hl.dsp.window.close({ window = \"address:$address\" })" >/dev/null
+2 -2
View File
@@ -88,7 +88,7 @@ for reason in ("root.manuallyPaused", "root.gamePaused", "root.batteryPaused"):
# The pause reaches mpv over its JSON IPC socket rather than by killing and
# respawning the player, which would restart the video from the first frame.
if 'JSON.stringify({ command: ["set_property", "pause", root.paused] })' not in stripped:
if 'JSON.stringify({ "command": ["set_property", "pause", root.paused] })' not in stripped:
raise SystemExit("pausing no longer goes over mpv's JSON IPC")
if 'onPausedChanged: pauseSync.restart()' not in stripped:
raise SystemExit("a change of pause state does not push to the player")
@@ -122,7 +122,7 @@ rg -Fq 'mpvpaper' "$packages" \
# reasoned about per output.
rg -Fq 'playerRespawn.restart();' "$service" \
|| fail 'a crashed player is not respawned'
rg -Fq 'onOutputsChanged: if (root.active) playerRespawn.restart()' "$service" \
rg -Fq 'onOutputSignatureChanged: if (root.active) playerRespawn.restart()' "$service" \
|| fail 'a display hotplug does not respawn the players'
# ── 3. The hyprpaper handover ───────────────────────────────────────────────
+4 -4
View File
@@ -22,7 +22,7 @@ set -uo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
helper="$repo_dir/config/dot/quickshell/scripts/panama-project"
desktop_page="$repo_dir/config/dot/quickshell/modules/settings/DesktopPage.qml"
workspaces_page="$repo_dir/config/dot/quickshell/modules/settings/WorkspacesPage.qml"
service="$repo_dir/config/dot/quickshell/services/Projects.qml"
findings=()
@@ -156,9 +156,9 @@ grep -q 'hl.dsp.window.move' "$helper" \
# ── The page ─────────────────────────────────────────────────────────────────
grep -q 'Projects.projects' "$desktop_page" \
|| note 'the Desktop page does not list saved projects'
grep -q 'confirmingProject' "$desktop_page" \
grep -q 'Projects.projects' "$workspaces_page" \
|| note 'the Workspaces page does not list saved projects'
grep -q 'confirmingProject' "$workspaces_page" \
|| note 'a project can be deleted without confirming'
grep -q '"list"' "$service" \
|| note 'the settings service never reads what is saved'