Skip to content
Ithril-LaydecPublic

About

No description, website, or topics provided.

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

SparkType (Rust)

English · Español

Offline voice dictation daemon: records the microphone, transcribes with whisper.cpp (via whisper-rs), and pastes the text into kitty through kitty remote control — cross-workspace, without stealing focus and with perfect Unicode.

Heads up — this is a personal tool with a narrow target. It only pastes into kitty, only runs on X11 (built and used under i3), and has no GUI. If that matches your setup, great. If you need Wayland, another terminal, or a graphical app, this is not it (yet — the backend architecture is designed to grow, see below).

v1 limitations

  • kitty only as paste target (no fallback: if the focused window is not kitty, the error is logged and the text stays in the history).
  • X11 only (i3): window capture and hotkeys use X11/XInput2.
  • No graphical UI: integration happens over the socket. The binary ships sparktype watch for bar-agnostic streaming (see Integrations); the polybar module is the polybar/sparktype.sh script.

Requirements

System

sudo apt install cmake pkg-config libasound2-dev
  • cmake — compiles whisper.cpp (build script of whisper-rs-sys)
  • pkg-config + libasound2-dev — ALSA, required by cpal/rodio
  • wl-clipboard (Wayland) or xclip (X11) — so history can copy to the clipboard
  • Rust with edition 2024 (toolchain ≥ 1.85)
  • just (optional, for the development recipes)

HDMI audio (optional)

With HDMI as the audio output, the sink enters SUSPENDED after inactivity. When a beep plays, the HDMI link can take 250ms–2s to wake up depending on the monitor, which clips the start of the sound. The fix is to disable automatic suspension of the HDMI sink via WirePlumber:

-- ~/.config/wireplumber/main.lua.d/51-disable-hdmi-suspend.lua
rule = {
  matches = { { { "node.name", "matches", "alsa_output.*hdmi*" } } },
  apply_properties = { ["session.suspend-timeout-seconds"] = 0 },
}
table.insert(alsa_monitor.rules, rule)

Then: systemctl --user restart wireplumber. Verify with pactl list sinks short (the HDMI sink should stay RUNNING while idle).

kitty

Paste uses kitty remote control. In the kitty.conf of the target kitty:

allow_remote_control yes
listen_on unix:@kitty

kitty creates an abstract socket unix:@kitty-<pid> per instance; SparkType derives it from the _NET_WM_PID of the captured window. Without this config the paste fails with an error explaining what is missing (the daemon stays alive).

Build and run

Install with cargo (alternative)

If you already have the Rust toolchain, you can install straight from git without cloning:

cargo install --git https://github.com/Ithril-Laydec/sparktype

This still needs cmake and the ALSA dev headers present (whisper.cpp is compiled from source — see Requirements). It drops the sparktype binary into ~/.cargo/bin. The repo's install.sh is the fuller path: it detects your distro, installs build deps, and copies the binary to /usr/local/bin. When run interactively it finishes by offering to open the interactive menu (which includes picking your microphone); otherwise it prints a tip on how to open it later.

Daemon lifecycle (primary path)

Once sparktype is on your PATH, the CLI is self-sufficient — no just required:

sparktype start      # launch the daemon in background and wait until it responds
sparktype stop       # stop it (SIGTERM, escalates to SIGKILL after 5s)
sparktype restart    # runtime restart: stop then start — does NOT compile
sparktype status     # daemon status (GET /status)
sparktype logs       # tail -f of the log
sparktype history    # interactive picker over the transcription history
  • stop is an alias of kill; both spellings run the identical teardown. kill stays valid for existing scripts (e.g. the hibernate hook).
  • restart performs a runtime restart: it stops any running daemon and starts a fresh one from the installed binary. It does not build. It is idempotent — if nothing is running, it just starts fresh. A real signal or start failure still surfaces as an error.
  • Zero-flag by default: with no --runtime-dir and no SPARKTYPE_RUNTIME_DIR, state follows the XDG Base Directory spec — config in ~/.config/sparktype/, data in ~/.local/share/sparktype/, logs/models in ~/.cache/sparktype/, socket/PID in $XDG_RUNTIME_DIR/sparktype/. No flag is needed for correct operation.
  • --runtime-dir DIR (or env SPARKTYPE_RUNTIME_DIR, flag wins) overrides everything to a single dir (used by the dev justfile with .runtime/).

