From af41bae643bf10b4f515528f604a300316633705 Mon Sep 17 00:00:00 2001 From: kuddev Date: Thu, 13 Aug 2026 14:29:48 +0800 Subject: [PATCH] feat(gpui): full settings page - legacy section nav, every key-value setting migrated Settings page now mirrors the legacy shell's layout: left section nav (Appearance / Profiles / Providers / SSH / Network / Interaction / Keymap / Advanced / Backup, same names and order as NebulaSettingsSection) with single-section content on the right. nebula-settings grows the full key-value surface ported verbatim from nebula_settings_load - language, shell, startup_directory, ghost/accept/completion_style, cjk_bold_regular, tab_reveal, density, new_tab_position, cell_width_mode, fetch, keep_session, restore_session, resume_ai, tray, blur, opacity, background (hex), panel_resize, ssh proxy trio, quick_terminal_hotkey - with factory defaults asserted item-by-item in tests (9 passing). Fixes a real unit bug: the font_size key stores LOGICAL PIXELS (legacy persists size/scale), not points - the GPUI shell was multiplying by 4/3 and rendering every terminal a third too large. Editors that depend on unmigrated subsystems (keymap table, SSH hosts, AI providers, backup) show explicit placeholders pointing at the legacy page; both shells keep reading and writing the same nebula_settings.txt. --- nebula_app/src/gpui_shell/config.rs | 17 +- nebula_app/src/gpui_shell/settings_pane.rs | 749 +++++++++++++++------ nebula_settings/src/lib.rs | 423 +++++++++++- 3 files changed, 975 insertions(+), 214 deletions(-) diff --git a/nebula_app/src/gpui_shell/config.rs b/nebula_app/src/gpui_shell/config.rs index 365b3ca..50c39ee 100644 --- a/nebula_app/src/gpui_shell/config.rs +++ b/nebula_app/src/gpui_shell/config.rs @@ -63,23 +63,32 @@ impl Settings { let secondary = |desc: &RawFontDesc| -> String { desc.family.clone().unwrap_or_else(|| normal_family.clone()) }; - let font_size_pt = - runtime.font_size_pt.or(raw.font.size).unwrap_or(11.25).clamp(4.0, 96.0); + // 字号语义(对齐旧壳写盘):settings.txt 的 font_size 是**逻辑像素** + // (设置 spinner/Ctrl+滚轮持久化时已除 scale);toml 的 font.size + // 才是 pt(1pt = 4/3 px @96dpi)。 + let font_size_px = runtime + .font_size_px + .unwrap_or_else(|| raw.font.size.unwrap_or(11.25).clamp(4.0, 96.0) * 4.0 / 3.0); - // 配色:toml 覆盖内置默认,主题最后裁定(与旧壳同序)。 + // 配色:toml 覆盖内置默认,主题裁定背景/浅色替换/Powerline 槽位, + // 用户的背景覆盖色(设置页取色器)最后压轴——与旧壳同序。 let mut palette = build_palette(&raw.colors); apply_theme(&mut palette, runtime.theme); + if let Some(background) = runtime.background { + palette.background = rgba8(background); + } Settings { font_bold_family: secondary(&raw.font.bold), font_italic_family: secondary(&raw.font.italic), font_bold_italic_family: secondary(&raw.font.bold_italic), - font_size_px: font_size_pt * 4.0 / 3.0, + font_size_px, palette, cursor_shape: runtime.cursor_shape.map(|shape| match shape { CursorShapeName::Block => CursorShape::Block, CursorShapeName::Beam => CursorShape::Beam, CursorShapeName::Underline => CursorShape::Underline, + CursorShapeName::Hollow => CursorShape::HollowBlock, }), cursor_blink: runtime.cursor_blink, copy_on_select: runtime.copy_on_select, diff --git a/nebula_app/src/gpui_shell/settings_pane.rs b/nebula_app/src/gpui_shell/settings_pane.rs index 006c7d4..a2f24e2 100644 --- a/nebula_app/src/gpui_shell/settings_pane.rs +++ b/nebula_app/src/gpui_shell/settings_pane.rs @@ -1,150 +1,265 @@ -//! 设置页(GPUI 组件版)。 +//! 设置页(GPUI 组件版,覆盖旧壳设置的全部纯键值项)。 //! -//! 薄壳纪律:本文件不定义任何设置语义——键名、值域、主题色表、持久化 -//! 格式全部在共享 crate `nebula-settings`(从旧壳逐字迁移而来)。这里只 -//! 负责用组件库把字段摆出来;改动经 `persist_keys` 原地写回 -//! `nebula_settings.txt`,与旧壳读写同一份文件、同一套语义。 +//! 版面对齐旧壳:**左侧分区导航 + 右侧单分区内容**(分区名与顺序对照 +//! `NebulaSettingsSection`:外观/配置文件/供应商/SSH/网络/交互/按键映射/ +//! 高级/备份),默认落在外观。 //! -//! 生效时机(对齐旧壳):主题/配色、copy_on_select、字号即时生效(宿主 -//! 收 `Changed` 事件后热应用);字体族与默认光标形状对新标签页生效。 +//! 薄壳纪律:本文件不定义任何设置语义——键名、值域、出厂默认、主题色表、 +//! 持久化格式全部在共享 crate `nebula-settings`(从旧壳逐字迁移并有单测 +//! 锁定)。这里只负责用组件库把字段摆出来;改动经 `persist_keys` 原地 +//! 写回 `nebula_settings.txt`,与旧壳读写同一份文件、同一套语义。 +//! +//! 生效时机(对齐旧壳):主题/配色/背景色/字号/copy_on_select 即时热应用; +//! 字体族与默认光标形状对新标签页生效;窗口透明/模糊/托盘/快速终端热键 +//! 等依赖尚未迁移的窗口子系统,写盘后由旧壳消费(本页如实标注)。 +//! +//! 未迁移的编辑器(依赖各自功能子系统):按键映射、SSH 主机管理、 +//! AI Provider、备份/同步。对应分区放明确的占位说明。 use gpui::{ App, AppContext as _, Context, Entity, EventEmitter, FocusHandle, Focusable, - InteractiveElement as _, IntoElement, ParentElement as _, Render, SharedString, Styled as _, - Subscription, Window, div, px, + InteractiveElement as _, IntoElement, ParentElement as _, Render, SharedString, + StatefulInteractiveElement as _, Styled as _, Subscription, Window, div, px, }; +use gpui::prelude::FluentBuilder as _; use gpui_component::select::SelectEvent; -use nebula_settings::{CursorShapeName, RuntimeSettings, ThemeName, persist_keys}; +use nebula_settings::{RuntimeSettings, format_hex_rgb, persist_keys}; use crate::gpui_shell::prelude::*; -/// 主题下拉的选项顺序(展示名 = 持久化名,与旧壳一致)。 -const THEMES: [ThemeName; 7] = [ - ThemeName::Nebula, - ThemeName::SilverLight, - ThemeName::SteelDark, - ThemeName::LimestoneLight, - ThemeName::CoalDark, - ThemeName::LinenLight, - ThemeName::MossDark, +/// 主题下拉(展示名 = 持久化名,与旧壳一致)。 +const THEME_VALUES: [&str; 7] = [ + "Nebula", + "SilverLight", + "SteelDark", + "LimestoneLight", + "CoalDark", + "LinenLight", + "MossDark", ]; -const CURSOR_SHAPES: [(&str, &str); 3] = - [("block", "块状"), ("beam", "竖线"), ("underline", "下划线")]; +/// 左侧分区导航(名称与顺序对照旧壳 `NebulaSettingsSection`)。 +const SECTIONS: [&str; 9] = + ["外观", "配置文件", "供应商", "SSH", "网络", "交互", "按键映射", "高级", "备份"]; /// 宿主(workspace)监听:设置已写盘,全局 `Settings` 已重载。 pub enum SettingsPaneEvent { Changed, } +type SharedSelect = Entity>>; + pub struct SettingsPane { focus_handle: FocusHandle, - theme_select: Entity>>, - cursor_select: Entity>>, - cursor_blink: bool, - copy_on_select: bool, - powerline: bool, - font_family: SharedString, - font_size_pt: f32, + /// 渲染与写盘的单一事实源;每次 persist 后整体重载。 + runtime: RuntimeSettings, + /// 当前分区(`SECTIONS` 下标);旧壳默认落在外观。 + active_section: usize, + selects: Vec<(&'static str, SharedSelect)>, + dir_input: Entity, + bg_input: Entity, + proxy_url_input: Entity, + proxy_bypass_input: Entity, _subscriptions: Vec, } impl SettingsPane { pub fn new(window: &mut Window, cx: &mut Context) -> Self { let runtime = RuntimeSettings::load(); + let mut selects: Vec<(&'static str, SharedSelect)> = Vec::new(); + let mut subscriptions = Vec::new(); - let theme_ix = THEMES.iter().position(|t| *t == runtime.theme).unwrap_or(0); - let theme_select = cx.new(|cx| { - SelectState::new( - THEMES.iter().map(|t| SharedString::from(t.prompt_name())).collect::>(), - Some(IndexPath::default().row(theme_ix)), + let mut add_select = |key: &'static str, + labels: &'static [&'static str], + values: &'static [&'static str], + current: &str, + window: &mut Window, + cx: &mut Context| { + let ix = values.iter().position(|v| *v == current).unwrap_or(0); + let select = cx.new(|cx| { + SelectState::new( + labels.iter().map(|l| SharedString::from(*l)).collect::>(), + Some(IndexPath::default().row(ix)), + window, + cx, + ) + }); + subscriptions.push(cx.subscribe_in( + &select, window, - cx, - ) - }); - - let cursor_ix = match runtime.cursor_shape { - Some(CursorShapeName::Block) => 0, - // 旧壳出厂默认 beam;未设置时展示 beam。 - Some(CursorShapeName::Beam) | None => 1, - Some(CursorShapeName::Underline) => 2, + move |this: &mut Self, + entity: &SharedSelect, + event: &SelectEvent>, + _window: &mut Window, + cx: &mut Context| { + if let SelectEvent::Confirm(Some(_)) = event { + let row = entity.read(cx).selected_index(cx).map(|path| path.row); + if let Some(value) = row.and_then(|row| values.get(row)) { + this.persist(&[(key, (*value).to_string())], cx); + } + } + }, + )); + selects.push((key, select)); }; - let cursor_select = cx.new(|cx| { - SelectState::new( - CURSOR_SHAPES - .iter() - .map(|(_, label)| SharedString::from(*label)) - .collect::>(), - Some(IndexPath::default().row(cursor_ix)), - window, - cx, - ) - }); - let subscriptions = vec![ - cx.subscribe_in(&theme_select, window, Self::on_theme_confirm), - cx.subscribe_in(&cursor_select, window, Self::on_cursor_confirm), - ]; + let cursor_current = + runtime.cursor_shape.map(|shape| shape.settings_value()).unwrap_or("beam"); + let shell_current = runtime.shell.clone().unwrap_or_else(|| "powershell".into()); + + add_select( + "language", + &["跟随系统", "简体中文", "English"], + &["system", "zh-CN", "en-US"], + runtime.language.settings_value(), + window, + cx, + ); + add_select( + "shell", + &["PowerShell", "Bash (Git Bash)"], + &["powershell", "bash"], + &shell_current, + window, + cx, + ); + add_select("theme", &THEME_VALUES, &THEME_VALUES, runtime.theme.prompt_name(), window, cx); + add_select( + "cursor_shape", + &["块状", "竖线", "下划线", "空心块"], + &["block", "beam", "underline", "hollow"], + cursor_current, + window, + cx, + ); + add_select( + "tab_reveal", + &["滑动", "立即"], + &["slide", "instant"], + runtime.tab_reveal.settings_value(), + window, + cx, + ); + add_select( + "density", + &["标准", "紧凑"], + &["standard", "compact"], + runtime.density.settings_value(), + window, + cx, + ); + add_select( + "new_tab_position", + &["当前标签之后", "列表末尾"], + &["after_current", "end"], + runtime.new_tab_position.settings_value(), + window, + cx, + ); + add_select( + "cell_width_mode", + &["紧凑", "宽松"], + &["compact", "relaxed"], + runtime.cell_width_mode.settings_value(), + window, + cx, + ); + add_select( + "accept", + &["→ 方向键", "Tab", "两者皆可"], + &["right", "tab", "both"], + runtime.accept.settings_value(), + window, + cx, + ); + add_select( + "completion_style", + &["内联 ghost", "弹出列表"], + &["inline", "popup"], + runtime.completion_style.settings_value(), + window, + cx, + ); + add_select( + "ssh_proxy_mode", + &["关闭", "系统代理", "自定义"], + &["off", "system", "custom"], + runtime.ssh_proxy_mode.settings_value(), + window, + cx, + ); + + let dir_input = cx.new(|cx| { + InputState::new(window, cx) + .placeholder("留空 = 继承当前窗格目录") + .default_value(runtime.startup_directory.clone().unwrap_or_default()) + }); + let bg_input = cx.new(|cx| { + InputState::new(window, cx) + .placeholder("#rrggbb,留空跟随主题") + .default_value(runtime.background.map(format_hex_rgb).unwrap_or_default()) + }); + let proxy_url_input = cx.new(|cx| { + InputState::new(window, cx) + .placeholder("socks5://127.0.0.1:7890") + .default_value(runtime.ssh_proxy_url.clone()) + }); + let proxy_bypass_input = cx.new(|cx| { + InputState::new(window, cx) + .placeholder("逗号分隔,如 10.0.0.0/8,*.internal") + .default_value(runtime.ssh_proxy_no_proxy.clone()) + }); Self { focus_handle: cx.focus_handle(), - theme_select, - cursor_select, - cursor_blink: runtime.cursor_blink.unwrap_or(true), - copy_on_select: runtime.copy_on_select, - powerline: runtime.powerline, - font_family: runtime.font_family.unwrap_or_default().into(), - font_size_pt: runtime.font_size_pt.unwrap_or(11.25), + runtime, + active_section: 0, + selects, + dir_input, + bg_input, + proxy_url_input, + proxy_bypass_input, _subscriptions: subscriptions, } } - fn on_theme_confirm( - &mut self, - _: &Entity>>, - event: &SelectEvent>, - _window: &mut Window, - cx: &mut Context, - ) { - if let SelectEvent::Confirm(Some(value)) = event { - if let Some(theme) = ThemeName::from_prompt_name(value.as_ref()) { - self.persist(&[("theme", theme.prompt_name().to_owned())], cx); - } - } - } - - fn on_cursor_confirm( - &mut self, - _: &Entity>>, - event: &SelectEvent>, - _window: &mut Window, - cx: &mut Context, - ) { - if let SelectEvent::Confirm(Some(value)) = event { - if let Some((key, _)) = CURSOR_SHAPES.iter().find(|(_, label)| *label == value.as_ref()) - { - self.persist(&[("cursor_shape", (*key).to_owned())], cx); - } - } - } - - /// 写盘 → 重载全局 `Settings` → 通知宿主热应用。 + /// 写盘 → 重载单一事实源与全局 `Settings` → 通知宿主热应用。 fn persist(&mut self, updates: &[(&str, String)], cx: &mut Context) { if let Err(err) = persist_keys(updates) { eprintln!("[nebula:gpui] failed to persist settings: {err}"); } + self.runtime = RuntimeSettings::load(); let settings = crate::gpui_shell::config::Settings::load(); cx.set_global(settings); cx.emit(SettingsPaneEvent::Changed); cx.notify(); } - fn set_font_size(&mut self, size: f32, cx: &mut Context) { - self.font_size_pt = size.clamp(4.0, 96.0); - self.persist(&[("font_size", format!("{:.2}", self.font_size_pt))], cx); + fn toggle(&mut self, key: &'static str, value: bool, cx: &mut Context) { + self.persist(&[(key, (value as u8).to_string())], cx); } - /// 分组卡片(对齐设置页的分组式版面)。 + fn font_size_px(&self, cx: &App) -> f32 { + self.runtime + .font_size_px + .unwrap_or_else(|| cx.global::().font_size_px) + } + + fn set_font_size(&mut self, size: f32, cx: &mut Context) { + let size = size.clamp(4.0, 96.0); + self.persist(&[("font_size", format!("{size:.2}"))], cx); + } + + fn set_opacity(&mut self, opacity: f32, cx: &mut Context) { + let opacity = opacity.clamp(0.2, 1.0); + self.persist(&[("opacity", format!("{opacity:.2}"))], cx); + } + + fn select_of(&self, key: &str) -> Option { + self.selects.iter().find(|(k, _)| *k == key).map(|(_, entity)| entity.clone()) + } + + /// 分组卡片(右侧内容区的分组式版面,对齐旧壳)。 fn group(&self, title: &'static str, cx: &Context) -> gpui::Div { v_flex() .w_full() @@ -165,6 +280,327 @@ impl SettingsPane { .child(div().flex_1().text_sm().child(label)) .child(control) } + + fn select_row(&self, key: &'static str, label: &'static str) -> impl IntoElement { + let select = self.select_of(key); + Self::row(label, div().w(px(220.0)).children(select.map(|state| Select::new(&state)))) + } + + fn checkbox_row( + &self, + key: &'static str, + label: &'static str, + checked: bool, + cx: &mut Context, + ) -> impl IntoElement { + Self::row( + label, + Checkbox::new(key).checked(checked).on_click(cx.listener( + move |this, checked: &bool, _, cx| { + this.toggle(key, *checked, cx); + }, + )), + ) + } + + /// 文本输入 + 保存按钮(保存时读值写盘)。 + fn input_row( + &self, + label: &'static str, + key: &'static str, + input: &Entity, + cx: &mut Context, + ) -> impl IntoElement { + let state = input.clone(); + Self::row( + label, + h_flex() + .gap_2() + .items_center() + .child(div().w(px(280.0)).child(Input::new(input))) + .child( + Button::new(SharedString::from(format!("save-{key}"))) + .label("保存") + .small() + .on_click(cx.listener(move |this, _, _, cx| { + let value = state.read(cx).value().to_string(); + this.persist(&[(key, value)], cx); + })), + ), + ) + } + + fn stepper_row( + &self, + label: &'static str, + id: &'static str, + display: SharedString, + on_minus: impl Fn(&mut Self, &mut Context) + 'static, + on_plus: impl Fn(&mut Self, &mut Context) + 'static, + cx: &mut Context, + ) -> impl IntoElement { + Self::row( + label, + h_flex() + .gap_2() + .items_center() + .child( + Button::new(SharedString::from(format!("minus-{id}"))) + .label("−") + .small() + .on_click(cx.listener(move |this, _, _, cx| { + on_minus(this, cx); + })), + ) + .child(div().text_sm().min_w(px(64.0)).child(display)) + .child( + Button::new(SharedString::from(format!("plus-{id}"))) + .label("+") + .small() + .on_click(cx.listener(move |this, _, _, cx| { + on_plus(this, cx); + })), + ), + ) + } + + /// 未迁移分区的占位说明。 + fn pending_group( + &self, + title: &'static str, + note: &'static str, + cx: &Context, + ) -> gpui::Div { + self.group(title, cx) + .child(div().text_sm().text_color(cx.theme().muted_foreground).child(note)) + } + + // ---- 分区内容(归属对照旧壳各 section)---- + + fn section_appearance(&mut self, cx: &mut Context) -> gpui::Div { + let font_size: SharedString = format!("{:.1} px", self.font_size_px(cx)).into(); + let opacity: SharedString = format!("{:.0}%", self.runtime.opacity * 100.0).into(); + self.group("外观", cx) + .child(self.select_row("theme", "主题")) + .child(self.checkbox_row( + "follow_system_theme", + "跟随系统外观自动切换深浅", + self.runtime.follow_system_theme, + cx, + )) + .child(self.input_row("终端背景色(覆盖主题)", "background", &self.bg_input.clone(), cx)) + .child(self.stepper_row( + "窗口透明度(旧壳窗口生效)", + "opacity", + opacity, + |this, cx| { + let opacity = this.runtime.opacity - 0.05; + this.set_opacity(opacity, cx); + }, + |this, cx| { + let opacity = this.runtime.opacity + 0.05; + this.set_opacity(opacity, cx); + }, + cx, + )) + .child(self.checkbox_row("blur", "背景模糊(旧壳窗口生效)", self.runtime.blur, cx)) + .child(self.select_row("density", "界面密度")) + .child(self.stepper_row( + "字号", + "font-size", + font_size, + |this, cx| { + let size = this.font_size_px(cx) - 0.5; + this.set_font_size(size, cx); + }, + |this, cx| { + let size = this.font_size_px(cx) + 0.5; + this.set_font_size(size, cx); + }, + cx, + )) + .child(self.select_row("cursor_shape", "光标形状(新标签页生效)")) + .child(self.checkbox_row( + "cursor_blink", + "光标闪烁", + self.runtime.cursor_blink.unwrap_or(true), + cx, + )) + } + + fn section_profiles(&mut self, cx: &mut Context) -> gpui::Div { + let font_label: SharedString = self + .runtime + .font_family + .clone() + .unwrap_or_else(|| "(跟随 nebula.toml / 内置默认)".into()) + .into(); + self.group("配置文件", cx) + .child(self.select_row("language", "界面语言")) + .child(self.select_row("shell", "默认 Shell")) + .child(self.input_row("新标签启动目录", "startup_directory", &self.dir_input.clone(), cx)) + .child(Self::row( + "字体(选择器待迁移)", + div().text_sm().text_color(cx.theme().muted_foreground).child(font_label), + )) + .child(self.checkbox_row("ghost", "启用命令补全", self.runtime.ghost, cx)) + .child(self.select_row("accept", "补全接受键")) + .child(self.select_row("completion_style", "补全样式")) + } + + fn section_network(&mut self, cx: &mut Context) -> gpui::Div { + self.group("网络(SSH 出站代理)", cx) + .child(self.select_row("ssh_proxy_mode", "代理模式")) + .child(self.input_row("代理 URL", "ssh_proxy_url", &self.proxy_url_input.clone(), cx)) + .child(self.input_row( + "绕过列表", + "ssh_proxy_no_proxy", + &self.proxy_bypass_input.clone(), + cx, + )) + } + + fn section_interaction(&mut self, cx: &mut Context) -> gpui::Div { + self.group("交互", cx) + .child(self.checkbox_row( + "copy_on_select", + "选中即复制(关 = 右键复制/粘贴)", + self.runtime.copy_on_select, + cx, + )) + .child(self.select_row("tab_reveal", "标签展开动效")) + .child(self.select_row("new_tab_position", "新标签位置")) + .child(self.select_row("cell_width_mode", "单元格宽度")) + .child(self.checkbox_row( + "panel_resize", + "拖拽调节侧栏宽度", + self.runtime.panel_resize, + cx, + )) + .child(self.checkbox_row( + "cjk_bold_regular", + "CJK 粗体使用常规字形(提亮不加粗)", + self.runtime.cjk_bold_regular, + cx, + )) + } + + fn section_keymap(&mut self, cx: &mut Context) -> gpui::Div { + let hotkey: SharedString = self.runtime.quick_terminal_hotkey.clone().into(); + self.group("按键映射", cx) + .child(Self::row( + "快速终端热键", + div().text_sm().text_color(cx.theme().muted_foreground).child(hotkey), + )) + .child(div().text_sm().text_color(cx.theme().muted_foreground).child( + "快捷键编辑器待迁移:绑定表当前请在旧壳设置页修改,\ + 存储于 nebula_settings.txt 的 keybind= 行,两壳同读。", + )) + } + + fn section_advanced(&mut self, cx: &mut Context) -> gpui::Div { + self.group("高级", cx) + .child(self.checkbox_row("fetch", "新会话欢迎屏 fastfetch", self.runtime.fetch, cx)) + .child(self.checkbox_row( + "powerline", + "Powerline 提示符(新会话生效)", + self.runtime.powerline, + cx, + )) + .child(self.checkbox_row( + "keep_session", + "关窗后保留后台会话", + self.runtime.keep_session, + cx, + )) + .child(self.checkbox_row( + "restore_session", + "启动时恢复上次标签", + self.runtime.restore_session, + cx, + )) + .child(self.checkbox_row( + "resume_ai", + "恢复会话时自动接续 AI 对话", + self.runtime.resume_ai, + cx, + )) + .child(self.checkbox_row( + "tray", + "常驻系统托盘图标(旧壳生效)", + self.runtime.tray, + cx, + )) + } + + fn section_content(&mut self, cx: &mut Context) -> gpui::AnyElement { + use gpui::IntoElement as _; + match self.active_section { + 0 => self.section_appearance(cx), + 1 => self.section_profiles(cx), + 2 => self.pending_group( + "供应商", + "AI Provider 编辑器待迁移(API 地址/密钥托管在系统凭据库)。\ + 当前请在旧壳设置页修改。", + cx, + ), + 3 => self.pending_group( + "SSH", + "SSH 主机管理(保存/置顶/隐藏恢复)待迁移。当前请在旧壳侧栏\ + 与设置页管理,数据两壳同读。", + cx, + ), + 4 => self.section_network(cx), + 5 => self.section_interaction(cx), + 6 => self.section_keymap(cx), + 7 => self.section_advanced(cx), + _ => self.pending_group( + "备份", + "加密备份/远程同步(文件夹/WebDAV/S3/SFTP)编辑器待迁移。\ + 当前请在旧壳设置页操作。", + cx, + ), + } + .into_any_element() + } + + fn render_nav(&self, cx: &mut Context) -> gpui::AnyElement { + use gpui::IntoElement as _; + let theme = cx.theme(); + let muted = theme.muted_foreground; + let active_bg = theme.sidebar_accent; + let active_fg = theme.sidebar_accent_foreground; + let hover_bg = theme.list_hover; + + v_flex() + .w(px(150.0)) + .h_full() + .flex_shrink_0() + .p_2() + .gap_1() + .border_r_1() + .border_color(theme.border) + .children(SECTIONS.iter().enumerate().map(|(ix, label)| { + let active = ix == self.active_section; + div() + .id(("settings-nav", ix)) + .px_3() + .h(px(30.0)) + .flex() + .items_center() + .rounded_md() + .cursor_pointer() + .text_sm() + .when(active, |item| item.bg(active_bg).text_color(active_fg)) + .when(!active, |item| item.text_color(muted).hover(|s| s.bg(hover_bg))) + .on_click(cx.listener(move |this, _, _, cx| { + this.active_section = ix; + cx.notify(); + })) + .child(*label) + })) + .into_any_element() + } } impl EventEmitter for SettingsPane {} @@ -177,113 +613,38 @@ impl Focusable for SettingsPane { impl Render for SettingsPane { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - let font_label: SharedString = if self.font_family.is_empty() { - "(跟随 nebula.toml / 内置默认)".into() - } else { - self.font_family.clone() - }; - let font_size: SharedString = format!("{:.1} pt", self.font_size_pt).into(); + let nav = self.render_nav(cx); + let content = self.section_content(cx); div() .size_full() .track_focus(&self.focus_handle) .bg(cx.theme().background) .text_color(cx.theme().foreground) + .flex() + .flex_row() + .child(nav) .child( v_flex() - .size_full() - .p_8() - .gap_5() + .flex_1() + .min_w_0() + .h_full() + .p_6() + .gap_4() .overflow_y_scrollbar() - .child( - div().text_xl().font_weight(gpui::FontWeight::SEMIBOLD).child("设置"), - ) - .child( - self.group("外观", cx) - .child(Self::row( - "主题", - div().w(px(220.0)).child(Select::new(&self.theme_select)), - )) - .child(Self::row( - "字号", - h_flex() - .gap_2() - .items_center() - .child( - Button::new("font-size-down") - .label("−") - .small() - .on_click(cx.listener(|this, _, _, cx| { - this.set_font_size(this.font_size_pt - 0.5, cx); - })), - ) - .child(div().text_sm().min_w(px(64.0)).child(font_size)) - .child( - Button::new("font-size-up") - .label("+") - .small() - .on_click(cx.listener(|this, _, _, cx| { - this.set_font_size(this.font_size_pt + 0.5, cx); - })), - ), - )) - .child(Self::row( - "字体", - div() - .text_sm() - .text_color(cx.theme().muted_foreground) - .child(font_label), - )) - .child(Self::row( - "光标形状(新标签页生效)", - div().w(px(220.0)).child(Select::new(&self.cursor_select)), - )) - .child(Self::row( - "光标闪烁", - Checkbox::new("cursor-blink").checked(self.cursor_blink).on_click( - cx.listener(|this, checked: &bool, _, cx| { - this.cursor_blink = *checked; - this.persist( - &[("cursor_blink", (*checked as u8).to_string())], - cx, - ); - }), - ), - )), - ) - .child( - self.group("交互", cx) - .child(Self::row( - "选中即复制(关 = 右键复制/粘贴)", - Checkbox::new("copy-on-select") - .checked(self.copy_on_select) - .on_click(cx.listener(|this, checked: &bool, _, cx| { - this.copy_on_select = *checked; - this.persist( - &[("copy_on_select", (*checked as u8).to_string())], - cx, - ); - })), - )) - .child(Self::row( - "Powerline 提示符(新会话生效)", - Checkbox::new("powerline").checked(self.powerline).on_click( - cx.listener(|this, checked: &bool, _, cx| { - this.powerline = *checked; - this.persist( - &[("powerline", (*checked as u8).to_string())], - cx, - ); - }), - ), - )), - ) .child( div() - .text_xs() - .text_color(cx.theme().muted_foreground) - .child("写入 nebula_settings.txt,与旧壳共享同一份设置。"), - ), + .text_xl() + .font_weight(gpui::FontWeight::SEMIBOLD) + .child(SharedString::from(format!( + "设置 · {}", + SECTIONS[self.active_section] + ))), + ) + .child(content) + .child(div().text_xs().text_color(cx.theme().muted_foreground).child( + "写入 nebula_settings.txt,与旧壳共享同一份设置;两边可交替修改。", + )), ) } } diff --git a/nebula_settings/src/lib.rs b/nebula_settings/src/lib.rs index ffad867..32552d3 100644 --- a/nebula_settings/src/lib.rs +++ b/nebula_settings/src/lib.rs @@ -313,21 +313,285 @@ pub enum CursorShapeName { Block, Beam, Underline, + Hollow, } -/// 新 UI 消费的运行时设置子集。字段随消费方增长;未设置的键保持 `None` -/// 让调用方自选回退。 +impl CursorShapeName { + pub fn from_settings(value: &str) -> Option { + match value.trim().to_ascii_lowercase().as_str() { + "block" => Some(Self::Block), + "beam" | "bar" => Some(Self::Beam), + "underline" => Some(Self::Underline), + "hollow" => Some(Self::Hollow), + _ => None, + } + } + + pub fn settings_value(self) -> &'static str { + match self { + Self::Block => "block", + Self::Beam => "beam", + Self::Underline => "underline", + Self::Hollow => "hollow", + } + } +} + +/// 下面的小枚举都是 `nebula_settings.txt` 的键值域,取值与旧壳设置页 +/// 逐字一致(parse 宽容:未知值回退默认)。 + +#[derive(Copy, Clone, Debug, PartialEq, Eq, Default)] +pub enum LanguagePref { + #[default] + System, + ZhCn, + EnUs, +} + +impl LanguagePref { + pub fn from_settings(value: &str) -> Option { + match value.trim() { + "system" => Some(Self::System), + "zh-CN" => Some(Self::ZhCn), + "en-US" => Some(Self::EnUs), + _ => None, + } + } + + pub fn settings_value(self) -> &'static str { + match self { + Self::System => "system", + Self::ZhCn => "zh-CN", + Self::EnUs => "en-US", + } + } +} + +#[derive(Copy, Clone, Debug, PartialEq, Eq, Default)] +pub enum AcceptKeyName { + Right, + Tab, + #[default] + Both, +} + +impl AcceptKeyName { + pub fn from_settings(value: &str) -> Option { + match value.trim().to_ascii_lowercase().as_str() { + "right" => Some(Self::Right), + "tab" => Some(Self::Tab), + "both" => Some(Self::Both), + _ => None, + } + } + + pub fn settings_value(self) -> &'static str { + match self { + Self::Right => "right", + Self::Tab => "tab", + Self::Both => "both", + } + } +} + +#[derive(Copy, Clone, Debug, PartialEq, Eq, Default)] +pub enum CompletionStyleName { + #[default] + Inline, + Popup, +} + +impl CompletionStyleName { + pub fn from_settings(value: &str) -> Option { + match value.trim().to_ascii_lowercase().as_str() { + "inline" | "ghost" => Some(Self::Inline), + "popup" | "menu" | "list" => Some(Self::Popup), + _ => None, + } + } + + pub fn settings_value(self) -> &'static str { + match self { + Self::Inline => "inline", + Self::Popup => "popup", + } + } +} + +#[derive(Copy, Clone, Debug, PartialEq, Eq, Default)] +pub enum TabRevealName { + #[default] + Slide, + Instant, +} + +impl TabRevealName { + pub fn from_settings(value: &str) -> Option { + match value.trim().to_ascii_lowercase().as_str() { + "slide" => Some(Self::Slide), + "instant" => Some(Self::Instant), + _ => None, + } + } + + pub fn settings_value(self) -> &'static str { + match self { + Self::Slide => "slide", + Self::Instant => "instant", + } + } +} + +#[derive(Copy, Clone, Debug, PartialEq, Eq, Default)] +pub enum DensityName { + #[default] + Standard, + Compact, +} + +impl DensityName { + pub fn from_settings(value: &str) -> Option { + match value.trim().to_ascii_lowercase().as_str() { + "standard" => Some(Self::Standard), + "compact" => Some(Self::Compact), + _ => None, + } + } + + pub fn settings_value(self) -> &'static str { + match self { + Self::Standard => "standard", + Self::Compact => "compact", + } + } +} + +#[derive(Copy, Clone, Debug, PartialEq, Eq, Default)] +pub enum NewTabPositionName { + #[default] + AfterCurrent, + End, +} + +impl NewTabPositionName { + pub fn from_settings(value: &str) -> Option { + match value.trim().to_ascii_lowercase().as_str() { + "after_current" => Some(Self::AfterCurrent), + "end" => Some(Self::End), + _ => None, + } + } + + pub fn settings_value(self) -> &'static str { + match self { + Self::AfterCurrent => "after_current", + Self::End => "end", + } + } +} + +#[derive(Copy, Clone, Debug, PartialEq, Eq, Default)] +pub enum CellWidthModeName { + #[default] + Compact, + Relaxed, +} + +impl CellWidthModeName { + pub fn from_settings(value: &str) -> Option { + match value.trim().to_ascii_lowercase().as_str() { + "compact" => Some(Self::Compact), + "relaxed" => Some(Self::Relaxed), + _ => None, + } + } + + pub fn settings_value(self) -> &'static str { + match self { + Self::Compact => "compact", + Self::Relaxed => "relaxed", + } + } +} + +#[derive(Copy, Clone, Debug, PartialEq, Eq, Default)] +pub enum ProxyModeName { + #[default] + Off, + System, + Custom, +} + +impl ProxyModeName { + pub fn from_settings(value: &str) -> Option { + match value.trim().to_ascii_lowercase().as_str() { + "off" => Some(Self::Off), + "system" => Some(Self::System), + "custom" => Some(Self::Custom), + _ => None, + } + } + + pub fn settings_value(self) -> &'static str { + match self { + Self::Off => "off", + Self::System => "system", + Self::Custom => "custom", + } + } +} + +/// 新 UI 消费的运行时设置。字段与出厂默认逐项对照旧壳 +/// `nebula_settings_load`;`Option` 字段的 `None` = 键未设置,调用方自选 +/// 回退(如 font_family 回落 nebula.toml)。 pub struct RuntimeSettings { + pub language: LanguagePref, pub theme: ThemeName, + /// 系统外观变化时自动在主题家族的深浅成员间切换(默认关,尊重显式选择)。 + pub follow_system_theme: bool, pub font_family: Option, - /// pt(与设置界面一致)。 - pub font_size_pt: Option, + /// **逻辑像素**(旧壳写盘语义:设置页 spinner 与 Ctrl+滚轮缩放持久化时 + /// 已除以 scale factor)。`None` = 跟随 nebula.toml 的 `font.size`(pt)。 + pub font_size_px: Option, pub cursor_shape: Option, pub cursor_blink: Option, pub copy_on_select: bool, pub powerline: bool, + /// 默认 shell 的原始 id(`shell=` 原文:powershell/bash/cmd/pwsh/WSL + /// 发行版等)。解析归 shell 检测层,这里只做持久化往返。 + pub shell: Option, + pub startup_directory: Option, + /// AI 内联补全(ghost text)。 + pub ghost: bool, + pub accept: AcceptKeyName, + pub completion_style: CompletionStyleName, + /// 全宽字形(CJK 等)bold run 用 Regular 字形(粗体提亮不加粗)。 + pub cjk_bold_regular: bool, + pub tab_reveal: TabRevealName, + pub density: DensityName, + pub new_tab_position: NewTabPositionName, + pub cell_width_mode: CellWidthModeName, + /// 新会话欢迎屏 fastfetch(默认关:启动速度优先于观感,旧壳裁定)。 + pub fetch: bool, + pub keep_session: bool, + pub restore_session: bool, + pub resume_ai: bool, + /// 常驻系统托盘图标。 + pub tray: bool, + pub blur: bool, + pub opacity: f32, + /// 终端背景覆盖色(设置页取色器写入,优先于主题背景)。 + pub background: Option, + pub panel_resize: bool, + pub ssh_proxy_mode: ProxyModeName, + pub ssh_proxy_url: String, + pub ssh_proxy_no_proxy: String, + pub quick_terminal_hotkey: String, } +/// 旧壳默认快速终端热键。 +pub const DEFAULT_QUICK_TERMINAL_HOTKEY: &str = "ctrl+`"; + impl RuntimeSettings { pub fn load() -> Self { Self::from_raw(&RawSettings::load()) @@ -335,24 +599,81 @@ impl RuntimeSettings { pub fn from_raw(raw: &RawSettings) -> Self { Self { + language: raw + .value("language") + .and_then(LanguagePref::from_settings) + .unwrap_or_default(), theme: raw.value("theme").and_then(ThemeName::from_prompt_name).unwrap_or_default(), + follow_system_theme: raw.bool_on("follow_system_theme").unwrap_or(false), font_family: raw.value("font_family").map(str::to_owned), - font_size_pt: raw.f32("font_size").map(|size| size.clamp(4.0, 96.0)), - cursor_shape: raw.value("cursor_shape").and_then(|value| { - match value.to_ascii_lowercase().as_str() { - "block" => Some(CursorShapeName::Block), - "beam" | "bar" => Some(CursorShapeName::Beam), - "underline" => Some(CursorShapeName::Underline), - _ => None, - } - }), + font_size_px: raw.f32("font_size").map(|size| size.clamp(4.0, 96.0)), + cursor_shape: raw.value("cursor_shape").and_then(CursorShapeName::from_settings), cursor_blink: raw.bool_on("cursor_blink"), copy_on_select: raw.bool_on("copy_on_select").unwrap_or(false), powerline: raw.bool_on("powerline").unwrap_or(true), + shell: raw.value("shell").or_else(|| raw.value("executor")).map(str::to_owned), + startup_directory: raw.value("startup_directory").map(str::to_owned), + ghost: raw.value("ghost").map(|v| v != "0").unwrap_or(true), + accept: raw.value("accept").and_then(AcceptKeyName::from_settings).unwrap_or_default(), + completion_style: raw + .value("completion_style") + .and_then(CompletionStyleName::from_settings) + .unwrap_or_default(), + cjk_bold_regular: raw.bool_on("cjk_bold_regular").unwrap_or(true), + tab_reveal: raw + .value("tab_reveal") + .and_then(TabRevealName::from_settings) + .unwrap_or_default(), + density: raw.value("density").and_then(DensityName::from_settings).unwrap_or_default(), + new_tab_position: raw + .value("new_tab_position") + .and_then(NewTabPositionName::from_settings) + .unwrap_or_default(), + cell_width_mode: raw + .value("cell_width_mode") + .and_then(CellWidthModeName::from_settings) + .unwrap_or_default(), + fetch: raw.bool_on("fetch").unwrap_or(false), + keep_session: raw.bool_on("keep_session").unwrap_or(false), + restore_session: raw.bool_on("restore_session").unwrap_or(true), + resume_ai: raw.bool_on("resume_ai").unwrap_or(true), + tray: raw.bool_on("tray").unwrap_or(true), + blur: raw.bool_on("blur").unwrap_or(true), + opacity: raw.f32("opacity").map(|o| o.clamp(0.0, 1.0)).unwrap_or(1.0), + background: raw.value("background").and_then(parse_hex_rgb), + panel_resize: raw.bool_on("panel_resize").unwrap_or(false), + ssh_proxy_mode: raw + .value("ssh_proxy_mode") + .and_then(ProxyModeName::from_settings) + .unwrap_or_default(), + ssh_proxy_url: raw.value("ssh_proxy_url").unwrap_or_default().to_owned(), + ssh_proxy_no_proxy: raw.value("ssh_proxy_no_proxy").unwrap_or_default().to_owned(), + quick_terminal_hotkey: raw + .value("quick_terminal_hotkey") + .unwrap_or(DEFAULT_QUICK_TERMINAL_HOTKEY) + .to_owned(), } } } +/// 解析 `#rrggbb`(旧壳 `parse_hex_rgb` 同款:# 前缀可省)。 +pub fn parse_hex_rgb(value: &str) -> Option { + let hex = value.trim(); + let hex = hex.strip_prefix('#').unwrap_or(hex); + if hex.len() != 6 || !hex.is_ascii() { + return None; + } + let r = u8::from_str_radix(&hex[0..2], 16).ok()?; + let g = u8::from_str_radix(&hex[2..4], 16).ok()?; + let b = u8::from_str_radix(&hex[4..6], 16).ok()?; + Some([r, g, b]) +} + +/// 格式化为 `#rrggbb`(写盘格式,与旧壳一致)。 +pub fn format_hex_rgb(rgb: Rgb8) -> String { + format!("#{:02x}{:02x}{:02x}", rgb[0], rgb[1], rgb[2]) +} + #[cfg(test)] mod tests { use super::*; @@ -360,26 +681,67 @@ mod tests { #[test] fn parses_real_settings_shape() { let raw = RawSettings::from_text( - "language=system\n\ + "language=zh-CN\n\ theme=SilverLight\n\ follow_system_theme=0\n\ - shell=powershell\n\ + ghost=1\n\ + accept=tab\n\ + completion_style=popup\n\ + shell=pwsh\n\ font_family=Maple Mono Normal NF CN\n\ font_size=16.3\n\ cursor_shape=beam\n\ cursor_blink=1\n\ copy_on_select=1\n\ + cjk_bold_regular=1\n\ + tab_reveal=instant\n\ + density=compact\n\ + new_tab_position=end\n\ + cell_width_mode=relaxed\n\ + fetch=1\n\ powerline=0\n\ + keep_session=1\n\ + restore_session=0\n\ + resume_ai=0\n\ + tray=0\n\ + blur=0\n\ + opacity=0.87\n\ + background=#101216\n\ + panel_resize=1\n\ + ssh_proxy_mode=custom\n\ + ssh_proxy_url=socks5://127.0.0.1:7890\n\ + quick_terminal_hotkey=alt+`\n\ startup_directory=\n", ); let settings = RuntimeSettings::from_raw(&raw); + assert_eq!(settings.language, LanguagePref::ZhCn); assert_eq!(settings.theme, ThemeName::SilverLight); assert_eq!(settings.font_family.as_deref(), Some("Maple Mono Normal NF CN")); - assert_eq!(settings.font_size_pt, Some(16.3)); + // font_size 键存的是逻辑像素(旧壳写盘语义),不做 pt 换算。 + assert_eq!(settings.font_size_px, Some(16.3)); assert_eq!(settings.cursor_shape, Some(CursorShapeName::Beam)); assert_eq!(settings.cursor_blink, Some(true)); assert!(settings.copy_on_select); assert!(!settings.powerline); + assert_eq!(settings.shell.as_deref(), Some("pwsh")); + assert_eq!(settings.accept, AcceptKeyName::Tab); + assert_eq!(settings.completion_style, CompletionStyleName::Popup); + assert_eq!(settings.tab_reveal, TabRevealName::Instant); + assert_eq!(settings.density, DensityName::Compact); + assert_eq!(settings.new_tab_position, NewTabPositionName::End); + assert_eq!(settings.cell_width_mode, CellWidthModeName::Relaxed); + assert!(settings.fetch); + assert!(settings.keep_session); + assert!(!settings.restore_session); + assert!(!settings.resume_ai); + assert!(!settings.tray); + assert!(!settings.blur); + assert!((settings.opacity - 0.87).abs() < 1e-6); + assert_eq!(settings.background, Some([0x10, 0x12, 0x16])); + assert!(settings.panel_resize); + assert_eq!(settings.ssh_proxy_mode, ProxyModeName::Custom); + assert_eq!(settings.ssh_proxy_url, "socks5://127.0.0.1:7890"); + assert_eq!(settings.quick_terminal_hotkey, "alt+`"); // 空值 = 未设置。 assert_eq!(raw.value("startup_directory"), None); } @@ -387,10 +749,39 @@ mod tests { #[test] fn defaults_when_file_content_is_absent_or_junk() { let settings = RuntimeSettings::from_raw(&RawSettings::from_text("theme=NoSuchTheme\n")); + // 出厂默认逐项对照旧壳 nebula_settings_load。 + assert_eq!(settings.language, LanguagePref::System); assert_eq!(settings.theme, ThemeName::Nebula); assert_eq!(settings.font_family, None); assert!(!settings.copy_on_select); assert!(settings.powerline); + assert!(settings.ghost); + assert_eq!(settings.accept, AcceptKeyName::Both); + assert_eq!(settings.completion_style, CompletionStyleName::Inline); + assert!(settings.cjk_bold_regular); + assert_eq!(settings.tab_reveal, TabRevealName::Slide); + assert_eq!(settings.density, DensityName::Standard); + assert_eq!(settings.new_tab_position, NewTabPositionName::AfterCurrent); + assert_eq!(settings.cell_width_mode, CellWidthModeName::Compact); + assert!(!settings.fetch); + assert!(!settings.keep_session); + assert!(settings.restore_session); + assert!(settings.resume_ai); + assert!(settings.tray); + assert!(settings.blur); + assert!((settings.opacity - 1.0).abs() < 1e-6); + assert_eq!(settings.background, None); + assert!(!settings.panel_resize); + assert_eq!(settings.ssh_proxy_mode, ProxyModeName::Off); + assert_eq!(settings.quick_terminal_hotkey, DEFAULT_QUICK_TERMINAL_HOTKEY); + } + + #[test] + fn hex_rgb_roundtrip() { + assert_eq!(parse_hex_rgb("#8bd5ca"), Some([0x8b, 0xd5, 0xca])); + assert_eq!(parse_hex_rgb("8bd5ca"), Some([0x8b, 0xd5, 0xca])); + assert_eq!(parse_hex_rgb("#nothex"), None); + assert_eq!(format_hex_rgb([0x8b, 0xd5, 0xca]), "#8bd5ca"); } #[test]