diff --git a/architecture/notes/nebula_settings/terminal_line_height/2026-10-03-natural-height-override.md b/architecture/notes/nebula_settings/terminal_line_height/2026-10-03-natural-height-override.md new file mode 100644 index 000000000..5b7f0675e --- /dev/null +++ b/architecture/notes/nebula_settings/terminal_line_height/2026-10-03-natural-height-override.md @@ -0,0 +1,39 @@ +# Natural terminal line-height override + +## Status +Proposed in the Issue #439 implementation. + +## Context +Users need independent terminal spacing without editing a custom theme. + +## Evidence +Terminal typography previously used shaped ascent plus descent, or the custom +theme's font-size multiplier, followed by the configured device-pixel offset. + +## Decision +`terminal_line_height` is optional, finite, clamped to 0.50–5.00, and rounded to +two decimal places by nebula-settings. Explicit values multiply natural shaped +font height and override theme line height. Auto removes the setting and preserves +existing theme/natural geometry. The editor starts at 1.00; no default migration +changes existing spacing. Device-pixel offsets and rounding remain unchanged. + +## Rejected alternatives +A default 1.40 would silently expand existing grids. Reusing the theme key would +change its established font-size semantics. Separate pointer/selection geometry +would duplicate the terminal cell-height authority. + +## Consequences +Startup and live layout call the same typography function. Prepaint writes the +new cell height into the grid, PTY viewport and pointer/selection layout together. +Existing settings reset removes the explicit override. + +## Validation +Regression coverage checks persistence, precision/range, reset, editor commit/ +cancel/blur, theme precedence and device-pixel geometry. Execution is delegated +to GitHub Actions; native visual acceptance remains separate. + +## Supersedes +None. + +## Revisit when +Font metrics or theme line-height semantics change. diff --git a/nebula_app/i18n/en-US.json b/nebula_app/i18n/en-US.json index b6f291b06..78e6f3299 100644 --- a/nebula_app/i18n/en-US.json +++ b/nebula_app/i18n/en-US.json @@ -1551,6 +1551,11 @@ "changed": "The package changed after inspection. Choose the file again before installing." } }, + "terminal_line_height": { + "label": "Line height", + "auto": "Auto", + "description": "0.50–5.00; 1.00 uses the font’s natural height. An explicit value overrides theme line height. Auto preserves the theme or natural height." + }, "ssh.ports.button": "Ports", "ssh.ports.title": "SSH port forwarding", "ssh.ports.empty": "No forwarded ports", diff --git a/nebula_app/i18n/zh-CN.json b/nebula_app/i18n/zh-CN.json index 97b219665..e240ecb01 100644 --- a/nebula_app/i18n/zh-CN.json +++ b/nebula_app/i18n/zh-CN.json @@ -1551,6 +1551,11 @@ "changed": "主题包在检查后发生了变化。请重新选择文件,再确认安装。" } }, + "terminal_line_height": { + "label": "行高", + "auto": "自动", + "description": "0.50–5.00;1.00 为字体自然高度。显式值覆盖主题行高,自动保留主题或自然高度。" + }, "ssh.ports.button": "端口", "ssh.ports.title": "SSH 端口转发", "ssh.ports.empty": "暂无端口转发", diff --git a/nebula_app/src/gpui_shell/config.rs b/nebula_app/src/gpui_shell/config.rs index c7859e911..c99563fdd 100644 --- a/nebula_app/src/gpui_shell/config.rs +++ b/nebula_app/src/gpui_shell/config.rs @@ -66,6 +66,7 @@ pub struct Settings { /// Optional theme line-height multiplier. `None` keeps the shaped font /// metrics, while `Some(multiplier)` is resolved as multiplier * font size. pub(crate) theme_line_height: Option, + pub(crate) terminal_line_height: Option, /// Effective window material values after user settings and theme defaults /// have been merged once during settings loading. pub(crate) visual_opacity: f32, @@ -282,6 +283,7 @@ impl Settings { font_offset_x: f32::from(offset.x), font_offset_y: f32::from(offset.y), theme_line_height, + terminal_line_height: runtime.terminal_line_height, visual_opacity, visual_blur, palette, diff --git a/nebula_app/src/gpui_shell/settings_pane.rs b/nebula_app/src/gpui_shell/settings_pane.rs index 5b23557d5..0d6d5886e 100644 --- a/nebula_app/src/gpui_shell/settings_pane.rs +++ b/nebula_app/src/gpui_shell/settings_pane.rs @@ -230,6 +230,7 @@ pub struct SettingsPane { font_family_cjk_input: Entity, font_size_input: Entity, font_size_editing: Option, + line_height_editing: bool, /// 字体输入框上一帧的窗口坐标。字体目录是宽弹层,不能把整条设置行当 /// 锚点;否则输入框在右侧、菜单却会从正文左缘展开。 font_picker_trigger_bounds: Option>, diff --git a/nebula_app/src/gpui_shell/settings_pane/appearance.rs b/nebula_app/src/gpui_shell/settings_pane/appearance.rs index 4febe81f9..1c5a03756 100644 --- a/nebula_app/src/gpui_shell/settings_pane/appearance.rs +++ b/nebula_app/src/gpui_shell/settings_pane/appearance.rs @@ -9,13 +9,19 @@ impl SettingsPane { cx: &mut Context, ) { let Some(ui) = self.font_size_editing.take() else { return }; + let line_height = std::mem::take(&mut self.line_height_editing); if apply { let value = self.font_size_input.read(cx).value(); if let Ok(size) = value.trim().parse::() { if size.is_finite() { let (key, min, max) = if ui { ("ui_font_size", 10.0, 24.0) } else { ("font_size", 4.0, 96.0) }; - self.persist(&[(key, format!("{:.2}", size.clamp(min, max)))], cx); + if line_height { + let height = nebula_settings::normalize_terminal_line_height(size); + self.persist(&[("terminal_line_height", format!("{height:.2}"))], cx); + } else { + self.persist(&[(key, format!("{:.2}", size.clamp(min, max)))], cx); + } } } } @@ -147,7 +153,7 @@ impl SettingsPane { this.persist(&[(key, format!("{next:.2}"))], cx); })), ) - .child(if self.font_size_editing == Some(ui) { + .child(if self.font_size_editing == Some(ui) && !self.line_height_editing { Input::new(&self.font_size_input) .appearance(false) .focus_bordered(false) @@ -168,6 +174,7 @@ impl SettingsPane { .h(px(34.0)) .label(format!("{size} px")) .on_click(cx.listener(move |this, _, window, cx| { + this.line_height_editing = false; this.font_size_editing = Some(ui); this.font_size_input.update(cx, |input, cx| { let value = size.to_string(); @@ -210,6 +217,67 @@ impl SettingsPane { .into_any_element() } + pub(super) fn terminal_line_height_row(&self, cx: &Context) -> gpui::AnyElement { + use crate::i18n::Message; + let language = crate::gpui_shell::config::ui_language(cx); + let value = self.runtime.terminal_line_height; + let control = h_flex() + .gap(px(4.0)) + .child(if self.line_height_editing { + Input::new(&self.font_size_input) + .appearance(false) + .cleanable(false) + .w(px(90.0)) + .h(px(34.0)) + .aria_label(language.text(Message::TerminalLineHeightLabel)) + .into_any_element() + } else { + Button::new("terminal-line-height-edit") + .debug_selector(|| "terminal-line-height-edit".to_owned()) + .ghost() + .w(px(90.0)) + .h(px(34.0)) + .label(value.map(|value| format!("{value:.2}")).unwrap_or_else(|| { + language.text(Message::TerminalLineHeightAuto).to_owned() + })) + .on_click(cx.listener(move |this, _, window, cx| { + this.font_size_editing = Some(false); + this.line_height_editing = true; + this.font_size_input.update(cx, |input, cx| { + let value = format!( + "{:.2}", + value.unwrap_or(nebula_settings::DEFAULT_TERMINAL_LINE_HEIGHT) + ); + input.set_value(value.clone(), window, cx); + input.focus(window, cx); + input.set_selected_range(0..value.len(), cx); + }); + cx.notify(); + })) + .into_any_element() + }) + .child( + Button::new("terminal-line-height-reset") + .debug_selector(|| "terminal-line-height-reset".to_owned()) + .ghost() + .label(language.text(Message::TerminalLineHeightAuto)) + .disabled(value.is_none()) + .on_click(cx.listener(|this, _, window, cx| { + if this.line_height_editing { + this.finish_font_size_edit(false, window, cx); + } + this.persist(&[("terminal_line_height", String::new())], cx); + })), + ); + self.row( + language.text(Message::TerminalLineHeightLabel), + language.text(Message::TerminalLineHeightDescription), + control, + cx, + ) + .into_any_element() + } + pub(super) fn section_appearance( &mut self, window: &mut Window, @@ -289,11 +357,20 @@ mod tests { ("ui_font_size", "17", "tab", 17.0), ("ui_font_size", "18", "shift-tab", 18.0), ("ui_font_size", "16", "blur", 16.0), + ("terminal_line_height", "1.456", "enter", 1.46), + ("terminal_line_height", "2", "escape", 1.46), + ("terminal_line_height", "NaN", "enter", 1.46), + ("terminal_line_height", "99", "tab", 5.0), + ("terminal_line_height", "0", "blur", 0.5), ] { cx.update(|window, cx| { let _ = window.draw(cx); }); - let selector = if key == "font_size" { "font_size-edit" } else { "ui_font_size-edit" }; + let selector = match key { + "font_size" => "font_size-edit", + "terminal_line_height" => "terminal-line-height-edit", + _ => "ui_font_size-edit", + }; let bounds = cx.debug_bounds(selector).unwrap(); // Increasing the interface size can move this row below the fold. // Reveal it with the same wheel path a user takes before clicking. @@ -359,6 +436,8 @@ mod tests { ); let actual = if key == "font_size" { pane.terminal_font_size_px(cx) + } else if key == "terminal_line_height" { + pane.runtime.terminal_line_height.unwrap() } else { pane.font_size_px(cx) }; @@ -368,10 +447,41 @@ mod tests { if key == "font_size" { assert_eq!(saved.font_size_px, Some(expected)); assert_eq!(saved.ui_font_size_px, Some(14.0)); + } else if key == "terminal_line_height" { + assert_eq!(saved.terminal_line_height, Some(expected)); + assert_eq!(saved.font_size_px, Some(4.0)); } else { assert_eq!(saved.font_size_px, Some(4.0)); assert_eq!(saved.ui_font_size_px, Some(expected)); } } + cx.update(|window, cx| { + let _ = window.draw(cx); + }); + let edit = cx.debug_bounds("terminal-line-height-edit").unwrap(); + cx.simulate_click(edit.center(), gpui::Modifiers::default()); + cx.run_until_parked(); + cx.update(|window, cx| { + let _ = window.draw(cx); + }); + cx.simulate_input("2"); + cx.update(|window, cx| { + let _ = window.draw(cx); + }); + let bounds = cx.debug_bounds("terminal-line-height-reset").unwrap(); + cx.simulate_click(bounds.center(), gpui::Modifiers::default()); + cx.run_until_parked(); + assert!(RuntimeSettings::load().terminal_line_height.is_none()); + cx.simulate_keystrokes("enter"); + cx.update(|window, cx| { + pane.read(cx).settings_search_input.read(cx).focus_handle(cx).focus(window, cx); + let _ = window.draw(cx); + }); + cx.run_until_parked(); + assert!(RuntimeSettings::load().terminal_line_height.is_none()); + pane.read_with(cx, |pane, _| { + assert!(pane.font_size_editing.is_none()); + assert!(!pane.line_height_editing); + }); } } diff --git a/nebula_app/src/gpui_shell/settings_pane/appearance_advanced.rs b/nebula_app/src/gpui_shell/settings_pane/appearance_advanced.rs index 4b4d820d6..4e8fdfe15 100644 --- a/nebula_app/src/gpui_shell/settings_pane/appearance_advanced.rs +++ b/nebula_app/src/gpui_shell/settings_pane/appearance_advanced.rs @@ -139,6 +139,7 @@ impl SettingsPane { cx, )) .child(self.font_size_row(false, cx)) + .child(self.terminal_line_height_row(cx)) .child(self.switch_row( "ctrl_wheel_font_zoom", language.text(crate::i18n::Message::SettingsFontCtrlWheelZoom), diff --git a/nebula_app/src/gpui_shell/settings_pane/initialization.rs b/nebula_app/src/gpui_shell/settings_pane/initialization.rs index 4162604ff..48520baea 100644 --- a/nebula_app/src/gpui_shell/settings_pane/initialization.rs +++ b/nebula_app/src/gpui_shell/settings_pane/initialization.rs @@ -729,6 +729,7 @@ impl SettingsPane { font_family_cjk_input, font_size_input, font_size_editing: None, + line_height_editing: false, font_picker_trigger_bounds: None, font_picker_cjk_bounds: None, backup_selection: backup_remote.selection, diff --git a/nebula_app/src/gpui_shell/terminal/view.rs b/nebula_app/src/gpui_shell/terminal/view.rs index 91070b5eb..686341e0e 100644 --- a/nebula_app/src/gpui_shell/terminal/view.rs +++ b/nebula_app/src/gpui_shell/terminal/view.rs @@ -295,6 +295,7 @@ pub struct TerminalView { /// Optional theme line-height multiplier. `None` preserves the shaped /// font metrics; a value is applied to the logical font size. line_height_multiplier: Option, + terminal_line_height: Option, pub palette: Arc, /// 把**应用写死的**颜色按当前主题矫正(最低对比度 + 旧主题表面重映射)。 /// @@ -492,6 +493,7 @@ impl TerminalView { typography::line_height_for_view( self.font_size, self.line_height_multiplier, + self.terminal_line_height, natural_height, self.font_offset_y, scale, @@ -986,6 +988,7 @@ impl TerminalView { self.font_offset_x = settings.font_offset_x; self.font_offset_y = settings.font_offset_y; self.line_height_multiplier = settings.theme_line_height; + self.terminal_line_height = settings.terminal_line_height; // 底色换了就把矫正缓存作废,并记下「旧底色 → 新底色」这一跳:应用当初 // 按旧主题底色画的连续表面(面板、状态栏)要跟着搬过去,否则浅色主题上 // 会留一整块旧的深色板。旧壳 `apply_nebula_theme` 同一时机做同一件事。 diff --git a/nebula_app/src/gpui_shell/terminal/view/activity_tests.rs b/nebula_app/src/gpui_shell/terminal/view/activity_tests.rs index dec49d6a9..d4e403958 100644 --- a/nebula_app/src/gpui_shell/terminal/view/activity_tests.rs +++ b/nebula_app/src/gpui_shell/terminal/view/activity_tests.rs @@ -273,3 +273,36 @@ fn claude_shift_enter_reaches_pty_without_committing_shell_history(cx: &mut Test .collect(); assert_eq!(bytes, b"\n"); } + +#[gpui::test] +fn line_height_hot_apply_updates_grid_and_pointer_geometry(cx: &mut TestAppContext) { + let (view, window, receiver) = open(cx); + view.update(window, |view, cx| { + view.grid_synced = true; + let content = gpui::size(px(800.0), px(400.0)); + for multiplier in [1.0, 2.0, 0.5] { + cx.global_mut::().terminal_line_height = Some(multiplier); + view.apply_settings(cx); + let height = view.line_height_for_metrics(20.0, 1.0); + view.set_layout(gpui::point(px(0.0), px(0.0)), px(10.0), height, content, 1.0, cx); + assert_eq!(view.rows, (400.0 / height.as_f32()).floor() as usize); + assert_eq!(view.line_height, height); + let size = receiver + .try_iter() + .find_map(|message| match message { + Msg::ResizeGrid(size) => Some(size), + _ => None, + }) + .expect("changed geometry must reach the terminal event loop"); + assert_eq!(size.num_lines as usize, view.rows); + assert_eq!(size.num_cols as usize, view.cols); + // This fixture has no I/O pump; consume its queued resize as the event loop does. + view.session.as_ref().unwrap().term.lock().resize(size); + let (point, _) = view.grid_point(gpui::point(px(15.0), height * 2.5)); + assert_eq!(point.line, nebula_terminal::index::Line(2)); + assert_eq!(point.column, nebula_terminal::index::Column(1)); + let term = view.session.as_ref().unwrap().term.lock(); + assert_eq!(term.screen_lines(), view.rows); + } + }); +} diff --git a/nebula_app/src/gpui_shell/terminal/view/pointer/tests.rs b/nebula_app/src/gpui_shell/terminal/view/pointer/tests.rs index b1c385e09..1f21113b3 100644 --- a/nebula_app/src/gpui_shell/terminal/view/pointer/tests.rs +++ b/nebula_app/src/gpui_shell/terminal/view/pointer/tests.rs @@ -39,7 +39,11 @@ impl Render for Probe { fn open(cx: &mut TestAppContext) -> (Entity, VisualTestContext) { cx.update(|cx| { gpui_component::init(cx); - let mut settings = Settings::load(nebula_settings::ThemeName::Nord); + // Settings UI fixtures write real preferences concurrently under nextest. + let runtime = nebula_settings::RuntimeSettings::from_raw( + &nebula_settings::RawSettings::from_text("font_size=15\nfollow_system_theme=0\n"), + ); + let mut settings = Settings::load_with_runtime(nebula_settings::ThemeName::Nord, runtime); settings.focus_follows_mouse = false; cx.set_global(settings); }); diff --git a/nebula_app/src/gpui_shell/terminal/view/startup.rs b/nebula_app/src/gpui_shell/terminal/view/startup.rs index ec3ff42c7..33e872405 100644 --- a/nebula_app/src/gpui_shell/terminal/view/startup.rs +++ b/nebula_app/src/gpui_shell/terminal/view/startup.rs @@ -40,6 +40,7 @@ impl TerminalView { font_offset_x, font_offset_y, line_height_multiplier, + terminal_line_height, palette, term_config, copy_on_select, @@ -57,6 +58,7 @@ impl TerminalView { settings.font_offset_x, settings.font_offset_y, settings.theme_line_height, + settings.terminal_line_height, Arc::new(settings.palette.clone()), settings.term_config(), settings.copy_on_select, @@ -76,6 +78,7 @@ impl TerminalView { 0.0, 0.0, None, + None, Arc::new(Palette::default()), nebula_terminal::term::Config::default(), // 旧壳的出厂默认即开。 @@ -294,6 +297,7 @@ impl TerminalView { font_offset_x, font_offset_y, line_height_multiplier, + terminal_line_height, palette, color_resolver: Default::default(), marked_text: None, diff --git a/nebula_app/src/gpui_shell/terminal/view/tests.rs b/nebula_app/src/gpui_shell/terminal/view/tests.rs index eb15c3a55..f1e457438 100644 --- a/nebula_app/src/gpui_shell/terminal/view/tests.rs +++ b/nebula_app/src/gpui_shell/terminal/view/tests.rs @@ -229,3 +229,20 @@ fn theme_line_height_uses_font_size_and_keeps_device_pixel_flooring() { assert!((f32::from(height) - 37.0 / 1.5).abs() < f32::EPSILON); } + +#[test] +fn explicit_line_height_uses_natural_metrics_and_preserves_theme_in_auto() { + use gpui::px; + for multiplier in [0.5, 1.0, 1.46, 5.0] { + assert_eq!( + typography::line_height_for_view(px(15.0), Some(1.5), Some(multiplier), 20.0, 4.0, 1.5,), + typography::effective_line_height(20.0 * multiplier, 4.0, 1.5), + ); + } + for theme in [None, Some(1.5)] { + assert_eq!( + typography::line_height_for_view(px(15.0), theme, None, 20.0, 4.0, 1.5), + typography::effective_line_height_with_theme(20.0, 15.0, theme, 4.0, 1.5), + ); + } +} diff --git a/nebula_app/src/gpui_shell/terminal/view/typography.rs b/nebula_app/src/gpui_shell/terminal/view/typography.rs index 7b4fe9222..cbe5798e5 100644 --- a/nebula_app/src/gpui_shell/terminal/view/typography.rs +++ b/nebula_app/src/gpui_shell/terminal/view/typography.rs @@ -73,15 +73,19 @@ pub(super) fn effective_line_height_with_theme( pub(super) fn line_height_for_view( font_size: Pixels, - line_height_multiplier: Option, + theme_line_height: Option, + terminal_line_height: Option, natural_height: f32, offset_y: f32, scale: f32, ) -> Pixels { + if let Some(multiplier) = terminal_line_height { + return effective_line_height(natural_height * multiplier, offset_y, scale); + } effective_line_height_with_theme( natural_height, font_size.as_f32(), - line_height_multiplier, + theme_line_height, offset_y, scale, ) @@ -96,6 +100,7 @@ fn measure_cell_metrics( offset_x: f32, offset_y: f32, line_height_multiplier: Option, + terminal_line_height: Option, ) -> (Pixels, Pixels) { let font = mono_font(family, FontWeight::NORMAL, FontStyle::Normal, false); let sample = text_system.shape_line( @@ -113,10 +118,11 @@ fn measure_cell_metrics( ); ( effective_cell_width(sample.width.as_f32(), mode, scale, offset_x), - effective_line_height_with_theme( - sample.ascent.as_f32() + sample.descent.as_f32(), - font_size.as_f32(), + line_height_for_view( + font_size, line_height_multiplier, + terminal_line_height, + sample.ascent.as_f32() + sample.descent.as_f32(), offset_y, scale, ), @@ -124,7 +130,7 @@ fn measure_cell_metrics( } pub(super) fn cell_metrics(window: &Window, cx: &App) -> (Pixels, Pixels) { - let (family, font_size, mode, offset_x, offset_y, line_height_multiplier) = + let (family, font_size, mode, offset_x, offset_y, line_height_multiplier, terminal_line_height) = match cx.try_global::() { Some(settings) => ( settings.font_family.as_str(), @@ -133,8 +139,9 @@ pub(super) fn cell_metrics(window: &Window, cx: &App) -> (Pixels, Pixels) { settings.font_offset_x, settings.font_offset_y, settings.theme_line_height, + settings.terminal_line_height, ), - None => (REQUIRED_FONT_FAMILY, 15.0, CellWidthModeName::Compact, 0.0, 0.0, None), + None => (REQUIRED_FONT_FAMILY, 15.0, CellWidthModeName::Compact, 0.0, 0.0, None, None), }; measure_cell_metrics( window.text_system(), @@ -145,6 +152,7 @@ pub(super) fn cell_metrics(window: &Window, cx: &App) -> (Pixels, Pixels) { offset_x, offset_y, line_height_multiplier, + terminal_line_height, ) } @@ -165,7 +173,7 @@ fn measure_startup_cell_metrics( scale: f32, cx: &App, ) -> (Pixels, Pixels) { - let (family, font_size, mode, offset_x, offset_y, line_height_multiplier) = + let (family, font_size, mode, offset_x, offset_y, line_height_multiplier, terminal_line_height) = match cx.try_global::() { Some(settings) => ( settings.font_family.as_str(), @@ -174,8 +182,9 @@ fn measure_startup_cell_metrics( settings.font_offset_x, settings.font_offset_y, settings.theme_line_height, + settings.terminal_line_height, ), - None => (REQUIRED_FONT_FAMILY, 15.0, CellWidthModeName::Compact, 0.0, 0.0, None), + None => (REQUIRED_FONT_FAMILY, 15.0, CellWidthModeName::Compact, 0.0, 0.0, None, None), }; measure_cell_metrics( text_system, @@ -186,5 +195,6 @@ fn measure_startup_cell_metrics( offset_x, offset_y, line_height_multiplier, + terminal_line_height, ) } diff --git a/nebula_settings/src/lib.rs b/nebula_settings/src/lib.rs index 3e660bcf3..c7f5cd780 100644 --- a/nebula_settings/src/lib.rs +++ b/nebula_settings/src/lib.rs @@ -44,6 +44,8 @@ pub use scrolling::{ DEFAULT_SCROLL_SPEED, DEFAULT_SCROLLBACK_LINES, MAX_SCROLL_SPEED, MIN_SCROLL_SPEED, SCROLL_SPEED_STEP, SCROLLBACK_VALUES, ScrollbarVisibility, normalize_scroll_speed, }; +mod terminal_line_height; +pub use terminal_line_height::{DEFAULT_TERMINAL_LINE_HEIGHT, normalize_terminal_line_height}; mod themes; pub use language::{LanguageInfo, LanguagePref}; pub use quick_terminal::{QuickTerminalMode, QuickTerminalSize}; @@ -997,6 +999,8 @@ pub struct RuntimeSettings { /// **逻辑像素**(旧壳写盘语义:设置页 spinner 与 Ctrl+滚轮缩放持久化时 /// 已除以 scale factor)。`None` = 跟随 nebula.toml 的 `font.size`(pt)。 pub font_size_px: Option, + /// Explicit natural-font-height multiplier; absent values follow theme typography. + pub terminal_line_height: Option, /// Ctrl+滚轮缩放终端字号。默认开启以保留既有行为;关闭时该手势被整体 /// 消费:既不缩放,也不回落成普通滚动。不影响普通滚轮与键盘字号快捷键。 pub ctrl_wheel_font_zoom: bool, @@ -1190,6 +1194,10 @@ impl RuntimeSettings { ui_font_family: raw.value("ui_font_family").map(str::to_owned), ui_font_size_px: raw.f32("ui_font_size").map(|size| size.clamp(10.0, 24.0)), font_size_px: raw.f32("font_size").map(|size| size.clamp(4.0, 96.0)), + terminal_line_height: raw + .f32("terminal_line_height") + .filter(|value| value.is_finite()) + .map(normalize_terminal_line_height), scrollbar_visibility: raw .value("scrollbar_visibility") .and_then(ScrollbarVisibility::from_settings) diff --git a/nebula_settings/src/reset.rs b/nebula_settings/src/reset.rs index a10d558da..a0dcc8476 100644 --- a/nebula_settings/src/reset.rs +++ b/nebula_settings/src/reset.rs @@ -17,6 +17,7 @@ const RESET_KEYS: &[&str] = &[ "ui_font_family", "ui_font_size", "font_size", + "terminal_line_height", "ctrl_wheel_font_zoom", "ligatures", "cursor_shape", @@ -150,6 +151,14 @@ mod tests { use super::*; use crate::{RawSettings, RuntimeSettings}; + #[test] + fn reset_restores_automatic_terminal_line_height() { + let restored = default_settings_text("terminal_line_height=1.50\ncustom=keep\n"); + let settings = RuntimeSettings::from_raw(&RawSettings::from_text(&restored)); + assert!(settings.terminal_line_height.is_none()); + assert!(restored.contains("custom=keep")); + } + #[test] fn reset_restores_auto_scrollbar_visibility() { let text = default_settings_text("scrollbar_visibility=always\ncustom=keep\n"); diff --git a/nebula_settings/src/terminal_line_height.rs b/nebula_settings/src/terminal_line_height.rs new file mode 100644 index 000000000..93840ac6d --- /dev/null +++ b/nebula_settings/src/terminal_line_height.rs @@ -0,0 +1,36 @@ +/// The editor starts at the natural shaped height; absent settings preserve typography. +pub const DEFAULT_TERMINAL_LINE_HEIGHT: f32 = 1.0; + +pub fn normalize_terminal_line_height(value: f32) -> f32 { + if value.is_finite() { + (value.clamp(0.5, 5.0) * 100.0).round() / 100.0 + } else { + DEFAULT_TERMINAL_LINE_HEIGHT + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{RawSettings, RuntimeSettings, apply_updates}; + + #[test] + fn line_height_round_trip_range_and_precision() { + assert!( + RuntimeSettings::from_raw(&RawSettings::from_text("")).terminal_line_height.is_none() + ); + for (input, expected) in [(0.1, 0.5), (1.0, 1.0), (1.456, 1.46), (6.0, 5.0)] { + let text = + apply_updates("font_size=18\n", &[("terminal_line_height", input.to_string())]); + let settings = RuntimeSettings::from_raw(&RawSettings::from_text(&text)); + assert_eq!(settings.terminal_line_height, Some(expected)); + assert_eq!(settings.font_size_px, Some(18.0)); + } + for input in ["NaN", "inf", "invalid"] { + let settings = RuntimeSettings::from_raw(&RawSettings::from_text(&format!( + "terminal_line_height={input}" + ))); + assert!(settings.terminal_line_height.is_none()); + } + } +}