diff --git a/openless-all/app/src-tauri/src/commands/hotkeys.rs b/openless-all/app/src-tauri/src/commands/hotkeys.rs index 3a7475c80..4b7a7cbfa 100644 --- a/openless-all/app/src-tauri/src/commands/hotkeys.rs +++ b/openless-all/app/src-tauri/src/commands/hotkeys.rs @@ -27,6 +27,7 @@ pub fn set_dictation_hotkey( reject_dictation_less_computer_hotkey_overlap(&binding, less_computer)?; } reject_existing_selection_polish_hotkey_overlap(&binding, &prefs)?; + reject_existing_style_pack_hotkey_overlap(&binding, &prefs)?; prefs.dictation_hotkey = binding; sync_dictation_hotkey_legacy_fields(&mut prefs); coord.prefs().set(prefs).map_err(|e| e.to_string())?; @@ -57,6 +58,7 @@ pub fn set_translation_hotkey( reject_translation_less_computer_hotkey_overlap(&binding, less_computer)?; } reject_existing_selection_polish_hotkey_overlap(&binding, &previous)?; + reject_existing_style_pack_hotkey_overlap(&binding, &previous)?; let mut prefs = previous.clone(); prefs.translation_hotkey = binding; coord.prefs().set(prefs).map_err(|e| e.to_string())?; @@ -96,6 +98,7 @@ pub fn set_switch_style_hotkey( reject_less_computer_switch_style_hotkey_overlap(less_computer, binding)?; } reject_existing_selection_polish_hotkey_overlap(binding, &prefs)?; + reject_existing_style_pack_hotkey_overlap(binding, &prefs)?; } prefs.switch_style_hotkey = binding; coord.prefs().set(prefs).map_err(|e| e.to_string())?; @@ -128,6 +131,7 @@ pub fn set_open_app_hotkey( reject_less_computer_open_app_hotkey_overlap(less_computer, binding)?; } reject_existing_selection_polish_hotkey_overlap(binding, &prefs)?; + reject_existing_style_pack_hotkey_overlap(binding, &prefs)?; } prefs.open_app_hotkey = binding; coord.prefs().set(prefs).map_err(|e| e.to_string())?; @@ -169,7 +173,105 @@ pub fn set_selection_polish_hotkey( Ok(()) } -fn reject_modifier_only_action_shortcut(binding: &ShortcutBinding) -> Result<(), String> { +/// 整表替换风格包直达快捷键(issue #759)。前端任何增删改都发全量列表, +/// 校验通过才落库并热更新全局键注册;失败时旧绑定原样保留。 +#[tauri::command] +pub fn set_style_pack_hotkeys( + coord: CoordinatorState<'_>, + hotkeys: Vec, +) -> Result<(), String> { + let mut prefs = coord.prefs().get(); + reject_style_pack_hotkey_conflicts(&hotkeys, &prefs)?; + prefs.style_pack_hotkeys = hotkeys; + coord.prefs().set(prefs).map_err(|e| e.to_string())?; + coord.update_style_pack_hotkey_bindings(); + Ok(()) +} + +/// 风格包快捷键集合的全量校验:逐条格式校验 + 集合内去重(同包一条、同键一条) +/// + 与其它所有快捷键互斥。 +pub(crate) fn reject_style_pack_hotkey_conflicts( + hotkeys: &[StylePackHotkey], + prefs: &UserPreferences, +) -> Result<(), String> { + for (index, entry) in hotkeys.iter().enumerate() { + if entry.pack_id.trim().is_empty() { + return Err("风格快捷键必须选择一个风格包".into()); + } + crate::shortcut_binding::validate_binding(&entry.binding).map_err(|e| e.to_string())?; + crate::shortcut_binding::reject_side_specific_non_dictation(&entry.binding)?; + reject_modifier_only_action_shortcut(&entry.binding)?; + for other in &hotkeys[..index] { + if other.pack_id == entry.pack_id { + return Err("同一个风格包只能绑定一个快捷键".into()); + } + reject_hotkey_overlap( + &other.binding, + &entry.binding, + "两个风格快捷键不能使用相同按键", + )?; + } + reject_style_pack_hotkey_overlap_with_others(&entry.binding, prefs)?; + } + Ok(()) +} + +fn reject_style_pack_hotkey_overlap_with_others( + binding: &ShortcutBinding, + prefs: &UserPreferences, +) -> Result<(), String> { + reject_hotkey_overlap( + binding, + &prefs.dictation_hotkey, + "风格快捷键不能和听写快捷键相同", + )?; + reject_hotkey_overlap( + binding, + &prefs.translation_hotkey, + "风格快捷键不能和翻译快捷键相同", + )?; + if let Some(qa) = prefs.qa_hotkey.as_ref() { + reject_hotkey_overlap(binding, qa, "风格快捷键不能和 QA 快捷键相同")?; + } + if let Some(switch_style) = prefs.switch_style_hotkey.as_ref() { + reject_hotkey_overlap( + binding, + switch_style, + "风格快捷键不能和切换风格快捷键相同", + )?; + } + if let Some(open_app) = prefs.open_app_hotkey.as_ref() { + reject_hotkey_overlap(binding, open_app, "风格快捷键不能和打开应用快捷键相同")?; + } + if let Some(less_computer) = prefs.coding_agent_voice_hotkey.as_ref() { + reject_hotkey_overlap( + binding, + less_computer, + "风格快捷键不能和 Less Computer 快捷键相同", + )?; + } + if let Some(selection_polish) = prefs.selection_polish_hotkey.as_ref() { + reject_hotkey_overlap( + binding, + selection_polish, + "风格快捷键不能和选区润色快捷键相同", + )?; + } + Ok(()) +} + +/// 其它快捷键 setter 的反向检查:新绑定不得与任何已配置的风格快捷键重叠。 +pub(crate) fn reject_existing_style_pack_hotkey_overlap( + binding: &ShortcutBinding, + prefs: &UserPreferences, +) -> Result<(), String> { + for entry in &prefs.style_pack_hotkeys { + reject_hotkey_overlap(binding, &entry.binding, "该快捷键已被风格快捷键使用")?; + } + Ok(()) +} + +pub(crate) fn reject_modifier_only_action_shortcut(binding: &ShortcutBinding) -> Result<(), String> { if binding.modifiers.is_empty() && (binding.primary.eq_ignore_ascii_case("shift") || crate::shortcut_binding::legacy_modifier_trigger(binding).is_some()) @@ -213,6 +315,7 @@ pub fn set_combo_hotkey(coord: CoordinatorState<'_>, binding: ComboBinding) -> R reject_dictation_less_computer_hotkey_overlap(&shortcut, less_computer)?; } reject_existing_selection_polish_hotkey_overlap(&shortcut, &prefs)?; + reject_existing_style_pack_hotkey_overlap(&shortcut, &prefs)?; prefs.custom_combo_hotkey = Some(binding); prefs.dictation_hotkey = shortcut; sync_dictation_hotkey_legacy_fields(&mut prefs); @@ -317,6 +420,7 @@ pub(crate) fn reject_hotkey_collisions(prefs: &UserPreferences) -> Result<(), St if let Some(selection_polish) = prefs.selection_polish_hotkey.as_ref() { reject_selection_polish_hotkey_collisions(selection_polish, prefs)?; } + reject_style_pack_hotkey_conflicts(&prefs.style_pack_hotkeys, prefs)?; Ok(()) } @@ -358,6 +462,7 @@ pub(crate) fn reject_selection_polish_hotkey_collisions( "选区润色快捷键不能和 Less Computer 快捷键相同", )?; } + reject_existing_style_pack_hotkey_overlap(selection_polish, prefs)?; Ok(()) } @@ -583,6 +688,84 @@ mod tests { assert!(reject_hotkey_collisions(&prefs).is_ok()); } + fn style_hotkey(pack_id: &str, primary: &str) -> StylePackHotkey { + StylePackHotkey { + pack_id: pack_id.into(), + binding: ShortcutBinding { + primary: primary.into(), + modifiers: vec!["alt".into()], + }, + } + } + + #[test] + fn style_pack_hotkeys_reject_duplicates_and_overlaps() { + let prefs = UserPreferences { + dictation_hotkey: key("A"), + ..Default::default() + }; + // 基线:两条不同包、不同键 → 通过。 + assert!(reject_style_pack_hotkey_conflicts( + &[style_hotkey("builtin.raw", "1"), style_hotkey("imported.x", "2")], + &prefs, + ) + .is_ok()); + // 同一个包绑两条 → 拒绝。 + assert!(reject_style_pack_hotkey_conflicts( + &[style_hotkey("builtin.raw", "1"), style_hotkey("builtin.raw", "2")], + &prefs, + ) + .is_err()); + // 两条绑同一个键 → 拒绝。 + assert!(reject_style_pack_hotkey_conflicts( + &[style_hotkey("builtin.raw", "1"), style_hotkey("imported.x", "1")], + &prefs, + ) + .is_err()); + // 空 pack_id → 拒绝。 + assert!( + reject_style_pack_hotkey_conflicts(&[style_hotkey("", "1")], &prefs).is_err() + ); + // 与听写键重叠 → 拒绝。 + let clash = StylePackHotkey { + pack_id: "builtin.raw".into(), + binding: key("A"), + }; + assert!(reject_style_pack_hotkey_conflicts(&[clash], &prefs).is_err()); + } + + #[test] + fn existing_style_pack_hotkey_rejects_other_setters() { + let prefs = UserPreferences { + style_pack_hotkeys: vec![style_hotkey("builtin.raw", "1")], + ..Default::default() + }; + assert!(reject_existing_style_pack_hotkey_overlap( + &ShortcutBinding { + primary: "1".into(), + modifiers: vec!["alt".into()], + }, + &prefs, + ) + .is_err()); + assert!(reject_existing_style_pack_hotkey_overlap(&key("P"), &prefs).is_ok()); + } + + #[test] + fn reject_hotkey_collisions_covers_style_pack_hotkeys() { + let mut prefs = UserPreferences { + dictation_hotkey: key("A"), + style_pack_hotkeys: vec![style_hotkey("builtin.raw", "1")], + ..Default::default() + }; + assert!(reject_hotkey_collisions(&prefs).is_ok()); + prefs.style_pack_hotkeys.push(StylePackHotkey { + pack_id: "imported.x".into(), + binding: key("A"), + }); + assert!(reject_hotkey_collisions(&prefs).is_err()); + } + #[test] fn selection_polish_hotkey_collides_with_existing_shortcuts() { let binding = key("RightControl"); diff --git a/openless-all/app/src-tauri/src/commands/mod.rs b/openless-all/app/src-tauri/src/commands/mod.rs index 86a6c6e69..e265815fb 100644 --- a/openless-all/app/src-tauri/src/commands/mod.rs +++ b/openless-all/app/src-tauri/src/commands/mod.rs @@ -60,7 +60,8 @@ pub(crate) use crate::types::{ AndroidAccessibilityStatus, AndroidOverlayStatus, ChineseScriptPreference, ComboBinding, CorrectionRule, CredentialsStatus, DictationSession, DictionaryEntry, HotkeyCapability, HotkeyStatus, OutputLanguagePreference, - PolishMode, ShortcutBinding, StylePack, StylePackKind, StylePackRuntimeDiagnostics, + PolishMode, ShortcutBinding, StylePack, StylePackHotkey, StylePackKind, + StylePackRuntimeDiagnostics, StyleSystemPrompts, UpdateChannel, UserPreferences, VocabPresetStore, }; diff --git a/openless-all/app/src-tauri/src/commands/qa.rs b/openless-all/app/src-tauri/src/commands/qa.rs index d41ba946b..312e49168 100644 --- a/openless-all/app/src-tauri/src/commands/qa.rs +++ b/openless-all/app/src-tauri/src/commands/qa.rs @@ -34,6 +34,7 @@ pub fn set_qa_hotkey( reject_qa_less_computer_hotkey_overlap(binding, less_computer)?; } reject_existing_selection_polish_hotkey_overlap(binding, &prefs)?; + reject_existing_style_pack_hotkey_overlap(binding, &prefs)?; } prefs.qa_hotkey = binding; coord.prefs().set(prefs).map_err(|e| e.to_string())?; diff --git a/openless-all/app/src-tauri/src/commands/settings.rs b/openless-all/app/src-tauri/src/commands/settings.rs index b03d19e81..b48ccb73e 100644 --- a/openless-all/app/src-tauri/src/commands/settings.rs +++ b/openless-all/app/src-tauri/src/commands/settings.rs @@ -30,6 +30,8 @@ pub(crate) trait SettingsWriter { fn refresh_open_app_hotkey(&self); fn refresh_selection_polish_hotkey(&self); fn refresh_coding_agent_hotkey(&self); + // 默认 no-op:测试 mock 不关心风格快捷键;真实实现(Coordinator / Arc)覆写。 + fn refresh_style_pack_hotkeys(&self) {} } impl SettingsWriter for Coordinator { @@ -89,6 +91,10 @@ impl SettingsWriter for Coordinator { fn refresh_coding_agent_hotkey(&self) { self.update_coding_agent_hotkey_binding(); } + + fn refresh_style_pack_hotkeys(&self) { + self.update_style_pack_hotkey_bindings(); + } } impl SettingsWriter for Arc { @@ -142,6 +148,10 @@ impl SettingsWriter for Arc { fn refresh_coding_agent_hotkey(&self) { (**self).refresh_coding_agent_hotkey(); } + + fn refresh_style_pack_hotkeys(&self) { + (**self).refresh_style_pack_hotkeys(); + } } /// 非核心热键,用于保存兜底的冲突化解。dictation 是核心热键,永不参与调整。 @@ -249,6 +259,44 @@ pub(crate) fn reconcile_hotkey_collisions( higher.push(value); } } + // 风格包直达快捷键是最低优先级:与更高优先级键重叠、非法或集合内重复的条目, + // 先尝试恢复该风格包的旧绑定,仍不行则整条移除(不影响其余设置落盘)。 + let mut kept: Vec = Vec::new(); + for entry in &prefs.style_pack_hotkeys { + let candidate_ok = |candidate: &StylePackHotkey| { + !candidate.pack_id.trim().is_empty() + && crate::shortcut_binding::validate_binding(&candidate.binding).is_ok() + && crate::shortcut_binding::reject_side_specific_non_dictation(&candidate.binding) + .is_ok() + && reject_modifier_only_action_shortcut(&candidate.binding).is_ok() + && !kept.iter().any(|held: &StylePackHotkey| { + held.pack_id == candidate.pack_id + || crate::shortcut_binding::bindings_overlap( + &held.binding, + &candidate.binding, + ) + }) + && !higher.iter().any(|held| { + crate::shortcut_binding::bindings_overlap(held, &candidate.binding) + }) + }; + if candidate_ok(entry) { + kept.push(entry.clone()); + continue; + } + adjusted += 1; + if let Some(fallback) = previous + .style_pack_hotkeys + .iter() + .find(|old| old.pack_id == entry.pack_id) + .filter(|old| candidate_ok(old)) + { + kept.push(fallback.clone()); + } + } + if kept != prefs.style_pack_hotkeys { + prefs.style_pack_hotkeys = kept; + } adjusted } @@ -288,6 +336,7 @@ pub(crate) fn persist_settings_with_keyboard_apply( let translation_changed = previous.translation_hotkey != prefs.translation_hotkey; let switch_style_changed = previous.switch_style_hotkey != prefs.switch_style_hotkey; let open_app_changed = previous.open_app_hotkey != prefs.open_app_hotkey; + let style_pack_hotkeys_changed = previous.style_pack_hotkeys != prefs.style_pack_hotkeys; let selection_polish_changed = previous.selection_polish_hotkey != prefs.selection_polish_hotkey; let coding_agent_changed = previous.coding_agent_enabled != prefs.coding_agent_enabled @@ -378,6 +427,9 @@ pub(crate) fn persist_settings_with_keyboard_apply( if open_app_changed { coord.refresh_open_app_hotkey(); } + if style_pack_hotkeys_changed { + coord.refresh_style_pack_hotkeys(); + } if selection_polish_changed { coord.refresh_selection_polish_hotkey(); } diff --git a/openless-all/app/src-tauri/src/commands/style_packs.rs b/openless-all/app/src-tauri/src/commands/style_packs.rs index 9afb8e8ec..123a45cf3 100644 --- a/openless-all/app/src-tauri/src/commands/style_packs.rs +++ b/openless-all/app/src-tauri/src/commands/style_packs.rs @@ -210,12 +210,21 @@ pub fn delete_style_pack( .style_packs() .remove_imported(&id) .map_err(|e| e.to_string())?; + // 孤儿清理:删除包时一并移除指向它的风格快捷键,避免残留一条按了没反应的绑定。 + let hotkeys_before = prefs.style_pack_hotkeys.len(); + prefs.style_pack_hotkeys.retain(|entry| entry.pack_id != id); + let removed_hotkey = prefs.style_pack_hotkeys.len() != hotkeys_before; if prefs.active_style_pack_id == id { prefs.active_style_pack_id = default_active_style_pack_id(); let _ = sync_style_pack_prefs_and_persist(&*coord, &app, prefs)?; + } else if removed_hotkey { + let _ = sync_style_pack_prefs_and_persist(&*coord, &app, prefs)?; } else { refresh_tray_menu_async(&app); } + if removed_hotkey { + coord.update_style_pack_hotkey_bindings(); + } Ok(()) } diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 6915be840..18defea8c 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -617,6 +617,10 @@ struct Inner { translation_hotkey: Mutex>, switch_style_hotkey: Mutex>, open_app_hotkey: Mutex>, + /// 风格包直达快捷键监听器(issue #759):pack_id → monitor。数量随用户配置 + /// 动态增减,与固定槽位的 action hotkey 分开管理;更新策略为对照 prefs 全量 + /// 对齐(新增注册 / 改键 update / 删除 drop 反注册)。 + style_pack_hotkeys: Mutex>, /// 选区润色快捷键:modifier-only 复用 `HotkeyMonitor`,其它组合键复用 /// `ComboHotkeyMonitor`。桌面(非 mobile)专属。 #[cfg(not(mobile))] @@ -847,6 +851,7 @@ impl Coordinator { translation_hotkey: Mutex::new(None), switch_style_hotkey: Mutex::new(None), open_app_hotkey: Mutex::new(None), + style_pack_hotkeys: Mutex::new(std::collections::HashMap::new()), #[cfg(not(mobile))] selection_polish_hotkey: Mutex::new(None), #[cfg(not(mobile))] @@ -965,6 +970,7 @@ impl Coordinator { translation_hotkey: Mutex::new(None), switch_style_hotkey: Mutex::new(None), open_app_hotkey: Mutex::new(None), + style_pack_hotkeys: Mutex::new(std::collections::HashMap::new()), #[cfg(not(mobile))] selection_polish_hotkey: Mutex::new(None), #[cfg(not(mobile))] @@ -1310,6 +1316,25 @@ impl Coordinator { take_action_hotkey_on_main_thread(&self.inner, ActionHotkeyKind::OpenApp); } + /// 启动风格包直达快捷键监听(issue #759)。supervisor 线程等 AppHandle 就绪后 + /// 按 prefs 全量注册,个别注册失败按 action hotkey 的节奏重试。 + pub fn start_style_pack_hotkey_listeners(&self) { + let inner = Arc::clone(&self.inner); + std::thread::Builder::new() + .name("openless-style-pack-hotkey-supervisor".into()) + .spawn(move || style_pack_hotkey_supervisor_loop(inner)) + .ok(); + } + + pub fn stop_style_pack_hotkey_listeners(&self) { + clear_style_pack_hotkeys_on_main_thread(&self.inner); + } + + /// 用户在设置里改了风格快捷键列表时调用:按最新 prefs 全量对齐注册状态。 + pub fn update_style_pack_hotkey_bindings(&self) { + sync_style_pack_hotkeys_on_main_thread(&self.inner); + } + /// 用户在设置里改了自定义组合键时调用。 pub fn update_combo_hotkey_binding(&self) { let prefs = self.inner.prefs.get(); diff --git a/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs b/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs index a071e5366..f30939b29 100644 --- a/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs +++ b/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs @@ -1109,6 +1109,17 @@ pub(super) fn handle_action_hotkey_pressed(inner: &Arc, kind: ActionHotke } } +/// 全局快捷键切风格后的轻量提示:用户多半在别的前台 app 里按键,不弹提示 +/// 无法知道切没切成功、切到了哪个风格。复用选区润色的无焦点一行提示胶囊 +/// (✓ + 文案,2s 自动隐藏,不抢焦点不挡点击);录音中按键最多闪一帧, +/// 下一个 ~30Hz 电平帧会立即夺回胶囊显示,auto-hide timer 也会因代数失效。 +#[cfg(not(mobile))] +pub(super) fn show_style_switch_capsule(inner: &Arc, name: &str) { + let event_epoch = + emit_selection_polish_capsule(inner, CapsuleState::Done, format!("已切换:{name}")); + schedule_selection_polish_capsule_idle(inner, event_epoch, CAPSULE_AUTO_HIDE_DELAY_MS); +} + pub(super) fn switch_to_previous_style(inner: &Arc) { let mut prefs = inner.prefs.get(); let packs = match inner.style_packs.list() { @@ -1142,6 +1153,8 @@ pub(super) fn switch_to_previous_style(inner: &Arc) { "[coord] switch style hotkey changed active style pack to {}", prefs.active_style_pack_id ); + #[cfg(not(mobile))] + show_style_switch_capsule(inner, &enabled[next_index].name); if let Some(app) = inner.app.lock().clone() { let _ = app.emit("prefs:changed", &prefs); let _ = app.emit_to("main", "prefs:changed", &prefs); @@ -1229,6 +1242,169 @@ pub(super) fn action_hotkey_bridge_thread_name(kind: ActionHotkeyKind) -> &'stat } } +// ─────────────────── style pack hotkeys (issue #759) ─────────────────── + +/// 启动期 supervisor:等 AppHandle 就绪后按 prefs 全量注册风格包直达快捷键; +/// 有注册失败时按 action hotkey 的节奏(3s)重试,直到全部装上或 shutdown。 +pub(super) fn style_pack_hotkey_supervisor_loop(inner: Arc) { + let mut attempts: u32 = 0; + loop { + if inner.shutdown.load(Ordering::SeqCst) { + return; + } + if inner.prefs.get().style_pack_hotkeys.is_empty() { + // 没有配置任何风格快捷键;用户后续新增走 update 主动路径。 + return; + } + let app = match inner.app.lock().clone() { + Some(a) => a, + None => { + std::thread::sleep(std::time::Duration::from_secs(1)); + continue; + } + }; + + let (done_tx, done_rx) = mpsc::sync_channel::(1); + let sync_inner = Arc::clone(&inner); + let _ = app.run_on_main_thread(move || { + let _ = done_tx.send(sync_style_pack_hotkeys(&sync_inner)); + }); + let failures = match done_rx.recv_timeout(std::time::Duration::from_secs(5)) { + Ok(n) => n, + Err(_) => { + attempts += 1; + if attempts <= 3 || attempts % 10 == 0 { + log::warn!("[coord] style pack hotkeys 第 {attempts} 次注册超时;3s 后重试"); + } + std::thread::sleep(std::time::Duration::from_secs(3)); + continue; + } + }; + if failures == 0 { + log::info!( + "[coord] style pack hotkey listeners installed after {} attempt(s)", + attempts + 1 + ); + return; + } + attempts += 1; + if attempts <= 3 || attempts % 10 == 0 { + log::warn!("[coord] style pack hotkeys 有 {failures} 条注册失败;3s 后重试"); + } + std::thread::sleep(std::time::Duration::from_secs(3)); + } +} + +/// 按 prefs 全量对齐风格包快捷键注册状态。**必须在主线程执行**(macOS Carbon +/// 要求 manager 在主线程构造)。策略为整表重建:先 drop 全部旧注册再逐条注册, +/// 避免「两个包互换按键」这类增量 update 场景下新键仍被旧注册占用而失败。 +/// 返回「配置有效但注册失败」的条数。 +pub(super) fn sync_style_pack_hotkeys(inner: &Arc) -> usize { + let entries: Vec = inner + .prefs + .get() + .style_pack_hotkeys + .into_iter() + .filter(|entry| { + !is_unconfigured_shortcut(&entry.binding) && !is_modifier_only_shortcut(&entry.binding) + }) + .collect(); + let mut monitors = inner.style_pack_hotkeys.lock(); + monitors.clear(); + let mut failures = 0; + for entry in entries { + let (tx, rx) = mpsc::channel::(); + match ComboHotkeyMonitor::start(entry.binding.clone(), tx) { + Ok(monitor) => { + monitors.insert(entry.pack_id.clone(), monitor); + let bridge_inner = Arc::clone(inner); + let pack_id = entry.pack_id.clone(); + std::thread::Builder::new() + .name("openless-style-pack-hotkey-bridge".into()) + .spawn(move || style_pack_hotkey_bridge_loop(bridge_inner, rx, pack_id)) + .ok(); + } + Err(e) => { + log::warn!( + "[coord] style pack hotkey {} 注册失败: {e}", + entry.pack_id + ); + failures += 1; + } + } + } + failures +} + +/// 设置变更后的主动同步路径(fire-and-forget dispatch 到主线程)。 +pub(super) fn sync_style_pack_hotkeys_on_main_thread(inner: &Arc) { + let app = inner.app.lock().clone(); + let Some(app) = app else { + log::warn!("[coord] sync style pack hotkeys: AppHandle 未 bind,跳过"); + return; + }; + let sync_inner = Arc::clone(inner); + let _ = app.run_on_main_thread(move || { + let failures = sync_style_pack_hotkeys(&sync_inner); + if failures > 0 { + log::warn!("[coord] style pack hotkeys 同步后仍有 {failures} 条注册失败"); + } + }); +} + +pub(super) fn clear_style_pack_hotkeys_on_main_thread(inner: &Arc) { + let app = inner.app.lock().clone(); + if let Some(app) = app { + let inner = Arc::clone(inner); + let _ = app.run_on_main_thread(move || { + inner.style_pack_hotkeys.lock().clear(); + }); + } else { + inner.style_pack_hotkeys.lock().clear(); + } +} + +pub(super) fn style_pack_hotkey_bridge_loop( + inner: Arc, + rx: mpsc::Receiver, + pack_id: String, +) { + while let Ok(evt) = rx.recv() { + if inner.shortcut_recording_active.load(Ordering::SeqCst) { + continue; + } + if matches!(evt, ComboHotkeyEvent::Pressed { .. }) { + handle_style_pack_hotkey_pressed(&inner, &pack_id); + } + } +} + +/// 复用 `activate_style_pack_by_id`(禁用包自动启用、写 prefs、sync、广播、刷托盘), +/// 与前端「点选风格包」走完全相同的激活路径;包已被删除时仅 warn 不做事。 +pub(super) fn handle_style_pack_hotkey_pressed(inner: &Arc, pack_id: &str) { + let Some(app) = inner.app.lock().clone() else { + log::warn!("[coord] style pack hotkey {pack_id} pressed but AppHandle not bound"); + return; + }; + let coord = Coordinator { + inner: Arc::clone(inner), + }; + match crate::commands::activate_style_pack_by_id(&coord, &app, pack_id) { + Ok(pack) => { + log::info!( + "[coord] style pack hotkey activated {} ({})", + pack.id, + pack.name + ); + #[cfg(not(mobile))] + show_style_switch_capsule(inner, &pack.name); + } + Err(error) => { + log::warn!("[coord] style pack hotkey {pack_id} activation failed: {error}") + } + } +} + pub(super) fn is_builtin_translation_shift(binding: &crate::types::ShortcutBinding) -> bool { binding.modifiers.is_empty() && binding.primary.eq_ignore_ascii_case("shift") } diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index 085ec111d..95e79660e 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -254,6 +254,7 @@ macro_rules! app_invoke_handler_desktop { commands::set_translation_hotkey, commands::set_switch_style_hotkey, commands::set_open_app_hotkey, + commands::set_style_pack_hotkeys, commands::qa_window_dismiss, commands::qa_toggle_recording, commands::qa_submit_text, @@ -767,6 +768,7 @@ fn run_desktop() { coordinator.start_translation_hotkey_listener(); coordinator.start_switch_style_hotkey_listener(); coordinator.start_open_app_hotkey_listener(); + coordinator.start_style_pack_hotkey_listeners(); } #[cfg(target_os = "macos")] RunEvent::Reopen { .. } => show_main_window(app), @@ -789,6 +791,7 @@ fn run_desktop() { coordinator.stop_translation_hotkey_listener(); coordinator.stop_switch_style_hotkey_listener(); coordinator.stop_open_app_hotkey_listener(); + coordinator.stop_style_pack_hotkey_listeners(); } _ => {} }); diff --git a/openless-all/app/src-tauri/src/types.rs b/openless-all/app/src-tauri/src/types.rs index b8536cf9d..e2dad0487 100644 --- a/openless-all/app/src-tauri/src/types.rs +++ b/openless-all/app/src-tauri/src/types.rs @@ -798,6 +798,12 @@ pub struct UserPreferences { /// 「唤起 App」全局快捷键。`None` = 停用;`Some(...)` = 注册。默认 `Some(默认键)`。 #[serde(default = "default_open_app_hotkey")] pub open_app_hotkey: Option, + /// 风格包直达快捷键:每条把一个全局组合键绑定到具体风格包 id(issue #759)。 + /// 按 id 而非「已启用列表第 N 个」绑定——启停其它风格包不会让已配的键位移。 + /// 默认空列表(不预设 Alt+1~9:macOS 上 Option+数字用于输入特殊字符,全局 + /// 注册会吞掉正常输入)。绑定指向已停用的包时,触发即自动启用并激活。 + #[serde(default)] + pub style_pack_hotkeys: Vec, /// Less Computer:是否启用。默认关闭,需用户在高级设置开启。 #[serde(default)] pub coding_agent_enabled: bool, @@ -1113,6 +1119,8 @@ struct UserPreferencesWire { switch_style_hotkey: Option, open_app_hotkey: Option, #[serde(default)] + style_pack_hotkeys: Vec, + #[serde(default)] coding_agent_enabled: bool, #[serde(default = "default_coding_agent_provider")] coding_agent_provider: String, @@ -1260,6 +1268,7 @@ impl Default for UserPreferencesWire { // 默认携带默认键(Some),保证缺字段时仍是启用状态;None 专表「用户主动停用」。 switch_style_hotkey: prefs.switch_style_hotkey, open_app_hotkey: prefs.open_app_hotkey, + style_pack_hotkeys: prefs.style_pack_hotkeys, coding_agent_enabled: prefs.coding_agent_enabled, coding_agent_provider: prefs.coding_agent_provider, coding_agent_model: prefs.coding_agent_model, @@ -1418,6 +1427,7 @@ impl<'de> Deserialize<'de> for UserPreferences { // 会落到 Some(默认键),保证老用户/新用户仍是启用。 switch_style_hotkey: wire.switch_style_hotkey, open_app_hotkey: wire.open_app_hotkey, + style_pack_hotkeys: wire.style_pack_hotkeys, local_asr_active_model: wire.local_asr_active_model, local_asr_mirror: wire.local_asr_mirror, local_asr_keep_loaded_secs: wire.local_asr_keep_loaded_secs, @@ -2215,6 +2225,7 @@ impl Default for UserPreferences { translation_hotkey: default_translation_hotkey(), switch_style_hotkey: default_switch_style_hotkey(), open_app_hotkey: default_open_app_hotkey(), + style_pack_hotkeys: Vec::new(), coding_agent_enabled: false, coding_agent_provider: default_coding_agent_provider(), coding_agent_model: None, @@ -2272,6 +2283,14 @@ pub struct ShortcutBinding { pub modifiers: Vec, } +/// 风格包直达快捷键:`binding` 按下即激活 `pack_id` 对应的风格包(issue #759)。 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct StylePackHotkey { + pub pack_id: String, + pub binding: ShortcutBinding, +} + impl ShortcutBinding { pub fn default_qa() -> Self { #[cfg(target_os = "macos")] @@ -3313,6 +3332,32 @@ mod tests { assert!(restored.open_app_hotkey.is_none()); } + #[test] + fn style_pack_hotkeys_default_empty_and_round_trip() { + // issue #759:老 preferences.json 没有该字段 → 空列表,不报错。 + let prefs: UserPreferences = serde_json::from_str("{}").unwrap(); + assert!(prefs.style_pack_hotkeys.is_empty()); + + // 带绑定的存盘→读回保持原样(camelCase 字段名)。 + let configured = UserPreferences { + style_pack_hotkeys: vec![StylePackHotkey { + pack_id: "imported.demo".into(), + binding: ShortcutBinding { + primary: "1".into(), + modifiers: vec!["alt".into()], + }, + }], + ..Default::default() + }; + let json = serde_json::to_string(&configured).unwrap(); + assert!( + json.contains("\"stylePackHotkeys\":[{\"packId\":\"imported.demo\""), + "应序列化为 camelCase,实际: {json}" + ); + let restored: UserPreferences = serde_json::from_str(&json).unwrap(); + assert_eq!(restored.style_pack_hotkeys, configured.style_pack_hotkeys); + } + #[test] fn explicit_action_hotkey_binding_round_trips() { // 旧 preferences.json 里带实际绑定 → 读回应保留为 Some(启用)。 diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index 5149714c1..81f122ab9 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -960,7 +960,7 @@ export const en: typeof zhCN = { requestTimeout: 'Request timed out. Try again later.', }, shortcuts: { - title: 'Shortcut reference', + title: 'Shortcut settings', descAcc: 'All shortcuts apply globally. Accessibility permission must be granted in Permissions.', descNoAcc: 'All shortcuts apply globally. If unresponsive, check the global hotkey status in Permissions.', startStop: 'Start / Stop recording', @@ -968,6 +968,12 @@ export const en: typeof zhCN = { confirm: 'Confirm capsule insertion', switchStyle: 'Switch to previous style', openApp: 'Open OpenLess', + stylePackTitle: 'Style shortcuts', + stylePackDesc: 'Bind a shortcut to each favorite style pack for one-press switching; disabled packs are re-enabled automatically.', + stylePackAdd: 'Add style shortcut', + stylePackSelect: 'Choose a style pack', + stylePackDisabledSuffix: ' (disabled)', + stylePackRemove: 'Remove', agentPolish: 'Polish selected text', agentPolishDesc: 'Select text → press → Claude polishes it → replaces the selection.', agentVoice: 'Less Computer', diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index c577a0da0..7aa778e39 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -962,7 +962,7 @@ export const ja: typeof zhCN = { requestTimeout: 'リクエストがタイムアウトしました。後で再試行してください。', }, shortcuts: { - title: 'ショートカット一覧', + title: 'ショートカット設定', descAcc: 'すべてのショートカットはグローバルで有効。権限設定でアクセシビリティを許可する必要があります。', descNoAcc: 'すべてのショートカットはグローバルで有効。応答がない場合は権限ページでグローバルショートカット監視の状態を確認してください。', startStop: '録音開始 / 停止', @@ -970,6 +970,12 @@ export const ja: typeof zhCN = { confirm: 'カプセル入力を確定', switchStyle: '前のスタイルに切り替え', openApp: 'OpenLess を開く', + stylePackTitle: 'スタイル直行ショートカット', + stylePackDesc: 'よく使うスタイルパックにショートカットを割り当てて一発切替;無効中のパックは自動で有効化されます。', + stylePackAdd: 'スタイルショートカットを追加', + stylePackSelect: 'スタイルパックを選択', + stylePackDisabledSuffix: '(無効)', + stylePackRemove: '削除', agentPolish: '選択テキストを推敲', agentPolishDesc: 'テキスト選択 → キー → Claude が推敲 → 選択範囲を置換。', agentVoice: 'Less Computer', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index ad3139b68..71a8e4210 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -962,7 +962,7 @@ export const ko: typeof zhCN = { requestTimeout: '요청 시간이 초과되었습니다. 잠시 후 다시 시도하세요.', }, shortcuts: { - title: '단축키 한눈에 보기', + title: '단축키 설정', descAcc: '모든 단축키는 전역에서 작동. 권한 설정에서 접근성을 활성화해야 합니다.', descNoAcc: '모든 단축키는 전역에서 작동. 응답이 없으면 권한 페이지에서 전역 단축키 감지 상태를 확인해 주세요.', startStop: '녹음 시작 / 정지', @@ -970,6 +970,12 @@ export const ko: typeof zhCN = { confirm: '캡슐 입력 확정', switchStyle: '이전 스타일로 전환', openApp: 'OpenLess 열기', + stylePackTitle: '스타일 바로가기 단축키', + stylePackDesc: '자주 쓰는 스타일 팩에 단축키를 지정해 한 번에 전환합니다. 비활성화된 팩은 자동으로 다시 활성화됩니다.', + stylePackAdd: '스타일 단축키 추가', + stylePackSelect: '스타일 팩 선택', + stylePackDisabledSuffix: ' (비활성화됨)', + stylePackRemove: '제거', agentPolish: '선택 텍스트 다듬기', agentPolishDesc: '텍스트 선택 → 키 → Claude 다듬기 → 선택 영역 교체.', agentVoice: 'Less Computer', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index 5086e86b8..74b35a19e 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -958,7 +958,7 @@ export const zhCN = { requestTimeout: '请求超时,请稍后重试。', }, shortcuts: { - title: '快捷键速查', + title: '快捷键设置', descAcc: '所有快捷键全局生效,需要在权限设置中开启辅助功能。', descNoAcc: '所有快捷键全局生效。若无响应,请在权限页查看全局快捷键监听状态。', startStop: '开始 / 停止录音', @@ -966,6 +966,12 @@ export const zhCN = { confirm: '胶囊确认插入', switchStyle: '切换到上一个风格', openApp: '打开 OpenLess', + stylePackTitle: '风格直达快捷键', + stylePackDesc: '为常用风格包各配一个快捷键,按下直接切换;停用中的包会自动启用。', + stylePackAdd: '添加风格快捷键', + stylePackSelect: '选择风格包', + stylePackDisabledSuffix: '(已停用)', + stylePackRemove: '移除', agentPolish: '选中文本润色', agentPolishDesc: '选中文本 → 按键 → Claude 润色 → 替换选区。', agentVoice: 'Less Computer', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index 9d18afe58..ac96d9d3c 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -960,7 +960,7 @@ export const zhTW: typeof zhCN = { requestTimeout: '請求超時,請稍後重試。', }, shortcuts: { - title: '快捷鍵速查', + title: '快捷鍵設定', descAcc: '所有快捷鍵全局生效,需要在權限設置中開啓輔助功能。', descNoAcc: '所有快捷鍵全局生效。若無響應,請在權限頁查看全局快捷鍵監聽狀態。', startStop: '開始 / 停止錄音', @@ -968,6 +968,12 @@ export const zhTW: typeof zhCN = { confirm: '膠囊確認插入', switchStyle: '切換到上一個風格', openApp: '打開 OpenLess', + stylePackTitle: '風格直達快捷鍵', + stylePackDesc: '為常用風格包各配一個快捷鍵,按下直接切換;停用中的包會自動啟用。', + stylePackAdd: '新增風格快捷鍵', + stylePackSelect: '選擇風格包', + stylePackDisabledSuffix: '(已停用)', + stylePackRemove: '移除', agentPolish: '選取文字潤色', agentPolishDesc: '選取文字 → 按鍵 → Claude 潤色 → 取代選取。', agentVoice: 'Less Computer', diff --git a/openless-all/app/src/lib/ipc/hotkeys.ts b/openless-all/app/src/lib/ipc/hotkeys.ts index 430b95aad..1526d3f70 100644 --- a/openless-all/app/src/lib/ipc/hotkeys.ts +++ b/openless-all/app/src/lib/ipc/hotkeys.ts @@ -1,4 +1,4 @@ -import type { ComboBinding, HotkeyCapability, HotkeyStatus, ShortcutBinding, WindowsImeStatus } from "../types" +import type { ComboBinding, HotkeyCapability, HotkeyStatus, ShortcutBinding, StylePackHotkey, WindowsImeStatus } from "../types" import { invokeOrMock, platformCapabilities, androidHotkeyStatus, androidHotkeyCapability, androidWindowsImeStatus } from "./shared" import { mockHotkeyStatus, @@ -86,6 +86,14 @@ export function setOpenAppHotkey(binding: ShortcutBinding | null): Promise return invokeOrMock("set_open_app_hotkey", { binding }, () => undefined) } +// 风格包直达快捷键:整表替换(前端任何增删改都发全量列表,issue #759)。 +export function setStylePackHotkeys(hotkeys: StylePackHotkey[]): Promise { + return invokeOrMock("set_style_pack_hotkeys", { hotkeys }, () => { + mockSetSettings({ ...mockSettings, stylePackHotkeys: hotkeys }) + return undefined + }) +} + export function setShortcutRecordingActive(active: boolean): Promise { return invokeOrMock( "set_shortcut_recording_active", diff --git a/openless-all/app/src/lib/ipc/index.ts b/openless-all/app/src/lib/ipc/index.ts index b92aa5683..b2e6d8264 100644 --- a/openless-all/app/src/lib/ipc/index.ts +++ b/openless-all/app/src/lib/ipc/index.ts @@ -106,6 +106,7 @@ export { setTranslationHotkey, setSwitchStyleHotkey, setOpenAppHotkey, + setStylePackHotkeys, setShortcutRecordingActive, } from "./hotkeys" diff --git a/openless-all/app/src/lib/ipc/mock-data.ts b/openless-all/app/src/lib/ipc/mock-data.ts index 9a0645c15..08bc71f35 100644 --- a/openless-all/app/src/lib/ipc/mock-data.ts +++ b/openless-all/app/src/lib/ipc/mock-data.ts @@ -75,6 +75,7 @@ export let mockSettings: UserPreferences = { modifiers: defaultAppShortcutModifiers(), }, openAppHotkey: { primary: "O", modifiers: defaultAppShortcutModifiers() }, + stylePackHotkeys: [], codingAgentEnabled: false, codingAgentProvider: "claude-code-cli", codingAgentModel: null, diff --git a/openless-all/app/src/lib/stylePrefs.test.ts b/openless-all/app/src/lib/stylePrefs.test.ts index 1d84aac65..e12a604f4 100644 --- a/openless-all/app/src/lib/stylePrefs.test.ts +++ b/openless-all/app/src/lib/stylePrefs.test.ts @@ -61,6 +61,7 @@ const previousPrefs: UserPreferences = { translationHotkey: { primary: 'Shift', modifiers: [] }, switchStyleHotkey: { primary: 'S', modifiers: ['alt'] }, openAppHotkey: { primary: 'O', modifiers: ['alt'] }, + stylePackHotkeys: [], codingAgentEnabled: false, codingAgentProvider: 'claude-code-cli', codingAgentModel: null, diff --git a/openless-all/app/src/lib/types.ts b/openless-all/app/src/lib/types.ts index 9af16fbcf..938f41fee 100644 --- a/openless-all/app/src/lib/types.ts +++ b/openless-all/app/src/lib/types.ts @@ -152,6 +152,12 @@ export interface ShortcutBinding { modifiers: string[]; } +/** 风格包直达快捷键:binding 按下即激活 packId 对应的风格包(issue #759)。 */ +export interface StylePackHotkey { + packId: string; + binding: ShortcutBinding; +} + /** 划词语音问答快捷键绑定。null 表示未启用。详见 issue #118。 */ export type QaHotkeyBinding = ShortcutBinding; @@ -343,6 +349,8 @@ export interface UserPreferences { switchStyleHotkey: ShortcutBinding | null; /** 打开 OpenLess 主窗口的全局快捷键。null = 用户已停用(issue #576)。 */ openAppHotkey: ShortcutBinding | null; + /** 风格包直达快捷键:按下即激活对应风格包。默认空列表(issue #759)。 */ + stylePackHotkeys: StylePackHotkey[]; /** Less Computer:是否启用。默认关闭。 */ codingAgentEnabled: boolean; /** Agent 后端:claude-code-cli(默认)/ opencode-cli。 */ diff --git a/openless-all/app/src/pages/settings/ShortcutsSection.tsx b/openless-all/app/src/pages/settings/ShortcutsSection.tsx index 5efa16b3f..c44eb9154 100644 --- a/openless-all/app/src/pages/settings/ShortcutsSection.tsx +++ b/openless-all/app/src/pages/settings/ShortcutsSection.tsx @@ -1,8 +1,9 @@ -// 快捷键设置:开始/停止、翻译、问答、切风格、唤起 App、以及只读取消/确认提示。 +// 快捷键设置:开始/停止、翻译、问答、切风格、风格直达、唤起 App、以及只读取消/确认提示。 import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { ShortcutRecorder } from '../../components/ShortcutRecorder'; +import { SelectLite } from '../../components/ui/SelectLite'; import { defaultLessComputerShortcut, defaultOpenAppShortcut, @@ -11,14 +12,16 @@ import { hotkeyModeSuffix, } from '../../lib/hotkey'; import { + listStylePacks, setDictationHotkey, setOpenAppHotkey, setQaHotkey, + setStylePackHotkeys, setSwitchStyleHotkey, setTranslationHotkey, } from '../../lib/ipc'; import { getPlatformCapabilities } from '../../lib/platform'; -import type { PlatformCapabilities } from '../../lib/types'; +import type { PlatformCapabilities, StylePack, StylePackHotkey } from '../../lib/types'; import { useHotkeySettings } from '../../state/HotkeySettingsContext'; import { Card } from '../_atoms'; import { SettingRow } from './shared'; @@ -29,9 +32,15 @@ export function ShortcutsSection() { const os = detectOS(); const { prefs, hotkey, updatePrefs: savePrefs } = useHotkeySettings(); const [platformCaps, setPlatformCaps] = useState(null); + const [stylePacks, setStylePacks] = useState([]); + // 新增行的草稿状态:先选风格包、再录快捷键,两者齐了才真正落库。 + const [draftOpen, setDraftOpen] = useState(false); + const [draftPackId, setDraftPackId] = useState(''); + const [stylePackError, setStylePackError] = useState(null); useEffect(() => { void getPlatformCapabilities().then(setPlatformCaps); + void listStylePacks().then(setStylePacks).catch(() => setStylePacks([])); }, []); if (!prefs || !hotkey) { @@ -46,6 +55,33 @@ export function ShortcutsSection() { return null; } + const stylePackHotkeys: StylePackHotkey[] = prefs.stylePackHotkeys ?? []; + // 下拉列出全部风格包(含停用的,激活时后端自动启用);已被其它行绑定的包置灰防重复。 + const stylePackOptions = (currentPackId: string) => + stylePacks.map(pack => ({ + value: pack.id, + label: pack.enabled + ? pack.name + : `${pack.name}${t('settings.shortcuts.stylePackDisabledSuffix')}`, + disabled: + pack.id !== currentPackId && stylePackHotkeys.some(entry => entry.packId === pack.id), + })); + // 整表替换:后端校验通过才落库;失败时抛错交给调用方展示,本地列表保持旧值。 + const saveStylePackHotkeys = async (next: StylePackHotkey[]) => { + setStylePackError(null); + await setStylePackHotkeys(next); + await savePrefs({ ...prefs, stylePackHotkeys: next }); + }; + const removeButtonStyle = { + border: 'none', + background: 'transparent', + color: 'var(--ol-ink-4)', + cursor: 'pointer', + fontSize: 13, + padding: '2px 4px', + lineHeight: 1, + } as const; + const readonlyRows: Array<[string, string]> = [ [t('settings.shortcuts.cancel'), 'Esc'], // 胶囊右侧「✓ 确认插入」目前只在 macOS 胶囊上有,Windows/Linux 胶囊没有这个按钮, @@ -118,6 +154,126 @@ export function ShortcutsSection() { }} /> +
+
+ {t('settings.shortcuts.stylePackTitle')} +
+
+ {t('settings.shortcuts.stylePackDesc')} +
+
+ {stylePackHotkeys.map((entry, index) => ( +
+ { + const next = stylePackHotkeys.map((item, i) => + i === index ? { ...item, packId } : item, + ); + void saveStylePackHotkeys(next).catch(error => + setStylePackError(String(error)), + ); + }} + /> +
+ { + const next = stylePackHotkeys.map((item, i) => + i === index ? { ...item, binding } : item, + ); + await saveStylePackHotkeys(next); + }} + /> +
+ +
+ ))} + {draftOpen && ( +
+ +
+ { + await saveStylePackHotkeys([ + ...stylePackHotkeys, + { packId: draftPackId, binding }, + ]); + setDraftOpen(false); + setDraftPackId(''); + }} + /> +
+ +
+ )} + {stylePackError && ( +
+ {stylePackError} +
+ )} + {!draftOpen && ( + + )}