checkpoint: preserve settings layout parity work

This commit is contained in:
kuddev committed 2026-08-15 16:39:18 +08:00
1 parent 7a1a954b11
commit ad869bb3ea
1 file changed
+246 -235
+246 -235
View File
@@ -36,14 +36,23 @@ const THEME_VALUES: [&str; 7] =
const SECTIONS: [&str; 9] =
["外观", "配置文件", "供应商", "SSH", "网络", "交互", "按键映射", "高级", "备份"];
/// 导航的显示顺序与分组,对照旧壳 `settings.rs` 的 `nav_groups`:前四项裸列
/// 不带标题,「连接」收拢供应商/SSH/网络,「系统」收拢高级/备份。
/// 导航的显示顺序与分组,对照旧壳 `settings.rs` 的 `nav_groups`:供应商是
/// 常用业务设置,和外观/配置/交互/按键同列;「连接」只收拢 SSH 与网络。
///
/// 第二项是组内成员在 [`SECTIONS`] 里的下标——**下标就是 section 身份**
/// (`section_content`/`section_icon` 都按它分派),所以调显示顺序或重新分组
/// 只动这张表,不必碰分派代码。
const NAV_GROUPS: [(Option<&str>, &[usize]); 3] =
[(None, &[0, 1, 5, 6]), (Some("连接"), &[2, 3, 4]), (Some("系统"), &[7, 8])];
[(None, &[0, 1, 2, 5, 6]), (Some("连接"), &[3, 4]), (Some("系统"), &[7, 8])];
// 这些几何值逐项来自旧壳 `display/settings.rs::settings_geometry`。GPUI
// 设置页沿用同一节奏,避免组件默认间距把标题、分组和表单压成一条均匀列表。
const SETTINGS_NAV_WIDTH: f32 = 196.0;
const SETTINGS_HEADER_HEIGHT: f32 = 72.0;
const SETTINGS_GROUP_GAP: f32 = 32.0;
const SETTINGS_GROUP_TITLE_HEIGHT: f32 = 26.0;
const SETTINGS_GROUP_TITLE_GAP: f32 = 16.0;
const SETTINGS_ROW_HEIGHT: f32 = 44.0;
const THEME_NAMES: [ThemeName; 7] = [
ThemeName::Nebula,
@@ -83,13 +92,8 @@ fn chrome_theme(theme: ThemeName) -> crate::display::NebulaTheme {
}
fn rgb_hsla(r: u8, g: u8, b: u8) -> Hsla {
GpuiRgba {
r: f32::from(r) / 255.0,
g: f32::from(g) / 255.0,
b: f32::from(b) / 255.0,
a: 1.0,
}
.into()
GpuiRgba { r: f32::from(r) / 255.0, g: f32::from(g) / 255.0, b: f32::from(b) / 255.0, a: 1.0 }
.into()
}
/// 宿主(workspace)监听:设置已写盘 / 请求打开 SSH 会话。
@@ -384,8 +388,7 @@ impl SettingsPane {
let backup_remote_inputs: Vec<Entity<InputState>> =
(0..4).map(|_| cx.new(|cx| InputState::new(window, cx))).collect();
let font_query_input =
cx.new(|cx| InputState::new(window, cx).placeholder("搜索字体…"));
let font_query_input = cx.new(|cx| InputState::new(window, cx).placeholder("搜索字体…"));
subscriptions.push(cx.subscribe_in(
&font_query_input,
window,
@@ -522,9 +525,7 @@ impl SettingsPane {
let system = crate::font_install::enumerate_system_font_families();
let imported: Vec<String> = crate::font_install::imported_font_files()
.iter()
.filter_map(|path| {
crate::font_install::probe_font_file_families(path).ok()
})
.filter_map(|path| crate::font_install::probe_font_file_families(path).ok())
.flatten()
.collect();
(system, imported)
@@ -618,9 +619,7 @@ impl SettingsPane {
return;
},
};
if let Err(error) =
cx.text_system().add_fonts(vec![std::borrow::Cow::Owned(bytes)])
{
if let Err(error) = cx.text_system().add_fonts(vec![std::borrow::Cow::Owned(bytes)]) {
cleanup(&stored);
self.font_notice = Some(format!("字体注册失败:{error}"));
cx.notify();
@@ -645,12 +644,7 @@ impl SettingsPane {
h_flex()
.gap_2()
.items_center()
.child(
div()
.text_sm()
.font_family(primary.clone())
.child(primary),
)
.child(div().text_sm().font_family(primary.clone()).child(primary))
.child(
Button::new("font-picker-toggle")
.label(if self.font_picker_open { "收起" } else { "更换" })
@@ -769,15 +763,13 @@ impl SettingsPane {
.items_center()
.child(div().flex_1().child(Input::new(&self.font_query_input)))
.child(div().text_sm().text_color(muted).child("显示全部"))
.child(
Switch::new("font-show-all")
.checked(self.font_show_all)
.on_click(cx.listener(|this, checked: &bool, _, cx| {
// 临时过滤,不写入设置(旧壳 toggle_font_show_all)。
this.font_show_all = *checked;
cx.notify();
})),
),
.child(Switch::new("font-show-all").checked(self.font_show_all).on_click(
cx.listener(|this, checked: &bool, _, cx| {
// 临时过滤,不写入设置(旧壳 toggle_font_show_all)。
this.font_show_all = *checked;
cx.notify();
}),
)),
)
.when(self.font_loading, |panel| {
panel.child(
@@ -790,20 +782,14 @@ impl SettingsPane {
})
.when(!self.font_loading, |panel| {
panel.child(
v_flex()
.max_h(px(320.0))
.gap_1()
.overflow_y_scrollbar()
.children(rows)
.when(empty, |list| {
v_flex().max_h(px(320.0)).gap_1().overflow_y_scrollbar().children(rows).when(
empty,
|list| {
list.child(
div()
.py_2()
.text_sm()
.text_color(muted)
.child("没有匹配的字体"),
div().py_2().text_sm().text_color(muted).child("没有匹配的字体"),
)
}),
},
),
)
})
.child(
@@ -818,7 +804,9 @@ impl SettingsPane {
}));
button
})
.child(div().text_xs().text_color(muted).child("支持 .ttf / .otf / .ttc / .otc"))
.child(
div().text_xs().text_color(muted).child("支持 .ttf / .otf / .ttc / .otc"),
)
.when_some(self.font_notice.clone(), |row, notice| {
row.child(div().text_xs().text_color(theme.danger).child(notice))
}),
@@ -833,14 +821,28 @@ impl SettingsPane {
fn group(&self, title: &'static str, cx: &Context<Self>) -> gpui::Div {
v_flex()
.w_full()
.max_w(px(760.0))
.gap_4()
.py_2()
.child(div().text_sm().text_color(cx.theme().muted_foreground).child(title))
.child(
div()
.h(px(SETTINGS_GROUP_TITLE_HEIGHT))
.flex()
.items_center()
.text_sm()
.text_color(cx.theme().muted_foreground)
.child(title),
)
// 旧壳组标题占 26px,首行位于标题顶端下方 42px;中间这 16px
// 是形成分组呼吸感的关键,不能交给组件默认 gap 随意变化。
.child(div().h(px(SETTINGS_GROUP_TITLE_GAP)).flex_shrink_0())
}
fn row(label: &'static str, control: impl IntoElement) -> impl IntoElement {
h_flex().w_full().items_center().child(div().flex_1().text_sm().child(label)).child(control)
h_flex()
.w_full()
.h(px(SETTINGS_ROW_HEIGHT))
.flex_shrink_0()
.items_center()
.child(div().flex_1().min_w_0().text_sm().child(label))
.child(control)
}
fn select_row(&self, key: &'static str, label: &'static str) -> impl IntoElement {
@@ -863,14 +865,7 @@ impl SettingsPane {
.w(px(220.0))
.gap_2()
.items_center()
.child(
div()
.w(px(18.0))
.flex_shrink_0()
.font_family(family)
.text_sm()
.child(glyph),
)
.child(div().w(px(18.0)).flex_shrink_0().font_family(family).text_sm().child(glyph))
.child(div().flex_1().min_w_0().children(select.map(|state| Select::new(&state)))),
)
}
@@ -879,10 +874,11 @@ impl SettingsPane {
let theme = chrome_theme(self.runtime.theme);
let palette = theme.palette();
let ink = theme.card_ink().fg;
let background = self
.runtime
.background
.unwrap_or([palette.term_bg.r, palette.term_bg.g, palette.term_bg.b]);
let background = self.runtime.background.unwrap_or([
palette.term_bg.r,
palette.term_bg.g,
palette.term_bg.b,
]);
let family: SharedString = cx
.try_global::<crate::gpui_shell::config::Settings>()
.map(|settings| settings.font_family.clone())
@@ -894,8 +890,7 @@ impl SettingsPane {
v_flex()
.w_full()
.max_w(px(680.0))
.h(px(118.0))
.h(px(150.0))
.p_4()
.gap_1()
.rounded_lg()
@@ -904,7 +899,11 @@ impl SettingsPane {
.text_size(px(size))
.text_color(foreground)
.child("user@nebula ~ $ nebula --version")
.child(format!("Nebula Terminal · {} · {:.0}px", self.runtime.font_family.as_deref().unwrap_or("Maple Mono Normal NF CN"), size))
.child(format!(
"Nebula Terminal · {} · {:.0}px",
self.runtime.font_family.as_deref().unwrap_or("Maple Mono Normal NF CN"),
size
))
.child(
h_flex()
.gap_1()
@@ -915,73 +914,75 @@ impl SettingsPane {
}
fn theme_previews(&self, cx: &mut Context<Self>) -> gpui::Div {
h_flex().w_full().flex_wrap().gap_3().children(THEME_NAMES.into_iter().map(|name| {
h_flex().w_full().flex_wrap().gap(px(20.0)).children(THEME_NAMES.into_iter().map(|name| {
let theme = chrome_theme(name);
let palette = theme.palette();
let accent = theme.accent();
let selected = self.runtime.theme == name;
div()
v_flex()
.id(SharedString::from(format!("theme-preview-{}", name.prompt_name())))
.w(px(142.0))
.w(px(170.0))
.h(px(92.0))
.p_2()
.flex()
.flex_col()
.gap_2()
.rounded_lg()
.cursor_pointer()
.bg(rgb_hsla(palette.shell_bg.r, palette.shell_bg.g, palette.shell_bg.b))
.when(selected, |card| {
card.border_1().border_color(rgb_hsla(accent.r, accent.g, accent.b))
})
.when(!selected, |card| card.hover(|style| style.shadow_md()))
.child(
v_flex()
.flex_1()
.h(px(64.0))
.flex_shrink_0()
.p_2()
.gap_1()
.rounded_md()
.bg(rgb_hsla(palette.term_bg.r, palette.term_bg.g, palette.term_bg.b))
.rounded_lg()
.bg(rgb_hsla(palette.shell_bg.r, palette.shell_bg.g, palette.shell_bg.b))
.when(selected, |card| {
card.border_1().border_color(rgb_hsla(accent.r, accent.g, accent.b))
})
.when(!selected, |card| card.hover(|style| style.shadow_md()))
.child(
h_flex()
v_flex()
.flex_1()
.p_2()
.gap_1()
.rounded_md()
.bg(rgb_hsla(
palette.term_bg.r,
palette.term_bg.g,
palette.term_bg.b,
))
.child(
div()
.w(px(10.0))
.h(px(4.0))
.rounded_full()
.bg(rgb_hsla(accent.r, accent.g, accent.b)),
)
.child(
div()
.w(px(52.0))
.h(px(4.0))
.rounded_full()
.bg(rgb_hsla(
theme.card_ink().fg.r,
theme.card_ink().fg.g,
theme.card_ink().fg.b,
h_flex()
.gap_1()
.child(
div()
.w(px(10.0))
.h(px(4.0))
.rounded_full()
.bg(rgb_hsla(accent.r, accent.g, accent.b)),
)
.child(div().w(px(52.0)).h(px(4.0)).rounded_full().bg(
rgb_hsla(
theme.card_ink().fg.r,
theme.card_ink().fg.g,
theme.card_ink().fg.b,
),
)),
),
)
.child(
div()
.w(px(82.0))
.h(px(4.0))
.rounded_full()
.bg(rgb_hsla(palette.edge_l.r, palette.edge_l.g, palette.edge_l.b)),
)
.child(
div()
.w(px(58.0))
.h(px(4.0))
.rounded_full()
.bg(rgb_hsla(palette.edge_r.r, palette.edge_r.g, palette.edge_r.b)),
)
.child(div().w(px(82.0)).h(px(4.0)).rounded_full().bg(rgb_hsla(
palette.edge_l.r,
palette.edge_l.g,
palette.edge_l.b,
)))
.child(div().w(px(58.0)).h(px(4.0)).rounded_full().bg(rgb_hsla(
palette.edge_r.r,
palette.edge_r.g,
palette.edge_r.b,
))),
),
)
.child(
div()
.w_full()
.text_xs()
.text_center()
.text_color(if selected {
rgb_hsla(accent.r, accent.g, accent.b)
} else {
@@ -1257,40 +1258,27 @@ impl SettingsPane {
let opacity: SharedString = format!("{:.0}%", self.runtime.opacity * 100.0).into();
let wallpaper_opacity: SharedString =
format!("{:.0}%", self.runtime.background_image_opacity * 100.0).into();
self.group("外观", cx)
.child(self.appearance_preview(cx))
.child(self.theme_previews(cx))
.child(self.select_row("theme", "主题"))
.child(self.switch_row(
"follow_system_theme",
"跟随系统外观自动切换深浅",
self.runtime.follow_system_theme,
cx,
))
let preview = self.group("预览", cx).child(self.appearance_preview(cx));
let themes = self.group("主题", cx).child(self.theme_previews(cx));
let theme_mode = self.group("主题模式", cx).child(self.switch_row(
"follow_system_theme",
"跟随系统明暗模式",
self.runtime.follow_system_theme,
cx,
));
let custom_background = self
.group("自定义背景", cx)
.child(self.input_row("背景色", "background", &self.bg_input.clone(), cx))
.child(self.input_row(
"终端背景色(覆盖主题)",
"background",
&self.bg_input.clone(),
"背景图片",
"background_image",
&self.wallpaper_input.clone(),
cx,
))
.child(self.select_row("background_image_fit", "背景图像拉伸模式"))
.child(self.select_row("background_image_alignment", "背景图像对齐"))
.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.switch_row("blur", "背景模糊", self.runtime.blur, cx))
.child(self.input_row("背景图", "background_image", &self.wallpaper_input.clone(), cx))
.child(self.stepper_row(
"背景图不透明度",
"背景图像不透明度",
"bgimg-opacity",
wallpaper_opacity,
|this, cx| {
@@ -1303,17 +1291,44 @@ impl SettingsPane {
},
cx,
))
.child(self.select_row("background_image_fit", "背景图适配"))
.child(self.select_row("background_image_alignment", "背景图对齐"))
.child(self.switch_row(
"background_image_cover_chrome",
"背景图铺满整窗(含侧栏/标题栏)",
"将背景图扩展到标题栏和侧边栏",
self.runtime.background_image_cover_chrome,
cx,
))
.child(self.select_row("density", "界面密度"))
));
let cursor = self
.group("光标", cx)
.child(self.select_row("cursor_shape", "光标形状"))
.child(self.switch_row(
"cursor_blink",
"光标闪烁",
self.runtime.cursor_blink.unwrap_or(true),
cx,
));
let interface = self
.group("界面", cx)
.child(self.select_row("language", "语言"))
.child(self.select_row("density", "界面外观"))
.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.switch_row("blur", "背景模糊", self.runtime.blur, cx));
let terminal = self
.group("终端外观", cx)
.child(self.stepper_row(
"终端字号(Ctrl+滚轮缩放)",
"font-size",
font_size,
|this, cx| {
@@ -1326,33 +1341,38 @@ impl SettingsPane {
},
cx,
))
.child(self.select_row("cursor_shape", "光标形状(新标签页生效)"))
.child(self.switch_row(
"cursor_blink",
"光标闪烁",
self.runtime.cursor_blink.unwrap_or(true),
cx,
))
.child(self.select_row("cell_width_mode", "字体间距"))
.child(self.switch_row("fetch", "启动欢迎信息", self.runtime.fetch, cx))
.child(self.switch_row("powerline", "Powerline 提示符", self.runtime.powerline, cx));
v_flex()
.w_full()
.gap(px(SETTINGS_GROUP_GAP))
.child(preview)
.child(themes)
.child(theme_mode)
.child(custom_background)
.child(cursor)
.child(interface)
.child(terminal)
}
fn section_profiles(&mut self, cx: &mut Context<Self>) -> gpui::Div {
self.group("配置文件", cx)
.child(self.select_row("language", "界面语言"))
let terminal = self
.group("终端", cx)
.child(self.shell_select_row(cx))
.child(self.input_row(
"新标签启动目录",
"startup_directory",
&self.dir_input.clone(),
cx,
))
.child(self.font_picker_row(cx))
.when(self.font_picker_open, |group| {
let panel = self.font_picker_panel(cx);
group.child(panel)
})
.child(self.input_row("启动目录", "startup_directory", &self.dir_input.clone(), cx))
.child(self.font_picker_row(cx));
let terminal = terminal.when(self.font_picker_open, |group| {
let panel = self.font_picker_panel(cx);
group.child(panel)
});
let completion = self
.group("补全", cx)
.child(self.switch_row("ghost", "启用命令补全", self.runtime.ghost, cx))
.child(self.select_row("accept", "补全接受键"))
.child(self.select_row("completion_style", "补全样式"))
.child(self.select_row("completion_style", "补全样式"));
v_flex().w_full().gap(px(SETTINGS_GROUP_GAP)).child(terminal).child(completion)
}
fn section_providers(&mut self, cx: &mut Context<Self>) -> gpui::Div {
@@ -2225,7 +2245,6 @@ impl SettingsPane {
))
.child(self.select_row("tab_reveal", "标签展开动效"))
.child(self.select_row("new_tab_position", "新标签位置"))
.child(self.select_row("cell_width_mode", "单元格宽度"))
.child(self.switch_row(
"panel_resize",
"拖拽调节侧栏宽度",
@@ -2255,13 +2274,6 @@ impl SettingsPane {
fn section_advanced(&mut self, cx: &mut Context<Self>) -> gpui::Div {
self.group("高级", cx)
.child(self.switch_row("fetch", "新会话欢迎屏 fastfetch", self.runtime.fetch, cx))
.child(self.switch_row(
"powerline",
"Powerline 提示符(新会话生效)",
self.runtime.powerline,
cx,
))
.child(self.switch_row(
"keep_session",
"关窗后保留后台会话",
@@ -2306,17 +2318,33 @@ impl SettingsPane {
let active_bg = crate::gpui_shell::theme::settings_hover_bg(cx, true);
let active_fg = theme.sidebar_accent_foreground;
let hover_bg = crate::gpui_shell::theme::settings_hover_bg(cx, false);
// 行高走 token 阶梯(旧壳导航行同档):34px 挤得读不出层级,也把
// 点击热区压到了 token 规定的最小命中之下。
let row_h = px(crate::display::ui::tokens::control::ROW);
let row_h = px(32.0);
let mut nav = v_flex().w(px(174.0)).h_full().flex_shrink_0().p_3().gap_1();
// 86 + 根容器的 2px gap = 旧壳 nav_y0(88)。标题和导航从此共享
// 同一坐标节奏,连接/系统 caption 也精确占用一个 24px 槽位。
let mut nav =
v_flex().w(px(SETTINGS_NAV_WIDTH)).h_full().flex_shrink_0().px_2().gap(px(2.0)).child(
div()
.h(px(86.0))
.px_3()
.pt_5()
.text_xl()
.font_weight(gpui::FontWeight::MEDIUM)
.child("Nebula 设置"),
);
for (caption, members) in NAV_GROUPS {
if let Some(caption) = caption {
// 分组标题(旧壳 nav_groups 的「连接」/「系统」):只做视觉
// 分隔,不参与命中;上留白比下大,标题贴住它统领的那一组。
// 根容器在 caption 后还会补 2px gap,所以正文高度取 22px,
// 合计正好复刻旧壳 `nav_groups` 的 24px 占位。
nav = nav.child(
div().px_3().pt_4().pb_1().text_xs().text_color(muted).child(caption),
div()
.h(px(22.0))
.px_3()
.flex()
.items_center()
.text_xs()
.text_color(muted)
.child(caption),
);
}
for &ix in members {
@@ -2324,7 +2352,7 @@ impl SettingsPane {
nav = nav.child(
div()
.id(("settings-nav", ix))
.px_3()
.px_2()
.h(row_h)
.flex()
.items_center()
@@ -2333,18 +2361,16 @@ impl SettingsPane {
.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))
})
.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(
Icon::new(section_icon(ix))
.small()
.text_color(if active { active_fg } else { muted }),
)
.child(Icon::new(section_icon(ix)).small().text_color(if active {
active_fg
} else {
muted
}))
.child(SECTIONS[ix]),
);
}
@@ -2352,37 +2378,6 @@ impl SettingsPane {
nav.into_any_element()
}
}
.flex_shrink_0()
.p_3()
.gap_2()
.children(SECTIONS.iter().enumerate().map(|(ix, label)| {
let active = ix == self.active_section;
div()
.id(("settings-nav", ix))
.px_3()
.h(px(34.0))
.flex()
.items_center()
.gap_2()
.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(
Icon::new(section_icon(ix))
.small()
.text_color(if active { active_fg } else { muted }),
)
.child(*label)
}))
.into_any_element()
}
}
impl EventEmitter<SettingsPaneEvent> for SettingsPane {}
@@ -2417,22 +2412,38 @@ impl Render for SettingsPane {
.flex_1()
.min_w_0()
.h_full()
.p_8()
.gap_6()
.overflow_y_scrollbar()
// 旧壳标题栏固定 72px;正文单独滚动,滚动设置时仍能知道
// 自己在哪个分区,也不会让标题与首组的距离随内容变化。
.child(
div()
.h(px(SETTINGS_HEADER_HEIGHT))
.flex_shrink_0()
.px_6()
.flex()
.items_center()
.text_xl()
.font_weight(gpui::FontWeight::SEMIBOLD)
.child(SharedString::from(format!(
"设置 · {}",
SECTIONS[self.active_section]
))),
.child(SECTIONS[self.active_section]),
)
.child(
v_flex()
.flex_1()
.min_h_0()
.px_6()
.pt_8()
.pb_8()
.overflow_y_scrollbar()
.child(content)
.child(
div()
.pt_8()
.text_xs()
.text_color(cx.theme().muted_foreground)
.child(
"写入 nebula_settings.txt,与旧壳共享同一份设置;两边可交替修改。",
),
),
)
.child(content)
.child(div().text_xs().text_color(cx.theme().muted_foreground).child(
"写入 nebula_settings.txt,与旧壳共享同一份设置;两边可交替修改。",
)),
)
}
}