aboutsummaryrefslogtreecommitdiffstats
path: root/bookmarks/Bookmarks.qml
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-10-26 13:07:08 +0100
committerDanilo M. <danix@danix.xyz>2026-10-26 13:07:08 +0100
commite320c1e0e4452d9bf4903d64e0dd3e1b3ced95d2 (patch)
tree34847129057c55116bc056f1af61c892342582f3 /bookmarks/Bookmarks.qml
parentf161ba41344c6834f10eaa5064dc02bc9c1cfdf6 (diff)
downloadquickshell-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.qml601
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));
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}