Skip to content
Merged
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
52 changes: 27 additions & 25 deletions openless-all/app/src-tauri/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -867,7 +867,7 @@ mod tests {
}

#[test]
fn persist_settings_rejects_less_computer_dictation_overlap() {
fn persist_settings_reconciles_less_computer_dictation_overlap_and_saves() {
let writer = FakeSettingsWriter::default();
let binding = ShortcutBinding {
primary: "LeftControl".into(),
Expand All @@ -879,11 +879,11 @@ mod tests {
..Default::default()
};

assert_eq!(
persist_settings(&writer, prefs),
Err("Less Computer 快捷键不能和听写快捷键相同".into())
);
assert!(writer.saved.lock().unwrap().is_none());
// 兜底(#904):冲突不再拒绝整份保存,较低优先级的 Less Computer 键被停用。
persist_settings(&writer, prefs).unwrap();
let saved = writer.saved.lock().unwrap().clone().expect("prefs saved");
assert_eq!(saved.dictation_hotkey.primary, "LeftControl");
assert!(saved.coding_agent_voice_hotkey.is_none());
}

#[test]
Expand Down Expand Up @@ -1249,7 +1249,7 @@ mod tests {
}

#[test]
fn persist_settings_rejects_dictation_translation_overlap() {
fn persist_settings_reconciles_dictation_translation_overlap_and_saves() {
let writer = FakeSettingsWriter::default();
let binding = ShortcutBinding {
primary: "RightControl".into(),
Expand All @@ -1261,15 +1261,15 @@ mod tests {
..Default::default()
};

assert_eq!(
persist_settings(&writer, prefs),
Err("翻译快捷键不能和听写快捷键相同".into())
);
assert!(writer.saved.lock().unwrap().is_none());
// 兜底(#904):冲突不再拒绝整份保存,翻译键恢复为旧默认 Shift。
persist_settings(&writer, prefs).unwrap();
let saved = writer.saved.lock().unwrap().clone().expect("prefs saved");
assert_eq!(saved.dictation_hotkey.primary, "RightControl");
assert_eq!(saved.translation_hotkey.primary, "Shift");
}

#[test]
fn persist_settings_rejects_translation_switch_style_overlap() {
fn persist_settings_reconciles_translation_switch_style_overlap_and_saves() {
let writer = FakeSettingsWriter::default();
let binding = ShortcutBinding {
primary: "T".into(),
Expand All @@ -1281,31 +1281,33 @@ mod tests {
..Default::default()
};

assert_eq!(
persist_settings(&writer, prefs),
Err("切换风格快捷键不能和翻译快捷键相同".into())
);
assert!(writer.saved.lock().unwrap().is_none());
// 兜底(#904):冲突不再拒绝整份保存,切换风格键恢复为旧默认。
persist_settings(&writer, prefs).unwrap();
let saved = writer.saved.lock().unwrap().clone().expect("prefs saved");
let defaults = UserPreferences::default();
assert_eq!(saved.translation_hotkey.primary, "T");
assert_eq!(saved.switch_style_hotkey, defaults.switch_style_hotkey);
}

#[test]
fn persist_settings_rejects_switch_style_open_app_overlap() {
fn persist_settings_reconciles_switch_style_open_app_overlap_and_saves() {
let writer = FakeSettingsWriter::default();
let binding = ShortcutBinding {
primary: "K".into(),
modifiers: vec!["cmd".into(), "shift".into()],
};
let prefs = UserPreferences {
switch_style_hotkey: Some(binding.clone()),
open_app_hotkey: Some(binding),
open_app_hotkey: Some(binding.clone()),
..Default::default()
};

assert_eq!(
persist_settings(&writer, prefs),
Err("打开应用快捷键不能和切换风格快捷键相同".into())
);
assert!(writer.saved.lock().unwrap().is_none());
// 兜底(#904):冲突不再拒绝整份保存,打开应用键恢复为旧默认。
persist_settings(&writer, prefs).unwrap();
let saved = writer.saved.lock().unwrap().clone().expect("prefs saved");
let defaults = UserPreferences::default();
assert_eq!(saved.switch_style_hotkey, Some(binding));
assert_eq!(saved.open_app_hotkey, defaults.open_app_hotkey);
}

#[test]
Expand Down
211 changes: 210 additions & 1 deletion openless-all/app/src-tauri/src/commands/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,114 @@ impl<T: SettingsWriter + ?Sized> SettingsWriter for Arc<T> {
}
}

/// 非核心热键,用于保存兜底的冲突化解。dictation 是核心热键,永不参与调整。
#[derive(Clone, Copy, PartialEq, Eq)]
enum NonCoreHotkey {
Translation,
Qa,
SwitchStyle,
OpenApp,
SelectionPolish,
LessComputer,
}

impl NonCoreHotkey {
fn get(&self, prefs: &UserPreferences) -> Option<ShortcutBinding> {
match self {
Self::Translation => Some(prefs.translation_hotkey.clone()),
Self::Qa => prefs.qa_hotkey.clone(),
Self::SwitchStyle => prefs.switch_style_hotkey.clone(),
Self::OpenApp => prefs.open_app_hotkey.clone(),
Self::SelectionPolish => prefs.selection_polish_hotkey.clone(),
Self::LessComputer => prefs.coding_agent_voice_hotkey.clone(),
}
}

fn set(&self, prefs: &mut UserPreferences, value: Option<ShortcutBinding>) {
match self {
// translation 是必填键,None 表示恢复失败时保持旧值不动。
Self::Translation => {
if let Some(value) = value {
prefs.translation_hotkey = value;
}
}
Self::Qa => prefs.qa_hotkey = value,
Self::SwitchStyle => prefs.switch_style_hotkey = value,
Self::OpenApp => prefs.open_app_hotkey = value,
Self::SelectionPolish => prefs.selection_polish_hotkey = value,
Self::LessComputer => prefs.coding_agent_voice_hotkey = value,
}
}
}

/// 单个非核心热键是否非法。与 `reject_non_dictation_side_specific_shortcuts`
/// 的逐键校验保持精确一致,避免把非冲突键一并停用。
fn non_core_hotkey_invalid(key: NonCoreHotkey, binding: &ShortcutBinding) -> bool {
if crate::shortcut_binding::reject_side_specific_non_dictation(binding).is_err() {
return true;
}
match key {
NonCoreHotkey::SelectionPolish => {
crate::shortcut_binding::validate_binding(binding).is_err()
|| reject_bare_shift_dictation_shortcut(binding).is_err()
}
_ => false,
}
}

/// 保存兜底(#904):热键冲突不能把整份设置挡在保存之外。
///
/// 按核心度从高到低处理每个非核心热键:凡与更高优先级键重叠、或本身非法
/// (侧特定修饰键等)的,恢复为旧值;旧值仍冲突/非法(历史遗留,例如 1.3.15
/// 升级注入的选区润色默认键与录音键重复)时停用(translation 回退默认 Shift)。
/// 返回被调整的键数量。dictation 永远保留,不参与调整。
pub(crate) fn reconcile_hotkey_collisions(
prefs: &mut UserPreferences,
previous: &UserPreferences,
) -> usize {
// 处理顺序 = 核心度从高到低:处理某项时,更高优先级的键已定稿。
const ORDER: [NonCoreHotkey; 6] = [
NonCoreHotkey::Translation,
NonCoreHotkey::Qa,
NonCoreHotkey::SwitchStyle,
NonCoreHotkey::OpenApp,
NonCoreHotkey::SelectionPolish,
NonCoreHotkey::LessComputer,
];
let mut higher: Vec<ShortcutBinding> = vec![prefs.dictation_hotkey.clone()];
let mut adjusted = 0;
for key in ORDER {
let Some(current) = key.get(prefs) else {
continue;
};
let collides = higher
.iter()
.any(|held| crate::shortcut_binding::bindings_overlap(held, &current));
if !collides && !non_core_hotkey_invalid(key, &current) {
higher.push(current);
continue;
}
let fallback = key.get(previous).filter(|candidate| {
!higher
.iter()
.any(|held| crate::shortcut_binding::bindings_overlap(held, candidate))
&& !non_core_hotkey_invalid(key, candidate)
});
// translation 不能停用:旧值仍冲突/非法时回退到默认 Shift(不会与任何键重叠)。
let resolved = if key == NonCoreHotkey::Translation && fallback.is_none() {
Some(UserPreferences::default().translation_hotkey.clone())
} else {
fallback
};
key.set(prefs, resolved.clone());
adjusted += 1;
if let Some(value) = resolved {
higher.push(value);
}
}
adjusted
}

pub(crate) fn persist_settings<T: SettingsWriter>(
coord: &T,
prefs: UserPreferences,
Expand All @@ -163,7 +271,17 @@ pub(crate) fn persist_settings_with_keyboard_apply<T: SettingsWriter>(
let mut previous = coord.read_settings();
sync_dictation_hotkey_legacy_fields(&mut previous);
sync_dictation_hotkey_legacy_fields(&mut prefs);
reject_hotkey_collisions(&prefs)?;
if let Err(collision_error) = reject_hotkey_collisions(&prefs) {
// 兜底(#904):热键冲突(含历史遗留的重复键)不能拒绝整份设置保存。
// 自动把冲突/非法的非核心热键恢复旧值或停用,其余设置照常落盘。
let adjusted = reconcile_hotkey_collisions(&mut prefs, &previous);
reject_hotkey_collisions(&prefs).map_err(|leftover| {
format!("{collision_error}; 自动化解 {adjusted} 项后仍无法通过校验: {leftover}")
})?;
log::warn!(
"[settings] 热键冲突已自动化解(调整 {adjusted} 项)后保存: {collision_error}"
);
}
let dictation_shortcut_changed = previous.dictation_hotkey != prefs.dictation_hotkey;
let dictation_mode_changed = previous.hotkey.mode != prefs.hotkey.mode;
let qa_changed = previous.qa_hotkey != prefs.qa_hotkey;
Expand Down Expand Up @@ -448,6 +566,97 @@ mod tests {
assert_eq!(saved.default_mode, PolishMode::Light);
assert_eq!(saved.microphone_device_name, "External Mic");
}

#[test]
fn reconcile_clears_legacy_dictation_selection_polish_duplication() {
// #904 历史遗留:1.3.15 升级注入的选区润色默认键(右 Alt)与录音键相同。
let prefs = UserPreferences {
hotkey: crate::types::HotkeyBinding {
trigger: crate::types::HotkeyTrigger::RightAlt,
mode: crate::types::HotkeyMode::Hold,
keys: None,
},
dictation_hotkey: ShortcutBinding {
primary: "RightAlt".into(),
modifiers: vec![],
},
selection_polish_hotkey: Some(ShortcutBinding {
primary: "RightAlt".into(),
modifiers: vec![],
}),
..Default::default()
};
let mut next = prefs.clone();

let adjusted = reconcile_hotkey_collisions(&mut next, &prefs);

assert!(adjusted >= 1);
assert!(next.selection_polish_hotkey.is_none());
assert!(reject_hotkey_collisions(&next).is_ok());
}

#[test]
fn persist_settings_reconciles_legacy_collision_and_still_saves_mode() {
// #904 复现:历史冲突存在时,用户切「自动」必须能保存成功,
// 冲突的选区润色键被停用,而不是整份设置被拒。
let collision = UserPreferences {
hotkey: crate::types::HotkeyBinding {
trigger: crate::types::HotkeyTrigger::RightAlt,
mode: crate::types::HotkeyMode::Hold,
keys: None,
},
dictation_hotkey: ShortcutBinding {
primary: "RightAlt".into(),
modifiers: vec![],
},
selection_polish_hotkey: Some(ShortcutBinding {
primary: "RightAlt".into(),
modifiers: vec![],
}),
..Default::default()
};
let mut next = collision.clone();
next.hotkey.mode = crate::types::HotkeyMode::Auto;
let writer = RaceSettingsWriter {
reads: Mutex::new(vec![collision]),
saved: Mutex::new(None),
};

persist_settings_with_keyboard_apply(&writer, next, |_| Ok(())).unwrap();

let saved = writer.saved.lock().unwrap().clone().expect("prefs saved");
assert_eq!(saved.hotkey.mode, crate::types::HotkeyMode::Auto);
assert!(saved.selection_polish_hotkey.is_none());
}

#[test]
fn reconcile_resolves_non_core_overlap_and_invalid_side_specific_hotkey() {
// QA 与翻译键相同:较低优先级的 QA 恢复旧值,旧值仍冲突则停用。
let previous = UserPreferences {
qa_hotkey: Some(ShortcutBinding {
primary: "E".into(),
modifiers: vec!["ctrl".into(), "shift".into()],
}),
..Default::default()
};
let mut next = previous.clone();
next.qa_hotkey = Some(ShortcutBinding {
primary: "Shift".into(),
modifiers: vec![],
});
// 侧特定修饰键对非 dictation 非法(SIDE_SPECIFIC_NON_DICTATION_MSG)。
next.translation_hotkey = ShortcutBinding {
primary: "D".into(),
modifiers: vec!["cmd-left".into()],
};

let adjusted = reconcile_hotkey_collisions(&mut next, &previous);

assert!(adjusted >= 2);
assert_eq!(next.qa_hotkey, previous.qa_hotkey);
assert_eq!(next.translation_hotkey, previous.translation_hotkey);
assert!(reject_hotkey_collisions(&next).is_ok());
}
}

// ─────────────────────────── release channel (Beta opt-in) ───────────────────────────
Expand Down
Loading
Loading