fix(niri): move the example toggle key and skip the portal in inject-probe on niri #47
+32
-4
@@ -39,7 +39,7 @@ The shipped bind:
|
||||
|
||||
```kdl
|
||||
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";
|
||||
}
|
||||
}
|
||||
@@ -47,9 +47,11 @@ binds {
|
||||
|
||||
`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.
|
||||
`Mod+Shift+M` was free in niri's stock binds and in Dank Material Shell's
|
||||
binds when this was written. To use another key, check it first (below).
|
||||
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 —
|
||||
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
|
||||
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
|
||||
|
||||
There is no permission prompt on niri. The RemoteDesktop portal is never
|
||||
|
||||
@@ -63,10 +63,12 @@ with the installed build's interpreter.
|
||||
version-probe result and timing, then a session-establishment attempt
|
||||
(fast failure versus hang matters more than the version answer), plus
|
||||
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
|
||||
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.
|
||||
lower bound. On niri the portal probe is skipped, as the injector skips
|
||||
it, and the report says so with `portal_skip_reason: "niri"` and
|
||||
`portal_available: false`. End-to-end key-to-screen latency is
|
||||
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
|
||||
the maximum observed per-rung footprint (transcribe peak, not just
|
||||
load) plus explicit headroom for the compositor and other GPU
|
||||
|
||||
@@ -11,12 +11,16 @@
|
||||
//
|
||||
// One key is either a compositor bind or an in-app Voculinux shortcut,
|
||||
// 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 {
|
||||
// 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" {
|
||||
Mod+Shift+M repeat=false hotkey-overlay-title="Toggle Voculinux dictation" {
|
||||
spawn "voculinux" "--toggle";
|
||||
}
|
||||
|
||||
@@ -24,13 +28,13 @@ binds {
|
||||
// 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" {
|
||||
// Mod+Shift+X 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" {
|
||||
// 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";
|
||||
// }
|
||||
}
|
||||
|
||||
@@ -433,8 +433,11 @@ def cmd_inject_probe(args: argparse.Namespace) -> int:
|
||||
|
||||
Never emits keystrokes: end-to-end key-to-screen latency would type
|
||||
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"
|
||||
started = time.monotonic()
|
||||
@@ -462,16 +465,20 @@ def cmd_inject_probe(args: argparse.Namespace) -> int:
|
||||
env=host_env(),
|
||||
)
|
||||
wtype_roundtrips.append(time.monotonic() - begin)
|
||||
try:
|
||||
from vocalinux.text_injection.remote_desktop_portal import RemoteDesktopPortal
|
||||
portal_available = False
|
||||
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_available = RemoteDesktopPortal().probe()
|
||||
portal_seconds = time.monotonic() - portal_started
|
||||
except Exception as exc: # noqa: BLE001 - probe must never fail the pass
|
||||
portal_available = False
|
||||
portal_seconds = None
|
||||
print(f"portal probe raised: {exc}", file=sys.stderr)
|
||||
portal_started = time.monotonic()
|
||||
portal_available = RemoteDesktopPortal().probe()
|
||||
portal_seconds = time.monotonic() - portal_started
|
||||
except Exception as exc: # noqa: BLE001 - probe must never fail the pass
|
||||
portal_available = False
|
||||
portal_seconds = None
|
||||
print(f"portal probe raised: {exc}", file=sys.stderr)
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
@@ -481,6 +488,7 @@ def cmd_inject_probe(args: argparse.Namespace) -> int:
|
||||
"wtype_empty_roundtrip_stats": describe(wtype_roundtrips),
|
||||
"portal_available": portal_available,
|
||||
"portal_probe_seconds": portal_seconds,
|
||||
"portal_skip_reason": portal_skip_reason,
|
||||
},
|
||||
indent=2,
|
||||
sort_keys=True,
|
||||
|
||||
@@ -8,6 +8,7 @@ widening fails loudly. When #32 records DMS evidence, update the doc and
|
||||
this assertion together.
|
||||
"""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
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"
|
||||
|
||||
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"
|
||||
|
||||
|
||||
@@ -45,6 +49,19 @@ def test_include_bind_is_non_repeating() -> None:
|
||||
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:
|
||||
"""Only plain spawn actions; no compositor message APIs."""
|
||||
assert "niri msg" not in _read(NIRI_INCLUDE).lower()
|
||||
|
||||
@@ -7,7 +7,10 @@ helpers tested here. Tests run offline with fixture text only and never
|
||||
touch audio hardware, the GPU, or the network.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import importlib.util
|
||||
import json
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
@@ -134,3 +137,90 @@ DBUS_TOGGLE_LOG = """\
|
||||
def test_parse_wake_latencies_pairs_dbus_toggle_starts_only() -> None:
|
||||
"""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])
|
||||
|
||||
|
||||
#: 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
|
||||
|
||||
Reference in New Issue
Block a user