fix(niri): conflict-free example keybinds and a niri-aware inject probe (#47)
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:
+32
-4
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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";
|
||||||
// }
|
// }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user