fix(niri): conflict-free example keybinds and a niri-aware inject probe (#47)
CI / security (push) Successful in 1m26s
CI / lint (push) Successful in 21s
CI / ai-review (push) Skipped

The niri example binds move to Mod+Shift+M (toggle), Mod+Shift+S (start) and Mod+Shift+X (stop), free in stock niri and in Dank Material Shell's binds. inject-probe reuses the injector's niri guard, so it no longer reports the portal on niri.

Fixes #45
Fixes #46
This commit was merged in pull request #47.
This commit is contained in:
2026-11-06 18:29:29 +00:00
parent a4a831c3c6
commit a451e1d512
6 changed files with 170 additions and 21 deletions
+32 -4
View File
@@ -39,7 +39,7 @@ The shipped bind:
```kdl ```kdl
binds { binds {
Mod+Shift+D repeat=false hotkey-overlay-title="Toggle Voculinux dictation" { Mod+Shift+M repeat=false hotkey-overlay-title="Toggle Voculinux dictation" {
spawn "voculinux" "--toggle"; spawn "voculinux" "--toggle";
} }
} }
@@ -47,9 +47,11 @@ binds {
`repeat=false` is what makes it toggle once per press: without it, holding `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. 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+Shift+M` was free in niri's stock binds and in Dank Material Shell's
`Mod+H`), and uncomment the `--start` / `--stop` variants in the include binds when this was written. To use another key, check it first (below).
file if you want dedicated keys. The bind's title shows in niri's hotkey overlay (`Mod+Shift+Slash`). The
include file's commented `--start` / `--stop` variants use `Mod+Shift+S` and
`Mod+Shift+X`, which were free in the same way.
A key is either a compositor bind or an in-app Voculinux shortcut — 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 never both. With External Activation on, do not keep the same key configured
@@ -57,6 +59,32 @@ 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 claimed on both sides fires twice: the compositor toggles over D-Bus while
the internal listener fires on its own. the internal listener fires on its own.
## Check a key before you bind it
`niri validate` rejects a key bound twice inside one `binds {}` block, but
not a key bound in two blocks: a bind in your include file and one in
`dms/binds.kdl` both pass validation with no warning. So validation cannot
tell you whether a key is free. Check with grep, against your own config and
niri's stock binds. Run this before you copy the include file, or your own
toggle bind will match:
```bash
KEY_RE='(Mod|Super)\+Shift\+M([[:space:]{]|$)' # the key you want to check
grep -rniE --include='*.kdl' "$KEY_RE" ~/.config/niri/
strings "$(command -v niri)" | grep -iE "^[[:space:]]*(//[[:space:]]*)?$KEY_RE"
```
Matching ignores case, because niri also accepts `mod+shift+m`. `strings`
comes from binutils. The first command searches your config. `~/.config/niri/` holds
`config.kdl` and the DMS-managed `dms/binds.kdl`, which `config.kdl`
includes. `--include='*.kdl'` skips the `binds.kdl.pre-*` backups. If you
include files from elsewhere, add their paths. The second command searches
the stock defaults embedded in the niri binary. Commented lines match too, so
a hit can be a bind you have commented out. No output from either command
means the key is free. For a hit, remove or move the other bind, because niri does not warn
about a key bound in two places. `Mod` and `Super` are both checked because
niri reads `Mod` as Super on a TTY.
## First dictation ## First dictation
There is no permission prompt on niri. The RemoteDesktop portal is never There is no permission prompt on niri. The RemoteDesktop portal is never
+6 -4
View File
@@ -63,10 +63,12 @@ with the installed build's interpreter.
version-probe result and timing, then a session-establishment attempt version-probe result and timing, then a session-establishment attempt
(fast failure versus hang matters more than the version answer), plus (fast failure versus hang matters more than the version answer), plus
the resolved first rung and a no-emission `wtype ""` spawn cost as a the resolved first rung and a no-emission `wtype ""` spawn cost as a
lower bound. End-to-end key-to-screen latency is deliberately not lower bound. On niri the portal probe is skipped, as the injector skips
taken here: emitting keystrokes into a live desktop from a background it, and the report says so with `portal_skip_reason: "niri"` and
run would disturb the session. It belongs to the hardware checklist `portal_available: false`. End-to-end key-to-screen latency is
with a dedicated target window. deliberately not taken here: emitting keystrokes into a live desktop
from a background run would disturb the session. It belongs to the
hardware checklist with a dedicated target window.
- **Thresholds for the model ladder:** derive free-memory cutoffs from - **Thresholds for the model ladder:** derive free-memory cutoffs from
the maximum observed per-rung footprint (transcribe peak, not just the maximum observed per-rung footprint (transcribe peak, not just
load) plus explicit headroom for the compositor and other GPU load) plus explicit headroom for the compositor and other GPU
+7 -3
View File
@@ -11,12 +11,16 @@
// //
// One key is either a compositor bind or an in-app Voculinux shortcut, // One key is either a compositor bind or an in-app Voculinux shortcut,
// never both: do not keep this key configured under Settings → Shortcuts. // never both: do not keep this key configured under Settings → Shortcuts.
//
// The keys below were free in niri's stock binds and in Dank Material Shell's
// binds when this file was written. Before you use one, check it against your
// own config: docs/NIRI.md, "Check a key before you bind it".
binds { binds {
// Toggle dictation, exactly once per press. repeat=false stops key // Toggle dictation, exactly once per press. repeat=false stops key
// repeat from firing the spawn again while the key is held (which // repeat from firing the spawn again while the key is held (which
// would toggle dictation back off before you let go). // would toggle dictation back off before you let go).
Mod+Shift+D repeat=false hotkey-overlay-title="Toggle Voculinux dictation" { Mod+Shift+M repeat=false hotkey-overlay-title="Toggle Voculinux dictation" {
spawn "voculinux" "--toggle"; spawn "voculinux" "--toggle";
} }
@@ -24,13 +28,13 @@ binds {
// Mod+Shift+S repeat=false hotkey-overlay-title="Start Voculinux dictation" { // Mod+Shift+S repeat=false hotkey-overlay-title="Start Voculinux dictation" {
// spawn "voculinux" "--start"; // spawn "voculinux" "--start";
// } // }
// Mod+Shift+E repeat=false hotkey-overlay-title="Stop Voculinux dictation" { // Mod+Shift+X repeat=false hotkey-overlay-title="Stop Voculinux dictation" {
// spawn "voculinux" "--stop"; // spawn "voculinux" "--stop";
// } // }
// Flatpak installs: voculinux is not on the host PATH, so bind the // Flatpak installs: voculinux is not on the host PATH, so bind the
// host D-Bus call instead (same toggle, no extra sandbox per press). // host D-Bus call instead (same toggle, no extra sandbox per press).
// Mod+Shift+D repeat=false hotkey-overlay-title="Toggle Voculinux dictation" { // Mod+Shift+M 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"; // spawn "gdbus" "call" "--session" "--dest" "com.voculinux.Voculinux" "--object-path" "/com/voculinux/Voculinux" "--method" "com.voculinux.Voculinux.Toggle";
// } // }
} }
+18 -10
View File
@@ -433,8 +433,11 @@ def cmd_inject_probe(args: argparse.Namespace) -> int:
Never emits keystrokes: end-to-end key-to-screen latency would type Never emits keystrokes: end-to-end key-to-screen latency would type
into the live desktop, so it stays a hardware-checklist item (#32). into the live desktop, so it stays a hardware-checklist item (#32).
On niri the portal is skipped with the injector's own niri guard: the
injector never offers the portal there, so the report must not claim it.
""" """
from vocalinux.text_injection.text_injector import TextInjector from vocalinux.text_injection.text_injector import TextInjector, _is_niri_session
probe_text = "the quick brown fox jumps over the lazy dog 0123456789" probe_text = "the quick brown fox jumps over the lazy dog 0123456789"
started = time.monotonic() started = time.monotonic()
@@ -462,16 +465,20 @@ def cmd_inject_probe(args: argparse.Namespace) -> int:
env=host_env(), env=host_env(),
) )
wtype_roundtrips.append(time.monotonic() - begin) wtype_roundtrips.append(time.monotonic() - begin)
try: portal_available = False
from vocalinux.text_injection.remote_desktop_portal import RemoteDesktopPortal portal_seconds = None
portal_skip_reason = "niri" if _is_niri_session() else None
if portal_skip_reason is None:
try:
from vocalinux.text_injection.remote_desktop_portal import RemoteDesktopPortal
portal_started = time.monotonic() portal_started = time.monotonic()
portal_available = RemoteDesktopPortal().probe() portal_available = RemoteDesktopPortal().probe()
portal_seconds = time.monotonic() - portal_started portal_seconds = time.monotonic() - portal_started
except Exception as exc: # noqa: BLE001 - probe must never fail the pass except Exception as exc: # noqa: BLE001 - probe must never fail the pass
portal_available = False portal_available = False
portal_seconds = None portal_seconds = None
print(f"portal probe raised: {exc}", file=sys.stderr) print(f"portal probe raised: {exc}", file=sys.stderr)
print( print(
json.dumps( json.dumps(
{ {
@@ -481,6 +488,7 @@ def cmd_inject_probe(args: argparse.Namespace) -> int:
"wtype_empty_roundtrip_stats": describe(wtype_roundtrips), "wtype_empty_roundtrip_stats": describe(wtype_roundtrips),
"portal_available": portal_available, "portal_available": portal_available,
"portal_probe_seconds": portal_seconds, "portal_probe_seconds": portal_seconds,
"portal_skip_reason": portal_skip_reason,
}, },
indent=2, indent=2,
sort_keys=True, sort_keys=True,
+17
View File
@@ -8,6 +8,7 @@ widening fails loudly. When #32 records DMS evidence, update the doc and
this assertion together. this assertion together.
""" """
import re
from pathlib import Path from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1] REPO_ROOT = Path(__file__).resolve().parents[1]
@@ -18,6 +19,9 @@ USER_GUIDE = REPO_ROOT / "docs" / "USER_GUIDE.md"
TRAY_INDICATOR = REPO_ROOT / "src" / "vocalinux" / "ui" / "tray_indicator.py" TRAY_INDICATOR = REPO_ROOT / "src" / "vocalinux" / "ui" / "tray_indicator.py"
TOGGLE_SPAWN = 'spawn "voculinux" "--toggle"' TOGGLE_SPAWN = 'spawn "voculinux" "--toggle"'
TOGGLE_HEADER = re.compile(r"^(\S+) repeat=false")
#: Move Column Right in Dank Material Shell's binds; the shipped key must not reuse it (#45).
DMS_MOVE_COLUMN_RIGHT_KEY = "Mod+Shift+D"
CURRENT_WATCHER = "org.kde.StatusNotifierWatcher" CURRENT_WATCHER = "org.kde.StatusNotifierWatcher"
@@ -45,6 +49,19 @@ def test_include_bind_is_non_repeating() -> None:
assert "repeat=false" in _read(NIRI_INCLUDE) assert "repeat=false" in _read(NIRI_INCLUDE)
def test_doc_and_include_share_a_toggle_key_not_taken_by_dms() -> None:
"""The doc's example key is the include file's toggle key, and it is not in use (#45)."""
keys = [
match.group(1)
for match in (TOGGLE_HEADER.match(line) for line in _active_lines(_read(NIRI_INCLUDE)))
if match
]
assert len(keys) == 1, "the include file should ship exactly one toggle bind"
key = keys[0]
assert key != DMS_MOVE_COLUMN_RIGHT_KEY
assert f"{key} repeat=false" in _read(NIRI_DOC)
def test_include_file_uses_no_compositor_internals() -> None: def test_include_file_uses_no_compositor_internals() -> None:
"""Only plain spawn actions; no compositor message APIs.""" """Only plain spawn actions; no compositor message APIs."""
assert "niri msg" not in _read(NIRI_INCLUDE).lower() assert "niri msg" not in _read(NIRI_INCLUDE).lower()
+90
View File
@@ -7,7 +7,10 @@ helpers tested here. Tests run offline with fixture text only and never
touch audio hardware, the GPU, or the network. touch audio hardware, the GPU, or the network.
""" """
import argparse
import importlib.util import importlib.util
import json
import shutil
from pathlib import Path from pathlib import Path
import pytest import pytest
@@ -134,3 +137,90 @@ DBUS_TOGGLE_LOG = """\
def test_parse_wake_latencies_pairs_dbus_toggle_starts_only() -> None: def test_parse_wake_latencies_pairs_dbus_toggle_starts_only() -> None:
"""Only IDLE toggles open capture; a stop toggle never borrows the next run.""" """Only IDLE toggles open capture; a stop toggle never borrows the next run."""
assert PROTOCOL.parse_wake_latencies(DBUS_TOGGLE_LOG) == pytest.approx([0.146, 0.649]) assert PROTOCOL.parse_wake_latencies(DBUS_TOGGLE_LOG) == pytest.approx([0.146, 0.649])
#: Session variables the injector's niri guard reads, plus Wayland; cleared so
#: the host's own session (this machine runs niri) cannot leak into a test.
_SESSION_ENV = (
"NIRI_SOCKET",
"XDG_CURRENT_DESKTOP",
"XDG_SESSION_DESKTOP",
"DESKTOP_SESSION",
"WAYLAND_DISPLAY",
)
class _FakeInjector:
"""Stands in for TextInjector: a Wayland session that selected wtype."""
wayland_mode = True
wayland_tool = "wtype"
def _run_inject_probe(
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
session_env: dict[str, str],
portal_answers: bool,
) -> tuple[dict, list[bool]]:
"""Run inject-probe under a fake session; return its report and portal probe calls."""
from vocalinux.text_injection import remote_desktop_portal, text_injector
probes: list[bool] = []
class _SpyPortal:
def probe(self) -> bool:
probes.append(True)
return portal_answers
for name in _SESSION_ENV:
monkeypatch.delenv(name, raising=False)
for name, value in session_env.items():
monkeypatch.setenv(name, value)
monkeypatch.setattr(text_injector, "TextInjector", _FakeInjector)
monkeypatch.setattr(remote_desktop_portal, "RemoteDesktopPortal", _SpyPortal)
monkeypatch.setattr(shutil, "which", lambda _name: None)
assert PROTOCOL.cmd_inject_probe(argparse.Namespace()) == 0
return json.loads(capsys.readouterr().out), probes
@pytest.mark.parametrize(
"session_env",
[
{"XDG_CURRENT_DESKTOP": "niri"},
{"NIRI_SOCKET": "/run/user/1000/niri.wayland-1.1.sock"},
],
)
def test_inject_probe_never_probes_portal_on_niri(
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
session_env: dict[str, str],
) -> None:
"""The injector never offers the portal on niri, so the probe must not claim it.
Both niri signals are covered: the probe reuses the injector's guard, so a
session the injector treats as niri is reported the same way here.
"""
report, probes = _run_inject_probe(
monkeypatch, capsys, session_env=session_env, portal_answers=True
)
assert probes == []
assert report["portal_available"] is False
assert report["portal_probe_seconds"] is None
assert report["portal_skip_reason"] == "niri"
def test_inject_probe_reports_portal_answer_off_niri(
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
"""Off niri the portal is probed and its answer is reported unchanged."""
report, probes = _run_inject_probe(
monkeypatch,
capsys,
session_env={"XDG_CURRENT_DESKTOP": "GNOME"},
portal_answers=True,
)
assert probes == [True]
assert report["portal_available"] is True
assert report["portal_skip_reason"] is None