diff options
| author | Danilo M. <danix@danix.xyz> | 2026-10-26 13:07:08 +0100 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-10-26 13:07:08 +0100 |
| commit | e320c1e0e4452d9bf4903d64e0dd3e1b3ced95d2 (patch) | |
| tree | 34847129057c55116bc056f1af61c892342582f3 /bookmarks/Bookmarks.qml | |
| parent | f161ba41344c6834f10eaa5064dc02bc9c1cfdf6 (diff) | |
| download | quickshell-e320c1e0e4452d9bf4903d64e0dd3e1b3ced95d2.tar.gz quickshell-e320c1e0e4452d9bf4903d64e0dd3e1b3ced95d2.zip | |
bookmarks: Firefox bookmarks as a full-width overlay
A new standalone shell, shaped like hosts, on SUPER+b. bookmarks.py reads
the bookmark tree and favicons of the profile a running Firefox holds
(found through the pid in its lock symlink; the last-modified places.sqlite
was tried first and picked the guest profile after a stray launch touched
it). Firefox keeps both databases in exclusive lock mode, so they are
copied with their -wal and the copy is read: an immutable=1 open of the
original skipped the log and kept showing bookmarks deleted minutes before.
The grid spans DP-1, a ListView of card rows so only visible rows exist,
grouped by folder with each folder's own bookmarks before its subfolders.
Six icon buttons (Ctrl+S cycles) sort by folder, newest, title, site, most
visited or link state. The wheel scrolls three rows a notch through a
MouseArea, since Flickable's own step crawled; only the selected card
carries a Glow, through a Loader.
Favicons are extracted by content hash; a page without one takes any icon
Firefox holds for its host, which found 52 more of 504.
bookmarks.py --links (Ctrl+R) checks every http(s) bookmark, HEAD then GET,
32 at a time, in about 22s, and caches the result. Dead means 404/410, an
NXDOMAIN or a refused connection; 403, 429, timeouts, TLS and resolver
failures are only "unsure", because a 32-thread burst produced EAI_AGAIN
that a serial retry resolved.
Bookmarks open with xdg-open: a plain `firefox <url>` does not reach an
instance started with -P and opened the default profile beside it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'bookmarks/Bookmarks.qml')
| -rw-r--r-- | bookmarks/Bookmarks.qml | 601 |
1 files changed, 601 insertions, 0 deletions
diff --git a/bookmarks/Bookmarks.qml b/bookmarks/Bookmarks.qml new file mode 100644 index 0000000..edb1ac5 --- /dev/null +++ b/bookmarks/Bookmarks.qml @@ -0,0 +1,601 @@ +// Copyright (C) 2026 Danilo M. <danix@danix.xyz> +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License version 2 as +// published by the Free Software Foundation. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +import Quickshell +import Quickshell.Io +import Quickshell.Wayland +import QtQuick + +Scope { + id: root + + property bool open: false + property string monitor: "DP-1" + property var items: [] + property string checked: "" + property string query: "" + property bool deadOnly: false + property bool checking: false + property int sortMode: 0 + // Cards per row, set by the grid from its width. + property int columns: 6 + // Index into `flat`. + property int current: 0 + // Non-empty while loading or after a failure; the grid shows only when + // this is empty, so a stale list from the last opening never shows. + property string status: "Loading..." + + readonly property int deadCount: root.items.filter(b => b.state === "dead").length + readonly property int warnCount: root.items.filter(b => b.state === "warn").length + + function host(url) { return url.replace(/^[a-z]+:\/\//, "").split("/")[0]; } + + readonly property var stateRank: ({ dead: 0, warn: 1, ok: 2, "": 3 }) + readonly property var stateName: ({ dead: "Dead", warn: "Unsure", ok: "Fine", "": "Not checked" }) + + // Each sort: its glyph (Inconsolata Nerd Font, checked in its cmap), a + // name for the info line, the header a bookmark falls under ("" for + // none) and a comparison (null keeps Firefox's order). + readonly property var modes: [ + { glyph: "", name: "folder", + group: b => b.folder, cmp: null }, + { glyph: "", name: "newest", + group: b => `${new Date(b.added * 1000).getFullYear()}`, + cmp: (a, b) => b.added - a.added }, + { glyph: "", name: "title", + group: b => /[a-z]/i.test(b.title.charAt(0)) ? b.title.charAt(0).toUpperCase() : "#", + cmp: (a, b) => a.title.toLowerCase() < b.title.toLowerCase() ? -1 + : a.title.toLowerCase() > b.title.toLowerCase() ? 1 : 0 }, + { glyph: "", name: "site", + group: b => "", + cmp: (a, b) => { + const x = root.host(a.url).replace(/^www\./, ""), y = root.host(b.url).replace(/^www\./, ""); + return x < y ? -1 : x > y ? 1 : 0; + } }, + { glyph: "", name: "most visited", + group: b => "", cmp: (a, b) => b.visits - a.visits }, + { glyph: "", name: "link state", + group: b => root.stateName[b.state], + cmp: (a, b) => root.stateRank[a.state] - root.stateRank[b.state] }, + ] + + // The bookmarks that match, in display order. Every word must appear in + // the title, url or folder. + readonly property var flat: { + const words = root.query.toLowerCase().split(/\s+/).filter(w => w); + const out = root.items.filter(b => { + if (root.deadOnly && b.state !== "dead") return false; + const hay = `${b.title} ${b.url} ${b.folder}`.toLowerCase(); + return words.every(w => hay.includes(w)); + }); + const cmp = root.modes[root.sortMode].cmp; + if (!cmp) return out; + // Ties keep Firefox's order, whatever the engine's sort does with them. + const at = new Map(out.map((b, i) => [b, i])); + return out.sort((a, b) => cmp(a, b) || at.get(a) - at.get(b)); + } + onFlatChanged: root.current = 0 + + // What the grid draws: a header row where the group changes, then that + // group's cards, `columns` to a row. rowOf and colOf place each card. + readonly property var layout: { + const rows = [], rowOf = [], colOf = []; + const group = root.modes[root.sortMode].group; + let name = null, row = null; + root.flat.forEach((b, i) => { + const g = group(b); + if (g !== name) { + if (g !== "") rows.push({ header: g }); + name = g; + row = null; + } + if (!row || row.cards.length === root.columns) { + row = { cards: [] }; + rows.push(row); + } + rowOf.push(rows.length - 1); + colOf.push(row.cards.length); + row.cards.push({ b: b, i: i }); + }); + return { rows: rows, rowOf: rowOf, colOf: colOf }; + } + + readonly property var screenObj: + Quickshell.screens.find(s => s.name === root.monitor) ?? Quickshell.screens[0] + + function toggle() { root.open = !root.open; } + function close() { root.open = false; } + + // Keyboard moves scroll the card into view; the pointer only selects, + // so a card half off the edge does not yank the grid under it. + signal reveal() + + function select(i) { + if (i < 0 || i >= root.flat.length) return; + root.current = i; + root.reveal(); + } + + // Up and down keep the column, across group boundaries, landing on the + // last card of a shorter row. + function vertical(step) { + const rows = root.layout.rows; + let r = root.layout.rowOf[root.current], target = -1; + const col = root.layout.colOf[root.current]; + while (step !== 0) { + r += step > 0 ? 1 : -1; + if (r < 0 || r >= rows.length) break; + if (rows[r].header !== undefined) continue; + target = rows[r].cards[Math.min(col, rows[r].cards.length - 1)].i; + step -= step > 0 ? 1 : -1; + } + if (target >= 0) root.select(target); + } + + function openUrl(b) { + if (!b) return; + // Through the desktop's handler, not `firefox <url>`: a plain + // firefox does not reach an instance started with -P, and opened + // the default profile in a new process beside it. firefox.desktop + // runs a wrapper that hands the url to the running profile. + Quickshell.execDetached(["xdg-open", b.url]); + root.close(); + } + + function reload() { + // A Process already running ignores running = true. + proc.running = false; + proc.running = true; + } + + function checkLinks() { + if (root.checking) return; + root.checking = true; + checker.running = true; + } + + // Read afresh on every opening, so a bookmark added in Firefox shows the + // next time. It measured 0.15s for 504 bookmarks, icons included. + onOpenChanged: if (open) { + root.status = "Loading..."; + root.query = ""; + root.reload(); + } + + Process { + id: proc + command: ["/usr/bin/python3", `${Quickshell.shellDir}/bookmarks.py`] + stdout: StdioCollector { + onStreamFinished: { + try { + const d = JSON.parse(text); + root.items = d.items; + root.checked = d.checked; + root.status = ""; + } catch (e) { + root.status = "bookmarks.py gave nothing readable"; + } + } + } + } + + // Takes about 20s over the network, so it runs only when asked and + // its results are cached; the grid shows the last check's. + Process { + id: checker + command: ["/usr/bin/python3", `${Quickshell.shellDir}/bookmarks.py`, "--links"] + onExited: { + root.checking = false; + if (root.open) root.reload(); + } + } + + // A config with no visible window exits, reporting nothing. This 1x1 + // click-through window holds the shell open while the overlay is hidden. + PanelWindow { + anchors { top: true; left: true } + implicitWidth: 1 + implicitHeight: 1 + color: "transparent" + exclusionMode: ExclusionMode.Ignore + mask: Region {} + WlrLayershell.keyboardFocus: WlrKeyboardFocus.None + } + + LazyLoader { + active: root.open + + PanelWindow { + screen: root.screenObj + anchors { top: true; left: true; right: true; bottom: true } + color: "transparent" + exclusionMode: ExclusionMode.Ignore + WlrLayershell.layer: WlrLayer.Overlay + WlrLayershell.namespace: "keystone-bookmarks" + WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive + + Rectangle { + anchors.fill: parent + color: Qt.rgba(Theme.base.r, Theme.base.g, Theme.base.b, 0.82) + + MouseArea { + anchors.fill: parent + onClicked: root.close() + } + + Column { + anchors.fill: parent + anchors.margins: 40 + spacing: 14 + + Rectangle { + width: 720 + anchors.horizontalCenter: parent.horizontalCenter + height: 52 + radius: 14 + color: Theme.surface + + // Keys reach a focused item, not a window, so the + // field takes all of them. + TextInput { + id: field + anchors.fill: parent + anchors.leftMargin: 18 + anchors.rightMargin: 18 + verticalAlignment: TextInput.AlignVCenter + focus: true + activeFocusOnTab: true + color: Theme.text + font.family: Theme.fontFamily + font.pixelSize: 20 + text: root.query + onTextEdited: root.query = text + + Keys.onEscapePressed: root.close() + // The arrows move through the grid, not the + // text: a query is short enough to retype. + Keys.onLeftPressed: root.select(root.current - 1) + Keys.onRightPressed: root.select(root.current + 1) + Keys.onUpPressed: root.vertical(-1) + Keys.onDownPressed: root.vertical(1) + Keys.onPressed: event => { + const ctrl = event.modifiers & Qt.ControlModifier; + if (event.key === Qt.Key_PageUp) root.vertical(-5); + else if (event.key === Qt.Key_PageDown) root.vertical(5); + else if (event.key === Qt.Key_Tab) root.deadOnly = !root.deadOnly; + else if (event.key === Qt.Key_R && ctrl) root.checkLinks(); + else if (event.key === Qt.Key_S && ctrl) + root.sortMode = (root.sortMode + 1) % root.modes.length; + else return; + event.accepted = true; + } + Keys.onReturnPressed: root.openUrl(root.flat[root.current]) + Keys.onEnterPressed: root.openUrl(root.flat[root.current]) + + Text { + anchors.verticalCenter: parent.verticalCenter + visible: field.text === "" + text: "title, address, folder..." + color: Theme.overlay + font: field.font + } + } + } + + Row { + anchors.horizontalCenter: parent.horizontalCenter + spacing: 8 + + Repeater { + model: root.modes + + Rectangle { + id: sortButton + required property var modelData + required property int index + readonly property bool on: root.sortMode === index + width: 32 + height: 32 + radius: 8 + color: sortMouse.containsMouse ? Theme.accent + : on ? Theme.surfaceAlt : Theme.surface + border.width: on ? 1 : 0 + border.color: Theme.accent + + Text { + anchors.centerIn: parent + text: sortButton.modelData.glyph + color: sortMouse.containsMouse ? Theme.base : Theme.text + font.family: Theme.iconFamily + font.pixelSize: 17 + } + MouseArea { + id: sortMouse + anchors.fill: parent + hoverEnabled: true + onClicked: root.sortMode = sortButton.index + } + } + } + + Item { width: 12; height: 1 } + + Repeater { + model: [ + { label: root.deadOnly ? "show all (Tab)" : "dead only (Tab)", + act: () => root.deadOnly = !root.deadOnly, on: root.deadOnly }, + { label: "check links (Ctrl+R)", + act: () => root.checkLinks(), on: root.checking }, + ] + + Rectangle { + id: chip + required property var modelData + anchors.verticalCenter: parent.verticalCenter + width: chipLabel.width + 20 + height: 26 + radius: 13 + color: chipMouse.containsMouse ? Theme.accent : Theme.surface + border.width: chip.modelData.on ? 1 : 0 + border.color: Theme.accent + + Text { + id: chipLabel + anchors.centerIn: parent + text: chip.modelData.label + color: chipMouse.containsMouse ? Theme.base : Theme.text + font.family: Theme.fontFamily + font.pixelSize: 13 + } + MouseArea { + id: chipMouse + anchors.fill: parent + hoverEnabled: true + onClicked: chip.modelData.act() + } + } + } + + Item { width: 12; height: 1 } + + Text { + anchors.verticalCenter: parent.verticalCenter + text: [`${root.flat.length} of ${root.items.length}`, + `by ${root.modes[root.sortMode].name} (Ctrl+S)`, + root.checked ? `${root.deadCount} dead, ${root.warnCount} unsure` : "", + root.checking ? "checking links..." + : root.checked ? `checked ${root.checked}` : "links never checked"] + .filter(s => s).join(" · ") + color: Theme.subtext + font.family: Theme.fontFamily + font.pixelSize: 13 + } + } + + Text { + visible: root.status !== "" + anchors.horizontalCenter: parent.horizontalCenter + text: root.status + color: Theme.text + font.family: Theme.fontFamily + font.pixelSize: 18 + } + + Item { + visible: root.status === "" + width: parent.width + height: parent.height - y + + // One delegate per row of cards, so only the rows on + // screen exist, whatever the bookmark count. + ListView { + id: grid + readonly property int gap: 14 + readonly property real cardWidth: + (width - leftMargin - rightMargin - gap * (root.columns - 1)) / root.columns + anchors.fill: parent + clip: true + model: root.layout.rows + reuseItems: true + cacheBuffer: 800 + boundsBehavior: Flickable.StopAtBounds + // The margins keep the selected card's glow inside the clip. + leftMargin: 16 + rightMargin: 16 + topMargin: 16 + bottomMargin: 16 + onWidthChanged: root.columns = Math.max(1, Math.floor((width - 32 + gap) / (340 + gap))) + + Connections { + target: root + function onReveal() { + const r = root.layout.rowOf[root.current]; + // The group's header too, when moving onto its first row. + if (r > 0 && root.layout.rows[r - 1].header !== undefined) + grid.positionViewAtIndex(r - 1, ListView.Contain); + grid.positionViewAtIndex(r, ListView.Contain); + } + function onFlatChanged() { grid.positionViewAtBeginning(); } + } + + delegate: Item { + id: row + required property var modelData + readonly property bool isHeader: modelData.header !== undefined + width: grid.width - grid.leftMargin - grid.rightMargin + height: isHeader ? 46 : 72 + grid.gap + + Text { + visible: row.isHeader + anchors.bottom: parent.bottom + anchors.bottomMargin: 10 + x: 4 + text: row.modelData.header ?? "" + color: Theme.subtext + font.family: Theme.fontFamily + font.pixelSize: 16 + font.bold: true + } + + Row { + visible: !row.isHeader + spacing: grid.gap + + Repeater { + model: row.modelData.cards ?? [] + + Rectangle { + id: card + required property var modelData + readonly property var b: modelData.b + readonly property bool selected: modelData.i === root.current + readonly property color stateColor: b.state === "dead" ? Theme.red + : b.state === "warn" ? Theme.yellow : "transparent" + width: grid.cardWidth + height: 72 + radius: 12 + color: selected ? Theme.surfaceAlt : Theme.surface + + // Only the selected card carries a glow: a shadow + // on every card, faded out, still costs a pass each. + Loader { + anchors.fill: parent + z: -1 + active: card.selected + sourceComponent: Glow { radius: 12; on: true } + } + + MouseArea { + anchors.fill: parent + hoverEnabled: true + onEntered: root.current = card.modelData.i + onClicked: root.openUrl(card.b) + } + + // The favicon, or the host's initial when + // Firefox holds none. + Rectangle { + id: icon + x: 14 + anchors.verticalCenter: parent.verticalCenter + width: 28 + height: 28 + radius: 7 + color: card.b.icon ? "transparent" : Theme.surfaceAlt + + Image { + anchors.fill: parent + visible: !!card.b.icon + source: card.b.icon ? `file://${card.b.icon}` : "" + sourceSize: Qt.size(56, 56) + fillMode: Image.PreserveAspectFit + asynchronous: true + } + Text { + anchors.centerIn: parent + visible: !card.b.icon + text: root.host(card.b.url).replace(/^www\./, "").charAt(0).toUpperCase() + color: Theme.text + font.family: Theme.fontFamily + font.pixelSize: 14 + font.bold: true + } + } + + Column { + anchors.left: icon.right + anchors.leftMargin: 12 + anchors.right: parent.right + anchors.rightMargin: 14 + anchors.verticalCenter: parent.verticalCenter + spacing: 4 + + Text { + width: parent.width + elide: Text.ElideRight + text: card.b.title + color: Theme.text + font.family: Theme.fontFamily + font.pixelSize: 15 + } + + Item { + width: parent.width + height: 20 + + // The folder when the grid is not grouped by it. + Text { + anchors.left: parent.left + anchors.right: badge.left + anchors.rightMargin: 8 + anchors.verticalCenter: parent.verticalCenter + elide: Text.ElideRight + text: root.host(card.b.url) + + (root.sortMode !== 0 ? ` · ${card.b.folder}` : "") + color: Theme.overlay + font.family: Theme.fontFamily + font.pixelSize: 12 + } + + // Red is gone, yellow is unsure: a 403 or a + // timeout may only be a site refusing scripts. + Rectangle { + id: badge + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + visible: card.b.state === "dead" || card.b.state === "warn" + width: visible ? reason.width + 14 : 0 + height: 20 + radius: 10 + color: "transparent" + border.width: 1 + border.color: card.stateColor + + Text { + id: reason + anchors.centerIn: parent + text: card.b.reason + color: card.stateColor + font.family: Theme.fontFamily + font.pixelSize: 11 + } + } + } + } + } + } + } + } + } + + // Flickable's own wheel step is a few pixels a notch + // and dragged on 500 bookmarks. This scrolls 3 rows of + // cards a notch instead; buttons pass through to the + // cards. A MouseArea, as WheelHandler never fired in + // the island. + MouseArea { + anchors.fill: grid + acceptedButtons: Qt.NoButton + onWheel: wheel => { + const step = wheel.pixelDelta.y !== 0 ? wheel.pixelDelta.y * 2 + : wheel.angleDelta.y / 120 * 3 * (72 + grid.gap); + const top = grid.originY - grid.topMargin; + const bottom = Math.max(top, grid.originY + grid.contentHeight + + grid.bottomMargin - grid.height); + grid.contentY = Math.max(top, Math.min(bottom, grid.contentY - step)); + } + } + } + } + } + } + } +} |