Full subcommand list: run | start | stop (alias kill) | restart | status | logs | config get [key] | config set <key> <value> | history [--limit N] [--search text] | watch [--json].

Interactive menu (no subcommand)

Run sparktype with no subcommand on an interactive terminal and it opens a curated arrow-key menu instead of erroring out:

sparktype            # opens the interactive menu (TTY only)

The menu offers the everyday actions — start / stop / restart the daemon, status, view logs, history, select microphone, doctor — plus Models… and Configuration… submenus (each with a Back entry). It deliberately omits run (foreground), watch, completions, and man, which only make sense as direct subcommands. Each action runs and returns to the menu; Quit or Esc exits cleanly. A failing action (e.g. daemon down) prints the error and keeps the menu open rather than aborting.

  • View logs shows a bounded snapshot of the most recent log lines and returns to the menu (it does not tail -f).
  • Non-TTY fallback: when stdin/stdout are redirected (sparktype </dev/null | cat), there is no menu — standard --help is printed and the process exits without blocking on a prompt.

With the justfile (development only)

The justfile is a dev-only convenience that injects --runtime-dir .runtime so all state (socket, PID, logs, config, DB) lands in .runtime/, isolated from your system install at ~/.cache/sparktype/. The models are shared in ~/.cache/sparktype/models/ so nothing is re-downloaded.

just start      # cargo build --release + daemon in background
just status     # daemon status (GET /status)
just logs       # tail -f of the log
just history    # interactive picker (accepts --limit/--search as pre-filter)
just restart    # kill + cargo build --release + start  ← BUILDS, unlike `sparktype restart`
just kill       # stop (SIGTERM, escalates to SIGKILL after 5s)
just run        # daemon in foreground (logs in the terminal)
just test       # cargo test (manual tests are separate, see below)

CLI restart vs just restart: sparktype restart is a pure runtime restart with no compilation — it relaunches the currently installed binary. just restart stops the daemon, then runs cargo build --release, then starts (the build lives inside just start), so it picks up source changes. Use sparktype restart in production; use just restart while developing.

watch streams the daemon's state to stdout, one line per meaningful change, so any status bar or script can integrate via a pipe — see Integrations.

history opens an interactive (fuzzy) picker over the history read directly from SQLite (does not need the daemon running) and copies the full text of the chosen entry to the clipboard (wl-copy on Wayland, xclip on X11). --limit/--search pre-filter the list. Requires a TTY.

State locations. By default SparkType splits its state across the XDG base directories (each file lands in the right place per the freedesktop spec):

File Default (XDG) location Base
config.toml ~/.config/sparktype/config.toml $XDG_CONFIG_HOME
history.db ~/.local/share/sparktype/history.db $XDG_DATA_HOME
sparktype.sock $XDG_RUNTIME_DIR/sparktype/sparktype.sock (perms 600) $XDG_RUNTIME_DIR
sparktype.pid $XDG_RUNTIME_DIR/sparktype/sparktype.pid $XDG_RUNTIME_DIR
sparktype.log ~/.cache/sparktype/sparktype.log (raw stdout/stderr) $XDG_CACHE_HOME
sparktype.<date>.log ~/.cache/sparktype/ (tracing, daily rotation, max 3) $XDG_CACHE_HOME
models ~/.cache/sparktype/models/ $XDG_CACHE_HOME

