feat(quickshell): replace walker's clipboard picker
Super+C opened walker's clipboard provider — the last keybind that still reached walker at all. ClipboardPopout takes over and shells out to cliphist: list on every open (cliphist has no change signal, so the open is the refresh point), decode | wl-copy on Enter or click, and delete with the entry's raw list line on stdin. Every call carries -db-path, because the history lives in the session db that cliphist-text.service creates, not in cliphist's ~/.cache default; every shell argument is positional, so no clipboard content becomes part of a script. Delete is Ctrl+D, not Del: focus sits in the search field, where Del is a character delete. It is also what walker's provider used. A footer hint spells the keys out, since a destructive shortcut is not guessable. Image entries have no text preview, only a "[[ binary data 6 KiB png 128x128 ]]" placeholder. That placeholder now yields both the thumbnail extension and a readable "png · 128x128 · 6 KiB" label, and the entry is decoded to $XDG_RUNTIME_DIR/moonarch-clip for an Image to load — one decode process per row, so rows do not serialize. The directory is tmpfs, so thumbnails expire with the session like the history itself. The popout is keybind-only, but register() needs an on-screen item for the anchor rect, so it registers from Launcher.qml and borrows that one. That also leaves shell.qml — and therefore every user copy of it — unchanged. walker stays installed: moonarch-sink-switcher, moonarch-cpugov and moonarch-vpn use it as a dmenu, and those are live in the Waybar reserve config.
This commit is contained in:
1 parent
b5fabbc0be
commit
de142ef73f
7 files changed
+330
-7
No files matched your search
@@ -92,9 +92,15 @@ quickshell is the **default bar and notification daemon**, spawned by niri as `q
|
||||
|
||||
- **Own popouts:** a composed widget carries its own popout as a `Component` and renders it in the shared morphing host. Pattern (see any indicator, e.g. `AudioIndicator.qml`): an inline `Component { AudioPopout { popouts: root.popouts } }`; the widget declares a `popoutName` and registers on completion (`popouts.register(popoutName, popoutComponent, root, payloadFn)`), then its click handler calls `popouts.toggleByName(popoutName)`. The widget file needs `pragma ComponentBehavior: Bound` so the popout resolves `root` when the shared host instantiates it. The low-level `popouts.open(component, rect, payload)` still exists for non-registered popouts (Tray). The host anchors the popout body to the trigger's on-screen box and picks the side from it: **left-aligned** when the trigger sits in the left half of the screen (so left-cluster popouts grow rightward and stay on-screen), **right-aligned** otherwise (`PopoutHost.qml` `frame.leftAnchored`). The anchor rect carries the trigger's left edge + width; both `openByName` and Tray's direct `open()` pass that box.
|
||||
|
||||
- **Popouts by keybind:** registered popouts are addressable by name through the `PopoutRouter` singleton, which exposes an `IpcHandler { target: "popout" }` with `toggle`/`open`/`close`. A niri keybind fires `qs -c moonarch ipc call popout toggle <name>` (e.g. `Mod+A` → `audio`, in `defaults/xdg/niri/config.kdl`). The router routes to the focused monitor's `PopoutState` via `Niri.activeOutput` (workspace `is_focused` → `output`, matched against `screen.name`); an open popout grabs the keyboard (Escape closes). Names: `audio backlight network bluetooth nightlight cpugov battery notifications media launcher camera calendar`. Tray is not keybind-addressable.
|
||||
- **Popouts by keybind:** registered popouts are addressable by name through the `PopoutRouter` singleton, which exposes an `IpcHandler { target: "popout" }` with `toggle`/`open`/`close`. A niri keybind fires `qs -c moonarch ipc call popout toggle <name>` (e.g. `Mod+A` → `audio`, in `defaults/xdg/niri/config.kdl`). The router routes to the focused monitor's `PopoutState` via `Niri.activeOutput` (workspace `is_focused` → `output`, matched against `screen.name`); an open popout grabs the keyboard (Escape closes). Names: `audio backlight network bluetooth nightlight cpugov battery notifications media launcher camera calendar clipboard`. Tray is not keybind-addressable.
|
||||
|
||||
- **Launcher:** `Launcher.qml` is the first widget in the left cluster (an apps glyph) and owns `LauncherPopout.qml` — an application launcher over `DesktopEntries.applications` (search field + keyboard-navigable icon+name list; ↑/↓ select, Enter/click launch via `entry.execute()`, Esc close; app icons via `Quickshell.iconPath`). It registers under `launcher` (so it is IPC-addressable) and is bound to `Mod+Space` (`qs -c moonarch ipc call popout toggle launcher`, in `config.kdl`) in addition to icon-click. Scope is app-launch (drun) only; `walker` stays installed and keeps the other modes — `Super+C` still opens walker's clipboard, and walker remains the run-command/calc launcher (no longer on a keybind).
|
||||
- **Launcher:** `Launcher.qml` is the first widget in the left cluster (an apps glyph) and owns `LauncherPopout.qml` — an application launcher over `DesktopEntries.applications` (search field + keyboard-navigable icon+name list; ↑/↓ select, Enter/click launch via `entry.execute()`, Esc close; app icons via `Quickshell.iconPath`). It registers under `launcher` (so it is IPC-addressable) and is bound to `Mod+Space` (`qs -c moonarch ipc call popout toggle launcher`, in `config.kdl`) in addition to icon-click. Scope is app-launch (drun) only; `walker` stays installed and keeps the other modes (run-command, calc, files), but has no keybind of its own left — what still calls it is dmenu duty for the Waybar-reserve scripts (`moonarch-sink-switcher`, `moonarch-cpugov`, `moonarch-vpn`), which is why the package cannot be dropped.
|
||||
|
||||
- **Clipboard history:** `ClipboardPopout.qml` (registered as `clipboard`, bound to `Super+C`) replaces walker's clipboard provider. Backend stays **cliphist** — the popout shells out to it: `list` on every open (cliphist has no change signal, so the open *is* the refresh point), `decode <id> | wl-copy` on Enter/click, and `delete` with the entry's raw list line on stdin for **Ctrl+D**. Every call carries `-db-path $XDG_RUNTIME_DIR/cliphist/db`, because the history lives in the session db that `cliphist-text.service` creates, not in cliphist's `~/.cache` default. All shell arguments are passed positionally (`sh -c '… "$1" "$2"' sh a b`), so no clipboard content ever becomes part of a script.
|
||||
- **Ctrl+D, not Del:** focus sits in the search field, where `Del` is a character delete. Same binding walker's clipboard provider used. The popout spells its keys out in a footer hint because a destructive shortcut is not guessable.
|
||||
- **Images** are shown as images: cliphist's preview for them is only a `[[ binary data 6 KiB png 128x128 ]]` placeholder, so the entry is decoded to `$XDG_RUNTIME_DIR/moonarch-clip/<id>.<type>` (extension from the placeholder's type token, so `QImageReader` never has to guess) and rendered in a taller row, with the placeholder recombined into a readable `png · 128x128 · 6 KiB` label. One decode `Process` **per row** — a shared one would serialize the rows — started from the delegate, so only rows that exist decode. Deleting an image entry removes its thumbnail with it; the rest goes with the session, since that directory is tmpfs like the db.
|
||||
- **No bar widget.** The popout is keybind-only, but `PopoutState.register` needs an on-screen item for the anchor rect, so it registers from `Launcher.qml` and unfolds under the launcher glyph. That also keeps `shell.qml` — and every user copy of it — unchanged.
|
||||
- **History does not survive a session**, and that is backend, not popout: the db sits in `XDG_RUNTIME_DIR` (tmpfs) and `cliphist-text.service` runs `cliphist wipe` as `ExecStartPre`.
|
||||
|
||||
- **Calendar:** clicking the clock opens `CalendarPopout.qml` (registered as `calendar`) — a `MonthGrid` with a `WeekNumberColumn`, both from `QtQuick.Controls` (already pulled in by `TrayMenu`). Today's cell is an `accent` rectangle with `base` text; leading/trailing days stay visible in `Theme.surface2` because the grid always renders six weeks and hiding them would leave the week-number column pointing at blank rows. Header actions page the month (‹ / ›) and snap back to today (calendar glyph); ←/→ page as well, which no other control in this popout claims. Week start and weekday/month names come from the **system locale** (`Qt.locale()`), so `de_DE` yields Monday-first and ISO week numbers; the title uses `standaloneMonthName` because some locales inflect the month inside a full date. The popout instance survives reopens, so `onShownChanged` snaps back to the current month on every open. Two implementation notes: the clock's root is a `Text`, whose `implicitHeight` is **read-only** (derived from content) — the full bar height goes on `height`, which is what keeps the topmost pixels clickable and anchors the popout below the bar; and a derived `Component.onCompleted` **adds** a handler rather than replacing the base one (verified with an exit-code probe), so `PopoutPanel`'s `focusFirst` still runs alongside the popout's own init. The clock's former click action (long/short date toggle) was dropped — the popout and the tooltip both carry the full date.
|
||||
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# Decisions
|
||||
|
||||
## 2026-08-10 – Clipboard history moves from walker to a quickshell popout
|
||||
- **Who**: Dominik, ClaudeCode
|
||||
- **Why**: `Super+C` (`spawn "walker" "-s" "clipboard"`) was the last keybind still opening walker; every other menu had already become a quickshell popout, and moonix had dropped walker entirely. Requirements set with it: entries deletable from the picker, history must not survive a session, images shown as images if possible.
|
||||
- **Tradeoffs**: (1) **Delete key** — `Del` was the ask, but focus sits in the search field where `Del` is a character delete. Options were dropping the search field entirely (`Del` free, but no filtering over up to 750 entries), `Del`-when-query-is-empty (silently mode-dependent), or a modifier. Picked `Ctrl+D` — what walker's clipboard provider already used. A footer hint spells the keys out, since a destructive shortcut is not guessable; no other popout needs one. (2) **Bar widget** — a clipboard glyph next to the launcher would have given the popout its own anchor item (every other popout is a widget), but Dominik chose keybind-only: it registers from `Launcher.qml` and borrows that anchor, which also leaves `shell.qml` and therefore every user copy of it untouched. Cost: the popout unfolds under the launcher glyph, which is not its own trigger. (3) **Images** — the only path to a thumbnail is decoding the entry to a file and pointing an `Image` at it; QML has no load-from-bytes. Chosen over base64 data URIs (unverified in this Qt build, and a multi-MB screenshot becomes a multi-MB string). One decode process **per row** rather than a shared queue, so rows don't serialize; `sourceSize.height` caps the decode. Files land in `$XDG_RUNTIME_DIR/moonarch-clip` — tmpfs, so they expire with the session like the db, and a popout-driven delete drops the thumbnail with its entry. (4) **No persistence work at all** — requirement 3 was already satisfied and was verified rather than rebuilt: the db is at `%t/cliphist/db` (`/run/user/1000`, `findmnt` confirms tmpfs) and `cliphist-text.service` runs `cliphist wipe` as `ExecStartPre`. (5) **walker stays installed** — it has no keybind left, but `moonarch-sink-switcher`, `moonarch-cpugov` and `moonarch-vpn` use it as a dmenu, and those are live in the Waybar reserve config. Removing the package would break the reserve bar.
|
||||
- **How**: New `ClipboardPopout.qml` built on the `LauncherPopout.qml` skeleton (fixed size, search field, keyboard-navigable `ListView`, `focusFirst`), with cliphist processes instead of `DesktopEntries`: `list` on every open (cliphist has no change signal, so the open is the refresh point), `decode <id> | wl-copy` on Enter/click, `printf '%s\n' <line> | cliphist delete` on Ctrl+D, all with `-db-path $XDG_RUNTIME_DIR/cliphist/db` and all shell arguments passed positionally so no clipboard content becomes part of a script. Delete gets the **raw** list line — measured against cliphist 0.7.0: `delete` reads that line from stdin, and previews are safe to split at the single tab because cliphist collapses newlines and tabs inside them. Image rows parse the `[[ binary data 6 KiB png 128x128 ]]` placeholder into both the thumbnail extension and a readable `png · 128x128 · 6 KiB` label. `Launcher.qml` registers `clipboard` alongside `launcher`; `qmldir` regenerated; `Super+C` in `config.kdl` now fires `qs -c moonarch ipc call popout toggle clipboard` and gained a hotkey-overlay title. Verified on a repo instance (`qs -p .`, loads clean, qmllint clean apart from the pre-existing `QProcess::ExitStatus` warning class): list matches `cliphist list`, refresh-on-open picks up a newly copied entry, a copied PNG renders as a thumbnail with the parsed label. Not machine-verifiable here (no `wtype`/`ydotool` installed): typing/filtering, Enter-copy and Ctrl+D-delete needed a human at the keyboard.
|
||||
|
||||
## 2026-07-28 – Quickshell bar: moduleHeight as the primary vertical token, unified glyph centering
|
||||
- **Who**: Dominik, ClaudeCode
|
||||
- **Why**: Bar height was to drop from 40 to 36. `barHeight` was the primary geometry token and `ModuleBox` derived its pill height from it (`barHeight - moduleMarginV * 2`), so lowering the bar shrank every module pill by the same amount — the margin is an absolute value and does not scale. At 30 px that left 14 px pills for a 13 px font. Investigating the result surfaced a second, pre-existing defect: module glyphs were not vertically centered. Measured against `30c9b55`, the network and bluetooth glyphs sat 1 px above the bar mid, and the nightlight/cpugov/idle/mpris/volume/backlight glyphs were off by 0.5 px.
|
||||
|
||||
@@ -18,13 +18,13 @@ desktop that can be rebuilt from scratch in minutes.
|
||||
| **Power Menu** | [moonset](https://gitea.moonarch.de/nevaforget/moonset) | GTK4 Layer Shell overlay above Waybar. Lock, logout, hibernate, reboot, shutdown with confirmation. |
|
||||
| **Idle Manager** | [stasis](https://aur.archlinux.org/packages/stasis) + [wayland-pipewire-idle-inhibit](https://github.com/rafaelrc7/wayland-pipewire-idle-inhibit) | Separate AC/battery power plans. Brightness dimming, DPMS, lock (via moonlock), and suspend on configurable timeouts. The companion inhibitor holds a Wayland idle-inhibitor while audio plays — keeps the screen awake during windowed browser video, which stasis' pactl detection skips by design. |
|
||||
| **Bar** | [Waybar](https://github.com/Alexays/Waybar) | Wayland-native, highly customizable. Niri workspace/window modules via community plugins. |
|
||||
| **Launcher** | [Walker](https://github.com/abenz1267/walker) + [Elephant](https://github.com/abenz1267/elephant) | Wayland-native GTK4 launcher with built-in providers for apps, clipboard, bluetooth, audio, files, and calculator. Dmenu mode for custom scripts (VPN, CPU governor). |
|
||||
| **Launcher** | [Walker](https://github.com/abenz1267/walker) + [Elephant](https://github.com/abenz1267/elephant) | Wayland-native GTK4 launcher with built-in providers for apps, bluetooth, audio, files, and calculator. Dmenu mode for custom scripts (VPN, CPU governor). |
|
||||
| **Terminal** | [Foot](https://codeberg.org/dnkl/foot) | Fast, minimal Wayland-native terminal. Server mode for instant window spawning. |
|
||||
| **Shell** | Zsh | Programmable completion, FZF integration, syntax highlighting. |
|
||||
| **Audio** | PipeWire | Drop-in replacement for PulseAudio/JACK with lower latency. RNNoise input denoising configured out of the box. |
|
||||
| **Display Management** | [kanshi](https://sr.ht/~emersion/kanshi/) + [wdisplays-persistent](https://github.com/sfs-pra/wdisplays) | kanshi auto-switches output profiles on hotplug. wdisplays-persistent saves changes directly to kanshi config. |
|
||||
| **Notifications** | [swaync](https://github.com/ErikReider/SwayNotificationCenter) | GTK4 notification daemon with DND toggle, grouping, MPRIS support. Fractional scaling via native GTK4. |
|
||||
| **Clipboard** | [cliphist](https://github.com/sentriz/cliphist) + Walker | Clipboard history stored in runtime dir, wiped on session start. Walker provides the picker UI. |
|
||||
| **Clipboard** | [cliphist](https://github.com/sentriz/cliphist) + quickshell | Clipboard history stored in runtime dir, wiped on session start. The quickshell bar provides the picker (`Super+C`) with search, image thumbnails, and per-entry delete. |
|
||||
| **GTK Theme** | Colloid-Grey-Dark-Catppuccin | Catppuccin Mocha palette, grey accent, explicit dark variant. |
|
||||
| **Icons** | Colloid-Grey-Catppuccin-Dark | Catppuccin-colored icon set matching the GTK theme. |
|
||||
| **Cursor** | Sweet-cursors | Visible on dark backgrounds without clashing. |
|
||||
|
||||
@@ -146,7 +146,7 @@ window-rule {
|
||||
binds {
|
||||
Mod+Shift+Slash { show-hotkey-overlay; }
|
||||
|
||||
Super+C hotkey-overlay-title=null { spawn "walker" "-s" "clipboard"; }
|
||||
Super+C hotkey-overlay-title="Clipboard History: quickshell popout" { spawn "qs" "-c" "moonarch" "ipc" "call" "popout" "toggle" "clipboard"; }
|
||||
|
||||
Alt+W hotkey-overlay-title="Toggle bar: quickshell / Waybar" { spawn-sh "if pkill -x quickshell; then moonarch-waybar & else killall waybar; quickshell -c moonarch & fi"; }
|
||||
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
// ABOUTME: Clipboard history popout — a search field over the cliphist history with a keyboard-navigable
|
||||
// ABOUTME: list; Enter/click copies an entry back to the clipboard, Ctrl+D deletes it from the history.
|
||||
pragma ComponentBehavior: Bound
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
|
||||
Item {
|
||||
id: root
|
||||
property var popouts: null
|
||||
|
||||
// Fixed size so the frame does not morph on every keystroke (the host sizes from implicit size).
|
||||
readonly property int panelWidth: 460
|
||||
readonly property int rowHeight: 40
|
||||
readonly property int imageRowHeight: 60
|
||||
readonly property int visibleRows: 8
|
||||
|
||||
implicitWidth: col.width
|
||||
implicitHeight: col.height
|
||||
|
||||
property string query: ""
|
||||
|
||||
// The history lives in the session db that cliphist-text.service creates (%t = XDG_RUNTIME_DIR),
|
||||
// not in cliphist's own ~/.cache default — every call has to carry -db-path.
|
||||
readonly property string runtimeDir: Quickshell.env("XDG_RUNTIME_DIR") || ""
|
||||
readonly property string dbPath: root.runtimeDir + "/cliphist/db"
|
||||
// Image entries are decoded to files here so an Image can load them. tmpfs, like the db itself,
|
||||
// so the thumbnails go with the session — nothing to clean up on logout.
|
||||
readonly property string thumbDir: root.runtimeDir + "/moonarch-clip"
|
||||
|
||||
property var entries: []
|
||||
|
||||
// cliphist prints "<id>\t<preview>" per entry and collapses newlines and tabs inside the preview
|
||||
// (verified on cliphist 0.7.0), so a line carries exactly one tab and splits cleanly. The raw line
|
||||
// is kept because `cliphist delete` reads it from stdin — it is not reconstructed from the parts.
|
||||
function parse(text) {
|
||||
return text.split("\n").map(line => {
|
||||
const tab = line.indexOf("\t");
|
||||
if (tab <= 0)
|
||||
return null;
|
||||
const preview = line.slice(tab + 1);
|
||||
// Images have no text preview, only a "[[ binary data 6 KiB png 128x128 ]]" placeholder.
|
||||
// The type token doubles as the thumbnail extension, so QImageReader never has to guess.
|
||||
// The parts are recombined into a readable label ("png · 128x128 · 6 KiB"); the raw preview
|
||||
// stays as the fallback (unparsed placeholder) and as what the search filters on.
|
||||
const m = /^\[\[ binary data (?:(.+?) )?(png|jpe?g|gif|bmp|webp|tiff)(?: (\d+x\d+))? \]\]$/.exec(preview);
|
||||
return {
|
||||
id: line.slice(0, tab),
|
||||
preview: preview,
|
||||
line: line,
|
||||
imageType: m ? m[2] : "",
|
||||
label: m ? [m[2], m[3], m[1]].filter(Boolean).join(" · ") : ""
|
||||
};
|
||||
}).filter(e => e !== null);
|
||||
}
|
||||
|
||||
readonly property var results: {
|
||||
const q = root.query.trim().toLowerCase();
|
||||
if (!q)
|
||||
return root.entries;
|
||||
return root.entries.filter(e => e.preview.toLowerCase().includes(q));
|
||||
}
|
||||
|
||||
Process {
|
||||
id: listProc
|
||||
command: ["cliphist", "-db-path", root.dbPath, "list"]
|
||||
stdout: StdioCollector {
|
||||
id: listOut
|
||||
}
|
||||
onExited: (code, status) => root.entries = code === 0 ? root.parse(listOut.text) : []
|
||||
}
|
||||
function refresh() {
|
||||
if (root.runtimeDir && !listProc.running)
|
||||
listProc.running = true;
|
||||
}
|
||||
|
||||
// Positional args throughout: nothing from an entry's own text is ever part of the shell script.
|
||||
Process {
|
||||
id: copyProc
|
||||
}
|
||||
function copy(entry) {
|
||||
if (!entry || copyProc.running)
|
||||
return;
|
||||
copyProc.command = ["sh", "-c", 'cliphist -db-path "$1" decode "$2" | wl-copy', "sh", root.dbPath, entry.id];
|
||||
copyProc.running = true;
|
||||
if (root.popouts)
|
||||
root.popouts.close();
|
||||
}
|
||||
|
||||
Process {
|
||||
id: delProc
|
||||
onExited: (code, status) => root.refresh()
|
||||
}
|
||||
function remove(entry) {
|
||||
if (!entry || delProc.running)
|
||||
return;
|
||||
// An image entry drops its decoded thumbnail with it; text entries pass an empty path.
|
||||
const thumb = entry.imageType ? root.thumbDir + "/" + entry.id + "." + entry.imageType : "";
|
||||
delProc.command = ["sh", "-c", 'printf "%s\\n" "$2" | cliphist -db-path "$1" delete; if [ -n "$3" ]; then rm -f "$3"; fi', "sh", root.dbPath, entry.line, thumb];
|
||||
delProc.running = true;
|
||||
}
|
||||
|
||||
Process {
|
||||
id: mkdirProc
|
||||
command: ["mkdir", "-p", root.thumbDir]
|
||||
}
|
||||
Component.onCompleted: if (root.runtimeDir)
|
||||
mkdirProc.running = true
|
||||
|
||||
// The host focuses this on open (PopoutHost's focusFirst path) — hand focus to the search field.
|
||||
// Clear and reload first: the host's Loader keeps this instance alive across close/reopen, so both
|
||||
// a stale query and a stale history would otherwise linger. cliphist has no change signal, so the
|
||||
// open is the refresh point.
|
||||
function focusFirst() {
|
||||
input.text = "";
|
||||
root.refresh();
|
||||
input.forceActiveFocus();
|
||||
}
|
||||
|
||||
// Reset the selection to the top whenever the filtered set changes, and keep it inside the list
|
||||
// when a delete shortens it (the deleted row was the last one).
|
||||
onQueryChanged: list.currentIndex = 0
|
||||
onResultsChanged: if (list.currentIndex >= root.results.length)
|
||||
list.currentIndex = Math.max(0, root.results.length - 1)
|
||||
|
||||
Column {
|
||||
id: col
|
||||
width: root.panelWidth
|
||||
leftPadding: 12
|
||||
rightPadding: 12
|
||||
topPadding: 12
|
||||
bottomPadding: 12
|
||||
spacing: 8
|
||||
readonly property int innerWidth: width - leftPadding - rightPadding
|
||||
|
||||
// --- Search field ---
|
||||
Rectangle {
|
||||
width: col.innerWidth
|
||||
height: 34
|
||||
radius: Theme.radius
|
||||
color: Theme.surface0
|
||||
border.width: input.activeFocus ? 1 : 0
|
||||
border.color: Theme.accent
|
||||
|
||||
Row {
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 10
|
||||
anchors.rightMargin: 10
|
||||
spacing: 8
|
||||
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: String.fromCodePoint(0xf0349) // md-magnify
|
||||
color: Theme.subtext0
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize + 2
|
||||
}
|
||||
|
||||
TextInput {
|
||||
id: input
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: parent.width - parent.spacing - 20
|
||||
color: Theme.text
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
clip: true
|
||||
selectByMouse: true
|
||||
activeFocusOnTab: true
|
||||
onTextChanged: root.query = text
|
||||
|
||||
// Placeholder — shown only while empty.
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: input.text.length === 0
|
||||
text: qsTr("Search clipboard…")
|
||||
color: Theme.subtext0
|
||||
font: input.font
|
||||
}
|
||||
|
||||
Keys.onDownPressed: list.incrementCurrentIndex()
|
||||
Keys.onUpPressed: list.decrementCurrentIndex()
|
||||
Keys.onReturnPressed: root.copy(root.results[list.currentIndex])
|
||||
Keys.onEnterPressed: root.copy(root.results[list.currentIndex])
|
||||
Keys.onEscapePressed: if (root.popouts)
|
||||
root.popouts.close()
|
||||
// Delete the selected entry. Ctrl+D and not Del: the focus sits in this field, where
|
||||
// Del is a character delete. Same binding walker's clipboard provider used.
|
||||
Keys.onPressed: event => {
|
||||
if (event.key === Qt.Key_D && (event.modifiers & Qt.ControlModifier)) {
|
||||
root.remove(root.results[list.currentIndex]);
|
||||
event.accepted = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Entries ---
|
||||
Item {
|
||||
width: col.innerWidth
|
||||
height: root.rowHeight * root.visibleRows
|
||||
|
||||
ListView {
|
||||
id: list
|
||||
anchors.fill: parent
|
||||
clip: true
|
||||
model: root.results
|
||||
currentIndex: 0
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
|
||||
delegate: Rectangle {
|
||||
id: entryRow
|
||||
required property var modelData
|
||||
required property int index
|
||||
width: ListView.view.width
|
||||
height: entryRow.modelData.imageType ? root.imageRowHeight : root.rowHeight
|
||||
radius: Theme.radius
|
||||
color: (entryRow.ListView.isCurrentItem || rowHover.hovered) ? Theme.surface0 : "transparent"
|
||||
|
||||
// Thumbnail for an image entry: decoded on demand, one process per row (a shared one
|
||||
// would serialize the rows). Until it arrives the row shows cliphist's placeholder
|
||||
// text, so a slow decode degrades to plain text rather than a blank row.
|
||||
readonly property string thumbPath: entryRow.modelData.imageType ? root.thumbDir + "/" + entryRow.modelData.id + "." + entryRow.modelData.imageType : ""
|
||||
property string thumb: ""
|
||||
|
||||
Process {
|
||||
id: decodeProc
|
||||
command: ["sh", "-c", 'cliphist -db-path "$1" decode "$2" > "$3"', "sh", root.dbPath, entryRow.modelData.id, entryRow.thumbPath]
|
||||
onExited: (code, status) => entryRow.thumb = code === 0 ? entryRow.thumbPath : ""
|
||||
}
|
||||
Component.onCompleted: if (entryRow.thumbPath && root.runtimeDir)
|
||||
decodeProc.running = true
|
||||
|
||||
Row {
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 8
|
||||
anchors.rightMargin: 8
|
||||
spacing: 10
|
||||
|
||||
Image {
|
||||
id: thumbImage
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: entryRow.thumb !== ""
|
||||
width: 64
|
||||
height: root.imageRowHeight - 12
|
||||
// Scale during load — a full-resolution screenshot would otherwise be
|
||||
// decoded at full size for a 48 px row.
|
||||
sourceSize.height: root.imageRowHeight - 12
|
||||
fillMode: Image.PreserveAspectFit
|
||||
source: entryRow.thumb ? "file://" + entryRow.thumb : ""
|
||||
}
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: parent.width - (thumbImage.visible ? thumbImage.width + parent.spacing : 0)
|
||||
text: entryRow.modelData.label || entryRow.modelData.preview
|
||||
elide: Text.ElideRight
|
||||
color: Theme.text
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
}
|
||||
}
|
||||
|
||||
HoverHandler {
|
||||
id: rowHover
|
||||
}
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
onClicked: {
|
||||
list.currentIndex = entryRow.index;
|
||||
root.copy(entryRow.modelData);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Empty state — overlays the (empty) list area.
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
visible: root.results.length === 0
|
||||
text: root.runtimeDir === "" ? qsTr("XDG_RUNTIME_DIR is not set") : (root.query.trim() ? qsTr("No matches") : qsTr("Clipboard is empty"))
|
||||
color: Theme.subtext0
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
}
|
||||
}
|
||||
|
||||
// Key hints — Ctrl+D is destructive and not guessable, so this popout spells its keys out.
|
||||
Text {
|
||||
width: col.innerWidth
|
||||
horizontalAlignment: Text.AlignRight
|
||||
text: qsTr("↑↓ select · Enter copy · Ctrl+D delete · Esc close")
|
||||
color: Theme.subtext0
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize - 2
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,8 +9,14 @@ Item {
|
||||
property string popoutName: "launcher"
|
||||
|
||||
// Register with the shared popout state so this popout is addressable by keybind (niri IPC).
|
||||
Component.onCompleted: if (root.popouts)
|
||||
root.popouts.register(popoutName, popoutComponent, root, null)
|
||||
// The clipboard popout rides along: it has no bar widget of its own (it is keybind-only, Super+C),
|
||||
// but register() needs an on-screen item for the anchor rect, so it borrows this one and unfolds
|
||||
// under the launcher glyph. Keeping it here also keeps shell.qml — and therefore every user copy
|
||||
// of shell.qml — unchanged.
|
||||
Component.onCompleted: if (root.popouts) {
|
||||
root.popouts.register(popoutName, popoutComponent, root, null);
|
||||
root.popouts.register("clipboard", clipboardComponent, root, null);
|
||||
}
|
||||
|
||||
// This widget's popout — handed to the shared animated host on click.
|
||||
Component {
|
||||
@@ -20,6 +26,13 @@ Item {
|
||||
}
|
||||
}
|
||||
|
||||
Component {
|
||||
id: clipboardComponent
|
||||
ClipboardPopout {
|
||||
popouts: root.popouts
|
||||
}
|
||||
}
|
||||
|
||||
implicitWidth: Theme.barHeight
|
||||
implicitHeight: Theme.barHeight
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ BluetoothPopout BluetoothPopout.qml
|
||||
CalendarPopout CalendarPopout.qml
|
||||
CameraIndicator CameraIndicator.qml
|
||||
CameraPopout CameraPopout.qml
|
||||
ClipboardPopout ClipboardPopout.qml
|
||||
Clock Clock.qml
|
||||
CpuGovIndicator CpuGovIndicator.qml
|
||||
CpuGovPopout CpuGovPopout.qml
|
||||
|
||||
Reference in new issue
Block a user