From 7596f8a9dcdc142ec1bde6e3b0b8c60b55eb239e Mon Sep 17 00:00:00 2001 From: EdisonJwa Date: Sat, 16 May 2026 00:31:57 +0800 Subject: [PATCH] fix(ui,voice): display 'Space' (not blank) in bind dialog; cut PTT poll latency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue 1: tapping Space in the PTT binding capture dialog set _captured to LogicalKeyboardKey.space.keyLabel which is ' ' (a single space character), rendering as a blank string in the 'Captured: ' display. Same problem for Enter, Tab, Backspace, etc. — Flutter's keyLabel returns the printable representation, not a readable name. Added _displayLabelForKey() with a table that maps whitespace and common special keys to canonical English labels matching the entries in crates/chanora_audio/src/ptt_backends/windows_keymap.rs (so the bridge resolves to the right VK_* on Windows). Pure modifier keys (shift / ctrl / alt / meta / caps / num / scroll lock) return null so they don't accidentally bind on their own. Issue 2: physical key press -> 'PTT=on' in the Voice Bar lagged by up to ~500 ms because audioStats was polled at 500 ms intervals. The Rust-side transition is microsecond-fast; the visible delay is purely the Flutter poll interval. Cut to 80 ms (~12 Hz), well below the perceptual lag threshold. Adds ~12 small FFI calls per second, trivially cheap. A push-based BridgeEvent::TransmitActiveChanged would let us drop the poll entirely; noted as a follow-up. flutter analyze: clean (6 pre-existing Radio.groupValue infos). --- apps/chanora_flutter/lib/main.dart | 77 +++++++++++++++++++++++++++--- 1 file changed, 71 insertions(+), 6 deletions(-) diff --git a/apps/chanora_flutter/lib/main.dart b/apps/chanora_flutter/lib/main.dart index a7f848d..aed2f87 100644 --- a/apps/chanora_flutter/lib/main.dart +++ b/apps/chanora_flutter/lib/main.dart @@ -246,7 +246,16 @@ class _BetaHomeState extends State<_BetaHome> { void _ensureStatsTimer() { if (_statsTimer != null) return; - _statsTimer = Timer.periodic(const Duration(milliseconds: 500), (_) async { + // Poll at 80 ms (~12 Hz) — this is the loop that drives the + // Voice Bar's "PTT=on/off" indicator and the level meter, so + // it needs to be fast enough that users don't perceive a lag + // between physically pressing the bound key and seeing the + // UI change. 80 ms is well below the ~150 ms perceptual + // delay threshold and adds only a dozen tiny FFI calls per + // second to the load. A future push-based BridgeEvent for + // transmit-active transitions would let us drop this poll + // entirely. + _statsTimer = Timer.periodic(const Duration(milliseconds: 80), (_) async { try { final s = await rust.audioStats(); if (!mounted) return; @@ -1273,17 +1282,73 @@ class _PttBindingCaptureDialogState extends State<_PttBindingCaptureDialog> { KeyEventResult _onKeyEvent(FocusNode node, KeyEvent event) { if (event is! KeyDownEvent) return KeyEventResult.ignored; - // Skip modifier-only presses so the user can chord into the - // real binding. - final keyLabel = event.logicalKey.keyLabel; - if (keyLabel.isEmpty) return KeyEventResult.ignored; + final label = _displayLabelForKey(event.logicalKey); + if (label == null) return KeyEventResult.ignored; setState(() { - _captured = keyLabel; + _captured = label; _capturedClass = rust.BridgePttInputClass.keyboard; }); return KeyEventResult.handled; } + /// Translate a [LogicalKeyboardKey] into the platform-neutral + /// label string the bridge expects (matching the entries in + /// `crates/chanora_audio/src/ptt_backends/windows_keymap.rs`). + /// + /// `LogicalKeyboardKey.keyLabel` returns `" "` for Space, empty + /// for pure modifiers (shift / ctrl / alt / meta), and localised + /// strings for some special keys; we normalise to the canonical + /// English label so the badge displays something readable AND so + /// the Windows backend's keymap can resolve to a `VK_*`. Pure + /// modifier keys are intentionally rejected — chording into the + /// real binding (e.g. Ctrl+Shift+M) is supported by ignoring the + /// individual modifier-down events. + String? _displayLabelForKey(LogicalKeyboardKey k) { + // Whitespace / common control keys whose keyLabel is unhelpful. + if (k == LogicalKeyboardKey.space) return 'Space'; + if (k == LogicalKeyboardKey.enter || k == LogicalKeyboardKey.numpadEnter) { + return 'Enter'; + } + if (k == LogicalKeyboardKey.tab) return 'Tab'; + if (k == LogicalKeyboardKey.escape) return 'Escape'; + if (k == LogicalKeyboardKey.backspace) return 'Backspace'; + if (k == LogicalKeyboardKey.delete) return 'Delete'; + if (k == LogicalKeyboardKey.insert) return 'Insert'; + if (k == LogicalKeyboardKey.home) return 'Home'; + if (k == LogicalKeyboardKey.end) return 'End'; + if (k == LogicalKeyboardKey.pageUp) return 'Page Up'; + if (k == LogicalKeyboardKey.pageDown) return 'Page Down'; + if (k == LogicalKeyboardKey.arrowUp) return 'Arrow Up'; + if (k == LogicalKeyboardKey.arrowDown) return 'Arrow Down'; + if (k == LogicalKeyboardKey.arrowLeft) return 'Arrow Left'; + if (k == LogicalKeyboardKey.arrowRight) return 'Arrow Right'; + // Pure modifier keys are not bindable on their own (user can + // still chord by pressing a non-modifier while holding them). + if (k == LogicalKeyboardKey.shift || + k == LogicalKeyboardKey.shiftLeft || + k == LogicalKeyboardKey.shiftRight || + k == LogicalKeyboardKey.control || + k == LogicalKeyboardKey.controlLeft || + k == LogicalKeyboardKey.controlRight || + k == LogicalKeyboardKey.alt || + k == LogicalKeyboardKey.altLeft || + k == LogicalKeyboardKey.altRight || + k == LogicalKeyboardKey.meta || + k == LogicalKeyboardKey.metaLeft || + k == LogicalKeyboardKey.metaRight || + k == LogicalKeyboardKey.capsLock || + k == LogicalKeyboardKey.numLock || + k == LogicalKeyboardKey.scrollLock) { + return null; + } + // Fall back to keyLabel for letters, digits, function keys, + // numpad digits, and punctuation. Trim whitespace as a final + // belt-and-braces guard. + final fallback = k.keyLabel.trim(); + if (fallback.isEmpty) return null; + return fallback; + } + void _captureMouseSideButton(int button) { setState(() { _captured = 'mouse-side-button:$button';