246 lines
8.6 KiB
QML
246 lines
8.6 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"
|
|
grabFocus: true
|
|
|
|
// Whether the dock has asked for the menu. Visibility is that AND a live
|
|
// anchor, rather than the request alone: the dock icon a menu is anchored
|
|
// to is a delegate, and a delegate dies when its app's last window closes.
|
|
// An anchored PopupWindow whose anchor has gone stays mapped with a null
|
|
// anchor -- a menu hanging over the desktop, attached to nothing, holding
|
|
// the dock revealed behind it.
|
|
property bool requested: false
|
|
visible: root.requested && root.anchorItem !== null
|
|
|
|
onAnchorItemChanged: if (root.anchorItem === null) root.requested = false
|
|
|
|
// Read by the Dock's interaction watchdog. A menu with the pointer on it is
|
|
// a menu being read, not a stuck flag -- and the pointer being here means
|
|
// it is not on the dock, which is the only other thing the dock can see.
|
|
readonly property bool hovered: menuPointer.hovered
|
|
|
|
// 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)
|
|
|
|
HoverHandler {
|
|
id: menuPointer
|
|
}
|
|
|
|
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.requested = 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.requested = 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.requested = 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.requested = false;
|
|
}
|
|
}
|
|
|
|
TrayMenuRow {
|
|
width: parent.width
|
|
label: root.windows.length > 1 ? "Quit all windows" : "Quit"
|
|
rowEnabled: root.windows.length > 0
|
|
onActivated: {
|
|
root.quit();
|
|
root.requested = 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.requested = false;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|