If $XDG_RUNTIME_DIR is unset, socket and PID fall back to ~/.cache/sparktype/ (today's location — still 600). Any $XDG_* value that is empty or non-absolute is ignored and the ~/… default is used.

Override mode. --runtime-dir DIR (flag) or SPARKTYPE_RUNTIME_DIR=DIR (env; the flag wins) collapses config/data/runtime/cache under one DIR and never consults $XDG_*. This is what the dev justfile uses (.runtime/). The models are the one exception: they always live in the shared ~/.cache/sparktype/models/ regardless of the override, so the ~1.6 GB model is never re-downloaded per runtime dir.

One-time migration. The first time the daemon starts in XDG mode, if a legacy ~/.cache/sparktype/config.toml or history.db exists but the XDG target does not, it is copied (size-verified) to its XDG home. The legacy file is never deleted and migration is idempotent — a rollback stays safe.

RUST_LOG=debug increases log detail (default info).

Note when diagnosing: the application logs (tracing) are in sparktype.<date>.log, NOT in sparktype.log — that one only contains the daemon's raw stderr (panics, whisper.cpp output, downloads).

Configuration (config.toml)

Lives in ~/.config/sparktype/config.toml (or <DIR>/config.toml in override mode). It is created with defaults the first time. A partial file is valid (missing fields take defaults); an unknown field is a parse error. Full defaults:

[model]
name = "large-v3-turbo"   # one of the 4 allowed models (see below)
language = "auto"          # "auto" or ISO code ("es", "en", ...)

[audio]
device = "HyperX"          # partial case-insensitive search; "" = default
sample_rate = 16000        # only 16000 (required by whisper.cpp)

[inference]
beam_size = 8           # whisper.cpp max is 8 (v1.7.6 fails with >8)
patience = 2.0             # kept for parity; whisper.cpp 1.7.6 does not implement it
condition_on_previous_text = true
initial_prompt = "Transcripción en español con puntuación correcta."
hotwords = "Albarán Claude SparkType"

[hotkeys]
paste_normal = "F9"
paste_sticky = "super+5"
paste_sticky_enter = "super+6"
cancel_recording = "super+9"

[sound]
enabled = true             # embedded beeps: start/stop/stop_sticky_enter/cancel

[app]
history_max_records = 10000

Editable with the CLI (with the daemon running):

sparktype config get                      # full config (JSON)
sparktype config get model               # a single top-level section (no dots)
sparktype config set sound.enabled false # set does accept a dotted key; hot-applied
sparktype config set model.name large-v3 # persisted, but requires a restart
  • Hot (no restart): hotkeys, sound, app.
  • Require a daemon restart: model, audio, inference (the response signals it with restart_required: true).
  • Invalid values (unknown model, malformed hotkey) → error 400 and the TOML does not change.

Whisper models

They auto-download from HuggingFace on first start, to ~/.cache/sparktype/models/ (directory shared with the Python SparkType — nothing is re-downloaded). Progress goes to stderr → visible in sparktype.log / just logs.

model.name File Size
large-v3-turbo (default) ggml-large-v3-turbo.bin ~1.6 GB
large-v3 ggml-large-v3.bin ~3.1 GB
large-v3-turbo-q5_0 ggml-large-v3-turbo-q5_0.bin ~574 MB
large-v3-q5_0 ggml-large-v3-q5_0.bin ~1.1 GB

The Silero VAD model (ggml-silero-v5.1.2.bin, ~885 KB) is always downloaded as well, to filter out silence.

Managing models (sparktype models)

Discover, pre-fetch, and select the active model without editing the TOML. Scope is the 4 allowed models above (the internal VAD is not selectable).

sparktype models list                    # all 4 models: size, DOWNLOADED, ACTIVE marks
sparktype models download                # pre-fetch the configured model (default)
sparktype models download large-v3-q5_0  # pre-fetch a specific model
sparktype models use large-v3            # select the active model (requires a restart)
Action What it does
list Enumerates the 4 models with size, short description, and DOWNLOADED / ACTIVE flags.
download [<model>] Pre-fetches a model (defaults to the configured one). Idempotent — an already-present valid model is a no-op. An unknown name is rejected before any download.
use <model> Validates against the 4 allowed models and persists model.name. Unknown name → error, config unchanged.

Download is otherwise lazy: any model auto-downloads on the first transcription that needs it. models download just forces it ahead of time. models use mirrors config set model.name (daemon-aware persistence) and prints a restart hint because model.name requires a restart to take effect.

Paste modes

Mode Target window
normal The focused one at paste time
sticky The focused one when the recording stops — you can move to another workspace and the text still arrives
sticky_enter Like sticky + Enter at the end. With empty audio (silence) it sends Enter only, respecting FIFO order

Pastes go through a FIFO queue with sequencing: even if a short audio finishes transcribing before a longer earlier one, they are pasted in recording-start order.

Enter in sticky_enter: Key vs Newline

In sticky_enter, the Enter is sent according to the context when each paste is executed:

  • send-key enter (real Enter): the default. kitty @ send-key enter encodes the event according to the app's keyboard mode — CSI 13u for apps with the kitty protocol (OpenCode, Neovim…) and \r for classic shells. Works in both worlds.

  • \n in the payload (Newline): when there is more dictation in flight for the same window, that paste's Enter is downgraded to \n. This keeps the correct order in dictation chains: each intermediate dictation ends with a newline, and only the last one sends the real Enter.

The decision is made when each job in the queue is executed (not at enqueue time), by consulting has_pending_work_for_window: if there is pending work for the target window, Newline; otherwise, Key. Pending work counts as:

  • a transcription in progress whose captured destination (on stop) is that window,
  • a job already enqueued for that window,
  • a recording in progress whose window hint is that window. The hint is captured when the recording starts (in all modes), separate from the real destination that is still captured on stop. This covers the case where a long dictation is pasted while the next one is still being recorded over the same window: the long one is downgraded to Newline and does not break the chain. Assumed risk: if the in-progress recording ends over another window (the user moved), the previous dictation is left with Newline without a real Enter (no repair).

Hotkeys (defaults)

Key Action
F9 Toggle recording, normal mode
super+5 Toggle recording, sticky mode
super+6 Toggle recording, sticky+enter mode
super+9 Cancel the recording in progress

Toggle semantics: any paste hotkey with a recording in progress stops it, and the mode of the stop hotkey wins over the start one (same as the original Python SparkType). Example: start with super+6 and stop with super+5 → without Enter; the other way around → with Enter. Stops without their own mode (REST API /recording/stop, shutdown) keep the start mode. Hotkeys use XInput2 passive grabs: they are consumed (the key does not reach the focused app) and work with NumLock/CapsLock on. Format: [mod+...]key with super/ctrl/alt/shift and F1–F12, letters or digits (e.g. ctrl+F9).

API (HTTP + SSE over a Unix socket)

Socket: $XDG_RUNTIME_DIR/sparktype/sparktype.sock (or <DIR>/sparktype.sock in override mode), permissions 600.

Endpoint What it does
GET /status { state, paste_mode, model, queue_depth, in_flight, recording_time_secs }
GET /events SSE: first event snapshot (current state), then each EngineEvent as data: <json>
POST /recording/start Body { "mode": "normal"|"sticky"|"sticky_enter" } → { session_id }
POST /recording/stop Stops the recording → { ok: true }
POST /recording/cancel Cancels the recording → { ok: true }
GET /config Full config (JSON)
PUT /config Partial merge: {"model": {"name": "..."}} or {"model.name": "..."} → { ok, restart_required }
GET /queue { pending: N, current: <job | null> }
GET /transcriptions?limit=N&q=text History (default limit 50)

Examples:

curl --unix-socket .runtime/sparktype.sock http://localhost/status
curl --unix-socket .runtime/sparktype.sock http://localhost/events          # SSE stream
curl --unix-socket .runtime/sparktype.sock -X POST \
     -H 'Content-Type: application/json' -d '{"mode":"sticky"}' \
     http://localhost/recording/start
curl --unix-socket .runtime/sparktype.sock -X PUT \
     -H 'Content-Type: application/json' -d '{"sound":{"enabled":false}}' \
     http://localhost/config

SSE stream notes:

  • Initial snapshot: on connect, the first event is {"event":"snapshot","state":...,"in_flight":N,"model":...,"queue_depth":N} — the client does not need a prior GET /status to sync.
  • in_flight: dictations in flight (recorded but not yet pasted: transcribing + paste queue). Each change emits {"event":"in_flight_changed","count":N}.

Integrations

The daemon exposes its state over the Unix socket, so any status bar or script can react to it without polling. There are two entry points.

X11-only for dictation. The HTTP/SSE daemon runs anywhere, so a Wayland bar (waybar, etc.) can read the state via watch / /events and render it. But hotkeys and paste require X11 (i3) — a Wayland setup can display SparkType's state but cannot dictate with it.

sparktype watch [--json]

watch connects to /events and prints the daemon state to stdout, one line per meaningful change (line-buffered, so pipes update immediately). It survives the daemon not running or restarting: it prints a disconnected marker and keeps retrying instead of exiting.

  • Plain mode (default) emits exactly one lowercase state word per change — idle, recording, processing, or error — and off when the daemon is unreachable. In-flight counts are omitted; consecutive duplicates are suppressed. Ideal for a minimal bar label:

    # i3blocks / tmux / any bar that reads a command's stdout line by line
    sparktype watch
    # → off
    # → idle
    # → recording
    # → idle
    # tmux status: refresh a pane variable on each state word
    sparktype watch | while read -r state; do
      tmux set -g @sparktype "$state"
    done
  • JSON mode (--json) forwards each SSE event object as one compact JSON line, preserving every field (state, paste_mode, in_flight, queue_depth, model, counts). Emits {"event":"disconnected"} when the daemon is down. Ideal for waybar-style consumers:

    sparktype watch --json
    # → {"event":"snapshot","state":"idle","in_flight":0,"queue_depth":0,"model":"large-v3-turbo",...}
    # → {"event":"state_changed","state":"recording"}
    # → {"event":"in_flight_changed","count":1}

Raw /events consumer (bar-agnostic)

If you would rather parse the stream yourself, read the SSE endpoint directly. This needs nothing but curl and jq:

curl --unix-socket ~/.cache/sparktype/sparktype.sock --no-buffer \
     -H 'Accept: text/event-stream' http://localhost/events \
  | grep --line-buffered '^data:' \
  | sed -u 's/^data: //' \
  | jq --unbuffered -c '{event, state, in_flight}'

The first event on connect is always a snapshot with the full current state, so a consumer never needs a prior GET /status to sync.

Shell completions & man page

SparkType generates its own shell completions and man page on demand, to STDOUT. Nothing is written at build time and install.sh does not install these into system directories (/etc, /usr/share/man, …) — redirect the output into your own paths.

# Shell completions — pick your shell and write to its completions dir
sparktype completions bash > ~/.local/share/bash-completion/completions/sparktype
sparktype completions zsh  > ~/.zsh/completions/_sparktype
sparktype completions fish > ~/.config/fish/completions/sparktype.fish

# Man page (roff) — write it and view it, or drop it in your MANPATH
sparktype man > sparktype.1
man ./sparktype.1

completions <shell> accepts any shell clap_complete supports (bash, zsh, fish, elvish, powershell); an unknown shell is rejected with the list of valid choices.

Ready-made assets (contrib/)

The contrib/ directory ships copy-pasteable configs:

  • polybar — contrib/polybar/config.example.ini: a [module/sparktype] block (type=custom/script, tail=true, click-left=... --launch) plus a modules-center placement hint. It points exec at the single-source script in polybar/sparktype.sh.

  • systemd — contrib/systemd/sparktype.service: a systemd user unit that supervises the daemon. Install and enable it with:

    mkdir -p ~/.config/systemd/user
    cp contrib/systemd/sparktype.service ~/.config/systemd/user/
    systemctl --user daemon-reload
    systemctl --user enable --now sparktype

polybar module (polybar/sparktype.sh)

A thin API client: it consumes /events with curl --unix-socket --no-buffer (reactive, no polling) and renders [queue][state] — one bullet per dictation in flight (newest on the left) and a fixed state token on the right: off, starting (there is a PID but no socket), idle, recording, error.

By default the module is vanilla-safe: it works on any polybar with no custom fonts and no layout escapes. State shows as ASCII tokens ([off] / [...] / [idle] / [REC] / [err]) with standard %{F} colors, so nothing renders as tofu even without Nerd Fonts.

Two independent switches control the look:

  • SPARKTYPE_ICONS=nerd|ascii — glyph set and whether a braille spinner animates while recording:
    • nerd → Nerd-Font glyphs (󰍬 mic, etc.) plus a braille spinner (⠋⠙⠹…, ~8 fps) next to the red mic while recording. It uses only %{F} color escapes — no %{O} offset and no %{T} font slots — so a Nerd-Font user gets the nice animated look and can drop the module anywhere on the bar. This is fully decoupled from the author's centering hack.
    • ascii → the vanilla [off]/[...]/[idle]/[REC]/[err] tokens, no spinner, no font slots.
    • Default auto → nerd when SPARKTYPE_OFFSET=1, else ascii.
  • SPARKTYPE_OFFSET=1 — the author's centering hack, layered on top of nerd icons (see "Advanced" below). It adds the %{O} offset and %{T6}/%{T7} font slots; it implies nerd.

Other flags:

  • --launch: starts sparktype start only if the daemon is not running (click action).
  • Path override: SPARKTYPE_RUNTIME_DIR (or --runtime-dir) collapses all state under one dir; without it, XDG base directories are used.

Minimal config (config.ini) — copy from contrib/polybar/config.example.ini:

[bar/main]
modules-center = sparktype

[module/sparktype]
type = custom/script
exec = <path-to-repo>/polybar/sparktype.sh
tail = true
click-left = <path-to-repo>/polybar/sparktype.sh --launch

Note: after editing sparktype.sh you must restart polybar (polybar-msg cmd restart) — the running bash instances read the script incrementally and an in-place rewrite can leave them corrupted or frozen (static module).

Advanced (author's setup)

The author additionally centers the module under a date module using a horizontal offset and dedicated font slots. This is layered on top of the nerd icons + spinner (which you can already get on their own with SPARKTYPE_ICONS=nerd, no offset). It is opt-in: export SPARKTYPE_OFFSET=1 in the environment polybar runs under. When enabled, the render is byte-identical to the pre-refactor script (Tokyo Night colors, Nerd glyphs, %{T6}/%{T7} font slots, %{O} offset) — zero regression. The only thing SPARKTYPE_OFFSET=1 adds over SPARKTYPE_ICONS=nerd is the %{O} centering offset and the %{T} font slots.

While recording it shows a braille spinner (~8 fps) next to the red mic. The icon, spinner and dots are wrapped in %{T6} (font-5 of the bar, IosevkaTerm NF size 15). The spinner slot has a fixed width: in states without a spinner a blank placeholder of the same width is emitted, so the module does not shift when recording starts/stops. The spinner glyph uses its own font slot (SPARKTYPE_SPINNER_FONT, default 7 = font-6, with a larger vertical offset ;4 to center the braille).

Compensation offset (centering the date): the usage block to the right of the date would de-center it; the compensation is a single offset emitted by sparktype.sh. date-claude.sh no longer emits %{O} (fallback: POLYBAR_DATE_EMIT_OFFSET=1 restores it to use the date module without sparktype) and publishes the usage width to ${XDG_RUNTIME_DIR:-/tmp}/polybar-ai-usage-width; sparktype.sh reads it on each render and prepends O_spark = usage_px − (visible_chars × PX_PER_CHAR) − SEP_PX − GAP_PX (clamped to 0; no file → 0). Geometry: [O_spark][spark][sep][date][usage] → to the left of the date there is exactly usage_px → date centered and spark stuck to it. The SSE loop uses read -t (tick ~1s while idle, ~0.12s while recording) to refresh the width and animate the spinner without missing events. Calibration via env (all only apply when SPARKTYPE_OFFSET=1):

Env Default Meaning
SPARKTYPE_OFFSET 0 1 = add the author centering (%{O} offset + %{T} font slots) on top of the nerd icons/spinner
SPARKTYPE_PX_PER_CHAR 13 px per visible character at font size 15
SPARKTYPE_SEP_PX 40 px of the module separator " | "
SPARKTYPE_GAP_PX 0 extra margin subtracted from the offset
SPARKTYPE_WIDTH_FILE ${XDG_RUNTIME_DIR:-/tmp}/polybar-ai-usage-width usage width file
SPARKTYPE_SPINNER_FONT 7 %{T} slot of the spinner (font-6 with vertical offset ;4)

Tests

cargo test          # automated tests (do not touch mic, model or X11)

The tests that require a real environment (1.6 GB model, microphone, X11 grabs, kitty) are manual and are in MANUAL_TESTS.md — run by the user by hand, never by an agent. There are auxiliary binaries (paste_test, engine_test, hotkey_test, validate_*) in src/bin/.

Known flaky tests (they sometimes fail in the full suite due to timing — short sleeps + runner parallelism — but always pass in isolation or on a second run):

  • paste::queue::tests::pending_for_window_refleja_conteo_correcto (race with the PasteQueue worker)
  • solo_enter_primero_luego_transcripcion_lenta_misma_ventana (tests/, 200ms sleep)

If one of these fails and you have not touched the paste queue, it is not your change — retry in isolation.

Note: cross-workspace paste PoC (poc/atspi-paste/)

Before the migration, several mechanisms to paste text into a window on another workspace under i3/X11 were validated live:

Mechanism Result
XSendEvent (xdotool type --window) — what the current Python uses Apps like kitty/Firefox reject events with send_event=True; accents lost due to the XChangeKeyboardMapping race
AT-SPI EditableText.InsertText (D-Bus) Works in GTK/Qt/browsers, but kitty does not expose AT-SPI (it is OpenGL)
XTest + XSetInputFocus BadMatch: i3 unmaps windows of invisible workspaces
XTest alone Only reaches the focused window — no good for targeting
kitty remote control (kitty @ send-text) ✅ Works: it is not X11, it is kitty's own socket. Cross-workspace, silent, perfect Unicode

That is why v1 uses kitty RC as the only paste backend: the PoC captures the focused window via _NET_WM_PID, derives the socket unix:@kitty-<pid> and sends with kitty @ --to <socket> send-text --stdin, verifying with a read-back (get-text). The full detail is in docs/plans/rust-migration/plan.md (parent worktree); the PoC code in poc/atspi-paste/. The tiered backend architecture allows adding AT-SPI/tmux/wezterm/XSendEvent in the future without touching the dispatcher or the engine.

Contributing

Issues and pull requests are welcome. Before your first PR you will need to sign the Contributor License Agreement (CLA) — a bot will request it automatically on the PR and you sign with a comment. See CONTRIBUTING.md for the full flow and the reasoning behind the CLA.

License

SparkType is distributed under the GNU Affero General Public License v3.0 or later (AGPL-3.0-or-later). See LICENSE.

In short: you may use, study, modify and redistribute the code, but any derived version you distribute or offer as a network service must also be published under AGPL-3.0 with its source code available. Copyright is held by Ithril Laydec; by retaining full copyright (via the CLA) the project reserves the option to offer commercial licenses in the future.

About

No description, website, or topics provided.

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages