TR

Architecture (for contributors)

How Snipdeck is built: the single-threaded Slint event loop and the pump that lets it sleep, worker threads and channels, the module map for Windows and Linux, the renderers, and the GNOME extension.

On this page (6 sections)
  1. The big picture
  2. Threads and channels
  3. Module map
  4. The renderers
  5. Process and lifecycle
  6. See also

This page is for contributors and curious users. It explains how Snipdeck is put together: a single-threaded Slint event loop owns all UI, while OS hooks and blocking work run on their own threads and hand results back over channels that the main thread drains. If you want to read the code or add a feature, start here.

The big picture

Everything that touches the UI runs on one thread, the one that owns the Slint event loop. Anything that would block it (capturing the screen, OCR, network requests, file dialogs on Linux, waiting on OS input) runs on another thread and reports back through a channel.

Two rules follow, and the whole codebase keeps them:

  1. UI state changes only on the main thread. Slint windows, models and properties are not Send, so they never leave it.
  2. Work on other threads returns by message, not by shared mutation. A worker sends its result over a channel; the main thread reads it on its next pass.

The pump

The main thread’s housekeeping (draining the channels, opening floating windows, watchdogs) used to run on an 8 ms timer that never stopped, so Snipdeck woke up constantly even while it sat idle in the tray. Now src/pump.rs runs that work only while there is something to do:

  • The 8 ms timer runs while work keeps arriving.
  • After a short idle grace period, the pump stops the timer, and the event loop sleeps until something happens.
  • Every source that can produce work while Snipdeck sleeps (the input hook, the tray, the hotkeys, floating-window callbacks, finished OCR, translation and upload workers, the GNOME extension’s calls) calls pump::wake() after queuing its event. wake() can be called from any thread.

Before it sleeps, the pump runs one more pass, so a wake-up that arrives at that moment is never lost.

// src/main.rs (abridged)
pump::install(std::time::Duration::from_millis(8), move || {
    let mut active = false;
    // drain the tray channel, then:
    if state.tick(&window) {
        active = true;
    }
    active // false lets the pump go to sleep
});

What a tick drains

AppState::tick() in src/app/mod.rs makes the same ordered sweep on every pass:

OrderWhat it handles
1The UI action queue: button clicks and menu picks pushed by Slint callbacks
2Global hotkey events
3Mouse-trigger events: Win+drag selections on Windows
4Errors from finished background work, shown as a banner
5The upload window’s buttons and finished uploads; imported .sxcu hosts
6Snips converted from PNG to WebP in the background
7Linux: calls on the session bus (Super+Alt+drag snips from the GNOME extension, a second launch asking for the window)
8Capture overlay and color picker upkeep, and watchdogs for captures that were started and abandoned
9Floating windows waiting to open
10Floating-window callbacks: drag positions, resize ends, menu and close requests
11Finished OCR + Translate, annotation and collage results; then the UI is updated

One exception: the gallery’s column-count callback is wired directly in main(). During a live window resize on Windows, Win32 runs its own modal loop and Slint’s timers stop, so a queued action would wait until the resize ends.

Threads and channels

Only the main thread touches the UI. The other threads, and how they report back:

WorkerPlatformReports back via
Low-level mouse and keyboard hooks (Win+drag)WindowsMouse-trigger channel
Global hotkeys (on Wayland through the desktop portal)BothHotkeyService::poll()
Session bus service for the GNOME extensionLinuxBus channel, drained in tick
Tray menuBothTrayCommand channel, drained before tick
OCR indexingBothWrites the text to the database; the gallery picks it up later
OCR + TranslateBothTranslatePopup::poll()
UploadBothOutcome channel, read by the upload window
PNG-to-WebP conversion and WebP re-encodingBothConversion channel
Save and .sxcu file dialogsLinuxBackground-error and import channels

On Linux the file dialogs belong to another process, the desktop portal, and the dialog library waits for them by blocking. On the UI thread that froze every Snipdeck window while you browsed, so on Linux they run on a worker. On Windows the native dialog keeps the thread’s windows responsive and runs in place.

Module map

ModuleResponsibility
src/app/ (mod.rs, input.rs, actions.rs, gallery.rs)AppState, the per-tick sweep, UI actions, the gallery model
src/pump.rsThe main-thread pump that sleeps when idle
src/capture.rs, src/dxgi_capture.rs, src/screen.rsMonitor capture (DXGI Desktop Duplication with a GDI fallback on Windows) and monitor geometry
src/wayland_screencast.rs, src/pipewire_dl.rsLinux: capture through the ScreenCast portal and PipeWire
src/mouse_trigger.rs, src/native_mouse_trigger.rsLow-level mouse and keyboard hooks that start and drive snips
src/slint_overlay.rs, ui/overlay.slintFull-screen selection overlay, live or frozen
src/floating.rs, ui/floating.slintFloating snip windows: drag, resize, crop, border
src/window_env.rsWhat the windowing system allows (placement, staying on top, taskbar), decided in one place
src/annotate.rs, ui/annotate.slintAnnotation editor: tiny-skia flatten, HSV wheel, zoom
src/collage.rs, ui/collage.slintCollage editor
src/loupe.rs, src/color.rsColor picker with its magnifier
src/ocr.rsOCR: Windows.Media.Ocr on Windows, tesseract through a pipe on Linux
src/translate.rs, src/translate_popup.rsOCR + Translate and its popup
src/share.rs, src/upload.rsShare sheet, mail and image editor hand-over; image-host upload
src/sxcu.rs, src/upload_window.rs, ui/upload.slintShareX custom uploaders; the upload window (consent, progress, result)
src/hotkeys.rs, src/portal_hotkeys.rsGlobal hotkeys; on Wayland through the desktop portal
src/bus_service.rsLinux: com.revisetouch.Snipdeck on the session bus, for the GNOME extension and a second launch
src/tray.rs, src/autostart.rs, src/single_instance.rs, src/elevate.rsTray icon, launch at login, the single-instance guard, and Windows’ one-time “Run as administrator”
src/snip.rs, src/settings.rs, src/paths.rsGallery persistence (SQLite with full-text search), settings, file locations
src/image_files.rs, src/atomic_file.rsSnip images on disk: lossless WebP, atomic writes
src/i18n.rs, lang/Runtime translation and the bundled gettext .po catalogs (23 languages)

