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
227 lines
7.7 KiB
QML
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;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|