Files

376 lines
15 KiB
QML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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: []
// The order the strip is drawing, as rows the Repeater can identify. A
// plain array of ids is a NEW model every time the order changes, and
// reordering is the one thing this strip does: handing that to a Repeater
// resets it and destroys the delegate the pointer is holding, mid-press, on
// the first cell crossed. The gesture dies there and draggingIndex stays
// set, which latches the strip. Keyed on the pin id -- the only thing a pin
// is -- a reorder becomes a row move instead, and the delegate under the
// hand survives it. One-property objects because ScriptModel identifies a
// row by a property of it, which a bare string has none of.
readonly property var displayed: (root.draggingIndex >= 0 ? root.workingOrder : root.pinned)
.map(id => ({ id: id }))
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);
}
// The drag let go of without a decision. Used when the cell being dragged
// is destroyed under the pointer -- an unpin from somewhere else, a
// settings file rewritten underneath -- where the working order is a
// rearrangement of a list that no longer exists, and writing it back would
// undo whatever really happened.
function cancelDrag(): void {
root.draggingIndex = -1;
root.workingOrder = [];
}
// ── Keyboard ────────────────────────────────────────────────────────────
//
// Which POSITION has the keyboard, remembered here rather than left to the
// delegate that happens to hold focus. Unpinning really does destroy the
// focused cell, and a keyed model still cannot keep focus on a row that is
// gone; remembering the index means the cell that lands on it takes focus
// back as it arrives, so Delete twice in a row deletes twice.
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 {
// Keyed on the pin id: see `displayed`. This is the same reason
// DockBody keys the dock's own icons.
model: ScriptModel {
values: root.displayed
objectProp: "id"
comparisonMode: ObjectComparison.Structure
}
delegate: Item {
id: cell
required property var modelData
required property int index
readonly property string appId: cell.modelData ? cell.modelData.id : ""
readonly property bool dragging: root.draggingIndex === cell.index
readonly property string appName: root.nameFor(cell.appId)
readonly property string iconSource: root.iconFor(cell.appId)
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()
// A cell that goes away while it is the one being dragged takes
// the release with it -- there is no MouseArea left to report
// one -- and the strip would sit there with draggingIndex still
// set, refusing every later press.
Component.onDestruction: if (cell.dragging) root.cancelDrag()
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
}
}