376 lines
15 KiB
QML
376 lines
15 KiB
QML
// 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
|
||
}
|
||
}
|