Smaller modules round it out: src/clipboard.rs (one clipboard for the whole process), src/context_menu.rs with ui/context_menu.slint (the right-click menu shared by floating snips and gallery cards), src/window_metadata.rs (the window information stored with each snip) and src/window_tamer.rs (Windows: no fade-in animation, taskbar handling, foreground tracking).

Outside src/:

PathWhat it is
packaging/gnome-extension/[email protected]/The GNOME Shell extension (extension.js, metadata.json)
packaging/linux/install.shInstalls into ~/.local without root; --uninstall reverses it
packaging/linux/install-from-source.shBuilds, stages every file afresh, and runs install.sh
packaging/linux/com.revisetouch.Snipdeck.desktopThe menu entry; its name is what gives Snipdeck an application id on Wayland

Capture

On Windows, dxgi_capture.rs grabs the monitor with DXGI Desktop Duplication and falls back to GDI when duplication is unavailable or the frame comes back fully black. Freeze-first (Win+Shift+Space) captures the whole screen when you press Space and reuses that frame for the drag, so hover tooltips stay in the shot. A watchdog in tick drops a frozen frame that was never used, so a full-screen buffer cannot linger.

On Linux under Wayland, wayland_screencast.rs reads the screen through the ScreenCast portal and takes one frame from PipeWire in memory. libpipewire is loaded at run time by pipewire_dl.rs rather than linked, so the build does not need PipeWire’s headers; where it is missing, capture falls back to the xcap crate.

The GNOME extension

Wayland does not let a program place its own windows, keep them on top, or watch the pointer globally. Code inside GNOME Shell can, so the extension does three things for Snipdeck:

  • It finds Snipdeck’s snip windows by their title, places them where they were captured, keeps them above other windows, shows them on every workspace, and keeps them out of the window switcher. The position travels in the window title, which is never visible on these borderless windows.
  • It provides Super+Alt+drag: it draws the selection, takes the pixels with GNOME Shell’s own screenshot code, and hands the region to Snipdeck through SnipRegion on the session bus.
  • It claims Super+Alt only while Snipdeck owns its bus name, so with Snipdeck closed the combination goes back to moving windows.

install.sh installs the extension only in a GNOME session. GNOME loads it at the next login.

Persistence

src/snip.rs keeps snips in a SQLite database with a full-text index; images and thumbnails live in a cache folder, and src/paths.rs resolves every location (see Settings).

src/image_files.rs is the one place that touches snip images. Snips are stored as lossless WebP; an image beyond WebP’s size limit is stored as PNG. A WebP is first written with the quickest lossless setting, then a background worker re-encodes it smaller. Each path carries a generation number, bumped by every write and delete, and a background result is renamed into place only if the generation is still the one it started from, so it cannot land on top of a newer crop or bring back a deleted snip.

src/atomic_file.rs writes a sibling temporary file and renames it into place. A crash or a full disk mid-write leaves the previous file, not a truncated one. Settings are written the same way.

Internationalization

One gettext .po per language under lang/<code>/LC_MESSAGES/ drives both the Slint markup (through @tr("…")) and the Rust messages (through i18n::t()). The catalogs are bundled into the binary at build time. See Languages.

The renderers

Snipdeck installs Slint’s winit backend itself, through set_platform, instead of choosing it with the SLINT_BACKEND variable. That is what unlocks the WinitWindowAccessor API the app needs. The renderer differs per platform:

  • Windows: software. The capture overlay is a transparent window, and GPU window transparency depends on the GPU driver: on setups with several adapters, the overlay came up opaque on one monitor and transparent on another. Software rendering goes through the system’s standard composition and looks the same on every monitor. The UI has no animation or 3D, so the cost is not visible, and without an OpenGL context the app uses less memory.
  • Linux: femtovg (GPU). Slint’s software path on Wayland renders into a surface format without alpha, so the overlay and the context menu came out as solid black rectangles. The GPU renderer gets a surface with alpha.

Cargo.toml compiles in only the renderer each platform uses.

Process and lifecycle

  • Single instance. single_instance::acquire() runs first. A second launch would fight the first over the hotkeys and input hooks, so it exits; on Linux it first asks the running Snipdeck, over the session bus, to show its window.
  • Application id on Linux. main() sets the xdg application id to com.revisetouch.Snipdeck, matching com.revisetouch.Snipdeck.desktop, before the first window opens, so the desktop ties Snipdeck’s windows to its menu entry.
  • High priority on Windows. Low-level hooks have a strict time limit per call, and an idle process at normal priority can have the first event after a quiet period dropped. Snipdeck raises its priority so the hook answers in time.
  • Close to tray. Closing the main window hides it to the tray; only the tray’s Exit quits. Where no tray is available, which Linux can only tell at run time, closing the window quits, so the process is never left running with no way back.
  • Launch at login without the registry. A Startup-folder shortcut on Windows, an XDG autostart entry on Linux, both started with --autostart so Snipdeck comes up in the tray. See Privacy and security.

See also

Use the arrow keys to choose, Enter to go.