From 894c6e87c63a760a9d9afc19aed12d7ecb94733a Mon Sep 17 00:00:00 2001 From: Sakyvo Date: Sat, 1 Aug 2026 17:49:49 +0800 Subject: [PATCH] feat: configurable terminal cell width mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增全局「字体间距」偏好:紧凑(上游兼容默认)或宽松。 终端把字体的非整数平均 advance 转成整像素列宽时,上游一律向下取整。同样的 字体与字号下,向下取整会少一像素。宽松改用最接近整数取整补上 这一像素,紧凑保持现有取整,因此现有列数与换行行为不变。 该偏好只控制终端内容网格的列宽:单元格高度在两种模式下逐位相同,字体家族、 字号、字重、字形比例与回退顺序均不受影响。`refresh_ui_font` 计算的 Nebula 原生界面字体单元格固定按向下取整,不随该偏好变化。 切换时重推当前字体,让既有的字体更新路径重算单元格,网格、viewport、pane、 渲染器与 PTY 一致更新,无需重启。 --- nebula_app/src/display/mod.rs | 130 ++++++++++++++++++++++++++--- nebula_app/src/display/settings.rs | 127 +++++++++++++++++++++++++++- nebula_app/src/input/chrome.rs | 15 ++++ nebula_app/src/input/mouse.rs | 2 + 4 files changed, 259 insertions(+), 15 deletions(-) diff --git a/nebula_app/src/display/mod.rs b/nebula_app/src/display/mod.rs index 06aadc1..abeb2a0 100644 --- a/nebula_app/src/display/mod.rs +++ b/nebula_app/src/display/mod.rs @@ -132,8 +132,8 @@ pub(crate) use ui::theme::write_nebula_prompt_theme; pub(crate) fn caret_blink_on() -> bool { ui::caret::is_on() } -pub(crate) use settings::SettingsOpacityTarget; pub use settings::{NebulaSettingsSection, SettingsDropdown, SettingsHit, settings_hit}; +pub(crate) use settings::{CellWidthMode, SettingsOpacityTarget}; /// 按显示列宽贪心断行(确认框正文等 UI 段落用):CJK 逐字可断,行首空 /// 格吞掉;零宽字符跟随前一个字。不做拉丁连词回退——正文以中文为主, @@ -1450,6 +1450,9 @@ pub struct Display { /// 捕获态实时回显:当前按住的修饰键前缀("Ctrl+Shift+")。松开清空。 pub nebula_keymap_capture_preview: String, pub nebula_tab_reveal_motion: settings::TabRevealMotion, + /// 单元格宽度模式。只作用于终端内容网格;Nebula 原生界面的字体单元格 + /// 始终按上游的向下取整计算,不随该偏好变化。 + pub nebula_cell_width_mode: settings::CellWidthMode, pub nebula_font_family: String, nebula_font_families: Vec, nebula_font_notice: Option, @@ -1707,7 +1710,8 @@ impl Display { crate::boot_trace("glyph cache (font faces loaded)"); let metrics = glyph_cache.font_metrics(); - let (cell_width, cell_height) = compute_cell_size(config, &metrics); + let (cell_width, cell_height) = + compute_cell_size(config, &metrics, settings_init.cell_width_mode); // Resize the window to the user-configured size, or a Windows // Terminal-like default when unset. A 116-column by 30-row canvas is @@ -1725,7 +1729,9 @@ impl Display { let base_font_size = config.font.size().scale(scale_factor); let (base_cell_width, base_cell_height) = glyph_cache .metrics_at(base_font_size) - .map(|base_metrics| compute_cell_size(config, &base_metrics)) + .map(|base_metrics| { + compute_cell_size(config, &base_metrics, settings_init.cell_width_mode) + }) .unwrap_or((cell_width, cell_height)); let size = window_size( config, @@ -1968,6 +1974,7 @@ impl Display { nebula_keymap_capture: None, nebula_keymap_capture_preview: String::new(), nebula_tab_reveal_motion: settings_init.tab_reveal, + nebula_cell_width_mode: settings_init.cell_width_mode, nebula_font_family: settings_init.font_family, nebula_font_families, nebula_font_notice: None, @@ -2992,6 +2999,7 @@ impl Display { panel_resize: self.nebula_panel_resize, cjk_bold_regular: self.nebula_cjk_bold_regular, tab_reveal: self.nebula_tab_reveal_motion, + cell_width_mode: self.nebula_cell_width_mode, preview_bg: self.preview_terminal_bg(), preview_fg: { let bg = self.preview_terminal_bg(); @@ -3234,6 +3242,22 @@ impl Display { self.pending_update.dirty = true; } + /// 切换单元格宽度模式。列宽取整方式变了就必须重算单元格——重推当前 + /// 字体让字体更新路径走一遍,网格、viewport、pane 与 PTY 随之一致更新, + /// 无需重启。字号、字体家族与行高都不变。 + pub fn set_cell_width_mode_option(&mut self, index: usize, base: &Font) { + if let Some(mode) = settings::CELL_WIDTH_MODE_OPTIONS.get(index) + && *mode != self.nebula_cell_width_mode + { + self.nebula_cell_width_mode = *mode; + self.persist_nebula_settings(); + let font = self.effective_font(base).with_size(self.font_size); + self.pending_update.set_font(font); + } + self.nebula_settings_dropdown = None; + self.pending_update.dirty = true; + } + /// Returns true when the default cursor style changed (the caller then /// pushes the new default into every live terminal). pub fn set_cursor_shape_option(&mut self, index: usize) -> bool { @@ -4518,7 +4542,10 @@ impl Display { fn refresh_ui_font(&mut self, config: &UiConfig) { let ui_size = FontSize::from_px(self.nebula_ui_font.px); let metrics = self.glyph_cache.set_ui_font_size(ui_size); - self.nebula_ui_font.cell = compute_cell_size(config, &metrics); + // 原生界面的字体单元格不受单元格宽度模式影响——该偏好只控制终端 + // 内容网格的列宽。这里固定用上游的向下取整。 + self.nebula_ui_font.cell = + compute_cell_size(config, &metrics, settings::CellWidthMode::Compact); let ratio = self.ui_text_scale(); // Unconditional breadcrumb (tiny, a handful of lines per session): // diagnosing "the sidebar zooms with the terminal" reports needs this @@ -4812,6 +4839,7 @@ impl Display { copy_on_select: self.nebula_copy_on_select, cjk_bold_regular: self.nebula_cjk_bold_regular, tab_reveal: self.nebula_tab_reveal_motion, + cell_width_mode: self.nebula_cell_width_mode, theme: self.nebula_theme_preference, follow_system_theme: self.nebula_follow_system_theme, pinned_hosts: self.nebula_pinned_hosts.clone(), @@ -4909,6 +4937,7 @@ impl Display { self.reset_glyph_cache(); } self.nebula_tab_reveal_motion = settings.tab_reveal; + self.nebula_cell_width_mode = settings.cell_width_mode; self.nebula_window_opacity = settings.opacity; self.nebula_background = if settings.follow_system_theme { Some(active_theme.palette().term_bg) @@ -5129,11 +5158,12 @@ impl Display { glyph_cache: &mut GlyphCache, config: &UiConfig, font: &Font, + cell_width_mode: settings::CellWidthMode, ) -> (f32, f32) { let _ = glyph_cache.update_font_size(font); // Compute new cell sizes. - compute_cell_size(config, &glyph_cache.font_metrics()) + compute_cell_size(config, &glyph_cache.font_metrics(), cell_width_mode) } /// Reset glyph cache. @@ -5173,7 +5203,9 @@ impl Display { // Update font size and cell dimensions. if let Some(font) = pending_update.font() { - let cell_dimensions = Self::update_font_size(&mut self.glyph_cache, config, font); + let cell_width_mode = self.nebula_cell_width_mode; + let cell_dimensions = + Self::update_font_size(&mut self.glyph_cache, config, font, cell_width_mode); cell_width = cell_dimensions.0; cell_height = cell_dimensions.1; @@ -8132,13 +8164,21 @@ impl FrameTimer { /// /// This will return a tuple of the cell width and height. #[inline] -fn compute_cell_size(config: &UiConfig, metrics: &crossfont::Metrics) -> (f32, f32) { +fn compute_cell_size( + config: &UiConfig, + metrics: &crossfont::Metrics, + cell_width_mode: settings::CellWidthMode, +) -> (f32, f32) { let offset_x = f64::from(config.font.offset.x); let offset_y = f64::from(config.font.offset.y); - ( - (metrics.average_advance + offset_x).floor().max(1.) as f32, - (metrics.line_height + offset_y).floor().max(1.) as f32, - ) + // 宽度取整方式由单元格宽度模式决定;高度始终向下取整,两种模式必须 + // 得到逐位相同的高度——该偏好只控制列宽。 + let raw_width = metrics.average_advance + offset_x; + let width = match cell_width_mode { + settings::CellWidthMode::Compact => raw_width.floor(), + settings::CellWidthMode::Relaxed => raw_width.round(), + }; + (width.max(1.) as f32, (metrics.line_height + offset_y).floor().max(1.) as f32) } /// Calculate the size of the window given padding, terminal dimensions and cell size. @@ -8177,11 +8217,77 @@ mod nebula_ux_tests { use super::{ AI_LOGO_GROK_DARK_PNG, AI_LOGO_GROK_LIGHT_PNG, AiLogo, NebulaConfirm, SizeInfo, ai_logo, - alt_screen_vertical_padding_bands, extract_program, nebula_command_hint, + alt_screen_vertical_padding_bands, compute_cell_size, extract_program, nebula_command_hint, percent_decode_lossy, prepare_ai_logo_texture, program_icon, remove_ssh_host_from_lists, replays_untrusted_terminal_output, restore_ssh_host_to_lists, strip_file_scheme, system_theme_snapshot, }; + use crate::config::UiConfig; + use crate::display::settings::CellWidthMode; + + /// 受控字体度量:只有 advance 与 line_height 参与单元格尺寸计算, + /// 其余字段取任意合法值。 + fn metrics(average_advance: f64, line_height: f64) -> crossfont::Metrics { + crossfont::Metrics { + average_advance, + line_height, + descent: -4.0, + underline_position: -2.0, + underline_thickness: 1.0, + strikeout_position: 5.0, + strikeout_thickness: 1.0, + } + } + + #[test] + fn relaxed_cell_width_rounds_up_the_fraction_compact_floors_it() { + let config = UiConfig::default(); + // Maple Mono NF CN 这类字体的平均 advance 常落在 .5 以上,紧凑向下 + // 取整因此比 Windows Terminal 少一像素——宽松就是为补这一像素而设。 + let m = metrics(9.6, 20.0); + assert_eq!(compute_cell_size(&config, &m, CellWidthMode::Compact).0, 9.0); + assert_eq!(compute_cell_size(&config, &m, CellWidthMode::Relaxed).0, 10.0); + } + + #[test] + fn a_fraction_below_half_stays_on_the_same_column_width_in_both_modes() { + let config = UiConfig::default(); + let m = metrics(9.4, 20.0); + assert_eq!(compute_cell_size(&config, &m, CellWidthMode::Compact).0, 9.0); + assert_eq!(compute_cell_size(&config, &m, CellWidthMode::Relaxed).0, 9.0); + } + + #[test] + fn the_exact_half_boundary_rounds_away_from_zero_in_relaxed_mode() { + let config = UiConfig::default(); + let m = metrics(9.5, 20.0); + assert_eq!(compute_cell_size(&config, &m, CellWidthMode::Compact).0, 9.0); + assert_eq!(compute_cell_size(&config, &m, CellWidthMode::Relaxed).0, 10.0); + } + + #[test] + fn both_modes_compute_the_same_cell_height() { + let config = UiConfig::default(); + // 该偏好只控制列宽;高度必须逐位相同,否则行距会随模式漂移。 + for (advance, line_height) in [(9.6, 20.7), (7.5, 16.5), (12.2, 25.9)] { + let m = metrics(advance, line_height); + let compact = compute_cell_size(&config, &m, CellWidthMode::Compact); + let relaxed = compute_cell_size(&config, &m, CellWidthMode::Relaxed); + assert_eq!(compact.1, relaxed.1, "line_height {line_height} 的高度在两模式间漂移"); + } + } + + #[test] + fn both_modes_share_the_same_minimum_cell_width() { + let config = UiConfig::default(); + // 退化度量(字体加载异常)不能产出 0 宽单元格——那会让网格除零。 + let m = metrics(0.3, 0.4); + let compact = compute_cell_size(&config, &m, CellWidthMode::Compact); + let relaxed = compute_cell_size(&config, &m, CellWidthMode::Relaxed); + assert_eq!(compact.0, 1.0); + assert_eq!(relaxed.0, 1.0); + assert_eq!(compact.1, relaxed.1, "退化度量下高度也不得随模式漂移"); + } fn strings(values: &[&str]) -> Vec { values.iter().map(|value| (*value).to_owned()).collect() diff --git a/nebula_app/src/display/settings.rs b/nebula_app/src/display/settings.rs index ef2a781..69b48d2 100644 --- a/nebula_app/src/display/settings.rs +++ b/nebula_app/src/display/settings.rs @@ -94,6 +94,7 @@ pub enum SettingsDropdown { Accept, CursorShape, TabReveal, + CellWidthMode, /// 背景色:色板网格 + 16 进制输入的专用浮层(不是通用行列表)。 BackgroundColor, } @@ -143,6 +144,9 @@ pub(super) const ACCEPT_OPTIONS: [AcceptKey; 3] = pub(super) const TAB_REVEAL_OPTIONS: [TabRevealMotion; 2] = [TabRevealMotion::Slide, TabRevealMotion::Instant]; +pub(super) const CELL_WIDTH_MODE_OPTIONS: [CellWidthMode; 2] = + [CellWidthMode::Compact, CellWidthMode::Relaxed]; + /// Order mirrors the appearance page the user referenced. pub(super) const CURSOR_SHAPE_OPTIONS: [CursorShape; 4] = [CursorShape::Beam, CursorShape::Underline, CursorShape::Block, CursorShape::HollowBlock]; @@ -178,6 +182,41 @@ fn tab_reveal_label(motion: TabRevealMotion, language: UiLanguage) -> &'static s } } +/// 单元格宽度模式:终端把字体的非整数设计宽度转换为整像素列宽的方式。 +/// 「紧凑」保持上游的向下取整并作为兼容默认;「宽松」采用最接近整数取整, +/// 补足与 Windows Terminal 相差的那一像素。它只影响列宽,不改变单元格高度、 +/// 字形比例或原生界面排版。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub(crate) enum CellWidthMode { + #[default] + Compact, + Relaxed, +} + +impl CellWidthMode { + fn parse(value: &str) -> Option { + match value.trim().to_ascii_lowercase().as_str() { + "compact" => Some(Self::Compact), + "relaxed" => Some(Self::Relaxed), + _ => None, + } + } + + fn settings_value(self) -> &'static str { + match self { + Self::Compact => "compact", + Self::Relaxed => "relaxed", + } + } +} + +fn cell_width_mode_label(mode: CellWidthMode, language: UiLanguage) -> &'static str { + match mode { + CellWidthMode::Compact => language.pick("紧凑", "Compact"), + CellWidthMode::Relaxed => language.pick("宽松", "Relaxed"), + } +} + pub(super) fn cursor_shape_label(shape: CursorShape, language: UiLanguage) -> &'static str { match shape { CursorShape::Beam => language.pick("条形(│)", "Bar (│)"), @@ -240,6 +279,8 @@ pub enum SettingsHit { CjkBoldToggle, TabRevealDropdown, TabRevealOption(usize), + CellWidthModeDropdown, + CellWidthModeOption(usize), /// Language combobox trigger (options resolve to [`SettingsHit::Language`]). LanguageDropdown, /// Expanded dropdown option rows for the cycle-style settings. @@ -309,6 +350,7 @@ pub(super) struct NebulaRuntimeSettings { /// 默认开:小字号下雅黑 Bold fallback 与 Regular 混排发闷(任务 #4)。 pub(super) cjk_bold_regular: bool, pub(super) tab_reveal: TabRevealMotion, + pub(super) cell_width_mode: CellWidthMode, pub(super) fetch: bool, pub(super) powerline: bool, /// Window close keeps the PTYs alive in the resident process (detach / @@ -381,6 +423,7 @@ pub(super) fn nebula_settings_load(config: &UiConfig) -> NebulaRuntimeSettings { copy_on_select: true, cjk_bold_regular: true, tab_reveal: TabRevealMotion::Slide, + cell_width_mode: CellWidthMode::Compact, // Off by default: the welcome screen pipes a whole script through the // fresh shell and repaints on resize — real startup-latency cost on // the critical path (user ruling: startup speed outranks the art). @@ -468,6 +511,9 @@ pub(super) fn nebula_settings_load(config: &UiConfig) -> NebulaRuntimeSettings { Some(("tab_reveal", v)) => { settings.tab_reveal = TabRevealMotion::parse(v).unwrap_or_default(); }, + Some(("cell_width_mode", v)) => { + settings.cell_width_mode = CellWidthMode::parse(v).unwrap_or_default(); + }, Some(("startup_directory", v)) => { let path = std::path::PathBuf::from(v.trim()); if path.is_dir() { @@ -681,7 +727,7 @@ pub(super) fn nebula_settings_write(settings: &NebulaRuntimeSettings) { path, format!( "language={}\ntheme={theme}\nfollow_system_theme={}\nghost={}\naccept={accept}\nshell={shell}\nstartup_directory={startup_directory}\nfont_family={}\nfont_size={font_size}\ncursor_shape={}\ncursor_blink={}\ncopy_on_select={}\ncjk_bold_regular={} -tab_reveal={}\nfetch={}\npowerline={}\nkeep_session={}\nrestore_session={}\nblur={}\nopacity={:.2}\nbackground={background}\nbackground_image={background_image}\nbackground_image_opacity={:.2}\nbackground_image_fit={}\nbackground_image_alignment={}\nbackground_image_cover_chrome={}\npanel_resize={}\nsidebar_w={:.0}\ndrawer_w={:.0}\nhosts_band={:.0}\npinned_hosts={pinned_hosts}\nsaved_hosts={saved_hosts}\nhidden_hosts={hidden_hosts}\nquick_terminal_hotkey={quick_terminal_hotkey}\n{keybinds}", +tab_reveal={}\ncell_width_mode={}\nfetch={}\npowerline={}\nkeep_session={}\nrestore_session={}\nblur={}\nopacity={:.2}\nbackground={background}\nbackground_image={background_image}\nbackground_image_opacity={:.2}\nbackground_image_fit={}\nbackground_image_alignment={}\nbackground_image_cover_chrome={}\npanel_resize={}\nsidebar_w={:.0}\ndrawer_w={:.0}\nhosts_band={:.0}\npinned_hosts={pinned_hosts}\nsaved_hosts={saved_hosts}\nhidden_hosts={hidden_hosts}\nquick_terminal_hotkey={quick_terminal_hotkey}\n{keybinds}", settings.language.as_str(), settings.follow_system_theme as u8, settings.ghost as u8, @@ -691,6 +737,7 @@ tab_reveal={}\nfetch={}\npowerline={}\nkeep_session={}\nrestore_session={}\nblur settings.copy_on_select as u8, settings.cjk_bold_regular as u8, settings.tab_reveal.settings_value(), + settings.cell_width_mode.settings_value(), settings.fetch as u8, settings.powerline as u8, settings.keep_session as u8, @@ -729,6 +776,7 @@ struct SettingsGeometry { font: (f32, f32, f32, f32), /// "字号" spinner row; the value box + steppers derive via `widgets`. font_size_row: (f32, f32, f32, f32), + cell_width_mode: (f32, f32, f32, f32), fetch: (f32, f32, f32, f32), powerline: (f32, f32, f32, f32), ghost: (f32, f32, f32, f32), @@ -923,7 +971,8 @@ fn settings_geometry( let startup_directory_y0 = shell_y0 + ROW_H; let font_y0 = startup_directory_y0 + ROW_H; let font_size_y0 = font_y0 + ROW_H; - let fetch_y0 = font_size_y0 + ROW_H; + let cell_width_mode_y0 = font_size_y0 + ROW_H; + let fetch_y0 = cell_width_mode_y0 + ROW_H; let ghost_y0 = fetch_y0 + 2.0 * ROW_H + GROUP_ADVANCE; let open_y0 = ghost_y0 + 2.0 * ROW_H + GROUP_ADVANCE; let hidden_y0 = open_y0 + ROW_H + GROUP_ADVANCE; @@ -1010,6 +1059,7 @@ fn settings_geometry( ), font: (row_x, at(font_y0), row_w, row_h), font_size_row: (row_x, at(font_size_y0), row_w, row_h), + cell_width_mode: (row_x, at(cell_width_mode_y0), row_w, row_h), fetch: (row_x, at(fetch_y0), row_w, row_h), powerline: (row_x, at(fetch_y0 + ROW_H), row_w, row_h), ghost: (row_x, at(ghost_y0), row_w, row_h), @@ -1135,6 +1185,9 @@ fn dropdown_anchor( (Section::Profiles, SettingsDropdown::Accept) => { Some((anchor(geometry.accept), ACCEPT_OPTIONS.len())) }, + (Section::Profiles, SettingsDropdown::CellWidthMode) => { + Some((anchor(geometry.cell_width_mode), CELL_WIDTH_MODE_OPTIONS.len())) + }, (Section::Interaction, SettingsDropdown::TabReveal) => { Some((anchor(geometry.tab_reveal), TAB_REVEAL_OPTIONS.len())) }, @@ -1226,6 +1279,7 @@ pub fn settings_hit( SettingsDropdown::Language => SettingsHit::Language(LANGUAGE_OPTIONS[index]), SettingsDropdown::Accept => SettingsHit::AcceptOption(index), SettingsDropdown::TabReveal => SettingsHit::TabRevealOption(index), + SettingsDropdown::CellWidthMode => SettingsHit::CellWidthModeOption(index), SettingsDropdown::CursorShape => SettingsHit::CursorShapeOption(index), // 背景色浮层在上方特判处理,走不到通用行列表。 SettingsDropdown::BackgroundColor => SettingsHit::Panel, @@ -1321,6 +1375,9 @@ pub fn settings_hit( return SettingsHit::FontSizeDown; } } + if contains_rect(geometry.cell_width_mode, x, y) { + return SettingsHit::CellWidthModeDropdown; + } if contains_rect(geometry.fetch, x, y) { return SettingsHit::FetchToggle; } @@ -1451,6 +1508,7 @@ pub(super) struct SettingsView { pub(super) panel_resize: bool, pub(super) cjk_bold_regular: bool, pub(super) tab_reveal: TabRevealMotion, + pub(super) cell_width_mode: CellWidthMode, /// Live-preview colors: the ACTUAL terminal background/foreground the /// grid would use right now (custom background wins over the theme). pub(super) preview_bg: Rgb, @@ -1617,6 +1675,9 @@ fn dropdown_selected_index(view: &SettingsView, dropdown: SettingsDropdown) -> O SettingsDropdown::TabReveal => { TAB_REVEAL_OPTIONS.iter().position(|motion| *motion == view.tab_reveal) }, + SettingsDropdown::CellWidthMode => { + CELL_WIDTH_MODE_OPTIONS.iter().position(|mode| *mode == view.cell_width_mode) + }, SettingsDropdown::CursorShape => { CURSOR_SHAPE_OPTIONS.iter().position(|shape| *shape == view.cursor_shape) }, @@ -1637,6 +1698,7 @@ fn dropdown_hover_index(hover: SettingsHit, dropdown: SettingsDropdown) -> Optio }, (SettingsDropdown::Accept, SettingsHit::AcceptOption(index)) => Some(index), (SettingsDropdown::TabReveal, SettingsHit::TabRevealOption(index)) => Some(index), + (SettingsDropdown::CellWidthMode, SettingsHit::CellWidthModeOption(index)) => Some(index), (SettingsDropdown::CursorShape, SettingsHit::CursorShapeOption(index)) => Some(index), _ => None, } @@ -2172,6 +2234,18 @@ pub(super) fn push_quads( view.hover == SettingsHit::FontSizeUp, view.hover == SettingsHit::FontSizeDown, ); + row_hover( + quads, + geometry.cell_width_mode, + view.hover == SettingsHit::CellWidthModeDropdown, + ); + combobox( + quads, + &mut staged, + geometry.cell_width_mode, + view.hover == SettingsHit::CellWidthModeDropdown, + view.dropdown == Some(SettingsDropdown::CellWidthMode), + ); for quad in staged.drain(..) { clip(quads, quad); } @@ -2604,6 +2678,9 @@ pub(super) fn draw_popup_text( SettingsDropdown::TabReveal => { tab_reveal_label(TAB_REVEAL_OPTIONS[index], language).to_owned() }, + SettingsDropdown::CellWidthMode => { + cell_width_mode_label(CELL_WIDTH_MODE_OPTIONS[index], language).to_owned() + }, SettingsDropdown::CursorShape => { cursor_shape_label(CURSOR_SHAPE_OPTIONS[index], language).to_owned() }, @@ -3329,6 +3406,26 @@ pub(super) fn draw_text( gc, ); } + if visible(geometry.cell_width_mode.1, geometry.cell_width_mode.3) { + row_label( + r, + gc, + size, + scale, + &sk, + geometry.cell_width_mode, + language.pick("字体间距", "Font spacing"), + "", + sk.ink, + ); + combobox_value( + r, + gc, + geometry.cell_width_mode, + cell_width_mode_label(view.cell_width_mode, language), + sk.accent, + ); + } // Boolean rows: the switch (drawn in `push_quads`) carries the // state; no "On/Off" string next to it. if visible(geometry.fetch.1, geometry.fetch.3) { @@ -3829,7 +3926,8 @@ pub(super) fn draw_text( #[cfg(test)] mod tests { use super::{ - SHOW_WEBDAV_SYNC_SETTINGS, TabRevealMotion, advanced_content_end, opacity_from_pointer, + CELL_WIDTH_MODE_OPTIONS, CellWidthMode, SHOW_WEBDAV_SYNC_SETTINGS, TabRevealMotion, + UiLanguage, advanced_content_end, cell_width_mode_label, opacity_from_pointer, }; #[test] @@ -3860,4 +3958,27 @@ mod tests { assert_eq!(TabRevealMotion::parse(value.settings_value()), Some(value)); } } + + #[test] + fn cell_width_mode_defaults_compatibly_and_round_trips() { + assert_eq!(CellWidthMode::default(), CellWidthMode::Compact); + assert_eq!(CellWidthMode::parse("compact"), Some(CellWidthMode::Compact)); + assert_eq!(CellWidthMode::parse("RELAXED"), Some(CellWidthMode::Relaxed)); + assert_eq!(CellWidthMode::parse("unknown").unwrap_or_default(), CellWidthMode::Compact); + for value in [CellWidthMode::Compact, CellWidthMode::Relaxed] { + assert_eq!(CellWidthMode::parse(value.settings_value()), Some(value)); + } + } + + #[test] + fn cell_width_mode_dropdown_offers_both_choices_with_the_compatible_one_first() { + // 列表顺序即下拉顺序,也是持久化索引的来源;兼容默认必须排第一。 + assert_eq!(CELL_WIDTH_MODE_OPTIONS.len(), 2); + assert_eq!(CELL_WIDTH_MODE_OPTIONS[0], CellWidthMode::default()); + assert_eq!(CELL_WIDTH_MODE_OPTIONS[1], CellWidthMode::Relaxed); + for option in CELL_WIDTH_MODE_OPTIONS { + assert!(!cell_width_mode_label(option, UiLanguage::ZhCn).is_empty()); + assert!(!cell_width_mode_label(option, UiLanguage::EnUs).is_empty()); + } + } } diff --git a/nebula_app/src/input/chrome.rs b/nebula_app/src/input/chrome.rs index 7297c10..5a2b1c9 100644 --- a/nebula_app/src/input/chrome.rs +++ b/nebula_app/src/input/chrome.rs @@ -862,6 +862,19 @@ impl> Processor { self.ctx.mark_dirty(); return; }, + crate::display::SettingsHit::CellWidthModeDropdown => { + self.ctx.display().toggle_settings_dropdown( + crate::display::SettingsDropdown::CellWidthMode, + ); + self.ctx.mark_dirty(); + return; + }, + crate::display::SettingsHit::CellWidthModeOption(index) => { + let base_font = self.ctx.config().font.clone(); + self.ctx.display().set_cell_width_mode_option(index, &base_font); + self.ctx.mark_dirty(); + return; + }, crate::display::SettingsHit::FontSizeUp => { let ui_scale = self.ctx.display().window.scale_factor as f32; self.ctx.change_font_size(ui_scale); @@ -1292,6 +1305,8 @@ fn settings_dropdown_keeps_open(hit: crate::display::SettingsHit) -> bool { | Hit::AcceptOption(_) | Hit::TabRevealDropdown | Hit::TabRevealOption(_) + | Hit::CellWidthModeDropdown + | Hit::CellWidthModeOption(_) | Hit::CursorShapeDropdown | Hit::CursorShapeOption(_) | Hit::BackgroundColor diff --git a/nebula_app/src/input/mouse.rs b/nebula_app/src/input/mouse.rs index 67ad371..c8802dc 100644 --- a/nebula_app/src/input/mouse.rs +++ b/nebula_app/src/input/mouse.rs @@ -331,6 +331,8 @@ impl> Processor { | crate::display::SettingsHit::KeymapRow(_) | crate::display::SettingsHit::TabRevealDropdown | crate::display::SettingsHit::TabRevealOption(_) + | crate::display::SettingsHit::CellWidthModeDropdown + | crate::display::SettingsHit::CellWidthModeOption(_) | crate::display::SettingsHit::FontSizeUp | crate::display::SettingsHit::FontSizeDown | crate::display::SettingsHit::BackgroundImageCoverChrome