diff --git a/nebula_app/src/display/chrome.rs b/nebula_app/src/display/chrome.rs index 1f9010a..b03d949 100644 --- a/nebula_app/src/display/chrome.rs +++ b/nebula_app/src/display/chrome.rs @@ -639,6 +639,7 @@ pub(super) fn draw_chrome(d: &mut Display) { // smoke on the light themes); close-hover red stays semantic. let palette = d.nebula_theme.palette(); let sk = d.nebula_theme.skin(); + let language = d.ui_language(); #[allow(non_snake_case)] let (HOVER_FILL, HOVER_FILL_STRONG) = (sk.hover, sk.hover_strong); const CLOSE_HOVER_FILL: Rgba = Rgba::new(240, 80, 104, 96); @@ -996,7 +997,7 @@ pub(super) fn draw_chrome(d: &mut Display) { d.nebula_chrome_hover, ChromeHit::TabsSection | ChromeHit::NewTab | ChromeHit::NewTabMenu ); - if !d.nebula_settings_open && tabs_plus_visible { + if tabs_plus_visible { let plus_ink = if d.nebula_chrome_hover == ChromeHit::NewTab { sk.icon_hover } else { sk.icon }; push_centered_add_icon(&mut quads, tab_layout.plus, scale, plus_ink); @@ -1006,18 +1007,16 @@ pub(super) fn draw_chrome(d: &mut Display) { if d.nebula_chrome_hover == ChromeHit::NewTabMenu { quads.push(UiQuad::solid(menu_x, menu_y, menu_w, menu_h, pill_r, HOVER_FILL_STRONG)); } - if !d.nebula_settings_open { - push_centered_more_icon( - &mut quads, - tab_layout.menu, - scale, - if d.nebula_chrome_hover == ChromeHit::NewTabMenu { sk.icon_hover } else { sk.icon }, - ); - } + push_centered_more_icon( + &mut quads, + tab_layout.menu, + scale, + if d.nebula_chrome_hover == ChromeHit::NewTabMenu { sk.icon_hover } else { sk.icon }, + ); // SSH HOSTS section (quad layer): hover fill per row + the per-section // overlay scrollbar thumbs. Row icons/labels render in the text pass. - if d.left_sidebar_visible() && !d.nebula_settings_open { + if d.left_sidebar_visible() { for (index, (hx, hy, hw, hh)) in tab_layout.hosts.iter().copied().enumerate() { if hw <= 0.0 { continue; @@ -1099,7 +1098,7 @@ pub(super) fn draw_chrome(d: &mut Display) { Gradient::Horizontal, )); - // Right-side drawer (directory tree / git) sits below modal layers. Keeps + // Right-side drawer (directory tree / git) remains part of the app shell. Keeps // drawing through slide-out; animation stepping is centralized in Display. d.step_chrome_anims(); if d.side_panel_visible() { @@ -1126,7 +1125,7 @@ pub(super) fn draw_chrome(d: &mut Display) { } // No base pill behind the gear — just the icon (hover still fills). - // Nebula settings modal (dim veil, glass panel, controls) above the drawer. + // The Settings special tab paints its page inside the normal content card. if d.nebula_settings_open { settings::push_quads(&d.settings_view(), &mut quads, &size, scale); } @@ -1228,9 +1227,8 @@ pub(super) fn draw_chrome(d: &mut Display) { // Vertical tab labels. Each row's Y comes from the eased anim slot; the // label is left-aligned after the accent gutter, the × pinned right. let row_text_cy = |ry: f32, rh: f32| ry + (rh - cell_h) / 2.0; - // Sidebar text (caption, labels, × buttons) also stays under the - // settings glass — skip it entirely while the modal is up. - if d.left_sidebar_visible() && tab_layout.panel.2 > 0.0 && !d.nebula_settings_open { + // Sidebar text remains visible because Settings is a normal tab. + if d.left_sidebar_visible() && tab_layout.panel.2 > 0.0 { // "TABS" caption at the panel head, with an accordion chevron. The // panel abuts the top bar with no gap, so the caption is pushed down // inside the header band to keep clearance from the join. @@ -1247,7 +1245,7 @@ pub(super) fn draw_chrome(d: &mut Display) { section_title_tracking, TXT_DIM, section_title_flags, - &format!("TABS {tabs_chevron}"), + &format!("{} {tabs_chevron}", language.pick("标签页", "TABS")), &mut d.glyph_cache, ); for (index, (tab_x, row_y, tab_w, tab_h)) in tab_layout.tabs.iter().copied().enumerate() { @@ -1463,7 +1461,7 @@ pub(super) fn draw_chrome(d: &mut Display) { section_title_tracking, TXT_DIM, section_title_flags, - &format!("SSH HOSTS {hosts_chevron}"), + &format!("{} {hosts_chevron}", language.pick("SSH 主机", "SSH HOSTS")), &mut d.glyph_cache, ); // Empty state: the section stays visible with a hint teaching the @@ -1490,7 +1488,13 @@ pub(super) fn draw_chrome(d: &mut Display) { let mut line = String::new(); let mut cols = 0; let mut line_y = hh_y + hh_h + s(2.0); - for ch in "输入 ssh 命令,连接后自动保存".chars() { + for ch in language + .pick( + "输入 ssh 命令,连接后自动保存", + "Run an ssh command to save hosts after connecting", + ) + .chars() + { let ch_cols = ch.width().unwrap_or(0); if cols + ch_cols > max_cols && !line.is_empty() { d.renderer.draw_doc_text( @@ -1592,7 +1596,7 @@ pub(super) fn draw_chrome(d: &mut Display) { &mut d.glyph_cache, ); } - // Nebula settings modal text labels, above its quads. + // Settings tab text labels, above its quads. if d.nebula_settings_open { let view = d.settings_view(); let settings_shell_icons = @@ -1605,8 +1609,8 @@ pub(super) fn draw_chrome(d: &mut Display) { } // Palette text (query + result rows) sits on top of every chrome label. - // Drawer text stays below settings; otherwise it pierces the modal glass. - if d.side_panel_visible() && !d.nebula_settings_open { + // Drawer text remains in its own shell region beside the Settings page. + if d.side_panel_visible() { if let Some(panel) = d.nebula_sftp_panel.as_ref() { let scale = d.window.scale_factor as f32; let layout = sftp_panel::layout(&d.side_panel_layout(), scale); diff --git a/nebula_app/src/display/command_palette.rs b/nebula_app/src/display/command_palette.rs index cb1bb4d..e1d7bc9 100644 --- a/nebula_app/src/display/command_palette.rs +++ b/nebula_app/src/display/command_palette.rs @@ -260,6 +260,7 @@ const RECENT_MAX: usize = 6; /// Command palette UI + filtering state, embedded in `Display`. pub struct CommandPalette { + language: super::UiLanguage, open: bool, query: String, query_selection: super::text_input::SelectAllState, @@ -294,6 +295,7 @@ pub struct CommandPalette { impl CommandPalette { pub fn new() -> Self { let mut palette = Self { + language: super::UiLanguage::ZhCn, open: false, query: String::new(), query_selection: Default::default(), @@ -315,6 +317,13 @@ impl CommandPalette { self.open } + pub fn set_language(&mut self, language: super::UiLanguage) { + self.language = language; + if !self.profiles_only { + self.refilter(); + } + } + /// Whether the palette is in default-shell picking mode. pub fn is_picking_default(&self) -> bool { self.picking_default @@ -337,7 +346,7 @@ impl CommandPalette { // The label carries a glyph-free prefix so profile rows read // distinctly from built-in actions; the haystack adds latin // aliases (matching the static items' convention). - label: format!("启动:{name}"), + label: format!("{}:{name}", self.language.pick("启动", "Launch")), search: format!("启动 {name} profile launch connect qidong"), index, }) @@ -622,13 +631,46 @@ impl CommandPalette { None => PaletteRow { icon: String::new(), color_id: String::new(), - label: ITEMS[idx].label.to_string(), + label: localized_item_label(&ITEMS[idx], self.language).to_owned(), hint: ITEMS[idx].hint.to_string(), }, } } } +fn localized_item_label(item: &PaletteItem, language: super::UiLanguage) -> &'static str { + use PaletteAction::*; + if language == super::UiLanguage::ZhCn { + return item.label; + } + match item.action { + NewTab => "New tab", + CloseTab => "Close tab", + NextTab => "Next tab", + PrevTab => "Previous tab", + NewWindow => "New window", + SplitRight => "Split right", + SplitDown => "Split down", + ToggleFilesPanel => "Files panel", + ToggleGitPanel => "Git panel", + OpenSettings => "Open settings", + OpenSettingsFile => "Open configuration file", + ToggleGhost => "Toggle ghost completion", + CycleAccept => "Cycle completion accept key", + PickBackgroundImage => "Choose background image...", + CycleBackground => "Cycle background color", + ResetAppearance => "Restore appearance defaults", + SelectTheme(NebulaTheme::Nebula) => "Theme: Nebula", + SelectTheme(NebulaTheme::SilverLight) => "Theme: Silver Light", + SelectTheme(NebulaTheme::SteelDark) => "Theme: Steel Dark", + SelectTheme(NebulaTheme::LimestoneLight) => "Theme: Limestone", + SelectTheme(NebulaTheme::CoalDark) => "Theme: Coal Dark", + SelectTheme(NebulaTheme::LinenLight) => "Theme: Linen Light", + SelectTheme(NebulaTheme::MossDark) => "Theme: Moss Dark", + LaunchProfile(_) | LaunchShell(_) | SetDefaultShell(_) => item.label, + } +} + /// One rendered palette row. `icon` is the Nerd Font fallback glyph (empty for /// built-in action rows); `color_id` names a full-color brand PNG when the row /// is a detected shell (empty otherwise, so the glyph shows instead). @@ -764,14 +806,7 @@ pub(super) fn push_quads( Gradient::Axis([0.25, 0.95]), )); - quads.push(UiQuad::solid( - ix, - iy, - iw, - ih, - s(super::design_tokens::control::RADIUS), - sk.input, - )); + quads.push(UiQuad::solid(ix, iy, iw, ih, s(super::design_tokens::control::RADIUS), sk.input)); if model.query_all_selected() && !model.query.is_empty() { let cell_w = size.cell_width(); let columns: usize = model.query.chars().map(|c| c.width().unwrap_or(0)).sum(); @@ -865,22 +900,28 @@ pub(super) fn draw_text( // 空态同时给出输入光标和用途提示,避免只有一个空白色块让用户猜测。 r.draw_chrome_text(size, query_x, text_y, sk.ink_strong, cursor, gc); let placeholder = if model.profiles_only { - "搜索 Shell 或 Profile…" + model.language.pick("搜索 Shell 或 Profile…", "Search shells or profiles...") } else { - "搜索命令、Shell 或 Profile…" + model + .language + .pick("搜索命令、Shell 或 Profile…", "Search commands, shells or profiles...") }; r.draw_chrome_text(size, query_x + s(10.0), text_y, sk.ink_dim, placeholder, gc); } else { - let shown = if model.query_all_selected() { - query.to_owned() - } else { - format!("{query}{cursor}") - }; + let shown = + if model.query_all_selected() { query.to_owned() } else { format!("{query}{cursor}") }; r.draw_chrome_text(size, query_x, text_y, sk.ink_strong, &shown, gc); } if model.is_empty() { - r.draw_chrome_text(size, text_x, layout.list_y + s(8.0), sk.ink_dim, "无匹配命令", gc); + r.draw_chrome_text( + size, + text_x, + layout.list_y + s(8.0), + sk.ink_dim, + model.language.pick("无匹配命令", "No matching commands"), + gc, + ); return icon_draws; } diff --git a/nebula_app/src/display/i18n.rs b/nebula_app/src/display/i18n.rs new file mode 100644 index 0000000..f8cf796 --- /dev/null +++ b/nebula_app/src/display/i18n.rs @@ -0,0 +1,84 @@ +use crate::config::template::{self, TemplateLanguage}; + +/// Persisted UI language choice. The serialized values are stable because the +/// runtime settings file is also a supported hand-editing surface. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum LanguagePreference { + #[default] + System, + ZhCn, + EnUs, +} + +impl LanguagePreference { + pub fn parse(value: &str) -> Option { + match value.trim() { + "system" => Some(Self::System), + "zh-CN" => Some(Self::ZhCn), + "en-US" => Some(Self::EnUs), + _ => None, + } + } + + pub const fn as_str(self) -> &'static str { + match self { + Self::System => "system", + Self::ZhCn => "zh-CN", + Self::EnUs => "en-US", + } + } + + pub fn resolved(self) -> UiLanguage { + let template = template::resolve_template_language( + Some(self.as_str()), + None, + template::system_locale().as_deref(), + ) + .unwrap_or(TemplateLanguage::EnUs); + match template { + TemplateLanguage::ZhCn => UiLanguage::ZhCn, + TemplateLanguage::EnUs => UiLanguage::EnUs, + } + } +} + +/// Resolved language used by the current process. `pick` keeps translations +/// adjacent at call sites, which makes missing English text obvious in review. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum UiLanguage { + ZhCn, + EnUs, +} + +impl UiLanguage { + pub const fn pick<'a>(self, zh_cn: &'a str, en_us: &'a str) -> &'a str { + match self { + Self::ZhCn => zh_cn, + Self::EnUs => en_us, + } + } +} + +#[cfg(test)] +mod tests { + use super::{LanguagePreference, UiLanguage}; + + #[test] + fn persisted_language_values_are_stable() { + for (raw, expected) in [ + ("system", LanguagePreference::System), + ("zh-CN", LanguagePreference::ZhCn), + ("en-US", LanguagePreference::EnUs), + ] { + assert_eq!(LanguagePreference::parse(raw), Some(expected)); + assert_eq!(expected.as_str(), raw); + } + assert_eq!(LanguagePreference::parse("zh"), None); + } + + #[test] + fn explicit_languages_do_not_depend_on_the_system_locale() { + assert_eq!(LanguagePreference::ZhCn.resolved(), UiLanguage::ZhCn); + assert_eq!(LanguagePreference::EnUs.resolved(), UiLanguage::EnUs); + } +} diff --git a/nebula_app/src/display/mod.rs b/nebula_app/src/display/mod.rs index b26eba7..0d3ea14 100644 --- a/nebula_app/src/display/mod.rs +++ b/nebula_app/src/display/mod.rs @@ -56,9 +56,9 @@ use crate::display::damage::{DamageTracker, damage_y_to_viewport_y}; use crate::display::hint::{HintMatch, HintState}; use crate::display::meter::Meter; use crate::display::window::Window; -use crate::renderer::Rasterizer; use crate::event::{Event, EventType, Mouse, SearchState}; use crate::message_bar::{MessageBuffer, MessageType}; +use crate::renderer::Rasterizer; use crate::renderer::rects::{RenderLine, RenderLines, RenderRect}; use crate::renderer::ui::{Gradient, Rgba, UiQuad}; use crate::renderer::{self, GlyphCache, Renderer, platform}; @@ -75,6 +75,7 @@ mod chrome; pub mod command_palette; mod context_menu; pub mod design_tokens; +mod i18n; pub mod markdown_view; pub mod sftp_panel; pub mod side_panel; @@ -86,6 +87,7 @@ use chrome::{ truncate_tab_label, }; pub use context_menu::{ContextMenuAction, ContextMenuHit, ContextMenuTarget}; +pub use i18n::{LanguagePreference, UiLanguage}; mod file_dialog; mod settings; @@ -325,13 +327,6 @@ pub enum AcceptKey { } impl AcceptKey { - fn label(self) -> &'static str { - match self { - Self::Right => "\u{2192}", - Self::Tab => "Tab", - Self::Both => "\u{2192} / Tab", - } - } fn cycle(self) -> Self { match self { Self::Right => Self::Tab, @@ -644,7 +639,7 @@ pub(crate) fn program_icon(program: &str) -> &'static str { "cursor" | "cursor-agent" => "\u{f0ec3}", // md-cursor-default-outline "aider" | "goose" | "crush" | "ollama" => "\u{f06a9}", // md-robot "opencode" => "\u{f489}", // oct-terminal - "pi" => "\u{f135}", // fa-code + "pi" => "\u{f135}", // fa-code "git" | "gh" | "lazygit" => "\u{f418}", // oct-git-branch "vim" | "nvim" | "vi" | "hx" | "nano" => "\u{e62b}", // custom-vim "ssh" | "mosh" => "\u{f489}", // oct-terminal (remote) @@ -1390,6 +1385,8 @@ pub struct Display { /// window is following the operating system. nebula_window_theme_override: Option, pub nebula_settings_open: bool, + nebula_language_preference: LanguagePreference, + nebula_language: UiLanguage, /// Paths from the last successful app configuration generation. nebula_config_paths: Vec, /// Settings content scroll offset in scaled px (0 = top of the section). @@ -1598,11 +1595,8 @@ impl Display { let required_font_install = None; debug!("Loading \"{}\" font", &settings_init.font_family); - let font = config - .font - .clone() - .with_family(settings_init.font_family.clone()) - .with_size(font_size); + let font = + config.font.clone().with_family(settings_init.font_family.clone()).with_size(font_size); let mut glyph_cache = GlyphCache::new(rasterizer, &font)?; #[cfg(windows)] let mut nebula_font_families = glyph_cache.private_font_families(); @@ -1813,9 +1807,15 @@ impl Display { nebula_system_theme, nebula_window_theme_override, nebula_settings_open: false, + nebula_language_preference: settings_init.language, + nebula_language: settings_init.language.resolved(), nebula_config_paths: config.config_paths.clone(), nebula_settings_scroll: 0.0, - nebula_palette: command_palette::CommandPalette::new(), + nebula_palette: { + let mut palette = command_palette::CommandPalette::new(); + palette.set_language(settings_init.language.resolved()); + palette + }, nebula_detected_shells: None, nebula_side_panel: side_panel::SidePanel::new(), nebula_sftp_panel: None, @@ -1884,6 +1884,10 @@ impl Display { self.nebula_settings_open } + pub fn ui_language(&self) -> UiLanguage { + self.nebula_language + } + pub fn settings_section(&self) -> NebulaSettingsSection { self.nebula_settings_section } @@ -1906,9 +1910,11 @@ impl Display { if !self.nebula_settings_open { return; } + let area = self.terminal_card_rect(); let max = settings::settings_max_scroll( &self.size_info, self.window.scale_factor as f32, + area, self.nebula_settings_section, self.nebula_shell_picker_open, self.nebula_detected_shells.as_ref().map_or(0, Vec::len), @@ -2761,6 +2767,9 @@ impl Display { /// path so `draw_chrome` can still borrow `&mut renderer` afterwards. fn settings_view(&self) -> settings::SettingsView { settings::SettingsView { + area: self.terminal_card_rect(), + language_preference: self.nebula_language_preference, + language: self.nebula_language, section: self.nebula_settings_section, hover: self.nebula_settings_hover, theme: self.nebula_theme, @@ -2808,24 +2817,31 @@ impl Display { } } - pub fn toggle_settings(&mut self) { - self.nebula_settings_open = !self.nebula_settings_open; - if !self.nebula_settings_open { + pub fn set_settings_tab_active(&mut self, active: bool) { + if self.nebula_settings_open == active { + return; + } + self.nebula_settings_open = active; + if !active { self.nebula_shell_picker_open = false; self.nebula_font_picker_open = false; + self.nebula_settings_hover = SettingsHit::None; + } else { + // Each explicit visit starts at a predictable page origin. + self.nebula_settings_scroll = 0.0; } - // Fresh open always starts at the top. - self.nebula_settings_scroll = 0.0; self.pending_update.dirty = true; } - pub fn dismiss_settings(&mut self) { - if self.nebula_settings_open { - self.nebula_settings_open = false; - self.nebula_shell_picker_open = false; - self.nebula_font_picker_open = false; - self.pending_update.dirty = true; + pub fn set_ui_language(&mut self, preference: LanguagePreference) { + if self.nebula_language_preference == preference { + return; } + self.nebula_language_preference = preference; + self.nebula_language = preference.resolved(); + self.nebula_palette.set_language(self.nebula_language); + self.persist_nebula_settings(); + self.pending_update.dirty = true; } fn apply_nebula_theme(&mut self, theme: NebulaTheme) { @@ -3148,7 +3164,7 @@ impl Display { }; if !path.exists() { let language = crate::config::template::resolve_template_language( - None, + Some(self.nebula_language_preference.as_str()), None, crate::config::template::system_locale().as_deref(), ) @@ -3749,6 +3765,7 @@ impl Display { fn persist_nebula_settings(&mut self) { settings::nebula_settings_write(&settings::NebulaRuntimeSettings { + language: self.nebula_language_preference, ghost: self.nebula_ghost_enabled, accept: self.nebula_accept, shell: self.nebula_shell, @@ -3777,6 +3794,9 @@ impl Display { } let settings = settings::nebula_settings_load(config); + self.nebula_language_preference = settings.language; + self.nebula_language = settings.language.resolved(); + self.nebula_palette.set_language(self.nebula_language); let image_changed = settings.background_image != self.nebula_background_image; let font_changed = settings.font_family != self.nebula_font_family; self.nebula_theme_preference = settings.theme; @@ -4704,6 +4724,26 @@ impl Display { self.present_frame(scheduler); } + /// Draw the Settings special tab. Its controls are emitted by the chrome + /// pass so they retain the same hit geometry and icon texture pipeline as + /// the rest of Nebula, but the base is a normal tab content card. + pub fn draw_settings_frame(&mut self, scheduler: &mut Scheduler) { + self.renderer.set_window_height(self.size_info.height()); + + let shell_bg = self.nebula_theme.palette().shell_bg; + self.renderer.clear(shell_bg, self.nebula_window_opacity); + self.draw_background_image(); + + let card_bg = self.nebula_background.unwrap_or(self.colors[NamedColor::Background]); + let (cx, cy, cw, ch) = self.terminal_card_rect(); + let scale = self.window.scale_factor as f32; + let card_r = (UI_SHELL_RADIUS_LOGICAL * scale).round(); + let card = Rgba::new(card_bg.r, card_bg.g, card_bg.b, 255); + self.renderer.draw_ui(&self.size_info, &[UiQuad::solid(cx, cy, cw, ch, card_r, card)]); + + self.present_frame(scheduler); + } + /// Draw one pane of a multi-pane layout into `view`. `clear_first` clears /// the whole window before the first pane; later panes paint on top. #[allow(clippy::too_many_arguments)] @@ -6534,9 +6574,8 @@ mod nebula_ux_tests { #[test] fn missing_font_notice_can_be_dismissed() { - let confirm = NebulaConfirm::InstallRequiredFont { - directory: std::path::PathBuf::from("fonts"), - }; + let confirm = + NebulaConfirm::InstallRequiredFont { directory: std::path::PathBuf::from("fonts") }; assert!(confirm.can_dismiss()); } diff --git a/nebula_app/src/display/settings.rs b/nebula_app/src/display/settings.rs index 5dac733..da6be88 100644 --- a/nebula_app/src/display/settings.rs +++ b/nebula_app/src/display/settings.rs @@ -1,5 +1,4 @@ -//! Top-left settings modal: a centered floating panel for Nebula's runtime -//! appearance and completion settings. +//! Settings special tab for Nebula's runtime appearance and completion settings. //! //! Mirrors the `command_palette` split, but goes one step further: besides the //! *model* (sections, hit-testing, geometry, and the `nebula_settings.txt` @@ -11,7 +10,7 @@ //! //! Being a descendant module of `display`, this file can freely use the parent's //! private helpers (`contains_rect`, `truncate_tab_label`, `nebula_data_dir`, -//! `NebulaTheme::palette`, `AcceptKey::label`, …) via `super::` — no visibility +//! `NebulaTheme::palette`, `AcceptKey`, …) via `super::` — no visibility //! churn needed in `mod.rs`. use unicode_width::UnicodeWidthChar; @@ -23,13 +22,13 @@ use crate::renderer::{GlyphCache, Renderer}; use super::theme::Skin; use super::{ - AcceptKey, NebulaShell, NebulaTheme, SizeInfo, chrome_settings_button_rect, contains_rect, - nebula_data_dir, truncate_tab_label, + AcceptKey, LanguagePreference, NebulaShell, NebulaTheme, SizeInfo, UiLanguage, + chrome_settings_button_rect, contains_rect, nebula_data_dir, truncate_tab_label, }; // Visual language: one flat panel color, one hairline, three text grays, ONE // accent — hierarchy comes from typography and spacing. Every color is a -// [`Skin`] token from `display::theme` (single source of truth), so the modal +// [`Skin`] token from `display::theme` (single source of truth), so the page // flips correctly between the light and dark theme families. /// Sidebar sections of the settings panel. Deliberately small: only sections @@ -48,12 +47,12 @@ pub enum NebulaSettingsSection { } impl NebulaSettingsSection { - fn label(self) -> &'static str { + fn label(self, language: UiLanguage) -> &'static str { match self { - Self::Appearance => "外观", - Self::Profiles => "配置文件", - Self::Keymap => "按键映射", - Self::Advanced => "高级", + Self::Appearance => language.pick("外观", "Appearance"), + Self::Profiles => language.pick("配置文件", "Profiles"), + Self::Keymap => language.pick("按键映射", "Key bindings"), + Self::Advanced => language.pick("高级", "Advanced"), } } } @@ -61,22 +60,22 @@ impl NebulaSettingsSection { /// Shortcut sheet shown in 设置→按键映射. Read-only for now: the combos on the /// right are Nebula's effective defaults; `[[keyboard.bindings]]` in the config /// file (设置→配置文件→打开配置文件) remaps the standard actions. -pub(super) const KEYMAP_ROWS: &[(&str, &str)] = &[ - ("新建标签页", "Ctrl+Shift+T"), - ("关闭标签页 / 分屏", "Ctrl+Shift+W"), - ("下一个 / 上一个标签页", "Ctrl+Tab / Ctrl+Shift+Tab"), - ("切换到第 N 个标签页", "Alt+1..9 或 Ctrl+1..9"), - ("新建窗口", "Ctrl+Shift+E"), - ("命令面板", "Ctrl+Shift+P"), - ("左右 / 上下分屏", "Ctrl+Shift+D / Ctrl+Shift+S"), - ("分屏焦点切换", "Ctrl+Alt+方向键"), - ("放大当前分屏", "Ctrl+Shift+Enter"), - ("启动 Profile N", "Ctrl+Shift+1..9"), - ("目录树 / Git 面板", "Ctrl+Shift+O / Ctrl+Shift+G"), - ("搜索(向前 / 向后)", "Ctrl+Shift+F / Ctrl+Shift+B"), - ("复制 / 粘贴", "Ctrl+Shift+C / Ctrl+V"), - ("字号 增 / 减 / 重置", "Ctrl+= / Ctrl+- / Ctrl+0"), - ("全屏", "Alt+Enter"), +pub(super) const KEYMAP_ROWS: &[(&str, &str, &str)] = &[ + ("新建标签页", "New tab", "Ctrl+Shift+T"), + ("关闭标签页 / 分屏", "Close tab / pane", "Ctrl+Shift+W"), + ("下一个 / 上一个标签页", "Next / previous tab", "Ctrl+Tab / Ctrl+Shift+Tab"), + ("切换到第 N 个标签页", "Select tab N", "Alt+1..9 / Ctrl+1..9"), + ("新建窗口", "New window", "Ctrl+Shift+E"), + ("命令面板", "Command palette", "Ctrl+Shift+P"), + ("左右 / 上下分屏", "Split right / down", "Ctrl+Shift+D / Ctrl+Shift+S"), + ("分屏焦点切换", "Move pane focus", "Ctrl+Alt+Arrow"), + ("放大当前分屏", "Zoom current pane", "Ctrl+Shift+Enter"), + ("启动 Profile N", "Launch Profile N", "Ctrl+Shift+1..9"), + ("目录树 / Git 面板", "Files / Git panel", "Ctrl+Shift+O / Ctrl+Shift+G"), + ("搜索(向前 / 向后)", "Search forward / backward", "Ctrl+Shift+F / Ctrl+Shift+B"), + ("复制 / 粘贴", "Copy / paste", "Ctrl+Shift+C / Ctrl+V"), + ("字号 增 / 减 / 重置", "Font size up / down / reset", "Ctrl+= / Ctrl+- / Ctrl+0"), + ("全屏", "Fullscreen", "Alt+Enter"), ]; /// Hit result for the top-left Nebula settings affordance. @@ -85,9 +84,9 @@ pub enum SettingsHit { None, Toggle, Panel, - Dismiss, Nav(NebulaSettingsSection), Theme(NebulaTheme), + Language(LanguagePreference), SystemThemeToggle, GhostToggle, AcceptCycle, @@ -114,6 +113,7 @@ pub enum SettingsHit { // ---- runtime settings store (`Nebula/nebula_settings.txt`) ---- pub(super) struct NebulaRuntimeSettings { + pub(super) language: LanguagePreference, pub(super) ghost: bool, pub(super) accept: AcceptKey, pub(super) shell: NebulaShell, @@ -157,6 +157,7 @@ pub(super) struct NebulaRuntimeSettings { pub(super) fn nebula_settings_load(config: &UiConfig) -> NebulaRuntimeSettings { let path = nebula_data_dir().join("nebula_settings.txt"); let mut settings = NebulaRuntimeSettings { + language: LanguagePreference::System, ghost: true, accept: AcceptKey::Both, shell: NebulaShell::PowerShell, @@ -186,6 +187,11 @@ pub(super) fn nebula_settings_load(config: &UiConfig) -> NebulaRuntimeSettings { if let Ok(data) = std::fs::read_to_string(path) { for line in data.lines() { match line.split_once('=') { + Some(("language", v)) => { + if let Some(language) = LanguagePreference::parse(v) { + settings.language = language; + } + }, Some(("ghost", v)) => settings.ghost = v.trim() != "0", Some(("theme", v)) => { if let Some(theme) = NebulaTheme::from_prompt_name(v.trim()) { @@ -316,7 +322,8 @@ pub(super) fn nebula_settings_write(settings: &NebulaRuntimeSettings) { let _ = std::fs::write( path, format!( - "theme={theme}\nfollow_system_theme={}\nghost={}\naccept={accept}\nshell={shell}\nfont_family={}\nfetch={}\npowerline={}\nkeep_session={}\nopacity={:.2}\nbackground={background}\nbackground_image={background_image}\nbackground_image_opacity={:.2}\npinned_hosts={pinned_hosts}\nsaved_hosts={saved_hosts}\nhidden_hosts={hidden_hosts}\n", + "language={}\ntheme={theme}\nfollow_system_theme={}\nghost={}\naccept={accept}\nshell={shell}\nfont_family={}\nfetch={}\npowerline={}\nkeep_session={}\nopacity={:.2}\nbackground={background}\nbackground_image={background_image}\nbackground_image_opacity={:.2}\npinned_hosts={pinned_hosts}\nsaved_hosts={saved_hosts}\nhidden_hosts={hidden_hosts}\n", + settings.language.as_str(), settings.follow_system_theme as u8, settings.ghost as u8, settings.font_family, @@ -355,9 +362,11 @@ struct SettingsGeometry { hidden_host_count: usize, /// Full-width "窗口透明度" row (the frame); the stepper buttons below sit /// inside it. + language_row: (f32, f32, f32, f32), opacity_row: (f32, f32, f32, f32), opacity_down: (f32, f32, f32, f32), opacity_up: (f32, f32, f32, f32), + language: [(LanguagePreference, f32, f32, f32, f32); 3], background: (f32, f32, f32, f32), background_image: (f32, f32, f32, f32), reset: (f32, f32, f32, f32), @@ -376,7 +385,7 @@ struct SettingsGeometry { keep_session: (f32, f32, f32, f32), } -/// Scrollable-content viewport height for the settings modal. +/// Scrollable-content viewport height for the Settings tab. fn settings_viewport_h(popup_h: f32, scale_factor: f32) -> f32 { popup_h - 72.0 * scale_factor } @@ -386,6 +395,7 @@ fn settings_viewport_h(popup_h: f32, scale_factor: f32) -> f32 { pub(super) fn settings_max_scroll( size_info: &SizeInfo, scale_factor: f32, + area: (f32, f32, f32, f32), section: NebulaSettingsSection, shell_picker_open: bool, shell_picker_count: usize, @@ -396,6 +406,7 @@ pub(super) fn settings_max_scroll( let geometry = settings_geometry( size_info, scale_factor, + area, 0.0, shell_picker_open, shell_picker_count, @@ -416,6 +427,7 @@ pub(super) fn settings_max_scroll( fn settings_geometry( size_info: &SizeInfo, scale_factor: f32, + area: (f32, f32, f32, f32), scroll: f32, shell_picker_open: bool, shell_picker_count: usize, @@ -424,23 +436,16 @@ fn settings_geometry( hidden_host_count: usize, ) -> SettingsGeometry { let s = |v: f32| v * scale_factor; - let w = size_info.width(); - let h = size_info.height(); - let margin = s(8.0); - let bar_h = s(40.0); let gear = chrome_settings_button_rect(size_info, scale_factor); - // Floating, centered modal — fixed design size clamped to the window so it - // never fills the whole screen, sized to hold the 4+3 theme card grid plus - // the compact appearance/profile controls (no large vertical gaps). - let popup_w = s(1000.0).min(w - 2.0 * margin); - let popup_h = s(640.0).min(h - 2.0 * bar_h - 2.0 * margin); - let popup_x = ((w - popup_w) * 0.5).max(margin); - let popup_y = ((h - popup_h) * 0.5).max(bar_h + margin); - let sidebar_w = s(240.0).min(popup_w * 0.30); + // The settings surface is the active tab's content card. Keeping the + // geometry rooted in that card makes sidebar/drawer animations and DPI + // changes follow the exact same bounds as terminal and document tabs. + let (popup_x, popup_y, popup_w, popup_h) = area; + let sidebar_w = s(220.0).min(popup_w * 0.30); let sidebar = (popup_x, popup_y, sidebar_w, popup_h); let content_x = popup_x + sidebar_w + s(16.0); - let content_w = (popup_w - sidebar_w - s(16.0)).max(s(520.0)); + let content_w = (popup_w - sidebar_w - s(16.0)).max(s(240.0)); let content = (content_x, popup_y, content_w, popup_h); // The header band (big section title) is fixed; everything below it @@ -479,11 +484,16 @@ fn settings_geometry( let system_theme_y0 = card_y0 + 2.0 * (64.0 + 48.0) + GROUP_ADVANCE; let color_y0 = system_theme_y0 + ROW_H + GROUP_ADVANCE; let iface_y0 = color_y0 + 2.0 * ROW_H + GROUP_ADVANCE; - let appearance_h = s(iface_y0 + ROW_H + 32.0 - 72.0); + let opacity_y0 = iface_y0 + ROW_H; + let appearance_h = s(opacity_y0 + ROW_H + 32.0 - 72.0); // Opacity row controls, right-aligned with NO overlap: value · − · slider · +. - let opacity_row = (row_x, at(iface_y0), row_w, row_h); - let opacity_down = (row_x + row_w - s(224.0), at(iface_y0) + s(4.0), s(40.0), s(36.0)); - let opacity_up = (row_x + row_w - s(46.0), at(iface_y0) + s(4.0), s(40.0), s(36.0)); + let opacity_row = (row_x, at(opacity_y0), row_w, row_h); + let opacity_down = (row_x + row_w - s(224.0), at(opacity_y0) + s(4.0), s(40.0), s(36.0)); + let opacity_up = (row_x + row_w - s(46.0), at(opacity_y0) + s(4.0), s(40.0), s(36.0)); + let language_track_w = s(360.0).min(row_w * 0.58); + let language_w = language_track_w / 3.0; + let language_x = row_x + row_w - language_track_w - s(8.0); + let language_y = at(iface_y0) + s(5.0); // Sidebar navigation rows. Only the two wired-up sections are listed; the // rects line up with the active-row highlight drawn while rendering. @@ -506,8 +516,7 @@ fn settings_geometry( let shell_picker_extra = if shell_picker_open { shell_picker_count as f32 * ROW_H } else { 0.0 }; let font_y0 = shell_y0 + ROW_H + shell_picker_extra; - let font_picker_extra = - if font_picker_open { font_picker_count as f32 * ROW_H } else { 0.0 }; + let font_picker_extra = if font_picker_open { font_picker_count as f32 * ROW_H } else { 0.0 }; let fetch_y0 = font_y0 + ROW_H + font_picker_extra; let ghost_y0 = fetch_y0 + 2.0 * ROW_H + GROUP_ADVANCE; let open_y0 = ghost_y0 + 2.0 * ROW_H + GROUP_ADVANCE; @@ -547,9 +556,21 @@ fn settings_geometry( system_theme: (row_x, at(system_theme_y0), row_w, row_h), background: (row_x, at(color_y0), row_w, row_h), background_image: (row_x, at(color_y0 + ROW_H), row_w, row_h), + language_row: (row_x, at(iface_y0), row_w, row_h), opacity_row, opacity_down, opacity_up, + language: [ + (LanguagePreference::System, language_x, language_y, language_w, s(34.0)), + (LanguagePreference::ZhCn, language_x + language_w, language_y, language_w, s(34.0)), + ( + LanguagePreference::EnUs, + language_x + language_w * 2.0, + language_y, + language_w, + s(34.0), + ), + ], shell: (row_x, at(shell_y0), row_w, row_h), shell_picker: ( at(shell_y0 + ROW_H), @@ -587,6 +608,7 @@ fn settings_geometry( pub fn settings_hit( size_info: &SizeInfo, scale_factor: f32, + area: (f32, f32, f32, f32), x: f32, y: f32, popup_open: bool, @@ -601,6 +623,7 @@ pub fn settings_hit( let geometry = settings_geometry( size_info, scale_factor, + area, scroll, shell_picker_open, shell_picker_count, @@ -650,6 +673,11 @@ pub fn settings_hit( if contains_rect(geometry.background_image, x, y) { return SettingsHit::BackgroundImage; } + for (language, lx, ly, lw, lh) in geometry.language { + if contains_rect((lx, ly, lw, lh), x, y) { + return SettingsHit::Language(language); + } + } if contains_rect(geometry.opacity_down, x, y) { return SettingsHit::OpacityDown; } @@ -680,12 +708,8 @@ pub fn settings_hit( if font_picker_open && in_viewport { let (picker_y0, row_h, max) = geometry.font_picker; for i in 0..max { - let rect = ( - geometry.font.0, - picker_y0 + i as f32 * row_h, - geometry.font.2, - row_h, - ); + let rect = + (geometry.font.0, picker_y0 + i as f32 * row_h, geometry.font.2, row_h); if contains_rect(rect, x, y) { return SettingsHit::FontPickerRow(i); } @@ -723,7 +747,7 @@ pub fn settings_hit( } } - if contains_rect(geometry.popup, x, y) { SettingsHit::Panel } else { SettingsHit::Dismiss } + if contains_rect(geometry.popup, x, y) { SettingsHit::Panel } else { SettingsHit::None } } // ---- rendering ---- @@ -732,6 +756,11 @@ pub fn settings_hit( /// data (notably the wallpaper path) so the caller can hand it in by reference /// while still borrowing `&mut renderer` for [`draw_text`]. pub(super) struct SettingsView { + /// The active tab's content card in physical pixels. Settings fills this + /// area like any other tab instead of inventing a second floating window. + pub(super) area: (f32, f32, f32, f32), + pub(super) language_preference: LanguagePreference, + pub(super) language: UiLanguage, pub(super) section: NebulaSettingsSection, pub(super) hover: SettingsHit, pub(super) theme: NebulaTheme, @@ -766,9 +795,7 @@ pub(super) struct SettingsView { pub(super) scroll: f32, } -/// Push the settings modal's background quads (dim veil, flat neutral panel, -/// sidebar separator, nav indicator, rows, theme cards and controls). Caller -/// guards on the panel being open. +/// Push the Settings tab's background quads, navigation, rows and controls. pub(super) fn push_quads( view: &SettingsView, quads: &mut Vec, @@ -776,13 +803,12 @@ pub(super) fn push_quads( scale: f32, ) { let s = |v: f32| v * scale; - let w = size.width(); - let h = size.height(); let sk = view.theme.skin(); let geometry = settings_geometry( size, scale, + view.area, view.scroll, view.shell_picker_open, view.shells.len(), @@ -806,31 +832,8 @@ pub(super) fn push_quads( } }; - // Heavily dim the whole window behind the modal. The panel is now opaque, - // so the veil's only job is to push the terminal far back — a strong, near - // -black wash reads as "modal focus" instead of the old faint glass tint. - quads.push(UiQuad::solid(0.0, 0.0, w, h, 0.0, Rgba::new(0, 0, 0, 205))); - - // Diffuse shadow: one wide, soft glow under the panel sells the Z-depth - // (the softer and wider, the higher the panel appears to float). - quads.push(UiQuad::glow( - px - s(44.0), - py - s(26.0), - pw + s(88.0), - ph + s(96.0), - Rgba::new(0, 0, 0, 180), - )); - - // Hairline edge + near-opaque themed panel (two-layer stroke: 1px larger - // quad underneath), then a 1px top edge-light — the lit bevel. - quads.push(UiQuad::solid( - px - s(1.0), - py - s(1.0), - pw + s(2.0), - ph + s(2.0), - s(13.0), - sk.hairline, - )); + // The page is flush with the active tab card. No veil, drop shadow or + // second window outline: depth belongs to the app shell, not this page. quads.push(UiQuad::solid(px, py, pw, ph, s(12.0), sk.panel)); quads.push(UiQuad::solid( px + s(12.0), @@ -1033,18 +1036,36 @@ pub(super) fn push_quads( } group_frame(quads, geometry.system_theme, 1); - row_hover( - quads, - geometry.system_theme, - view.hover == SettingsHit::SystemThemeToggle, - ); + row_hover(quads, geometry.system_theme, view.hover == SettingsHit::SystemThemeToggle); toggle(quads, geometry.system_theme, view.follow_system_theme); - // 自定义背景: one 2-row frame. 界面: one single-row frame. + // 自定义背景和界面都使用连续分组,避免设置 Tab 内再次出现 + // 漂浮卡片语言。 group_frame(quads, geometry.background, 2); row_hover(quads, geometry.background, view.hover == SettingsHit::BackgroundColor); row_hover(quads, geometry.background_image, view.hover == SettingsHit::BackgroundImage); - group_frame(quads, geometry.opacity_row, 1); + group_frame(quads, geometry.language_row, 2); + for (language, lx, ly, lw, lh) in geometry.language { + let selected = language == view.language_preference; + let hovered = view.hover == SettingsHit::Language(language); + clip( + quads, + UiQuad::solid( + lx, + ly, + lw, + lh, + s(6.0), + if selected { + sk.accent_soft + } else if hovered { + sk.hover + } else { + sk.surface + }, + ), + ); + } // Opacity controls inside the row: ghost stepper buttons flanking // a muted rail with a flat accent fill and a round thumb. @@ -1175,11 +1196,7 @@ pub(super) fn push_quads( for index in 0..geometry.hidden_host_count { let mut rect = geometry.hidden_host_row0; rect.1 += index as f32 * rect.3; - row_hover( - quads, - rect, - view.hover == SettingsHit::RestoreHiddenSsh(index), - ); + row_hover(quads, rect, view.hover == SettingsHit::RestoreHiddenSsh(index)); } } for (hit, rect) in [ @@ -1305,8 +1322,7 @@ fn row_label( r.draw_chrome_text(size, vx.max(value_left), ty, value_ink, &value, gc); } -/// Draw the settings modal's text labels on top of its quads. Caller guards on -/// the panel being open. +/// Draw the Settings tab's text labels on top of its quads. pub(super) fn draw_text( view: &SettingsView, r: &mut Renderer, @@ -1318,10 +1334,12 @@ pub(super) fn draw_text( let cell_w = size.cell_width(); let cell_h = size.cell_height(); let sk = view.theme.skin(); + let language = view.language; let geometry = settings_geometry( size, scale, + view.area, view.scroll, view.shell_picker_open, view.shells.len(), @@ -1354,12 +1372,12 @@ pub(super) fn draw_text( py + s(22.0), 1.5, sk.ink_strong, - "Nebula 设置", + language.pick("Nebula 设置", "Nebula Settings"), ); { // Center the reset label inside its ghost button. let (rx, ry, rw, rh) = geometry.reset; - let label = "恢复默认设置"; + let label = language.pick("恢复默认设置", "Restore defaults"); let cols: usize = label.chars().map(|c| c.width().unwrap_or(0)).sum(); let tx = rx + (rw - cols as f32 * cell_w) / 2.0; r.draw_chrome_text(size, tx, ry + (rh - cell_h) / 2.0, sk.ink_dim, label, gc); @@ -1380,7 +1398,7 @@ pub(super) fn draw_text( } else { sk.ink_dim }, - nav_section.label(), + nav_section.label(view.language), gc, ); } @@ -1396,7 +1414,7 @@ pub(super) fn draw_text( content_y + s(20.0), 1.6, sk.ink_strong, - section.label(), + section.label(view.language), ); match section { @@ -1411,7 +1429,7 @@ pub(super) fn draw_text( &sk, content_x + s(24.0), group_y(cards_y), - "主题", + language.pick("主题", "Themes"), ); } for (theme, ox, oy, ow, oh) in geometry.options { @@ -1443,7 +1461,16 @@ pub(super) fn draw_text( } let (st_x, st_y, _, st_h) = geometry.system_theme; if visible(group_y(st_y), title_h) { - section_title(r, gc, size, scale, &sk, st_x, group_y(st_y), "主题模式"); + section_title( + r, + gc, + size, + scale, + &sk, + st_x, + group_y(st_y), + language.pick("主题模式", "Theme mode"), + ); } if visible(st_y, st_h) { r.draw_chrome_text( @@ -1451,17 +1478,28 @@ pub(super) fn draw_text( st_x + s(16.0), st_y + (st_h - cell_h) / 2.0, sk.ink, - "跟随系统明暗模式", + language.pick("跟随系统明暗模式", "Follow system appearance"), gc, ); } let (bg_x, bg_y, _, bg_h) = geometry.background; if visible(group_y(bg_y), title_h) { - section_title(r, gc, size, scale, &sk, bg_x, group_y(bg_y), "自定义背景"); + section_title( + r, + gc, + size, + scale, + &sk, + bg_x, + group_y(bg_y), + language.pick("自定义背景", "Custom background"), + ); } if visible(bg_y, bg_h) { - let background_v = - view.background.map(format_hex_rgb).unwrap_or_else(|| "主题默认".to_owned()); + let background_v = view + .background + .map(format_hex_rgb) + .unwrap_or_else(|| language.pick("主题默认", "Theme default").to_owned()); row_label( r, gc, @@ -1469,7 +1507,7 @@ pub(super) fn draw_text( scale, &sk, geometry.background, - "背景色", + language.pick("背景色", "Background color"), &background_v, sk.accent, ); @@ -1481,7 +1519,7 @@ pub(super) fn draw_text( .background_image .as_deref() .map(|path| format!("{path} · {:.0}%", view.background_image_opacity * 100.0)) - .unwrap_or_else(|| "未设置".to_owned()); + .unwrap_or_else(|| language.pick("未设置", "Not set").to_owned()); row_label( r, gc, @@ -1489,14 +1527,50 @@ pub(super) fn draw_text( scale, &sk, geometry.background_image, - "背景图片", + language.pick("背景图片", "Background image"), &image_v, sk.accent, ); } let (or_x, or_y, _, or_h) = geometry.opacity_row; - if visible(group_y(or_y), title_h) { - section_title(r, gc, size, scale, &sk, content_x + s(24.0), group_y(or_y), "界面"); + let (lr_x, lr_y, _, lr_h) = geometry.language_row; + if visible(group_y(lr_y), title_h) { + section_title( + r, + gc, + size, + scale, + &sk, + content_x + s(24.0), + group_y(lr_y), + language.pick("界面", "Interface"), + ); + } + if visible(lr_y, lr_h) { + r.draw_chrome_text( + size, + lr_x + s(16.0), + lr_y + (lr_h - cell_h) / 2.0, + sk.ink, + language.pick("语言", "Language"), + gc, + ); + for (preference, lx, ly, lw, lh) in geometry.language { + let label = match preference { + LanguagePreference::System => language.pick("跟随系统", "Follow system"), + LanguagePreference::ZhCn => "简体中文", + LanguagePreference::EnUs => "English", + }; + let cols: usize = label.chars().map(|c| c.width().unwrap_or(0)).sum(); + r.draw_chrome_text( + size, + lx + (lw - cols as f32 * cell_w) / 2.0, + ly + (lh - cell_h) / 2.0, + if preference == view.language_preference { sk.accent } else { sk.ink_dim }, + label, + gc, + ); + } } if visible(or_y, or_h) { r.draw_chrome_text( @@ -1504,7 +1578,7 @@ pub(super) fn draw_text( or_x + s(16.0), or_y + (or_h - cell_h) / 2.0, sk.ink, - "窗口透明度", + language.pick("窗口透明度", "Window opacity"), gc, ); // Center the fullwidth −/+ glyphs inside their stepper buttons. @@ -1538,7 +1612,16 @@ pub(super) fn draw_text( // with the next group's title. let (sh_x, sh_y, _, sh_h) = geometry.shell; if visible(group_y(sh_y), title_h) { - section_title(r, gc, size, scale, &sk, sh_x, group_y(sh_y), "终端"); + section_title( + r, + gc, + size, + scale, + &sk, + sh_x, + group_y(sh_y), + language.pick("终端", "Terminal"), + ); } if visible(sh_y, sh_h) { row_label( @@ -1548,7 +1631,7 @@ pub(super) fn draw_text( scale, &sk, geometry.shell, - "默认 Shell", + language.pick("默认 Shell", "Default shell"), &view.shell_label, sk.accent, ); @@ -1597,7 +1680,7 @@ pub(super) fn draw_text( scale, &sk, geometry.font, - "终端字体", + language.pick("终端字体", "Terminal font"), font_value, if view.font_notice.is_some() { sk.ink_dim } else { sk.accent }, ); @@ -1611,7 +1694,7 @@ pub(super) fn draw_text( let (label, color) = match view.fonts.get(i) { Some(family) if family == &view.font_family => (family.as_str(), sk.accent), Some(family) => (family.as_str(), sk.ink), - None => ("+ 导入字体…", sk.accent), + None => (language.pick("+ 导入字体…", "+ Import font..."), sk.accent), }; r.draw_chrome_text( size, @@ -1635,7 +1718,17 @@ pub(super) fn draw_text( // Boolean rows: the switch (drawn in `push_quads`) carries the // state; no "On/Off" string next to it. if visible(geometry.fetch.1, geometry.fetch.3) { - row_label(r, gc, size, scale, &sk, geometry.fetch, "启动欢迎信息", "", sk.ink); + row_label( + r, + gc, + size, + scale, + &sk, + geometry.fetch, + language.pick("启动欢迎信息", "Startup welcome"), + "", + sk.ink, + ); } if visible(geometry.powerline.1, geometry.powerline.3) { row_label( @@ -1645,7 +1738,7 @@ pub(super) fn draw_text( scale, &sk, geometry.powerline, - "Powerline 提示符", + language.pick("Powerline 提示符", "Powerline prompt"), "", sk.ink, ); @@ -1653,10 +1746,29 @@ pub(super) fn draw_text( let (gh_x, gh_y, _, gh_h) = geometry.ghost; if visible(group_y(gh_y), title_h) { - section_title(r, gc, size, scale, &sk, gh_x, group_y(gh_y), "补全"); + section_title( + r, + gc, + size, + scale, + &sk, + gh_x, + group_y(gh_y), + language.pick("补全", "Completion"), + ); } if visible(gh_y, gh_h) { - row_label(r, gc, size, scale, &sk, geometry.ghost, "历史补全灰字", "", sk.ink); + row_label( + r, + gc, + size, + scale, + &sk, + geometry.ghost, + language.pick("历史补全灰字", "History ghost text"), + "", + sk.ink, + ); } if visible(geometry.accept.1, geometry.accept.3) { row_label( @@ -1666,15 +1778,28 @@ pub(super) fn draw_text( scale, &sk, geometry.accept, - "补全接受键", - view.accept.label(), + language.pick("补全接受键", "Completion accept key"), + match view.accept { + AcceptKey::Right => language.pick("右方向键", "Right arrow"), + AcceptKey::Tab => "Tab", + AcceptKey::Both => language.pick("Tab 或右方向键", "Tab or Right arrow"), + }, sk.accent, ); } let (ocx, ocy, _ocw, och) = geometry.open_config_file; if visible(group_y(ocy), title_h) { - section_title(r, gc, size, scale, &sk, ocx, group_y(ocy), "配置文件"); + section_title( + r, + gc, + size, + scale, + &sk, + ocx, + group_y(ocy), + language.pick("配置文件", "Configuration"), + ); } if visible(ocy, och) { r.draw_chrome_text( @@ -1682,7 +1807,7 @@ pub(super) fn draw_text( ocx + s(16.0), ocy + (och - cell_h) / 2.0, sk.accent, - "打开配置文件", + language.pick("打开配置文件", "Open configuration file"), gc, ); } @@ -1698,13 +1823,26 @@ pub(super) fn draw_text( &sk, hx, group_y(hy), - "已隐藏 SSH 主机 · 密码不会恢复", + language.pick( + "已隐藏 SSH 主机 · 密码不会恢复", + "Hidden SSH hosts · passwords are not restored", + ), ); } for (index, host) in view.hidden_hosts.iter().enumerate() { let rect = (hx, hy + index as f32 * hh, hw, hh); if visible(rect.1, rect.3) { - row_label(r, gc, size, scale, &sk, rect, host, "恢复", sk.accent); + row_label( + r, + gc, + size, + scale, + &sk, + rect, + host, + language.pick("恢复", "Restore"), + sk.accent, + ); } } } @@ -1720,20 +1858,42 @@ pub(super) fn draw_text( &sk, kx, group_y(ky), - "快捷键(可在配置文件 [[keyboard.bindings]] 中自定义)", + language.pick( + "快捷键(可在配置文件 [[keyboard.bindings]] 中自定义)", + "Shortcuts (customize in [[keyboard.bindings]])", + ), ); } - for (i, (label, combo)) in KEYMAP_ROWS.iter().enumerate() { + for (i, (zh_label, en_label, combo)) in KEYMAP_ROWS.iter().enumerate() { let rect = (kx, ky + i as f32 * geometry.keymap_row_h, kw, kh); if visible(rect.1, rect.3) { - row_label(r, gc, size, scale, &sk, rect, label, combo, sk.ink_dim); + row_label( + r, + gc, + size, + scale, + &sk, + rect, + view.language.pick(zh_label, en_label), + combo, + sk.ink_dim, + ); } } }, NebulaSettingsSection::Advanced => { let (ax, ay, _, ah) = geometry.keep_session; if visible(group_y(ay), title_h) { - section_title(r, gc, size, scale, &sk, ax, group_y(ay), "会话"); + section_title( + r, + gc, + size, + scale, + &sk, + ax, + group_y(ay), + language.pick("会话", "Sessions"), + ); } if visible(ay, ah) { // The switch (drawn in `push_quads`) carries the state; the @@ -1745,7 +1905,10 @@ pub(super) fn draw_text( scale, &sk, geometry.keep_session, - "关闭窗口后保留会话(后台驻留,可恢复对话)", + language.pick( + "关闭窗口后保留会话(后台驻留,可恢复对话)", + "Keep sessions after closing the window (resident and restorable)", + ), "", sk.ink, ); diff --git a/nebula_app/src/display/ssh_editor_render.rs b/nebula_app/src/display/ssh_editor_render.rs index 56cb1c6..33b5e8d 100644 --- a/nebula_app/src/display/ssh_editor_render.rs +++ b/nebula_app/src/display/ssh_editor_render.rs @@ -27,6 +27,7 @@ impl Display { let scale = self.window.scale_factor as f32; let s = |value: f32| value * scale; let skin = self.nebula_theme.skin(); + let language = self.ui_language(); let accent = Rgba::new(skin.accent.r, skin.accent.g, skin.accent.b, 255); let cell_h = size.cell_height(); let cell_w = size.cell_width(); @@ -84,7 +85,8 @@ impl Display { } else { zero }; - let save_label = "保存密码到 Windows 凭据管理器"; + let save_label = language + .pick("保存密码到 Windows 凭据管理器", "Save password in Windows Credential Manager"); let save_toggle = if show_password { ( destination.0, @@ -135,9 +137,9 @@ impl Display { Vec::new() }; - let primary_action = "保存"; + let primary_action = language.pick("保存", "Save"); let primary_key = "Enter"; - let cancel_action = "取消"; + let cancel_action = language.pick("取消", "Cancel"); let cancel_key = "Esc"; let key_pad = s(6.0); let label_gap = s(8.0); @@ -374,9 +376,9 @@ impl Display { skin.ink_strong, Flags::empty(), if editor.original_destination.is_some() { - "编辑 SSH 主机" + language.pick("编辑 SSH 主机", "Edit SSH host") } else { - "添加 SSH 主机" + language.pick("添加 SSH 主机", "Add SSH host") }, glyph_cache, ); @@ -385,7 +387,7 @@ impl Display { destination.0, destination.1 - cell_h - s(5.0), skin.ink, - "连接地址", + language.pick("连接地址", "Destination"), glyph_cache, ); self.renderer.draw_doc_text( @@ -399,7 +401,10 @@ impl Display { skin.ink_dim }, Flags::empty(), - editor.error.as_deref().unwrap_or("user@host · 非 22 端口用 ssh://"), + editor.error.as_deref().unwrap_or(language.pick( + "user@host · 非 22 端口用 ssh://", + "user@host · use ssh:// for non-22 ports", + )), glyph_cache, ); self.renderer.draw_chrome_text( @@ -415,20 +420,20 @@ impl Display { destination.0, auth_y - cell_h - s(5.0), skin.ink_dim, - "认证方式", + language.pick("认证方式", "Authentication"), glyph_cache, ); - let auth_labels = ["自动", "密码", "密钥", "Agent", "交互式"]; - for ((_, rect), label) in auth.iter().zip(auth_labels) { + let auth_labels = if language == super::UiLanguage::ZhCn { + ["自动", "密码", "密钥", "Agent", "交互式"] + } else { + ["Auto", "Password", "Key", "Agent", "Interactive"] + }; + for ((mode, rect), label) in auth.iter().zip(auth_labels) { self.renderer.draw_chrome_text( &size, rect.0 + (rect.2 - text_width(label)) * 0.5, rect.1 + (rect.3 - cell_h) / 2.0, - if editor.auth == auth_mode_for_label(label) { - skin.ink_strong - } else { - skin.ink_dim - }, + if editor.auth == *mode { skin.ink_strong } else { skin.ink_dim }, label, glyph_cache, ); @@ -445,6 +450,7 @@ impl Display { save_toggle, save_checkbox, save_label, + language, field_h, cell_h, cell_w, @@ -459,15 +465,17 @@ impl Display { destination.0, key_header_y, skin.ink_dim, - "私钥", + language.pick("私钥", "Private keys"), glyph_cache, ); self.renderer.draw_chrome_text( &size, - add_private_key.0 + (add_private_key.2 - text_width("+ 添加私钥")) * 0.5, + add_private_key.0 + + (add_private_key.2 - text_width(language.pick("+ 添加私钥", "+ Add key"))) + * 0.5, add_private_key.1 + (add_private_key.3 - cell_h) / 2.0, skin.ink, - "+ 添加私钥", + language.pick("+ 添加私钥", "+ Add key"), glyph_cache, ); if editor.private_keys.is_empty() { @@ -476,7 +484,10 @@ impl Display { destination.0, key_rows_y + s(7.0), skin.ink_faint, - "未指定;将使用 IdentityFile 和默认 id_* 私钥", + language.pick( + "未指定;将使用 IdentityFile 和默认 id_* 私钥", + "None specified; IdentityFile and default id_* keys will be used", + ), glyph_cache, ); } @@ -505,7 +516,10 @@ impl Display { destination.0, content_y, skin.ink_dim, - "仅使用 Windows OpenSSH Agent 与 Pageant,不回退密码。", + language.pick( + "仅使用 Windows OpenSSH Agent 与 Pageant,不回退密码。", + "Use Windows OpenSSH Agent and Pageant only; do not fall back to passwords.", + ), glyph_cache, ); } else if editor.auth == SshAuthMode::KeyboardInteractive { @@ -514,7 +528,10 @@ impl Display { destination.0, content_y, skin.ink_dim, - "仅响应服务器的 keyboard-interactive / MFA 提示。", + language.pick( + "仅响应服务器的 keyboard-interactive / MFA 提示。", + "Respond only to server keyboard-interactive / MFA prompts.", + ), glyph_cache, ); } @@ -547,16 +564,6 @@ impl Display { } } -fn auth_mode_for_label(label: &str) -> SshAuthMode { - match label { - "密码" => SshAuthMode::Password, - "密钥" => SshAuthMode::PublicKey, - "Agent" => SshAuthMode::Agent, - "交互式" => SshAuthMode::KeyboardInteractive, - _ => SshAuthMode::Auto, - } -} - fn input_quads( quads: &mut Vec, rect: Rect, @@ -662,6 +669,7 @@ fn draw_password_text( save_toggle: Rect, save_checkbox: Rect, save_label: &str, + language: super::UiLanguage, field_h: f32, cell_h: f32, cell_w: f32, @@ -675,11 +683,11 @@ fn draw_password_text( password.0, password.1 - cell_h - s(5.0), skin.ink_dim, - "密码", + language.pick("密码", "Password"), glyph_cache, ); let masked = if editor.password.is_empty() { - "连接时询问".to_owned() + language.pick("连接时询问", "Ask when connecting").to_owned() } else if editor.show_password { editor.password.clone() } else { diff --git a/nebula_app/src/event.rs b/nebula_app/src/event.rs index 597434f..a4f86e0 100644 --- a/nebula_app/src/event.rs +++ b/nebula_app/src/event.rs @@ -998,6 +998,8 @@ pub enum TabRequest { /// Open this file (markdown/text/json) in a read-only viewer tab — /// double-clicking a viewable file in the directory tree lands here. OpenDoc(std::path::PathBuf), + /// Open or focus the singleton Settings tab. It owns no PTY. + OpenSettings, Close, CloseIndex(usize), /// Duplicate a tab using the launch identity captured when it was opened. diff --git a/nebula_app/src/input/keyboard.rs b/nebula_app/src/input/keyboard.rs index c25725e..50b4a2f 100644 --- a/nebula_app/src/input/keyboard.rs +++ b/nebula_app/src/input/keyboard.rs @@ -722,7 +722,7 @@ impl> Processor { SplitDown => self .ctx .nebula_tab(TabRequest::SplitToggle(crate::display::SplitDirection::TopBottom)), - OpenSettings => self.ctx.display().toggle_settings(), + OpenSettings => self.ctx.nebula_tab(TabRequest::OpenSettings), OpenSettingsFile => self.ctx.display().open_user_config_file(), ToggleGhost => self.ctx.display().toggle_ghost(), CycleAccept => self.ctx.display().cycle_accept(), diff --git a/nebula_app/src/input/mod.rs b/nebula_app/src/input/mod.rs index b855eba..f461568 100644 --- a/nebula_app/src/input/mod.rs +++ b/nebula_app/src/input/mod.rs @@ -648,6 +648,7 @@ impl> Processor { let font_picker_open = self.ctx.display().nebula_font_picker_open; let font_picker_count = self.ctx.display().font_picker_count(); let hidden_host_count = self.ctx.display().hidden_ssh_host_count(); + let settings_area = self.ctx.display().terminal_card_rect(); // A native context menu is a pointer-modal overlay: while it is open, // underlying links, tabs and drawer rows must not react to hover. if self.ctx.display().context_menu_interactive() { @@ -673,6 +674,7 @@ impl> Processor { let settings_hover = crate::display::settings_hit( &window_size, scale, + settings_area, x as f32, y as f32, settings_open, @@ -698,13 +700,9 @@ impl> Processor { // pane's viewport — otherwise a band of the terminal area is mistaken // for chrome and swallows link hovers (no underline / no click). let resize_enabled = self.ctx.window().allows_drag_resize(); - if let Some(dir) = crate::display::resize_edge( - &window_size, - scale, - x as f32, - y as f32, - resize_enabled, - ) { + if let Some(dir) = + crate::display::resize_edge(&window_size, scale, x as f32, y as f32, resize_enabled) + { use winit::window::ResizeDirection::*; let icon = match dir { East | West => CursorIcon::EwResize, @@ -719,6 +717,7 @@ impl> Processor { crate::display::SettingsHit::Toggle | crate::display::SettingsHit::Nav(_) | crate::display::SettingsHit::Theme(_) + | crate::display::SettingsHit::Language(_) | crate::display::SettingsHit::SystemThemeToggle | crate::display::SettingsHit::GhostToggle | crate::display::SettingsHit::AcceptCycle @@ -743,7 +742,7 @@ impl> Processor { self.ctx.window().set_mouse_cursor(CursorIcon::Default); return; }, - crate::display::SettingsHit::Dismiss | crate::display::SettingsHit::None => {}, + crate::display::SettingsHit::None => {}, } if crate::display::in_chrome_bar(&window_size, scale, x as f32, y as f32) { let icon = match self.ctx.display().chrome_hit(x as f32, y as f32) { @@ -1543,9 +1542,7 @@ impl> Processor { let scale = self.ctx.window().scale_factor as f32; // Window border resize takes priority over the chrome controls. let resize_enabled = self.ctx.window().allows_drag_resize(); - if let Some(dir) = - crate::display::resize_edge(&size, scale, x, y, resize_enabled) - { + if let Some(dir) = crate::display::resize_edge(&size, scale, x, y, resize_enabled) { self.ctx.window().drag_resize(dir); return; } @@ -1574,9 +1571,11 @@ impl> Processor { let font_picker_open = self.ctx.display().nebula_font_picker_open; let font_picker_count = self.ctx.display().font_picker_count(); let hidden_host_count = self.ctx.display().hidden_ssh_host_count(); + let settings_area = self.ctx.display().terminal_card_rect(); let settings_hit = crate::display::settings_hit( &size, scale, + settings_area, x, y, settings_open, @@ -1614,8 +1613,7 @@ impl> Processor { } match settings_hit { crate::display::SettingsHit::Toggle => { - self.ctx.display().toggle_settings(); - self.ctx.mark_dirty(); + self.ctx.nebula_tab(crate::event::TabRequest::OpenSettings); return; }, crate::display::SettingsHit::Nav(section) => { @@ -1628,6 +1626,11 @@ impl> Processor { self.ctx.mark_dirty(); return; }, + crate::display::SettingsHit::Language(language) => { + self.ctx.display().set_ui_language(language); + self.ctx.mark_dirty(); + return; + }, crate::display::SettingsHit::GhostToggle => { self.ctx.display().toggle_ghost(); self.ctx.mark_dirty(); @@ -1716,11 +1719,6 @@ impl> Processor { return; }, crate::display::SettingsHit::Panel => return, - crate::display::SettingsHit::Dismiss => { - self.ctx.display().dismiss_settings(); - self.ctx.mark_dirty(); - return; - }, crate::display::SettingsHit::None => {}, } let chrome_hit = self.ctx.display().chrome_hit(x, y); @@ -2086,8 +2084,8 @@ impl> Processor { } pub fn mouse_wheel_input(&mut self, delta: MouseScrollDelta, phase: TouchPhase) { - // The settings modal captures the wheel: scroll its content, not the - // terminal hiding behind the veil. + // The Settings tab captures the wheel: scroll its page instead of the + // sink terminal used for special-tab input routing. if self.ctx.display().settings_open() { let px = match delta { MouseScrollDelta::LineDelta(_, lines) => { diff --git a/nebula_app/src/window_context.rs b/nebula_app/src/window_context.rs index 76b22a6..983d5fa 100644 --- a/nebula_app/src/window_context.rs +++ b/nebula_app/src/window_context.rs @@ -48,9 +48,9 @@ use crate::message_bar::MessageBuffer; use crate::scheduler::{Scheduler, TimerId, Topic}; use crate::{input, renderer, session}; +mod nebula_fetch_art; /// New-tab welcome page (Windows logo + fastfetch intro). Stateless helpers. mod welcome; -mod nebula_fetch_art; use welcome::nebula_fastfetch_intro_command_for; /// Split-pane behaviour (toggle/resize/drag/focus); `impl WindowContext`. @@ -134,6 +134,9 @@ struct TabEntry { /// Document viewer content (markdown/text/json). `Some` marks this as a /// doc tab: it has no pane, and the draw/input paths route to the viewer. doc: Option, + /// Settings is a singleton special tab. Like a document tab it owns no + /// PTY, while its controls and state remain owned by `Display`. + settings: bool, } #[derive(Clone)] @@ -143,6 +146,7 @@ enum TabLaunch { Shell { name: String, shell: tty::Shell }, Ssh(String), Document(std::path::PathBuf), + Settings, } fn select_initial_shell( @@ -439,6 +443,7 @@ impl WindowContext { .and_then(|tab| tab.color), launch: TabLaunch::Default, doc: None, + settings: false, }], 0, 1, @@ -682,7 +687,18 @@ impl WindowContext { self.open_doc_tab(path); false }, + TabRequest::OpenSettings => { + self.open_settings_tab(); + false + }, TabRequest::Close => { + if self + .tabs + .get(self.active_tab) + .is_some_and(|tab| tab.doc.is_some() || tab.settings) + { + return self.close_tab(self.active_tab); + } let id = self.focused_pane_id(); // A pending confirm for this pane means the user re-triggered // the close (or pressed Enter, which re-dispatches this @@ -785,7 +801,7 @@ impl WindowContext { false }, TabRequest::SplitIndex { index, direction } => { - if self.tabs.get(index).is_some_and(|tab| tab.doc.is_none()) { + if self.tabs.get(index).is_some_and(|tab| tab.doc.is_none() && !tab.settings) { self.select_tab(index); self.split_focused(direction); } @@ -884,15 +900,19 @@ impl WindowContext { // Insert right after the current tab (insert right next to the current tab) // rather than at the end of the bar. let at = (self.active_tab + 1).min(self.tabs.len()); - self.tabs.insert(at, TabEntry { - layout: Layout::Leaf(id), - active_pane: id, - has_bell: false, - custom_name: None, - custom_color: None, - launch: TabLaunch::Default, - doc: None, - }); + self.tabs.insert( + at, + TabEntry { + layout: Layout::Leaf(id), + active_pane: id, + has_bell: false, + custom_name: None, + custom_color: None, + launch: TabLaunch::Default, + doc: None, + settings: false, + }, + ); self.active_tab = at; self.resize_active_layout(); self.dirty = true; @@ -918,24 +938,24 @@ impl WindowContext { fn spawn_tab_profile_value(&mut self, profile: Profile) { // Profile cwd wins when it exists; else inherit the focused pane's. - let cwd = profile - .cwd - .as_ref() - .filter(|p| p.is_dir()) - .cloned() - .or_else(|| self.focused_cwd()); + let cwd = + profile.cwd.as_ref().filter(|p| p.is_dir()).cloned().or_else(|| self.focused_cwd()); let shell = profile.shell(); if let Some(id) = self.spawn_pane_detached_with(cwd, self.display.size_info, Some(shell)) { let at = (self.active_tab + 1).min(self.tabs.len()); - self.tabs.insert(at, TabEntry { - layout: Layout::Leaf(id), - active_pane: id, - has_bell: false, - custom_name: Some(profile.name.clone()), - custom_color: None, - launch: TabLaunch::Profile(profile), - doc: None, - }); + self.tabs.insert( + at, + TabEntry { + layout: Layout::Leaf(id), + active_pane: id, + has_bell: false, + custom_name: Some(profile.name.clone()), + custom_color: None, + launch: TabLaunch::Profile(profile), + doc: None, + settings: false, + }, + ); self.active_tab = at; self.resize_active_layout(); self.dirty = true; @@ -946,23 +966,25 @@ impl WindowContext { /// `spawn_tab_profile` but the spec is passed in rather than looked up in /// the config, and the cwd inherits the focused pane's. fn spawn_tab_shell(&mut self, name: String, shell: nebula_terminal::tty::Shell) { - if let Some(id) = - self.spawn_pane_detached_with( - self.focused_cwd(), - self.display.size_info, - Some(shell.clone()), - ) - { + if let Some(id) = self.spawn_pane_detached_with( + self.focused_cwd(), + self.display.size_info, + Some(shell.clone()), + ) { let at = (self.active_tab + 1).min(self.tabs.len()); - self.tabs.insert(at, TabEntry { - layout: Layout::Leaf(id), - active_pane: id, - has_bell: false, - custom_name: Some(name.clone()), - custom_color: None, - launch: TabLaunch::Shell { name, shell }, - doc: None, - }); + self.tabs.insert( + at, + TabEntry { + layout: Layout::Leaf(id), + active_pane: id, + has_bell: false, + custom_name: Some(name.clone()), + custom_color: None, + launch: TabLaunch::Shell { name, shell }, + doc: None, + settings: false, + }, + ); self.active_tab = at; self.resize_active_layout(); self.dirty = true; @@ -988,15 +1010,19 @@ impl WindowContext { self.next_pane_id += 1; self.panes.push(pane); let at = (self.active_tab + 1).min(self.tabs.len()); - self.tabs.insert(at, TabEntry { - layout: Layout::Leaf(pane_id), - active_pane: pane_id, - has_bell: false, - custom_name: Some(host.clone()), - custom_color: None, - launch: TabLaunch::Ssh(host), - doc: None, - }); + self.tabs.insert( + at, + TabEntry { + layout: Layout::Leaf(pane_id), + active_pane: pane_id, + has_bell: false, + custom_name: Some(host.clone()), + custom_color: None, + launch: TabLaunch::Ssh(host), + doc: None, + settings: false, + }, + ); self.active_tab = at; self.resize_active_layout(); self.dirty = true; @@ -1019,57 +1045,59 @@ impl WindowContext { #[cfg(not(windows))] { - let Ok(exe) = std::env::current_exe() else { - error!("Cannot locate nebula.exe for the SSH AskPass helper"); - return; - }; - let shell_id = self.display.nebula_shell_id.clone().unwrap_or_else(|| { - match self.display.nebula_shell { - crate::display::NebulaShell::PowerShell => "powershell".into(), - crate::display::NebulaShell::Bash => "bash".into(), - } - }); - let launch = match crate::ssh::build_pane_launch(&shell_id, &exe, &host) { - Ok(launch) => launch, - Err(err) => { - error!("Refusing unsafe SSH destination {host:?}: {err}"); + let Ok(exe) = std::env::current_exe() else { + error!("Cannot locate nebula.exe for the SSH AskPass helper"); return; - }, - }; - let default_shell = Self::default_shell_override(); - if let Some(id) = self.spawn_pane_detached_with( - self.focused_cwd(), - self.display.size_info, - default_shell, - ) { - let at = (self.active_tab + 1).min(self.tabs.len()); - self.tabs.insert(at, TabEntry { - layout: Layout::Leaf(id), - active_pane: id, - has_bell: false, - custom_name: Some(host.clone()), - custom_color: None, - launch: TabLaunch::Ssh(host), - doc: None, + }; + let shell_id = self.display.nebula_shell_id.clone().unwrap_or_else(|| { + match self.display.nebula_shell { + crate::display::NebulaShell::PowerShell => "powershell".into(), + crate::display::NebulaShell::Bash => "bash".into(), + } }); - self.active_tab = at; - self.resize_active_layout(); - self.dirty = true; - if let Some(pane) = self.panes.iter().find(|pane| pane.id == id) { - pane.notifier.notify(launch.command); + let launch = match crate::ssh::build_pane_launch(&shell_id, &exe, &host) { + Ok(launch) => launch, + Err(err) => { + error!("Refusing unsafe SSH destination {host:?}: {err}"); + return; + }, + }; + let default_shell = Self::default_shell_override(); + if let Some(id) = self.spawn_pane_detached_with( + self.focused_cwd(), + self.display.size_info, + default_shell, + ) { + let at = (self.active_tab + 1).min(self.tabs.len()); + self.tabs.insert( + at, + TabEntry { + layout: Layout::Leaf(id), + active_pane: id, + has_bell: false, + custom_name: Some(host.clone()), + custom_color: None, + launch: TabLaunch::Ssh(host), + doc: None, + settings: false, + }, + ); + self.active_tab = at; + self.resize_active_layout(); + self.dirty = true; + if let Some(pane) = self.panes.iter().find(|pane| pane.id == id) { + pane.notifier.notify(launch.command); + } } } - } } /// Open `path` in a read-only document viewer tab. A tab already viewing /// this file is re-focused (and re-read, so the view is fresh) instead of /// duplicated — double-click twice shouldn't litter the bar. fn open_doc_tab(&mut self, path: std::path::PathBuf) { - if let Some(index) = self - .tabs - .iter() - .position(|tab| tab.doc.as_ref().is_some_and(|doc| doc.path == path)) + if let Some(index) = + self.tabs.iter().position(|tab| tab.doc.as_ref().is_some_and(|doc| doc.path == path)) { if let Some(doc) = self.tabs[index].doc.as_mut() { doc.reload(); @@ -1084,16 +1112,51 @@ impl WindowContext { // as the file tree's markdown icon, so tab and tree read as one system. let label = format!("\u{eb1d} {}", doc.title); let at = (self.active_tab + 1).min(self.tabs.len()); - self.tabs.insert(at, TabEntry { - layout: Layout::Leaf(DOC_PANE_ID), - active_pane: DOC_PANE_ID, - has_bell: false, - custom_name: Some(label), - custom_color: None, - launch: TabLaunch::Document(doc.path.clone()), - doc: Some(doc), - }); + self.tabs.insert( + at, + TabEntry { + layout: Layout::Leaf(DOC_PANE_ID), + active_pane: DOC_PANE_ID, + has_bell: false, + custom_name: Some(label), + custom_color: None, + launch: TabLaunch::Document(doc.path.clone()), + doc: Some(doc), + settings: false, + }, + ); self.active_tab = at; + self.display.set_settings_tab_active(false); + self.dirty = true; + } + + /// Open Settings as a real singleton tab. Re-focusing the existing tab + /// avoids duplicate preference surfaces and never starts a shell process. + fn open_settings_tab(&mut self) { + if let Some(index) = self.tabs.iter().position(|tab| tab.settings) { + self.select_tab(index); + self.display.set_settings_tab_active(true); + self.dirty = true; + return; + } + + let at = (self.active_tab + 1).min(self.tabs.len()); + self.tabs.insert( + at, + TabEntry { + layout: Layout::Leaf(DOC_PANE_ID), + active_pane: DOC_PANE_ID, + has_bell: false, + custom_name: Some("\u{eb51} 设置".to_owned()), + custom_color: None, + launch: TabLaunch::Settings, + doc: None, + settings: true, + }, + ); + self.active_tab = at; + self.display.set_settings_tab_active(true); + self.sync_chrome_tabs(); self.dirty = true; } @@ -1118,18 +1181,23 @@ impl WindowContext { let doc = crate::display::markdown_view::DocView::open(path.clone()); let label = format!("\u{eb1d} {}", doc.title); let at = (self.active_tab + 1).min(self.tabs.len()); - self.tabs.insert(at, TabEntry { - layout: Layout::Leaf(DOC_PANE_ID), - active_pane: DOC_PANE_ID, - has_bell: false, - custom_name: Some(label), - custom_color: None, - launch: TabLaunch::Document(path), - doc: Some(doc), - }); + self.tabs.insert( + at, + TabEntry { + layout: Layout::Leaf(DOC_PANE_ID), + active_pane: DOC_PANE_ID, + has_bell: false, + custom_name: Some(label), + custom_color: None, + launch: TabLaunch::Document(path), + doc: Some(doc), + settings: false, + }, + ); self.active_tab = at; self.dirty = true; }, + TabLaunch::Settings => self.open_settings_tab(), } if self.tabs.len() > before { @@ -1158,6 +1226,7 @@ impl WindowContext { custom_color: tab.color, launch: TabLaunch::Default, doc: None, + settings: false, }); self.run_fastfetch_intro(id); } @@ -1229,9 +1298,16 @@ impl WindowContext { /// Current tab list + per-tab cwd as a persistable session. fn session_snapshot(&self) -> session::Session { - let tabs = self + let active_tab = self .tabs .iter() + .take(self.active_tab) + .filter(|tab| tab.doc.is_none() && !tab.settings) + .count(); + let tabs: Vec<_> = self + .tabs + .iter() + .filter(|tab| tab.doc.is_none() && !tab.settings) .map(|t| session::TabSession { cwd: self .pane(t.active_pane) @@ -1241,7 +1317,7 @@ impl WindowContext { color: t.custom_color, }) .collect(); - session::Session::new(self.active_tab, tabs) + session::Session::new(active_tab.min(tabs.len().saturating_sub(1)), tabs) } /// 1 Hz autosave (piggybacks on the chrome clock tick): persist the session @@ -1280,7 +1356,14 @@ impl WindowContext { fn dock_tab_into_active(&mut self, source: usize, nav: crate::display::SplitNav) { use crate::display::{SplitDirection, SplitNav}; - if source >= self.tabs.len() || source == self.active_tab || self.tabs.len() < 2 { + if source >= self.tabs.len() + || source == self.active_tab + || self.tabs.len() < 2 + || self.tabs[source].doc.is_some() + || self.tabs[source].settings + || self.tabs[self.active_tab].doc.is_some() + || self.tabs[self.active_tab].settings + { return; } @@ -1298,11 +1381,8 @@ impl WindowContext { SplitNav::Up => (SplitDirection::TopBottom, true), SplitNav::Down => (SplitDirection::TopBottom, false), }; - let (first, second) = if src_first { - (src_entry.layout, old) - } else { - (old, src_entry.layout) - }; + let (first, second) = + if src_first { (src_entry.layout, old) } else { (old, src_entry.layout) }; entry.layout = Layout::Split { direction, ratio: 0.5, @@ -1371,8 +1451,14 @@ impl WindowContext { if cwd.is_some() { pty_config.working_directory = cwd; } - match Self::create_pane(&size_info, window_id, &self.config, pty_config, &self.proxy, pane_id) - { + match Self::create_pane( + &size_info, + window_id, + &self.config, + pty_config, + &self.proxy, + pane_id, + ) { Ok(pane) => { self.panes.push(pane); Some(pane_id) @@ -1438,9 +1524,8 @@ impl WindowContext { // `terminal.is_focused` starts true and may never see a focus // event, which would double-ring against the per-pane path. if self.display.window.has_focus() { - let program = self - .pane(pane_id) - .and_then(|p| p.nebula_state.running_program.clone()); + let program = + self.pane(pane_id).and_then(|p| p.nebula_state.running_program.clone()); crate::notify::deliver( &self.display.window, &crate::notify::Notification::Bell { program }, @@ -1479,8 +1564,7 @@ impl WindowContext { // spinner a start mark so the turn still animates. state.command_started.get_or_insert_with(std::time::Instant::now); }, - crate::ai_hook::AiHookKind::TurnDone - | crate::ai_hook::AiHookKind::NeedsAttention => { + crate::ai_hook::AiHookKind::TurnDone | crate::ai_hook::AiHookKind::NeedsAttention => { { let state = &mut self.panes[idx].nebula_state; state.awaiting_input = true; @@ -1556,6 +1640,7 @@ impl WindowContext { } self.active_tab = index; self.tabs[index].has_bell = false; + self.display.set_settings_tab_active(self.tabs[index].settings); self.zoom = None; self.resize_active_layout(); self.dirty = true; @@ -1615,6 +1700,9 @@ impl WindowContext { } else if self.active_tab >= self.tabs.len() { self.active_tab = self.tabs.len() - 1; } + self.display.set_settings_tab_active( + self.tabs.get(self.active_tab).is_some_and(|tab| tab.settings), + ); self.resize_active_layout(); self.dirty = true; false @@ -1645,10 +1733,8 @@ impl WindowContext { self.display.font_size = self.config.font.size().scale(scale_factor); } - let font = self - .display - .effective_font(&self.config.font) - .with_size(self.display.font_size); + let font = + self.display.effective_font(&self.config.font).with_size(self.display.font_size); self.display.pending_update.set_font(font); } @@ -1796,6 +1882,14 @@ impl WindowContext { let divider_rects = self.layout_geometry(true).1; let focused = self.focused_pane_id(); + // Settings is rendered inside the normal tab content card; it is not + // a modal and therefore keeps the tab/sidebar chrome fully usable. + if self.tabs.get(self.active_tab).is_some_and(|tab| tab.settings) { + self.display.begin_pane_frame(&self.config); + self.display.draw_settings_frame(scheduler); + return; + } + // Document-viewer tab: no pane, no grid. Draw the doc into the tab's // content rect; `present_frame` inside lays the normal chrome on top. if let Some(doc) = self.tabs.get_mut(self.active_tab).and_then(|tab| tab.doc.as_mut()) { @@ -1893,6 +1987,9 @@ impl WindowContext { } fn sync_chrome_tabs(&mut self) { + self.display.set_settings_tab_active( + self.tabs.get(self.active_tab).is_some_and(|tab| tab.settings), + ); // The visible tab's activity is seen by definition — consume its // flag before it can render (dots are for background tabs only). if let Some(id) = self.tabs.get(self.active_tab).map(|t| t.active_pane) { @@ -1906,11 +2003,14 @@ impl WindowContext { let mut dots = Vec::with_capacity(self.tabs.len()); let mut running = Vec::with_capacity(self.tabs.len()); let mut logos = Vec::with_capacity(self.tabs.len()); + let ui_language = self.display.ui_language(); for tab in &self.tabs { let pane = self.pane(tab.active_pane); let state = pane.map(|p| &p.nebula_state); // Use custom name if set, otherwise derive from cwd/title - let mut label = if let Some(custom) = &tab.custom_name { + let mut label = if tab.settings { + format!("\u{eb51} {}", ui_language.pick("设置", "Settings")) + } else if let Some(custom) = &tab.custom_name { custom.clone() } else { pane.map(Self::chrome_tab_label).unwrap_or_default() @@ -1934,14 +2034,15 @@ impl WindowContext { // for input" (claude between turns). The ring collapsing into a // dot IS the "turn finished, your move" signal — also on the // visible tab, where a merely-paused ring still read as busy. - dots.push(tab.has_bell || state.is_some_and(|s| { - s.finished_unseen || (s.command_started.is_some() && s.awaiting_input) - })); + dots.push( + tab.has_bell + || state.is_some_and(|s| { + s.finished_unseen || (s.command_started.is_some() && s.awaiting_input) + }), + ); // Spinner only while the command actually works; once it rang BEL // and waits for input the dot above takes over. - running.push( - state.is_some_and(|s| s.command_started.is_some() && !s.awaiting_input), - ); + running.push(state.is_some_and(|s| s.command_started.is_some() && !s.awaiting_input)); } let active = self.active_tab.min(labels.len().saturating_sub(1)); // displayed == storage index always holds now, so the bar is reorderable. @@ -1969,9 +2070,7 @@ impl WindowContext { std::env::current_dir() .ok() - .and_then(|path| { - path.file_name().map(|n| n.to_string_lossy().into_owned()) - }) + .and_then(|path| path.file_name().map(|n| n.to_string_lossy().into_owned())) .filter(|name| !name.trim().is_empty()) .unwrap_or_else(|| ".".to_owned()) } @@ -2051,7 +2150,8 @@ impl WindowContext { // didn't move this batch (then it is already the current position). let latest_pos = self.event_queue.iter().rev().find_map(|e| match e { WinitEvent::WindowEvent { - event: WindowEvent::CursorMoved { position, .. }, .. + event: WindowEvent::CursorMoved { position, .. }, + .. } => Some((position.x as f32, position.y as f32)), _ => None, }); @@ -2097,10 +2197,11 @@ impl WindowContext { // A doc tab has no pane: its events run against `doc_pane` below so // chrome interaction (tab switching, closing, the sidebar) keeps // working; anything typed lands in the sink notifier. - let doc_tab = self.tabs.get(self.active_tab).is_some_and(|tab| tab.doc.is_some()); + let special_tab = + self.tabs.get(self.active_tab).is_some_and(|tab| tab.doc.is_some() || tab.settings); let focused = match self.pane_index(focused_id) { Some(index) => Some(index), - None if doc_tab => None, + None if special_tab => None, None => return, }; @@ -2114,8 +2215,8 @@ impl WindowContext { }; self.display.nebula_pane_view = pane_view; - let old_is_searching = focused - .is_some_and(|index| self.panes[index].search_state.history_index.is_some()); + let old_is_searching = + focused.is_some_and(|index| self.panes[index].search_state.history_index.is_some()); let target_of = |event: &WinitEvent| match event { WinitEvent::UserEvent(event) => event.terminal_tab_id().unwrap_or(focused_id), @@ -2128,7 +2229,7 @@ impl WindowContext { let target_id = target_of(&event); let (pane, doc) = match self.pane_index(target_id) { Some(pane_idx) => (&mut self.panes[pane_idx], None), - None if target_id == DOC_PANE_ID && doc_tab => ( + None if target_id == DOC_PANE_ID && special_tab => ( &mut self.doc_pane, self.tabs.get_mut(self.active_tab).and_then(|tab| tab.doc.as_mut()), ), @@ -2214,8 +2315,7 @@ impl WindowContext { .last_pty_resize .is_some_and(|t| now.duration_since(t) < Duration::from_millis(300)); if dragging { - let timer = - TimerId::new(Topic::NebulaResizeSettle, self.display.window.id()); + let timer = TimerId::new(Topic::NebulaResizeSettle, self.display.window.id()); scheduler.unschedule(timer); let event = Event::new(EventType::NebulaResizeSettled, self.display.window.id()); @@ -2374,11 +2474,8 @@ mod startup_shell_tests { #[test] fn startup_shell_uses_user_default_instead_of_the_base_pty_shell() { - let selected = select_initial_shell( - Some(shell("powershell.exe")), - Some(shell("pwsh.exe")), - None, - ); + let selected = + select_initial_shell(Some(shell("powershell.exe")), Some(shell("pwsh.exe")), None); assert_eq!(selected, Some(shell("pwsh.exe"))); }