fix: localize Windows tray menu labels (#957) (#972)

* fix(tray): localize Windows tray labels (#957)

* fix(ci): register qwen3 ASR submodule

* fix(tray): preserve renamed builtin style names
This commit is contained in:
Chris233 authored and GitHub committed 2026-08-20 22:52:50 +08:00
1 parent 1ee93576b0
commit 892f37c820
2 files changed
+205 -19

No files matched your search

@@ -29,6 +29,14 @@ pub fn regenerate_remote_pin(coord: CoordinatorState<'_>) -> Result<String, Stri
/// 同步 PC 端界面语言到远程输入服务,H5 录音页据此显示对应语言。
#[tauri::command]
pub fn set_remote_locale(coord: CoordinatorState<'_>, locale: String) {
pub fn set_remote_locale(app: AppHandle, coord: CoordinatorState<'_>, locale: String) {
coord.set_remote_locale(locale);
let refresh_app = app.clone();
if let Err(err) = app.run_on_main_thread(move || {
if let Err(err) = crate::refresh_tray_microphone_menu(&refresh_app) {
log::warn!("[tray] refresh menu after locale change failed: {err}");
}
}) {
log::warn!("[tray] dispatch locale refresh failed: {err}");
}
}
+196 -18
View File
@@ -137,7 +137,7 @@ use tauri::{
use tauri::{WebviewUrl, WebviewWindowBuilder};
#[cfg(not(mobile))]
use crate::types::StylePack;
use crate::types::{PolishMode, StylePack, StylePackKind};
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
@@ -860,6 +860,132 @@ struct TrayMenu {
microphone_items: Vec<commands::TrayMicrophoneMenuItem>,
}
#[derive(Debug, Clone, Copy)]
#[cfg(not(mobile))]
struct TrayLabels {
toggle: &'static str,
style: &'static str,
microphone: &'static str,
default_microphone: &'static str,
no_microphones: &'static str,
default_device_suffix: &'static str,
quit: &'static str,
raw: &'static str,
light: &'static str,
structured: &'static str,
formal: &'static str,
}
#[cfg(not(mobile))]
impl TrayLabels {
fn for_locale(locale: &str) -> Self {
match locale {
"en" => Self {
toggle: "Show main window",
style: "Output style",
microphone: "Select microphone",
default_microphone: "System default microphone",
no_microphones: "No microphones found",
default_device_suffix: " (System default)",
quit: "Quit OpenLess",
raw: "Raw",
light: "Light polish",
structured: "Structured",
formal: "Formal",
},
"zh-TW" => Self {
toggle: "顯示主視窗",
style: "輸出風格",
microphone: "選擇麥克風",
default_microphone: "系統預設麥克風",
no_microphones: "找不到麥克風",
default_device_suffix: "(系統預設)",
quit: "退出 OpenLess",
raw: "原文",
light: "輕度潤色",
structured: "清晰結構",
formal: "正式表達",
},
"ja" => Self {
toggle: "メインウィンドウを表示",
style: "出力スタイル",
microphone: "マイクを選択",
default_microphone: "システムのデフォルトマイク",
no_microphones: "マイクが見つかりません",
default_device_suffix: "(システムのデフォルト)",
quit: "OpenLessを終了",
raw: "原文",
light: "軽い整文",
structured: "明確な構造",
formal: "正式な表現",
},
"ko" => Self {
toggle: "메인 창 표시",
style: "출력 스타일",
microphone: "마이크 선택",
default_microphone: "시스템 기본 마이크",
no_microphones: "마이크를 찾을 수 없음",
default_device_suffix: "(시스템 기본)",
quit: "OpenLess 종료",
raw: "원문",
light: "가벼운 정리",
structured: "명확한 구조",
formal: "정식 표현",
},
_ => Self {
toggle: "显示主窗口",
style: "输出风格",
microphone: "选择麦克风",
default_microphone: "系统默认麦克风",
no_microphones: "未发现麦克风",
default_device_suffix: "(系统默认)",
quit: "退出 OpenLess",
raw: "原文",
light: "轻度润色",
structured: "清晰结构",
formal: "正式表达",
},
}
}
fn style_pack_name(self, mode: PolishMode) -> &'static str {
match mode {
PolishMode::Raw => self.raw,
PolishMode::Light => self.light,
PolishMode::Structured => self.structured,
PolishMode::Formal => self.formal,
}
}
fn style_pack_label(self, pack: &StylePack) -> String {
if pack.kind == StylePackKind::Builtin
&& (pack.name.trim().is_empty()
|| pack.name.trim() == builtin_style_pack_default_name(pack.base_mode))
{
return self.style_pack_name(pack.base_mode).to_string();
}
if pack.name.trim().is_empty() {
pack.id.clone()
} else {
pack.name.clone()
}
}
fn default_device_label(self, device_name: &str) -> String {
format!("{device_name}{}", self.default_device_suffix)
}
}
#[cfg(not(mobile))]
fn builtin_style_pack_default_name(mode: PolishMode) -> &'static str {
match mode {
PolishMode::Raw => "原文",
PolishMode::Light => "轻度润色",
PolishMode::Structured => "清晰结构",
PolishMode::Formal => "正式表达",
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg(not(mobile))]
struct TrayStylePackMenuEntry {
@@ -894,6 +1020,7 @@ fn parse_tray_style_pack_menu_id(id: &str) -> Option<&str> {
fn tray_style_pack_menu_entries(
packs: &[StylePack],
active_style_pack_id: &str,
labels: TrayLabels,
) -> Vec<TrayStylePackMenuEntry> {
packs
.iter()
@@ -901,11 +1028,7 @@ fn tray_style_pack_menu_entries(
.map(|pack| TrayStylePackMenuEntry {
id: tray_style_pack_menu_id(&pack.id),
pack_id: pack.id.clone(),
label: if pack.name.trim().is_empty() {
pack.id.clone()
} else {
pack.name.clone()
},
label: labels.style_pack_label(pack),
checked: pack.id == active_style_pack_id,
})
.collect()
@@ -925,12 +1048,13 @@ fn build_tray_menu<M: Manager<tauri::Wry>>(
app: &M,
coordinator: &Arc<coordinator::Coordinator>,
) -> tauri::Result<TrayMenu> {
let toggle = MenuItemBuilder::with_id("toggle", "显示主窗口").build(app)?;
let microphone_menu = build_microphone_tray_menu(app, coordinator)?;
let quit = MenuItemBuilder::with_id("quit", "退出 OpenLess").build(app)?;
let labels = TrayLabels::for_locale(&coordinator.remote_locale());
let toggle = MenuItemBuilder::with_id("toggle", labels.toggle).build(app)?;
let microphone_menu = build_microphone_tray_menu(app, coordinator, labels)?;
let quit = MenuItemBuilder::with_id("quit", labels.quit).build(app)?;
let mut builder = MenuBuilder::new(app);
let style_menu = if tray_style_menu_enabled() {
Some(build_style_tray_menu(app, coordinator)?)
Some(build_style_tray_menu(app, coordinator, labels)?)
} else {
None
};
@@ -950,14 +1074,15 @@ fn build_tray_menu<M: Manager<tauri::Wry>>(
fn build_style_tray_menu<M: Manager<tauri::Wry>>(
app: &M,
coordinator: &Arc<coordinator::Coordinator>,
labels: TrayLabels,
) -> tauri::Result<StyleTrayMenu> {
let prefs = coordinator.prefs().get();
let packs = coordinator.style_packs().list().unwrap_or_else(|err| {
log::warn!("[tray] list style packs for tray menu failed: {err}");
Vec::new()
});
let mut submenu = SubmenuBuilder::with_id(app, "style", "输出风格");
for entry in tray_style_pack_menu_entries(&packs, &prefs.active_style_pack_id) {
let mut submenu = SubmenuBuilder::with_id(app, "style", labels.style);
for entry in tray_style_pack_menu_entries(&packs, &prefs.active_style_pack_id, labels) {
let item = CheckMenuItemBuilder::with_id(&entry.id, entry.label)
.checked(entry.checked)
.build(app)?;
@@ -972,10 +1097,11 @@ fn build_style_tray_menu<M: Manager<tauri::Wry>>(
fn build_microphone_tray_menu<M: Manager<tauri::Wry>>(
app: &M,
coordinator: &Arc<coordinator::Coordinator>,
labels: TrayLabels,
) -> tauri::Result<MicrophoneTrayMenu> {
let selected = coordinator.prefs().get().microphone_device_name;
let mut items = Vec::new();
let mut submenu = SubmenuBuilder::with_id(app, "microphone", "选择麦克风");
let mut submenu = SubmenuBuilder::with_id(app, "microphone", labels.microphone);
// CoreAudio device enumeration can block inside AudioUnitSetProperty while AppKit is
// finishing launch. Tray menus must be built on the main thread, so only consume the
// cache here; the watcher below owns every potentially blocking enumeration.
@@ -983,7 +1109,7 @@ fn build_microphone_tray_menu<M: Manager<tauri::Wry>>(
let selected_available =
selected.trim().is_empty() || devices.iter().any(|device| device.name == selected);
let default_item = CheckMenuItemBuilder::with_id("mic-default", "系统默认麦克风")
let default_item = CheckMenuItemBuilder::with_id("mic-default", labels.default_microphone)
.checked(selected.trim().is_empty() || !selected_available)
.build(app)?;
submenu = submenu.item(&default_item);
@@ -994,7 +1120,7 @@ fn build_microphone_tray_menu<M: Manager<tauri::Wry>>(
});
if devices.is_empty() {
let empty = MenuItemBuilder::with_id("mic-empty", "未发现麦克风")
let empty = MenuItemBuilder::with_id("mic-empty", labels.no_microphones)
.enabled(false)
.build(app)?;
submenu = submenu.item(&empty);
@@ -1002,7 +1128,7 @@ fn build_microphone_tray_menu<M: Manager<tauri::Wry>>(
for (index, device) in devices.into_iter().enumerate() {
let id = format!("mic-device-{index}");
let label = if device.is_default {
format!("{}(系统默认)", device.name)
labels.default_device_label(&device.name)
} else {
device.name.clone()
};
@@ -2955,7 +3081,7 @@ mod tests {
capsule_visual_height, capsule_window_bounds, clamp_to_monitor, frame_contains_point,
frame_distance_to_point_squared, logical_monitor_frame, parse_tray_style_pack_menu_id,
resolve_tray_style_pack_id, rotate_log_if_too_large, tray_style_menu_enabled,
tray_style_pack_menu_entries, tray_style_pack_menu_id, LogicalMonitorFrame,
tray_style_pack_menu_entries, tray_style_pack_menu_id, LogicalMonitorFrame, TrayLabels,
LOG_ROTATE_LIMIT_BYTES,
};
use crate::types::{builtin_style_pack_for_mode, PolishMode, StylePack, StylePackKind};
@@ -3004,7 +3130,11 @@ mod tests {
duplicate_base_mode,
disabled,
];
let entries = tray_style_pack_menu_entries(&packs, "imported.meeting");
let entries = tray_style_pack_menu_entries(
&packs,
"imported.meeting",
TrayLabels::for_locale("zh-CN"),
);
assert_eq!(
entries
@@ -3031,6 +3161,54 @@ mod tests {
assert_eq!(entries[0].id, tray_style_pack_menu_id("builtin.raw"));
}
#[test]
fn tray_labels_follow_locale_and_localize_builtin_styles() {
let english = TrayLabels::for_locale("en");
assert_eq!(english.toggle, "Show main window");
assert_eq!(english.style, "Output style");
assert_eq!(english.microphone, "Select microphone");
assert_eq!(english.default_microphone, "System default microphone");
assert_eq!(english.quit, "Quit OpenLess");
assert_eq!(english.style_pack_name(PolishMode::Light), "Light polish");
assert_eq!(
english.default_device_label("USB microphone"),
"USB microphone (System default)"
);
let chinese = TrayLabels::for_locale("zh-CN");
assert_eq!(chinese.style_pack_name(PolishMode::Structured), "清晰结构");
assert_eq!(TrayLabels::for_locale("unknown").toggle, "显示主窗口");
}
#[test]
fn tray_style_menu_localizes_builtins_and_preserves_custom_names() {
let packs = vec![
builtin_style_pack_for_mode(PolishMode::Raw),
builtin_style_pack_for_mode(PolishMode::Light),
StylePack {
id: "imported.meeting".into(),
name: "会议纪要".into(),
kind: StylePackKind::Imported,
base_mode: PolishMode::Structured,
..StylePack::default()
},
];
let entries = tray_style_pack_menu_entries(&packs, "", TrayLabels::for_locale("en"));
assert_eq!(entries[0].label, "Raw");
assert_eq!(entries[1].label, "Light polish");
assert_eq!(entries[2].label, "会议纪要");
}
#[test]
fn tray_style_menu_preserves_renamed_builtin_names() {
let mut renamed = builtin_style_pack_for_mode(PolishMode::Raw);
renamed.name = "My own raw style".into();
let entries = tray_style_pack_menu_entries(&[renamed], "", TrayLabels::for_locale("en"));
assert_eq!(entries[0].label, "My own raw style");
}
#[test]
fn tray_style_menu_ids_are_stable_and_collision_safe() {
let first = tray_style_pack_menu_id("imported.meeting");