Files
Panama/config/dot/quickshell/modules/dock/DockContextMenu.qml
T
Gabriel Brown f8f5b25510 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
2026-08-24 04:28:20 -04:00

227 lines
7.7 KiB
QML

// 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
import qs.services
import qs.widgets
PopupWindow {
id: root
property Item anchorItem: 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.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
color: Theme.alpha(Theme.bgPopover, Theme.popoverAlpha)
border.width: 1
border.color: Theme.alpha(Theme.fg, 0.08)
PrismEdge {
anchors.top: parent.top
anchors.topMargin: 1
anchors.left: parent.left
anchors.right: parent.right
inset: parent.radius
}
Column {
id: menu
anchors.fill: parent
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 : []
delegate: TrayMenuRow {
required property var modelData
width: parent.width
label: modelData.name
onActivated: {
modelData.execute();
root.visible = false;
}
}
}
Rectangle {
width: parent.width
height: 1
anchors.margins: 3
visible: applicationActions.count > 0
border.width: 0
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("dock");
root.visible = false;
}
}
}
}
}