diff --git a/MANIFEST.in b/MANIFEST.in index 3000916..9855146 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,4 +1,5 @@ include voculinux.desktop include resources/icons/scalable/*.svg include resources/sounds/*.wav +include resources/niri/*.kdl recursive-include src/vocalinux/resources * diff --git a/docs/NIRI.md b/docs/NIRI.md new file mode 100644 index 0000000..be0e8e1 --- /dev/null +++ b/docs/NIRI.md @@ -0,0 +1,94 @@ +# niri and Dank Material Shell + +Dictation on the [niri](https://github.com/YaLTeR/niri) Wayland compositor, +including sessions running Dank Material Shell (DMS). There is no +compositor-specific code: activation reuses the standard external triggers +over D-Bus, and the tray is the standard StatusNotifier item. + +## Prerequisites + +1. Install Voculinux first: [INSTALL.md](INSTALL.md). +2. Open **Settings → Shortcuts** and turn on **External Activation (Desktop + Shortcut)**. The built-in key listener stops, and a running instance + answers these triggers on the session bus (`com.voculinux.Voculinux`): + +```bash +voculinux --toggle # start if idle, stop if active +voculinux --start # start voice typing +voculinux --stop # stop voice typing +``` + +## Keybind: toggle once per press + +Either paste the bind below into the `binds {}` section of +`~/.config/niri/config.kdl`, or copy the ready-made include file and +reference it (easier to keep across config edits): + +```bash +mkdir -p ~/.config/niri +cp resources/niri/voculinux.kdl ~/.config/niri/voculinux.kdl +``` + +then near the top of `~/.config/niri/config.kdl`: + +```kdl +include "~/.config/niri/voculinux.kdl" +``` + +The shipped bind: + +```kdl +binds { + Mod+Shift+D repeat=false hotkey-overlay-title="Toggle Voculinux dictation" { + spawn "voculinux" "--toggle"; + } +} +``` + +`repeat=false` is what makes it toggle once per press: without it, holding +the key repeats the spawn and toggles dictation back off before you let go. +Pick any key you like (the overlay title keeps it discoverable under +`Mod+H`), and uncomment the `--start` / `--stop` variants in the include +file if you want dedicated keys. + +A key is either a compositor bind or an in-app Voculinux shortcut — +never both. With External Activation on, do not keep the same key configured +under **Settings → Shortcuts**; with it off, remove the niri bind. A key +claimed on both sides fires twice: the compositor toggles over D-Bus while +the internal listener fires on its own. + +## Hold-to-talk stays in the app + +A compositor bind only sends the key press; it never sees the release, so +it can only toggle dictation. Push-to-talk (hold Right Alt, release to +stop) keeps working only through the built-in Voculinux shortcut. This +pack ships plain press binds only, so if you dictate with hold-to-talk, +leave the in-app shortcut in place for it and use the niri bind for toggle. + +## Flatpak + +`voculinux` is not on the host PATH from a sandbox, so bind the host D-Bus +call instead (same toggle, no extra sandbox spawned per press). The include +file carries a commented-out `gdbus` variant; background and the exact +command live under [Running without device +access](../packaging/flatpak/README.md#running-without-device-access). + +## Tray under Dank Material Shell + +Not yet observed on hardware: the StatusNotifier watcher bus name (or +names) DMS owns will be recorded with ticket #32, the hardware acceptance +run. The tray probe (`_check_status_notifier_watcher` in +`src/vocalinux/ui/tray_indicator.py`) currently accepts only +`org.kde.StatusNotifierWatcher`, and no probe code changed in this ticket. + +The observation to record on #32, with a DMS session running: + +```bash +busctl --user list | grep -i -E 'watcher|statusnotifier' +``` + +If DMS owns only `org.freedesktop.StatusNotifierWatcher`, the probe is +widened to accept both watcher names so no false missing-tray warning +appears, with behaviour tests covering both names. If +`org.kde.StatusNotifierWatcher` is present, the current probe stands and +the recorded output closes the question. diff --git a/docs/README.md b/docs/README.md index b576c87..0740e76 100644 --- a/docs/README.md +++ b/docs/README.md @@ -8,6 +8,7 @@ User and maintainer documentation for Voculinux. | [INSTALL_MANUAL.md](INSTALL_MANUAL.md) | Users | Manual install, system packages, PyPI/pipx | | [TROUBLESHOOTING.md](TROUBLESHOOTING.md) | Users | Common install and runtime failures | | [USER_GUIDE.md](USER_GUIDE.md) | Users | Dictation, engines, models, shortcuts, tips | +| [NIRI.md](NIRI.md) | Users | niri keybind pack and Dank Material Shell notes | | [UPDATE.md](UPDATE.md) | Users | How to upgrade; release notes by version | | [DISTRO_COMPATIBILITY.md](DISTRO_COMPATIBILITY.md) | Users | Distro matrix, session caveats | | [HTTP_REMOTE.md](HTTP_REMOTE.md) | Power users | Remote HTTP transcription | diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 7a38029..1bb4502 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -165,7 +165,9 @@ support push-to-talk because it never sees the release. On KDE Plasma: Now your chosen key combination toggles dictation, handled by the compositor rather than by Voculinux reading the keyboard directly. This works the same way on other compositors that support binding a key to a command (e.g. GNOME custom -shortcuts, Sway/Hyprland `bindsym`/`bind`). +shortcuts, Sway/Hyprland `bindsym`/`bind`). On niri, including under Dank +Material Shell, use the ready-made pack: [NIRI.md](NIRI.md) (optional include +file, once-per-press bind, tray note). As an advanced alternative, you can set the same option in `~/.config/voculinux/config.json` under `shortcuts`: diff --git a/resources/niri/voculinux.kdl b/resources/niri/voculinux.kdl new file mode 100644 index 0000000..16e610f --- /dev/null +++ b/resources/niri/voculinux.kdl @@ -0,0 +1,36 @@ +// Voculinux dictation toggle for the niri Wayland compositor. +// +// Copy this file to ~/.config/niri/voculinux.kdl, then add this line near +// the top of ~/.config/niri/config.kdl: +// +// include "~/.config/niri/voculinux.kdl" +// +// Prerequisite inside Voculinux: open Settings → Shortcuts and turn on +// "External Activation (Desktop Shortcut)". A running instance then answers +// the triggers below over D-Bus; the built-in key listener stays off. +// +// One key is either a compositor bind or an in-app Voculinux shortcut, +// never both: do not keep this key configured under Settings → Shortcuts. + +binds { + // Toggle dictation, exactly once per press. repeat=false stops key + // repeat from firing the spawn again while the key is held (which + // would toggle dictation back off before you let go). + Mod+Shift+D repeat=false hotkey-overlay-title="Toggle Voculinux dictation" { + spawn "voculinux" "--toggle"; + } + + // Optional explicit start/stop binds. Same rule: keep repeat off. + // Mod+Shift+S repeat=false hotkey-overlay-title="Start Voculinux dictation" { + // spawn "voculinux" "--start"; + // } + // Mod+Shift+E repeat=false hotkey-overlay-title="Stop Voculinux dictation" { + // spawn "voculinux" "--stop"; + // } + + // Flatpak installs: voculinux is not on the host PATH, so bind the + // host D-Bus call instead (same toggle, no extra sandbox per press). + // Mod+Shift+D repeat=false hotkey-overlay-title="Toggle Voculinux dictation" { + // spawn "gdbus" "call" "--session" "--dest" "com.voculinux.Voculinux" "--object-path" "/com/voculinux/Voculinux" "--method" "com.voculinux.Voculinux.Toggle"; + // } +} diff --git a/tests/test_niri_pack.py b/tests/test_niri_pack.py new file mode 100644 index 0000000..e03cd3e --- /dev/null +++ b/tests/test_niri_pack.py @@ -0,0 +1,88 @@ +"""Ticket #22: niri + Dank Material Shell integration pack (docs/keybind seam). + +A niri user following only the new documentation gets a working toggle bind +(fires once per press) with no compositor-internal APIs. The tray-watcher +question stays code-untouched until hardware observation (ticket #32); +the probe assertion below pins the current watcher name so a silent +widening fails loudly. When #32 records DMS evidence, update the doc and +this assertion together. +""" + +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +NIRI_DOC = REPO_ROOT / "docs" / "NIRI.md" +NIRI_INCLUDE = REPO_ROOT / "resources" / "niri" / "voculinux.kdl" +DOCS_INDEX = REPO_ROOT / "docs" / "README.md" +USER_GUIDE = REPO_ROOT / "docs" / "USER_GUIDE.md" +TRAY_INDICATOR = REPO_ROOT / "src" / "vocalinux" / "ui" / "tray_indicator.py" + +TOGGLE_SPAWN = 'spawn "voculinux" "--toggle"' +CURRENT_WATCHER = "org.kde.StatusNotifierWatcher" + + +def _read(path: Path) -> str: + assert path.is_file(), f"{path.relative_to(REPO_ROOT)} is missing" + return path.read_text(encoding="utf-8") + + +def _active_lines(text: str) -> list[str]: + """Non-blank lines that are not niri // comments.""" + return [ + line.strip() + for line in text.splitlines() + if line.strip() and not line.strip().startswith("//") + ] + + +def test_include_file_spawns_renamed_toggle_trigger() -> None: + """The shipped bind drives the renamed CLI trigger with split argv.""" + assert any(TOGGLE_SPAWN in line for line in _active_lines(_read(NIRI_INCLUDE))) + + +def test_include_bind_is_non_repeating() -> None: + """Key repeat must not re-fire the toggle while held.""" + assert "repeat=false" in _read(NIRI_INCLUDE) + + +def test_include_file_uses_no_compositor_internals() -> None: + """Only plain spawn actions; no compositor message APIs.""" + assert "niri msg" not in _read(NIRI_INCLUDE).lower() + + +def test_doc_shows_include_and_once_per_press() -> None: + """Doc presents the optional include file and the once-per-press promise.""" + doc = _read(NIRI_DOC).lower() + assert "include" in doc + assert "repeat=false" in doc + assert "once per press" in doc + + +def test_doc_warns_compositor_bind_xor_in_app_shortcut() -> None: + """One key is a compositor bind or an in-app shortcut, never both.""" + doc = _read(NIRI_DOC).lower() + assert "never both" in doc + assert "external activation" in doc + + +def test_doc_keeps_hold_to_talk_in_app() -> None: + """A press-only bind cannot do push-to-talk; the release never arrives.""" + doc = _read(NIRI_DOC).lower() + assert "push-to-talk" in doc or "hold-to-talk" in doc + assert "release" in doc + + +def test_doc_records_tray_watcher_gap_without_probe_change() -> None: + """DMS observation is deferred to #32; the probe stays as-is meanwhile.""" + doc = _read(NIRI_DOC) + assert CURRENT_WATCHER in doc + assert "#32" in doc + probe = _read(TRAY_INDICATOR) + assert CURRENT_WATCHER in probe + assert "org.freedesktop.StatusNotifierWatcher" not in probe + + +def test_doc_linked_from_index_and_user_guide() -> None: + """The niri page is reachable from the docs index and the user guide.""" + assert "NIRI.md" in _read(DOCS_INDEX) + assert "NIRI.md" in _read(USER_GUIDE)