Clipboard history + snippet manager, written in Rust. One codebase, built for Linux (Wayland — KDE Plasma 6 / KWin; X11 sessions are not supported) and for Windows.
Everything the OS is asked for sits behind three traits, and each platform supplies one implementation:
| Linux | Windows | |
|---|---|---|
| Global hotkeys | XGrabKey on the XWayland root window |
RegisterHotKey |
| Clipboard changes | wl-clipboard-watch, arboard polling as fallback |
WM_CLIPBOARDUPDATE |
| Paste keystroke | /dev/uinput |
SendInput |
| Single instance | abstract unix socket | named mutex |
Everything above fastpaste-platform is platform-independent; adding an
OS means adding an implementation there, not a fork.
Linux only — the global hotkeys are not truly global. They are grabbed with
XGrabKeyon the XWayland root window, and under a Wayland compositor keyboard input only reaches XWayland while an X11/XWayland window has focus. With a Wayland-native window focused the keystroke never reaches us and goes to that application instead.On a current Plasma 6 desktop most applications are Wayland-native (Chrome and Chromium run that way whenever
--ozone-platform=waylandis in effect), so in practice the shortcuts fire rarely. Making them work everywhere needs a different mechanism — theorg.freedesktop.portal.GlobalShortcutsportal, or delegating the shortcut to KDE and giving the app an IPC entry point. Until then, the tray icon and the main window are the reliable ways in.Windows does not have this problem:
RegisterHotKeyis honoured whatever has focus.
Selection dialog — the quick-paste popup. Type to filter, arrow to a row, Enter pastes. Saved snippets first, clipboard history last, each group under its own heading.
Main window — snippet/folder tree with an editor pane. The clipboard history appears as a virtual folder alongside the real ones.
Options — applied live; a rejected hotkey is reported in the dialog rather than silently reverted.
- Main window: snippet/folder tree (CRUD) with an editor pane and toolbar. Double-click or Enter on a row pastes it. Deleting a folder — which removes its whole subtree, with no undo — asks first
- Selection dialog: keyboard-driven quick-paste popup over the clipboard history and the snippet library, with type-to-filter and wrapping arrow-key navigation. PageUp/PageDown move by a screenful; Home/End jump to the ends while the filter box is empty (once you have typed a filter they move the text cursor instead)
- Options dialog: general / hotkeys / clipboard-history / paste settings, applied live (hotkey changes re-register immediately). A rejected hotkey is reported in the dialog and leaves the other changes intact
- Global hotkeys (defaults, configurable):
Ctrl+Alt+V→ selection dialog,Ctrl+Alt+M→ main window; layout-independent physical-keycode grabs that work under any keyboard layout. Letters, digits,F1-F12, and named keys (Space,Tab,Esc,Home, arrows, punctuation…) are accepted, with at least one modifier. See the limitation above for when they actually fire, and note that KDE's own Klipper may already holdCtrl+Alt+V— the options dialog reports that case as "already claimed by another application" - Clipboard history folder: bounded ring populated by watching the system clipboard (wlr-data-control), rendered as a virtual folder in the tree
- Paste via
/dev/uinputCtrl+V emulation, with the previous clipboard contents restored afterwards - System tray icon with context menu (Slint native
SystemTrayIcon) - Localization: English, Russian, German, Spanish, Simplified Chinese
- Single-instance guard (abstract unix socket, kernel-released on exit)
The app starts and stays useful when a piece of the desktop is missing:
| Missing | Effect |
|---|---|
/dev/uinput |
Paste puts the payload on the clipboard and leaves it there for a manual Ctrl+V; nothing else changes |
| XWayland / X connection | Global hotkeys do not fire at all (as opposed to firing only over X11 windows — see the limitation at the top). The tray, main window and clipboard history all still work. A connection that drops later is reconnected automatically (up to 5 attempts, 500 ms apart), replaying the registered grabs; past that the hotkeys are inert until the app is restarted |
| System tray | The app quits when the main window is closed, rather than staying resident with no way to reach it |
A readable config.toml |
The unreadable file is moved to config.toml.bak and defaults are used, rather than failing to start |
| A decodable database row | The row is skipped and logged; the rest of the library still loads |
- Rust 1.95+ (rustup recommended)
libfontconfig1-dev(required by Slint):sudo apt install libfontconfig1-dev- A Wayland session (KDE Plasma 6 / KWin target) with XWayland available (global hotkeys grab via XGrabKey on the XWayland root window).
- For paste via
/dev/uinput: the user must have read/write access to/dev/uinput. systemd-logind typically grants this via ACL on login for physical sessions; check withgetfacl /dev/uinput.
cargo build --release
The app needs a compositor to run, which makes "does this layout look
right" hard to answer on a build machine. examples/ui_preview.rs
renders a window with representative data straight to a pixel buffer via
Slint's software renderer — no display, no compositor:
cargo run --example ui_preview -- options 2 out.ppm # options dialog, page 2
cargo run --example ui_preview -- main 0 out.ppm # main window
cargo run --example ui_preview -- main 1 out.ppm # …with the delete confirmation
cargo run --example ui_preview -- select 0 out.ppm # selection dialog
cargo run --example ui_preview -- select 0 out.ppm en # …seeded in English
ffmpeg -i out.ppm out.png # P6 -> PNG
It seeds the longest shipped locale (Russian) by default, because label
clipping and column overflow only show up in the widest strings. Pass
en as the fourth argument for English — that is how the screenshots
above are produced. The example
pulls in slint's software-renderer-systemfonts feature as a
dev-dependency, so the shipped binary is unaffected — but it does
make cargo test builds larger.
cargo run --release --bin fastpaste-gui
Four crates layered bottom-up. app imports data + platform; gui
imports all three (it uses data types and platform hotkey ids directly,
not only through app):
No GUI, no platform code, no I/O beyond SQLite.
item.rs—Item/ItemKind: the snippet/folder value type that crosses all layers (a plainClonestruct — it ownsStrings, so notCopy— with serde + chrono timestamps)database.rs—Database: SQLite CRUD for items and folders. Owns the tree invariant:update/move_to_parentreject aparent_idthat is the item itself, one of its own descendants, or a row that does not exist, and every recursive query usesUNIONso a cycle that reached the file some other way still terminateserror.rs—DataError, the layer's error enummigrations/— refinery SQL migrations (V001__initial_schema.sql)
Everything that touches the OS/desktop environment:
hotkey.rs—GlobalHotkeytrait +X11GlobalHotkey(XGrabKey on the XWayland root window, layout-independent physical keycodes), and theOPEN_DIALOG_ID/OPEN_MAIN_WINDOW_IDaction ids. The reader thread owns the X connection, reconnects when the server goes away, and exits cleanly when the backend is dropped. Registering one id never disturbs another's live grab, so the two shortcuts can be swapped in one applyclipboard.rs—Clipboardtrait +ArboardClipboard(read/write via data-control) and thewl-clipboard-watch-based change watcher with an arboard-polling fallback. Own writes are announced by content with a short TTL (suppress_text), so an announced write that never lands — or that the polling fallback never observes — expires instead of swallowing the user's next real copyuinput.rs—EvdevUinputCtrlV/NullUinputCtrlV:/dev/uinputCtrl+V emulation, degrading gracefully when the device is unavailabletake_once.rs—TakeOnceChannel, a one-shot hand-off primitive used by the hotkey event path
Orchestrates data + platform into long-lived services:
context.rs—AppContext: composition root bundling every service behindArcfor the UI controllers, plus the single-instance guard — acquired first, before the database is opened or any device claimed.AppContext::newtakes the services directly (the seam the tests use);buildis the production wrapper that resolves XDG paths and opens the real backends.set_settingsowns the "every field applies live" contractsettings.rs—Settings: typedconfig.tomlload/save (confy), grouped into general / hotkeys / clipboard-history / paste sectionsclipboard_history.rs—ClipboardHistory: the bounded ring fed by the clipboard watcher, exposed as a virtual folder.max_itemsresizes live, trimming from the oldest endpaster.rs—Paster: the paste sequence (snapshot → set clipboard → delay → Ctrl+V via uinput → restore). Whole sequences are serialised, the restore runs even when the keystroke fails, and it is skipped when no keystroke was sent at all (so the manual-Ctrl+V fallback has something left to paste)paths.rs— single source of truth for XDGconfig_dir/data_dir
ui/main_window.slint— snippet tree + editor + toolbarui/selection_dialog.slint— the quick-paste popupui/options_dialog.slint— settings dialogui/tray_icon.slint—FastpasteTray(nativeSystemTrayIcon+ menu)ui/widgets.slint— compact in-house widget set + design tokensui/translations.slint— global singleton carrying every UI stringsrc/tree_builder.rs— flattensItemrows + history entries into theTreeItemlist consumed by the TreeViewsrc/i18n.rs— fluent runtime (embedded.ftl, per-locale fallback, live language switch)src/main.rs— controllers wiring Slint callbacks/hotkey events toAppContext. Editor changes are debounced (~300 ms) into one write instead of one per keystroke, and flushed on selection change and on close; the editor is seeded from the database rather than the tree model, which lags a renamesrc/ui_state.rs— thread-local keep-alive slots for the windows and the tray.hide()drops the only other strong handle, so without these a hidden window would be freed and rebuilt on every reopenbuild.rs—slint_buildcompilation of the.slintfiles
The two defaults differ by the key (V / M), not by a modifier.
That is deliberate. A pair that differs only by Shift is fragile: on many
layouts Alt+Shift is the layout switcher, a compositor can claim the
longer combination before it reaches the app, and anything that
normalises Shift away collapses the two into one — which shows up as
"the four-key combination triggers the three-key action". Two different
keys cannot collapse that way.
Note that KDE's own Klipper may already hold Ctrl+Alt+V. The options
dialog reports that case as "already claimed by another application"
rather than silently reverting the field.
If a shortcut behaves unexpectedly, ask the app what it actually received:
RUST_LOG=fastpaste_platform=debug cargo run --bin fastpaste-gui
Every registration is logged with its resolved keycode and modifier mask, and every fire is logged with the id that claimed it plus the raw modifier state that arrived. If the state that arrives does not match the mask that was registered, the compositor or the keymap changed it on the way.
| What | Path |
|---|---|
| Settings | ~/.config/fastpaste/config.toml |
| Database | ~/.local/share/fastpaste/fastpaste.sqlite |
The config file is created on first save with these defaults. Missing fields fall back to their defaults, so a config written by an older version keeps loading after upgrades:
[general]
language = "system" # "system" follows the OS locale; or a BCP-47 tag
[hotkeys]
open_dialog = "Ctrl+Alt+V"
open_main_window = "Ctrl+Alt+M"
[clipboard_history]
enabled = true
max_items = 10 # clamped to 1..=500
position = "bottom" # "top" | "bottom"
[paste]
delay_ms = 70 # pause between clipboard set and Ctrl+V; clamped to 0..=5000
restore_clipboard = true # put the previous clipboard back after pasteNumeric values are clamped to the ranges above on load, so a hand-edited
typo cannot turn into a failed allocation or a wedged paste worker. A
file that cannot be parsed at all is moved aside to config.toml.bak
and defaults are used — the app never fails to start over its config.
Versions used by more than one crate are pinned in the workspace root
Cargo.toml ([workspace.dependencies]) and pulled in via
dependency.workspace = true. Single-consumer dependencies
(rusqlite, refinery, directories, confy, single-instance, …)
are pinned in the crate that owns them.
| Crate | Used by | Purpose |
|---|---|---|
slint |
gui | Declarative UI toolkit: main window, dialogs, system tray |
slint-tree-view |
gui | Slint TreeView component for the snippet tree |
slint-build |
gui (build) | Compiles .slint files at build time; experimental-module-builds feature resolves import { TreeView } from "@TreeView" against the library crate |
fluent-templates |
gui | Fluent static loader: embeds the .ftl files at compile time, per-key fallback chain to English |
sys-locale |
gui | Resolves the "system" language setting from the OS locale |
fluent-syntax |
gui (dev) | Parses .ftl sources in the key-parity test |
tracing |
platform, app, gui | Structured logging |
tracing-subscriber |
gui | Log output with RUST_LOG env-filter support |
anyhow |
app, gui | Ergonomic error handling at the service/main level |
thiserror |
data, platform, app | Derive error enums for each layer's error type |
rusqlite (features bundled, chrono) |
data | SQLite driver; bundled builds the C library in, chrono gives native DateTime<Utc> ↔ TEXT conversion |
refinery |
data | SQL migrations (fastpaste-data/migrations/) |
serde |
data, app | Serialization: Item rows and config.toml |
chrono |
data, app, gui (dev) | Timestamps: RFC 3339 in the DB, history entry times |
directories |
app | XDG data_dir/config_dir resolution |
confy |
app | TOML config load/save |
single-instance |
app | Single-instance guard: abstract unix socket keyed on the data dir (no lock file, kernel-released on exit) |
arboard (feature wayland-data-control) |
platform | Clipboard read/write (set_text/text): native data-control protocol on Wayland — the same transport as the watcher — with arboard's X11 backend as fallback when the compositor offers no data-control |
wl-clipboard-watch |
platform | Event-driven clipboard change detection (ext/wlr-data-control); the platform layer falls back to arboard polling when neither protocol is available |
evdev |
platform | /dev/uinput virtual keyboard to emit Ctrl+V; KeyCode: FromStr also resolves hotkey key names to physical kernel keycodes |
x11rb |
platform | XGrabKey global hotkeys on XWayland — physical keycodes, layout-independent (the global-hotkey crate resolves keysyms via the core keymap and fails under Cyrillic layouts); GetModifierMapping resolves real Alt/NumLock/Super modifier bits |
libc |
platform | poll(2) for the hotkey reader's blocking loop (wakes on X traffic or a wake-pipe byte); already a transitive dep of x11rb |
tempfile |
data, app (dev) | Temp directories in tests |
serde_json |
data (dev) | Serde round-trip asserts in tests (enum #[serde(...)] attributes) |
Version constraints worth knowing when upgrading:
slint,slint-build, andslint-tree-vieware all pinned in the workspace root[workspace.dependencies]and must stay on compatible versions — the TreeView library exports.slinttypes compiled against the same Slint compiler.fluent-templates0.15 (fluent-bundle 0.16, fluent-syntax 0.12, unic-langid 0.9) and the dev-onlyfluent-syntax0.12 share the sameunic-langid-implmajor; mixing older majors conflicts.wl-clipboard-rs(pulled in transitively by arboard'swayland-data-controlfeature) exposes no watch API in any published version — its watch surface lives only in the separatewl-clipboard-rs-toolsbinary (verified against docs.rs, 2026-08). Clipboard watching therefore stays onwl-clipboard-watch; don't "simplify" it away in favor of the transitivewl-clipboard-rs.
MIT — see LICENSE.


