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.
This commit is contained in:
kuddev committed 2026-08-13 14:29:48 +08:00
1 parent 3d738f6bd8
commit af41bae643
3 files changed
+975 -214

No files matched your search

+13 -4
View File
@@ -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,
+555 -194
View File
@@ -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<SelectState<Vec<SharedString>>>;
pub struct SettingsPane {
focus_handle: FocusHandle,
theme_select: Entity<SelectState<Vec<SharedString>>>,
cursor_select: Entity<SelectState<Vec<SharedString>>>,
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<InputState>,
bg_input: Entity<InputState>,
proxy_url_input: Entity<InputState>,
proxy_bypass_input: Entity<InputState>,
_subscriptions: Vec<Subscription>,
}
impl SettingsPane {
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> 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::<Vec<_>>(),
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<Self>| {
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::<Vec<_>>(),
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<Vec<SharedString>>,
_window: &mut Window,
cx: &mut Context<Self>| {
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::<Vec<_>>(),
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<SelectState<Vec<SharedString>>>,
event: &SelectEvent<Vec<SharedString>>,
_window: &mut Window,
cx: &mut Context<Self>,
) {
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<SelectState<Vec<SharedString>>>,
event: &SelectEvent<Vec<SharedString>>,
_window: &mut Window,
cx: &mut Context<Self>,
) {
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<Self>) {
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>) {
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>) {
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::<crate::gpui_shell::config::Settings>().font_size_px)
}
fn set_font_size(&mut self, size: f32, cx: &mut Context<Self>) {
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<Self>) {
let opacity = opacity.clamp(0.2, 1.0);
self.persist(&[("opacity", format!("{opacity:.2}"))], cx);
}
fn select_of(&self, key: &str) -> Option<SharedSelect> {
self.selects.iter().find(|(k, _)| *k == key).map(|(_, entity)| entity.clone())
}
/// 分组卡片(右侧内容区的分组式版面,对齐旧壳)。
fn group(&self, title: &'static str, cx: &Context<Self>) -> 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<Self>,
) -> 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<InputState>,
cx: &mut Context<Self>,
) -> 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<Self>) + 'static,
on_plus: impl Fn(&mut Self, &mut Context<Self>) + 'static,
cx: &mut Context<Self>,
) -> 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<Self>,
) -> 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<Self>) -> 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<Self>) -> 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<Self>) -> 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<Self>) -> 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<Self>) -> 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<Self>) -> 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<Self>) -> 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<Self>) -> 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<SettingsPaneEvent> for SettingsPane {}
@@ -177,113 +613,38 @@ impl Focusable for SettingsPane {
impl Render for SettingsPane {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> 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,与旧壳共享同一份设置;两边可交替修改。",
)),
)
}
}
+407 -16
View File
@@ -313,21 +313,285 @@ pub enum CursorShapeName {
Block,
Beam,
Underline,
Hollow,
}
/// 新 UI 消费的运行时设置子集。字段随消费方增长;未设置的键保持 `None`
/// 让调用方自选回退。
impl CursorShapeName {
pub fn from_settings(value: &str) -> Option<Self> {
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<Self> {
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<Self> {
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<Self> {
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<Self> {
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<Self> {
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<Self> {
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<Self> {
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<Self> {
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<String>,
/// pt(与设置界面一致)。
pub font_size_pt: Option<f32>,
/// **逻辑像素**(旧壳写盘语义:设置页 spinner 与 Ctrl+滚轮缩放持久化时
/// 已除以 scale factor)。`None` = 跟随 nebula.toml 的 `font.size`(pt)。
pub font_size_px: Option<f32>,
pub cursor_shape: Option<CursorShapeName>,
pub cursor_blink: Option<bool>,
pub copy_on_select: bool,
pub powerline: bool,
/// 默认 shell 的原始 id(`shell=` 原文:powershell/bash/cmd/pwsh/WSL
/// 发行版等)。解析归 shell 检测层,这里只做持久化往返。
pub shell: Option<String>,
pub startup_directory: Option<String>,
/// 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<Rgb8>,
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<Rgb8> {
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]