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:
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user