Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
185 changes: 184 additions & 1 deletion openless-all/app/src-tauri/src/commands/hotkeys.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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())?;
Expand Down Expand Up @@ -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())?;
Expand Down Expand Up @@ -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())?;
Expand Down Expand Up @@ -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())?;
Expand Down Expand Up @@ -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<StylePackHotkey>,
) -> 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())
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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(())
}

Expand Down Expand Up @@ -358,6 +462,7 @@ pub(crate) fn reject_selection_polish_hotkey_collisions(
"选区润色快捷键不能和 Less Computer 快捷键相同",
)?;
}
reject_existing_style_pack_hotkey_overlap(selection_polish, prefs)?;
Ok(())
}

Expand Down Expand Up @@ -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");
Expand Down
3 changes: 2 additions & 1 deletion openless-all/app/src-tauri/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};

Expand Down
1 change: 1 addition & 0 deletions openless-all/app/src-tauri/src/commands/qa.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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())?;
Expand Down
52 changes: 52 additions & 0 deletions openless-all/app/src-tauri/src/commands/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>)覆写。
fn refresh_style_pack_hotkeys(&self) {}
}

impl SettingsWriter for Coordinator {
Expand Down Expand Up @@ -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<T: SettingsWriter + ?Sized> SettingsWriter for Arc<T> {
Expand Down Expand Up @@ -142,6 +148,10 @@ impl<T: SettingsWriter + ?Sized> SettingsWriter for Arc<T> {
fn refresh_coding_agent_hotkey(&self) {
(**self).refresh_coding_agent_hotkey();
}

fn refresh_style_pack_hotkeys(&self) {
(**self).refresh_style_pack_hotkeys();
}
}

/// 非核心热键,用于保存兜底的冲突化解。dictation 是核心热键,永不参与调整。
Expand Down Expand Up @@ -249,6 +259,44 @@ pub(crate) fn reconcile_hotkey_collisions(
higher.push(value);
}
}
// 风格包直达快捷键是最低优先级:与更高优先级键重叠、非法或集合内重复的条目,
// 先尝试恢复该风格包的旧绑定,仍不行则整条移除(不影响其余设置落盘)。
let mut kept: Vec<StylePackHotkey> = 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
}

Expand Down Expand Up @@ -288,6 +336,7 @@ pub(crate) fn persist_settings_with_keyboard_apply<T: SettingsWriter>(
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
Expand Down Expand Up @@ -378,6 +427,9 @@ pub(crate) fn persist_settings_with_keyboard_apply<T: SettingsWriter>(
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();
}
Expand Down
9 changes: 9 additions & 0 deletions openless-all/app/src-tauri/src/commands/style_packs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(())
}

Expand Down
Loading
Loading