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
+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