feat: add settings tab and bilingual UI

This commit is contained in:
kuddev committed 2026-07-16 12:04:04 +08:00
1 parent 992cc4f375
commit 928057ce37
10 files changed
+846 -410

No files matched your search

+25 -21
View File
@@ -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);
+59 -18
View File
@@ -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;
}
+84
View File
@@ -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<Self> {
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);
}
}
+69 -30
View File
@@ -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<WinitTheme>,
pub nebula_settings_open: bool,
nebula_language_preference: LanguagePreference,
nebula_language: UiLanguage,
/// Paths from the last successful app configuration generation.
nebula_config_paths: Vec<PathBuf>,
/// 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());
}
+298 -135
View File
@@ -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<UiQuad>,
@@ -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,
);
+41 -33
View File
@@ -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<UiQuad>,
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 {
+2
View File
@@ -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.
+1 -1
View File
@@ -722,7 +722,7 @@ impl<T: EventListener, A: ActionContext<T>> Processor<T, A> {
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(),
+18 -20
View File
@@ -648,6 +648,7 @@ impl<T: EventListener, A: ActionContext<T>> Processor<T, A> {
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<T: EventListener, A: ActionContext<T>> Processor<T, A> {
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<T: EventListener, A: ActionContext<T>> Processor<T, A> {
// 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<T: EventListener, A: ActionContext<T>> Processor<T, A> {
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<T: EventListener, A: ActionContext<T>> Processor<T, A> {
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<T: EventListener, A: ActionContext<T>> Processor<T, A> {
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<T: EventListener, A: ActionContext<T>> Processor<T, A> {
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<T: EventListener, A: ActionContext<T>> Processor<T, A> {
}
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<T: EventListener, A: ActionContext<T>> Processor<T, A> {
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<T: EventListener, A: ActionContext<T>> Processor<T, A> {
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<T: EventListener, A: ActionContext<T>> Processor<T, A> {
}
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) => {
+249 -152
View File
@@ -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<crate::display::markdown_view::DocView>,
/// 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<Event>| 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")));
}