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:
nevaforget committed 2026-08-10 17:04:39 +02:00
1 parent b5fabbc0be
commit de142ef73f
7 files changed
+330 -7

No files matched your search

+8 -2
View File
@@ -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.
+6
View File
@@ -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.
+2 -2
View File
@@ -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. |
+1 -1
View File
@@ -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
}
}
}
+15 -2
View File
@@ -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
+1
View File
@@ -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