// Copyright (C) 2026 Danilo M. // // 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 `: 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)); } } } } } } } }