diff --git a/apps/chanora_flutter/lib/l10n/app_en.arb b/apps/chanora_flutter/lib/l10n/app_en.arb index d2b0614..5a15685 100644 --- a/apps/chanora_flutter/lib/l10n/app_en.arb +++ b/apps/chanora_flutter/lib/l10n/app_en.arb @@ -40,6 +40,12 @@ "backend": { "type": "String" } } }, + "pttCapabilityBoundKey": "Key: {key}", + "@pttCapabilityBoundKey": { + "placeholders": { + "key": { "type": "String" } + } + }, "pttCapabilityExplainTitle": "Push-to-Talk capability", "pttCapabilityExplainFocusedHeading": "Focused PTT", "pttCapabilityExplainFocusedBody": "Chanora is currently using Focused Push-to-Talk: the binding only fires while the Chanora window is focused. This is the universal fallback used on every platform when a global capture path is not available.", diff --git a/apps/chanora_flutter/lib/l10n/app_zh.arb b/apps/chanora_flutter/lib/l10n/app_zh.arb index 7847c6a..bea46a8 100644 --- a/apps/chanora_flutter/lib/l10n/app_zh.arb +++ b/apps/chanora_flutter/lib/l10n/app_zh.arb @@ -30,6 +30,7 @@ "pttTransmitting": "正在发送…", "pttHoldToTalkSemanticsHint": "按住进行语音发送,松开停止。", "pttCapabilityBadge": "对讲能力:{level}({backend})", + "pttCapabilityBoundKey": "按键:{key}", "pttCapabilityExplainTitle": "对讲能力说明", "pttCapabilityExplainFocusedHeading": "聚焦对讲", "pttCapabilityExplainFocusedBody": "Chanora 当前使用聚焦对讲:按键只在 Chanora 窗口处于聚焦时生效。这是所有平台在无法启用全局采集时的通用回退方案。", diff --git a/apps/chanora_flutter/lib/l10n/generated/app_localizations.dart b/apps/chanora_flutter/lib/l10n/generated/app_localizations.dart index 4abfc6d..02a0c32 100644 --- a/apps/chanora_flutter/lib/l10n/generated/app_localizations.dart +++ b/apps/chanora_flutter/lib/l10n/generated/app_localizations.dart @@ -253,6 +253,12 @@ abstract class AppL10n { /// **'PTT: {level} ({backend})'** String pttCapabilityBadge(String level, String backend); + /// No description provided for @pttCapabilityBoundKey. + /// + /// In en, this message translates to: + /// **'Key: {key}'** + String pttCapabilityBoundKey(String key); + /// No description provided for @pttCapabilityExplainTitle. /// /// In en, this message translates to: diff --git a/apps/chanora_flutter/lib/l10n/generated/app_localizations_en.dart b/apps/chanora_flutter/lib/l10n/generated/app_localizations_en.dart index 06cbc3c..b134b4e 100644 --- a/apps/chanora_flutter/lib/l10n/generated/app_localizations_en.dart +++ b/apps/chanora_flutter/lib/l10n/generated/app_localizations_en.dart @@ -96,6 +96,11 @@ class AppL10nEn extends AppL10n { return 'PTT: $level ($backend)'; } + @override + String pttCapabilityBoundKey(String key) { + return 'Key: $key'; + } + @override String get pttCapabilityExplainTitle => 'Push-to-Talk capability'; diff --git a/apps/chanora_flutter/lib/l10n/generated/app_localizations_zh.dart b/apps/chanora_flutter/lib/l10n/generated/app_localizations_zh.dart index 03870cb..67db686 100644 --- a/apps/chanora_flutter/lib/l10n/generated/app_localizations_zh.dart +++ b/apps/chanora_flutter/lib/l10n/generated/app_localizations_zh.dart @@ -93,6 +93,11 @@ class AppL10nZh extends AppL10n { return '对讲能力:$level($backend)'; } + @override + String pttCapabilityBoundKey(String key) { + return '按键:$key'; + } + @override String get pttCapabilityExplainTitle => '对讲能力说明'; diff --git a/apps/chanora_flutter/lib/main.dart b/apps/chanora_flutter/lib/main.dart index a38102f..87c3f56 100644 --- a/apps/chanora_flutter/lib/main.dart +++ b/apps/chanora_flutter/lib/main.dart @@ -115,6 +115,16 @@ class _BetaHomeState extends State<_BetaHome> { String _pttLevel = 'L0Focused'; String _pttBackendId = 'focused'; String _pttBoundInputClass = 'keyboard'; + // Last platform-neutral key label the user saved in the + // `_PttBindingCaptureDialog` (e.g. "Space", "F10", + // "mouse-side-button:8"). Surfaced next to the capability + // badge so the user can remember which key drives PTT. The + // bridge-side `PttController` (SDD-088) holds the authoritative + // binding; this is a display-only cache that resets on app + // restart. Per DEC-027 the raw OS key code never lives here — + // only the platform-neutral label that already crossed into + // the Rust side. + String _pttBoundKeyLabel = ''; List _bookmarks = const []; @@ -435,6 +445,15 @@ class _BetaHomeState extends State<_BetaHome> { inputClass: binding.inputClass, platformKey: binding.platformKey, ); + // Cache the captured label so the badge can surface "Key: + // Space" / "Key: Mouse4" next to the capability descriptor. + // The bridge holds the authoritative binding; this is only + // for display continuity until the next app restart. + if (mounted) { + setState(() { + _pttBoundKeyLabel = binding.platformKey; + }); + } } catch (e) { if (!mounted) return; // Surface the failure as a snackbar so the user sees that @@ -723,6 +742,7 @@ class _BetaHomeState extends State<_BetaHome> { pttLevel: _pttLevel, pttBackendId: _pttBackendId, pttBoundInputClass: _pttBoundInputClass, + pttBoundKeyLabel: _pttBoundKeyLabel, onPttDown: () => _setPtt(true), onPttUp: () => _setPtt(false), onToggleInputMute: _toggleInputMute, @@ -881,6 +901,7 @@ class _AudioControls extends StatefulWidget { required this.pttLevel, required this.pttBackendId, required this.pttBoundInputClass, + required this.pttBoundKeyLabel, required this.onPttDown, required this.onPttUp, required this.onToggleInputMute, @@ -896,6 +917,7 @@ class _AudioControls extends StatefulWidget { final String pttLevel; final String pttBackendId; final String pttBoundInputClass; + final String pttBoundKeyLabel; final VoidCallback onPttDown; final VoidCallback onPttUp; final VoidCallback onToggleInputMute; @@ -935,6 +957,7 @@ class _AudioControlsState extends State<_AudioControls> { level: widget.pttLevel, backendId: widget.pttBackendId, boundInputClass: widget.pttBoundInputClass, + boundKeyLabel: widget.pttBoundKeyLabel, onConfigure: widget.onConfigurePtt, ), // Accessibility (SysRS-262 + SysRS-282 + SysRS-263): @@ -1069,6 +1092,7 @@ class PttCapabilityBadge extends StatelessWidget { required this.level, required this.backendId, required this.boundInputClass, + required this.boundKeyLabel, required this.onConfigure, }); @@ -1084,6 +1108,14 @@ class PttCapabilityBadge extends StatelessWidget { /// or empty when no binding is set). final String boundInputClass; + /// Platform-neutral key label captured by the binding dialog + /// (e.g. `"Space"`, `"F10"`, `"mouse-side-button:8"`). Empty + /// when the user has not saved a binding in the current + /// process. Display-only; the bridge holds the authoritative + /// binding. Per DEC-027 this string is the same one already + /// crossed into the Rust side — no new privacy surface. + final String boundKeyLabel; + /// Open the configure-binding dialog. Wired by the caller. final VoidCallback onConfigure; @@ -1165,38 +1197,59 @@ class PttCapabilityBadge extends StatelessWidget { : '$badgeLabel\n($boundInputClass)'; return Padding( padding: const EdgeInsets.only(bottom: 6), - child: Tooltip( - message: tooltipMessage, - child: Row( - children: [ - Icon( - _isFocused ? Icons.crop_free : Icons.public, - size: 14, - color: theme.colorScheme.onSurfaceVariant, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Tooltip( + message: tooltipMessage, + child: Row( + children: [ + Icon( + _isFocused ? Icons.crop_free : Icons.public, + size: 14, + color: theme.colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 4), + Expanded( + child: Text( + badgeLabel, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + if (_isFocused) + IconButton( + icon: const Icon(Icons.info_outline, size: 16), + tooltip: l10n.pttCapabilityExplainTitle, + visualDensity: VisualDensity.compact, + onPressed: () => _openExplanationSheet(context), + ), + TextButton.icon( + icon: const Icon(Icons.tune, size: 14), + label: Text(l10n.pttConfigureAction), + onPressed: onConfigure, + ), + ], ), - const SizedBox(width: 4), - Expanded( + ), + // Second line: show which key the user just bound, so + // they can remember what to press. Only rendered when a + // binding has been saved in the current process (the + // bridge holds the authoritative binding across restarts; + // this label is display-only). + if (boundKeyLabel.isNotEmpty) + Padding( + padding: const EdgeInsets.only(left: 18, top: 2), child: Text( - badgeLabel, + l10n.pttCapabilityBoundKey(boundKeyLabel), style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, + fontFamily: 'monospace', ), ), ), - if (_isFocused) - IconButton( - icon: const Icon(Icons.info_outline, size: 16), - tooltip: l10n.pttCapabilityExplainTitle, - visualDensity: VisualDensity.compact, - onPressed: () => _openExplanationSheet(context), - ), - TextButton.icon( - icon: const Icon(Icons.tune, size: 14), - label: Text(l10n.pttConfigureAction), - onPressed: onConfigure, - ), - ], - ), + ], ), ); }