diff --git a/nebula_app/src/display/chrome.rs b/nebula_app/src/display/chrome.rs index df14e2f..cc26ec4 100644 --- a/nebula_app/src/display/chrome.rs +++ b/nebula_app/src/display/chrome.rs @@ -11,6 +11,7 @@ #![allow(clippy::wildcard_imports)] use super::*; +use super::ui::{icons, tokens}; /// Result of hit-testing a pixel against the Nebula top chrome bar. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -1616,7 +1617,7 @@ pub(super) fn draw_chrome(d: &mut Display) { // would bleed across the seam onto the terminal grid. if d.nebula_ssh_hosts.is_empty() && d.nebula_hosts_section_open { use unicode_width::UnicodeWidthChar; - const HINT_SCALE: f32 = design_tokens::type_scale::SUPPORTING; + const HINT_SCALE: f32 = tokens::type_scale::SUPPORTING; let hint_flags = nebula_terminal::term::cell::Flags::empty(); let (pnl_x, _, pnl_w, _) = tab_layout.panel; let text_x = hh_x + s(28.0); diff --git a/nebula_app/src/display/command_palette.rs b/nebula_app/src/display/command_palette.rs index 2b024e3..c9d5143 100644 --- a/nebula_app/src/display/command_palette.rs +++ b/nebula_app/src/display/command_palette.rs @@ -961,7 +961,7 @@ pub fn palette_layout( let s = |v: f32| v * scale; let margin = s(8.0); let pad = s(12.0); - let row_h = s(super::design_tokens::control::COMPACT_ROW); + let row_h = s(super::ui::tokens::control::COMPACT_ROW); // 搜索框与结果行等高:输入仍然可发现,但不会压过真正的数据内容。 let input_h = row_h; let cards = model.is_picker(); @@ -1095,7 +1095,14 @@ pub(super) fn push_quads( // (浅色主题还因 alpha 差露出脏渐变)。 quads.push(UiQuad::solid(px, py, pw, ph, s(14.0), sk.panel)); - quads.push(UiQuad::solid(ix, iy, iw, ih, s(super::design_tokens::control::RADIUS), sk.input)); + quads.push(UiQuad::solid( + ix, + iy, + iw, + ih, + s(super::ui::tokens::radius::CONTROL), + sk.input, + )); if model.query_all_selected() && !model.query.is_empty() { let cell_w = size.cell_width(); let columns: usize = model.query.chars().map(|c| c.width().unwrap_or(0)).sum(); @@ -1115,14 +1122,14 @@ pub(super) fn push_quads( // Ctrl+K 键帽(图4/图8):仅 shell picker 空查询时展示,指认打开快捷 // 键;输入开始后让位给查询文字。 if model.mode == PaletteMode::Profiles && model.query.is_empty() { - let combo = super::keycap::layout_combo( + let combo = super::ui::keycap::layout_combo( "Ctrl+K", ix + iw - s(GUTTER), iy + ih / 2.0, cell_w, scale, ); - super::keycap::push_combo(quads, &sk, &combo, scale); + super::ui::keycap::push_combo(quads, &sk, &combo, scale); } if let Some((fx, fy, fw, fh)) = layout.footer { @@ -1223,7 +1230,7 @@ pub(super) fn push_quads( // 图8 规范:命令列表右侧的快捷键 hint 画成逐键 chip 底(键名在文 // 字 pass)。挤不下就整组让位,不压进标签。 if !cards && !entry.hint.is_empty() { - let combo = super::keycap::layout_combo( + let combo = super::ui::keycap::layout_combo( &entry.hint, ix + iw - s(GUTTER), ry + rh / 2.0, @@ -1234,7 +1241,7 @@ pub(super) fn push_quads( + entry.label.chars().map(|c| c.width().unwrap_or(1)).sum::() as f32 * cell_w; if combo.bounds.0 > label_end + s(HINT_GAP) { - super::keycap::push_combo(quads, &sk, &combo, scale); + super::ui::keycap::push_combo(quads, &sk, &combo, scale); } } if !entry.is_default || (hero && row == 0) { @@ -1260,11 +1267,11 @@ pub(super) fn push_quads( if let Some((_, fy, _, fh)) = layout.footer { // 图8 键帽规范:底栏键帽与 Ctrl+K/设置页共用同一 chip 配方。底栏 // 背景横贯面板,但里面的文字与卡片列对齐(走 ix/iw,不是 panel)。 - let key_h = s(super::keycap::KEY_H); + let key_h = s(super::ui::keycap::KEY_H); let key_y = fy + (fh - key_h) * 0.5; let mut chip = |quads: &mut Vec, x: f32, cols: usize| { let width = cols as f32 * cell_w + s(8.0); - super::keycap::push_chip(quads, &sk, x, key_y, width, key_h, scale); + super::ui::keycap::push_chip(quads, &sk, x, key_y, width, key_h, scale); }; chip(quads, ix, 3); chip(quads, ix + iw * 0.42, 5); @@ -1391,7 +1398,7 @@ pub(super) fn draw_text( } // Ctrl+K 键帽文字(chip 底在 quad pass,几何同源)。 if model.mode == PaletteMode::Profiles && model.query.is_empty() { - let combo = super::keycap::layout_combo( + let combo = super::ui::keycap::layout_combo( "Ctrl+K", ix + iw - s(GUTTER), iy + ih / 2.0, @@ -1479,7 +1486,7 @@ pub(super) fn draw_text( if !cards { // 图8 规范:命令列表快捷键 hint 逐键 chip;与 quad pass 用 // 同一 layout_combo 几何与让位守卫。 - let combo = super::keycap::layout_combo( + let combo = super::ui::keycap::layout_combo( &hint, ix + iw - s(GUTTER), row_y + row_hh / 2.0, @@ -1537,10 +1544,10 @@ fn draw_combo_text( r: &mut Renderer, gc: &mut GlyphCache, size: &SizeInfo, - combo: &super::keycap::ComboChips, + combo: &super::ui::keycap::ComboChips, cell_w: f32, cell_h: f32, - sk: &super::theme::Skin, + sk: &super::ui::theme::Skin, ) { let (_, key_y, _, key_h) = combo.bounds; let ty = key_y + (key_h - cell_h) / 2.0; diff --git a/nebula_app/src/display/content.rs b/nebula_app/src/display/content.rs index 567fad7..9d58de8 100644 --- a/nebula_app/src/display/content.rs +++ b/nebula_app/src/display/content.rs @@ -15,7 +15,7 @@ use nebula_terminal::vte::ansi::{Color, CursorShape, NamedColor}; use crate::config::UiConfig; use crate::config::color::{InvertedCellColors, NEBULA_DEFAULT_CURSOR}; use crate::display::color::{CellRgb, DIM_FACTOR, List, Rgb}; -use crate::display::design_tokens::terminal_feedback; +use crate::display::ui::tokens::terminal_feedback; use crate::display::hint::{self, HintState}; use crate::display::terminal_color::{TerminalColorResolver, is_fixed_color}; use crate::display::{Display, SizeInfo}; @@ -640,7 +640,7 @@ mod tests { }; use crate::config::color::NEBULA_DEFAULT_CURSOR; use crate::display::color::Rgb; - use crate::display::design_tokens::terminal_feedback; + use crate::display::ui::tokens::terminal_feedback; use nebula_terminal::index::{Column, Line, Point}; use nebula_terminal::term::cell::Flags; diff --git a/nebula_app/src/display/context_menu.rs b/nebula_app/src/display/context_menu.rs index f17e361..84fcabf 100644 --- a/nebula_app/src/display/context_menu.rs +++ b/nebula_app/src/display/context_menu.rs @@ -4,7 +4,8 @@ //! hit-testing and animation live together here so pointer targets cannot drift //! away from the pixels the user sees at non-integer DPI scales. -use super::design_tokens::{control, elevation, space}; +use super::ui::surface::{self, fade as alpha}; +use super::ui::tokens::{control, space}; use super::*; // Maple Mono Normal NF CN ships the stable Codicon block below. Keep menu @@ -468,10 +469,6 @@ pub(super) fn hit_test( if contains(layout.panel, x, y) { ContextMenuHit::Panel } else { ContextMenuHit::Outside } } -fn alpha(color: Rgba, opacity: f32) -> Rgba { - Rgba::new(color.r, color.g, color.b, (color.a as f32 * opacity).round() as u8) -} - fn fade_ink(base: Rgba, ink: Rgb, opacity: f32) -> Rgb { let mix = |a: u8, b: u8| (a as f32 + (b as f32 - a as f32) * opacity).round() as u8; Rgb::new(mix(base.r, ink.r), mix(base.g, ink.g), mix(base.b, ink.b)) @@ -494,37 +491,19 @@ pub(super) fn draw(display: &mut Display) { let s = |v: f32| v * scale; let layout = layout(&menu, size, scale, -s(5.0) * (1.0 - progress)); let sk = display.nebula_theme.skin(); - let radius = s(9.0); - let (px, py, pw, ph) = layout.panel; - let shadow_alpha = if sk.is_light { - elevation::FLOATING_SHADOW_ALPHA_LIGHT - } else { - elevation::FLOATING_SHADOW_ALPHA_DARK - }; - let hairline = s(control::HAIRLINE).max(1.0); - let mut quads = vec![ - UiQuad::shadow( - px, - py, - pw, - ph, - radius, - s(elevation::FLOATING_BLUR), - s(elevation::FLOATING_OFFSET_Y), - alpha(Rgba::new(0, 0, 0, shadow_alpha), progress), - ), - // 全周 hairline 在浅色背景上定义边界;外阴影承担 Z 轴层级。 - UiQuad::solid( - px - hairline, - py - hairline, - pw + hairline * 2.0, - ph + hairline * 2.0, - radius + hairline, - alpha(sk.hairline, progress), - ), - UiQuad::solid(px, py, pw, ph, radius, alpha(sk.panel, progress)), - ]; + // 阴影 + 发丝环 + 面板填充的配方现在只有一处定义(`ui::surface`)。 + // 右键菜单是 Menu 层级:不画遮罩,阴影按小浮层的扩散取值。 + let mut quads = Vec::new(); + surface::push_surface( + &mut quads, + layout.panel, + (size.width(), size.height()), + scale, + &sk, + surface::Elevation::Menu, + progress, + ); for separator in &layout.separators { quads.push(UiQuad::solid( separator.0, diff --git a/nebula_app/src/display/markdown_view.rs b/nebula_app/src/display/markdown_view.rs index 481a76d..2c6259a 100644 --- a/nebula_app/src/display/markdown_view.rs +++ b/nebula_app/src/display/markdown_view.rs @@ -36,7 +36,7 @@ use crate::renderer::ui::UiQuad; use crate::renderer::{GlyphCache, Renderer}; use super::SizeInfo; -use super::theme::Skin; +use super::ui::theme::Skin; /// Extensions the viewer opens (double-click in the file tree). Everything /// else keeps the tree's default activation behaviour. diff --git a/nebula_app/src/display/message_queue_entry.rs b/nebula_app/src/display/message_queue_entry.rs index e3c7f1c..3050e60 100644 --- a/nebula_app/src/display/message_queue_entry.rs +++ b/nebula_app/src/display/message_queue_entry.rs @@ -8,7 +8,7 @@ use nebula_terminal::term::cell::Flags; use super::color::Rgb; -use super::theme::Skin; +use super::ui::theme::Skin; use super::{SizeInfo, UiLanguage}; use crate::renderer::ui::{Gradient, Rgba, UiQuad}; use crate::renderer::{GlyphCache, Renderer}; diff --git a/nebula_app/src/display/mod.rs b/nebula_app/src/display/mod.rs index e812df3..49554d7 100644 --- a/nebula_app/src/display/mod.rs +++ b/nebula_app/src/display/mod.rs @@ -1,8079 +1,8075 @@ -//! The display subsystem including window management, font rasterization, and -//! GPU drawing. - -use std::cmp; -use std::collections::HashSet; -use std::fmt::{self, Formatter}; -use std::mem::{self, ManuallyDrop}; -use std::num::NonZeroU32; -use std::ops::Deref; -use std::path::{Path, PathBuf}; -use std::time::{Duration, Instant}; - -use glutin::config::GetGlConfig; -use glutin::context::{NotCurrentContext, PossiblyCurrentContext}; -use glutin::display::GetGlDisplay; -use glutin::error::ErrorKind; -use glutin::prelude::*; -use glutin::surface::{Surface, SwapInterval, WindowSurface}; - -use log::{debug, info, warn}; -use parking_lot::MutexGuard; -use winit::dpi::PhysicalSize; -use winit::keyboard::ModifiersState; -use winit::raw_window_handle::RawWindowHandle; -use winit::window::{CursorIcon, Theme as WinitTheme}; - -use crossfont::{Rasterize, Size as FontSize}; -use unicode_width::UnicodeWidthChar; - -use nebula_terminal::event::{EventListener, OnResize}; -use nebula_terminal::grid::Dimensions as TermDimensions; -use nebula_terminal::index::{Column, Direction, Line, Point}; -use nebula_terminal::selection::Selection; -#[cfg(windows)] -use nebula_terminal::term::cell::Cell; -use nebula_terminal::term::cell::Flags; -use nebula_terminal::term::{ - self, LineDamageBounds, MIN_COLUMNS, MIN_SCREEN_LINES, Term, TermDamage, TermMode, -}; -use nebula_terminal::vte::ansi::{CursorShape, NamedColor}; - -use nebula_completions::file::complete_item; -use nebula_completions::{CompletionOptions, Span}; - -use crate::config::UiConfig; -use crate::config::debug::RendererPreference; -use crate::config::font::Font; -use crate::config::window::Dimensions; -use crate::config::window::StartupMode; -use crate::display::bell::VisualBell; -use crate::display::color::{List, Rgb}; -use crate::display::content::{RenderableContent, RenderableCursor}; -use crate::display::cursor::IntoRects; -use crate::display::damage::{DamageTracker, damage_y_to_viewport_y}; -use crate::display::hint::{HintMatch, HintState}; -use crate::display::meter::Meter; -use crate::display::window::Window; -use crate::event::{Event, EventType, Mouse, SearchState}; -use crate::message_bar::{self, MessageBuffer, MessageType}; -use crate::renderer::Rasterizer; -use crate::renderer::image::{BackgroundImageAlignment, BackgroundImageFit}; -use crate::renderer::rects::{RenderLine, RenderLines, RenderRect}; -use crate::renderer::ui::{Gradient, Rgba, UiQuad}; -use crate::renderer::{self, GlyphCache, Renderer, platform}; -use crate::scheduler::{Scheduler, TimerId, Topic}; -use crate::string::{ShortenDirection, StrShortener}; - -pub mod color; -pub mod content; -pub mod cursor; -pub mod hint; -pub mod window; - -mod chrome; -pub mod command_palette; -mod context_menu; -pub mod design_tokens; -mod keycap; -mod i18n; -pub mod markdown_view; -mod message_queue_entry; -pub mod sftp_panel; -pub mod side_panel; -mod icons; -mod size_info; -mod state; -mod surface_opacity; -mod terminal_color; -mod terminal_math; -mod widgets; - -/// Processor uses the same persisted value before the first window exists so -/// the global quick-terminal shortcut is active from application startup. -pub(crate) fn quick_terminal_hotkey_from_settings(config: &UiConfig) -> String { - settings::nebula_settings_load(config).quick_terminal_hotkey -} - -pub(crate) use chrome::chrome_settings_button_rect; -pub use chrome::{ChromeHit, TabDropAction, in_chrome_bar, resize_edge}; -use chrome::{ - ChromeTabLayout, TabDrag, chrome_hit_with_tabs, chrome_tab_layout, contains_rect, - truncate_tab_label, -}; -pub use context_menu::{ContextMenuAction, ContextMenuHit, ContextMenuTarget}; -pub use i18n::{LanguagePreference, UiLanguage}; -pub use size_info::SizeInfo; -pub use state::{ - AcceptKey, NebulaConfirm, NebulaInlineImage, NebulaPaneState, NebulaShell, SplitDirection, - SplitNav, -}; - -mod file_dialog; -pub(crate) mod keymap; -mod settings; -mod ssh_editor_render; -mod ssh_ui; -mod text_input; -mod theme; - -use ssh_ui::SshDeleteUndo; -pub use ssh_ui::{ - SSH_DELETE_UNDO_DURATION, SshEditorField, SshEditorHit, SshEditorRects, SshHostEditor, -}; -pub use theme::NebulaTheme; -pub(crate) use theme::write_nebula_prompt_theme; - -/// Shared caret blink phase for the chrome text editors (rename / filter / -/// commit boxes): 500ms on / 500ms off wall-clock, same time source as the -/// sidebar spinner. The fast tick (armed while an editor is focused) keeps -/// frames coming so the phase is actually visible instead of looking frozen. -pub(crate) fn caret_blink_on() -> bool { - let millis = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_millis()) - .unwrap_or(0); - (millis / 500) % 2 == 0 -} -pub use settings::{NebulaSettingsSection, SettingsDropdown, SettingsHit, settings_hit}; -pub(crate) use settings::SettingsOpacityTarget; - -/// 按显示列宽贪心断行(确认框正文等 UI 段落用):CJK 逐字可断,行首空 -/// 格吞掉;零宽字符跟随前一个字。不做拉丁连词回退——正文以中文为主, -/// 偶发的英文单词被折断可接受。 -fn wrap_display_cols(text: &str, max_cols: usize) -> Vec { - let mut lines = Vec::new(); - let mut line = String::new(); - let mut cols = 0usize; - for ch in text.chars() { - let w = ch.width().unwrap_or(0); - if w == 0 { - line.push(ch); - continue; - } - if cols + w > max_cols && cols > 0 { - lines.push(std::mem::take(&mut line)); - cols = 0; - if ch == ' ' { - continue; - } - } - line.push(ch); - cols += w; - } - if !line.is_empty() || lines.is_empty() { - lines.push(line); - } - lines -} - -mod bell; -mod damage; -mod meter; - -/// Label for the forward terminal search bar. -const FORWARD_SEARCH_LABEL: &str = "Search: "; - -/// Label for the backward terminal search bar. -const BACKWARD_SEARCH_LABEL: &str = "Backward Search: "; - -/// The character used to shorten the visible text like uri preview or search regex. -const SHORTENER: char = '…'; - -/// Private-use placeholders emitted by Nebula's injected prompt. They are -/// replaced with spaces before text rendering; the real icons are vector UI -/// quads, so no Nerd Font or bundled font is required. -const NEBULA_FOLDER_ICON_MARKER: char = '\u{E100}'; -const NEBULA_GIT_BRANCH_ICON_MARKER: char = '\u{E101}'; - -/// Color which is used to highlight damaged rects when debugging. -const DAMAGE_RECT_COLOR: Rgb = Rgb::new(255, 0, 255); - -/// Cap on ghost-text length so a deeply-nested path can never spill across the -/// whole row and clobber the chrome. -const NEBULA_GHOST_MAX: usize = 96; - -/// Prompt arrow injected by the Windows PowerShell profile (`U+276F`, `NebPromptArrow`). -/// The active input line is rendered as `❯ `, so on Windows the real, -/// echoed input can be read straight off the grid instead of being guessed from -/// keystrokes — that is the only source that never desyncs from the shell's own -/// line editor (PSReadLine). -#[cfg(windows)] -const NEBULA_PROMPT_ARROW: char = '\u{276F}'; - -/// Visible split divider gap. The drag hit target is intentionally wider. -pub(crate) const NEBULA_SPLIT_DIVIDER_GAP: f32 = 2.0; -pub(crate) const NEBULA_SPLIT_HIT_SLOP: f32 = 8.0; - -/// How far the unfocused split is dimmed. Focus is conveyed by brightness, not -/// a border: the inactive pane is pushed back under a translucent veil so the -/// focused pane visually "lifts" without any outline. -/// `unfocused-split-opacity = 0.7` (i.e. a 0.3 dim veil). -pub(crate) const NEBULA_UNFOCUSED_SPLIT_DIM: f32 = 0.30; - -/// Max remembered commands for the history hint. - -/// Top chrome reserve, in logical pixels at scale factor 1.0. Sized as: top -/// bar (8 margin + 40 bar) + card seam (8) + 8px of breathing room inside the -/// terminal card, so the first grid row doesn't touch the card's top edge. -pub const CHROME_BAR_LOGICAL: f32 = 64.0; - -/// Shared chrome/control corner radius. Used for the small in-shell affordances -/// (window-control hover pills, tab pills, the "+" square) — kept modest so the -/// controls stay crisp. -pub(super) const UI_CORNER_RADIUS_LOGICAL: f32 = 8.0; - -/// Outer radius of the connected chrome shell (the L-frame formed by the top -/// bar + left sidebar). Larger than the control radius so the whole window -/// chrome reads as one soft-cornered card while the affordances inside keep -/// their tighter [`UI_CORNER_RADIUS_LOGICAL`] curve. -pub(super) const UI_SHELL_RADIUS_LOGICAL: f32 = 14.0; - -/// Gap between the terminal card and the window's right/bottom edges, in -/// logical pixels — the visible "seam" of shell color that makes the terminal -/// read as a rounded card floating on the shell backdrop. Top and left carry -/// no seam of their own: the card tucks up under the top bar and sidebar. -pub(super) const UI_CARD_SEAM_LOGICAL: f32 = 8.0; - -/// Shared quiet outline thickness. -pub(super) const UI_HAIRLINE_LOGICAL: f32 = 1.0; - -/// Horizontal breathing space for terminal content, in logical pixels. -/// Kept modest so the grid stays wide — this is *added on top of* the user's -/// configured `window.padding`, on both sides, so large values noticeably -/// narrow the usable area. -pub const CONTENT_PAD_X_LOGICAL: f32 = 20.0; - -/// Reserved chrome height per side, in physical pixels for `scale_factor`. -#[inline] -pub fn chrome_reserve(scale_factor: f32) -> f32 { - (CHROME_BAR_LOGICAL * scale_factor).round() -} - -/// Bottom grid reserve: card seam plus the same 8px inner breathing room used -/// above the first row. Unlike [`chrome_reserve`], there is no title bar below -/// the terminal, so mirroring the 64px top reserve creates a large dead band. -#[inline] -pub fn bottom_content_reserve(scale_factor: f32) -> f32 { - ((UI_CARD_SEAM_LOGICAL + 8.0) * scale_factor).round() -} - -/// Horizontal content padding, in physical pixels for `scale_factor`. -#[inline] -pub fn content_pad_x(scale_factor: f32) -> f32 { - (CONTENT_PAD_X_LOGICAL * scale_factor).round() -} - -/// Width of the left tab sidebar when expanded, in logical pixels. Chosen to -/// match the reference design — wide enough for a directory-ish label plus a -/// close affordance, narrow enough to leave the grid roomy. -pub const SIDEBAR_W_LOGICAL: f32 = 230.0; - -/// Sidebar width in physical pixels for `scale_factor`, honouring the collapsed -/// state. Collapsed folds the panel away entirely (0) so the grid reclaims the -/// full width; the reveal affordance then lives in the top bar. -#[inline] -pub fn sidebar_width(scale_factor: f32, collapsed: bool) -> f32 { - if collapsed { 0.0 } else { (SIDEBAR_W_LOGICAL * scale_factor).round() } -} - -#[derive(Debug, Clone, Copy)] -struct UiAnim { - spring: crate::motion::Spring, -} - -impl UiAnim { - fn new(value: f32) -> Self { - Self { spring: crate::motion::Spring::new(value.clamp(0.0, 1.0)).with_response(0.14) } - } - - fn value(self) -> f32 { - self.spring.value().clamp(0.0, 1.0) - } - - fn visible(self, target_open: bool) -> bool { - target_open || self.value() > 0.004 - } - - fn animating_to(self, target: f32) -> bool { - (self.value() - target.clamp(0.0, 1.0)).abs() > 0.004 || self.spring.is_active() - } - - fn step(&mut self, frame: crate::motion::Frame, target: f32) { - self.spring.set_target(target.clamp(0.0, 1.0), crate::motion::MotionPolicy::Full); - self.spring.step(frame); - } -} - -#[derive(Debug, Clone)] -struct NebulaUiAnims { - clock: crate::motion::MotionClock, - frame: Option, - /// Continuous sidebar-spinner phase in turns (`0.0..1.0`). Advancing it - /// from the shared monotonic frame delta avoids wall-clock jumps and needs - /// only four bytes per window. - spinner_phase: f32, - left_sidebar: UiAnim, - right_drawer: UiAnim, - ssh_editor: UiAnim, -} - -impl NebulaUiAnims { - fn new() -> Self { - Self { - clock: crate::motion::MotionClock::default(), - frame: None, - spinner_phase: 0.0, - left_sidebar: UiAnim::new(1.0), - right_drawer: UiAnim::new(0.0), - ssh_editor: UiAnim::new(0.0), - } - } - - fn step(&mut self, left_open: bool, right_open: bool, ssh_open: bool) { - let frame = self.clock.tick(); - self.frame = Some(frame); - self.left_sidebar.step(frame, if left_open { 1.0 } else { 0.0 }); - self.right_drawer.step(frame, if right_open { 1.0 } else { 0.0 }); - self.ssh_editor.step(frame, if ssh_open { 1.0 } else { 0.0 }); - } - - fn frame(&mut self) -> crate::motion::Frame { - if let Some(frame) = self.frame { - frame - } else { - let frame = self.clock.tick(); - self.frame = Some(frame); - frame - } - } - - fn animating(&self, left_open: bool, right_open: bool) -> bool { - self.left_sidebar.animating_to(if left_open { 1.0 } else { 0.0 }) - || self.right_drawer.animating_to(if right_open { 1.0 } else { 0.0 }) - } -} - -#[derive(Debug, Clone, Copy)] -struct ResizeHud { - columns: usize, - rows: usize, - opacity: crate::motion::Tween, -} - -impl ResizeHud { - fn new(columns: usize, rows: usize) -> Self { - let mut opacity = crate::motion::Tween::new(1.0); - opacity.animate_to( - 0.0, - Duration::from_millis(900), - crate::motion::Easing::Linear, - crate::motion::MotionPolicy::Full, - ); - Self { columns, rows, opacity } - } -} - -#[derive(Debug, Clone, Copy)] -pub struct SplitReveal { - rect: (f32, f32, f32, f32), - direction: SplitDirection, - motion: crate::motion::Tween, -} - -impl SplitReveal { - pub fn new(rect: (f32, f32, f32, f32), direction: SplitDirection) -> Self { - let mut motion = crate::motion::Tween::new(0.0); - motion.animate_role( - 1.0, - crate::motion::MotionRole::Enter, - crate::motion::MotionPolicy::Full, - ); - Self { rect, direction, motion } - } -} - -#[derive(Debug, Clone, Copy)] -enum NebulaPowerlineIconKind { - Folder, - GitBranch, -} - -#[derive(Debug, Clone, Copy)] -struct NebulaPowerlineIcon { - kind: NebulaPowerlineIconKind, - point: Point, -} - -/// Sidebar SSH HOSTS content: auto-saved destinations (most recent first) + -/// `~/.ssh/config` aliases (file order), deduped, pinned entries floated to -/// the top in pin order. One function so startup and settings hot-reload -/// build the exact same list. -fn merge_ssh_hosts(saved: &[String], pinned: &[String], hidden: &[String]) -> Vec { - let mut hosts: Vec<_> = saved.iter().filter(|host| !hidden.contains(host)).cloned().collect(); - for host in crate::ssh::ssh_config_hosts() { - if !hidden.contains(&host) && !hosts.contains(&host) { - hosts.push(host); - } - } - // Stable sort: pinned first in pin order, the rest keep saved→config order. - hosts.sort_by_key(|h| pinned.iter().position(|p| p == h).unwrap_or(usize::MAX)); - hosts -} - -/// Remove one destination while recording exactly enough list state for Undo. -/// Kept independent from rendering and Credential Manager so the destructive -/// state transition can be regression-tested without touching real secrets. -fn remove_ssh_host_from_lists( - host: &str, - saved: &mut Vec, - pinned: &mut Vec, - hidden: &mut Vec, -) -> (Option, Option, bool) { - let saved_index = saved.iter().position(|entry| entry == host); - let pinned_index = pinned.iter().position(|entry| entry == host); - let was_hidden = hidden.iter().any(|entry| entry == host); - saved.retain(|entry| entry != host); - pinned.retain(|entry| entry != host); - if !was_hidden { - hidden.push(host.to_owned()); - } - (saved_index, pinned_index, was_hidden) -} - -fn restore_ssh_host_to_lists( - host: &str, - saved_index: Option, - pinned_index: Option, - was_hidden: bool, - saved: &mut Vec, - pinned: &mut Vec, - hidden: &mut Vec, -) { - saved.retain(|entry| entry != host); - if let Some(index) = saved_index { - saved.insert(index.min(saved.len()), host.to_owned()); - } - pinned.retain(|entry| entry != host); - if let Some(index) = pinned_index { - pinned.insert(index.min(pinned.len()), host.to_owned()); - } - if !was_hidden { - hidden.retain(|entry| entry != host); - } -} - -/// First word of a committed command line, normalized to a program identity -/// for the sidebar icon: lowercased, path prefix and Windows launcher -/// extensions stripped. `D:\tools\Claude.EXE --resume` → `claude`. -pub(crate) fn extract_program(line: &str) -> Option { - let first = line.trim().split_whitespace().next()?; - let base = first.trim_matches('"').rsplit(['/', '\\']).next().unwrap_or(first); - let mut name = base.to_ascii_lowercase(); - for ext in [".exe", ".cmd", ".bat", ".ps1", ".com"] { - if let Some(stripped) = name.strip_suffix(ext) { - name = stripped.to_owned(); - break; - } - } - (!name.is_empty()).then_some(name) -} - -/// Log replay commands can contain terminal query sequences captured from a -/// different process. Replying writes those answers into the shell's stdin, -/// where they become the next command after the replay process exits. -pub(crate) fn replays_untrusted_terminal_output(line: &str) -> bool { - let words: Vec = line - .split_whitespace() - .take(4) - .map(|word| word.trim_matches(['"', '\'']).to_ascii_lowercase()) - .collect(); - matches!( - words.as_slice(), - [docker, logs, ..] if docker == "docker" && logs == "logs" - ) || matches!( - words.as_slice(), - [docker, compose, logs, ..] - if docker == "docker" && compose == "compose" && logs == "logs" - ) || matches!( - words.as_slice(), - [podman, logs, ..] if podman == "podman" && logs == "logs" - ) || matches!( - words.as_slice(), - [kubectl, logs, ..] if kubectl == "kubectl" && logs == "logs" - ) || matches!(words.as_slice(), [journalctl, ..] if journalctl == "journalctl") -} - -/// Sidebar icon for a running program — Nerd Font glyphs (the chrome text -/// layer renders with Maple NF). AI CLIs get distinct marks; everything else -/// shares a generic "running" play sign. -/// AI clients whose REAL brand mark is drawn in the sidebar as a textured -/// quad (embedded PNG), instead of a lookalike Nerd Font glyph. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum AiLogo { - /// Anthropic's coral starburst. - Claude, - /// OpenAI's blossom knot (codex). - OpenAi, - /// opencode's terminal-frame mark (sst). Two-tone: bright frame + dimmer - /// inner screen block, encoded as luma and multiplied by the theme ink. - OpenCode, - /// Pi's block-built `pi` mark from pi.dev. - Pi, -} - -/// Official logo assets (Wikimedia SVG renders, 64 px, alpha-transparent). -const AI_LOGO_CLAUDE_PNG: &[u8] = include_bytes!("../../../extra/logo/ai_claude.png"); -const AI_LOGO_OPENAI_PNG: &[u8] = include_bytes!("../../../extra/logo/ai_openai.png"); -/// opencode's mark, rasterized from their `favicon.svg`. RGB carries luma -/// (frame=255, block=90), alpha the shape; tinted `ink × luma/255` at runtime. -const AI_LOGO_OPENCODE_PNG: &[u8] = include_bytes!("../../../extra/logo/ai_opencode.png"); -const AI_LOGO_PI_PNG: &[u8] = include_bytes!("../../../extra/logo/ai_pi.png"); - -/// Texture ids for chrome logos live far above the inline-image counter -/// (which starts at 1), so the two id spaces can share the renderer cache. -const AI_LOGO_ID_BASE: u64 = 1 << 62; - -/// Real-logo mapping for AI clients; everything else falls back to the -/// [`program_icon`] glyph. Gated on PNG support: without it there is no -/// texture to draw and the glyph must stay. -pub(crate) fn ai_logo(program: &str) -> Option { - if cfg!(any(not(feature = "png"), target_os = "macos")) { - return None; - } - match program { - "claude" => Some(AiLogo::Claude), - "codex" => Some(AiLogo::OpenAi), - "opencode" => Some(AiLogo::OpenCode), - "pi" => Some(AiLogo::Pi), - _ => None, - } -} - -/// Drop a `file://` / `file:///` scheme so a local link reads as a plain -/// path in the hover tooltip. On Windows `file:///D:/x` → `D:/x` (the slash -/// before the drive letter goes too); non-`file:` URIs pass through. -/// -/// 2026-07-26 用户反馈:`ls --hyperlink` 会把非 ASCII 文件名 percent-encode -/// (一个汉字 → `%E6%98%9F` 九列),48 列预算被编码噪声吃光后再截尾,提示 -/// "显示不全"。file 路径解码后再展示;其他 scheme 保持原样(URL 的编码 -/// 属于其身份,不替它翻译)。 -fn strip_file_scheme(uri: &str) -> String { - match uri.strip_prefix("file:///").or_else(|| uri.strip_prefix("file://")) { - // `file:///D:/x` yields `D:/x`; a UNC-ish `file://host/x` keeps `host/x`. - Some(rest) => percent_decode_lossy(rest), - None => uri.to_owned(), - } -} - -/// Decode `%XX` escapes as UTF-8 bytes for DISPLAY. Malformed escapes pass -/// through verbatim; if the decoded bytes are not valid UTF-8 the original -/// string is returned unchanged — a tooltip must never invent mojibake. -fn percent_decode_lossy(s: &str) -> String { - if !s.contains('%') { - return s.to_owned(); - } - let bytes = s.as_bytes(); - let mut out = Vec::with_capacity(bytes.len()); - let mut i = 0; - while i < bytes.len() { - let decoded = (bytes[i] == b'%' && i + 2 < bytes.len()) - .then(|| { - let hi = (bytes[i + 1] as char).to_digit(16)?; - let lo = (bytes[i + 2] as char).to_digit(16)?; - Some((hi * 16 + lo) as u8) - }) - .flatten(); - match decoded { - Some(byte) => { - out.push(byte); - i += 3; - }, - None => { - out.push(bytes[i]); - i += 1; - }, - } - } - String::from_utf8(out).unwrap_or_else(|_| s.to_owned()) -} - -/// Truncate `s` to at most `budget` display columns (CJK counts as 2), keeping -/// the TAIL and prefixing `…` when cut — the filename end is what a hover -/// tooltip most needs. Returns a string whose display width is `<= budget`. -fn fit_tail(s: &str, budget: usize) -> String { - let width = |c: char| c.width().unwrap_or(0); - let total: usize = s.chars().map(width).sum(); - if total <= budget { - return s.to_owned(); - } - if budget == 0 { - return String::new(); - } - // Reserve one column for the ellipsis, then take chars from the end until - // the reserved room fills up. - let room = budget.saturating_sub(1); - let mut kept = std::collections::VecDeque::new(); - let mut used = 0; - for c in s.chars().rev() { - let w = width(c); - if used + w > room { - break; - } - used += w; - kept.push_front(c); - } - let mut out = String::with_capacity(kept.len() + 1); - out.push('…'); - out.extend(kept); - out -} - -pub(crate) fn program_icon(program: &str) -> &'static str { - match program { - "claude" => "\u{f0ce5}", // md-star-four-points (Claude spark) - "codex" => "\u{f02d8}", // md-hexagon (OpenAI mark) - "gemini" => "\u{f0ce6}", // md-star-four-points-outline - "copilot" => "\u{f4b8}", // oct-copilot - "cursor" | "cursor-agent" => "\u{f0ec3}", // md-cursor-default-outline - "aider" | "goose" | "crush" | "ollama" => "\u{f06a9}", // md-robot - "opencode" => "\u{f489}", // oct-terminal - "pi" => "\u{f135}", // fa-code - "git" | "gh" | "lazygit" => "\u{f418}", // oct-git-branch - "vim" | "nvim" | "vi" | "hx" | "nano" => "\u{e62b}", // custom-vim - "ssh" | "mosh" => "\u{f489}", // oct-terminal (remote) - "cargo" | "rustc" => "\u{e7a8}", // dev-rust - "node" | "npm" | "pnpm" | "yarn" | "bun" | "deno" => "\u{e718}", // dev-nodejs - "python" | "python3" | "pip" | "uv" => "\u{e73c}", // dev-python - "docker" | "podman" => "\u{e7b0}", // dev-docker - _ => "\u{f04b}", // fa-play (generic busy) - } -} - -/// The UI font role (wezterm's `window_frame` pattern): the size chrome -/// typography rasterizes at and the cell chrome layout steps by. Anchored to -/// the config font at the window's DPI — never to the terminal zoom. Stage 3 -/// exposes family/size as user config. -#[derive(Clone, Copy, Debug)] -struct NebulaUiFont { - /// Role size in physical px (config size × DPI scale). - px: f32, - /// Cell the chrome layout steps by, from the role's REAL rasterized - /// metrics (`compute_cell_size` over `GlyphCache::set_ui_font_size`). - cell: (f32, f32), -} - -pub(crate) fn nebula_debug_log(message: impl AsRef) { - use std::io::Write as _; - - static ENABLED: std::sync::OnceLock = std::sync::OnceLock::new(); - if !*ENABLED.get_or_init(|| { - std::env::var("NEBULA_DEBUG_LOG").is_ok_and(|value| { - let value = value.trim(); - !value.is_empty() && value != "0" && !value.eq_ignore_ascii_case("false") - }) - }) { - return; - } - - let ts = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| format!("{}.{:03}", d.as_secs(), d.subsec_millis())) - .unwrap_or_else(|_| "0.000".to_owned()); - let path = nebula_data_dir().join("nebula_debug.log"); - if let Ok(mut file) = std::fs::OpenOptions::new().create(true).append(true).open(path) { - let _ = writeln!(file, "[{ts} pid={}] {}", std::process::id(), message.as_ref()); - } -} - -/// Unconditional variant of [`nebula_debug_log`] for the link-click diagnosis: -/// clicks are rare (no perf concern), and requiring a relaunch with -/// NEBULA_DEBUG_LOG=1 would double every remote-debug round-trip. Remove or -/// downgrade to the gated logger once the link path is verified. -pub(crate) fn nebula_link_log(message: impl AsRef) { - use std::io::Write as _; - - let ts = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| format!("{}.{:03}", d.as_secs(), d.subsec_millis())) - .unwrap_or_else(|_| "0.000".to_owned()); - let path = nebula_data_dir().join("nebula_debug.log"); - if let Ok(mut file) = std::fs::OpenOptions::new().create(true).append(true).open(path) { - let _ = writeln!(file, "[{ts}] {}", message.as_ref()); - } -} - -/// Directory holding Nebula's persistent state (`%APPDATA%\Nebula` or -/// `~/.config/Nebula`), created on demand. Settings live here next to the -/// history file managed by [`crate::nebula_history`] and the session -/// snapshot managed by [`crate::session`]. -pub(crate) fn nebula_data_dir() -> PathBuf { - let base = std::env::var_os("APPDATA") - .map(PathBuf::from) - .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config"))) - .unwrap_or_else(std::env::temp_dir); - let dir = base.join("Nebula"); - let _ = std::fs::create_dir_all(&dir); - dir -} - -/// Read one raw `key=value` from `nebula_settings.txt` (case-insensitive key). -/// The typed loader is `settings::nebula_settings_load`; this is for the few -/// callers (e.g. the default-shell id) that want the raw string verbatim. -pub(crate) fn nebula_settings_value(key: &str) -> Option { - let data = std::fs::read_to_string(nebula_data_dir().join("nebula_settings.txt")).ok()?; - data.lines().find_map(|line| { - let (k, v) = line.split_once('=')?; - k.trim().eq_ignore_ascii_case(key).then(|| v.trim().to_owned()) - }) -} - -#[cfg(windows)] -fn nebula_pathexts() -> Vec { - std::env::var("PATHEXT") - .unwrap_or_else(|_| ".COM;.EXE;.BAT;.CMD;.PS1".to_owned()) - .split(';') - .filter_map(|ext| { - let ext = ext.trim(); - if ext.is_empty() { - None - } else if ext.starts_with('.') { - Some(ext.to_ascii_lowercase()) - } else { - Some(format!(".{}", ext.to_ascii_lowercase())) - } - }) - .collect() -} - -#[cfg(windows)] -fn nebula_command_name(path: &Path, pathexts: &[String]) -> Option { - let ext = path.extension()?.to_str()?; - let ext = format!(".{}", ext).to_ascii_lowercase(); - if !pathexts.iter().any(|known| known == &ext) { - return None; - } - path.file_stem()?.to_str().filter(|name| !name.is_empty()).map(ToOwned::to_owned) -} - -#[cfg(not(windows))] -fn nebula_command_name(path: &Path) -> Option { - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - if path.metadata().ok()?.permissions().mode() & 0o111 == 0 { - return None; - } - } - path.file_name()?.to_str().filter(|name| !name.is_empty()).map(ToOwned::to_owned) -} - -fn nebula_path_commands() -> Vec { - let Some(path_env) = std::env::var_os("PATH") else { - return Vec::new(); - }; - - let mut commands = Vec::new(); - let mut seen = HashSet::new(); - #[cfg(windows)] - let pathexts = nebula_pathexts(); - - for dir in std::env::split_paths(&path_env).filter(|dir| !dir.as_os_str().is_empty()) { - let Ok(entries) = std::fs::read_dir(dir) else { - continue; - }; - - for entry in entries.flatten() { - let Ok(file_type) = entry.file_type() else { - continue; - }; - if !file_type.is_file() { - continue; - } - - #[cfg(windows)] - let command = nebula_command_name(&entry.path(), &pathexts); - #[cfg(not(windows))] - let command = nebula_command_name(&entry.path()); - - if let Some(command) = command { - #[cfg(windows)] - let key = command.to_ascii_lowercase(); - #[cfg(not(windows))] - let key = command.clone(); - - // PATH 里同名 shim/真实可执行文件经常重复;这里只保留第一个, - // 避免每次输入首 token 时 ghost 在等价候选间跳动。 - if seen.insert(key) { - commands.push(command); - } - } - } - } - - commands.sort_by(|a, b| { - #[cfg(windows)] - { - a.to_ascii_lowercase().cmp(&b.to_ascii_lowercase()).then(a.cmp(b)) - } - #[cfg(not(windows))] - { - a.cmp(b) - } - }); - commands -} - -/// Collect external-shell command names: PATH executables plus, on Windows, the -/// running shell's cmdlets / functions / aliases (which never appear on PATH). -/// Merged and de-duplicated (case-insensitively on Windows). -fn nebula_collect_commands() -> Vec { - let mut commands = nebula_path_commands(); - - #[cfg(windows)] - { - let mut seen: HashSet = commands.iter().map(|c| c.to_ascii_lowercase()).collect(); - for command in nebula_powershell_commands() { - if seen.insert(command.to_ascii_lowercase()) { - commands.push(command); - } - } - commands.sort_by(|a, b| a.to_ascii_lowercase().cmp(&b.to_ascii_lowercase()).then(a.cmp(b))); - } - - commands -} - -/// PowerShell cmdlets / functions / aliases via a one-shot `Get-Command`, run -/// with `-NoProfile` to avoid the user's profile cost. Best-effort: any failure -/// (no PowerShell, error, parse problem) yields an empty list. -#[cfg(windows)] -fn nebula_powershell_commands() -> Vec { - // CREATE_NO_WINDOW: Nebula is a GUI-subsystem process, so a console child - // would otherwise pop up (and instantly vanish) a visible PowerShell - // window at startup. - use std::os::windows::process::CommandExt; - let output = std::process::Command::new("powershell") - .args([ - "-NoProfile", - "-NonInteractive", - "-Command", - "Get-Command -CommandType Cmdlet,Function,Alias -ErrorAction SilentlyContinue \ - | Select-Object -ExpandProperty Name", - ]) - .creation_flags(windows_sys::Win32::System::Threading::CREATE_NO_WINDOW) - .output(); - - match output { - Ok(out) if out.status.success() => String::from_utf8_lossy(&out.stdout) - .lines() - .map(str::trim) - .filter(|line| !line.is_empty()) - .map(ToOwned::to_owned) - .collect(), - _ => Vec::new(), - } -} - -/// Process-wide handle to the command list, populated once on a background -/// thread so the (PowerShell-invoking) collection never blocks window startup. -/// Readers see an empty list until collection finishes, then the merged set. -fn nebula_commands_handle() -> std::sync::Arc>> { - static COMMANDS: std::sync::OnceLock>>> = - std::sync::OnceLock::new(); - COMMANDS - .get_or_init(|| { - let shared = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); - let bg = shared.clone(); - std::thread::spawn(move || { - let commands = nebula_collect_commands(); - if let Ok(mut guard) = bg.lock() { - *guard = commands; - } - }); - shared - }) - .clone() -} - -fn nebula_command_hint<'a>(commands: &'a [String], prefix: &str) -> Option<&'a str> { - if prefix.is_empty() { - return None; - } - - // 完整命令已经可执行时必须停止补全;否则 `claude` 会跳过自身,继续 - // 命中 `claude-agent-acp` 这类更长的 PATH 邻居。 - #[cfg(windows)] - let exact = commands.iter().any(|command| command.eq_ignore_ascii_case(prefix)); - #[cfg(not(windows))] - let exact = commands.iter().any(|command| command == prefix); - if exact { - return None; - } - - commands.iter().find_map(|command| { - if command.len() <= prefix.len() || !command.is_char_boundary(prefix.len()) { - return None; - } - let (head, rem) = command.split_at(prefix.len()); - #[cfg(windows)] - let matches = head.eq_ignore_ascii_case(prefix); - #[cfg(not(windows))] - let matches = head == prefix; - - matches.then_some(rem) - }) -} - -fn nebula_is_command_position(line: &str) -> bool { - !line.contains([' ', '\t']) - && !line.contains(['/', '\\']) - && line.as_bytes().get(1) != Some(&b':') -} - -fn nebula_path_wants_directory(line: &str) -> bool { - let command = line.split([' ', '\t']).next().unwrap_or(""); - matches!( - command.to_ascii_lowercase().as_str(), - "cd" | "chdir" | "pushd" | "sl" | "set-location" - ) -} - -#[derive(Debug)] -pub enum Error { - /// Error with window management. - Window(window::Error), - - /// Error dealing with fonts. - Font(crossfont::Error), - - /// Error in renderer. - Render(renderer::Error), - - /// Error during context operations. - Context(glutin::error::Error), -} - -impl std::error::Error for Error { - fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { - match self { - Error::Window(err) => err.source(), - Error::Font(err) => err.source(), - Error::Render(err) => err.source(), - Error::Context(err) => err.source(), - } - } -} - -impl fmt::Display for Error { - fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { - match self { - Error::Window(err) => err.fmt(f), - Error::Font(err) => err.fmt(f), - Error::Render(err) => err.fmt(f), - Error::Context(err) => err.fmt(f), - } - } -} - -impl From for Error { - fn from(val: window::Error) -> Self { - Error::Window(val) - } -} - -impl From for Error { - fn from(val: crossfont::Error) -> Self { - Error::Font(val) - } -} - -impl From for Error { - fn from(val: renderer::Error) -> Self { - Error::Render(val) - } -} - -impl From for Error { - fn from(val: glutin::error::Error) -> Self { - Error::Context(val) - } -} - -#[derive(Default, Clone, Debug, PartialEq, Eq)] -pub struct DisplayUpdate { - pub dirty: bool, - - dimensions: Option>, - cursor_dirty: bool, - font: Option, - terminal_colors_dirty: bool, -} - -impl DisplayUpdate { - pub fn dimensions(&self) -> Option> { - self.dimensions - } - - pub fn font(&self) -> Option<&Font> { - self.font.as_ref() - } - - pub fn cursor_dirty(&self) -> bool { - self.cursor_dirty - } - - pub fn terminal_colors_dirty(&self) -> bool { - self.terminal_colors_dirty - } - - pub fn set_dimensions(&mut self, dimensions: PhysicalSize) { - self.dimensions = Some(dimensions); - self.dirty = true; - } - - pub fn set_font(&mut self, font: Font) { - self.font = Some(font); - self.dirty = true; - } - - pub fn set_cursor_dirty(&mut self) { - self.cursor_dirty = true; - self.dirty = true; - } - - fn set_terminal_colors_dirty(&mut self) { - self.terminal_colors_dirty = true; - self.dirty = true; - } -} - -/// The display wraps a window, font rasterizer, and GPU renderer. -pub struct Display { - pub window: Window, - - pub size_info: SizeInfo, - - /// Hint highlighted by the mouse. - pub highlighted_hint: Option, - /// Frames since hint highlight was created. - highlighted_hint_age: usize, - - /// Hint highlighted by the vi mode cursor. - pub vi_highlighted_hint: Option, - /// Frames since hint highlight was created. - vi_highlighted_hint_age: usize, - - pub raw_window_handle: RawWindowHandle, - - /// UI cursor visibility for blinking. - pub cursor_hidden: bool, - - /// When a split is active, the focused pane's geometry. Input and hint - /// hit-testing use this (via `pane_view()`) so mouse coordinates map into - /// the focused half-width grid rather than the full window, which would - /// otherwise index past the grid and panic. - pub nebula_pane_view: Option, - - /// Transient "cols × rows" HUD shown briefly after a window resize; it fades - /// out over ~0.9s. `None` when nothing is showing. - nebula_resize_hud: Option, - /// Skip the first resize (window creation) so no HUD flashes at startup. - nebula_resize_hud_armed: bool, - - /// Indexed, persistent command history used to hint a whole previous - /// command from its prefix. - nebula_history: crate::nebula_history::NebulaHistory, - /// Process-wide frecency model fed only by successful shell cwd reports. - directory_history: crate::directory_history::DirectoryHistory, - /// Executable commands for first-token completion: PATH executables plus, on - /// Windows, the shell's cmdlets/functions/aliases. Filled on a background - /// thread so the PowerShell probe never blocks startup. - nebula_commands: std::sync::Arc>>, - /// Per-displayed-tab animated draw-x, eased toward the laid-out / drag - /// target each frame so tab reorder "make way" slides instead of snapping. - nebula_tab_anim: Vec, - /// Active scrollbar drag: the pointer's y-offset inside the thumb captured - /// at press time, so the thumb tracks the pointer without jumping. - pub nebula_scrollbar_drag: Option, - /// Slide-in reveal for a freshly created split pane: its final rect, the - /// split direction and the animation start time. Drawn as a shrinking - /// bg-coloured cover in `draw_split_overlays`; cleared when done. - pub nebula_split_reveal: Option, - /// Pending destructive-action confirmation (close with busy children / - /// multi-line paste), drawn as a centered modal that owns the keyboard. - pub nebula_confirm: Option, - /// Screen rects of the confirm modal's (primary, cancel) buttons, written - /// by `draw_confirm_modal` each frame so the mouse hit-test can never - /// drift from what was actually drawn. `None` while no modal shows. - pub nebula_confirm_buttons: Option<((f32, f32, f32, f32), (f32, f32, f32, f32))>, - /// Most recently deleted SSH host while its action is still reversible. - nebula_ssh_delete_undo: Option, - /// 焦点 pane 的助手建议条快照(spec 001):每帧由 WindowContext 从 - /// `NebulaPaneState::ai_fix` 同步,绘制层只认自己的字段(撤销条同款)。 - pub nebula_ai_fix_bar: Option, - /// Undo button geometry published by the draw pass for exact hit-testing. - nebula_ssh_delete_undo_rect: Option<(f32, f32, f32, f32)>, - nebula_ssh_delete_undo_hover: bool, - pub nebula_ssh_editor: Option, - pub nebula_ssh_editor_rects: Option, - nebula_ssh_editor_open: bool, - nebula_ssh_editor_hover: SshEditorHit, - /// 「测试连接」点击时暂存的请求;input 层随后取走并交给 SSH runtime。 - /// display 不持有事件代理,这一格就是点击→网络之间的交接台。 - nebula_ssh_test_request: Option, - /// Monotonic identity for SSH editor test requests. Results must match the - /// exact attempt, not merely a destination that a user can edit back to. - nebula_ssh_test_seq: u64, - /// Inline images visible this frame, collected per pane during - /// `draw_pane` (grid lock + pane viewport at hand) and drawn in one - /// full-window pass in `present_frame` — mid-pane GL viewport swaps are - /// fragile, one batched pass is not. - nebula_frame_images: Vec<(u64, std::sync::Arc>, (u32, u32), (f32, f32, f32, f32))>, - - /// Theme currently painted. In automatic mode this is the light/dark - /// member resolved from `nebula_theme_preference` and the system state. - pub nebula_theme: NebulaTheme, - /// Theme family explicitly selected by the user and written to settings. - /// Kept separate from the painted theme so an automatic light switch does - /// not forget which dark theme to restore later. - nebula_theme_preference: NebulaTheme, - pub nebula_follow_system_theme: bool, - nebula_system_theme: Option, - /// User-configured winit decoration override. Automatic Nebula theming - /// temporarily clears it because winit only emits `ThemeChanged` while a - /// window is following the operating system. - nebula_window_theme_override: Option, - pub nebula_settings_open: bool, - pub nebula_special_tab_active: bool, - nebula_language_preference: LanguagePreference, - nebula_language: UiLanguage, - /// Paths from the last successful app configuration generation. - nebula_config_paths: Vec, - /// Settings content scroll offset in scaled px (0 = top of the section). - nebula_settings_scroll: f32, - /// Command palette (Ctrl+Shift+P): fuzzy launcher model + UI state. - nebula_palette: command_palette::CommandPalette, - /// Installed shells, detected once (registry + filesystem scan) and cached - /// for the new-tab dropdown. `None` until the first menu open. - nebula_detected_shells: Option>, - /// Right-side drawer: directory tree / git status of the focused cwd. - pub nebula_side_panel: side_panel::SidePanel, - /// Remote file drawer opened from an SSH destination context menu. - pub nebula_sftp_panel: Option, - /// Shared chrome animation state. All sidebar/drawer transitions step here - /// so easing/timing does not get scattered across render code. - nebula_ui_anims: NebulaUiAnims, - /// Active sidebar section inside the settings panel. - nebula_settings_section: NebulaSettingsSection, - nebula_chrome_hover: ChromeHit, - /// Bottom-docked queue affordance. The entry state lives separately from - /// Tabs/SSH so real Agent events can be connected without changing chrome - /// geometry or input contracts again. - nebula_message_queue_entry: message_queue_entry::MessageQueueEntry, - nebula_settings_hover: SettingsHit, - /// Active settings opacity drag: target plus the screen-space track used - /// for pointer-to-value mapping. Values persist only when the drag ends. - pub nebula_settings_opacity_drag: Option<(settings::SettingsOpacityTarget, f32, f32)>, - /// Unified native right-click menu shared by tab and SSH rows. The menu - /// owns its short open/close animation so no input path needs timers. - nebula_context_menu: Option, - nebula_tab_labels: Vec, - /// Per-tab custom accent. `None` follows the live theme accent. - nebula_tab_colors: Vec>, - nebula_tab_bells: Vec, - /// Per-tab "command is running" flags driving the sidebar spinners. - nebula_tab_running: Vec, - /// Per-tab real AI brand logo (claude/codex), textured over the icon slot. - nebula_tab_logos: Vec>, - /// Decoded (+theme-tinted) logo pixels with stable renderer texture ids, - /// keyed by (logo, ink). Decode and tint run once per key. - nebula_ai_logo_cache: - std::collections::HashMap<(AiLogo, [u8; 3]), (u64, std::sync::Arc>, (u32, u32))>, - /// Decoded shell icons (full-color PNGs) with stable texture ids, keyed by - /// shell id (pwsh/cmd/nu/wsl:Ubuntu). Decode runs once per id. - nebula_shell_icon_cache: - std::collections::HashMap>, (u32, u32))>, - /// Brand logos staged by the chrome pass, drawn AFTER all chrome text. - /// draw_inline_image flips viewport/blend around its draw; interleaving - /// it with chrome text kills every glyph batch after it, so the textured - /// icons get their own pass at the very end of the frame. - nebula_chrome_logo_draws: Vec<(u64, std::sync::Arc>, (u32, u32), (f32, f32, f32, f32))>, - nebula_active_tab: usize, - /// In-progress tab reorder drag, if the pointer is grabbing a tab. - nebula_tab_drag: Option, - /// Whether the tab bar may be reordered right now (false during a split, - /// where the bar hides a pane and reordering is ambiguous). - nebula_tabs_reorderable: bool, - /// Whether the tab sidebar is folded away. When collapsed the grid - /// reclaims the full width and only a reveal button remains in the top bar. - nebula_sidebar_collapsed: bool, - /// SSH host aliases from `~/.ssh/config` for the sidebar's "SSH HOSTS" - /// section, pinned entries first (see `nebula_pinned_hosts`). - pub nebula_ssh_hosts: Vec, - /// Host names the user pinned to the top (right-click), persisted in the - /// runtime settings file so the order survives restarts. - nebula_pinned_hosts: Vec, - /// Destinations auto-saved from typed `ssh` commands once the connection - /// confirmed (see `NebulaPaneState::pending_ssh_host`), most recent - /// first, persisted. Merged into `nebula_ssh_hosts` after the pinned - /// block, before the `~/.ssh/config` aliases. - nebula_saved_hosts: Vec, - /// User-deleted SSH config aliases. Config files remain untouched; hiding - /// them here makes Delete stable instead of letting the next merge revive - /// the row immediately. - nebula_hidden_hosts: Vec, - /// Accordion fold state of the two sidebar sections. - nebula_tabs_section_open: bool, - nebula_hosts_section_open: bool, - /// Per-section scroll offsets, in whole rows (clamped by the layout). - nebula_tabs_scroll: usize, - nebula_hosts_scroll: usize, - /// A grid resize happened whose PTY notification is deferred until the - /// interactive resize settles (see `Topic::NebulaResizeSettle`): the - /// in-box ConPTY repaints the whole viewport per resize, so notifying it - /// on every drag tick floods the scrollback with shredded repaints. - pub nebula_pty_resize_pending: bool, - /// Whether inline ghost-text suggestions are shown at all. - pub nebula_ghost_enabled: bool, - /// Which key accepts a ghost suggestion. - pub nebula_accept: AcceptKey, - /// Default executor used by new sessions when no explicit shell is configured. - pub nebula_shell: NebulaShell, - /// Raw default-shell id when the user picked a detected shell the 2-value - /// `nebula_shell` enum can't represent (cmd/pwsh/nu/wsl:X). Drives the - /// settings row label and is persisted verbatim. - pub nebula_shell_id: Option, - /// User-selected working directory for newly created terminal tabs. - pub nebula_startup_directory: Option, - /// Whether new sessions print the Nebula welcome/fetch screen. - pub nebula_fetch_enabled: bool, - /// Whether the injected prompt uses Nebula's powerline segments. - pub nebula_powerline_enabled: bool, - /// Closing a window detaches its panes into the resident process for - /// re-attach (multiplexer restore). Off = close kills the shells. - pub nebula_keep_session: bool, - /// Runtime window opacity controlled from Nebula settings. - pub nebula_window_opacity: f32, - /// Which settings combobox (floating option list) is expanded, if any. - /// One field for every dropdown: shell, font, wallpaper fit/alignment, - /// language, accept key and cursor shape all share the widget. - pub nebula_settings_dropdown: Option, - /// Default cursor shape/blink from settings. Programs may still override - /// the shape with DECSCUSR escapes (vim's mode cursor keeps working). - pub nebula_cursor_shape: CursorShape, - pub nebula_cursor_blink: bool, - /// 交互: WT-style copyOnSelect. Off = right-click copies / pastes instead. - pub nebula_copy_on_select: bool, - /// 全宽字形(CJK 等)bold run 用 Regular 字形(粗体提亮不加粗,#4)。 - pub nebula_cjk_bold_regular: bool, - /// User keybinding overrides, raw `(combo, action)` from - /// `nebula_settings.txt` in file order (persisted verbatim). - pub(crate) nebula_keybinds: Vec<(String, String)>, - /// 快速终端全局快捷键的持久值;系统注册由顶层 Processor 负责。 - pub nebula_quick_terminal_hotkey: String, - /// 等待 Processor 确认注册的新值。设置页不会绕过全局管理器自行假设成功。 - pub(crate) nebula_quick_hotkey_request: Option, - pub(crate) nebula_quick_hotkey_error: Option, - /// Parsed override table (newest-first); consulted by - /// `process_key_bindings` BEFORE the config table (spec 002). - pub nebula_keymap: Vec, - /// When `Some(row)`, the Keymap settings page is capturing a new combo - /// for `keymap::EDITABLE_ACTIONS[row]` and the keyboard is owned by it. - pub nebula_keymap_capture: Option, - /// 捕获态实时回显:当前按住的修饰键前缀("Ctrl+Shift+")。松开清空。 - pub nebula_keymap_capture_preview: String, - pub nebula_font_family: String, - nebula_font_families: Vec, - nebula_font_notice: Option, - /// Optional runtime clear/background color controlled from settings. - pub nebula_background: Option, - /// Optional background image path drawn as a full-window wallpaper. - pub nebula_background_image: Option, - /// Wallpaper alpha, separate from the window opacity to preserve text contrast. - pub nebula_background_image_opacity: f32, - /// Windows Terminal-compatible wallpaper sizing and anchor settings. - pub nebula_background_image_fit: BackgroundImageFit, - pub nebula_background_image_alignment: BackgroundImageAlignment, - /// Off by default: wallpapers stay inside terminal content. Enabling this - /// requires an explicit warning confirmation because it reduces chrome contrast. - pub nebula_background_image_cover_chrome: bool, - nebula_settings_mtime: Option, - nebula_bg_palette_index: usize, - /// 背景色浮层的 16 进制草稿与聚焦态(浮层关闭时归零)。 - nebula_bg_hex_input: String, - pub(crate) nebula_bg_hex_active: bool, - /// 设置→高级→同步(WebDAV)的四个输入草稿:url、用户名、WebDAV - /// 密码、E2E 口令。密码/口令只是「待保存」缓冲——提交即入凭据 - /// 管理器并清空,明文从不驻留。 - nebula_sync_inputs: [String; 4], - /// 聚焦的同步输入框(0..4,对应 [`nebula_sync_inputs`] 下标)。 - pub(crate) nebula_sync_focus: Option, - nebula_sync_auto_pull: bool, - /// 凭据管理器里已有 [密码, 口令](只存在性,绝不回读明文进 UI)。 - nebula_sync_secret_set: [bool; 2], - /// 最近一次同步动作的结果 `(message, is_error)`,画在按钮行下方。 - pub(crate) nebula_sync_status: Option<(String, bool)>, - nebula_sync_busy: bool, - - /// Tab rename state: when `Some(index, text)`, a text input is shown over - /// tab `index` with the current edit buffer `text`. The user types to edit, - /// Enter commits, Esc cancels (Windows Terminal double-click rename). - pub nebula_tab_rename: Option<(usize, String)>, - /// True for the instant after a rename begins: the whole existing name - /// reads as "selected" (nushell-style blue fill) and the first typed - /// character replaces it wholesale. Cleared on the first edit. - pub nebula_tab_rename_select_all: bool, - /// Insertion caret inside the rename buffer, as a CHAR index (0..=chars). - /// Click-to-place, arrow keys, and mid-string insert/delete all go - /// through this — a rename is a real text field, not append-only. - pub nebula_tab_rename_caret: usize, - /// Left pixel of the rename buffer's first glyph, stashed by `draw_chrome` - /// each frame the box shows. Click-to-place-caret maps pointer X through - /// this — recomputing the draw-side layout in the input path would just - /// let the two drift. - pub nebula_tab_rename_text_x: f32, - - pub visual_bell: VisualBell, - - /// Mapped RGB values for each terminal color. - pub colors: List, - /// The user's configured color scheme, untouched by theme restyling — - /// the base every `apply_term_colors` starts from. - nebula_default_colors: List, - /// Draw-time adaptation for application-owned RGB colors. The terminal - /// grid retains the original values so protocol state and copying are exact. - terminal_color_resolver: terminal_color::TerminalColorResolver, - - /// State of the keyboard hints. - pub hint_state: HintState, - - /// Unprocessed display updates. - pub pending_update: DisplayUpdate, - - /// The renderer update that takes place only once before the actual rendering. - pub pending_renderer_update: Option, - - /// The ime on the given display. - pub ime: Ime, - - /// The state of the timer for frame scheduling. - pub frame_timer: FrameTimer, - - /// Damage tracker for the given display. - pub damage_tracker: DamageTracker, - - /// Font size used by the window. - pub font_size: FontSize, - - /// UI 字体角色(wezterm `window_frame` 范式):chrome 排版锚定在这个 - /// 状态上,永不跟随终端缩放。Ctrl+滚轮 / 设置 spinner 只改 - /// `font_size`(终端网格与跟随它的文档查看器)。阶段 3 将把 - /// family/size 暴露为独立配置。 - nebula_ui_font: NebulaUiFont, - - /// 抽屉视图是否路由到 SFTP(聚焦 pane 的 SSH 身份与面板连接匹配)。 - /// 见 [`Self::route_side_panel`]。 - nebula_sftp_routed: bool, - - // Mouse point position when highlighting hints. - hint_mouse_point: Option, - - renderer: ManuallyDrop, - renderer_preference: Option, - - surface: ManuallyDrop>, - - context: ManuallyDrop, - - glyph_cache: GlyphCache, - meter: Meter, -} - -/// 计算全屏 TUI 在网格之外需要补齐的垂直背景带。内部边缘必须停在 Pane 边界, -/// 只有接触终端外沿的 Pane 才能继续延伸到圆角卡片边缘。 -fn alt_screen_vertical_padding_bands( - window: &SizeInfo, - pane: &SizeInfo, - card_y: f32, - card_height: f32, -) -> [Option<(f32, f32)>; 2] { - const EDGE_EPSILON: f32 = 0.5; - - let window_grid_top = window.padding_y(); - let window_grid_bottom = window.height() - window.padding_bottom(); - let pane_top = pane.padding_y(); - let pane_bottom = pane.height() - pane.padding_bottom(); - let grid_bottom = pane_top + pane.screen_lines() as f32 * pane.cell_height(); - - let band = |start: f32, end: f32| { - let height = (end - start).max(0.0); - (height > f32::EPSILON).then_some((start, height)) - }; - - let top = if (pane_top - window_grid_top).abs() <= EDGE_EPSILON { - band(card_y, pane_top) - } else { - None - }; - let bottom_limit = if (pane_bottom - window_grid_bottom).abs() <= EDGE_EPSILON { - card_y + card_height - } else { - pane_bottom - }; - - [top, band(grid_bottom, bottom_limit)] -} - -/// 本地文件/目录送回收站(Windows:`SHFileOperationW` + `FOF_ALLOWUNDO`)。 -/// 不弹系统确认(Nebula 自己的确认弹窗已经问过了),不弹系统错误框—— -/// 失败以 Err 返回给面板提示条。 -#[cfg(windows)] -fn send_to_recycle_bin(path: &std::path::Path) -> Result<(), String> { - use std::os::windows::ffi::OsStrExt; - - use windows_sys::Win32::UI::Shell::{ - FO_DELETE, FOF_ALLOWUNDO, FOF_NOCONFIRMATION, FOF_NOERRORUI, FOF_SILENT, SHFILEOPSTRUCTW, - SHFileOperationW, - }; - - if !path.exists() { - return Err("路径已不存在".to_owned()); - } - // pFrom 是双 NUL 结尾的路径列表。 - let mut from: Vec = path.as_os_str().encode_wide().collect(); - from.push(0); - from.push(0); - let mut op = SHFILEOPSTRUCTW { - hwnd: std::ptr::null_mut(), - wFunc: FO_DELETE as u32, - pFrom: from.as_ptr(), - pTo: std::ptr::null(), - fFlags: (FOF_ALLOWUNDO | FOF_NOCONFIRMATION | FOF_SILENT | FOF_NOERRORUI) as u16, - fAnyOperationsAborted: 0, - hNameMappings: std::ptr::null_mut(), - lpszProgressTitle: std::ptr::null(), - }; - let code = unsafe { SHFileOperationW(&mut op) }; - if code == 0 && op.fAnyOperationsAborted == 0 { - Ok(()) - } else { - Err(format!("系统拒绝(代码 {code})")) - } -} - -#[cfg(not(windows))] -fn send_to_recycle_bin(path: &std::path::Path) -> Result<(), String> { - // 非 Windows 暂无回收站集成:递归删除前置确认已由弹窗承担。 - let result = if path.is_dir() { - std::fs::remove_dir_all(path) - } else { - std::fs::remove_file(path) - }; - result.map_err(|err| err.to_string()) -} - -/// Prefer the event loop's system-wide appearance over the window theme. -/// -/// On Windows, `Window::theme()` is a cached per-window value and can still -/// contain the previous manual override immediately after `set_theme(None)`. -fn system_theme_snapshot( - event_loop_theme: Option, - window_theme: Option, -) -> Option { - event_loop_theme.or(window_theme) -} - -impl Display { - pub fn new( - window: Window, - gl_context: NotCurrentContext, - config: &UiConfig, - system_theme: Option, - _tabbed: bool, - ) -> Result { - let raw_window_handle = window.raw_window_handle(); - - let scale_factor = window.scale_factor as f32; - let settings_init = settings::nebula_settings_load(config); - let rasterizer = Rasterizer::new()?; - crate::boot_trace("rasterizer ready"); - - // 设置里保存过字号则优先生效(逻辑 px × 缩放),否则跟随配置文件; - // Ctrl+滚轮 / 设置 spinner 改过的字号因此在重启后保持。 - let font_size = settings_init - .font_size - .map(|px| FontSize::from_px(px * scale_factor)) - .unwrap_or_else(|| config.font.size().scale(scale_factor)); - // UI 锚定字号始终取配置字号(0.7 默认):终端字号的持久化缩放不 - // 影响 chrome。2026-07-28 曾试过锚定 settings 保存字号,实测 16.3px - // 让 chrome 明显过大,用户裁定回到 0.7 默认——当时「图标变小」的 - // 真凶是 ambiguous 宽度缩放误伤 PUA 图标,已在 glyph_cache 排除。 - let ui_font_px = config.font.size().scale(scale_factor).as_px(); - #[cfg(windows)] - let (rasterizer, required_font_install) = { - let mut rasterizer = rasterizer; - let installed = GlyphCache::font_family_available( - &mut rasterizer, - crate::font_install::REQUIRED_FONT_FAMILY, - font_size, - ); - let required = (!installed).then(|| NebulaConfirm::InstallRequiredFont { - directory: crate::font_install::bundled_font_directory(), - }); - (rasterizer, required) - }; - #[cfg(not(windows))] - let required_font_install = None; - - debug!("Loading \"{}\" font", &settings_init.font_family); - let font = - config.font.clone().with_family(settings_init.font_family.clone()).with_size(font_size); - let mut glyph_cache = GlyphCache::new(rasterizer, &font)?; - glyph_cache.wide_bold_use_regular = settings_init.cjk_bold_regular; - #[cfg(windows)] - let mut nebula_font_families = glyph_cache.private_font_families(); - #[cfg(not(windows))] - let mut nebula_font_families = vec![settings_init.font_family.clone()]; - nebula_font_families.retain(|family| family != crate::font_install::REQUIRED_FONT_FAMILY); - nebula_font_families.insert(0, crate::font_install::REQUIRED_FONT_FAMILY.to_owned()); - if !nebula_font_families.iter().any(|family| family == &settings_init.font_family) { - nebula_font_families.push(settings_init.font_family.clone()); - } - crate::boot_trace("glyph cache (font faces loaded)"); - - let metrics = glyph_cache.font_metrics(); - let (cell_width, cell_height) = compute_cell_size(config, &metrics); - - // Resize the window to the user-configured size, or a Windows - // Terminal-like default when unset. A 116-column by 30-row canvas is - // the standard startup size. Two inputs are deliberately excluded: - // the session file's saved window size (stale/wrong-domain values - // kept resurfacing as near-fullscreen launches), and the persisted - // terminal zoom — the startup grid is priced at the CONFIG base font - // size, because 116 columns of a Ctrl+wheel-enlarged cell is itself - // a near-fullscreen window. The zoomed font still renders; it just - // shows fewer columns in the standard-sized window. - let dimensions = config - .window - .dimensions() - .unwrap_or(crate::config::window::Dimensions { columns: 116, lines: 30 }); - 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)) - .unwrap_or((cell_width, cell_height)); - let size = - window_size(config, dimensions, base_cell_width, base_cell_height, scale_factor); - window.request_inner_size(size); - - // Create the GL surface to draw into. - let surface = platform::create_gl_surface( - &gl_context, - window.inner_size(), - window.raw_window_handle(), - )?; - - // Make the context current. - let context = gl_context.make_current(&surface)?; - crate::boot_trace("surface + context current"); - - // Create renderer. - let mut renderer = Renderer::new(&context, config.debug.renderer)?; - crate::boot_trace("renderer (shaders compiled)"); - - // Load font common glyphs to accelerate rendering. - debug!("Filling glyph cache with common glyphs"); - renderer.with_loader(|mut api| { - glyph_cache.reset_glyph_cache(&mut api); - }); - crate::boot_trace("glyph cache warmed"); - - let padding = config.window.padding(window.scale_factor as f32); - let chrome = chrome_reserve(window.scale_factor as f32); - let viewport_size = window.inner_size(); - - // Create new size with at least one column and row. - // Asymmetric from the start: the sidebar is expanded on launch, so the - // left padding carries it while the right keeps the plain content - // margin. Dynamic padding is dropped — the sidebar fixes the left edge. - let scale = window.scale_factor as f32; - let content_pad = content_pad_x(scale); - let size_info = SizeInfo::new_fully_asymmetric( - viewport_size.width as f32, - viewport_size.height as f32, - cell_width, - cell_height, - padding.0 + content_pad + sidebar_width(scale, false), - padding.0 + content_pad, - padding.1 + chrome, - padding.1 + bottom_content_reserve(scale), - ); - - info!("Cell size: {cell_width} x {cell_height}"); - info!("Padding: {} x {}", size_info.padding_x(), size_info.padding_y()); - info!("Width: {}, Height: {}", size_info.width(), size_info.height()); - - // Update OpenGL projection. - renderer.resize(&size_info); - - // Clear screen. - let nebula_window_theme_override = config.window.theme(); - if settings_init.follow_system_theme { - window.set_theme(None); - } - let nebula_system_theme = system_theme_snapshot(system_theme, window.theme()); - let nebula_theme = if settings_init.follow_system_theme { - nebula_system_theme - .map(|theme| { - settings_init.theme.for_system_appearance(matches!(theme, WinitTheme::Light)) - }) - .unwrap_or(settings_init.theme) - } else { - settings_init.theme - }; - let background_color = if settings_init.follow_system_theme { - nebula_theme.palette().term_bg - } else { - settings_init.background.unwrap_or(config.colors.primary.background) - }; - renderer.clear(background_color, settings_init.opacity); - window.set_transparent(settings_init.opacity < 1.0); - - // Disable shadows for transparent windows on macOS. - #[cfg(target_os = "macos")] - window.set_has_shadow(settings_init.opacity >= 1.0); - - let is_wayland = matches!(raw_window_handle, RawWindowHandle::Wayland(_)); - - // On Wayland we can safely ignore this call, since the window isn't visible until you - // actually draw something into it and commit those changes. - if !is_wayland { - surface.swap_buffers(&context).expect("failed to swap buffers."); - renderer.finish(); - } - crate::boot_trace("first swap done"); - - // Set resize increments for the newly created window. - if config.window.resize_increments { - window.set_resize_increments(Some(PhysicalSize::new(cell_width, cell_height))); - } - - window.set_visible(true); - crate::boot_trace("window visible"); - - // Always focus new windows, even if no Nebula window is currently focused. - #[cfg(target_os = "macos")] - window.focus_window(); - - if !_tabbed { - match config.window.startup_mode { - #[cfg(target_os = "macos")] - StartupMode::SimpleFullscreen => window.set_simple_fullscreen(true), - StartupMode::Maximized if !is_wayland => window.set_maximized(true), - #[cfg(windows)] - StartupMode::Fullscreen => window.set_fullscreen(true), - _ => (), - } - } - - let hint_state = HintState::new(config.hints.alphabet()); - // Publish the RESTORED theme to the prompt bridge (writing the default - // here used to reset the powerline colors on every launch). - write_nebula_prompt_theme(nebula_theme); - - let mut damage_tracker = DamageTracker::new(size_info.screen_lines(), size_info.columns()); - damage_tracker.debug = config.debug.highlight_damage; - - // Disable vsync. - if let Err(err) = surface.set_swap_interval(&context, SwapInterval::DontWait) { - info!("Failed to disable vsync: {err}"); - } - crate::boot_trace("swap interval set"); - - // Terminal color table: the user's configured scheme, restyled by the - // restored theme (light themes swap in a readable light ANSI set, and - // the background OSC 11 reports must match the theme from frame one). - let nebula_default_colors = List::from(&config.colors); - let mut initial_colors = nebula_default_colors; - nebula_theme.apply_term_colors(&mut initial_colors, &nebula_default_colors); - - let mut display = Self { - context: ManuallyDrop::new(context), - visual_bell: VisualBell::from(&config.bell), - renderer: ManuallyDrop::new(renderer), - renderer_preference: config.debug.renderer, - surface: ManuallyDrop::new(surface), - colors: initial_colors, - nebula_default_colors, - terminal_color_resolver: Default::default(), - frame_timer: FrameTimer::new(), - raw_window_handle, - damage_tracker, - glyph_cache, - hint_state, - size_info, - font_size, - nebula_ui_font: NebulaUiFont { px: ui_font_px, cell: (0.0, 0.0) }, - nebula_sftp_routed: true, - window, - pending_renderer_update: Default::default(), - vi_highlighted_hint_age: Default::default(), - highlighted_hint_age: Default::default(), - vi_highlighted_hint: Default::default(), - highlighted_hint: Default::default(), - hint_mouse_point: Default::default(), - pending_update: Default::default(), - cursor_hidden: Default::default(), - nebula_pane_view: None, - nebula_resize_hud: None, - nebula_resize_hud_armed: false, - nebula_history: { - let history = crate::nebula_history::NebulaHistory::load(); - crate::boot_trace("history loaded"); - history - }, - directory_history: crate::directory_history::global(), - nebula_commands: nebula_commands_handle(), - nebula_tab_anim: Vec::new(), - nebula_scrollbar_drag: None, - nebula_split_reveal: None, - nebula_confirm: required_font_install, - nebula_confirm_buttons: None, - nebula_ssh_delete_undo: None, - nebula_ai_fix_bar: None, - nebula_ssh_delete_undo_rect: None, - nebula_ssh_delete_undo_hover: false, - nebula_ssh_editor: None, - nebula_ssh_editor_rects: None, - nebula_ssh_editor_open: false, - nebula_ssh_editor_hover: SshEditorHit::None, - nebula_ssh_test_request: None, - nebula_ssh_test_seq: 0, - nebula_frame_images: Vec::new(), - nebula_theme, - nebula_theme_preference: settings_init.theme, - nebula_follow_system_theme: settings_init.follow_system_theme, - nebula_system_theme, - nebula_window_theme_override, - nebula_settings_open: false, - nebula_special_tab_active: false, - nebula_language_preference: settings_init.language, - nebula_language: settings_init.language.resolved(), - nebula_config_paths: config.config_paths.clone(), - nebula_settings_scroll: 0.0, - nebula_palette: { - let mut palette = command_palette::CommandPalette::new(); - palette.set_language(settings_init.language.resolved()); - palette - }, - nebula_detected_shells: None, - nebula_side_panel: side_panel::SidePanel::new(), - nebula_sftp_panel: None, - nebula_ui_anims: NebulaUiAnims::new(), - nebula_settings_section: NebulaSettingsSection::default(), - nebula_chrome_hover: ChromeHit::None, - nebula_message_queue_entry: message_queue_entry::MessageQueueEntry::default(), - nebula_settings_hover: SettingsHit::None, - nebula_settings_opacity_drag: None, - nebula_context_menu: None, - nebula_settings_dropdown: None, - nebula_cursor_shape: settings_init.cursor_shape, - nebula_cursor_blink: settings_init.cursor_blink, - nebula_copy_on_select: settings_init.copy_on_select, - nebula_cjk_bold_regular: settings_init.cjk_bold_regular, - nebula_keymap: keymap::build_bindings(&settings_init.keybinds), - nebula_keybinds: settings_init.keybinds, - nebula_quick_terminal_hotkey: settings_init.quick_terminal_hotkey, - nebula_quick_hotkey_request: None, - nebula_quick_hotkey_error: None, - nebula_keymap_capture: None, - nebula_keymap_capture_preview: String::new(), - nebula_font_family: settings_init.font_family, - nebula_font_families, - nebula_font_notice: None, - nebula_tab_labels: vec![".".to_owned()], - nebula_tab_colors: vec![None], - nebula_tab_bells: vec![false], - nebula_tab_running: vec![false], - nebula_tab_logos: vec![None], - nebula_ai_logo_cache: Default::default(), - nebula_shell_icon_cache: Default::default(), - nebula_chrome_logo_draws: Vec::new(), - nebula_active_tab: 0, - nebula_tab_drag: None, - nebula_tabs_reorderable: true, - nebula_sidebar_collapsed: false, - nebula_ssh_hosts: merge_ssh_hosts( - &settings_init.saved_hosts, - &settings_init.pinned_hosts, - &settings_init.hidden_hosts, - ), - nebula_pinned_hosts: settings_init.pinned_hosts.clone(), - nebula_saved_hosts: settings_init.saved_hosts.clone(), - nebula_hidden_hosts: settings_init.hidden_hosts.clone(), - nebula_tabs_section_open: true, - nebula_hosts_section_open: true, - nebula_tabs_scroll: 0, - nebula_hosts_scroll: 0, - nebula_tab_rename: None, - nebula_tab_rename_select_all: false, - nebula_tab_rename_caret: 0, - nebula_tab_rename_text_x: 0.0, - nebula_pty_resize_pending: false, - nebula_ghost_enabled: settings_init.ghost, - nebula_accept: settings_init.accept, - nebula_shell: settings_init.shell, - nebula_shell_id: settings_init.shell_id.clone(), - nebula_startup_directory: settings_init.startup_directory, - nebula_fetch_enabled: settings_init.fetch, - nebula_powerline_enabled: settings_init.powerline, - nebula_keep_session: settings_init.keep_session, - nebula_window_opacity: settings_init.opacity, - nebula_background: if settings_init.follow_system_theme { - Some(nebula_theme.palette().term_bg) - } else { - settings_init.background - }, - nebula_background_image: settings_init.background_image, - nebula_background_image_opacity: settings_init.background_image_opacity, - nebula_background_image_fit: settings_init.background_image_fit, - nebula_background_image_alignment: settings_init.background_image_alignment, - nebula_background_image_cover_chrome: settings_init.background_image_cover_chrome, - nebula_settings_mtime: settings::nebula_settings_mtime(), - nebula_bg_palette_index: 0, - nebula_bg_hex_input: String::new(), - nebula_bg_hex_active: false, - nebula_sync_inputs: Default::default(), - nebula_sync_focus: None, - nebula_sync_auto_pull: false, - nebula_sync_secret_set: [false; 2], - nebula_sync_status: None, - nebula_sync_busy: false, - meter: Default::default(), - ime: Default::default(), - }; - // A persisted zoom means the very first frame already runs off the UI - // base size — the font role must be pinned NOW, not after the first - // font change funnels through handle_update. - display.refresh_ui_font(config); - Ok(display) - } - - pub fn settings_open(&self) -> bool { - self.nebula_settings_open - } - - pub fn ui_language(&self) -> UiLanguage { - self.nebula_language - } - - pub fn settings_section(&self) -> NebulaSettingsSection { - self.nebula_settings_section - } - - pub fn select_settings_section(&mut self, section: NebulaSettingsSection) { - self.close_shell_picker(); - self.close_font_picker(); - if self.nebula_settings_section != section { - self.nebula_settings_section = section; - // Each section starts reading from its top. - self.nebula_settings_scroll = 0.0; - self.pending_update.dirty = true; - } - } - - /// Scroll the settings content by `delta` px (positive = content moves - /// up). Clamped against the active section's overflow; no-op while the - /// panel is closed. - pub fn settings_scroll_by(&mut self, delta: f32) { - if !self.nebula_settings_open { - return; - } - let area = self.terminal_card_rect(); - let max = settings::settings_max_scroll( - &self.size_info, - self.window.scale_factor as f32, - area, - self.nebula_settings_section, - self.nebula_hidden_hosts.len(), - ); - let next = (self.nebula_settings_scroll + delta).clamp(0.0, max); - if (next - self.nebula_settings_scroll).abs() > f32::EPSILON { - self.nebula_settings_scroll = next; - self.pending_update.dirty = true; - self.window.request_redraw(); - } - } - - pub fn settings_scroll(&self) -> f32 { - self.nebula_settings_scroll - } - - pub fn shell_picker_count(&self) -> usize { - self.nebula_detected_shells.as_ref().map_or(0, Vec::len) - } - - pub fn font_picker_count(&self) -> usize { - self.nebula_font_families.len() + 1 - } - - pub fn hidden_ssh_host_count(&self) -> usize { - self.nebula_hidden_hosts.len() - } - - pub fn open_ssh_editor(&mut self) { - self.nebula_ssh_editor = Some(SshHostEditor { - original_destination: None, - error: None, - destination_selection: Default::default(), - password_selection: Default::default(), - destination: String::new(), - password: String::new(), - save_password: true, - show_password: false, - auth: crate::ssh_profiles::SshAuthMode::Auto, - private_keys: Vec::new(), - field: SshEditorField::Destination, - focus: crate::ux::FocusIndex::default(), - test: Default::default(), - }); - self.nebula_ssh_editor_rects = None; - self.nebula_ssh_editor_open = true; - self.nebula_ssh_editor_hover = SshEditorHit::None; - // 每次打开都从零开始,避免上一次退出动画的残余进度造成闪跳。 - self.nebula_ui_anims.ssh_editor = UiAnim::new(0.0); - self.pending_update.dirty = true; - } - - pub fn edit_ssh_host(&mut self, index: usize) { - let Some(destination) = self.nebula_ssh_hosts.get(index).cloned() else { return }; - let profile = - crate::ssh_profiles::SshProfiles::load(&nebula_data_dir().join("ssh_profiles.json")) - .unwrap_or_else(|err| { - warn!("加载 SSH Profile 失败,编辑器使用自动认证: {err}"); - crate::ssh_profiles::SshProfiles::default() - }) - .for_destination(&destination); - self.nebula_ssh_editor = Some(SshHostEditor { - original_destination: Some(destination.clone()), - error: None, - destination_selection: Default::default(), - password_selection: Default::default(), - destination, - // Never pull a stored secret back into a text field. Leaving this - // blank preserves the existing credential when the address stays - // unchanged; typing a new value explicitly replaces it. - password: String::new(), - save_password: true, - show_password: false, - auth: profile.auth, - private_keys: profile.private_keys, - field: SshEditorField::Destination, - focus: crate::ux::FocusIndex::default(), - test: Default::default(), - }); - self.nebula_ssh_editor_rects = None; - self.nebula_ssh_editor_open = true; - self.nebula_ssh_editor_hover = SshEditorHit::None; - self.nebula_ui_anims.ssh_editor = UiAnim::new(0.0); - self.pending_update.dirty = true; - self.window.request_redraw(); - } - - pub fn ssh_editor_active(&self) -> bool { - self.nebula_ssh_editor_open && self.nebula_ssh_editor.is_some() - } - - pub fn close_ssh_editor(&mut self) { - if self.nebula_ssh_editor.is_some() { - self.nebula_ssh_editor_open = false; - self.nebula_ssh_editor_hover = SshEditorHit::None; - self.pending_update.dirty = true; - self.window.request_redraw(); - } - } - - pub fn ssh_editor_hit(&self, x: f32, y: f32) -> SshEditorHit { - let Some(rects) = self.nebula_ssh_editor_rects.as_ref() else { - return SshEditorHit::None; - }; - let hit = |r: (f32, f32, f32, f32)| x >= r.0 && x < r.0 + r.2 && y >= r.1 && y < r.1 + r.3; - if hit(rects.close) { - SshEditorHit::Close - } else if hit(rects.destination) { - SshEditorHit::Destination - } else if let Some((mode, _)) = rects.auth.iter().find(|(_, rect)| hit(*rect)) { - SshEditorHit::Auth(*mode) - } else if hit(rects.add_private_key) { - SshEditorHit::AddPrivateKey - } else if let Some((index, _)) = rects.private_key_rows.iter().find(|(_, rect)| hit(*rect)) - { - SshEditorHit::RemovePrivateKey(*index) - } else if hit(rects.password_toggle) { - SshEditorHit::PasswordToggle - } else if hit(rects.password) { - SshEditorHit::Password - } else if hit(rects.save_checkbox) { - SshEditorHit::SaveToggleBox - } else if hit(rects.save_toggle) { - SshEditorHit::SaveToggleLabel - } else if hit(rects.test) { - SshEditorHit::Test - } else if hit(rects.test_status) { - SshEditorHit::TestStatus - } else if hit(rects.cancel) { - SshEditorHit::Cancel - } else if hit(rects.primary) { - SshEditorHit::Primary - } else { - SshEditorHit::None - } - } - - pub fn set_ssh_editor_hover(&mut self, hover: SshEditorHit) { - if self.nebula_ssh_editor_hover != hover { - self.nebula_ssh_editor_hover = hover; - self.pending_update.dirty = true; - } - } - - pub fn ssh_editor_insert(&mut self, text: &str) { - if let Some(editor) = self.nebula_ssh_editor.as_mut() { - editor.error = None; - match editor.field { - SshEditorField::Destination => { - editor.destination_selection.insert(&mut editor.destination, text) - }, - SshEditorField::Password => { - editor.password_selection.insert(&mut editor.password, text) - }, - } - editor.test = Default::default(); - } - } - pub fn ssh_editor_backspace(&mut self) { - if let Some(editor) = self.nebula_ssh_editor.as_mut() { - let before = match editor.field { - SshEditorField::Destination => editor.destination.len(), - SshEditorField::Password => editor.password.len(), - }; - match editor.field { - SshEditorField::Destination => { - editor.destination_selection.backspace(&mut editor.destination) - }, - SshEditorField::Password => { - editor.password_selection.backspace(&mut editor.password) - }, - } - let after = match editor.field { - SshEditorField::Destination => editor.destination.len(), - SshEditorField::Password => editor.password.len(), - }; - if before != after { - editor.test = Default::default(); - } - } - } - - pub fn ssh_editor_select_all(&mut self) { - if let Some(editor) = self.nebula_ssh_editor.as_mut() { - match editor.field { - SshEditorField::Destination => { - editor.destination_selection.select(&editor.destination) - }, - SshEditorField::Password => editor.password_selection.select(&editor.password), - } - } - } - - /// Copying an invisible password would persist a secret in the system - /// clipboard without visible intent, so it is enabled only after Reveal. - pub fn ssh_editor_selected_text(&self) -> Option { - let editor = self.nebula_ssh_editor.as_ref()?; - match editor.field { - SshEditorField::Destination => { - editor.destination_selection.selected_text(&editor.destination) - }, - SshEditorField::Password if editor.show_password => { - editor.password_selection.selected_text(&editor.password) - }, - SshEditorField::Password => None, - } - } - - pub fn ssh_editor_next_field(&mut self, reverse: bool) { - if let Some(editor) = self.nebula_ssh_editor.as_mut() { - editor.destination_selection.clear(); - editor.password_selection.clear(); - let shows_password = ssh_ui::auth_sections(editor.auth).0; - // Destination, optional password, test, cancel, save. - let count = if shows_password { 5 } else { 4 }; - editor.focus.advance(count, reverse); - editor.field = match (shows_password, editor.focus.current()) { - (_, 0) => SshEditorField::Destination, - (true, 1) => SshEditorField::Password, - _ => editor.field, - }; - } - } - - pub fn ssh_editor_activate_focus(&mut self) { - let Some(editor) = self.nebula_ssh_editor.as_ref() else { return }; - let shows_password = ssh_ui::auth_sections(editor.auth).0; - match (shows_password, editor.focus.current()) { - (true, 2) | (false, 1) => self.queue_ssh_test(), - (true, 3) | (false, 2) => self.close_ssh_editor(), - (true, 4) | (false, 3) => self.save_ssh_editor(), - _ => {}, - } - } - - pub fn ssh_editor_toggle_save(&mut self) { - if let Some(editor) = self.nebula_ssh_editor.as_mut() { - editor.save_password = !editor.save_password; - editor.test = Default::default(); - } - } - - /// input 层在处理完编辑器点击后调用:取走「测试连接」暂存的请求。 - pub fn take_ssh_test_request(&mut self) -> Option { - self.nebula_ssh_test_request.take() - } - - /// Validate the current draft and stage an exact, numbered test request. - /// The input layer owns the event proxy and takes this request immediately - /// after mouse/keyboard activation. - fn queue_ssh_test(&mut self) { - let Some(editor) = self.nebula_ssh_editor.as_mut() else { return }; - let destination = editor.destination.trim().to_owned(); - let valid = !destination.is_empty() - && !destination - .chars() - .any(|c| c.is_whitespace() || c.is_control() || ";&|<>\"'`".contains(c)); - if !valid { - editor.error = Some(if destination.is_empty() { - "请输入 SSH 地址,例如 user@example.com".to_owned() - } else { - "地址不能包含空白、控制字符或 shell 分隔符".to_owned() - }); - editor.test = Default::default(); - return; - } - if matches!(editor.test, ssh_ui::SshTestState::Running { .. }) { - return; - } - - let shows_password = ssh_ui::auth_sections(editor.auth).0; - editor.focus.set(if shows_password { 2 } else { 1 }, if shows_password { 5 } else { 4 }); - self.nebula_ssh_test_seq = self.nebula_ssh_test_seq.wrapping_add(1).max(1); - let request_id = self.nebula_ssh_test_seq; - editor.error = None; - editor.test = ssh_ui::SshTestState::Running { request_id }; - self.nebula_ssh_test_request = Some(crate::ssh_session::SshTestRequest { - request_id, - destination, - auth: editor.auth, - private_keys: editor.private_keys.clone(), - password: (!editor.password.is_empty()).then(|| editor.password.clone()), - }); - } - - /// 后台测试完成回报。结果只属于发起时的草稿:地址已改或状态已非 - /// Running(用户改过字段被清位)时直接丢弃,不让旧结果背书新配置。 - pub fn ssh_test_done( - &mut self, - request_id: u64, - destination: &str, - ok: bool, - message: &str, - elapsed_ms: u64, - ) { - let Some(editor) = self.nebula_ssh_editor.as_mut() else { return }; - if editor.destination.trim() != destination - || editor.test != (ssh_ui::SshTestState::Running { request_id }) - { - return; - } - editor.test = if ok { - ssh_ui::SshTestState::Ok { elapsed_ms } - } else { - ssh_ui::SshTestState::Failed { summary: message.to_owned() } - }; - self.pending_update.dirty = true; - self.window.request_redraw(); - } - - pub fn ssh_editor_click(&mut self, x: f32, y: f32) -> bool { - match self.ssh_editor_hit(x, y) { - SshEditorHit::Destination => { - if let Some(editor) = self.nebula_ssh_editor.as_mut() { - let count = if ssh_ui::auth_sections(editor.auth).0 { 5 } else { 4 }; - editor.focus.set(0, count); - editor.destination_selection.clear(); - editor.password_selection.clear(); - editor.field = SshEditorField::Destination; - } - }, - SshEditorHit::PasswordToggle => { - if let Some(editor) = self.nebula_ssh_editor.as_mut() { - editor.show_password = !editor.show_password; - } - }, - SshEditorHit::Password => { - if let Some(editor) = self.nebula_ssh_editor.as_mut() { - editor.focus.set(1, 5); - editor.destination_selection.clear(); - editor.password_selection.clear(); - editor.field = SshEditorField::Password; - } - }, - SshEditorHit::Auth(mode) => { - if let Some(editor) = self.nebula_ssh_editor.as_mut() { - editor.auth = mode; - editor.error = None; - editor.test = Default::default(); - if !ssh_ui::auth_sections(mode).0 { - editor.field = SshEditorField::Destination; - } - } - }, - SshEditorHit::AddPrivateKey => { - if let Some(result) = file_dialog::pick_private_key_file(&self.window) { - if let Some(editor) = self.nebula_ssh_editor.as_mut() { - match result { - Ok(path) => { - ssh_ui::push_private_key(&mut editor.private_keys, path); - editor.error = None; - editor.test = Default::default(); - }, - Err(err) => editor.error = Some(err), - } - } - } - }, - SshEditorHit::RemovePrivateKey(index) => { - if let Some(editor) = self.nebula_ssh_editor.as_mut() { - if index < editor.private_keys.len() { - editor.private_keys.remove(index); - editor.test = Default::default(); - } - } - }, - SshEditorHit::SaveToggleBox | SshEditorHit::SaveToggleLabel => { - self.ssh_editor_toggle_save(); - }, - SshEditorHit::Test => self.queue_ssh_test(), - SshEditorHit::TestStatus => {}, - SshEditorHit::Close => { - self.close_ssh_editor(); - }, - SshEditorHit::Cancel => { - if let Some(editor) = self.nebula_ssh_editor.as_mut() { - let shows_password = ssh_ui::auth_sections(editor.auth).0; - editor.focus.set( - if shows_password { 3 } else { 2 }, - if shows_password { 5 } else { 4 }, - ); - } - self.close_ssh_editor(); - }, - SshEditorHit::Primary => { - if let Some(editor) = self.nebula_ssh_editor.as_mut() { - let shows_password = ssh_ui::auth_sections(editor.auth).0; - editor.focus.set( - if shows_password { 4 } else { 3 }, - if shows_password { 5 } else { 4 }, - ); - } - self.save_ssh_editor(); - }, - SshEditorHit::None => return false, - } - self.pending_update.dirty = true; - self.window.request_redraw(); - true - } - - pub fn save_ssh_editor(&mut self) { - let Some(mut editor) = self.nebula_ssh_editor.take() else { return }; - let destination = editor.destination.trim().to_owned(); - let valid = !destination.is_empty() - && !destination - .chars() - .any(|c| c.is_whitespace() || c.is_control() || ";&|<>\"'`".contains(c)); - if !valid { - editor.error = Some(if destination.is_empty() { - "请输入 SSH 地址,例如 user@example.com".to_owned() - } else { - "地址不能包含空白、控制字符或 shell 分隔符".to_owned() - }); - editor.field = SshEditorField::Destination; - self.nebula_ssh_editor = Some(editor); - self.pending_update.dirty = true; - self.window.request_redraw(); - return; - } - if let Some(original) = editor.original_destination.as_deref() { - if original != destination { - self.nebula_saved_hosts.retain(|host| host != original); - self.nebula_pinned_hosts.retain(|host| host != original); - if !self.nebula_hidden_hosts.iter().any(|host| host == original) { - self.nebula_hidden_hosts.push(original.to_owned()); - } - #[cfg(windows)] - { - let _ = crate::ssh_credentials::forget_password(original); - } - } - } - // Saving/editing is an explicit request to surface this destination, - // so it also undoes a previous Delete of the same address. - self.nebula_hidden_hosts.retain(|host| host != &destination); - self.nebula_saved_hosts.retain(|host| host != &destination); - self.nebula_saved_hosts.insert(0, destination.clone()); - self.nebula_saved_hosts.truncate(20); - self.nebula_ssh_hosts = merge_ssh_hosts( - &self.nebula_saved_hosts, - &self.nebula_pinned_hosts, - &self.nebula_hidden_hosts, - ); - let profile_path = nebula_data_dir().join("ssh_profiles.json"); - let mut profiles = - crate::ssh_profiles::SshProfiles::load(&profile_path).unwrap_or_else(|err| { - warn!("加载 SSH Profile 失败,将创建新文件: {err}"); - crate::ssh_profiles::SshProfiles::default() - }); - if let Some(original) = editor.original_destination.as_deref() { - if original != destination { - profiles.rename(original, &destination); - } - } - profiles.upsert(crate::ssh_profiles::SshProfileAuth { - destination: destination.clone(), - auth: editor.auth, - private_keys: editor.private_keys.clone(), - }); - if let Err(err) = profiles.save(&profile_path) { - editor.error = Some(format!("保存 SSH Profile 失败: {err}")); - self.nebula_ssh_editor = Some(editor); - self.pending_update.dirty = true; - self.window.request_redraw(); - return; - } - if ssh_ui::auth_sections(editor.auth).0 - && editor.save_password - && !editor.password.is_empty() - { - #[cfg(windows)] - { - let _ = crate::ssh_credentials::store_password( - &destination, - editor.password.as_bytes(), - ); - } - } - // 凭据落盘后立即清除内存中的明文,但保留其余内容完成短退出动画。 - editor.password.clear(); - self.persist_nebula_settings(); - self.nebula_ssh_editor = Some(editor); - self.close_ssh_editor(); - } - - /// Ask before removing a saved destination. Config aliases use different - /// wording because Delete hides them inside Nebula and never edits - /// `~/.ssh/config` itself. - pub fn request_delete_ssh_host(&mut self, index: usize) { - let Some(host) = self.nebula_ssh_hosts.get(index).cloned() else { return }; - let from_config = crate::ssh::ssh_config_hosts().contains(&host); - self.nebula_confirm = Some(NebulaConfirm::DeleteSsh { host, from_config }); - self.pending_update.dirty = true; - self.window.request_redraw(); - } - - /// Apply a confirmed deletion and arm a complete, credential-safe Undo. - /// Replacing an older Undo finalizes that older credential deletion first. - pub fn confirm_delete_ssh_host(&mut self, host: &str) -> bool { - if !self.nebula_ssh_hosts.iter().any(|entry| entry == host) { - return false; - } - - let from_config = crate::ssh::ssh_config_hosts().iter().any(|entry| entry == host); - - // Taking the previous record commits its pending Credential Manager - // deletion through Drop. Only the most recent destructive action is - // reversible, matching standard snackbar Undo behavior. - self.nebula_ssh_delete_undo.take(); - - let (saved_index, pinned_index, was_hidden) = remove_ssh_host_from_lists( - host, - &mut self.nebula_saved_hosts, - &mut self.nebula_pinned_hosts, - &mut self.nebula_hidden_hosts, - ); - self.nebula_ssh_hosts = merge_ssh_hosts( - &self.nebula_saved_hosts, - &self.nebula_pinned_hosts, - &self.nebula_hidden_hosts, - ); - self.nebula_ssh_delete_undo = Some(SshDeleteUndo { - host: host.to_owned(), - saved_index, - pinned_index, - was_hidden, - from_config, - started_at: std::time::Instant::now(), - delete_credential_on_drop: true, - }); - self.persist_nebula_settings(); - self.pending_update.dirty = true; - self.window.request_redraw(); - true - } - - /// Reverse the complete host-list mutation. The credential was intentionally - /// kept alive during the grace period, so disarming Drop restores it without - /// ever copying secret bytes into the UI process. - pub fn undo_delete_ssh_host(&mut self) -> bool { - let Some(mut undo) = self.nebula_ssh_delete_undo.take() else { return false }; - if undo.started_at.elapsed() >= SSH_DELETE_UNDO_DURATION { - // Drop commits the pending credential deletion. - return false; - } - - undo.delete_credential_on_drop = false; - restore_ssh_host_to_lists( - &undo.host, - undo.saved_index, - undo.pinned_index, - undo.was_hidden, - &mut self.nebula_saved_hosts, - &mut self.nebula_pinned_hosts, - &mut self.nebula_hidden_hosts, - ); - self.nebula_ssh_hosts = merge_ssh_hosts( - &self.nebula_saved_hosts, - &self.nebula_pinned_hosts, - &self.nebula_hidden_hosts, - ); - self.nebula_ssh_delete_undo_rect = None; - self.nebula_ssh_delete_undo_hover = false; - self.persist_nebula_settings(); - self.pending_update.dirty = true; - self.window.request_redraw(); - true - } - - /// Commit the pending Credential Manager deletion when the Undo timer ends. - pub fn expire_ssh_delete_undo(&mut self) { - self.nebula_ssh_delete_undo.take(); - self.nebula_ssh_delete_undo_rect = None; - self.nebula_ssh_delete_undo_hover = false; - self.pending_update.dirty = true; - self.window.request_redraw(); - } - - pub fn ssh_delete_undo_available(&self) -> bool { - self.nebula_ssh_delete_undo - .as_ref() - .is_some_and(|undo| undo.started_at.elapsed() < SSH_DELETE_UNDO_DURATION) - } - - pub fn ssh_delete_undo_hit(&self, x: f32, y: f32) -> bool { - self.ssh_delete_undo_available() - && self.nebula_ssh_delete_undo_rect.is_some_and(|rect| { - x >= rect.0 && x < rect.0 + rect.2 && y >= rect.1 && y < rect.1 + rect.3 - }) - } - - pub fn set_ssh_delete_undo_hover(&mut self, hovered: bool) { - if self.nebula_ssh_delete_undo_hover != hovered { - self.nebula_ssh_delete_undo_hover = hovered; - self.pending_update.dirty = true; - self.window.request_redraw(); - } - } - - pub fn set_chrome_tabs( - &mut self, - labels: Vec, - mut colors: Vec>, - mut dots: Vec, - mut running: Vec, - mut logos: Vec>, - active: usize, - reorderable: bool, - ) { - self.nebula_tab_labels = if labels.is_empty() { vec![".".to_owned()] } else { labels }; - colors.truncate(self.nebula_tab_labels.len()); - colors.resize(self.nebula_tab_labels.len(), None); - self.nebula_tab_colors = colors; - dots.truncate(self.nebula_tab_labels.len()); - dots.resize(self.nebula_tab_labels.len(), false); - self.nebula_tab_bells = dots; - running.truncate(self.nebula_tab_labels.len()); - running.resize(self.nebula_tab_labels.len(), false); - self.nebula_tab_running = running; - logos.truncate(self.nebula_tab_labels.len()); - logos.resize(self.nebula_tab_labels.len(), None); - self.nebula_tab_logos = logos; - self.nebula_active_tab = active.min(self.nebula_tab_labels.len().saturating_sub(1)); - self.nebula_tabs_reorderable = reorderable; - // A tab count change (close/open) mid-drag invalidates the grabbed slot. - if self.nebula_tab_drag.map_or(false, |d| d.source >= self.nebula_tab_labels.len()) { - self.nebula_tab_drag = None; - } - } - - /// Whether any sidebar tab currently shows a running spinner. Only this - /// state raises the chrome clock to display-rate frames. - pub fn any_tab_running(&self) -> bool { - self.nebula_tab_running.iter().any(|running| *running) - } - - /// A chrome text editor (tab rename / drawer filter / commit message / SSH - /// host editor) has keyboard focus — the window context bumps the redraw - /// tick to the fast cadence so the insertion caret visibly blinks. - pub fn chrome_editor_active(&self) -> bool { - self.nebula_tab_rename.is_some() - || self.nebula_side_panel.search_focus - || self.nebula_side_panel.commit_focus - || self.nebula_sftp_panel.as_ref().is_some_and(sftp_panel::SftpPanel::editor_active) - || self.ssh_editor_active() - } - - /// Decoded (and theme-tinted) pixels for an AI brand logo, plus a stable - /// texture id for the renderer's inline cache. Decode + tint run once per - /// (logo, ink); the GPU upload happens lazily inside the renderer. - fn ai_logo_pixels( - &mut self, - logo: AiLogo, - ink: Rgb, - ) -> Option<(u64, std::sync::Arc>, (u32, u32))> { - // Claude's mark ships in Anthropic coral and is used as-is (only its - // alpha matters), so its cache key ignores the ink. The OpenAI mark - // is black-on-alpha and follows the chrome ink to stay visible on - // every theme. - let key = match logo { - AiLogo::Claude => (logo, [0, 0, 0]), - AiLogo::OpenAi | AiLogo::OpenCode | AiLogo::Pi => (logo, [ink.r, ink.g, ink.b]), - }; - if let Some(cached) = self.nebula_ai_logo_cache.get(&key) { - return Some(cached.clone()); - } - let bytes: &[u8] = match logo { - AiLogo::Claude => AI_LOGO_CLAUDE_PNG, - AiLogo::OpenAi => AI_LOGO_OPENAI_PNG, - AiLogo::OpenCode => AI_LOGO_OPENCODE_PNG, - AiLogo::Pi => AI_LOGO_PI_PNG, - }; - let (width, height, mut rgba) = match crate::renderer::image::decode_png_bytes(bytes) { - Ok(decoded) => decoded, - // Unreachable for a valid embedded asset; degrade to no icon. - Err(err) => { - log::warn!("failed to decode embedded AI logo: {err}"); - return None; - }, - }; - match logo { - AiLogo::OpenAi | AiLogo::Pi => { - for px in rgba.chunks_exact_mut(4) { - (px[0], px[1], px[2]) = (ink.r, ink.g, ink.b); - } - }, - AiLogo::OpenCode => { - // Stored grayscale = luma map (frame 255, screen-block 90). - // Tint to theme ink scaled by luma: frame → full ink, inner - // block → ~35% ink, keeping the two-tone mark on every theme. - for px in rgba.chunks_exact_mut(4) { - let luma = px[0] as u16; // R==G==B in the asset - px[0] = (ink.r as u16 * luma / 255) as u8; - px[1] = (ink.g as u16 * luma / 255) as u8; - px[2] = (ink.b as u16 * luma / 255) as u8; - } - }, - AiLogo::Claude => {}, - } - let id = AI_LOGO_ID_BASE + self.nebula_ai_logo_cache.len() as u64; - let entry = (id, std::sync::Arc::new(rgba), (width, height)); - self.nebula_ai_logo_cache.insert(key, entry.clone()); - Some(entry) - } - - /// Decoded pixels for a full-color shell icon (128×128 PNG embedded from - /// extra/shell-icons), plus a stable texture id for the renderer's inline - /// cache. Decode runs once per shell id; the GPU upload happens lazily - /// inside the renderer. Returns `None` when the id has no brand asset. - fn shell_icon_pixels( - &mut self, - shell_id: &str, - ) -> Option<(u64, std::sync::Arc>, (u32, u32))> { - if let Some(cached) = self.nebula_shell_icon_cache.get(shell_id) { - return Some(cached.clone()); - } - let bytes = crate::shell_detect::color_icon_png(shell_id)?; - let (width, height, rgba) = match crate::renderer::image::decode_png_bytes(bytes) { - Ok(decoded) => decoded, - Err(err) => { - log::warn!("failed to decode shell icon for {shell_id}: {err}"); - return None; - }, - }; - // Shell icons ship in brand colors and are used as-is (no tint). - let id = AI_LOGO_ID_BASE + 1000 + self.nebula_shell_icon_cache.len() as u64; - let entry = (id, std::sync::Arc::new(rgba), (width, height)); - self.nebula_shell_icon_cache.insert(shell_id.to_owned(), entry.clone()); - Some(entry) - } - - /// Arm a potential tab drag from a press on displayed tab `source`. Always - /// arms (even single-tab), because the release decides between click / - /// reorder / dock — selection itself is deferred to the release. - pub fn arm_tab_drag(&mut self, source: usize, x: f32, y: f32) { - self.nebula_tab_drag = - Some(TabDrag { source, origin_x: x, origin: y, current: y, active: false, dock: None }); - } - - /// Whether a tab drag is currently armed (pressed, possibly not yet moved). - pub fn tab_drag_armed(&self) -> bool { - self.nebula_tab_drag.is_some() - } - - /// Feed the pointer into an armed drag. Y drives the in-sidebar reorder; - /// crossing into the terminal area computes the dock side. Returns `true` - /// once the drag is active (past threshold on either axis), signalling the - /// caller to show the grab cursor and repaint. - pub fn update_tab_drag(&mut self, x: f32, y: f32) -> bool { - let threshold = 6.0 * self.window.scale_factor as f32; - // Compute before the mutable borrow below. - let dock = self.dock_nav_at(x, y); - match self.nebula_tab_drag.as_mut() { - Some(drag) => { - drag.current = y; - if !drag.active - && ((y - drag.origin).abs() > threshold - || (x - drag.origin_x).abs() > threshold) - { - drag.active = true; - } - if drag.active { - drag.dock = dock; - } - drag.active - }, - None => false, - } - } - - /// Dock side for a pointer inside the terminal area, `None` outside it. - /// The area is quartered along its diagonals: the nearest edge wins, which - /// gives the natural triangular dock zones. - fn dock_nav_at(&self, x: f32, y: f32) -> Option { - let gx = self.size_info.padding_x(); - let gy = self.size_info.padding_y(); - let gw = self.size_info.width() - gx - self.size_info.padding_right(); - let gh = self.size_info.height() - gy - self.size_info.padding_bottom(); - if gw <= 0.0 || gh <= 0.0 || x < gx || y < gy || x > gx + gw || y > gy + gh { - return None; - } - let nx = (x - gx) / gw; - let ny = (y - gy) / gh; - let (dl, dr, dt, db) = (nx, 1.0 - nx, ny, 1.0 - ny); - let min = dl.min(dr).min(dt).min(db); - Some(if min == dl { - SplitNav::Left - } else if min == dr { - SplitNav::Right - } else if min == dt { - SplitNav::Up - } else { - SplitNav::Down - }) - } - - /// Finish a tab drag, deciding what the release means. - pub fn end_tab_drag(&mut self) -> Option { - let drag = self.nebula_tab_drag.take()?; - if !drag.active { - // Never moved: a plain click — select on release. - return Some(TabDropAction::Click(drag.source)); - } - if let Some(nav) = drag.dock { - return Some(TabDropAction::Dock { source: drag.source, nav }); - } - if !self.nebula_tabs_reorderable || self.nebula_tab_labels.len() < 2 { - return Some(TabDropAction::Click(drag.source)); - } - let target = self.tab_drop_index(drag.source, drag.current); - if target != drag.source - && drag.source < self.nebula_tab_anim.len() - && target < self.nebula_tab_anim.len() - { - // Reorder the animated draw-y values alongside the tabs so each - // pill keeps its on-screen position and *eases* into its new slot - // instead of snapping when the drop commits. - let v = self.nebula_tab_anim.remove(drag.source); - self.nebula_tab_anim.insert(target, v); - } - if target != drag.source { - Some(TabDropAction::Reorder { from: drag.source, to: target }) - } else { - Some(TabDropAction::Click(drag.source)) - } - } - - /// Displayed slot the grabbed tab would drop into for pointer X: the number - /// of *other* tabs whose centre the pointer has passed. This yields the - /// correct remove-then-insert target index for a single-tab move. - fn tab_drop_index(&self, source: usize, y: f32) -> usize { - let scale = self.window.scale_factor as f32; - let sidebar_expand = self.left_sidebar_progress(); - let layout = - chrome_tab_layout(&self.ui_size_info(), scale, self.sidebar_model(), sidebar_expand); - // Tabs stack vertically now: count rows whose vertical centre the - // pointer has passed to get the remove-then-insert target slot. - let passed = layout - .tabs - .iter() - .enumerate() - .filter(|(i, rect)| *i != source && y > rect.1 + rect.3 * 0.5) - .count(); - passed.min(self.nebula_tab_labels.len().saturating_sub(1)) - } - - /// Draw-X for a tab's pill/label during a reorder drag. The grabbed pill - /// follows the pointer (clamped to the strip); every other tab between the - /// grabbed slot and the current drop target slides one slot toward the - /// vacated source, opening a gap for the drop ("让位"). No shift when idle. - fn tab_drag_draw_y(&self, index: usize, tab_y: f32, layout: &ChromeTabLayout) -> f32 { - let Some(d) = self.nebula_tab_drag.filter(|d| d.active) else { return tab_y }; - - // The grabbed pill tracks the pointer, clamped to the tab column. - if d.source == index { - let lo = layout.tabs.first().map_or(tab_y, |t| t.1); - let hi = layout.tabs.last().map_or(tab_y, |t| t.1); - return (tab_y + d.current - d.origin).clamp(lo, hi); - } - - // Other tabs make way. Slot pitch = distance between adjacent rows - // (uniform height + gap); needs at least two tabs, which a drag implies. - let Some(second) = layout.tabs.get(1) else { return tab_y }; - let slot = second.1 - layout.tabs[0].1; - let target = self.tab_drop_index(d.source, d.current); - if d.source < target && index > d.source && index <= target { - tab_y - slot // dragging down: rows in (source, target] slide up - } else if d.source > target && index >= target && index < d.source { - tab_y + slot // dragging up: rows in [target, source) slide down - } else { - tab_y - } - } - - pub fn set_chrome_hover(&mut self, chrome: ChromeHit, settings: SettingsHit) { - if self.nebula_chrome_hover != chrome || self.nebula_settings_hover != settings { - self.nebula_chrome_hover = chrome; - self.nebula_settings_hover = settings; - self.pending_update.dirty = true; - } - } - - pub fn context_menu_interactive(&self) -> bool { - self.nebula_context_menu.as_ref().is_some_and(context_menu::ContextMenu::interactive) - } - - /// 右键菜单是否已在同一目标上开着(开着就别重开——反复右键不该让菜单 - /// 因指针位置或底边 clamp 而漂移)。 - fn context_menu_open_for(&self, target: ContextMenuTarget) -> bool { - self.nebula_context_menu - .as_ref() - .is_some_and(|menu| menu.interactive() && menu.target() == target) - } - - /// 侧栏行右键菜单的锚点:贴行矩形右缘、与行顶对齐——菜单与被点的行 - /// 强相关,而不是跟着指针走。行不存在时回落指针位置。 - fn sidebar_row_anchor(&self, tab: bool, index: usize, fallback: (f32, f32)) -> (f32, f32) { - let size = self.ui_size_info(); - let scale = self.window.scale_factor as f32; - let expand = if self.nebula_sidebar_collapsed { 0.0 } else { 1.0 }; - let layout = chrome::chrome_tab_layout(&size, scale, self.sidebar_model(), expand); - let rows = if tab { &layout.tabs } else { &layout.hosts }; - rows.get(index).map_or(fallback, |(rx, ry, rw, _)| (rx + rw + 4.0 * scale, *ry)) - } - - pub fn open_tab_context_menu(&mut self, index: usize, x: f32, y: f32) { - if index >= self.nebula_tab_labels.len() - || self.context_menu_open_for(ContextMenuTarget::Tab(index)) - { - return; - } - let anchor = self.sidebar_row_anchor(true, index, (x, y)); - let color = self.nebula_tab_colors.get(index).copied().flatten(); - self.nebula_context_menu = - Some(context_menu::ContextMenu::new(ContextMenuTarget::Tab(index), anchor, color)); - self.nebula_tab_drag = None; - self.pending_update.dirty = true; - self.window.request_redraw(); - } - - pub fn open_ssh_context_menu(&mut self, index: usize, x: f32, y: f32) { - if index >= self.nebula_ssh_hosts.len() - || self.context_menu_open_for(ContextMenuTarget::Ssh(index)) - { - return; - } - let anchor = self.sidebar_row_anchor(false, index, (x, y)); - self.nebula_context_menu = - Some(context_menu::ContextMenu::new(ContextMenuTarget::Ssh(index), anchor, None)); - self.pending_update.dirty = true; - self.window.request_redraw(); - } - - pub fn open_sftp_context_menu(&mut self, index: usize, x: f32, y: f32) { - let Some(panel) = self.nebula_sftp_panel.as_ref() else { return }; - if panel.visible_entry(index).is_none() { - return; - } - self.nebula_context_menu = - Some(context_menu::ContextMenu::new(ContextMenuTarget::Sftp(index), (x, y), None)); - self.pending_update.dirty = true; - self.window.request_redraw(); - } - - /// 本地文件树行的右键菜单。`..` 导航行不给菜单;打开时把该行设为 - /// 持久选中,菜单与行的关联在视觉上立得住。 - pub fn open_file_tree_context_menu(&mut self, row: usize, x: f32, y: f32) { - let Some((path, is_dir, is_parent)) = self - .nebula_side_panel - .visible_row(row) - .map(|r| (r.path.clone(), r.is_dir, r.is_parent)) - else { - return; - }; - if is_parent { - return; - } - let target = ContextMenuTarget::FileTree { row, is_dir }; - if self.context_menu_open_for(target) { - return; - } - self.nebula_side_panel.selected = Some(path); - self.nebula_context_menu = Some(context_menu::ContextMenu::new(target, (x, y), None)); - self.pending_update.dirty = true; - self.window.request_redraw(); - } - - /// 菜单动作执行时按行索引回查路径(树可能在菜单打开期间被节流刷新, - /// 拿不到就当无操作,绝不落在别的行上)。 - pub fn file_tree_row_path(&self, row: usize) -> Option<(std::path::PathBuf, bool)> { - self.nebula_side_panel - .visible_row(row) - .filter(|r| !r.is_parent) - .map(|r| (r.path.clone(), r.is_dir)) - } - - pub fn request_delete_file_tree(&mut self, row: usize) { - let Some((path, is_dir)) = self.file_tree_row_path(row) else { return }; - self.nebula_confirm = Some(NebulaConfirm::DeleteFileTreePath { path, is_dir }); - self.pending_update.dirty = true; - self.window.request_redraw(); - } - - /// 确认后的本地删除:送回收站(`FOF_ALLOWUNDO`),不是永久删除—— - /// 树紧挨终端、误触成本高,回收站是最后一道保险。 - pub fn confirm_delete_file_tree(&mut self, path: &std::path::Path) { - self.nebula_confirm = None; - match send_to_recycle_bin(path) { - Ok(()) => self.nebula_side_panel.request_refresh(), - Err(err) => self.nebula_side_panel.set_notice(format!("删除失败:{err}")), - } - self.pending_update.dirty = true; - self.window.request_redraw(); - } - - pub fn open_sftp_panel_context_menu(&mut self, x: f32, y: f32) { - if self.nebula_sftp_panel.is_none() { - return; - } - self.nebula_context_menu = - Some(context_menu::ContextMenu::new(ContextMenuTarget::SftpPanel, (x, y), None)); - self.pending_update.dirty = true; - self.window.request_redraw(); - } - - pub fn context_menu_hit(&self, x: f32, y: f32) -> ContextMenuHit { - self.nebula_context_menu.as_ref().map_or(ContextMenuHit::Outside, |menu| { - context_menu::hit_test(menu, self.ui_size_info(), self.window.scale_factor as f32, x, y) - }) - } - - pub fn context_menu_hover(&mut self, x: f32, y: f32) -> ContextMenuHit { - let hit = self.context_menu_hit(x, y); - let action = match hit { - ContextMenuHit::Action(action) => Some(action), - ContextMenuHit::Outside | ContextMenuHit::Panel => None, - }; - if self.nebula_context_menu.as_mut().is_some_and(|menu| menu.set_hover(action)) { - self.pending_update.dirty = true; - self.window.request_redraw(); - } - hit - } - - /// Resolve one menu click and start the close animation. A click inside - /// the panel but between targets is swallowed without dismissing it. - pub fn context_menu_click(&mut self, x: f32, y: f32) -> ContextMenuHit { - let hit = self.context_menu_hit(x, y); - if matches!(hit, ContextMenuHit::Action(_) | ContextMenuHit::Outside) { - self.close_context_menu(); - } - hit - } - - pub fn close_context_menu(&mut self) { - if let Some(menu) = self.nebula_context_menu.as_mut() { - menu.begin_close(); - self.pending_update.dirty = true; - self.window.request_redraw(); - } - } - - pub fn chrome_hit(&self, x: f32, y: f32) -> ChromeHit { - // Hit-testing must read the SAME layout the chrome was drawn with — - // the UI-anchored SizeInfo — or clicks drift off their targets as - // soon as the terminal is zoomed away from the base font size. - chrome_hit_with_tabs( - &self.ui_size_info(), - self.window.scale_factor as f32, - self.sidebar_model(), - self.nebula_sidebar_collapsed, - x, - y, - ) - } - - /// Fold the tab sidebar in or out. Toggling changes the grid's usable width, - /// so it re-runs the resize/reflow path by re-feeding the current window - /// size — `handle_update` then recomputes the asymmetric padding split. - pub fn toggle_sidebar(&mut self) { - self.nebula_sidebar_collapsed = !self.nebula_sidebar_collapsed; - let size = PhysicalSize::new(self.size_info.width() as u32, self.size_info.height() as u32); - self.pending_update.set_dimensions(size); - self.window.request_redraw(); - self.pending_update.dirty = true; - } - - /// Snapshot of the state the settings render reads, owning the wallpaper - /// path so `draw_chrome` can still borrow `&mut renderer` afterwards. - fn settings_view(&self) -> settings::SettingsView { - settings::SettingsView { - area: self.terminal_card_rect(), - language_preference: self.nebula_language_preference, - language: self.nebula_language, - section: self.nebula_settings_section, - hover: self.nebula_settings_hover, - theme: self.nebula_theme, - follow_system_theme: self.nebula_follow_system_theme, - ghost: self.nebula_ghost_enabled, - accept: self.nebula_accept, - shell_label: { - // Rich picked id (cmd/pwsh/nu/wsl:X) wins; else the 2-value - // enum label. Icon comes from the same table the dropdown - // rows use, so the setting always mirrors the menu. - let id = self.nebula_shell_id.as_deref(); - let name = id - .map(crate::shell_detect::display_name_for_id) - .unwrap_or_else(|| self.nebula_shell.label().to_owned()); - let icon = crate::shell_detect::icon_for_id( - id.unwrap_or_else(|| self.nebula_shell.settings_value()), - ); - format!("{icon} {name}") - }, - dropdown: self.nebula_settings_dropdown, - shells: self - .nebula_detected_shells - .as_ref() - .map(|shells| { - shells - .iter() - .map(|s| (s.id.clone(), s.name.clone(), s.program.clone())) - .collect() - }) - .unwrap_or_default(), - shell_id: self.nebula_shell_id.clone(), - startup_directory: self - .nebula_startup_directory - .as_ref() - .map(|path| path.to_string_lossy().into_owned()), - font_family: self.nebula_font_family.clone(), - font_size_px: self.font_size.as_px() / self.window.scale_factor as f32, - fonts: self.nebula_font_families.clone(), - font_notice: self.nebula_font_notice.clone(), - hidden_hosts: self.nebula_hidden_hosts.clone(), - fetch: self.nebula_fetch_enabled, - powerline: self.nebula_powerline_enabled, - keep_session: self.nebula_keep_session, - opacity: self.nebula_window_opacity, - dragging_opacity: self.nebula_settings_opacity_drag.map(|(target, _, _)| target), - cursor_shape: self.nebula_cursor_shape, - cursor_blink: self.nebula_cursor_blink, - copy_on_select: self.nebula_copy_on_select, - cjk_bold_regular: self.nebula_cjk_bold_regular, - preview_bg: self.preview_terminal_bg(), - preview_fg: { - let bg = self.preview_terminal_bg(); - // 亮底配深字、暗底配浅字:预览要在任何自定义背景色上可读。 - let luma = 0.299 * bg.r as f32 + 0.587 * bg.g as f32 + 0.114 * bg.b as f32; - if luma > 140.0 { Rgb::new(40, 44, 52) } else { Rgb::new(225, 228, 240) } - }, - background: self.nebula_background, - bg_hex_input: self.nebula_bg_hex_input.clone(), - bg_hex_active: self.nebula_bg_hex_active, - background_image: self.nebula_background_image.clone(), - background_image_opacity: self.nebula_background_image_opacity, - background_image_fit: self.nebula_background_image_fit, - background_image_alignment: self.nebula_background_image_alignment, - background_image_cover_chrome: self.nebula_background_image_cover_chrome, - scroll: self.nebula_settings_scroll, - keymap: keymap::EDITABLE_ACTIONS - .iter() - .map(|(action, ..)| keymap::effective_combo(action, &self.nebula_keymap)) - .collect(), - quick_terminal_hotkey: self.nebula_quick_terminal_hotkey.clone(), - quick_hotkey_error: self.nebula_quick_hotkey_error.clone(), - keymap_capture: self.nebula_keymap_capture, - keymap_capture_preview: self.nebula_keymap_capture_preview.clone(), - sync_inputs: self.nebula_sync_inputs.clone(), - sync_focus: self.nebula_sync_focus, - sync_auto_pull: self.nebula_sync_auto_pull, - sync_secret_set: self.nebula_sync_secret_set, - sync_status: self.nebula_sync_status.clone(), - sync_busy: self.nebula_sync_busy, - } - } - - pub fn set_settings_tab_active(&mut self, active: bool) { - if self.nebula_settings_open == active { - return; - } - self.nebula_settings_open = active; - self.nebula_special_tab_active = active; - if !active { - self.commit_sync_field(); - self.nebula_settings_dropdown = None; - self.nebula_settings_hover = SettingsHit::None; - self.nebula_keymap_capture = None; - } else { - self.load_sync_state(); - // Each explicit visit starts at a predictable page origin. - self.nebula_settings_scroll = 0.0; - } - self.pending_update.dirty = true; - } - - pub fn set_special_tab_active(&mut self, active: bool) { - self.nebula_special_tab_active = active; - if !active { - self.nebula_settings_open = false; - } - } - - pub fn set_ui_language(&mut self, preference: LanguagePreference) { - if self.nebula_language_preference == preference { - return; - } - self.nebula_language_preference = preference; - self.nebula_language = preference.resolved(); - self.nebula_palette.set_language(self.nebula_language); - self.persist_nebula_settings(); - self.pending_update.dirty = true; - } - - fn apply_nebula_theme(&mut self, theme: NebulaTheme) { - let previous_theme = self.nebula_theme; - let theme_changed = previous_theme != theme; - self.nebula_theme = theme; - // A theme carries its terminal background (the light themes are - // unusable without it). Switching theme IS choosing the look, so it - // overwrites a previous custom color by design. - self.nebula_background = Some(theme.palette().term_bg); - // Restyle the terminal color table: OSC 11 must report the new - // background (TUIs key light/dark off it) and light themes need the - // light ANSI set to stay readable. - let defaults = self.nebula_default_colors; - theme.apply_term_colors(&mut self.colors, &defaults); - if theme_changed { - // 旧 pane 可能持有应用通过 OSC 写入的上一主题颜色;交给窗口层在 - // 未持有任何终端锁时统一清理,避免只刷新当前焦点 pane。 - self.terminal_color_resolver - .theme_changed(previous_theme.palette().term_bg, theme.palette().term_bg); - self.pending_update.set_terminal_colors_dirty(); - } - write_nebula_prompt_theme(theme); - self.pending_update.dirty = true; - } - - pub fn select_nebula_theme(&mut self, theme: NebulaTheme) { - self.nebula_theme_preference = theme; - // Clicking a concrete theme is an explicit manual choice. Automatic - // mode must step aside instead of changing it again on the next OS - // appearance event. - self.nebula_follow_system_theme = false; - self.window.set_theme(self.nebula_window_theme_override); - self.apply_nebula_theme(theme); - self.persist_nebula_settings(); - // Panel stays open so users can adjust several settings at once. - } - - pub fn toggle_system_theme_following(&mut self) { - self.nebula_follow_system_theme = !self.nebula_follow_system_theme; - if self.nebula_follow_system_theme { - // winit explicitly suppresses ThemeChanged for overridden - // windows, so automatic mode must let the OS own this value. - self.window.set_theme(None); - self.nebula_system_theme = - system_theme_snapshot(self.nebula_system_theme, self.window.theme()); - } else { - self.window.set_theme(self.nebula_window_theme_override); - } - let theme = if self.nebula_follow_system_theme { - self.nebula_system_theme - .map(|system| { - self.nebula_theme_preference - .for_system_appearance(matches!(system, WinitTheme::Light)) - }) - .unwrap_or(self.nebula_theme_preference) - } else { - self.nebula_theme_preference - }; - self.apply_nebula_theme(theme); - self.persist_nebula_settings(); - } - - /// Apply a live operating-system appearance change without rewriting the - /// stored theme family. This is intentionally a no-op in manual mode. - pub fn system_theme_changed(&mut self, system_theme: WinitTheme) { - self.sync_system_theme(Some(system_theme)); - } - - /// Refresh the system appearance independently from the window's cached - /// theme. This also keeps manual-mode windows ready to switch immediately - /// when the user enables automatic following. - pub fn sync_system_theme(&mut self, system_theme: Option) { - let Some(system_theme) = system_theme else { return }; - if self.nebula_system_theme == Some(system_theme) { - return; - } - - self.nebula_system_theme = Some(system_theme); - if self.nebula_follow_system_theme { - let theme = self - .nebula_theme_preference - .for_system_appearance(matches!(system_theme, WinitTheme::Light)); - self.apply_nebula_theme(theme); - } - } - - /// Remember a reloaded window-decoration preference without allowing it - /// to suppress OS theme notifications while automatic mode is enabled. - pub fn update_window_theme_override(&mut self, theme: Option) { - self.nebula_window_theme_override = theme; - self.window.set_theme(if self.nebula_follow_system_theme { None } else { theme }); - } - - pub fn toggle_ghost(&mut self) { - self.nebula_ghost_enabled = !self.nebula_ghost_enabled; - self.persist_nebula_settings(); - self.pending_update.dirty = true; - } - - pub fn cycle_accept(&mut self) { - self.nebula_accept = self.nebula_accept.cycle(); - self.persist_nebula_settings(); - self.pending_update.dirty = true; - } - - /// Open the "默认 Shell" picker (the settings row click): the same - /// Toggle the inline shell picker in settings (expand/collapse the list). - /// Toggle a settings combobox. All dropdowns share one field so opening - /// one always closes the others. - pub fn toggle_settings_dropdown(&mut self, dropdown: settings::SettingsDropdown) { - if self.nebula_settings_dropdown == Some(dropdown) { - self.nebula_settings_dropdown = None; - } else { - if dropdown == settings::SettingsDropdown::Shell { - // Ensure shells are detected before opening. - let _ = self - .nebula_detected_shells - .get_or_insert_with(crate::shell_detect::detect_shells); - } - if dropdown == settings::SettingsDropdown::Font { - self.nebula_font_notice = None; - } - self.nebula_settings_dropdown = Some(dropdown); - } - self.pending_update.dirty = true; - } - - pub fn close_settings_dropdown(&mut self) -> bool { - if self.nebula_settings_dropdown.take().is_none() { - return false; - } - self.nebula_bg_hex_active = false; - self.pending_update.dirty = true; - self.window.request_redraw(); - true - } - - pub fn set_background_image_fit_option(&mut self, index: usize) { - if let Some(fit) = settings::BACKGROUND_FIT_OPTIONS.get(index) { - self.nebula_background_image_fit = *fit; - self.persist_nebula_settings(); - } - self.nebula_settings_dropdown = None; - self.pending_update.dirty = true; - } - - pub fn set_background_image_alignment_option(&mut self, index: usize) { - if let Some(alignment) = settings::BACKGROUND_ALIGNMENT_OPTIONS.get(index) { - self.nebula_background_image_alignment = *alignment; - self.persist_nebula_settings(); - } - self.nebula_settings_dropdown = None; - self.pending_update.dirty = true; - } - - pub fn set_accept_option(&mut self, index: usize) { - if let Some(accept) = settings::ACCEPT_OPTIONS.get(index) { - self.nebula_accept = *accept; - self.persist_nebula_settings(); - } - 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 { - self.nebula_settings_dropdown = None; - self.pending_update.dirty = true; - let Some(shape) = settings::CURSOR_SHAPE_OPTIONS.get(index).copied() else { - return false; - }; - if self.nebula_cursor_shape == shape { - return false; - } - self.nebula_cursor_shape = shape; - self.persist_nebula_settings(); - true - } - - pub fn toggle_cursor_blink(&mut self) { - self.nebula_cursor_blink = !self.nebula_cursor_blink; - self.persist_nebula_settings(); - self.pending_update.dirty = true; - } - - pub fn toggle_copy_on_select(&mut self) { - self.nebula_copy_on_select = !self.nebula_copy_on_select; - self.persist_nebula_settings(); - self.pending_update.dirty = true; - } - - /// 同步拉到新历史后热加载(spec 003):ghost 补全的内存副本只在 - /// 启动时 load,这里手动重读一次。 - pub fn reload_nebula_history(&mut self) { - self.nebula_history = crate::nebula_history::NebulaHistory::load(); - self.pending_update.dirty = true; - } - - pub fn toggle_cjk_bold_regular(&mut self) { - self.nebula_cjk_bold_regular = !self.nebula_cjk_bold_regular; - self.glyph_cache.wide_bold_use_regular = self.nebula_cjk_bold_regular; - // 已缓存的 bold CJK 位图立即作废:切换要当场可见,不能等重启。 - self.reset_glyph_cache(); - self.persist_nebula_settings(); - self.pending_update.dirty = true; - } - - /// The default cursor style (shape + blink) every terminal should fall - /// back to when no escape has overridden it. - pub fn nebula_default_cursor_style(&self) -> nebula_terminal::vte::ansi::CursorStyle { - nebula_terminal::vte::ansi::CursorStyle { - shape: self.nebula_cursor_shape, - blinking: self.nebula_cursor_blink, - } - } - - /// Terminal background color the live settings preview should show: the - /// custom background wins, else the active theme's terminal background. - fn preview_terminal_bg(&self) -> Rgb { - self.nebula_background.unwrap_or(self.nebula_theme.palette().term_bg) - } - - pub fn toggle_shell_picker(&mut self) { - self.toggle_settings_dropdown(settings::SettingsDropdown::Shell); - } - - pub fn close_shell_picker(&mut self) { - if self.nebula_settings_dropdown == Some(settings::SettingsDropdown::Shell) { - self.close_settings_dropdown(); - } - } - - pub fn pick_startup_directory(&mut self) { - let Some(path) = file_dialog::pick_startup_directory(&self.window) else { return }; - if !path.is_dir() { - return; - } - - self.nebula_startup_directory = Some(path); - self.persist_nebula_settings(); - self.pending_update.dirty = true; - self.window.request_redraw(); - } - - pub fn clear_startup_directory(&mut self) { - if self.nebula_startup_directory.take().is_none() { - return; - } - - self.persist_nebula_settings(); - self.pending_update.dirty = true; - self.window.request_redraw(); - } - - pub(crate) fn startup_directory(&self) -> Option { - self.nebula_startup_directory.as_ref().filter(|path| path.is_dir()).cloned() - } - - pub fn toggle_font_picker(&mut self) { - self.toggle_settings_dropdown(settings::SettingsDropdown::Font); - } - - pub fn close_font_picker(&mut self) { - if self.nebula_settings_dropdown == Some(settings::SettingsDropdown::Font) { - self.close_settings_dropdown(); - } - } - - pub fn effective_font(&self, base: &Font) -> Font { - base.clone().with_family(self.nebula_font_family.clone()) - } - - fn apply_font_family(&mut self, family: String, base: &Font) { - self.nebula_font_family = family; - self.nebula_font_notice = None; - let font = self.effective_font(base).with_size(self.font_size); - self.pending_update.set_font(font); - self.persist_nebula_settings(); - self.window.request_redraw(); - } - - pub fn set_terminal_font_by_index(&mut self, index: usize, base: &Font) { - if let Some(family) = self.nebula_font_families.get(index).cloned() { - self.apply_font_family(family, base); - self.nebula_settings_dropdown = None; - return; - } - if index != self.nebula_font_families.len() { - return; - } - - #[cfg(windows)] - { - let Some(source) = file_dialog::pick_font_file(&self.window) else { return }; - let stored = match crate::font_install::store_imported_font(&source) { - Ok(stored) => stored, - Err(error) => { - self.nebula_font_notice = Some(error); - self.nebula_settings_dropdown = None; - self.pending_update.dirty = true; - return; - }, - }; - match self.glyph_cache.add_private_font(&stored.path) { - Ok(families) => { - for family in &families { - if !self.nebula_font_families.iter().any(|known| known == family) { - self.nebula_font_families.push(family.clone()); - } - } - self.nebula_font_families[1..] - .sort_by_key(|family| family.to_ascii_lowercase()); - if let Some(family) = families.into_iter().next() { - self.apply_font_family(family, base); - } - }, - Err(error) => { - if stored.created { - let _ = std::fs::remove_file(&stored.path); - } - self.nebula_font_notice = Some(format!("字体无法加载:{error}")); - self.pending_update.dirty = true; - }, - } - self.nebula_settings_dropdown = None; - } - #[cfg(not(windows))] - self.open_user_config_file(); - } - - /// WT-style default-shell picker (command palette mode). Kept for compatibility. - /// detected-shell dropdown as the "+" chevron, but confirming SETS the - /// default instead of launching a tab. Replaces the old 2-value cycle. - pub fn open_default_shell_picker(&mut self) { - let shells = - self.nebula_detected_shells.get_or_insert_with(crate::shell_detect::detect_shells); - self.nebula_palette.set_default_shell_menu(shells); - self.nebula_palette.open_default_picker(); - self.pending_update.dirty = true; - } - - /// Apply a picked default shell: keep the raw id for persistence and the - /// spawn override, and track the PTY-integrated executor family in the - /// enum so the prompt bootstrap picks the right base. - pub fn set_default_shell(&mut self, shell: &crate::shell_detect::DetectedShell) { - if let Some(family) = NebulaShell::from_settings(&shell.id) { - self.nebula_shell = family; - } - self.nebula_shell_id = Some(shell.id.clone()); - self.persist_nebula_settings(); - self.pending_update.dirty = true; - } - - pub fn set_default_shell_by_index(&mut self, index: usize) { - let shell = - self.nebula_detected_shells.as_ref().and_then(|shells| shells.get(index)).cloned(); - if let Some(shell) = shell { - self.set_default_shell(&shell); - } - self.nebula_settings_dropdown = None; - self.pending_update.dirty = true; - } - - /// Restore a destination after the short Undo period. Config aliases only - /// need to leave `hidden_hosts`; manually saved addresses are re-added to - /// the saved list. Expired credentials intentionally remain deleted. - pub fn restore_hidden_ssh_host(&mut self, index: usize) { - let Some(host) = self.nebula_hidden_hosts.get(index).cloned() else { return }; - let pending_same_host = - self.nebula_ssh_delete_undo.as_ref().is_some_and(|undo| undo.host == host); - if pending_same_host && self.undo_delete_ssh_host() { - return; - } - - self.nebula_hidden_hosts.retain(|entry| entry != &host); - let from_config = crate::ssh::ssh_config_hosts().iter().any(|entry| entry == &host); - if !from_config && !self.nebula_saved_hosts.iter().any(|entry| entry == &host) { - self.nebula_saved_hosts.insert(0, host); - } - self.nebula_ssh_hosts = merge_ssh_hosts( - &self.nebula_saved_hosts, - &self.nebula_pinned_hosts, - &self.nebula_hidden_hosts, - ); - self.persist_nebula_settings(); - self.pending_update.dirty = true; - self.window.request_redraw(); - } - - pub fn toggle_fetch(&mut self) { - self.nebula_fetch_enabled = !self.nebula_fetch_enabled; - self.persist_nebula_settings(); - self.pending_update.dirty = true; - } - - pub fn toggle_powerline(&mut self) { - self.nebula_powerline_enabled = !self.nebula_powerline_enabled; - self.persist_nebula_settings(); - self.pending_update.dirty = true; - } - - /// 高级→会话: whether closing a window keeps its shells in the resident - /// process (detach / re-attach restore) or kills them outright. - pub fn toggle_keep_session(&mut self) { - self.nebula_keep_session = !self.nebula_keep_session; - self.persist_nebula_settings(); - self.pending_update.dirty = true; - } - - pub fn begin_settings_opacity_drag(&mut self, target: SettingsOpacityTarget, pointer_x: f32) { - let slider = settings::opacity_slider_rect( - &self.ui_size_info(), - self.window.scale_factor as f32, - self.terminal_card_rect(), - self.nebula_settings_scroll, - target, - ); - self.nebula_settings_opacity_drag = Some((target, slider.0, slider.2)); - self.update_settings_opacity_drag(pointer_x); - } - - pub fn update_settings_opacity_drag(&mut self, pointer_x: f32) -> bool { - let Some((target, track_x, track_width)) = self.nebula_settings_opacity_drag else { - return false; - }; - let value = settings::opacity_from_pointer(pointer_x, (track_x, 0.0, track_width, 0.0)); - match target { - SettingsOpacityTarget::Terminal => { - if (self.nebula_window_opacity - value).abs() <= f32::EPSILON { - return true; - } - self.nebula_window_opacity = value; - self.update_window_transparency(); - }, - SettingsOpacityTarget::BackgroundImage => { - if (self.nebula_background_image_opacity - value).abs() <= f32::EPSILON { - return true; - } - self.nebula_background_image_opacity = value; - self.update_window_transparency(); - }, - } - self.pending_update.dirty = true; - self.window.request_redraw(); - true - } - - pub fn finish_settings_opacity_drag(&mut self) -> bool { - if self.nebula_settings_opacity_drag.take().is_none() { - return false; - } - // 拖动过程只刷新画面,松手后集中落盘,避免连续写设置文件。 - self.persist_nebula_settings(); - self.pending_update.dirty = true; - true - } - - /// 键盘快捷键仍可循环预设背景色(与色盘同一色板)。 - pub fn cycle_background_color(&mut self) { - self.nebula_bg_palette_index = - (self.nebula_bg_palette_index + 1) % settings::BACKGROUND_SWATCHES.len(); - self.nebula_background = Some(settings::BACKGROUND_SWATCHES[self.nebula_bg_palette_index]); - self.persist_nebula_settings(); - self.pending_update.dirty = true; - } - - /// 打开/关闭背景色浮层(色板 + 16 进制输入),草稿预填当前生效色。 - pub fn open_background_color_picker(&mut self) { - let current = self.nebula_background.unwrap_or(self.colors[NamedColor::Background]); - self.nebula_bg_hex_input = format!("#{:02X}{:02X}{:02X}", current.r, current.g, current.b); - self.nebula_bg_hex_active = false; - self.toggle_settings_dropdown(settings::SettingsDropdown::BackgroundColor); - } - - /// 点选色板某格:应用、落盘并收起浮层。 - pub fn set_background_color_option(&mut self, index: usize) { - if let Some(color) = settings::BACKGROUND_SWATCHES.get(index) { - self.nebula_bg_palette_index = index; - self.nebula_background = Some(*color); - self.persist_nebula_settings(); - } - self.close_settings_dropdown(); - self.pending_update.dirty = true; - } - - pub fn focus_bg_hex_input(&mut self) { - self.nebula_bg_hex_active = true; - self.pending_update.dirty = true; - } - - /// 追加 hex 字符:只收 `#` 与 16 进制位,总长 ≤ 7(`#RRGGBB`)。 - pub fn bg_hex_push(&mut self, ch: char) { - let ok = ch == '#' || ch.is_ascii_hexdigit(); - if ok && self.nebula_bg_hex_input.chars().count() < 7 { - self.nebula_bg_hex_input.push(ch); - self.pending_update.dirty = true; - } - } - - pub fn bg_hex_backspace(&mut self) { - if self.nebula_bg_hex_input.pop().is_some() { - self.pending_update.dirty = true; - } - } - - /// 回车应用 16 进制草稿;解析失败保持浮层与草稿原样。 - pub fn bg_hex_commit(&mut self) -> bool { - let Some(color) = settings::parse_hex_rgb(self.nebula_bg_hex_input.trim()) else { - return false; - }; - self.nebula_background = Some(color); - self.persist_nebula_settings(); - self.close_settings_dropdown(); - self.pending_update.dirty = true; - true - } - - // ---- 设置→高级→同步(WebDAV) ---- - - /// 打开设置时装载同步状态:url/username/auto_pull 来自 - /// `nebula_sync.txt`,密码/口令只查存在性(明文不进 UI 状态)。 - pub fn load_sync_state(&mut self) { - let cfg = crate::sync::SyncConfig::load(); - self.nebula_sync_inputs[0] = cfg.url; - self.nebula_sync_inputs[1] = cfg.username; - self.nebula_sync_inputs[2].clear(); - self.nebula_sync_inputs[3].clear(); - self.nebula_sync_auto_pull = cfg.auto_pull; - self.nebula_sync_secret_set = - [crate::sync::has_password(), crate::sync::has_passphrase()]; - self.nebula_sync_focus = None; - } - - /// 聚焦某个同步输入框;先提交上一个(点击切换即失焦保存)。 - pub fn focus_sync_field(&mut self, index: usize) { - if self.nebula_sync_focus == Some(index) { - return; - } - self.commit_sync_field(); - self.nebula_sync_focus = Some(index.min(3)); - self.pending_update.dirty = true; - } - - pub fn sync_field_push(&mut self, ch: char) { - let Some(index) = self.nebula_sync_focus else { return }; - if ch.is_control() { - return; - } - // url/username 拒绝空白;密码/口令允许内部空格(trim 在保存侧)。 - if index < 2 && ch.is_whitespace() { - return; - } - if self.nebula_sync_inputs[index].chars().count() < 256 { - self.nebula_sync_inputs[index].push(ch); - self.pending_update.dirty = true; - } - } - - pub fn sync_field_paste(&mut self, text: &str) { - for ch in text.chars() { - self.sync_field_push(ch); - } - } - - pub fn sync_field_backspace(&mut self) { - let Some(index) = self.nebula_sync_focus else { return }; - if self.nebula_sync_inputs[index].pop().is_some() { - self.pending_update.dirty = true; - } - } - - /// 失焦提交:url/username 写 `nebula_sync.txt`;密码/口令若有输入则 - /// 存入凭据管理器并清空缓冲。口令被弱口令闸拒绝时留在状态行。 - pub fn commit_sync_field(&mut self) { - let Some(index) = self.nebula_sync_focus.take() else { return }; - self.pending_update.dirty = true; - match index { - 0 | 1 => { - let mut cfg = crate::sync::SyncConfig::load(); - cfg.url = self.nebula_sync_inputs[0].trim().to_owned(); - cfg.username = self.nebula_sync_inputs[1].trim().to_owned(); - cfg.auto_pull = self.nebula_sync_auto_pull; - if let Err(err) = cfg.save() { - self.nebula_sync_status = Some((err, true)); - } - }, - 2 | 3 => { - let secret = std::mem::take(&mut self.nebula_sync_inputs[index]); - if secret.trim().is_empty() { - return; - } - let username = self.nebula_sync_inputs[1].trim().to_owned(); - let result = if index == 2 { - crate::sync::store_password(&username, &secret) - } else { - crate::sync::store_passphrase(&username, &secret) - }; - match result { - Ok(()) => { - self.nebula_sync_secret_set[index - 2] = true; - self.nebula_sync_status = Some(( - if index == 2 { - "WebDAV 密码已保存到凭据管理器".to_owned() - } else { - "同步口令已保存到凭据管理器".to_owned() - }, - false, - )); - }, - Err(err) => self.nebula_sync_status = Some((err, true)), - } - }, - _ => {}, - } - } - - /// Esc:丢弃当前草稿并失焦(url/username 还原为文件值)。 - pub fn cancel_sync_field(&mut self) { - let Some(index) = self.nebula_sync_focus.take() else { return }; - let cfg = crate::sync::SyncConfig::load(); - match index { - 0 => self.nebula_sync_inputs[0] = cfg.url, - 1 => self.nebula_sync_inputs[1] = cfg.username, - _ => self.nebula_sync_inputs[index].clear(), - } - self.pending_update.dirty = true; - } - - pub fn toggle_sync_auto_pull(&mut self) { - self.nebula_sync_auto_pull = !self.nebula_sync_auto_pull; - let mut cfg = crate::sync::SyncConfig::load(); - cfg.url = self.nebula_sync_inputs[0].trim().to_owned(); - cfg.username = self.nebula_sync_inputs[1].trim().to_owned(); - cfg.auto_pull = self.nebula_sync_auto_pull; - if let Err(err) = cfg.save() { - self.nebula_sync_status = Some((err, true)); - } - self.pending_update.dirty = true; - } - - /// 推/拉按钮按下:提交草稿、置忙。实际网络动作由调用侧发事件。 - pub fn begin_sync_action(&mut self) -> bool { - if self.nebula_sync_busy { - return false; - } - self.commit_sync_field(); - self.nebula_sync_busy = true; - self.nebula_sync_status = Some(("同步中…".to_owned(), false)); - self.pending_update.dirty = true; - true - } - - /// 后台同步线程回报(`NebulaSyncDone`)。 - pub fn sync_action_done(&mut self, message: &str, error: bool) { - self.nebula_sync_busy = false; - self.nebula_sync_status = Some((message.to_owned(), error)); - // 拉取可能改写了设置文件的凭据外字段;存在性也可能被首存翻转。 - self.nebula_sync_secret_set = - [crate::sync::has_password(), crate::sync::has_passphrase()]; - self.pending_update.dirty = true; - self.window.request_redraw(); - } - - /// Pick a background image through the OS file dialog, then persist it and - /// refresh the renderer's cached wallpaper. On non-Windows platforms the - /// native dialog isn't wired up, so we fall back to opening the settings - /// file for the path to be entered by hand. - /// Native save dialog for a workspace export, pre-filled with - /// `default_name`. `None` when the user cancels. - pub fn save_workspace_dialog(&self, default_name: &str) -> Option { - file_dialog::save_workspace_file(&self.window, default_name) - } - - /// Native open dialog for a workspace import. `None` when cancelled. - pub fn pick_workspace_dialog(&self) -> Option { - file_dialog::pick_workspace_file(&self.window) - } - - pub fn pick_background_image(&mut self) { - #[cfg(windows)] - { - if let Some(path) = file_dialog::pick_image_file(&self.window) { - self.nebula_background_image = Some(path); - self.persist_nebula_settings(); - self.renderer.invalidate_background_image(); - self.update_window_transparency(); - self.pending_update.dirty = true; - } - } - #[cfg(not(windows))] - { - self.open_user_config_file(); - } - } - - pub fn clear_background_image(&mut self) { - if self.nebula_background_image.take().is_some() { - self.persist_nebula_settings(); - self.renderer.invalidate_background_image(); - self.update_window_transparency(); - self.pending_update.dirty = true; - } - } - - pub fn request_toggle_background_image_cover_chrome(&mut self) { - if self.nebula_background_image_cover_chrome { - self.nebula_background_image_cover_chrome = false; - self.persist_nebula_settings(); - } else { - self.nebula_confirm = Some(NebulaConfirm::EnableBackgroundImageCoverChrome); - } - self.pending_update.dirty = true; - } - - pub fn confirm_background_image_cover_chrome(&mut self) { - self.nebula_confirm = None; - self.nebula_background_image_cover_chrome = true; - self.persist_nebula_settings(); - self.pending_update.dirty = true; - } - - pub fn open_user_config_file(&mut self) { - self.persist_nebula_settings(); - let active_lua = self.nebula_config_paths.first().filter(|path| { - path.extension() - .and_then(|extension| extension.to_str()) - .is_some_and(|extension| extension.eq_ignore_ascii_case("lua")) - }); - let path = active_lua.cloned().or_else(|| crate::config::source::default_lua_path().ok()); - let Some(path) = path else { - log::error!( - target: crate::logging::LOG_TARGET_CONFIG, - "Unable to determine Lua config path" - ); - return; - }; - if !path.exists() { - let language = crate::config::template::resolve_template_language( - Some(self.nebula_language_preference.as_str()), - None, - crate::config::template::system_locale().as_deref(), - ) - .unwrap_or(crate::config::template::TemplateLanguage::EnUs); - if let Err(error) = crate::config::template::ensure_user_lua_config(&path, language) { - log::error!( - target: crate::logging::LOG_TARGET_CONFIG, - "Unable to create Lua config {:?}: {error}", - path - ); - return; - } - } - #[cfg(windows)] - let _ = std::process::Command::new("notepad.exe").arg(&path).spawn(); - #[cfg(target_os = "macos")] - let _ = std::process::Command::new("open").arg(&path).spawn(); - #[cfg(all(not(windows), not(target_os = "macos")))] - let _ = std::process::Command::new("xdg-open").arg(&path).spawn(); - self.pending_update.dirty = true; - } - - pub fn reset_appearance_settings(&mut self) { - self.nebula_theme_preference = NebulaTheme::default(); - self.nebula_follow_system_theme = false; - self.window.set_theme(self.nebula_window_theme_override); - self.nebula_theme = self.nebula_theme_preference; - let defaults = self.nebula_default_colors; - self.nebula_theme.apply_term_colors(&mut self.colors, &defaults); - write_nebula_prompt_theme(self.nebula_theme); - self.nebula_window_opacity = 1.0; - self.nebula_background = None; - self.nebula_background_image = None; - self.nebula_background_image_opacity = 0.38; - self.nebula_background_image_fit = BackgroundImageFit::default(); - self.nebula_background_image_alignment = BackgroundImageAlignment::default(); - self.nebula_background_image_cover_chrome = false; - self.window.set_transparent(false); - self.persist_nebula_settings(); - self.pending_update.dirty = true; - } - - /// Toggle the command palette (Ctrl+Shift+P). `profiles` are the config's - /// quick-launch profile names, refreshed on every open so live config - /// reloads are reflected. - pub fn toggle_command_palette(&mut self, profiles: &[String]) { - self.nebula_palette.set_profiles(profiles); - self.nebula_palette.toggle(); - self.pending_update.dirty = true; - } - - /// Open the new-tab dropdown: detected shells (installed-shell order) plus - /// any config profiles. Detection runs once and is cached — the chevron - /// beside the "+" opens this, mirroring Windows Terminal's profile menu. - pub fn open_shell_menu(&mut self, profiles: &[String]) { - let shells = - self.nebula_detected_shells.get_or_insert_with(crate::shell_detect::detect_shells); - let default_shell = self - .nebula_shell_id - .as_deref() - .unwrap_or_else(|| self.nebula_shell.settings_value()); - self.nebula_palette.set_shell_menu(shells, profiles, default_shell); - self.nebula_palette.open_profiles(); - self.pending_update.dirty = true; - } - - /// Ctrl+K:开/关 shell picker——与 "+" 旁 chevron 打开的是同一份列表 - /// (settings 页的 shell 下拉是另一回事,见 `toggle_shell_picker`)。 - /// 已开着的 shell picker 再按一次收起;其他 palette 模式则切换过来。 - pub fn toggle_shell_menu(&mut self, profiles: &[String]) { - let picker_open = self.nebula_palette.is_picker() - && !self.nebula_palette.is_picking_default() - && !self.nebula_palette.is_picking_directory(); - if picker_open { - self.nebula_palette.close(); - self.pending_update.dirty = true; - } else { - self.open_shell_menu(profiles); - } - } - - /// Open a terminal-directory picker backed by the same frecency model as - /// ghost text and filesystem completion. No shell command is installed. - pub fn open_directory_picker(&mut self) { - let paths = self.directory_history.search("", 128); - self.nebula_palette.set_directories(paths); - self.nebula_palette.open_directories(); - self.pending_update.dirty = true; - } - - fn refresh_directory_picker(&mut self) { - if !self.nebula_palette.is_picking_directory() { - return; - } - let query = self.nebula_palette.query().to_owned(); - let paths = self.directory_history.search(&query, 128); - self.nebula_palette.set_directories(paths); - } - - pub fn command_palette_open(&self) -> bool { - self.nebula_palette.is_open() - } - - /// One geometry contract for palette rendering and pointer input. Picker - /// height depends on the live filtered row count, so callers must not - /// reconstruct this from window dimensions alone. - pub fn command_palette_layout(&self) -> command_palette::PaletteLayout { - let size = self.ui_size_info(); - command_palette::palette_layout( - &self.nebula_palette, - size.width(), - size.height(), - self.window.scale_factor as f32, - ) - } - - pub fn command_palette_picking_default(&self) -> bool { - self.nebula_palette.is_picking_default() - } - - pub fn command_palette_picker_open(&self) -> bool { - self.nebula_palette.is_picker() - } - - pub fn close_command_palette(&mut self) { - self.nebula_palette.close(); - self.pending_update.dirty = true; - } - - pub fn palette_input_char(&mut self, c: char) { - self.nebula_palette.input_char(c); - self.refresh_directory_picker(); - self.pending_update.dirty = true; - } - - pub fn palette_input_text(&mut self, text: &str) { - self.nebula_palette.input_text(text); - self.refresh_directory_picker(); - self.pending_update.dirty = true; - } - - pub fn palette_select_all(&mut self) { - self.nebula_palette.select_all(); - self.pending_update.dirty = true; - } - - pub fn palette_selected_text(&self) -> Option { - self.nebula_palette.selected_text() - } - - pub fn palette_backspace(&mut self) { - self.nebula_palette.backspace(); - self.refresh_directory_picker(); - self.pending_update.dirty = true; - } - - pub fn palette_move(&mut self, delta: i32) { - self.nebula_palette.move_selection(delta); - self.pending_update.dirty = true; - } - - /// Confirm the palette selection; returns the action for the input layer to - /// dispatch (only it can reach both the display and the window context). - pub fn palette_confirm(&mut self) -> Option { - let action = self.nebula_palette.confirm(); - self.pending_update.dirty = true; - action - } - - /// Mouse click on the palette's visible row `row` (0 = topmost visible): - /// select and confirm it, returning the action to dispatch. - pub fn palette_click( - &mut self, - row: usize, - max_rows: usize, - ) -> Option { - let action = self.nebula_palette.click(row, max_rows); - self.pending_update.dirty = true; - action - } - - /// Update palette hover state. `row` is the visual row index, or `None` when - /// the mouse left the palette area. - pub fn palette_hover(&mut self, pos: (f32, f32), row: Option) -> bool { - if self.nebula_palette.pointer_hover(pos, row) { - self.pending_update.dirty = true; - return true; - } - false - } - - /// Toggle the right-side drawer (directory tree / git status). - pub fn toggle_side_panel(&mut self, view: side_panel::PanelView) { - let was_open = self.nebula_side_panel.open; - if self.nebula_sftp_panel.take().is_some() { - self.nebula_side_panel.open = true; - self.nebula_side_panel.view = view; - } else { - self.nebula_side_panel.toggle(view); - } - // The drawer reserves real grid width, so opening/closing it (not - // just switching views) must reflow the grid like the left sidebar. - if self.nebula_side_panel.open != was_open { - let size = - PhysicalSize::new(self.size_info.width() as u32, self.size_info.height() as u32); - self.pending_update.set_dimensions(size); - } - self.window.request_redraw(); - self.pending_update.dirty = true; - } - - // ---- tab rename caret editing (the rename box is a real text field) ---- - - /// Insert `text` at the caret. A pending select-all is replaced wholesale - /// (type-to-overwrite), matching every native text field. - pub fn tab_rename_insert(&mut self, text: &str) { - let text: String = text.chars().filter(|character| !character.is_control()).collect(); - if text.is_empty() { - return; - } - let select_all = self.nebula_tab_rename_select_all; - let caret = self.nebula_tab_rename_caret; - let Some((_, buf)) = self.nebula_tab_rename.as_mut() else { return }; - if select_all { - buf.clear(); - self.nebula_tab_rename_select_all = false; - self.nebula_tab_rename_caret = 0; - } - let caret = if select_all { 0 } else { caret.min(buf.chars().count()) }; - let byte = buf.char_indices().nth(caret).map(|(b, _)| b).unwrap_or(buf.len()); - buf.insert_str(byte, &text); - self.nebula_tab_rename_caret = caret + text.chars().count(); - self.pending_update.dirty = true; - } - - /// Backspace at the caret; a pending select-all clears the whole name. - pub fn tab_rename_backspace(&mut self) { - let select_all = self.nebula_tab_rename_select_all; - let caret = self.nebula_tab_rename_caret; - let Some((_, buf)) = self.nebula_tab_rename.as_mut() else { return }; - if select_all { - buf.clear(); - self.nebula_tab_rename_select_all = false; - self.nebula_tab_rename_caret = 0; - } else if caret > 0 { - let caret = caret.min(buf.chars().count()); - if let Some((byte, _)) = buf.char_indices().nth(caret - 1) { - buf.remove(byte); - self.nebula_tab_rename_caret = caret - 1; - } - } - self.pending_update.dirty = true; - } - - pub fn tab_rename_select_all(&mut self) { - if let Some((_, text)) = self.nebula_tab_rename.as_ref() { - self.nebula_tab_rename_select_all = !text.is_empty(); - self.nebula_tab_rename_caret = text.chars().count(); - self.pending_update.dirty = true; - } - } - - pub fn tab_rename_selected_text(&self) -> Option { - self.nebula_tab_rename_select_all - .then(|| self.nebula_tab_rename.as_ref().map(|(_, text)| text.clone())) - .flatten() - } - - /// Move the caret by `delta` chars. A select-all collapses to the matching - /// end first (left → start, right → end) without moving further. - pub fn tab_rename_move_caret(&mut self, delta: i32) { - let Some((_, buf)) = self.nebula_tab_rename.as_ref() else { return }; - let len = buf.chars().count(); - if self.nebula_tab_rename_select_all { - self.nebula_tab_rename_select_all = false; - self.nebula_tab_rename_caret = if delta < 0 { 0 } else { len }; - } else { - let caret = self.nebula_tab_rename_caret.min(len) as i64 + delta as i64; - self.nebula_tab_rename_caret = caret.clamp(0, len as i64) as usize; - } - self.pending_update.dirty = true; - } - - /// Jump the caret to the start/end (Home/End). - pub fn tab_rename_caret_edge(&mut self, end: bool) { - let Some((_, buf)) = self.nebula_tab_rename.as_ref() else { return }; - self.nebula_tab_rename_select_all = false; - self.nebula_tab_rename_caret = if end { buf.chars().count() } else { 0 }; - self.pending_update.dirty = true; - } - - /// Place the caret from a pointer press at window-space `x`: map the - /// pixel offset from the buffer's first glyph (stashed by `draw_chrome`) - /// into a char index, honoring CJK double-width glyphs. This is what lets - /// users click where they want to edit instead of retyping the name. - pub fn tab_rename_click(&mut self, x: f32) { - let text_x = self.nebula_tab_rename_text_x; - let cell_w = self.size_info.cell_width(); - let Some((_, buf)) = self.nebula_tab_rename.as_ref() else { return }; - let mut col = ((x - text_x) / cell_w).round().max(0.0) as usize; - let mut caret = 0usize; - for c in buf.chars() { - let w = c.width().unwrap_or(0).max(1); - if col < w { - break; - } - col -= w; - caret += 1; - } - self.nebula_tab_rename_select_all = false; - self.nebula_tab_rename_caret = caret; - self.pending_update.dirty = true; - } - - /// Adopt the focused pane's cwd into the drawer (per drawn frame; cheap - /// no-op unless the drawer is open and something changed). - pub fn side_panel_sync(&mut self, cwd: Option) { - if self.sftp_view_active() { - return; - } - if self.nebula_side_panel.sync(cwd) { - self.pending_update.dirty = true; - } - } - - /// Whether the drawer currently shows the SFTP view. The SFTP panel is a - /// window-level object, but its VIEW follows the focused pane: focusing a - /// local tab flips the drawer back to the directory tree (the SFTP - /// connection stays warm for the next switch), so the tree keeps - /// following tab switches instead of being captured by one SSH session. - pub(crate) fn sftp_view_active(&self) -> bool { - self.nebula_sftp_panel.is_some() && self.nebula_sftp_routed - } - - /// Re-route the drawer to the focused pane's identity, called every draw. - /// `focused_ssh` is the pane's stable SSH destination, `None` for local - /// panes. - pub fn route_side_panel(&mut self, focused_ssh: Option<&str>) { - let routed = match (self.nebula_sftp_panel.as_ref(), focused_ssh) { - (Some(panel), Some(destination)) => panel.snapshot().destination == destination, - _ => false, - }; - if self.nebula_sftp_routed != routed { - self.nebula_sftp_routed = routed; - self.pending_update.dirty = true; - self.window.request_redraw(); - } - } - - pub fn choose_side_panel_directory(&mut self) { - let Some(path) = file_dialog::pick_side_panel_directory(&self.window) else { - return; - }; - if self.nebula_side_panel.set_custom_root(path) { - self.pending_update.dirty = true; - self.window.request_redraw(); - } - } - - pub fn follow_focused_directory(&mut self) { - if self.nebula_side_panel.clear_custom_root() { - self.pending_update.dirty = true; - self.window.request_redraw(); - } - } - - /// Geometry of the drawer for the current window size. - pub fn side_panel_layout(&self) -> side_panel::PanelLayout { - let size = self.size_info; - let scale = self.window.scale_factor as f32; - let reserve = chrome_reserve(scale); - side_panel::panel_layout( - size.width(), - size.height(), - reserve, - reserve, - scale, - self.nebula_ui_anims.right_drawer.value(), - ) - } - - pub fn open_sftp_panel( - &mut self, - destination: String, - proxy: winit::event_loop::EventLoopProxy, - ) -> Result<(), String> { - let was_open = self.nebula_side_panel.open; - let controller = crate::ssh_sftp::SftpController::new(destination, proxy, self.window.id()) - .map_err(|err| format!("无法打开 SFTP: {err}"))?; - self.nebula_side_panel.search_unfocus(false); - self.nebula_side_panel.commit_unfocus(); - self.nebula_side_panel.open = true; - self.nebula_sftp_panel = Some(sftp_panel::SftpPanel::new(controller)); - if !was_open { - let size = - PhysicalSize::new(self.size_info.width() as u32, self.size_info.height() as u32); - self.pending_update.set_dimensions(size); - } - self.pending_update.dirty = true; - self.window.request_redraw(); - Ok(()) - } - - pub fn close_sftp_panel(&mut self) { - if let Some(panel) = self.nebula_sftp_panel.take() { - panel.cancel_transfer(); - } - if self.nebula_side_panel.open { - self.nebula_side_panel.open = false; - let size = - PhysicalSize::new(self.size_info.width() as u32, self.size_info.height() as u32); - self.pending_update.set_dimensions(size); - } - self.pending_update.dirty = true; - self.window.request_redraw(); - } - - pub fn sftp_layout(&self) -> sftp_panel::SftpLayout { - sftp_panel::layout(&self.side_panel_layout(), self.window.scale_factor as f32) - } - - pub fn sftp_hit(&self, x: f32, y: f32) -> sftp_panel::SftpHit { - // A hidden SFTP view (drawer re-routed to a local pane) must not eat - // clicks that belong to the directory tree drawn in its place. - if !self.sftp_view_active() { - return sftp_panel::SftpHit::None; - } - let Some(panel) = self.nebula_sftp_panel.as_ref() else { - return sftp_panel::SftpHit::None; - }; - let working = panel.snapshot().phase == crate::ssh_sftp::SftpPhase::Working; - sftp_panel::hit_test(&self.sftp_layout(), working, x, y) - } - - pub fn sftp_set_hover(&mut self, hit: sftp_panel::SftpHit) -> bool { - self.nebula_sftp_panel.as_mut().is_some_and(|panel| panel.set_hover(hit)) - } - - pub fn sftp_click(&mut self, hit: sftp_panel::SftpHit) { - use sftp_panel::SftpHit; - match hit { - SftpHit::Close => self.close_sftp_panel(), - SftpHit::Path => { - if let Some(panel) = self.nebula_sftp_panel.as_mut() { - panel.begin_path(); - } - }, - SftpHit::Filter => { - if let Some(panel) = self.nebula_sftp_panel.as_mut() { - panel.begin_filter(); - } - }, - SftpHit::Row(index) => { - let selected = - self.nebula_sftp_panel.as_mut().and_then(|panel| panel.select_row(index)); - if let Some((entry, true)) = selected { - let navigated = - self.nebula_sftp_panel.as_mut().is_some_and(|panel| panel.navigate(&entry)); - if !navigated { - self.sftp_download_entry(entry); - } - } - }, - SftpHit::Cancel => { - if let Some(panel) = self.nebula_sftp_panel.as_ref() { - panel.cancel_transfer(); - } - }, - SftpHit::None | SftpHit::Inside => {}, - } - self.pending_update.dirty = true; - self.window.request_redraw(); - } - - pub fn sftp_refresh(&mut self) { - if let Some(panel) = self.nebula_sftp_panel.as_ref() { - panel.refresh(); - } - } - - pub fn sftp_pick_upload_files(&mut self) { - let paths = file_dialog::pick_upload_files(&self.window); - if !paths.is_empty() - && let Some(panel) = self.nebula_sftp_panel.as_ref() - { - panel.upload_paths(paths); - } - } - - pub fn sftp_pick_upload_directory(&mut self) { - if let Some(path) = file_dialog::pick_upload_directory(&self.window) - && let Some(panel) = self.nebula_sftp_panel.as_ref() - { - panel.upload_paths(vec![path]); - } - } - - pub fn sftp_begin_create_directory(&mut self) { - if let Some(panel) = self.nebula_sftp_panel.as_mut() { - panel.begin_create_directory(); - } - } - - pub fn sftp_upload_dropped_paths(&mut self, paths: Vec) -> bool { - if paths.is_empty() { - return false; - } - let Some(panel) = self.nebula_sftp_panel.as_ref() else { return false }; - panel.upload_paths(paths); - self.pending_update.dirty = true; - self.window.request_redraw(); - true - } - - pub fn sftp_download_row(&mut self, index: usize) { - if let Some(entry) = - self.nebula_sftp_panel.as_ref().and_then(|panel| panel.visible_entry(index)) - { - self.sftp_download_entry(entry); - } - } - - fn sftp_download_entry(&mut self, entry: crate::ssh_sftp::SftpEntry) { - let Some(directory) = file_dialog::pick_download_directory(&self.window) else { - return; - }; - if let Some(panel) = self.nebula_sftp_panel.as_ref() { - panel.download(entry, directory); - } - } - - pub fn sftp_begin_rename_row(&mut self, index: usize) { - let entry = self.nebula_sftp_panel.as_ref().and_then(|panel| panel.visible_entry(index)); - if let (Some(panel), Some(entry)) = (self.nebula_sftp_panel.as_mut(), entry) { - panel.begin_rename(entry); - } - } - - pub fn sftp_request_delete_row(&mut self, index: usize) { - if let Some(entry) = - self.nebula_sftp_panel.as_ref().and_then(|panel| panel.visible_entry(index)) - { - self.nebula_confirm = Some(NebulaConfirm::DeleteSftp { entry }); - } - } - - pub fn sftp_confirm_delete(&mut self, entry: crate::ssh_sftp::SftpEntry) { - self.nebula_confirm = None; - if let Some(panel) = self.nebula_sftp_panel.as_ref() { - panel.delete(entry); - } - } - - pub fn step_chrome_anims(&mut self) { - self.nebula_ui_anims.step( - !self.nebula_sidebar_collapsed, - self.nebula_side_panel.open, - self.nebula_ssh_editor_open, - ); - } - - pub fn chrome_animating(&self) -> bool { - self.nebula_ui_anims.animating(!self.nebula_sidebar_collapsed, self.nebula_side_panel.open) - } - - pub fn left_sidebar_progress(&self) -> f32 { - self.nebula_ui_anims.left_sidebar.value() - } - - pub fn left_sidebar_visible(&self) -> bool { - self.nebula_ui_anims.left_sidebar.visible(!self.nebula_sidebar_collapsed) - } - - /// DPI 变化时按同一比例重标 UI 角色字号(等价于配置字号 × 新缩放)。 - pub(crate) fn rescale_ui_font(&mut self, factor: f32) { - if factor.is_finite() && factor > 0.0 { - self.nebula_ui_font.px *= factor; - } - } - - /// UI 角色字号相对当前终端字号的比率。仅供尚未角色化的历史缩放路径 - /// (`begin_chrome_text_scaled`、链接预览的手动锚定)使用;新代码一律 - /// 走 `draw_chrome_text*` / `draw_ui_text*`,它们直接从字体角色取真实 - /// 字号与 metrics。 - pub(crate) fn ui_text_scale(&self) -> f32 { - let cur = self.font_size.as_px(); - if cur <= 0.0 || self.nebula_ui_font.px <= 0.0 { - return 1.0; - } - self.nebula_ui_font.px / cur - } - - /// [`Self::size_info`] 的 UI 版本:cell 尺寸来自 UI 字体角色的真实 - /// 栅格 metrics([`Self::refresh_ui_font`] 量取)。chrome / 设置 / - /// 浮层的布局与命中测试统一用它,与按同一角色栅格化的 UI 文本严格 - /// 同源;终端网格、damage、光标继续用原 `size_info`。 - pub(crate) fn ui_size_info(&self) -> SizeInfo { - let mut ui = self.size_info; - let (cell_w, cell_h) = self.nebula_ui_font.cell; - ui.cell_width = cell_w; - ui.cell_height = cell_h; - ui - } - - /// Pin the glyph cache's UI font role to the anchor size and refresh the - /// cell the chrome layout steps by. Runs at construction and on every - /// terminal font change — zoom, family, DPI all funnel through the font - /// update, so this is the single place the role can go stale. - 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); - 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 - // from USER instances, which never run with NEBULA_DEBUG_LOG set. - let line = format!( - "[{}] ui_anchor ratio={ratio:.3} ui_font_px={:.1} font_px={:.1} scale={:.2} term_cell={}x{} ui_cell={:?}\n", - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_secs()) - .unwrap_or(0), - self.nebula_ui_font.px, - self.font_size.as_px(), - self.window.scale_factor, - self.size_info.cell_width, - self.size_info.cell_height, - self.nebula_ui_font.cell - ); - if let Ok(mut file) = std::fs::OpenOptions::new() - .create(true) - .append(true) - .open(nebula_data_dir().join("ui_anchor.log")) - { - use std::io::Write as _; - let _ = file.write_all(line.as_bytes()); - } - } - - /// Geometry of the rounded terminal card in physical pixels `(x, y, w, h)`. - /// The card floats on the shell backdrop: flush-ish against the sidebar on - /// the left and the top bar above (they share the shell color, so no seam - /// is needed there), with a visible [`UI_CARD_SEAM_LOGICAL`] gap of shell - /// color on the right and bottom edges. The grid's own padding - /// (`content_pad_x` / `chrome_reserve`) is larger than the card inset, so - /// all cell content lands inside the card. - pub(crate) fn terminal_card_rect(&self) -> (f32, f32, f32, f32) { - let scale = self.window.scale_factor as f32; - let s = |v: f32| (v * scale).round(); - let seam = s(UI_CARD_SEAM_LOGICAL); - // Left edge rides the sidebar's fold animation (same swift-out cubic - // as the panel slide in `chrome_tab_layout`), so collapsing the - // sidebar reads as the terminal card gliding left to claim the space - // instead of snapping. Resting expanded: just past the sidebar - // panel's right edge (`sw - 12` logical, see `chrome_tab_layout`); - // resting collapsed: the chrome margin. - let t = self.left_sidebar_progress().clamp(0.0, 1.0); - let sw = (SIDEBAR_W_LOGICAL * scale).round(); - let x = s(8.0) + t * (sw - s(4.0) - s(8.0)); - // Top edge: the top bar's bottom (margin 8 + bar height 40, matching - // `draw_chrome`), plus a seam so the card visibly floats below it. - let y = s(8.0 + 40.0) + seam; - // Right edge follows the file/git drawer the same way: as it slides - // in, the card cedes its width (drawer width + margin) plus the seam. - let dt = self.nebula_ui_anims.right_drawer.value().clamp(0.0, 1.0); - let drawer = dt * (side_panel::PANEL_W_LOGICAL * scale + s(8.0)); - let w = (self.size_info.width() - drawer - seam - x).max(0.0); - let h = (self.size_info.height() - seam - y).max(0.0); - (x, y, w, h) - } - - pub fn side_panel_visible(&self) -> bool { - self.nebula_ui_anims.right_drawer.visible(self.nebula_side_panel.open) - } - - /// Sidebar content model for `chrome_tab_layout` — the single place the - /// section states are read, so drawing / hit-testing / wheel agree. - pub(super) fn sidebar_model(&self) -> chrome::SidebarModel { - chrome::SidebarModel { - tab_count: self.nebula_tab_labels.len().max(1), - host_count: self.nebula_ssh_hosts.len(), - tabs_open: self.nebula_tabs_section_open, - hosts_open: self.nebula_hosts_section_open, - tabs_scroll: self.nebula_tabs_scroll, - hosts_scroll: self.nebula_hosts_scroll, - } - } - - /// Toggle a sidebar section's accordion fold (click on its caption). - pub fn toggle_sidebar_section(&mut self, hosts: bool) { - if hosts { - self.nebula_hosts_section_open = !self.nebula_hosts_section_open; - } else { - self.nebula_tabs_section_open = !self.nebula_tabs_section_open; - } - self.pending_update.dirty = true; - } - - /// Toggle the queue entry now; the expanded panel will consume the same - /// state in the next integration stage, so the entry's hit contract does - /// not need to change when real queue content lands. - pub fn toggle_message_queue_entry(&mut self) { - self.nebula_message_queue_entry.toggle(); - self.pending_update.dirty = true; - self.window.request_redraw(); - } - - /// Route a mouse-wheel tick over the sidebar into the section under the - /// pointer. Returns true when consumed (pointer was over a section band). - pub fn sidebar_wheel(&mut self, x: f32, y: f32, rows: i32) -> bool { - if !self.left_sidebar_visible() { - return false; - } - let layout = chrome_tab_layout( - &self.ui_size_info(), - self.window.scale_factor as f32, - self.sidebar_model(), - self.left_sidebar_progress(), - ); - let (px, _, pw, _) = layout.panel; - if pw <= 0.0 || x < px || x > px + pw { - return false; - } - let scroll = - |cur: usize, max: usize| -> usize { (cur as i32 + rows).clamp(0, max as i32) as usize }; - // Band membership includes each section's header so the wheel works - // right up against the caption. - if y >= layout.tabs_header.1 && y <= layout.tabs_band.1 { - self.nebula_tabs_scroll = scroll(self.nebula_tabs_scroll, layout.tabs_max_scroll); - } else if y >= layout.hosts_header.1 - && y <= layout.hosts_band.1.max(layout.hosts_header.1 + layout.hosts_header.3) - { - self.nebula_hosts_scroll = scroll(self.nebula_hosts_scroll, layout.hosts_max_scroll); - } else { - return false; - } - self.pending_update.dirty = true; - true - } - - /// Auto-save an SSH destination the user typed and successfully connected - /// to — armed at OSC 133;C, confirmed by a remote `NEBULA|` title or a - /// session that outlived [`crate::ssh::SAVE_MIN_SESSION`]. Tabby-style - /// recents: most recent first, deduped, capped. An already-listed host - /// only refreshes its recency (for the next launch) — the visible list - /// never jumps while the user is looking at it. - pub fn nebula_save_ssh_host(&mut self, host: &str) { - const SAVED_HOSTS_CAP: usize = 20; - if host.is_empty() { - return; - } - self.nebula_saved_hosts.retain(|h| h != host); - self.nebula_hidden_hosts.retain(|h| h != host); - self.nebula_saved_hosts.insert(0, host.to_owned()); - self.nebula_saved_hosts.truncate(SAVED_HOSTS_CAP); - if !self.nebula_ssh_hosts.iter().any(|h| h == host) { - // New host: insert below the pinned block, above everything else. - let at = self - .nebula_ssh_hosts - .iter() - .take_while(|h| self.nebula_pinned_hosts.contains(h)) - .count(); - self.nebula_ssh_hosts.insert(at, host.to_owned()); - } - self.persist_nebula_settings(); - self.pending_update.dirty = true; - } - - // ---- 键位自定义(spec 002)---- - - /// 设置页点击某行的 keycap:进入捕获态(下一次按键成为新绑定)。 - pub fn keymap_begin_capture(&mut self, row: usize) { - if row < keymap::editable_row_count() { - self.nebula_keymap_capture = Some(row); - self.nebula_keymap_capture_preview.clear(); - self.nebula_quick_hotkey_error = None; - self.pending_update.dirty = true; - } - } - - pub fn keymap_cancel_capture(&mut self) { - if self.nebula_keymap_capture.take().is_some() { - self.nebula_keymap_capture_preview.clear(); - self.pending_update.dirty = true; - } - } - - /// 捕获态的实时修饰键回显(ModifiersChanged 与纯修饰键按下都会走到 - /// 这里):按住 Ctrl 立即显示 "Ctrl+…",全部松开回到占位提示。 - pub fn keymap_capture_preview(&mut self, mods: winit::keyboard::ModifiersState) { - if self.nebula_keymap_capture.is_none() { - return; - } - let prefix = keymap::mods_prefix(mods); - if self.nebula_keymap_capture_preview != prefix { - self.nebula_keymap_capture_preview = prefix; - self.pending_update.dirty = true; - self.window.request_redraw(); - } - } - - /// 捕获完成:`combo` 归属该行动作。同 combo 的旧自定义行被移除(键随 - /// 最后写入者),该动作旧的自定义行也移除(一动作一自定义键)。 - pub fn keymap_assign(&mut self, row: usize, combo: String) { - if row == keymap::QUICK_TERMINAL_ROW { - self.nebula_keymap_capture = None; - self.nebula_keymap_capture_preview.clear(); - self.nebula_quick_terminal_hotkey = combo; - self.nebula_quick_hotkey_error = None; - self.nebula_quick_hotkey_request = Some(self.nebula_quick_terminal_hotkey.clone()); - self.persist_nebula_settings(); - self.pending_update.dirty = true; - return; - } - let action_row = row.saturating_sub(1); - let Some((action, ..)) = keymap::EDITABLE_ACTIONS.get(action_row) else { return }; - let name = keymap::action_storage_name(action); - self.nebula_keybinds.retain(|(c, a)| c != &combo && !a.eq_ignore_ascii_case(&name)); - self.nebula_keybinds.push((combo, name)); - self.keymap_commit(); - } - - /// 捕获态里按 Backspace:删除该动作的自定义绑定,回落内置默认。 - pub fn keymap_clear_custom(&mut self, row: usize) { - if row == keymap::QUICK_TERMINAL_ROW { - self.nebula_keymap_capture = None; - self.nebula_keymap_capture_preview.clear(); - self.nebula_quick_terminal_hotkey = keymap::DEFAULT_QUICK_TERMINAL_HOTKEY.to_owned(); - self.nebula_quick_hotkey_error = None; - self.nebula_quick_hotkey_request = Some(self.nebula_quick_terminal_hotkey.clone()); - self.persist_nebula_settings(); - self.pending_update.dirty = true; - return; - } - let action_row = row.saturating_sub(1); - let Some((action, ..)) = keymap::EDITABLE_ACTIONS.get(action_row) else { return }; - let name = keymap::action_storage_name(action); - self.nebula_keybinds.retain(|(_, a)| !a.eq_ignore_ascii_case(&name)); - self.keymap_commit(); - } - - fn keymap_commit(&mut self) { - self.nebula_keymap_capture = None; - self.nebula_keymap_capture_preview.clear(); - self.nebula_keymap = keymap::build_bindings(&self.nebula_keybinds); - self.persist_nebula_settings(); - self.pending_update.dirty = true; - } - - /// 取走一次性全局快捷键更新请求,由输入层送到 Processor 的全局管理器。 - pub(crate) fn take_quick_hotkey_request(&mut self) -> Option { - self.nebula_quick_hotkey_request.take() - } - - /// Processor 完成注册后的确认/回滚。失败时恢复磁盘与界面中的旧值, - /// 防止设置页把未注册的组合误显示成当前快捷键。 - pub(crate) fn quick_hotkey_registration_done( - &mut self, - requested: &str, - accepted: bool, - error: Option<&str>, - fallback: &str, - ) { - if accepted { - self.nebula_quick_hotkey_error = None; - return; - } - if self.nebula_quick_terminal_hotkey == requested { - self.nebula_quick_terminal_hotkey = fallback.to_owned(); - self.persist_nebula_settings(); - } - self.nebula_quick_hotkey_error = error.map(str::to_owned); - self.pending_update.dirty = true; - self.window.request_redraw(); - } - - pub(crate) fn persist_nebula_settings(&mut self) { - settings::nebula_settings_write(&settings::NebulaRuntimeSettings { - language: self.nebula_language_preference, - ghost: self.nebula_ghost_enabled, - accept: self.nebula_accept, - shell: self.nebula_shell, - shell_id: self.nebula_shell_id.clone(), - startup_directory: self.nebula_startup_directory.clone(), - font_family: self.nebula_font_family.clone(), - fetch: self.nebula_fetch_enabled, - powerline: self.nebula_powerline_enabled, - keep_session: self.nebula_keep_session, - opacity: self.nebula_window_opacity, - background: self.nebula_background, - background_image: self.nebula_background_image.clone(), - background_image_opacity: self.nebula_background_image_opacity, - background_image_fit: self.nebula_background_image_fit, - background_image_alignment: self.nebula_background_image_alignment, - background_image_cover_chrome: self.nebula_background_image_cover_chrome, - font_size: Some(self.font_size.as_px() / self.window.scale_factor as f32), - cursor_shape: self.nebula_cursor_shape, - cursor_blink: self.nebula_cursor_blink, - copy_on_select: self.nebula_copy_on_select, - cjk_bold_regular: self.nebula_cjk_bold_regular, - theme: self.nebula_theme_preference, - follow_system_theme: self.nebula_follow_system_theme, - pinned_hosts: self.nebula_pinned_hosts.clone(), - saved_hosts: self.nebula_saved_hosts.clone(), - hidden_hosts: self.nebula_hidden_hosts.clone(), - keybinds: self.nebula_keybinds.clone(), - quick_terminal_hotkey: self.nebula_quick_terminal_hotkey.clone(), - }); - self.nebula_settings_mtime = settings::nebula_settings_mtime(); - } - - fn reload_nebula_settings_if_changed(&mut self, config: &UiConfig) { - let mtime = settings::nebula_settings_mtime(); - if mtime == self.nebula_settings_mtime { - return; - } - - let settings = settings::nebula_settings_load(config); - self.nebula_language_preference = settings.language; - self.nebula_language = settings.language.resolved(); - self.nebula_palette.set_language(self.nebula_language); - let image_changed = settings.background_image != self.nebula_background_image; - let font_changed = settings.font_family != self.nebula_font_family; - self.nebula_theme_preference = settings.theme; - let follow_system_changed = self.nebula_follow_system_theme != settings.follow_system_theme; - self.nebula_follow_system_theme = settings.follow_system_theme; - if follow_system_changed { - self.window.set_theme(if settings.follow_system_theme { - None - } else { - self.nebula_window_theme_override - }); - if settings.follow_system_theme { - self.nebula_system_theme = - system_theme_snapshot(self.nebula_system_theme, self.window.theme()); - } - } - let active_theme = if settings.follow_system_theme { - self.nebula_system_theme - .map(|system| { - settings.theme.for_system_appearance(matches!(system, WinitTheme::Light)) - }) - .unwrap_or(settings.theme) - } else { - settings.theme - }; - if active_theme != self.nebula_theme { - // Hand-edited theme or automatic-mode setting: apply and publish - // it exactly like an in-panel selection would. - self.apply_nebula_theme(active_theme); - } - self.nebula_ghost_enabled = settings.ghost; - self.nebula_accept = settings.accept; - self.nebula_shell = settings.shell; - self.nebula_shell_id = settings.shell_id; - self.nebula_startup_directory = settings.startup_directory; - self.nebula_font_family = settings.font_family; - if font_changed { - #[cfg(windows)] - { - self.nebula_font_families = self.glyph_cache.refresh_private_fonts(); - self.nebula_font_families - .retain(|family| family != crate::font_install::REQUIRED_FONT_FAMILY); - self.nebula_font_families - .insert(0, crate::font_install::REQUIRED_FONT_FAMILY.to_owned()); - } - let font = self.effective_font(&config.font).with_size(self.font_size); - self.pending_update.set_font(font); - } - self.nebula_fetch_enabled = settings.fetch; - self.nebula_powerline_enabled = settings.powerline; - self.nebula_keep_session = settings.keep_session; - if self.nebula_cjk_bold_regular != settings.cjk_bold_regular { - // 字形层策略变了:已缓存的 bold CJK 位图作废,清缓存重栅格。 - self.nebula_cjk_bold_regular = settings.cjk_bold_regular; - self.glyph_cache.wide_bold_use_regular = settings.cjk_bold_regular; - self.reset_glyph_cache(); - } - self.nebula_window_opacity = settings.opacity; - self.nebula_background = if settings.follow_system_theme { - Some(active_theme.palette().term_bg) - } else { - settings.background - }; - self.nebula_background_image = settings.background_image; - self.nebula_background_image_opacity = settings.background_image_opacity; - self.nebula_background_image_fit = settings.background_image_fit; - self.nebula_background_image_alignment = settings.background_image_alignment; - self.nebula_background_image_cover_chrome = settings.background_image_cover_chrome; - // Sync the host lists too: another window shares the settings file, - // and skipping this would let this window's next persist overwrite a - // host that window just saved or pinned. - self.nebula_pinned_hosts = settings.pinned_hosts; - self.nebula_saved_hosts = settings.saved_hosts; - self.nebula_hidden_hosts = settings.hidden_hosts; - // Hand-edited keybind lines take effect on the next keypress; an - // in-flight capture is dropped so it can't overwrite the file edit. - self.nebula_keymap = keymap::build_bindings(&settings.keybinds); - self.nebula_keybinds = settings.keybinds; - if self.nebula_quick_terminal_hotkey != settings.quick_terminal_hotkey { - self.nebula_quick_terminal_hotkey = settings.quick_terminal_hotkey.clone(); - self.nebula_quick_hotkey_request = Some(self.nebula_quick_terminal_hotkey.clone()); - self.nebula_quick_hotkey_error = None; - } - self.nebula_keymap_capture = None; - self.nebula_ssh_hosts = merge_ssh_hosts( - &self.nebula_saved_hosts, - &self.nebula_pinned_hosts, - &self.nebula_hidden_hosts, - ); - if image_changed { - self.renderer.invalidate_background_image(); - } - self.nebula_settings_mtime = mtime; - self.update_window_transparency(); - self.pending_update.dirty = true; - } - - fn draw_background_image(&mut self) { - let Some(path) = self.nebula_background_image.as_deref() else { - return; - }; - let path = path.trim().trim_matches('"'); - if path.is_empty() { - return; - } - - // Keep PNG wallpaper loading in the renderer cache. The setting stores a - // user path verbatim (usually `D:\...` on Windows); `cover` scaling and - // alpha are handled by the image renderer. - let target = if self.nebula_background_image_cover_chrome { - (0.0, 0.0, self.size_info.width(), self.size_info.height()) - } else { - self.terminal_card_rect() - }; - // 卡片模式必须携带卡片圆角:矩形壁纸盖上去会吃掉终端卡的圆角。 - let clip_radius = if self.nebula_background_image_cover_chrome { - 0.0 - } else { - (UI_SHELL_RADIUS_LOGICAL * self.window.scale_factor as f32).round() - }; - self.renderer.draw_background_image( - &self.size_info, - Path::new(path), - self.nebula_background_image_opacity, - self.nebula_background_image_fit, - self.nebula_background_image_alignment, - target, - target, - clip_radius, - ); - } - - /// Compose the stable frame backdrop once per frame. - /// - /// 层模型(2026-07-24 修订):清屏完全透明,终端卡底永远先铺主题底 - /// 色(用户透明度),壁纸再以自身不透明度叠在其上——降低壁纸不透 - /// 明度时图片淡向主题底色(浅色主题→白、深色主题→黑),而不是透出 - /// 窗口后面的桌面(旧模型有壁纸时不画卡底,深色主题下低不透明度会 - /// 透出刺眼的白)。卡以外的壳由 chrome pass 的一体化壳层负责(同一 - /// 用户透明度)。 - fn draw_window_backdrop(&mut self, terminal_background: Rgb) { - // rgb 保留壳色:不透明窗口下 DWM 忽略 alpha,尚未被壳/卡覆盖的 - // 像素以壳色兜底而不是黑底。 - self.renderer.clear(self.nebula_theme.palette().shell_bg, 0.0); - { - let (card_x, card_y, card_w, card_h) = self.terminal_card_rect(); - let scale = self.window.scale_factor as f32; - let alpha = (self.nebula_window_opacity * 255.0).round().clamp(0.0, 255.0) as u8; - // 与 chrome 壳层的凹角同径同 round:半径差出小数像素会让两侧 - // 的圆弧 AA 错位成一圈细缝。 - let card = UiQuad::solid( - card_x, - card_y, - card_w, - card_h, - (UI_SHELL_RADIUS_LOGICAL * scale).round(), - Rgba::new( - terminal_background.r, - terminal_background.g, - terminal_background.b, - alpha, - ), - ); - self.renderer.draw_ui(&self.size_info, &[card]); - } - - // The image is intentionally independent of the terminal tint: its own - // opacity means image strength, not a value that disappears at 100% - // terminal opacity. - self.draw_background_image(); - } - - /// Whether a wallpaper path is configured for the terminal card. - fn has_background_image(&self) -> bool { - self.nebula_background_image - .as_deref() - .map(|p| !p.trim().trim_matches('"').is_empty()) - .unwrap_or(false) - } - - /// Sync the OS transparency flag with the user opacity slider. 壁纸不 - /// 透明度不再参与:卡底永远先铺主题底色,壁纸变淡是淡向主题色而非 - /// 透出窗口后面的桌面。 - fn update_window_transparency(&mut self) { - let transparent = self.nebula_window_opacity < 1.0; - self.window.set_transparent(transparent); - #[cfg(target_os = "macos")] - self.window.set_has_shadow(!transparent); - } - - #[inline] - pub fn gl_context(&self) -> &PossiblyCurrentContext { - &self.context - } - - pub fn make_not_current(&mut self) { - if self.context.is_current() { - self.context.make_not_current_in_place().expect("failed to disable context"); - } - } - - pub fn make_current(&mut self) { - let is_current = self.context.is_current(); - - // Attempt to make the context current if it's not. - let context_loss = if is_current { - self.renderer.was_context_reset() - } else { - match self.context.make_current(&self.surface) { - Err(err) if err.error_kind() == ErrorKind::ContextLost => { - info!("Context lost for window {:?}", self.window.id()); - true - }, - _ => false, - } - }; - - if !context_loss { - return; - } - - let gl_display = self.context.display(); - let gl_config = self.context.config(); - let raw_window_handle = Some(self.window.raw_window_handle()); - let context = platform::create_gl_context(&gl_display, &gl_config, raw_window_handle) - .expect("failed to recreate context."); - - // Drop the old context and renderer. - unsafe { - ManuallyDrop::drop(&mut self.renderer); - ManuallyDrop::drop(&mut self.context); - } - - // Activate new context. - let context = context.treat_as_possibly_current(); - self.context = ManuallyDrop::new(context); - self.context.make_current(&self.surface).expect("failed to reativate context after reset."); - - // Recreate renderer. - let renderer = Renderer::new(&self.context, self.renderer_preference) - .expect("failed to recreate renderer after reset"); - self.renderer = ManuallyDrop::new(renderer); - - // Resize the renderer. - self.renderer.resize(&self.size_info); - - self.reset_glyph_cache(); - self.damage_tracker.frame().mark_fully_damaged(); - - debug!("Recovered window {:?} from gpu reset", self.window.id()); - } - - fn swap_buffers(&self) { - #[allow(clippy::single_match)] - let res = match (self.surface.deref(), &self.context.deref()) { - #[cfg(not(any(target_os = "macos", windows)))] - (Surface::Egl(surface), PossiblyCurrentContext::Egl(context)) - if matches!(self.raw_window_handle, RawWindowHandle::Wayland(_)) - && !self.damage_tracker.debug => - { - let damage = self.damage_tracker.shape_frame_damage(self.size_info.into()); - surface.swap_buffers_with_damage(context, &damage) - }, - (surface, context) => surface.swap_buffers(context), - }; - if let Err(err) = res { - debug!("error calling swap_buffers: {err}"); - } - } - - /// Update font size and cell dimensions. - /// - /// This will return a tuple of the cell width and height. - fn update_font_size( - glyph_cache: &mut GlyphCache, - config: &UiConfig, - font: &Font, - ) -> (f32, f32) { - let _ = glyph_cache.update_font_size(font); - - // Compute new cell sizes. - compute_cell_size(config, &glyph_cache.font_metrics()) - } - - /// Reset glyph cache. - fn reset_glyph_cache(&mut self) { - let cache = &mut self.glyph_cache; - self.renderer.with_loader(|mut api| { - cache.reset_glyph_cache(&mut api); - }); - } - - // XXX: this function must not call to any `OpenGL` related tasks. Renderer updates are - // performed in [`Self::process_renderer_update`] right before drawing. - // - /// Process update events. - pub fn handle_update( - &mut self, - terminal: &mut Term, - // PTY resizes are deferred to the settle timer (see - // `nebula_pty_resize_pending`); the handle stays in the signature so - // the call sites don't churn if an immediate path returns. - _pty_resize_handle: &mut dyn OnResize, - message_buffer: &MessageBuffer, - search_state: &mut SearchState, - config: &UiConfig, - ) where - T: EventListener, - { - let pending_update = mem::take(&mut self.pending_update); - - let (mut cell_width, mut cell_height) = - (self.size_info.cell_width(), self.size_info.cell_height()); - - if pending_update.font().is_some() || pending_update.cursor_dirty() { - let renderer_update = self.pending_renderer_update.get_or_insert(Default::default()); - renderer_update.clear_font_cache = true - } - - // 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); - cell_width = cell_dimensions.0; - cell_height = cell_dimensions.1; - - info!("Cell size: {cell_width} x {cell_height}"); - - // Every zoom / font / DPI change funnels through a font update, - // so this is the single point where the UI font role can go - // stale. - self.refresh_ui_font(config); - - // Mark entire terminal as damaged since glyph size could change without cell size - // changes. - self.damage_tracker.frame().mark_fully_damaged(); - } - - let (mut width, mut height) = (self.size_info.width(), self.size_info.height()); - if let Some(dimensions) = pending_update.dimensions() { - width = dimensions.width as f32; - height = dimensions.height as f32; - } - - let padding = config.window.padding(self.window.scale_factor as f32); - let chrome = chrome_reserve(self.window.scale_factor as f32); - - let scale = self.window.scale_factor as f32; - let content_pad = content_pad_x(scale); - let sidebar = sidebar_width(scale, self.nebula_sidebar_collapsed); - // The file/git drawer occupies real layout space: the grid cedes its - // width (plus the window margin) on the right, exactly like the left - // sidebar reserve — it does not float over the terminal. - let drawer = if self.nebula_side_panel.open { - (side_panel::PANEL_W_LOGICAL * scale + 8.0 * scale).round() - } else { - 0.0 - }; - let mut new_size = SizeInfo::new_fully_asymmetric( - width, - height, - cell_width, - cell_height, - padding.0 + content_pad + sidebar, - padding.0 + content_pad + drawer, - padding.1 + chrome, - padding.1 + bottom_content_reserve(scale), - ); - - // Update number of column/lines in the viewport. - let search_active = search_state.history_index.is_some(); - let message_bar_lines = message_buffer.message().map_or(0, |m| m.text(&new_size).len()); - let search_lines = usize::from(search_active); - new_size.reserve_lines(message_bar_lines + search_lines); - - // Update resize increments. - if config.window.resize_increments { - let increments = self - .window - .allows_drag_resize() - .then_some(PhysicalSize::new(cell_width, cell_height)); - self.window.set_resize_increments(increments); - } - - // Resize when terminal when its dimensions have changed. - if self.size_info.screen_lines() != new_size.screen_lines - || self.size_info.columns() != new_size.columns() - { - // Defer the PTY resize to the settle timer instead of notifying - // per tick: the in-box ConPTY repaints its entire viewport on - // every resize, so drag-resizing would flood the scrollback with - // dozens of shredded repaints (and TUIs like Claude Code redraw - // storms). The grid resizes live below, so rendering stays exact; - // only the child's SIGWINCH-equivalent waits for the drag to end. - self.nebula_pty_resize_pending = true; - - // Resize terminal. - terminal.resize(new_size); - - // Resize damage tracking. - self.damage_tracker.resize(new_size.screen_lines(), new_size.columns()); - - // Flash a transient "cols × rows" HUD, skipping the first (startup) - // resize so nothing flashes when the window is first created. - if self.nebula_resize_hud_armed { - self.nebula_resize_hud = - Some(ResizeHud::new(new_size.columns(), new_size.screen_lines())); - } - self.nebula_resize_hud_armed = true; - nebula_link_log(format!( - "grid_resize {}x{} px={width}x{height} pad_x={} pad_r={} pad_y={} \ - cell={cell_width}x{cell_height} drawer={drawer} sidebar={sidebar} \ - reserved={}", - new_size.columns(), - new_size.screen_lines(), - new_size.padding_x(), - new_size.padding_right(), - new_size.padding_y(), - message_bar_lines + search_lines, - )); - } - - // Check if dimensions have changed. - if new_size != self.size_info { - // Queue renderer update. - let renderer_update = self.pending_renderer_update.get_or_insert(Default::default()); - renderer_update.resize = true; - - // Clear focused search match. - search_state.clear_focused_match(); - } - self.size_info = new_size; - } - - // NOTE: Renderer updates are split off, since platforms like Wayland require resize and other - // OpenGL operations to be performed right before rendering. Otherwise they could lock the - // back buffer and render with the previous state. This also solves flickering during resizes. - // - /// Update the state of the renderer. - pub fn process_renderer_update(&mut self) { - let renderer_update = match self.pending_renderer_update.take() { - Some(renderer_update) => renderer_update, - _ => return, - }; - - // Resize renderer. - if renderer_update.resize { - let width = NonZeroU32::new(self.size_info.width() as u32).unwrap(); - let height = NonZeroU32::new(self.size_info.height() as u32).unwrap(); - self.surface.resize(&self.context, width, height); - } - - // Ensure we're modifying the correct OpenGL context. - self.make_current(); - - if renderer_update.clear_font_cache { - self.reset_glyph_cache(); - } - - self.renderer.resize(&self.size_info); - - info!("Padding: {} x {}", self.size_info.padding_x(), self.size_info.padding_y()); - info!("Width: {}, Height: {}", self.size_info.width(), self.size_info.height()); - } - - /// Draw the screen. - /// - /// A reference to Term whose state is being drawn must be provided. - /// - /// This call may block if vsync is enabled. - /// Render a single terminal into the region described by `view`. - /// - /// This paints grid cells, cursor, overlays (search/IME/message bar) and the - /// inline ghost suggestion, but it does NOT clear (unless `clear_first`), - /// draw the window chrome, or present — those are the caller's job so that - /// multiple panes can share one frame. `force_focus` overrides the terminal's - /// own focus state for split panes (`None` keeps the real window focus). - #[allow(clippy::too_many_arguments)] - fn draw_pane( - &mut self, - mut terminal: MutexGuard<'_, Term>, - message_buffer: &MessageBuffer, - config: &UiConfig, - search_state: &mut SearchState, - pane_state: &mut NebulaPaneState, - view: SizeInfo, - force_focus: Option, - clear_first: bool, - ) { - // Override focus for split panes so the unfocused side shows a hollow - // cursor; in single-pane mode keep the real window focus state. - if let Some(focused) = force_focus { - terminal.is_focused = focused; - } - - // 把设置页的光标默认值同步进每一个被渲染的终端。事件路径只覆盖 - // "当前聚焦"的那一个 Term,新建 tab、分屏或后台 pane 都会漏掉; - // set_default_cursor_style 内部有相等短路,逐帧调用无重绘代价。 - terminal.set_default_cursor_style(self.nebula_default_cursor_style()); - - // Tell the renderer the full window height so pane viewports flip - // correctly into OpenGL's bottom-left origin — matters for top/bottom - // splits, where panes occupy different vertical bands of the window. - self.renderer.set_window_height(self.size_info.height()); - - pane_state.terminal_math.observe_program(pane_state.running_program.as_deref()); - - // Collect renderable content before the terminal is dropped. - let custom_background = self.nebula_background; - let clickable_matches = hint::visible_clickable_matches(&terminal, config); - let mut content = RenderableContent::new(config, self, &terminal, search_state, &view); - let mut grid_cells = Vec::new(); - let mut grid_pad_bg = None; - for cell in &mut content { - if grid_pad_bg.is_none() && cell.bg_alpha > 0.0 { - grid_pad_bg = Some(cell.bg); - } - grid_cells.push(cell); - } - let selection_range = content.selection_range(); - nebula_debug_log(format!( - "render_pane clear_first={clear_first} view={}x{} pad=({:.0},{:.0},{:.0},{:.0}) selection={selection_range:?}", - view.width(), - view.height(), - view.padding_x(), - view.padding_right(), - view.padding_y(), - view.padding_bottom(), - )); - let foreground_color = content.color(NamedColor::Foreground as usize); - let background_color = - custom_background.unwrap_or_else(|| content.color(NamedColor::Background as usize)); - let display_offset = content.display_offset(); - let cursor = content.cursor(); - - let cursor_point = terminal.grid().cursor.point; - // Anchors for OSC 1337 inline images (absolute-line bookkeeping). - let grid_scrolled_out = terminal.grid().scrolled_out(); - let image_anchor = grid_scrolled_out + terminal.grid().history_size(); - // Ghost text is suppressed on the alt screen (vim/less/etc.). - let alt_screen = terminal.mode().contains(TermMode::ALT_SCREEN); - let total_lines = terminal.grid().total_lines(); - let metrics = self.glyph_cache.font_metrics(); - let size_info = view; - - let vi_mode = terminal.mode().contains(TermMode::VI); - let vi_cursor_point = if vi_mode { Some(terminal.vi_mode_cursor.point) } else { None }; - #[cfg(windows)] - let line_override = if alt_screen || vi_mode || search_state.regex().is_some() { - None - } else { - Self::nebula_input_from_raw_grid(&terminal, cursor_point) - }; - #[cfg(windows)] - let row_preview = if alt_screen || vi_mode || search_state.regex().is_some() { - None - } else { - Some(Self::nebula_raw_grid_row_preview(&terminal, cursor_point)) - }; - - // 打字(含 IME 组词)不影响网格内容,扫描保持开启;一旦这里随 - // preedit 关断,中文输入的每次拼音组合都会让全部公式闪回原文。 - // - // 扫描本身不按 AI CLI 探测门控:$$/\[ \]/\( \) 定界符加上 - // plausible_math_source 已经足够抗误报,而 WSL/SSH 里跑的 AI CLI - // 在本机进程树上只留下 wsl.exe/ssh.exe,按进程门控会把远程会话的 - // 公式全部关掉。只有误报率最高的裸 $…$ 仍要求 AI CLI 上下文 - // (进程探测命中,或本 pane 已渲染过块级公式)。 - let allow_inline_dollar = pane_state.terminal_math.inline_dollar_enabled(); - let terminal_math_overlays = if !alt_screen - && !vi_mode - && search_state.regex().is_none() - && selection_range.is_none() - { - let visible_cursor = term::point_to_viewport(display_offset, cursor_point); - terminal_math::scan_visible( - &mut pane_state.terminal_math, - &terminal, - &view, - &grid_cells, - allow_inline_dollar, - visible_cursor, - foreground_color, - ) - } else { - Vec::new() - }; - let math_pixel_size = self.glyph_cache.font_size.as_px(); - let math_pixels_per_point = self.window.scale_factor as f32 * 96.0 / 72.27; - let prepared_math = terminal_math::prepare_overlays( - &mut pane_state.terminal_math, - &terminal_math_overlays, - &view, - math_pixel_size, - math_pixels_per_point, - ); - let math_coverage = - terminal_math::CoverageMask::build(&terminal_math_overlays, &prepared_math); - - // Add damage from the terminal, keeping a pane-local copy: the shared - // tracker gets flooded with a full-window mark every frame further - // down, so "did the grid actually change?" (hint invalidation) must - // be judged from the terminal's own report captured here. - let mut term_damage_full = false; - let mut term_damage_lines = Vec::new(); - match terminal.damage() { - TermDamage::Full => { - term_damage_full = true; - self.damage_tracker.frame().mark_fully_damaged(); - }, - TermDamage::Partial(damaged_lines) => { - for damage in damaged_lines { - self.damage_tracker.frame().damage_line(damage); - term_damage_lines.push(damage); - } - }, - } - terminal.reset_damage(); - - // Drop terminal as early as possible to free lock. - drop(terminal); - - // Invalidate highlighted hints if grid has changed. Only the pane - // that owns the hover may judge that: `highlighted_hint` is hit-tested - // against the focused pane, so a background pane's output (build log, - // `top`) must not tear down the foreground's highlight. - if force_focus != Some(false) { - self.validate_hint_highlights(display_offset, term_damage_full, &term_damage_lines); - } - - // OSC 1337 inline images: prune rows that scrolled out of history for - // good, then collect the ones visible in this pane's viewport for the - // single full-window draw pass in `present_frame`. - if !pane_state.inline_images.is_empty() { - let cell_h = view.cell_height(); - pane_state.inline_images.retain(|img| { - let rows = (img.height / cell_h).ceil().max(1.0) as usize; - img.abs_line + rows >= grid_scrolled_out - }); - let top_abs = (image_anchor - display_offset) as f32; - for img in &pane_state.inline_images { - let y = view.padding_y() + (img.abs_line as f32 - top_abs) * cell_h; - // Cull images entirely outside this pane's band. - if y + img.height <= view.padding_y() - cell_h - || y >= view.padding_y() + view.height() - { - continue; - } - self.nebula_frame_images.push(( - img.id, - img.rgba.clone(), - (img.px_w, img.px_h), - (view.padding_x(), y, img.width, img.height), - )); - } - } - - // Refresh the inline ghost-text suggestion. On Windows the input is read - // off the grid (screen truth, never desyncs); elsewhere the tracked - // `line_buf` is used. Only on the primary screen, never during vi/search - // overlays. - if alt_screen || vi_mode || search_state.regex().is_some() { - pane_state.suggestion.clear(); - pane_state.suggestion_key.clear(); - } else { - #[cfg(windows)] - { - // No prompt arrow before the cursor (or a mid-line edit) means we - // cannot trust a hint here — clear it rather than guess. - if !pane_state.line_buf.is_empty() - || line_override.as_ref().is_some_and(|s| !s.is_empty()) - { - nebula_debug_log(format!( - "grid_input cwd={:?} line_buf={:?} raw={:?} cursor=line:{} col:{} row={:?}", - pane_state.cwd, - pane_state.line_buf, - line_override, - cursor_point.line.0, - cursor_point.column.0, - row_preview - )); - } - match line_override { - Some(line) => { - pane_state.screen_line = line.clone(); - self.nebula_update_suggestion(pane_state, Some(line)); - }, - None => { - pane_state.screen_line.clear(); - pane_state.suggestion.clear(); - pane_state.suggestion_key.clear(); - }, - } - } - #[cfg(not(windows))] - self.nebula_update_suggestion(pane_state, None); - } - - // Add damage from nebula's UI elements overlapping terminal. - - // Nebula always redraws and presents the full window: the chrome - // (clock, ambient glow, gradient border) is painted every frame, and - // partial damage would leave terminal content (prompt, scrollback) - // stale after the window is occluded or sent to the background. - let _ = (self.visual_bell.intensity(), self.hint_state.active(), search_state.regex()); - self.damage_tracker.frame().mark_fully_damaged(); - self.damage_tracker.next_frame().mark_fully_damaged(); - - let vi_cursor_viewport_point = - vi_cursor_point.and_then(|cursor| term::point_to_viewport(display_offset, cursor)); - self.damage_tracker.damage_vi_cursor(vi_cursor_viewport_point); - self.damage_tracker.damage_selection(selection_range, display_offset); - - // Make sure this window's OpenGL context is active. The caller is - // expected to have already activated it; calling again is cheap and - // keeps `draw_pane` safe to invoke standalone. - self.make_current(); - - // Only the first pane of a frame clears the whole window; subsequent - // panes paint on top of the shared, already-cleared backdrop. - if clear_first { - // Layer model: the window clears to the opaque shell color (the - // chrome backdrop), then the terminal is painted as a rounded - // `term_bg` card floating on it. Default-background cells draw no - // background of their own (bg_alpha == 0), so they show the card. - nebula_debug_log(format!( - "render_clear path=pane window={}x{} alpha={:.3}", - self.size_info.width(), - self.size_info.height(), - self.nebula_window_opacity, - )); - self.draw_window_backdrop(background_color); - } - - // 分屏渲染时每个 pane 都有独立的 viewport/projection;否则右侧内容会沿用上一帧 - // 或左侧 pane 的坐标系,最终叠到左边而不是显示在右边。 - self.renderer.resize(&size_info); - - let mut lines = RenderLines::new(); - - // Optimize loop hint comparator. - let has_highlighted_hint = - self.highlighted_hint.is_some() || self.vi_highlighted_hint.is_some(); - - // Draw grid. - let mut powerline_icons = Vec::new(); - { - let _sampler = self.meter.sampler(); - - // Ensure macOS hasn't reset our viewport. - #[cfg(target_os = "macos")] - self.renderer.set_viewport(&size_info); - - let glyph_cache = &mut self.glyph_cache; - let highlighted_hint = &self.highlighted_hint; - let vi_highlighted_hint = &self.vi_highlighted_hint; - let damage_tracker = &mut self.damage_tracker; - let mut clickable_index = 0usize; - - let cells = grid_cells.into_iter().filter_map(|mut cell| { - // 会被公式覆盖的源码字形直接不画:公式落在真实窗口背景上, - // 不再需要事后用不透明色块把源文本盖掉。 - if !math_coverage.is_empty() && math_coverage.covers(cell.point) { - return None; - } - match cell.character { - NEBULA_FOLDER_ICON_MARKER => { - powerline_icons.push(NebulaPowerlineIcon { - kind: NebulaPowerlineIconKind::Folder, - point: cell.point, - }); - cell.character = ' '; - }, - NEBULA_GIT_BRANCH_ICON_MARKER => { - powerline_icons.push(NebulaPowerlineIcon { - kind: NebulaPowerlineIconKind::GitBranch, - point: cell.point, - }); - cell.character = ' '; - }, - _ => (), - } - - let point = term::viewport_to_point(display_offset, cell.point); - while clickable_matches - .get(clickable_index) - .is_some_and(|bounds| bounds.end() < &point) - { - clickable_index += 1; - } - let is_clickable = clickable_matches - .get(clickable_index) - .is_some_and(|bounds| bounds.contains(&point)); - if is_clickable { - // 点击目标的虚线直接继承每个 cell 的文字色;不能统一成主题色, - // 否则 ls 的目录/可执行文件颜色语义会被下划线悄悄抹平。 - cell.flags.remove(Flags::ALL_UNDERLINES); - cell.flags.insert(Flags::DASHED_UNDERLINE); - cell.underline = cell.fg; - } - - // Underline hints hovered by mouse or vi mode cursor. Persistent - // clickable ranges stay dashed; other hint states retain the - // stronger solid underline used by keyboard/vi highlighting. - if has_highlighted_hint { - let hyperlink = cell.extra.as_ref().and_then(|extra| extra.hyperlink.as_ref()); - - let should_highlight = |hint: &Option| { - hint.as_ref().is_some_and(|hint| hint.should_highlight(point, hyperlink)) - }; - if should_highlight(highlighted_hint) || should_highlight(vi_highlighted_hint) { - damage_tracker.frame().damage_point(cell.point); - if !is_clickable { - cell.flags.insert(Flags::UNDERLINE); - } - } - } - - // Update underline/strikeout. - lines.update(&cell); - - Some(cell) - }); - self.renderer.draw_cells(&size_info, glyph_cache, cells); - } - - let mut rects = lines.rects(&metrics, &size_info); - - if alt_screen { - if let Some(pad_bg) = grid_pad_bg { - let (_, card_y, _, card_h) = self.terminal_card_rect(); - let x = size_info.padding_x(); - let w = size_info.width() - size_info.padding_x() - size_info.padding_right(); - // 备用屏幕会给整张网格着色。补齐背景时只能填当前 Pane 的边缘; - // 下方 Pane 若从整张卡片顶部开始填,会在最后绘制时盖住上方 Pane。 - for (y, height) in - alt_screen_vertical_padding_bands(&self.size_info, &size_info, card_y, card_h) - .into_iter() - .flatten() - { - rects.push(RenderRect::new(x, y, w, height, pad_bg, 1.0)); - } - } - } - - if let Some(vi_cursor_point) = vi_cursor_point { - // Indicate vi mode by showing the cursor's position in the top right corner. - let line = (-vi_cursor_point.line.0 + size_info.bottommost_line().0) as usize; - let obstructed_column = Some(vi_cursor_point) - .filter(|point| point.line == -(display_offset as i32)) - .map(|point| point.column); - self.draw_line_indicator(config, total_lines, obstructed_column, line); - } else if search_state.regex().is_some() { - // Show current display offset in vi-less search to indicate match position. - self.draw_line_indicator(config, total_lines, None, display_offset); - }; - - // Draw cursor. - rects.extend(cursor.rects(&size_info, config.cursor.thickness())); - - // Push visual bell after url/underline/strikeout rects. - let visual_bell_intensity = self.visual_bell.intensity(); - if visual_bell_intensity != 0. { - let visual_bell_rect = RenderRect::new( - 0., - 0., - size_info.width(), - size_info.height(), - config.bell.color, - visual_bell_intensity as f32, - ); - rects.push(visual_bell_rect); - } - - // Handle IME positioning and search bar rendering. - let ime_position = match search_state.regex() { - Some(regex) => { - let search_label = match search_state.direction() { - Direction::Right => FORWARD_SEARCH_LABEL, - Direction::Left => BACKWARD_SEARCH_LABEL, - }; - - let search_text = Self::format_search(regex, search_label, size_info.columns()); - - // Render the search bar. - self.draw_search(config, &search_text); - - // Draw search bar cursor. - let line = size_info.screen_lines(); - let column = Column(search_text.chars().count() - 1); - - // Add cursor to search bar if IME is not active. - if self.ime.preedit().is_none() { - let fg = config.colors.footer_bar_foreground(); - let shape = CursorShape::Underline; - let cursor_width = NonZeroU32::new(1).unwrap(); - let cursor = - RenderableCursor::new(Point::new(line, column), shape, fg, cursor_width); - rects.extend(cursor.rects(&size_info, config.cursor.thickness())); - } - - Some(Point::new(line, column)) - }, - None => { - let num_lines = size_info.screen_lines(); - match vi_cursor_viewport_point { - None => term::point_to_viewport(display_offset, cursor_point) - .filter(|point| point.line < num_lines), - point => point, - } - }, - }; - - // Handle IME. - if self.ime.is_enabled() { - if let Some(point) = ime_position { - let (fg, bg) = if search_state.regex().is_some() { - (config.colors.footer_bar_foreground(), config.colors.footer_bar_background()) - } else { - (foreground_color, background_color) - }; - - self.draw_ime_preview(point, fg, bg, &mut rects, config); - } - } - - if let Some(message) = message_buffer.message() { - let search_offset = usize::from(search_state.regex().is_some()); - let text = message.text(&size_info); - - // Create a new rectangle for the background. - let start_line = size_info.screen_lines() + search_offset; - let bar = message_bar::message_bar_rect(&size_info, search_offset != 0); - - let bg = match message.ty() { - MessageType::Error => config.colors.normal.red, - MessageType::Warning => config.colors.normal.yellow, - }; - - let x = bar.x as i32; - let y = bar.y as i32; - let width = bar.width as i32; - let height = bar.height as i32; - let message_bar_rect = RenderRect::new(bar.x, bar.y, bar.width, bar.height, bg, 1.); - - // Push message_bar in the end, so it'll be above all other content. - rects.push(message_bar_rect); - - // Always damage message bar, since it could have messages of the same size in it. - self.damage_tracker.frame().add_viewport_rect(&size_info, x, y, width, height); - - // Draw rectangles. - self.renderer.draw_rects(&size_info, &metrics, rects); - - // Relay messages to the user. - let glyph_cache = &mut self.glyph_cache; - let fg = config.colors.primary.background; - for (i, message_text) in text.iter().enumerate() { - let point = Point::new(start_line + i, Column(0)); - self.renderer.draw_string( - point, - fg, - bg, - message_text.chars(), - &size_info, - glyph_cache, - ); - } - } else { - // Draw rectangles. - self.renderer.draw_rects(&size_info, &metrics, rects); - } - - terminal_math::draw_overlays( - &mut self.renderer, - &mut self.glyph_cache, - &mut pane_state.terminal_math, - &terminal_math_overlays, - &prepared_math, - &size_info, - math_pixels_per_point, - ); - - self.draw_powerline_icons(&powerline_icons, size_info); - // `draw_powerline_icons` uses the full-window UI renderer and restores - // a full-window viewport; bind the pane projection again before drawing - // the inline ghost suggestion. - self.renderer.resize(&size_info); - - // Draw inline ghost-text autosuggestion directly after the cursor, - // once everything else for the cell row is on screen. The color is - // the theme's faintest ink (not a fixed gray), so on light themes it - // stays clearly weaker than the near-black real input instead of - // colliding with it. - if !pane_state.suggestion.is_empty() && self.ime.preedit().is_none() { - if let Some(point) = term::point_to_viewport(display_offset, cursor_point) - .filter(|p| p.line < size_info.screen_lines() && p.column.0 < size_info.columns()) - { - let avail = size_info.columns() - point.column.0; - let ghost: String = pane_state.suggestion.chars().take(avail).collect(); - let ghost_fg = self.nebula_theme.skin().ink_faint; - let glyph_cache = &mut self.glyph_cache; - self.renderer.draw_string( - point, - ghost_fg, - background_color, - ghost.chars(), - &size_info, - glyph_cache, - ); - } - } - - self.draw_render_timer(config); - - // Draw hyperlink uri preview. - if has_highlighted_hint { - let cursor_point = vi_cursor_point.or(Some(cursor_point)); - self.draw_hyperlink_preview(config, cursor_point, display_offset); - } - - // Overlay scrollbar on the right edge while scrolled into history. - self.draw_scrollbar(&size_info, display_offset, total_lines); - } - - /// Draw the screen for a single, full-window terminal. - /// - /// A reference to the Term whose state is being drawn must be provided. - /// This call may block if vsync is enabled. - pub fn draw( - &mut self, - terminal: MutexGuard<'_, Term>, - scheduler: &mut Scheduler, - message_buffer: &MessageBuffer, - config: &UiConfig, - search_state: &mut SearchState, - pane_state: &mut NebulaPaneState, - ) { - let view = self.size_info; - self.make_current(); - self.reload_nebula_settings_if_changed(config); - // `None` focus → keep the terminal's real window-focus state. - self.draw_pane( - terminal, - message_buffer, - config, - search_state, - pane_state, - view, - None, - true, - ); - self.present_frame(scheduler); - } - - /// Begin a multi-pane frame: bind the GL context and refresh themed - /// settings before the per-pane draws. - pub fn begin_pane_frame(&mut self, config: &UiConfig) { - self.reload_nebula_settings_if_changed(config); - self.make_current(); - } - - /// Draw a document-viewer tab's frame: the shell backdrop and terminal - /// card exactly like a pane frame (same layer model), then the document - /// instead of a grid, then the normal chrome via `present_frame`. - pub fn draw_doc_frame( - &mut self, - doc: &mut markdown_view::DocView, - view: SizeInfo, - scheduler: &mut Scheduler, - ) { - self.renderer.set_window_height(self.size_info.height()); - - nebula_debug_log(format!( - "render_clear path=document window={}x{} alpha={:.3}", - self.size_info.width(), - self.size_info.height(), - self.nebula_window_opacity, - )); - let card_bg = self.nebula_background.unwrap_or(self.colors[NamedColor::Background]); - self.draw_window_backdrop(card_bg); - let (cx, cy, cw, ch) = self.terminal_card_rect(); - let scale = self.window.scale_factor as f32; - - // The document reads inside the card, inset off its rounded corners. - let area = ( - (cx + 4.0 * scale).max(view.padding_x()), - (cy + 4.0 * scale).max(view.padding_y()), - (cw - 8.0 * scale).min(view.width()), - (ch - 8.0 * scale).min(view.height()), - ); - let skin = self.nebula_theme.skin(); - let size = self.size_info; - markdown_view::draw( - doc, - &mut self.renderer, - &mut self.glyph_cache, - &size, - &skin, - area, - scale, - ); - - self.present_frame(scheduler); - } - - /// Draw the Settings special tab. Its controls are emitted by the chrome - /// pass so they retain the same hit geometry and icon texture pipeline as - /// the rest of Nebula, but the base is a normal tab content card. - pub fn draw_settings_frame(&mut self, scheduler: &mut Scheduler) { - self.renderer.set_window_height(self.size_info.height()); - - nebula_debug_log(format!( - "render_clear path=settings window={}x{} alpha={:.3}", - self.size_info.width(), - self.size_info.height(), - self.nebula_window_opacity, - )); - let card_bg = self.nebula_background.unwrap_or(self.colors[NamedColor::Background]); - self.draw_window_backdrop(card_bg); - - self.present_frame(scheduler); - } - - /// Draw one pane of a multi-pane layout into `view`. `clear_first` clears - /// the whole window before the first pane; later panes paint on top. - #[allow(clippy::too_many_arguments)] - pub fn draw_pane_view( - &mut self, - terminal: MutexGuard<'_, Term>, - message_buffer: &MessageBuffer, - config: &UiConfig, - search_state: &mut SearchState, - pane_state: &mut NebulaPaneState, - view: SizeInfo, - focused: bool, - clear_first: bool, - ) { - self.draw_pane( - terminal, - message_buffer, - config, - search_state, - pane_state, - view, - Some(focused), - clear_first, - ); - } - - /// Overlay split chrome over the drawn panes: dim every unfocused pane and - /// paint divider hairlines. Rectangles are screen-space `(x, y, w, h)` with - /// a top-left origin. Focus reads as a brightness difference - /// `unfocused-split-opacity`) rather than an outline. - pub fn draw_split_overlays( - &mut self, - dim_rects: &[(f32, f32, f32, f32)], - divider_rects: &[(f32, f32, f32, f32)], - ) { - let palette = self.nebula_theme.palette(); - let veil = Rgba::new(0, 0, 0, 0).with_alpha(NEBULA_UNFOCUSED_SPLIT_DIM); - let line_color = palette.edge_l.with_alpha(0.35); - - let mut quads: Vec = Vec::with_capacity(dim_rects.len() + divider_rects.len() + 1); - for &(x, y, w, h) in dim_rects { - if w > 0.0 && h > 0.0 { - quads.push(UiQuad::solid(x, y, w, h, 0.0, veil)); - } - } - for &(x, y, w, h) in divider_rects { - if w > 0.0 && h > 0.0 { - quads.push(UiQuad::solid(x, y, w, h, 0.0, line_color)); - } - } - - // Freshly split pane slides in: a bg-coloured cover anchored at the - // pane's far edge shrinks away over ~160ms (ease-out), so the new pane - // wipes in from the divider instead of popping. Timestamp-derived, no - // per-frame allocation (same discipline as the quick-terminal slide). - if let Some(mut reveal) = self.nebula_split_reveal { - reveal.motion.step(self.nebula_ui_anims.frame()); - let e = reveal.motion.value(); - if !reveal.motion.is_active() { - self.nebula_split_reveal = None; - } else { - self.nebula_split_reveal = Some(reveal); - let (x, y, w, h) = reveal.rect; - let bg = self.nebula_background.unwrap_or(Rgb::new(15, 17, 26)); - let cover = Rgba::new(bg.r, bg.g, bg.b, 255); - let (cx, cy, cw, chh) = match reveal.direction { - SplitDirection::LeftRight => (x + w * e, y, w * (1.0 - e), h), - SplitDirection::TopBottom => (x, y + h * e, w, h * (1.0 - e)), - }; - if cw > 0.5 && chh > 0.5 { - quads.push(UiQuad::solid(cx, cy, cw, chh, 0.0, cover)); - } - self.window.request_redraw(); - } - } - - self.renderer.draw_ui(&self.size_info, &quads); - } - - /// Finish a multi-pane frame: draw window chrome and present. - pub fn finish_pane_frame(&mut self, scheduler: &mut Scheduler) { - self.present_frame(scheduler); - } - - /// Paint the divider between two split panes and dim the unfocused one. - /// (Removed: superseded by `draw_split_overlays` + the layout tree in - /// `window_context/split.rs`.) - #[cfg(any())] - fn _removed_split_helpers() {} - - /// Overlay scrollbar on the right edge of a pane, shown only while scrolled - /// up into the scrollback (auto-hides at the bottom). - /// overlay-style `scrollbar`: a thin, semi-transparent thumb floating over - /// the grid's right edge, sized to the visible fraction of total content. - fn draw_scrollbar(&mut self, view: &SizeInfo, display_offset: usize, total_lines: usize) { - let Some(geo) = self.scrollbar_geometry(view, display_offset, total_lines) else { return }; - let (thumb_x, thumb_y, thumb_w, thumb_h) = geo; - - // Skinned so it reads as chrome on both light and dark themes; a bit - // more opaque while grabbed so the drag has visible feedback. - let alpha = if self.nebula_scrollbar_drag.is_some() { 0.62 } else { 0.40 }; - let thumb_color = self.nebula_theme.skin().scrollbar_thumb.with_alpha(alpha); - let quad = UiQuad::solid(thumb_x, thumb_y, thumb_w, thumb_h, thumb_w * 0.5, thumb_color); - self.renderer.draw_ui(&self.size_info, &[quad]); - } - - /// Scrollbar thumb rect `(x, y, w, h)` for a pane `view` — the single - /// source of truth shared by rendering and input hit-testing. `None` while - /// the bar is hidden (at the bottom, or no history). - fn scrollbar_geometry( - &self, - view: &SizeInfo, - display_offset: usize, - total_lines: usize, - ) -> Option<(f32, f32, f32, f32)> { - let screen_lines = view.screen_lines(); - // Nothing to show when sitting at the bottom or when there's no history. - if display_offset == 0 || total_lines <= screen_lines { - return None; - } - - let scale = self.window.scale_factor as f32; - let total = total_lines as f32; - let track_top = view.padding_y(); - let track_h = screen_lines as f32 * view.cell_height(); - if track_h <= 1.0 { - return None; - } - - // Thumb height = visible fraction of total content, with a sane minimum. - let min_thumb = (24.0 * scale).min(track_h); - let thumb_h = (track_h * (screen_lines as f32 / total)).clamp(min_thumb, track_h); - - // Lines of history above the current viewport top (0 = top, history = bottom). - let history = total_lines - screen_lines; - let above = (history - display_offset) as f32; - let max_y = (track_h - thumb_h).max(0.0); - let thumb_y = track_top + (track_h * (above / total)).clamp(0.0, max_y); - - // Float over the grid's right edge (overlay style, like macOS scrollbars). - let thumb_w = (4.0 * scale).max(2.0); - let grid_right = view.padding_x() + view.columns() as f32 * view.cell_width(); - let thumb_x = grid_right - thumb_w; - - Some((thumb_x, thumb_y, thumb_w, thumb_h)) - } - - /// Hit-test a press against the scrollbar. The 4px thumb gets a widened - /// grab zone; a hit returns the pointer's y-offset inside the thumb so the - /// drag doesn't jump. A press on the track (above/below the thumb) recenters - /// the thumb there (`grab = thumb_h / 2`). - pub fn scrollbar_grab( - &self, - view: &SizeInfo, - display_offset: usize, - total_lines: usize, - x: f32, - y: f32, - ) -> Option { - let (thumb_x, thumb_y, thumb_w, thumb_h) = - self.scrollbar_geometry(view, display_offset, total_lines)?; - let scale = self.window.scale_factor as f32; - let slop = 8.0 * scale; - // Horizontal band around the thumb column. - if x < thumb_x - slop || x > thumb_x + thumb_w + slop { - return None; - } - // Vertical: inside the track at all? - let track_top = view.padding_y(); - let track_h = view.screen_lines() as f32 * view.cell_height(); - if y < track_top || y > track_top + track_h { - return None; - } - if y >= thumb_y && y <= thumb_y + thumb_h { - Some(y - thumb_y) // grab inside the thumb - } else { - Some(thumb_h / 2.0) // track press: jump so the thumb centers on it - } - } - - /// Map a dragged pointer `y` back to a scrollback `display_offset`, - /// inverting the thumb-position math (`grab` = offset captured at press). - pub fn scrollbar_target_offset( - &self, - view: &SizeInfo, - total_lines: usize, - y: f32, - grab: f32, - ) -> usize { - let screen_lines = view.screen_lines(); - let history = total_lines.saturating_sub(screen_lines); - if history == 0 { - return 0; - } - let track_top = view.padding_y(); - let track_h = (screen_lines as f32 * view.cell_height()).max(1.0); - let above = ((y - grab - track_top) / track_h * total_lines as f32).round(); - let above = above.clamp(0.0, history as f32) as usize; - history - above - } - - /// Centered modal for confirmations and mandatory setup gates. - fn draw_confirm_modal(&mut self) { - let Some(confirm) = self.nebula_confirm.clone() else { - self.nebula_confirm_buttons = None; - return; - }; - let size = self.ui_size_info(); - let scale = self.window.scale_factor as f32; - let s = |v: f32| v * scale; - let cell_w = size.cell_width(); - let cell_h = size.cell_height(); - - // Same tokens as the settings shell (design discipline: one flat - // surface, hairline stroke, semantic color only on the primary - // action). Danger red for destructive closes, theme accent for paste. - // All from the theme skin, so light themes get a pale card + dark ink. - let sk = self.nebula_theme.skin(); - let accent = Rgba::new(sk.accent.r, sk.accent.g, sk.accent.b, 255); - let txt = sk.ink; - let dim = sk.ink_dim; - - let (title, body, danger) = match &confirm { - NebulaConfirm::EnableBackgroundImageCoverChrome => ( - "让背景图覆盖窗口控件区域?".to_owned(), - "背景图会延伸到标题栏、窗口按钮、Tab 与 SSH 侧栏下方,低对比度图片可能影响操作可见性;界面仍会保留最低不透明度保护。".to_owned(), - false, - ), - NebulaConfirm::InstallRequiredFont { .. } => ( - "建议安装终端字体".to_owned(), - "未检测到 Maple Mono Nerd Font;缺少图标时可安装后重启 Nebula。".to_owned(), - false, - ), - NebulaConfirm::ClosePane { process, .. } => ( - "关闭此分栏?".to_owned(), - format!("{process} 仍在运行,关闭会中止它。"), - true, - ), - NebulaConfirm::CloseTab { process, .. } => ( - "关闭此标签页?".to_owned(), - format!("{process} 仍在运行,关闭会中止它。"), - true, - ), - NebulaConfirm::CloseWindow { process } => ( - "关闭整个窗口?".to_owned(), - format!("{process} 仍在运行,关闭会中止它。"), - true, - ), - NebulaConfirm::Paste { lines, .. } => ( - format!("粘贴 {lines} 行文本?"), - "多行粘贴会被 shell 逐行执行,请确认来源可信。".to_owned(), - false, - ), - NebulaConfirm::DeleteSsh { host, from_config } => { - let host = truncate_tab_label(host, 28); - if *from_config { - ( - format!("隐藏 SSH 主机 {host}?"), - "只从 Nebula 隐藏;~/.ssh/config 不会修改,保存的密码将在撤销期后清除。" - .to_owned(), - true, - ) - } else { - ( - format!("删除 SSH 主机 {host}?"), - "会从主机列表移除,保存的 Windows 密码将在撤销期后清除。".to_owned(), - true, - ) - } - }, - NebulaConfirm::DeleteSftp { entry } => ( - format!("删除远端项目 {}?", truncate_tab_label(&entry.name, 28)), - if entry.kind == crate::ssh_sftp::SftpEntryKind::Directory { - "文件夹及其全部远端内容会被递归删除,此操作无法撤销。".to_owned() - } else { - "远端文件会被永久删除,此操作无法撤销。".to_owned() - }, - true, - ), - NebulaConfirm::DeleteFileTreePath { path, is_dir } => { - let name = path - .file_name() - .map(|n| n.to_string_lossy().into_owned()) - .unwrap_or_else(|| path.display().to_string()); - ( - format!("删除 {}?", truncate_tab_label(&name, 28)), - if *is_dir { - "文件夹及其全部内容会移入回收站。".to_owned() - } else { - "文件会移入回收站。".to_owned() - }, - true, - ) - }, - }; - - let text_w = |t: &str| -> f32 { - let cols: usize = t.chars().map(|c| c.width().unwrap_or(1)).sum(); - cols as f32 * cell_w - }; - - // Buttons: right-aligned row, primary rightmost (Windows order). 文案 - // 统一"是 / 否"(2026-07-23 用户裁定)。 - // - // 2026-07-27 用户反馈:Enter / Esc 一直生效,但按钮上只有"是""否" - // 两个字,键位从没画出来——同文件的 SSH 撤销条却老实写着 Ctrl+Z, - // 标准不一致。`can_dismiss()` 恒真,故两个键位都名副其实。 - // - // 键位画成键帽(描边小方框)而不是裸文字:v0.5/v0.6 起 welcome 页的 - // 快捷键就是灰底药丸 + 亮墨的键帽(`welcome.rs` 的 `kbd`),用户记 - // 的"白色的框"就是它。那边是终端文本(ANSI + powerline 圆头字形), - // 这里走 draw_ui + chrome text,没法照搬,所以用本文件既有的惯用法 - // 手画:外圈描边 quad + 内层填充 quad,只露 1px 圆环。描边取按钮 - // 自己的墨色,深色主题下自然读作白框,浅色主题下是深框。 - let language = self.ui_language(); - let primary_label = language.pick("是", "Yes"); - let cancel_label = language.pick("否", "No"); - let primary_key = "Enter"; - let cancel_key = "Esc"; - let btn_h = s(34.0); - let btn_pad = s(18.0); - let btn_min_w = s(88.0); - // Keycap: text plus breathing room, and a hair taller than the glyph so - // the ring never clips ascenders. Gap sits between label and cap. - let cap_pad = s(6.0); - let cap_h = cell_h + s(6.0); - let key_gap = s(8.0); - let cap_w = |key: &str| text_w(key) + 2.0 * cap_pad; - let btn_w = |label: &str, key: &str| -> f32 { - (text_w(label) + key_gap + cap_w(key) + 2.0 * btn_pad).max(btn_min_w) - }; - let primary_w = btn_w(primary_label, primary_key); - let cancel_w = btn_w(cancel_label, cancel_key); - - // Card sized to title/buttons, clamped into the window, and capped at - // 520 logical px: a long body WRAPS instead of stretching the card - // into a full-width banner (2026-07-23 用户反馈"警告框太宽"). - let pad = s(26.0); - let head_w = text_w(&title).max(primary_w + s(12.0) + cancel_w); - let box_w = (head_w + 2.0 * pad).max(s(380.0)).min(s(520.0)).min(size.width() - s(32.0)); - let body_cols = (((box_w - 2.0 * pad) / cell_w).floor() as usize).max(8); - let body_lines = wrap_display_cols(&body, body_cols); - let line_h = cell_h + s(6.0); - let body_h = body_lines.len() as f32 * line_h - s(6.0); - let box_h = pad + cell_h + s(10.0) + body_h + s(24.0) + btn_h + pad * 0.75; - let bx = ((size.width() - box_w) * 0.5).max(s(16.0)); - let by = ((size.height() - box_h) * 0.5).max(s(16.0)); - - // Veil dims the whole window so the modal reads as, well, modal - // (white fog on light themes, black dim on dark — sk.veil). - let veil = UiQuad::solid(0.0, 0.0, size.width(), size.height(), 0.0, sk.veil); - // Hairline edge + flat themed card (no glow, no gradient). - let edge = UiQuad::solid( - bx - s(1.0), - by - s(1.0), - box_w + s(2.0), - box_h + s(2.0), - s(13.0), - sk.hairline, - ); - let card = UiQuad::solid(bx, by, box_w, box_h, s(12.0), sk.panel); - - // Button geometry (kept for the mouse hit-test). - let btn_y = by + box_h - pad * 0.75 - btn_h; - let primary_x = bx + box_w - pad + s(2.0) - primary_w; - let cancel_x = primary_x - s(12.0) - cancel_w; - let primary_rect = (primary_x, btn_y, primary_w, btn_h); - let cancel_rect = (cancel_x, btn_y, cancel_w, btn_h); - self.nebula_confirm_buttons = Some((primary_rect, cancel_rect)); - - let primary_fill = if danger { sk.danger } else { accent }; - // Ink first: the keycap ring is derived from the ink it wraps, so both - // buttons' text colors have to exist before the quads are built. - let on_primary = if danger { Rgb::new(255, 244, 246) } else { sk.ink_on_accent }; - // Keycap geometry, shared by the ring quads and the glyph runs below. - let cap_y = btn_y + (btn_h - cap_h) / 2.0; - let cancel_cap_x = cancel_x + btn_pad + text_w(cancel_label) + key_gap; - let primary_cap_x = primary_x + btn_pad + text_w(primary_label) + key_gap; - - let mut quads = vec![veil, edge, card]; - // Cancel: quiet ghost button (hairline + faint fill). - quads.push(UiQuad::solid( - cancel_x - s(1.0), - btn_y - s(1.0), - cancel_w + s(2.0), - btn_h + s(2.0), - s(9.0), - sk.hairline, - )); - quads.push(UiQuad::solid(cancel_x, btn_y, cancel_w, btn_h, s(8.0), sk.panel)); - quads.push(UiQuad::solid(cancel_x, btn_y, cancel_w, btn_h, s(8.0), sk.surface)); - // Primary: the single loud element on the card. - quads.push(UiQuad::solid(primary_x, btn_y, primary_w, btn_h, s(8.0), primary_fill)); - - // Keycaps: 图8 键帽规范(2026-07-29)——与 Ctrl+K/设置页共用 - // `keycap::push_chip` 配方(hairline 圈 + panel/surface 叠底), - // 不再按按钮墨色自造描边圈。chip 底是中性 panel,所以放在 accent - // 主按钮上天然成为浅色小块(图3 mockup 的 Enter 键帽即此形态)。 - // Returns its quads instead of pushing: a closure capturing `quads` - // mutably would lock it for the rest of the batch. - let cap = |x: f32, key: &str| -> Vec { - let mut out = Vec::new(); - keycap::push_chip(&mut out, &sk, x, cap_y, cap_w(key), cap_h, scale); - out - }; - quads.extend(cap(cancel_cap_x, cancel_key)); - quads.extend(cap(primary_cap_x, primary_key)); - self.renderer.draw_ui(&size, &quads); - - // Text: free-pixel chrome text (no opaque cell backgrounds), left - // aligned like a native Windows dialog. - let glyph_cache = &mut self.glyph_cache; - let tx = bx + pad; - self.renderer.draw_chrome_text(&size, tx, by + pad, txt, &title, glyph_cache); - let btn_text_y = btn_y + (btn_h - cell_h) / 2.0; - // Body wraps to the card's inner width; lines carry a small leading. - let mut line_y = by + pad + cell_h + s(10.0); - for line in &body_lines { - self.renderer.draw_chrome_text(&size, tx, line_y, dim, line, glyph_cache); - line_y += line_h; - } - self.renderer.draw_chrome_text( - &size, - cancel_x + btn_pad, - btn_text_y, - txt, - cancel_label, - glyph_cache, - ); - // Key text is centered in its cap. The cap shares the button's text - // centerline by construction, so `btn_text_y` needs no adjustment. - // Ink stays full strength: the ring already marks this run as a key, - // dimming it too would push it under the contrast floor. - self.renderer.draw_chrome_text( - &size, - cancel_cap_x + cap_pad, - btn_text_y, - txt, - cancel_key, - glyph_cache, - ); - // Danger keeps pale ink (red is dark in both modes); the accent - // button contrast flips with the theme. - self.renderer.draw_chrome_text( - &size, - primary_x + btn_pad, - btn_text_y, - on_primary, - primary_label, - glyph_cache, - ); - // 键帽文字统一走 chip 自己的墨(sk.ink):chip 底是中性 panel, - // 不随按钮填充翻转,主按钮上的键名也保持可读。 - self.renderer.draw_chrome_text( - &size, - primary_cap_x + cap_pad, - btn_text_y, - txt, - primary_key, - glyph_cache, - ); - } - - /// Bottom-center reversible-action bar for SSH deletion. Its action rect is - /// published to input after layout, keeping hover/click geometry identical - /// to the pixels on screen. - /// 助手建议条(spec 001):底部居中浮条,SSH 撤销条同款组件语言(中性 - /// 壳、accent/danger 只在 ✦/⚠ 一处,渐变预算不动)。Pending 一行"正在 - /// 分析",Ready 是图标 + 命令 + 暗色解释 + 键位提示;一律只贴不执行。 - /// 撤销条在场时让位——它 8 秒自清,之后建议条自然浮现。 - fn draw_ai_fix_bar(&mut self) { - use crate::ai_assistant::AiFixState; - if self.nebula_ssh_delete_undo.is_some() { - return; - } - let Some(state) = self.nebula_ai_fix_bar.clone() else { return }; - - let size = self.ui_size_info(); - let scale = self.window.scale_factor as f32; - let s = |value: f32| value * scale; - let cell_w = size.cell_width(); - let cell_h = size.cell_height(); - let sk = self.nebula_theme.skin(); - let language = self.ui_language(); - let text_cols = - |text: &str| -> usize { text.chars().map(|ch| ch.width().unwrap_or(1).max(1)).sum() }; - - let accent = Rgba::new(sk.accent.r, sk.accent.g, sk.accent.b, 255); - let (icon, icon_color, command, explain, hint) = match &state { - AiFixState::Pending { .. } => ( - "✦", - accent, - language.pick("正在分析失败原因…", "Analyzing failure…").to_owned(), - String::new(), - String::new(), - ), - AiFixState::Ready { fix, .. } => ( - if fix.danger { "⚠" } else { "✦" }, - if fix.danger { sk.danger } else { accent }, - fix.command.clone(), - fix.explain.clone(), - language.pick("Ctrl+. 贴入 · Esc 关闭", "Ctrl+. paste · Esc dismiss").to_owned(), - ), - }; - - // Budget: icon + command are non-negotiable; the explain is the first - // thing dropped, then the command itself is HEAD-truncated (unlike - // paths, a command's identity lives at its start). - let pad = s(14.0); - let gap = s(10.0); - let max_w = size.width() - s(24.0); - let fixed = pad * 2.0 + cell_w * 2.0 + gap + text_cols(&hint) as f32 * cell_w; - let cmd_budget = (((max_w - fixed) / cell_w) as usize).max(12); - let command = truncate_tab_label(&command, cmd_budget.min(96)); - let explain_budget = - cmd_budget.saturating_sub(text_cols(&command)).saturating_sub(3).min(60); - let explain = if text_cols(&explain) + 8 > explain_budget { - String::new() - } else { - explain - }; - - let mut content_cols = 2 + text_cols(&command); - if !explain.is_empty() { - content_cols += 3 + text_cols(&explain); - } - if !hint.is_empty() { - content_cols += 2 + text_cols(&hint); - } - let bar_h = s(44.0).max(cell_h + s(12.0)); - let bar_w = (pad * 2.0 + content_cols as f32 * cell_w).max(s(320.0)).min(max_w); - let bar_x = (size.width() - bar_w) * 0.5; - let bar_y = size.height() - bar_h - s(18.0); - - let quads = vec![ - UiQuad::glow( - bar_x - s(10.0), - bar_y - s(8.0), - bar_w + s(20.0), - bar_h + s(16.0), - Rgba::new(0, 0, 0, 72), - ), - UiQuad::solid( - bar_x - s(1.0), - bar_y - s(1.0), - bar_w + s(2.0), - bar_h + s(2.0), - s(11.0), - sk.hairline, - ), - UiQuad::solid(bar_x, bar_y, bar_w, bar_h, s(10.0), sk.panel), - ]; - self.renderer.draw_ui(&size, &quads); - - let text_y = bar_y + (bar_h - cell_h) * 0.5; - let mut x = bar_x + pad; - let gc = &mut self.glyph_cache; - self.renderer.draw_chrome_text( - &size, - x, - text_y, - Rgb::new(icon_color.r, icon_color.g, icon_color.b), - icon, - gc, - ); - x += cell_w * 2.0; - self.renderer.draw_chrome_text(&size, x, text_y, sk.ink_strong, &command, gc); - x += text_cols(&command) as f32 * cell_w; - if !explain.is_empty() { - self.renderer.draw_chrome_text( - &size, - x + cell_w, - text_y, - sk.ink_dim, - &format!("— {explain}"), - gc, - ); - x += (3 + text_cols(&explain)) as f32 * cell_w; - } - if !hint.is_empty() { - let hint_x = (bar_x + bar_w - pad - text_cols(&hint) as f32 * cell_w).max(x + gap); - self.renderer.draw_chrome_text(&size, hint_x, text_y, sk.ink_dim, &hint, gc); - } - } - - fn draw_ssh_delete_undo(&mut self) { - let Some(undo) = self.nebula_ssh_delete_undo.as_ref() else { - self.nebula_ssh_delete_undo_rect = None; - self.nebula_ssh_delete_undo_hover = false; - return; - }; - if undo.started_at.elapsed() >= SSH_DELETE_UNDO_DURATION { - self.expire_ssh_delete_undo(); - return; - } - - let size = self.ui_size_info(); - let scale = self.window.scale_factor as f32; - let s = |value: f32| value * scale; - let cell_w = size.cell_width(); - let cell_h = size.cell_height(); - let sk = self.nebula_theme.skin(); - - let fixed_cols = 20usize; - let host_budget = (((size.width() - s(300.0)).max(cell_w * 8.0) / cell_w) as usize) - .saturating_sub(fixed_cols) - .max(8); - let host = truncate_tab_label(&undo.host, host_budget.min(28)); - let message = if undo.from_config { - format!("已隐藏 {host}(SSH config 未修改)") - } else { - format!("已移除 {host}") - }; - let hint = "Ctrl+Z"; - let action = "撤销"; - let text_cols = - |text: &str| -> usize { text.chars().map(|ch| ch.width().unwrap_or(1).max(1)).sum() }; - - let pad = s(14.0); - let gap = s(12.0); - let action_w = s(76.0); - let bar_h = s(48.0).max(cell_h + s(12.0)); - let content_w = (text_cols(&message) + text_cols(hint) + 2) as f32 * cell_w; - let bar_w = - (pad * 2.0 + content_w + gap + action_w).max(s(360.0)).min(size.width() - s(24.0)); - let bar_x = (size.width() - bar_w) * 0.5; - let bar_y = size.height() - bar_h - s(18.0); - let action_rect = - (bar_x + bar_w - pad - action_w, bar_y + (bar_h - s(34.0)) * 0.5, action_w, s(34.0)); - self.nebula_ssh_delete_undo_rect = Some(action_rect); - - let mut quads = vec![ - UiQuad::glow( - bar_x - s(10.0), - bar_y - s(8.0), - bar_w + s(20.0), - bar_h + s(16.0), - Rgba::new(0, 0, 0, 72), - ), - UiQuad::solid( - bar_x - s(1.0), - bar_y - s(1.0), - bar_w + s(2.0), - bar_h + s(2.0), - s(11.0), - sk.hairline, - ), - UiQuad::solid(bar_x, bar_y, bar_w, bar_h, s(10.0), sk.panel), - UiQuad::solid( - action_rect.0, - action_rect.1, - action_rect.2, - action_rect.3, - s(7.0), - if self.nebula_ssh_delete_undo_hover { sk.hover_strong } else { sk.surface }, - ), - ]; - if self.nebula_ssh_delete_undo_hover { - quads.push(UiQuad::solid( - action_rect.0, - action_rect.1 + action_rect.3 - s(2.0), - action_rect.2, - s(2.0), - s(1.0), - Rgba::new(sk.accent.r, sk.accent.g, sk.accent.b, 220), - )); - } - self.renderer.draw_ui(&size, &quads); - - let text_y = bar_y + (bar_h - cell_h) * 0.5; - let message_x = bar_x + pad; - self.renderer.draw_chrome_text( - &size, - message_x, - text_y, - sk.ink, - &message, - &mut self.glyph_cache, - ); - let hint_x = action_rect.0 - gap - text_cols(hint) as f32 * cell_w; - self.renderer.draw_chrome_text( - &size, - hint_x, - text_y, - sk.ink_faint, - hint, - &mut self.glyph_cache, - ); - let action_x = action_rect.0 + (action_rect.2 - text_cols(action) as f32 * cell_w) * 0.5; - let action_y = action_rect.1 + (action_rect.3 - cell_h) * 0.5; - self.renderer.draw_chrome_text_styled( - &size, - action_x, - action_y, - if self.nebula_ssh_delete_undo_hover { sk.ink_strong } else { sk.accent }, - nebula_terminal::term::cell::Flags::BOLD, - action, - &mut self.glyph_cache, - ); - } - - /// Draw the window chrome and present the accumulated frame. - /// Overlay a transient, fading "cols × rows" HUD centered in the window, - /// shown briefly after a resize (a resize overlay HUD). Keeps requesting - /// redraws until it fades out, then clears itself. - fn draw_resize_hud(&mut self) { - let Some(mut hud) = self.nebula_resize_hud else { return }; - hud.opacity.step(self.nebula_ui_anims.frame()); - if !hud.opacity.is_active() { - self.nebula_resize_hud = None; - return; - } - self.nebula_resize_hud = Some(hud); - let cols = hud.columns; - let rows = hud.rows; - let fade = hud.opacity.value().clamp(0.0, 1.0); - - // UI-anchored metrics: the HUD is chrome, so its box and label must - // not inflate with the terminal zoom it is reporting. - let size = self.ui_size_info(); - let scale = self.window.scale_factor as f32; - let cw = size.cell_width(); - let ch = size.cell_height(); - - let text = format!("{cols} × {rows}"); - let text_cols: usize = text.chars().map(|c| c.width().unwrap_or(1)).sum(); - - // Centered translucent rounded box (fades out), skinned by the theme - // so it reads as chrome on light panels too. - let sk = self.nebula_theme.skin(); - let hud_rgb = Rgb::new(sk.panel.r, sk.panel.g, sk.panel.b); - let pad = 12.0 * scale; - let box_w = text_cols as f32 * cw + 2.0 * pad; - let box_h = ch + 2.0 * pad; - let box_x = ((size.width() - box_w) * 0.5).max(0.0); - let box_y = ((size.height() - box_h) * 0.5).max(0.0); - let bg = Rgba::new(hud_rgb.r, hud_rgb.g, hud_rgb.b, 0).with_alpha(0.85 * fade); - let quad = UiQuad::solid(box_x, box_y, box_w, box_h, 8.0 * scale, bg); - self.renderer.draw_ui(&size, &[quad]); - - // The label shares the box's pixel coordinate system — the old - // grid-cell placement centered on the TERMINAL area (whose origin - // carries the asymmetric sidebar padding), so the text drifted out of - // the window-centered box whenever the sidebar was open. Ink fades - // with the box by mixing toward the panel color. - let mix = |a: u8, b: u8| (a as f32 * fade + b as f32 * (1.0 - fade)).round() as u8; - let ink = Rgb::new( - mix(sk.ink_strong.r, hud_rgb.r), - mix(sk.ink_strong.g, hud_rgb.g), - mix(sk.ink_strong.b, hud_rgb.b), - ); - let glyph_cache = &mut self.glyph_cache; - self.renderer.draw_chrome_text( - &size, - box_x + pad, - box_y + pad, - ink, - &text, - glyph_cache, - ); - - // Keep the frame loop alive so the HUD animates out. - self.window.request_redraw(); - } - - fn present_frame(&mut self, scheduler: &mut Scheduler) { - // 本帧的 UI 锚定比率:chrome/设置/浮层文本按它反向补偿终端缩放。 - // 终端网格与文档正文不经过 chrome-text 路径,不受影响。 - let ui_scale = self.ui_text_scale(); - self.renderer.set_ui_text_scale(ui_scale); - nebula_debug_log(format!( - "render_present window={}x{} pane_view={} frame_images={} chrome_logos={}", - self.size_info.width(), - self.size_info.height(), - self.nebula_pane_view.is_some(), - self.nebula_frame_images.len(), - self.nebula_chrome_logo_draws.len(), - )); - // OSC 1337 inline images collected by the pane passes: draw above the - // cells, below the chrome/modals. - if !self.nebula_frame_images.is_empty() { - let size = self.size_info; - let images = std::mem::take(&mut self.nebula_frame_images); - for (id, rgba, px, rect) in &images { - self.renderer.draw_inline_image(&size, *id, rgba, *px, *rect); - } - } - - // Draw Nebula window chrome (title bar and tab sidebar). - chrome::draw_chrome(self); - - // AI brand logos staged by the chrome pass: drawn only now, after the - // last chrome text flush, because draw_inline_image's viewport/blend - // round-trip poisons any glyph batch that follows it. - if !self.nebula_chrome_logo_draws.is_empty() { - let size = self.size_info; - let logos = std::mem::take(&mut self.nebula_chrome_logo_draws); - for (id, rgba, px, rect) in &logos { - self.renderer.draw_inline_image(&size, *id, rgba, *px, *rect); - } - } - - // Transient resize HUD painted on top of the chrome. - self.draw_resize_hud(); - context_menu::draw(self); - self.draw_ssh_delete_undo(); - self.draw_ai_fix_bar(); - self.draw_ssh_editor_modal(); - self.draw_confirm_modal(); - - // Notify winit that we're about to present. - self.window.pre_present_notify(); - - // Highlight damage for debugging. - if self.damage_tracker.debug { - let metrics = self.glyph_cache.font_metrics(); - let damage = self.damage_tracker.shape_frame_damage(self.size_info.into()); - let mut rects = Vec::with_capacity(damage.len()); - self.highlight_damage(&mut rects); - self.renderer.draw_rects(&self.size_info, &metrics, rects); - } - - // Clearing debug highlights from the previous frame requires full redraw. - self.swap_buffers(); - - if matches!(self.raw_window_handle, RawWindowHandle::Xcb(_) | RawWindowHandle::Xlib(_)) { - // On X11 `swap_buffers` does not block for vsync. However the next OpenGl command - // will block to synchronize (this is `glClear` in Nebula), which causes a - // permanent one frame delay. - self.renderer.finish(); - } - - // XXX: Request the new frame after swapping buffers, so the - // time to finish OpenGL operations is accounted for in the timeout. - if !matches!(self.raw_window_handle, RawWindowHandle::Wayland(_)) { - self.request_frame(scheduler); - } - - self.damage_tracker.swap_damage(); - } - - /// Geometry that input and hint hit-testing should use: the focused pane's - /// half-width view when a split is active, otherwise the full window. - #[inline] - pub fn pane_view(&self) -> SizeInfo { - self.nebula_pane_view.unwrap_or(self.size_info) - } - - /// Update to a new configuration. - pub fn update_config(&mut self, config: &UiConfig) { - self.nebula_config_paths.clone_from(&config.config_paths); - self.damage_tracker.debug = config.debug.highlight_damage; - self.visual_bell.update_config(&config.bell); - // Refresh the base scheme, then re-apply the active theme's restyle. - self.nebula_default_colors = List::from(&config.colors); - let defaults = self.nebula_default_colors; - self.nebula_theme.apply_term_colors(&mut self.colors, &defaults); - } - - /// Update the mouse/vi mode cursor hint highlighting. - /// - /// This will return whether the highlighted hints changed. - pub fn update_highlighted_hints( - &mut self, - term: &Term, - config: &UiConfig, - mouse: &Mouse, - modifiers: ModifiersState, - ) -> bool { - // Update vi mode cursor hint. - let vi_highlighted_hint = if term.mode().contains(TermMode::VI) { - let mods = ModifiersState::all(); - let point = term.vi_mode_cursor.point; - hint::highlighted_at(term, config, point, mods) - } else { - None - }; - let mut dirty = vi_highlighted_hint != self.vi_highlighted_hint; - self.vi_highlighted_hint = vi_highlighted_hint; - self.vi_highlighted_hint_age = 0; - - // Force full redraw if the vi mode highlight was cleared. - if dirty { - self.damage_tracker.frame().mark_fully_damaged(); - } - - // Abort if mouse highlighting conditions are not met. - if !self.window.mouse_visible() - || !mouse.inside_text_area - || !term.selection.as_ref().is_none_or(Selection::is_empty) - { - if self.highlighted_hint.take().is_some() { - self.damage_tracker.frame().mark_fully_damaged(); - dirty = true; - } - return dirty; - } - - // Find highlighted hint at mouse position. - let point = mouse.point(&self.pane_view(), term.grid().display_offset()); - let highlighted_hint = hint::highlighted_at(term, config, point, modifiers); - - // Update cursor shape. - if highlighted_hint.is_some() { - // If mouse changed the line, we should update the hyperlink preview, since the - // highlighted hint could be disrupted by the old preview. - dirty = self.hint_mouse_point.is_some_and(|p| p.line != point.line); - self.hint_mouse_point = Some(point); - self.window.set_mouse_cursor(CursorIcon::Pointer); - } else if self.highlighted_hint.is_some() { - self.hint_mouse_point = None; - if term.mode().intersects(TermMode::MOUSE_MODE) && !term.mode().contains(TermMode::VI) { - self.window.set_mouse_cursor(CursorIcon::Default); - } else { - // Nebula: normal arrow over the terminal area (no I-beam). - self.window.set_mouse_cursor(CursorIcon::Default); - } - } - - let mouse_highlight_dirty = self.highlighted_hint != highlighted_hint; - dirty |= mouse_highlight_dirty; - self.highlighted_hint = highlighted_hint; - self.highlighted_hint_age = 0; - - // Force full redraw if the mouse cursor highlight was changed. - if mouse_highlight_dirty { - self.damage_tracker.frame().mark_fully_damaged(); - } - - dirty - } - - #[inline(never)] - /// Append a typed character to the prompt-line buffer that backs the - /// history hint. - pub fn nebula_input_char(state: &mut NebulaPaneState, c: char) { - state.line_buf.push(c); - state.touched = true; - state.suggestion.clear(); - state.suggestion_key.clear(); - nebula_debug_log(format!("input_char c={c:?} line_buf={:?}", state.line_buf)); - } - - /// Drop the last character (Backspace) from the prompt-line buffer. - pub fn nebula_input_backspace(state: &mut NebulaPaneState) { - state.line_buf.pop(); - state.touched = true; - state.suggestion.clear(); - state.suggestion_key.clear(); - nebula_debug_log(format!("input_backspace line_buf={:?}", state.line_buf)); - } - - /// Drop the previous whitespace-delimited token, matching the common - /// Ctrl+W/Ctrl+Backspace shell behavior closely enough for prompt hints. - pub fn nebula_input_delete_word(state: &mut NebulaPaneState) { - state.touched = true; - while state.line_buf.ends_with(char::is_whitespace) { - state.line_buf.pop(); - } - while state.line_buf.chars().last().is_some_and(|c| !c.is_whitespace()) { - state.line_buf.pop(); - } - state.suggestion.clear(); - state.suggestion_key.clear(); - nebula_debug_log(format!("input_delete_word line_buf={:?}", state.line_buf)); - } - - /// Merge pasted literal text into the prompt-line buffer. Multi-line or - /// escape-bearing paste can execute commands or move the cursor, so we - /// invalidate instead of guessing; Nushell avoids this class of bug by - /// owning the editor buffer directly via Reedline. - pub fn nebula_input_text(state: &mut NebulaPaneState, text: &str) { - if text.contains(['\r', '\n']) || text.chars().any(|c| c.is_control() && c != '\t') { - nebula_debug_log(format!("input_text_clear text={text:?}")); - Self::nebula_clear_line(state); - return; - } - - state.line_buf.push_str(text); - state.touched = true; - state.suggestion.clear(); - state.suggestion_key.clear(); - nebula_debug_log(format!("input_text text={text:?} line_buf={:?}", state.line_buf)); - } - - /// Commit the current line to history (on Enter) and reset the buffer. - /// - /// `screen_line` (the input read off the grid, i.e. what the shell's own - /// editor really contained) wins over the keystroke-reconstructed - /// `line_buf`: the latter desyncs on cursor motion / completion / history - /// recall and used to commit spliced garbage like "laudeclaude", which the - /// hint would then resurface as a command the user never typed. - pub fn nebula_commit_line(&mut self, state: &mut NebulaPaneState) { - // On Windows the grid read is the only source that sees tab - // completions; when it failed (no prompt arrow — cmd/ssh/REPL — or a - // mid-line edit) the keystroke buffer likely holds spliced garbage, - // and recording that would resurface it forever as a bogus ghost hint - // (truncated CJK paths were the visible symptom). Better no history - // entry than a corrupted one. - #[cfg(windows)] - let line = state.screen_line.trim().to_owned(); - #[cfg(not(windows))] - let line = if state.screen_line.trim().is_empty() { - state.line_buf.trim() - } else { - state.screen_line.trim() - } - .to_owned(); - nebula_debug_log(format!( - "input_commit cwd={:?} line={line:?} line_buf={:?} screen_line={:?}", - state.cwd, state.line_buf, state.screen_line - )); - self.nebula_history.record(&line, &state.cwd); - // Kept for CommandStart (OSC 133;C): by the time it arrives from the - // PTY these buffers are already cleared, so the program identity for - // the tab icon has to be captured here. Fall back to the keystroke - // buffer so the icon still resolves when the grid read failed — its - // first token is good enough for program identity. - state.last_committed = - if line.is_empty() { state.line_buf.trim().to_owned() } else { line }; - Self::nebula_clear_line(state); - } - - /// Feed the shared directory model from an authoritative shell report. - pub fn nebula_record_directory(&self, cwd: &str) { - self.directory_history.record(cwd); - } - - /// Reset the prompt-line buffer (Enter, Ctrl-C, or any non-text key). - pub fn nebula_clear_line(state: &mut NebulaPaneState) { - if !state.line_buf.is_empty() || !state.suggestion.is_empty() { - nebula_debug_log(format!( - "input_clear line_buf={:?} suggestion={:?}", - state.line_buf, state.suggestion - )); - } - state.line_buf.clear(); - state.screen_line.clear(); - state.suggestion.clear(); - state.suggestion_key.clear(); - } - - /// Read the real, echoed input off the cursor's grid row on Windows. - /// - /// The PowerShell profile renders the active input line as `❯ `, so - /// the user's current input is exactly the run of cells from just past the - /// last prompt arrow up to the cursor column. Reading the grid (the screen - /// truth PSReadLine itself produced) sidesteps the keystroke-reconstructed - /// `line_buf`, which desyncs the instant the cursor moves, Tab-completes or - /// recalls history — the "hint flickers in and out" bug. - /// - /// Returns `None` (suppressing the hint) when no prompt arrow precedes the - /// cursor, or when non-space cells sit after the cursor on the same row — - /// i.e. a mid-line edit, where a trailing ghost would be misplaced (fish and - /// PSReadLine suppress the hint there too). - #[cfg(windows)] - fn nebula_raw_grid_row_preview(terminal: &Term, cursor: Point) -> String { - let grid = terminal.grid(); - let columns = grid.columns(); - let mut text = String::with_capacity(columns); - let mut arrow_cols = Vec::new(); - - for col in 0..columns { - let cell: &Cell = &grid[cursor.line][Column(col)]; - if cell.flags.intersects(Flags::WIDE_CHAR_SPACER | Flags::LEADING_WIDE_CHAR_SPACER) { - continue; - } - if cell.c == NEBULA_PROMPT_ARROW { - arrow_cols.push(col); - } - text.push(cell.c); - } - - while text.ends_with(' ') { - text.pop(); - } - - format!( - "line={} col={} arrows={arrow_cols:?} text={text:?}", - cursor.line.0, cursor.column.0 - ) - } - - #[cfg(windows)] - pub(crate) fn nebula_input_from_raw_grid( - terminal: &Term, - cursor: Point, - ) -> Option { - let grid = terminal.grid(); - let columns = grid.columns(); - let cursor_col = cursor.column.0.min(columns); - - // A cursor mid-wrap means the input continues on the row below (a - // mid-line edit); a hint anchored here would be wrong. - if grid[cursor.line][Column(columns - 1)].flags.contains(Flags::WRAPLINE) { - return None; - } - - // Suppress when anything non-space follows the cursor on this row. - for col in cursor_col..columns { - let cell: &Cell = &grid[cursor.line][Column(col)]; - if cell.flags.intersects(Flags::WIDE_CHAR_SPACER | Flags::LEADING_WIDE_CHAR_SPACER) { - continue; - } - if !cell.c.is_whitespace() { - return None; - } - } - - // A long input soft-wraps — CJK paths hit the margin fast at two - // columns per char — leaving the prompt arrow rows ABOVE the cursor. - // Walk up while the row above ends in WRAPLINE to the logical line's - // first row (bounded, so a pathological grid can't spin every frame). - let topmost = grid.topmost_line().0; - let mut first_row = cursor.line.0; - while first_row > topmost - && cursor.line.0 - first_row < 64 - && grid[Line(first_row - 1)][Column(columns - 1)].flags.contains(Flags::WRAPLINE) - { - first_row -= 1; - } - - // Rebuild the logical line from its first row down to the cursor, - // remembering the last prompt arrow before it. This deliberately reads - // the raw terminal grid, not RenderableCell: renderables omit - // default-background spaces, and that was collapsing `cd D:\te` into - // `cdD:\te`, making directory-history hints impossible to parse. - let mut text = String::with_capacity(columns); - let mut arrow_pos = None; - for row in first_row..=cursor.line.0 { - let row_end = if row == cursor.line.0 { cursor_col } else { columns }; - for col in 0..row_end { - let cell: &Cell = &grid[Line(row)][Column(col)]; - if cell.flags.intersects(Flags::WIDE_CHAR_SPACER | Flags::LEADING_WIDE_CHAR_SPACER) - { - continue; - } - if cell.c == NEBULA_PROMPT_ARROW { - arrow_pos = Some(text.len()); - } - text.push(cell.c); - } - } - - let input = &text[arrow_pos? + NEBULA_PROMPT_ARROW.len_utf8()..]; - - // Drop the single space that follows the arrow. - Some(input.strip_prefix(' ').unwrap_or(input).to_owned()) - } - - /// Recompute the inline ghost-text suggestion. `line_override` carries the - /// grid-read input on Windows (the authoritative screen truth); when `None` - /// the keystroke-tracked `line_buf` is used (other platforms). A whole - /// previous command sharing the prefix wins (fish-style history hint); - /// otherwise the final token gets path completion against the shell-reported - /// cwd. Cached on `cwd\0buffer` so disk is only touched when the line - /// changes — not every frame. - fn nebula_update_suggestion( - &mut self, - state: &mut NebulaPaneState, - line_override: Option, - ) { - let line = line_override.unwrap_or_else(|| state.line_buf.clone()); - if !self.nebula_ghost_enabled || line.is_empty() { - state.suggestion.clear(); - state.suggestion_key.clear(); - nebula_debug_log(format!( - "suggest_skip enabled={} cwd={:?} line={:?} line_buf={:?}", - self.nebula_ghost_enabled, state.cwd, line, state.line_buf - )); - return; - } - - let key = format!("{}\u{0}{line}", state.cwd); - if key == state.suggestion_key { - return; - } - state.suggestion_key = key; - state.suggestion.clear(); - - nebula_debug_log(format!( - "suggest_begin cwd={:?} line={:?} line_buf={:?}", - state.cwd, line, state.line_buf - )); - - // History first: newest command that extends the whole line (indexed - // prefix lookup — scales with matches, not history size). - if let Some(rem) = self.nebula_history.hint(&line) { - state.suggestion = Self::nebula_clamp_ghost(rem); - nebula_debug_log(format!("suggest_result kind=history rem={:?}", state.suggestion)); - return; - } - - // Directory-history hint for cd-like commands. This normalizes Windows - // slash style/case, so `cd D:\te` can pick a previous - // `cd D:/temp_build/wuwei` before generic filesystem completion falls - // back to alphabetic candidates like `D:\Telegram\`. - if let Some(rem) = self.directory_history.hint(&line, &state.cwd) { - state.suggestion = Self::nebula_clamp_ghost(&rem); - nebula_debug_log(format!("suggest_result kind=dir rem={:?}", state.suggestion)); - return; - } - - // First token with no path separators is a command position. Reuse the - // process PATH inherited by the shell so typing `ca` can ghost `rgo` - // even before that command has appeared in Nebula/Nushell history. - if nebula_is_command_position(&line) { - if let Ok(commands) = self.nebula_commands.lock() { - if let Some(rem) = nebula_command_hint(commands.as_slice(), &line) { - state.suggestion = Self::nebula_clamp_ghost(rem); - nebula_debug_log(format!( - "suggest_result kind=command rem={:?}", - state.suggestion - )); - return; - } - } - } - - // Otherwise complete the final path token. Absolute tokens (drive, - // root or `~`) resolve without a cwd; relative ones need the tracked - // cwd, so bail if it is unknown. - let token = line.rsplit([' ', '\t']).next().unwrap_or(""); - if token.is_empty() { - return; - } - let absolute = - token.starts_with(['/', '\\', '~']) || token.as_bytes().get(1) == Some(&b':'); // Windows drive, e.g. `D:` - if !absolute && state.cwd.is_empty() { - return; - } - - // Case-insensitive so `mor` completes `MoRealm` on Windows; prefer - // directories for the common directory-changing commands. - let options = CompletionOptions { case_sensitive: false, ..CompletionOptions::default() }; - let want_dir = nebula_path_wants_directory(&line); - let span = Span::new(0, token.len()); - let cwd = state.cwd.clone(); - let cwd_slot = [cwd.as_str()]; - let cwds: &[&str] = if cwd.is_empty() { &[] } else { &cwd_slot }; - let matches = complete_item(want_dir, span, token, cwds, &options, false, None); - let matches = if want_dir { - self.directory_history.rank_file_suggestions(matches, &state.cwd) - } else { - matches - }; - let candidates: Vec<_> = matches - .iter() - .take(6) - .map(|s| s.display_override.as_deref().unwrap_or(&s.path).to_owned()) - .collect(); - let remainder = matches.into_iter().find_map(|s| { - let path = s.display_override.as_deref().unwrap_or(&s.path); - // The match was case-insensitive, so the suggestion is the slice of - // `path` past what the user typed. Compare the head ignoring ASCII - // case (so `mor` matches `MoRealm`) and guard the byte split against - // multibyte boundaries. - if path.len() <= token.len() || !path.is_char_boundary(token.len()) { - return None; - } - let (head, rem) = path.split_at(token.len()); - if !head.eq_ignore_ascii_case(token) { - return None; - } - // Stop at the first separator so a single deep match doesn't drill - // the whole tree into the ghost; suggest one segment. - Some(match rem.find(['/', '\\']) { - Some(i) => rem[..=i].to_owned(), - None => rem.to_owned(), - }) - }); - if let Some(rem) = remainder { - state.suggestion = Self::nebula_clamp_ghost(&rem); - nebula_debug_log(format!( - "suggest_result kind=path token={:?} candidates={:?} rem={:?}", - token, candidates, state.suggestion - )); - } else { - nebula_debug_log(format!( - "suggest_result kind=none token={:?} candidates={:?}", - token, candidates - )); - } - } - - /// Cap ghost length so a long path/command can't spill into the chrome. - fn nebula_clamp_ghost(rem: &str) -> String { - rem.chars().take(NEBULA_GHOST_MAX).collect() - } - - fn draw_powerline_icons(&mut self, icons: &[NebulaPowerlineIcon], view: SizeInfo) { - if icons.is_empty() { - return; - } - - let size = view; - let cell_w = size.cell_width(); - let cell_h = size.cell_height(); - let pad_x = size.padding_x(); - let pad_y = size.padding_y(); - let palette = self.nebula_theme.palette(); - let folder_color = Rgb::new(palette.edge_r.r, palette.edge_r.g, palette.edge_r.b); - let branch_color = Rgb::new(palette.edge_l.r, palette.edge_l.g, palette.edge_l.b); - - let mut quads = Vec::with_capacity(icons.len() * 8); - for icon in icons { - if icon.point.line >= size.screen_lines() { - continue; - } - - let x = pad_x + icon.point.column.0 as f32 * cell_w; - let y = pad_y + icon.point.line as f32 * cell_h; - - match icon.kind { - NebulaPowerlineIconKind::Folder => { - Self::push_folder_icon(&mut quads, x, y, cell_w, cell_h, folder_color); - }, - NebulaPowerlineIconKind::GitBranch => { - Self::push_git_branch_icon(&mut quads, x, y, cell_w, cell_h, branch_color); - }, - } - } - - self.renderer.draw_ui(&self.size_info, &quads); - } - - fn push_folder_icon( - quads: &mut Vec, - cell_x: f32, - cell_y: f32, - cell_w: f32, - cell_h: f32, - color: Rgb, - ) { - let icon_w = (cell_w * 1.18).clamp(8.0, cell_h * 0.72); - let icon_h = (icon_w * 0.74).clamp(6.0, cell_h * 0.58); - let x = cell_x + (cell_w - icon_w) * 0.5; - let y = cell_y + (cell_h - icon_h) * 0.5 + cell_h * 0.02; - let radius = (icon_h * 0.16).max(1.4); - - let glow = Self::rgba_from_rgb(color, 46); - let main = Self::rgba_towards_white(color, 0.16, 236); - let light = Self::rgba_towards_white(color, 0.34, 246); - let shade = Self::rgba_towards_black(color, 0.16, 230); - let shine = Rgba::new(255, 255, 255, 82); - - quads.push(UiQuad::glow( - x - icon_w * 0.20, - y - icon_h * 0.22, - icon_w * 1.40, - icon_h * 1.45, - glow, - )); - quads.push(UiQuad::gradient( - x + icon_w * 0.03, - y + icon_h * 0.08, - icon_w * 0.48, - icon_h * 0.30, - radius * 0.70, - light, - main, - Gradient::Axis([0.9, 0.35]), - )); - quads.push(UiQuad::gradient( - x, - y + icon_h * 0.25, - icon_w, - icon_h * 0.68, - radius, - main, - shade, - Gradient::Axis([0.85, 0.45]), - )); - quads.push(UiQuad::solid( - x + icon_w * 0.14, - y + icon_h * 0.48, - icon_w * 0.72, - (cell_h * 0.035).max(1.0), - 0.8, - shine, - )); - } - - fn push_git_branch_icon( - quads: &mut Vec, - cell_x: f32, - cell_y: f32, - cell_w: f32, - cell_h: f32, - color: Rgb, - ) { - let icon = (cell_w * 1.12).clamp(7.0, cell_h * 0.68); - let x = cell_x + (cell_w - icon) * 0.5; - let y = cell_y + (cell_h - icon) * 0.5; - let stroke = (icon * 0.13).clamp(1.15, 2.4); - let node = (icon * 0.27).clamp(2.8, 5.0); - let radius = node * 0.5; - - let main = Self::rgba_towards_white(color, 0.12, 240); - let glow = Self::rgba_from_rgb(color, 42); - let line = Self::rgba_towards_black(color, 0.08, 218); - - let trunk_x = x + icon * 0.34; - let top_y = y + icon * 0.23; - let mid_y = y + icon * 0.43; - let bottom_y = y + icon * 0.78; - let branch_x = x + icon * 0.70; - - quads.push(UiQuad::glow(x - icon * 0.20, y - icon * 0.18, icon * 1.42, icon * 1.40, glow)); - Self::push_icon_line(quads, trunk_x, top_y, trunk_x, bottom_y, stroke, line); - Self::push_icon_line(quads, trunk_x, mid_y, branch_x, top_y, stroke, line); - - for (cx, cy) in [(trunk_x, top_y), (branch_x, top_y), (trunk_x, bottom_y)] { - quads.push(UiQuad::solid(cx - node * 0.5, cy - node * 0.5, node, node, radius, main)); - } - } - - fn push_icon_line( - quads: &mut Vec, - x0: f32, - y0: f32, - x1: f32, - y1: f32, - width: f32, - color: Rgba, - ) { - let dx = x1 - x0; - let dy = y1 - y0; - let len = (dx * dx + dy * dy).sqrt(); - if len <= f32::EPSILON { - return; - } - - let nx = -dy / len * width * 0.5; - let ny = dx / len * width * 0.5; - quads.push(UiQuad::poly( - [[x0 + nx, y0 + ny], [x0 - nx, y0 - ny], [x1 + nx, y1 + ny], [x1 - nx, y1 - ny]], - color, - color, - Gradient::None, - )); - } - - fn rgba_from_rgb(color: Rgb, alpha: u8) -> Rgba { - Rgba::new(color.r, color.g, color.b, alpha) - } - - fn rgba_towards_white(color: Rgb, amount: f32, alpha: u8) -> Rgba { - Self::rgba_mix(color, Rgb::new(255, 255, 255), amount, alpha) - } - - fn rgba_towards_black(color: Rgb, amount: f32, alpha: u8) -> Rgba { - Self::rgba_mix(color, Rgb::new(0, 0, 0), amount, alpha) - } - - fn rgba_mix(from: Rgb, to: Rgb, amount: f32, alpha: u8) -> Rgba { - let t = amount.clamp(0.0, 1.0); - let mix = |a: u8, b: u8| (f32::from(a) + (f32::from(b) - f32::from(a)) * t).round() as u8; - Rgba::new(mix(from.r, to.r), mix(from.g, to.g), mix(from.b, to.b), alpha) - } - - #[inline(never)] - fn draw_ime_preview( - &mut self, - point: Point, - fg: Rgb, - bg: Rgb, - rects: &mut Vec, - config: &UiConfig, - ) { - let preedit = match self.ime.preedit() { - Some(preedit) => preedit, - None => { - // In case we don't have preedit, just set the popup point. - self.window.update_ime_position(point, &self.size_info); - return; - }, - }; - - let num_cols = self.size_info.columns(); - - // Get the visible preedit. - let visible_text: String = match (preedit.cursor_byte_offset, preedit.cursor_end_offset) { - (Some(byte_offset), Some(end_offset)) if end_offset.0 > num_cols => StrShortener::new( - &preedit.text[byte_offset.0..], - num_cols, - ShortenDirection::Right, - Some(SHORTENER), - ), - _ => { - StrShortener::new(&preedit.text, num_cols, ShortenDirection::Left, Some(SHORTENER)) - }, - } - .collect(); - - let visible_len = visible_text.chars().count(); - - let end = cmp::min(point.column.0 + visible_len, num_cols); - let start = end.saturating_sub(visible_len); - - let start = Point::new(point.line, Column(start)); - let end = Point::new(point.line, Column(end - 1)); - - let glyph_cache = &mut self.glyph_cache; - let metrics = glyph_cache.font_metrics(); - - self.renderer.draw_string( - start, - fg, - bg, - visible_text.chars(), - &self.size_info, - glyph_cache, - ); - - // Damage preedit inside the terminal viewport. - if point.line < self.size_info.screen_lines() { - let damage = LineDamageBounds::new(start.line, 0, num_cols); - self.damage_tracker.frame().damage_line(damage); - self.damage_tracker.next_frame().damage_line(damage); - } - - // Add underline for preedit text. - let underline = RenderLine { start, end, color: fg }; - rects.extend(underline.rects(Flags::UNDERLINE, &metrics, &self.size_info)); - - let ime_popup_point = match preedit.cursor_end_offset { - Some(cursor_end_offset) => { - // Use hollow block when multiple characters are changed at once. - let (shape, width) = if let Some(width) = - NonZeroU32::new((cursor_end_offset.0 - cursor_end_offset.1) as u32) - { - (CursorShape::HollowBlock, width) - } else { - (CursorShape::Beam, NonZeroU32::new(1).unwrap()) - }; - - let cursor_column = Column( - (end.column.0 as isize - cursor_end_offset.0 as isize + 1).max(0) as usize, - ); - let cursor_point = Point::new(point.line, cursor_column); - let cursor = RenderableCursor::new(cursor_point, shape, fg, width); - rects.extend(cursor.rects(&self.size_info, config.cursor.thickness())); - cursor_point - }, - _ => end, - }; - - self.window.update_ime_position(ime_popup_point, &self.size_info); - } - - /// Format search regex to account for the cursor and fullwidth characters. - fn format_search(search_regex: &str, search_label: &str, max_width: usize) -> String { - let label_len = search_label.len(); - - // Skip `search_regex` formatting if only label is visible. - if label_len > max_width { - return search_label[..max_width].to_owned(); - } - - // The search string consists of `search_label` + `search_regex` + `cursor`. - let mut bar_text = String::from(search_label); - bar_text.extend(StrShortener::new( - search_regex, - max_width.wrapping_sub(label_len + 1), - ShortenDirection::Left, - Some(SHORTENER), - )); - - // Add place for cursor. - bar_text.push(' '); - - bar_text - } - - /// Draw preview for the currently highlighted `Hyperlink`. - #[inline(never)] - /// Draw a compact "open this link" tooltip near the hovered hint. - /// - /// 2026-07-23 用户反馈重构:上一版是整行 opaque `draw_string`,锚在鼠 - /// 标 cell 上——指针沿链接滑动时提示逐格跳动(被感知为"闪烁"),路 - /// 径还能占满整行("显示太长")。现在锚定到 hint 自己的起始 cell(指 - /// 针滑动时纹丝不动)、提示词压缩为 `Ctrl+点击`,并以 0.85× UI 锚定 - /// 字号画进圆角小气泡。 - /// - /// 2026-07-26 用户反馈"显示不全"三连修:① file URI percent-decode 后 - /// 再展示(见 [`strip_file_scheme`]);② 48 列硬帽退役,预算放开到整 - /// 个视口宽(fit_tail 仍兜底真溢出);③ 气泡宽度按渲染器真实步进 - /// `average_advance × scale` 量取——`cell_w` 是 floor 后的值,48 列累 - /// 积下来尾部文字会戳出气泡右缘。 - fn draw_hyperlink_preview( - &mut self, - config: &UiConfig, - _cursor_point: Option, - display_offset: usize, - ) { - let num_cols = self.size_info.columns(); - - // The destination under the mouse (first highlighted hint with a URI) - // plus that hint's start cell as the anchor. - let Some((uri, hint_start)) = self - .highlighted_hint - .iter() - .chain(&self.vi_highlighted_hint) - .find_map(|hint| { - hint.hyperlink().map(|h| (h.uri().to_owned(), *hint.bounds().start())) - }) - else { - return; - }; - // Hint start scrolled out of the viewport → fall back to the mouse cell. - let anchor = term::point_to_viewport(display_offset, hint_start).or_else(|| { - self.hint_mouse_point.and_then(|p| term::point_to_viewport(display_offset, p)) - }); - let Some(anchor) = anchor else { - return; - }; - - // Strip the `file://` scheme (and its leading slash before a Windows - // drive) so a local path reads as a path, not a URL. - let target = strip_file_scheme(&uri); - const HINT: &str = " · Ctrl+点击"; - let width = |s: &str| -> usize { s.chars().map(|c| c.width().unwrap_or(0)).sum() }; - let hint_w = width(HINT); - let target_budget = num_cols.saturating_sub(hint_w + 1); - let target = fit_tail(&target, target_budget); - let label = format!("{target}{HINT}"); - - // Position: one row below the hint's first cell, or above on the last row. - let line = if anchor.line + 1 < self.size_info.screen_lines() { - anchor.line + 1 - } else { - anchor.line.saturating_sub(1) - }; - - // Damage every row the bubble touches, this frame and next (it can - // appear/vanish). The bubble is taller than one cell row (0.85·cell - // plus padding and border, centered on its row), so it bleeds into - // both neighbours — an un-damaged neighbour ghosts the bubble's edges - // on partial-present paths. - let last_line = self.size_info.screen_lines().saturating_sub(1); - for touched in line.saturating_sub(1)..=(line + 1).min(last_line) { - let damage = LineDamageBounds::new(touched, 0, num_cols); - self.damage_tracker.frame().damage_line(damage); - self.damage_tracker.next_frame().damage_line(damage); - } - - let scale_px = self.window.scale_factor as f32; - let s = |v: f32| v * scale_px; - let text_scale = 0.85 * self.ui_text_scale(); - let cell_w = self.size_info.cell_width(); - let cell_h = self.size_info.cell_height(); - // Measure with the renderer's REAL step for scaled doc text — the - // unfloored design advance (`draw_doc_text_tracked` walks - // `average_advance × scale`). `cell_w` is that advance floored; the - // fraction lost per column made long labels poke out of the bubble. - let advance = self.glyph_cache.font_metrics().average_advance as f32 * text_scale; - let label_px = width(&label) as f32 * advance; - let pad_x = s(8.0); - let bubble_w = label_px + 2.0 * pad_x; - let bubble_h = cell_h * text_scale + s(8.0); - let max_x = (self.size_info.width() - self.size_info.padding_right() - bubble_w).max(0.0); - let x = (self.size_info.padding_x() + anchor.column.0 as f32 * cell_w).min(max_x); - let y = self.size_info.padding_y() + line as f32 * cell_h + (cell_h - bubble_h) * 0.5; - - let fg = config.colors.footer_bar_foreground(); - let bg = config.colors.footer_bar_background(); - let quads = [ - UiQuad::solid( - x - s(1.0), - y - s(1.0), - bubble_w + s(2.0), - bubble_h + s(2.0), - s(7.0), - Rgba::new(fg.r, fg.g, fg.b, 46), - ), - UiQuad::solid(x, y, bubble_w, bubble_h, s(6.0), Rgba::new(bg.r, bg.g, bg.b, 240)), - ]; - self.renderer.draw_ui(&self.size_info, &quads); - - let glyph_cache = &mut self.glyph_cache; - let size = self.size_info; - self.renderer.draw_doc_text_tracked( - &size, - x + pad_x, - y + (bubble_h - cell_h * text_scale) * 0.5, - text_scale, - 0.0, - fg, - Flags::empty(), - &label, - glyph_cache, - ); - } - - /// Draw current search regex. - #[inline(never)] - fn draw_search(&mut self, config: &UiConfig, text: &str) { - // Assure text length is at least num_cols. - let num_cols = self.size_info.columns(); - let text = format!("{text:, - line: usize, - ) { - let columns = self.size_info.columns(); - let text = format!("[{}/{}]", line, total_lines - 1); - let column = Column(self.size_info.columns().saturating_sub(text.len())); - let point = Point::new(0, column); - - // Damage the line indicator for current and next frame. - let damage = LineDamageBounds::new(point.line, point.column.0, columns - 1); - self.damage_tracker.frame().damage_line(damage); - self.damage_tracker.next_frame().damage_line(damage); - - let colors = &config.colors; - let fg = colors.line_indicator.foreground.unwrap_or(colors.primary.background); - let bg = colors.line_indicator.background.unwrap_or(colors.primary.foreground); - - // Do not render anything if it would obscure the vi mode cursor. - if obstructed_column.is_none_or(|obstructed_column| obstructed_column < column) { - let glyph_cache = &mut self.glyph_cache; - self.renderer.draw_string(point, fg, bg, text.chars(), &self.size_info, glyph_cache); - } - } - - /// Highlight damaged rects. - /// - /// This function is for debug purposes only. - fn highlight_damage(&self, render_rects: &mut Vec) { - for damage_rect in &self.damage_tracker.shape_frame_damage(self.size_info.into()) { - let x = damage_rect.x as f32; - let height = damage_rect.height as f32; - let width = damage_rect.width as f32; - let y = damage_y_to_viewport_y(&self.size_info, damage_rect) as f32; - let render_rect = RenderRect::new(x, y, width, height, DAMAGE_RECT_COLOR, 0.5); - - render_rects.push(render_rect); - } - } - - /// Check whether a hint highlight needs to be cleared. - /// - /// 2026-07-26 闪烁根因:这里原本拿共享 damage tracker 的 `intersects` - /// 判断"hint 底下的网格变没变",可 Nebula 每帧把 frame/next_frame 都标 - /// 成全窗 damage(全窗重绘呈现模型),`intersects` 的 `full ||` 短路恒 - /// 真——悬停高亮活不过两帧就被掐灭,鼠标一动重新点亮又立刻熄灭,ls - /// 里扫过文件时下划线和气泡狂闪。改判终端自己上报的本帧 damage - /// (`draw_pane` 在污染 tracker 之前捕获),语义回到上游本意。 - fn validate_hint_highlights( - &mut self, - display_offset: usize, - term_damage_full: bool, - term_damage_lines: &[LineDamageBounds], - ) { - let hints = [ - (&mut self.highlighted_hint, &mut self.highlighted_hint_age, true), - (&mut self.vi_highlighted_hint, &mut self.vi_highlighted_hint_age, false), - ]; - - let num_lines = self.size_info.screen_lines(); - for (hint, hint_age, reset_mouse) in hints { - let (start, end) = match hint { - Some(hint) => (*hint.bounds().start(), *hint.bounds().end()), - None => continue, - }; - - // Ignore hints that were created this frame. - *hint_age += 1; - if *hint_age == 1 { - continue; - } - - // Convert hint bounds to viewport coordinates. - let start = term::point_to_viewport(display_offset, start) - .filter(|point| point.line < num_lines) - .unwrap_or_default(); - let end = term::point_to_viewport(display_offset, end) - .filter(|point| point.line < num_lines) - .unwrap_or_else(|| Point::new(num_lines - 1, self.size_info.last_column())); - - // Clear hints whose underlying grid content actually changed. - let grid_changed = term_damage_full - || term_damage_lines.iter().any(|l| { - l.line >= start.line - && l.line <= end.line - // On the hint's first/last line only the hint's own - // column span counts; interior lines count wholly. - && (l.line != start.line || l.right >= start.column.0) - && (l.line != end.line || l.left <= end.column.0) - }); - if grid_changed { - if reset_mouse { - self.window.set_mouse_cursor(CursorIcon::Default); - } - self.damage_tracker.frame().mark_fully_damaged(); - *hint = None; - } - } - } - - /// Request a new frame for a window on Wayland. - fn request_frame(&mut self, scheduler: &mut Scheduler) { - // Mark that we've used a frame. - self.window.has_frame = false; - - // Get the display vblank interval. - let monitor_vblank_interval = 1_000_000. - / self - .window - .current_monitor() - .and_then(|monitor| monitor.refresh_rate_millihertz()) - .unwrap_or(60_000) as f64; - - // Now convert it to micro seconds. - let monitor_vblank_interval = - Duration::from_micros((1000. * monitor_vblank_interval) as u64); - - let swap_timeout = self.frame_timer.compute_timeout(monitor_vblank_interval); - - let window_id = self.window.id(); - let timer_id = TimerId::new(Topic::Frame, window_id); - let event = Event::new(EventType::Frame, window_id); - - scheduler.schedule(event, swap_timeout, false, timer_id); - } -} - -impl Drop for Display { - fn drop(&mut self) { - // Switch OpenGL context before dropping, otherwise objects (like programs) from other - // contexts might be deleted when dropping renderer. - self.make_current(); - unsafe { - ManuallyDrop::drop(&mut self.renderer); - ManuallyDrop::drop(&mut self.context); - ManuallyDrop::drop(&mut self.surface); - } - } -} - -/// Input method state. -#[derive(Debug, Default)] -pub struct Ime { - /// Whether the IME is enabled. - enabled: bool, - - /// Current IME preedit. - preedit: Option, -} - -impl Ime { - #[inline] - pub fn set_enabled(&mut self, is_enabled: bool) { - if is_enabled { - self.enabled = is_enabled - } else { - // Clear state when disabling IME. - *self = Default::default(); - } - } - - #[inline] - pub fn is_enabled(&self) -> bool { - self.enabled - } - - #[inline] - pub fn set_preedit(&mut self, preedit: Option) { - self.preedit = preedit; - } - - #[inline] - pub fn preedit(&self) -> Option<&Preedit> { - self.preedit.as_ref() - } -} - -#[derive(Debug, Default, PartialEq, Eq)] -pub struct Preedit { - /// The preedit text. - text: String, - - /// Byte offset for cursor start into the preedit text. - /// - /// `None` means that the cursor is invisible. - cursor_byte_offset: Option<(usize, usize)>, - - /// The cursor offset from the end of the start of the preedit in char width. - cursor_end_offset: Option<(usize, usize)>, -} - -impl Preedit { - pub fn new(text: String, cursor_byte_offset: Option<(usize, usize)>) -> Self { - let cursor_end_offset = if let Some(byte_offset) = cursor_byte_offset { - // Convert byte offset into char offset. - let start_to_end_offset = - text[byte_offset.0..].chars().fold(0, |acc, ch| acc + ch.width().unwrap_or(1)); - let end_to_end_offset = - text[byte_offset.1..].chars().fold(0, |acc, ch| acc + ch.width().unwrap_or(1)); - - Some((start_to_end_offset, end_to_end_offset)) - } else { - None - }; - - Self { text, cursor_byte_offset, cursor_end_offset } - } -} - -/// Pending renderer updates. -/// -/// All renderer updates are cached to be applied just before rendering, to avoid platform-specific -/// rendering issues. -#[derive(Debug, Default, Copy, Clone)] -pub struct RendererUpdate { - /// Should resize the window. - resize: bool, - - /// Clear font caches. - clear_font_cache: bool, -} - -/// The frame timer state. -pub struct FrameTimer { - /// Base timestamp used to compute sync points. - base: Instant, - - /// The last timestamp we synced to. - last_synced_timestamp: Instant, - - /// The refresh rate we've used to compute sync timestamps. - refresh_interval: Duration, -} - -impl FrameTimer { - pub fn new() -> Self { - let now = Instant::now(); - Self { base: now, last_synced_timestamp: now, refresh_interval: Duration::ZERO } - } - - /// Compute the delay that we should use to achieve the target frame - /// rate. - pub fn compute_timeout(&mut self, refresh_interval: Duration) -> Duration { - let now = Instant::now(); - - // Handle refresh rate change. - if self.refresh_interval != refresh_interval { - self.base = now; - self.last_synced_timestamp = now; - self.refresh_interval = refresh_interval; - return refresh_interval; - } - - let next_frame = self.last_synced_timestamp + self.refresh_interval; - - if next_frame < now { - // Redraw immediately if we haven't drawn in over `refresh_interval` microseconds. - let elapsed_micros = (now - self.base).as_micros() as u64; - let refresh_micros = self.refresh_interval.as_micros() as u64; - self.last_synced_timestamp = - now - Duration::from_micros(elapsed_micros % refresh_micros); - Duration::ZERO - } else { - // Redraw on the next `refresh_interval` clock tick. - self.last_synced_timestamp = next_frame; - next_frame - now - } - } -} - -/// Calculate the cell dimensions based on font metrics. -/// -/// This will return a tuple of the cell width and height. -#[inline] -fn compute_cell_size(config: &UiConfig, metrics: &crossfont::Metrics) -> (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, - ) -} - -/// Calculate the size of the window given padding, terminal dimensions and cell size. -fn window_size( - config: &UiConfig, - dimensions: Dimensions, - cell_width: f32, - cell_height: f32, - scale_factor: f32, -) -> PhysicalSize { - let padding = config.window.padding(scale_factor); - let chrome = chrome_reserve(scale_factor); - - let grid_width = cell_width * dimensions.columns.max(MIN_COLUMNS) as f32; - let grid_height = cell_height * dimensions.lines.max(MIN_SCREEN_LINES) as f32; - - // Left absorbs the sidebar (expanded by default), right is the plain - // content margin, matching the asymmetric grid the sidebar produces. - let pad_left = padding.0 + content_pad_x(scale_factor) + sidebar_width(scale_factor, false); - let pad_right = padding.0 + content_pad_x(scale_factor); - let width = (grid_width + pad_left + pad_right).floor(); - let pad_top = padding.1 + chrome; - let pad_bottom = padding.1 + bottom_content_reserve(scale_factor); - let height = (pad_top + grid_height + pad_bottom).floor(); - - PhysicalSize::new(width as u32, height as u32) -} - -#[cfg(test)] -mod nebula_ux_tests { - use nebula_terminal::grid::Dimensions; - use winit::window::Theme as WinitTheme; - - use super::{ - NebulaConfirm, SizeInfo, alt_screen_vertical_padding_bands, nebula_command_hint, - percent_decode_lossy, remove_ssh_host_from_lists, replays_untrusted_terminal_output, - restore_ssh_host_to_lists, strip_file_scheme, system_theme_snapshot, - }; - - fn strings(values: &[&str]) -> Vec { - values.iter().map(|value| (*value).to_owned()).collect() - } - - #[test] - fn file_uri_tooltip_shows_decoded_path() { - // `ls --hyperlink` percent-encodes CJK names; the tooltip must not. - assert_eq!( - strip_file_scheme("file:///D:/%E6%98%9F%E9%9B%B2/read%20me.txt"), - "D:/星雲/read me.txt" - ); - // Non-file URIs keep their encoding — it is part of their identity. - assert_eq!(strip_file_scheme("https://a.b/c%20d"), "https://a.b/c%20d"); - // Malformed escapes and non-UTF-8 decodes survive verbatim. - assert_eq!(percent_decode_lossy("100%"), "100%"); - assert_eq!(percent_decode_lossy("%zz"), "%zz"); - assert_eq!(percent_decode_lossy("%ff%fe"), "%ff%fe"); - } - - #[test] - fn log_replay_commands_do_not_receive_terminal_query_answers() { - for command in [ - "docker logs app", - "docker compose logs -f api", - "podman logs app", - "kubectl logs pod/api", - "journalctl -f -u nebula", - ] { - assert!(replays_untrusted_terminal_output(command), "{command}"); - } - for command in ["docker run app", "kubectl exec pod -- sh", "cargo test", "nvim"] { - assert!(!replays_untrusted_terminal_output(command), "{command}"); - } - } - - #[test] - fn exact_path_command_suppresses_longer_neighbor_completion() { - let commands = strings(&["claude", "claude-agent-acp"]); - assert_eq!(nebula_command_hint(&commands, "clau"), Some("de")); - assert_eq!(nebula_command_hint(&commands, "claude"), None); - - #[cfg(windows)] - assert_eq!(nebula_command_hint(&commands, "CLAUDE"), None); - } - - #[test] - fn system_theme_snapshot_beats_a_stale_window_override() { - assert_eq!( - system_theme_snapshot(Some(WinitTheme::Dark), Some(WinitTheme::Light)), - Some(WinitTheme::Dark) - ); - assert_eq!(system_theme_snapshot(None, Some(WinitTheme::Light)), Some(WinitTheme::Light)); - } - - #[test] - fn ssh_delete_undo_restores_saved_and_pinned_order() { - let mut saved = strings(&["alpha", "target", "omega"]); - let mut pinned = strings(&["target", "alpha"]); - let mut hidden = strings(&["already-hidden"]); - - let snapshot = remove_ssh_host_from_lists("target", &mut saved, &mut pinned, &mut hidden); - assert_eq!(snapshot, (Some(1), Some(0), false)); - assert_eq!(saved, strings(&["alpha", "omega"])); - assert_eq!(pinned, strings(&["alpha"])); - assert_eq!(hidden, strings(&["already-hidden", "target"])); - - restore_ssh_host_to_lists( - "target", - snapshot.0, - snapshot.1, - snapshot.2, - &mut saved, - &mut pinned, - &mut hidden, - ); - assert_eq!(saved, strings(&["alpha", "target", "omega"])); - assert_eq!(pinned, strings(&["target", "alpha"])); - assert_eq!(hidden, strings(&["already-hidden"])); - } - - #[test] - fn ssh_config_only_hide_is_fully_reversible() { - let mut saved = Vec::new(); - let mut pinned = Vec::new(); - let mut hidden = Vec::new(); - - let snapshot = - remove_ssh_host_from_lists("config-alias", &mut saved, &mut pinned, &mut hidden); - assert_eq!(snapshot, (None, None, false)); - assert_eq!(hidden, strings(&["config-alias"])); - - restore_ssh_host_to_lists( - "config-alias", - snapshot.0, - snapshot.1, - snapshot.2, - &mut saved, - &mut pinned, - &mut hidden, - ); - assert!(saved.is_empty()); - assert!(pinned.is_empty()); - assert!(hidden.is_empty()); - } - - #[test] - fn asymmetric_bottom_reserve_recovers_rows_hidden_by_top_chrome() { - let size = SizeInfo::new_fully_asymmetric(1000.0, 1000.0, 10.0, 20.0, 0.0, 0.0, 64.0, 16.0); - assert_eq!(size.screen_lines(), 46); - assert_eq!(size.padding_y(), 64.0); - assert_eq!(size.padding_bottom(), 16.0); - - let old_symmetric = SizeInfo::new_asymmetric(1000.0, 1000.0, 10.0, 20.0, 0.0, 0.0, 64.0); - assert_eq!(old_symmetric.screen_lines(), 43); - } - - #[test] - fn alternate_screen_padding_stays_inside_stacked_panes() { - let window = - SizeInfo::new_fully_asymmetric(1000.0, 700.0, 10.0, 20.0, 100.0, 20.0, 80.0, 20.0); - let top = - SizeInfo::new_fully_asymmetric(1000.0, 700.0, 10.0, 20.0, 100.0, 20.0, 80.0, 324.0); - let bottom = - SizeInfo::new_fully_asymmetric(1000.0, 700.0, 10.0, 20.0, 100.0, 20.0, 384.0, 20.0); - - assert_eq!( - alt_screen_vertical_padding_bands(&window, &top, 56.0, 636.0), - [Some((56.0, 24.0)), Some((360.0, 16.0))] - ); - assert_eq!( - alt_screen_vertical_padding_bands(&window, &bottom, 56.0, 636.0), - [None, Some((664.0, 28.0))] - ); - } - - #[test] - fn missing_font_notice_can_be_dismissed() { - let confirm = - NebulaConfirm::InstallRequiredFont { directory: std::path::PathBuf::from("fonts") }; - - assert!(confirm.can_dismiss()); - } -} +//! The display subsystem including window management, font rasterization, and +//! GPU drawing. + +use std::cmp; +use std::collections::HashSet; +use std::fmt::{self, Formatter}; +use std::mem::{self, ManuallyDrop}; +use std::num::NonZeroU32; +use std::ops::Deref; +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; + +use glutin::config::GetGlConfig; +use glutin::context::{NotCurrentContext, PossiblyCurrentContext}; +use glutin::display::GetGlDisplay; +use glutin::error::ErrorKind; +use glutin::prelude::*; +use glutin::surface::{Surface, SwapInterval, WindowSurface}; + +use log::{debug, info, warn}; +use parking_lot::MutexGuard; +use winit::dpi::PhysicalSize; +use winit::keyboard::ModifiersState; +use winit::raw_window_handle::RawWindowHandle; +use winit::window::{CursorIcon, Theme as WinitTheme}; + +use crossfont::{Rasterize, Size as FontSize}; +use unicode_width::UnicodeWidthChar; + +use nebula_terminal::event::{EventListener, OnResize}; +use nebula_terminal::grid::Dimensions as TermDimensions; +use nebula_terminal::index::{Column, Direction, Line, Point}; +use nebula_terminal::selection::Selection; +#[cfg(windows)] +use nebula_terminal::term::cell::Cell; +use nebula_terminal::term::cell::Flags; +use nebula_terminal::term::{ + self, LineDamageBounds, MIN_COLUMNS, MIN_SCREEN_LINES, Term, TermDamage, TermMode, +}; +use nebula_terminal::vte::ansi::{CursorShape, NamedColor}; + +use nebula_completions::file::complete_item; +use nebula_completions::{CompletionOptions, Span}; + +use crate::config::UiConfig; +use crate::config::debug::RendererPreference; +use crate::config::font::Font; +use crate::config::window::Dimensions; +use crate::config::window::StartupMode; +use crate::display::bell::VisualBell; +use crate::display::color::{List, Rgb}; +use crate::display::content::{RenderableContent, RenderableCursor}; +use crate::display::cursor::IntoRects; +use crate::display::damage::{DamageTracker, damage_y_to_viewport_y}; +use crate::display::hint::{HintMatch, HintState}; +use crate::display::meter::Meter; +use crate::display::window::Window; +use crate::event::{Event, EventType, Mouse, SearchState}; +use crate::message_bar::{self, MessageBuffer, MessageType}; +use crate::renderer::Rasterizer; +use crate::renderer::image::{BackgroundImageAlignment, BackgroundImageFit}; +use crate::renderer::rects::{RenderLine, RenderLines, RenderRect}; +use crate::renderer::ui::{Gradient, Rgba, UiQuad}; +use crate::renderer::{self, GlyphCache, Renderer, platform}; +use crate::scheduler::{Scheduler, TimerId, Topic}; +use crate::string::{ShortenDirection, StrShortener}; + +pub mod color; +pub mod content; +pub mod cursor; +pub mod hint; +pub mod ui; +pub mod window; + +mod chrome; +pub mod command_palette; +mod context_menu; +mod i18n; +pub mod markdown_view; +mod message_queue_entry; +pub mod sftp_panel; +pub mod side_panel; +mod size_info; +mod state; +mod surface_opacity; +mod terminal_color; +mod terminal_math; + +/// Processor uses the same persisted value before the first window exists so +/// the global quick-terminal shortcut is active from application startup. +pub(crate) fn quick_terminal_hotkey_from_settings(config: &UiConfig) -> String { + settings::nebula_settings_load(config).quick_terminal_hotkey +} + +pub(crate) use chrome::chrome_settings_button_rect; +pub use chrome::{ChromeHit, TabDropAction, in_chrome_bar, resize_edge}; +use chrome::{ + ChromeTabLayout, TabDrag, chrome_hit_with_tabs, chrome_tab_layout, contains_rect, + truncate_tab_label, +}; +pub use context_menu::{ContextMenuAction, ContextMenuHit, ContextMenuTarget}; +pub use i18n::{LanguagePreference, UiLanguage}; +pub use size_info::SizeInfo; +pub use state::{ + AcceptKey, NebulaConfirm, NebulaInlineImage, NebulaPaneState, NebulaShell, SplitDirection, + SplitNav, +}; + +mod file_dialog; +pub(crate) mod keymap; +mod settings; +mod ssh_editor_render; +mod ssh_ui; +mod text_input; + +use ssh_ui::SshDeleteUndo; +pub use ssh_ui::{ + SSH_DELETE_UNDO_DURATION, SshEditorField, SshEditorHit, SshEditorRects, SshHostEditor, +}; +pub use ui::theme::NebulaTheme; +pub(crate) use ui::theme::write_nebula_prompt_theme; + +/// Shared caret blink phase for the chrome text editors (rename / filter / +/// commit boxes): 500ms on / 500ms off wall-clock, same time source as the +/// sidebar spinner. The fast tick (armed while an editor is focused) keeps +/// frames coming so the phase is actually visible instead of looking frozen. +pub(crate) fn caret_blink_on() -> bool { + let millis = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis()) + .unwrap_or(0); + (millis / 500) % 2 == 0 +} +pub use settings::{NebulaSettingsSection, SettingsDropdown, SettingsHit, settings_hit}; +pub(crate) use settings::SettingsOpacityTarget; + +/// 按显示列宽贪心断行(确认框正文等 UI 段落用):CJK 逐字可断,行首空 +/// 格吞掉;零宽字符跟随前一个字。不做拉丁连词回退——正文以中文为主, +/// 偶发的英文单词被折断可接受。 +fn wrap_display_cols(text: &str, max_cols: usize) -> Vec { + let mut lines = Vec::new(); + let mut line = String::new(); + let mut cols = 0usize; + for ch in text.chars() { + let w = ch.width().unwrap_or(0); + if w == 0 { + line.push(ch); + continue; + } + if cols + w > max_cols && cols > 0 { + lines.push(std::mem::take(&mut line)); + cols = 0; + if ch == ' ' { + continue; + } + } + line.push(ch); + cols += w; + } + if !line.is_empty() || lines.is_empty() { + lines.push(line); + } + lines +} + +mod bell; +mod damage; +mod meter; + +/// Label for the forward terminal search bar. +const FORWARD_SEARCH_LABEL: &str = "Search: "; + +/// Label for the backward terminal search bar. +const BACKWARD_SEARCH_LABEL: &str = "Backward Search: "; + +/// The character used to shorten the visible text like uri preview or search regex. +const SHORTENER: char = '…'; + +/// Private-use placeholders emitted by Nebula's injected prompt. They are +/// replaced with spaces before text rendering; the real icons are vector UI +/// quads, so no Nerd Font or bundled font is required. +const NEBULA_FOLDER_ICON_MARKER: char = '\u{E100}'; +const NEBULA_GIT_BRANCH_ICON_MARKER: char = '\u{E101}'; + +/// Color which is used to highlight damaged rects when debugging. +const DAMAGE_RECT_COLOR: Rgb = Rgb::new(255, 0, 255); + +/// Cap on ghost-text length so a deeply-nested path can never spill across the +/// whole row and clobber the chrome. +const NEBULA_GHOST_MAX: usize = 96; + +/// Prompt arrow injected by the Windows PowerShell profile (`U+276F`, `NebPromptArrow`). +/// The active input line is rendered as `❯ `, so on Windows the real, +/// echoed input can be read straight off the grid instead of being guessed from +/// keystrokes — that is the only source that never desyncs from the shell's own +/// line editor (PSReadLine). +#[cfg(windows)] +const NEBULA_PROMPT_ARROW: char = '\u{276F}'; + +/// Visible split divider gap. The drag hit target is intentionally wider. +pub(crate) const NEBULA_SPLIT_DIVIDER_GAP: f32 = 2.0; +pub(crate) const NEBULA_SPLIT_HIT_SLOP: f32 = 8.0; + +/// How far the unfocused split is dimmed. Focus is conveyed by brightness, not +/// a border: the inactive pane is pushed back under a translucent veil so the +/// focused pane visually "lifts" without any outline. +/// `unfocused-split-opacity = 0.7` (i.e. a 0.3 dim veil). +pub(crate) const NEBULA_UNFOCUSED_SPLIT_DIM: f32 = 0.30; + +/// Max remembered commands for the history hint. + +/// Top chrome reserve, in logical pixels at scale factor 1.0. Sized as: top +/// bar (8 margin + 40 bar) + card seam (8) + 8px of breathing room inside the +/// terminal card, so the first grid row doesn't touch the card's top edge. +pub const CHROME_BAR_LOGICAL: f32 = 64.0; + +/// Shared chrome/control corner radius. Used for the small in-shell affordances +/// (window-control hover pills, tab pills, the "+" square) — kept modest so the +/// controls stay crisp. +pub(super) const UI_CORNER_RADIUS_LOGICAL: f32 = 8.0; + +/// Outer radius of the connected chrome shell (the L-frame formed by the top +/// bar + left sidebar). Larger than the control radius so the whole window +/// chrome reads as one soft-cornered card while the affordances inside keep +/// their tighter [`UI_CORNER_RADIUS_LOGICAL`] curve. +pub(super) const UI_SHELL_RADIUS_LOGICAL: f32 = 14.0; + +/// Gap between the terminal card and the window's right/bottom edges, in +/// logical pixels — the visible "seam" of shell color that makes the terminal +/// read as a rounded card floating on the shell backdrop. Top and left carry +/// no seam of their own: the card tucks up under the top bar and sidebar. +pub(super) const UI_CARD_SEAM_LOGICAL: f32 = 8.0; + +/// Shared quiet outline thickness. +pub(super) const UI_HAIRLINE_LOGICAL: f32 = 1.0; + +/// Horizontal breathing space for terminal content, in logical pixels. +/// Kept modest so the grid stays wide — this is *added on top of* the user's +/// configured `window.padding`, on both sides, so large values noticeably +/// narrow the usable area. +pub const CONTENT_PAD_X_LOGICAL: f32 = 20.0; + +/// Reserved chrome height per side, in physical pixels for `scale_factor`. +#[inline] +pub fn chrome_reserve(scale_factor: f32) -> f32 { + (CHROME_BAR_LOGICAL * scale_factor).round() +} + +/// Bottom grid reserve: card seam plus the same 8px inner breathing room used +/// above the first row. Unlike [`chrome_reserve`], there is no title bar below +/// the terminal, so mirroring the 64px top reserve creates a large dead band. +#[inline] +pub fn bottom_content_reserve(scale_factor: f32) -> f32 { + ((UI_CARD_SEAM_LOGICAL + 8.0) * scale_factor).round() +} + +/// Horizontal content padding, in physical pixels for `scale_factor`. +#[inline] +pub fn content_pad_x(scale_factor: f32) -> f32 { + (CONTENT_PAD_X_LOGICAL * scale_factor).round() +} + +/// Width of the left tab sidebar when expanded, in logical pixels. Chosen to +/// match the reference design — wide enough for a directory-ish label plus a +/// close affordance, narrow enough to leave the grid roomy. +pub const SIDEBAR_W_LOGICAL: f32 = 230.0; + +/// Sidebar width in physical pixels for `scale_factor`, honouring the collapsed +/// state. Collapsed folds the panel away entirely (0) so the grid reclaims the +/// full width; the reveal affordance then lives in the top bar. +#[inline] +pub fn sidebar_width(scale_factor: f32, collapsed: bool) -> f32 { + if collapsed { 0.0 } else { (SIDEBAR_W_LOGICAL * scale_factor).round() } +} + +#[derive(Debug, Clone, Copy)] +struct UiAnim { + spring: crate::motion::Spring, +} + +impl UiAnim { + fn new(value: f32) -> Self { + Self { spring: crate::motion::Spring::new(value.clamp(0.0, 1.0)).with_response(0.14) } + } + + fn value(self) -> f32 { + self.spring.value().clamp(0.0, 1.0) + } + + fn visible(self, target_open: bool) -> bool { + target_open || self.value() > 0.004 + } + + fn animating_to(self, target: f32) -> bool { + (self.value() - target.clamp(0.0, 1.0)).abs() > 0.004 || self.spring.is_active() + } + + fn step(&mut self, frame: crate::motion::Frame, target: f32) { + self.spring.set_target(target.clamp(0.0, 1.0), crate::motion::MotionPolicy::Full); + self.spring.step(frame); + } +} + +#[derive(Debug, Clone)] +struct NebulaUiAnims { + clock: crate::motion::MotionClock, + frame: Option, + /// Continuous sidebar-spinner phase in turns (`0.0..1.0`). Advancing it + /// from the shared monotonic frame delta avoids wall-clock jumps and needs + /// only four bytes per window. + spinner_phase: f32, + left_sidebar: UiAnim, + right_drawer: UiAnim, + ssh_editor: UiAnim, +} + +impl NebulaUiAnims { + fn new() -> Self { + Self { + clock: crate::motion::MotionClock::default(), + frame: None, + spinner_phase: 0.0, + left_sidebar: UiAnim::new(1.0), + right_drawer: UiAnim::new(0.0), + ssh_editor: UiAnim::new(0.0), + } + } + + fn step(&mut self, left_open: bool, right_open: bool, ssh_open: bool) { + let frame = self.clock.tick(); + self.frame = Some(frame); + self.left_sidebar.step(frame, if left_open { 1.0 } else { 0.0 }); + self.right_drawer.step(frame, if right_open { 1.0 } else { 0.0 }); + self.ssh_editor.step(frame, if ssh_open { 1.0 } else { 0.0 }); + } + + fn frame(&mut self) -> crate::motion::Frame { + if let Some(frame) = self.frame { + frame + } else { + let frame = self.clock.tick(); + self.frame = Some(frame); + frame + } + } + + fn animating(&self, left_open: bool, right_open: bool) -> bool { + self.left_sidebar.animating_to(if left_open { 1.0 } else { 0.0 }) + || self.right_drawer.animating_to(if right_open { 1.0 } else { 0.0 }) + } +} + +#[derive(Debug, Clone, Copy)] +struct ResizeHud { + columns: usize, + rows: usize, + opacity: crate::motion::Tween, +} + +impl ResizeHud { + fn new(columns: usize, rows: usize) -> Self { + let mut opacity = crate::motion::Tween::new(1.0); + opacity.animate_to( + 0.0, + Duration::from_millis(900), + crate::motion::Easing::Linear, + crate::motion::MotionPolicy::Full, + ); + Self { columns, rows, opacity } + } +} + +#[derive(Debug, Clone, Copy)] +pub struct SplitReveal { + rect: (f32, f32, f32, f32), + direction: SplitDirection, + motion: crate::motion::Tween, +} + +impl SplitReveal { + pub fn new(rect: (f32, f32, f32, f32), direction: SplitDirection) -> Self { + let mut motion = crate::motion::Tween::new(0.0); + motion.animate_role( + 1.0, + crate::motion::MotionRole::Enter, + crate::motion::MotionPolicy::Full, + ); + Self { rect, direction, motion } + } +} + +#[derive(Debug, Clone, Copy)] +enum NebulaPowerlineIconKind { + Folder, + GitBranch, +} + +#[derive(Debug, Clone, Copy)] +struct NebulaPowerlineIcon { + kind: NebulaPowerlineIconKind, + point: Point, +} + +/// Sidebar SSH HOSTS content: auto-saved destinations (most recent first) + +/// `~/.ssh/config` aliases (file order), deduped, pinned entries floated to +/// the top in pin order. One function so startup and settings hot-reload +/// build the exact same list. +fn merge_ssh_hosts(saved: &[String], pinned: &[String], hidden: &[String]) -> Vec { + let mut hosts: Vec<_> = saved.iter().filter(|host| !hidden.contains(host)).cloned().collect(); + for host in crate::ssh::ssh_config_hosts() { + if !hidden.contains(&host) && !hosts.contains(&host) { + hosts.push(host); + } + } + // Stable sort: pinned first in pin order, the rest keep saved→config order. + hosts.sort_by_key(|h| pinned.iter().position(|p| p == h).unwrap_or(usize::MAX)); + hosts +} + +/// Remove one destination while recording exactly enough list state for Undo. +/// Kept independent from rendering and Credential Manager so the destructive +/// state transition can be regression-tested without touching real secrets. +fn remove_ssh_host_from_lists( + host: &str, + saved: &mut Vec, + pinned: &mut Vec, + hidden: &mut Vec, +) -> (Option, Option, bool) { + let saved_index = saved.iter().position(|entry| entry == host); + let pinned_index = pinned.iter().position(|entry| entry == host); + let was_hidden = hidden.iter().any(|entry| entry == host); + saved.retain(|entry| entry != host); + pinned.retain(|entry| entry != host); + if !was_hidden { + hidden.push(host.to_owned()); + } + (saved_index, pinned_index, was_hidden) +} + +fn restore_ssh_host_to_lists( + host: &str, + saved_index: Option, + pinned_index: Option, + was_hidden: bool, + saved: &mut Vec, + pinned: &mut Vec, + hidden: &mut Vec, +) { + saved.retain(|entry| entry != host); + if let Some(index) = saved_index { + saved.insert(index.min(saved.len()), host.to_owned()); + } + pinned.retain(|entry| entry != host); + if let Some(index) = pinned_index { + pinned.insert(index.min(pinned.len()), host.to_owned()); + } + if !was_hidden { + hidden.retain(|entry| entry != host); + } +} + +/// First word of a committed command line, normalized to a program identity +/// for the sidebar icon: lowercased, path prefix and Windows launcher +/// extensions stripped. `D:\tools\Claude.EXE --resume` → `claude`. +pub(crate) fn extract_program(line: &str) -> Option { + let first = line.trim().split_whitespace().next()?; + let base = first.trim_matches('"').rsplit(['/', '\\']).next().unwrap_or(first); + let mut name = base.to_ascii_lowercase(); + for ext in [".exe", ".cmd", ".bat", ".ps1", ".com"] { + if let Some(stripped) = name.strip_suffix(ext) { + name = stripped.to_owned(); + break; + } + } + (!name.is_empty()).then_some(name) +} + +/// Log replay commands can contain terminal query sequences captured from a +/// different process. Replying writes those answers into the shell's stdin, +/// where they become the next command after the replay process exits. +pub(crate) fn replays_untrusted_terminal_output(line: &str) -> bool { + let words: Vec = line + .split_whitespace() + .take(4) + .map(|word| word.trim_matches(['"', '\'']).to_ascii_lowercase()) + .collect(); + matches!( + words.as_slice(), + [docker, logs, ..] if docker == "docker" && logs == "logs" + ) || matches!( + words.as_slice(), + [docker, compose, logs, ..] + if docker == "docker" && compose == "compose" && logs == "logs" + ) || matches!( + words.as_slice(), + [podman, logs, ..] if podman == "podman" && logs == "logs" + ) || matches!( + words.as_slice(), + [kubectl, logs, ..] if kubectl == "kubectl" && logs == "logs" + ) || matches!(words.as_slice(), [journalctl, ..] if journalctl == "journalctl") +} + +/// Sidebar icon for a running program — Nerd Font glyphs (the chrome text +/// layer renders with Maple NF). AI CLIs get distinct marks; everything else +/// shares a generic "running" play sign. +/// AI clients whose REAL brand mark is drawn in the sidebar as a textured +/// quad (embedded PNG), instead of a lookalike Nerd Font glyph. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum AiLogo { + /// Anthropic's coral starburst. + Claude, + /// OpenAI's blossom knot (codex). + OpenAi, + /// opencode's terminal-frame mark (sst). Two-tone: bright frame + dimmer + /// inner screen block, encoded as luma and multiplied by the theme ink. + OpenCode, + /// Pi's block-built `pi` mark from pi.dev. + Pi, +} + +/// Official logo assets (Wikimedia SVG renders, 64 px, alpha-transparent). +const AI_LOGO_CLAUDE_PNG: &[u8] = include_bytes!("../../../extra/logo/ai_claude.png"); +const AI_LOGO_OPENAI_PNG: &[u8] = include_bytes!("../../../extra/logo/ai_openai.png"); +/// opencode's mark, rasterized from their `favicon.svg`. RGB carries luma +/// (frame=255, block=90), alpha the shape; tinted `ink × luma/255` at runtime. +const AI_LOGO_OPENCODE_PNG: &[u8] = include_bytes!("../../../extra/logo/ai_opencode.png"); +const AI_LOGO_PI_PNG: &[u8] = include_bytes!("../../../extra/logo/ai_pi.png"); + +/// Texture ids for chrome logos live far above the inline-image counter +/// (which starts at 1), so the two id spaces can share the renderer cache. +const AI_LOGO_ID_BASE: u64 = 1 << 62; + +/// Real-logo mapping for AI clients; everything else falls back to the +/// [`program_icon`] glyph. Gated on PNG support: without it there is no +/// texture to draw and the glyph must stay. +pub(crate) fn ai_logo(program: &str) -> Option { + if cfg!(any(not(feature = "png"), target_os = "macos")) { + return None; + } + match program { + "claude" => Some(AiLogo::Claude), + "codex" => Some(AiLogo::OpenAi), + "opencode" => Some(AiLogo::OpenCode), + "pi" => Some(AiLogo::Pi), + _ => None, + } +} + +/// Drop a `file://` / `file:///` scheme so a local link reads as a plain +/// path in the hover tooltip. On Windows `file:///D:/x` → `D:/x` (the slash +/// before the drive letter goes too); non-`file:` URIs pass through. +/// +/// 2026-07-26 用户反馈:`ls --hyperlink` 会把非 ASCII 文件名 percent-encode +/// (一个汉字 → `%E6%98%9F` 九列),48 列预算被编码噪声吃光后再截尾,提示 +/// "显示不全"。file 路径解码后再展示;其他 scheme 保持原样(URL 的编码 +/// 属于其身份,不替它翻译)。 +fn strip_file_scheme(uri: &str) -> String { + match uri.strip_prefix("file:///").or_else(|| uri.strip_prefix("file://")) { + // `file:///D:/x` yields `D:/x`; a UNC-ish `file://host/x` keeps `host/x`. + Some(rest) => percent_decode_lossy(rest), + None => uri.to_owned(), + } +} + +/// Decode `%XX` escapes as UTF-8 bytes for DISPLAY. Malformed escapes pass +/// through verbatim; if the decoded bytes are not valid UTF-8 the original +/// string is returned unchanged — a tooltip must never invent mojibake. +fn percent_decode_lossy(s: &str) -> String { + if !s.contains('%') { + return s.to_owned(); + } + let bytes = s.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut i = 0; + while i < bytes.len() { + let decoded = (bytes[i] == b'%' && i + 2 < bytes.len()) + .then(|| { + let hi = (bytes[i + 1] as char).to_digit(16)?; + let lo = (bytes[i + 2] as char).to_digit(16)?; + Some((hi * 16 + lo) as u8) + }) + .flatten(); + match decoded { + Some(byte) => { + out.push(byte); + i += 3; + }, + None => { + out.push(bytes[i]); + i += 1; + }, + } + } + String::from_utf8(out).unwrap_or_else(|_| s.to_owned()) +} + +/// Truncate `s` to at most `budget` display columns (CJK counts as 2), keeping +/// the TAIL and prefixing `…` when cut — the filename end is what a hover +/// tooltip most needs. Returns a string whose display width is `<= budget`. +fn fit_tail(s: &str, budget: usize) -> String { + let width = |c: char| c.width().unwrap_or(0); + let total: usize = s.chars().map(width).sum(); + if total <= budget { + return s.to_owned(); + } + if budget == 0 { + return String::new(); + } + // Reserve one column for the ellipsis, then take chars from the end until + // the reserved room fills up. + let room = budget.saturating_sub(1); + let mut kept = std::collections::VecDeque::new(); + let mut used = 0; + for c in s.chars().rev() { + let w = width(c); + if used + w > room { + break; + } + used += w; + kept.push_front(c); + } + let mut out = String::with_capacity(kept.len() + 1); + out.push('…'); + out.extend(kept); + out +} + +pub(crate) fn program_icon(program: &str) -> &'static str { + match program { + "claude" => "\u{f0ce5}", // md-star-four-points (Claude spark) + "codex" => "\u{f02d8}", // md-hexagon (OpenAI mark) + "gemini" => "\u{f0ce6}", // md-star-four-points-outline + "copilot" => "\u{f4b8}", // oct-copilot + "cursor" | "cursor-agent" => "\u{f0ec3}", // md-cursor-default-outline + "aider" | "goose" | "crush" | "ollama" => "\u{f06a9}", // md-robot + "opencode" => "\u{f489}", // oct-terminal + "pi" => "\u{f135}", // fa-code + "git" | "gh" | "lazygit" => "\u{f418}", // oct-git-branch + "vim" | "nvim" | "vi" | "hx" | "nano" => "\u{e62b}", // custom-vim + "ssh" | "mosh" => "\u{f489}", // oct-terminal (remote) + "cargo" | "rustc" => "\u{e7a8}", // dev-rust + "node" | "npm" | "pnpm" | "yarn" | "bun" | "deno" => "\u{e718}", // dev-nodejs + "python" | "python3" | "pip" | "uv" => "\u{e73c}", // dev-python + "docker" | "podman" => "\u{e7b0}", // dev-docker + _ => "\u{f04b}", // fa-play (generic busy) + } +} + +/// The UI font role (wezterm's `window_frame` pattern): the size chrome +/// typography rasterizes at and the cell chrome layout steps by. Anchored to +/// the config font at the window's DPI — never to the terminal zoom. Stage 3 +/// exposes family/size as user config. +#[derive(Clone, Copy, Debug)] +struct NebulaUiFont { + /// Role size in physical px (config size × DPI scale). + px: f32, + /// Cell the chrome layout steps by, from the role's REAL rasterized + /// metrics (`compute_cell_size` over `GlyphCache::set_ui_font_size`). + cell: (f32, f32), +} + +pub(crate) fn nebula_debug_log(message: impl AsRef) { + use std::io::Write as _; + + static ENABLED: std::sync::OnceLock = std::sync::OnceLock::new(); + if !*ENABLED.get_or_init(|| { + std::env::var("NEBULA_DEBUG_LOG").is_ok_and(|value| { + let value = value.trim(); + !value.is_empty() && value != "0" && !value.eq_ignore_ascii_case("false") + }) + }) { + return; + } + + let ts = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| format!("{}.{:03}", d.as_secs(), d.subsec_millis())) + .unwrap_or_else(|_| "0.000".to_owned()); + let path = nebula_data_dir().join("nebula_debug.log"); + if let Ok(mut file) = std::fs::OpenOptions::new().create(true).append(true).open(path) { + let _ = writeln!(file, "[{ts} pid={}] {}", std::process::id(), message.as_ref()); + } +} + +/// Unconditional variant of [`nebula_debug_log`] for the link-click diagnosis: +/// clicks are rare (no perf concern), and requiring a relaunch with +/// NEBULA_DEBUG_LOG=1 would double every remote-debug round-trip. Remove or +/// downgrade to the gated logger once the link path is verified. +pub(crate) fn nebula_link_log(message: impl AsRef) { + use std::io::Write as _; + + let ts = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| format!("{}.{:03}", d.as_secs(), d.subsec_millis())) + .unwrap_or_else(|_| "0.000".to_owned()); + let path = nebula_data_dir().join("nebula_debug.log"); + if let Ok(mut file) = std::fs::OpenOptions::new().create(true).append(true).open(path) { + let _ = writeln!(file, "[{ts}] {}", message.as_ref()); + } +} + +/// Directory holding Nebula's persistent state (`%APPDATA%\Nebula` or +/// `~/.config/Nebula`), created on demand. Settings live here next to the +/// history file managed by [`crate::nebula_history`] and the session +/// snapshot managed by [`crate::session`]. +pub(crate) fn nebula_data_dir() -> PathBuf { + let base = std::env::var_os("APPDATA") + .map(PathBuf::from) + .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config"))) + .unwrap_or_else(std::env::temp_dir); + let dir = base.join("Nebula"); + let _ = std::fs::create_dir_all(&dir); + dir +} + +/// Read one raw `key=value` from `nebula_settings.txt` (case-insensitive key). +/// The typed loader is `settings::nebula_settings_load`; this is for the few +/// callers (e.g. the default-shell id) that want the raw string verbatim. +pub(crate) fn nebula_settings_value(key: &str) -> Option { + let data = std::fs::read_to_string(nebula_data_dir().join("nebula_settings.txt")).ok()?; + data.lines().find_map(|line| { + let (k, v) = line.split_once('=')?; + k.trim().eq_ignore_ascii_case(key).then(|| v.trim().to_owned()) + }) +} + +#[cfg(windows)] +fn nebula_pathexts() -> Vec { + std::env::var("PATHEXT") + .unwrap_or_else(|_| ".COM;.EXE;.BAT;.CMD;.PS1".to_owned()) + .split(';') + .filter_map(|ext| { + let ext = ext.trim(); + if ext.is_empty() { + None + } else if ext.starts_with('.') { + Some(ext.to_ascii_lowercase()) + } else { + Some(format!(".{}", ext.to_ascii_lowercase())) + } + }) + .collect() +} + +#[cfg(windows)] +fn nebula_command_name(path: &Path, pathexts: &[String]) -> Option { + let ext = path.extension()?.to_str()?; + let ext = format!(".{}", ext).to_ascii_lowercase(); + if !pathexts.iter().any(|known| known == &ext) { + return None; + } + path.file_stem()?.to_str().filter(|name| !name.is_empty()).map(ToOwned::to_owned) +} + +#[cfg(not(windows))] +fn nebula_command_name(path: &Path) -> Option { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if path.metadata().ok()?.permissions().mode() & 0o111 == 0 { + return None; + } + } + path.file_name()?.to_str().filter(|name| !name.is_empty()).map(ToOwned::to_owned) +} + +fn nebula_path_commands() -> Vec { + let Some(path_env) = std::env::var_os("PATH") else { + return Vec::new(); + }; + + let mut commands = Vec::new(); + let mut seen = HashSet::new(); + #[cfg(windows)] + let pathexts = nebula_pathexts(); + + for dir in std::env::split_paths(&path_env).filter(|dir| !dir.as_os_str().is_empty()) { + let Ok(entries) = std::fs::read_dir(dir) else { + continue; + }; + + for entry in entries.flatten() { + let Ok(file_type) = entry.file_type() else { + continue; + }; + if !file_type.is_file() { + continue; + } + + #[cfg(windows)] + let command = nebula_command_name(&entry.path(), &pathexts); + #[cfg(not(windows))] + let command = nebula_command_name(&entry.path()); + + if let Some(command) = command { + #[cfg(windows)] + let key = command.to_ascii_lowercase(); + #[cfg(not(windows))] + let key = command.clone(); + + // PATH 里同名 shim/真实可执行文件经常重复;这里只保留第一个, + // 避免每次输入首 token 时 ghost 在等价候选间跳动。 + if seen.insert(key) { + commands.push(command); + } + } + } + } + + commands.sort_by(|a, b| { + #[cfg(windows)] + { + a.to_ascii_lowercase().cmp(&b.to_ascii_lowercase()).then(a.cmp(b)) + } + #[cfg(not(windows))] + { + a.cmp(b) + } + }); + commands +} + +/// Collect external-shell command names: PATH executables plus, on Windows, the +/// running shell's cmdlets / functions / aliases (which never appear on PATH). +/// Merged and de-duplicated (case-insensitively on Windows). +fn nebula_collect_commands() -> Vec { + let mut commands = nebula_path_commands(); + + #[cfg(windows)] + { + let mut seen: HashSet = commands.iter().map(|c| c.to_ascii_lowercase()).collect(); + for command in nebula_powershell_commands() { + if seen.insert(command.to_ascii_lowercase()) { + commands.push(command); + } + } + commands.sort_by(|a, b| a.to_ascii_lowercase().cmp(&b.to_ascii_lowercase()).then(a.cmp(b))); + } + + commands +} + +/// PowerShell cmdlets / functions / aliases via a one-shot `Get-Command`, run +/// with `-NoProfile` to avoid the user's profile cost. Best-effort: any failure +/// (no PowerShell, error, parse problem) yields an empty list. +#[cfg(windows)] +fn nebula_powershell_commands() -> Vec { + // CREATE_NO_WINDOW: Nebula is a GUI-subsystem process, so a console child + // would otherwise pop up (and instantly vanish) a visible PowerShell + // window at startup. + use std::os::windows::process::CommandExt; + let output = std::process::Command::new("powershell") + .args([ + "-NoProfile", + "-NonInteractive", + "-Command", + "Get-Command -CommandType Cmdlet,Function,Alias -ErrorAction SilentlyContinue \ + | Select-Object -ExpandProperty Name", + ]) + .creation_flags(windows_sys::Win32::System::Threading::CREATE_NO_WINDOW) + .output(); + + match output { + Ok(out) if out.status.success() => String::from_utf8_lossy(&out.stdout) + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .map(ToOwned::to_owned) + .collect(), + _ => Vec::new(), + } +} + +/// Process-wide handle to the command list, populated once on a background +/// thread so the (PowerShell-invoking) collection never blocks window startup. +/// Readers see an empty list until collection finishes, then the merged set. +fn nebula_commands_handle() -> std::sync::Arc>> { + static COMMANDS: std::sync::OnceLock>>> = + std::sync::OnceLock::new(); + COMMANDS + .get_or_init(|| { + let shared = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let bg = shared.clone(); + std::thread::spawn(move || { + let commands = nebula_collect_commands(); + if let Ok(mut guard) = bg.lock() { + *guard = commands; + } + }); + shared + }) + .clone() +} + +fn nebula_command_hint<'a>(commands: &'a [String], prefix: &str) -> Option<&'a str> { + if prefix.is_empty() { + return None; + } + + // 完整命令已经可执行时必须停止补全;否则 `claude` 会跳过自身,继续 + // 命中 `claude-agent-acp` 这类更长的 PATH 邻居。 + #[cfg(windows)] + let exact = commands.iter().any(|command| command.eq_ignore_ascii_case(prefix)); + #[cfg(not(windows))] + let exact = commands.iter().any(|command| command == prefix); + if exact { + return None; + } + + commands.iter().find_map(|command| { + if command.len() <= prefix.len() || !command.is_char_boundary(prefix.len()) { + return None; + } + let (head, rem) = command.split_at(prefix.len()); + #[cfg(windows)] + let matches = head.eq_ignore_ascii_case(prefix); + #[cfg(not(windows))] + let matches = head == prefix; + + matches.then_some(rem) + }) +} + +fn nebula_is_command_position(line: &str) -> bool { + !line.contains([' ', '\t']) + && !line.contains(['/', '\\']) + && line.as_bytes().get(1) != Some(&b':') +} + +fn nebula_path_wants_directory(line: &str) -> bool { + let command = line.split([' ', '\t']).next().unwrap_or(""); + matches!( + command.to_ascii_lowercase().as_str(), + "cd" | "chdir" | "pushd" | "sl" | "set-location" + ) +} + +#[derive(Debug)] +pub enum Error { + /// Error with window management. + Window(window::Error), + + /// Error dealing with fonts. + Font(crossfont::Error), + + /// Error in renderer. + Render(renderer::Error), + + /// Error during context operations. + Context(glutin::error::Error), +} + +impl std::error::Error for Error { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Error::Window(err) => err.source(), + Error::Font(err) => err.source(), + Error::Render(err) => err.source(), + Error::Context(err) => err.source(), + } + } +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + match self { + Error::Window(err) => err.fmt(f), + Error::Font(err) => err.fmt(f), + Error::Render(err) => err.fmt(f), + Error::Context(err) => err.fmt(f), + } + } +} + +impl From for Error { + fn from(val: window::Error) -> Self { + Error::Window(val) + } +} + +impl From for Error { + fn from(val: crossfont::Error) -> Self { + Error::Font(val) + } +} + +impl From for Error { + fn from(val: renderer::Error) -> Self { + Error::Render(val) + } +} + +impl From for Error { + fn from(val: glutin::error::Error) -> Self { + Error::Context(val) + } +} + +#[derive(Default, Clone, Debug, PartialEq, Eq)] +pub struct DisplayUpdate { + pub dirty: bool, + + dimensions: Option>, + cursor_dirty: bool, + font: Option, + terminal_colors_dirty: bool, +} + +impl DisplayUpdate { + pub fn dimensions(&self) -> Option> { + self.dimensions + } + + pub fn font(&self) -> Option<&Font> { + self.font.as_ref() + } + + pub fn cursor_dirty(&self) -> bool { + self.cursor_dirty + } + + pub fn terminal_colors_dirty(&self) -> bool { + self.terminal_colors_dirty + } + + pub fn set_dimensions(&mut self, dimensions: PhysicalSize) { + self.dimensions = Some(dimensions); + self.dirty = true; + } + + pub fn set_font(&mut self, font: Font) { + self.font = Some(font); + self.dirty = true; + } + + pub fn set_cursor_dirty(&mut self) { + self.cursor_dirty = true; + self.dirty = true; + } + + fn set_terminal_colors_dirty(&mut self) { + self.terminal_colors_dirty = true; + self.dirty = true; + } +} + +/// The display wraps a window, font rasterizer, and GPU renderer. +pub struct Display { + pub window: Window, + + pub size_info: SizeInfo, + + /// Hint highlighted by the mouse. + pub highlighted_hint: Option, + /// Frames since hint highlight was created. + highlighted_hint_age: usize, + + /// Hint highlighted by the vi mode cursor. + pub vi_highlighted_hint: Option, + /// Frames since hint highlight was created. + vi_highlighted_hint_age: usize, + + pub raw_window_handle: RawWindowHandle, + + /// UI cursor visibility for blinking. + pub cursor_hidden: bool, + + /// When a split is active, the focused pane's geometry. Input and hint + /// hit-testing use this (via `pane_view()`) so mouse coordinates map into + /// the focused half-width grid rather than the full window, which would + /// otherwise index past the grid and panic. + pub nebula_pane_view: Option, + + /// Transient "cols × rows" HUD shown briefly after a window resize; it fades + /// out over ~0.9s. `None` when nothing is showing. + nebula_resize_hud: Option, + /// Skip the first resize (window creation) so no HUD flashes at startup. + nebula_resize_hud_armed: bool, + + /// Indexed, persistent command history used to hint a whole previous + /// command from its prefix. + nebula_history: crate::nebula_history::NebulaHistory, + /// Process-wide frecency model fed only by successful shell cwd reports. + directory_history: crate::directory_history::DirectoryHistory, + /// Executable commands for first-token completion: PATH executables plus, on + /// Windows, the shell's cmdlets/functions/aliases. Filled on a background + /// thread so the PowerShell probe never blocks startup. + nebula_commands: std::sync::Arc>>, + /// Per-displayed-tab animated draw-x, eased toward the laid-out / drag + /// target each frame so tab reorder "make way" slides instead of snapping. + nebula_tab_anim: Vec, + /// Active scrollbar drag: the pointer's y-offset inside the thumb captured + /// at press time, so the thumb tracks the pointer without jumping. + pub nebula_scrollbar_drag: Option, + /// Slide-in reveal for a freshly created split pane: its final rect, the + /// split direction and the animation start time. Drawn as a shrinking + /// bg-coloured cover in `draw_split_overlays`; cleared when done. + pub nebula_split_reveal: Option, + /// Pending destructive-action confirmation (close with busy children / + /// multi-line paste), drawn as a centered modal that owns the keyboard. + pub nebula_confirm: Option, + /// Screen rects of the confirm modal's (primary, cancel) buttons, written + /// by `draw_confirm_modal` each frame so the mouse hit-test can never + /// drift from what was actually drawn. `None` while no modal shows. + pub nebula_confirm_buttons: Option<((f32, f32, f32, f32), (f32, f32, f32, f32))>, + /// Most recently deleted SSH host while its action is still reversible. + nebula_ssh_delete_undo: Option, + /// 焦点 pane 的助手建议条快照(spec 001):每帧由 WindowContext 从 + /// `NebulaPaneState::ai_fix` 同步,绘制层只认自己的字段(撤销条同款)。 + pub nebula_ai_fix_bar: Option, + /// Undo button geometry published by the draw pass for exact hit-testing. + nebula_ssh_delete_undo_rect: Option<(f32, f32, f32, f32)>, + nebula_ssh_delete_undo_hover: bool, + pub nebula_ssh_editor: Option, + pub nebula_ssh_editor_rects: Option, + nebula_ssh_editor_open: bool, + nebula_ssh_editor_hover: SshEditorHit, + /// 「测试连接」点击时暂存的请求;input 层随后取走并交给 SSH runtime。 + /// display 不持有事件代理,这一格就是点击→网络之间的交接台。 + nebula_ssh_test_request: Option, + /// Monotonic identity for SSH editor test requests. Results must match the + /// exact attempt, not merely a destination that a user can edit back to. + nebula_ssh_test_seq: u64, + /// Inline images visible this frame, collected per pane during + /// `draw_pane` (grid lock + pane viewport at hand) and drawn in one + /// full-window pass in `present_frame` — mid-pane GL viewport swaps are + /// fragile, one batched pass is not. + nebula_frame_images: Vec<(u64, std::sync::Arc>, (u32, u32), (f32, f32, f32, f32))>, + + /// Theme currently painted. In automatic mode this is the light/dark + /// member resolved from `nebula_theme_preference` and the system state. + pub nebula_theme: NebulaTheme, + /// Theme family explicitly selected by the user and written to settings. + /// Kept separate from the painted theme so an automatic light switch does + /// not forget which dark theme to restore later. + nebula_theme_preference: NebulaTheme, + pub nebula_follow_system_theme: bool, + nebula_system_theme: Option, + /// User-configured winit decoration override. Automatic Nebula theming + /// temporarily clears it because winit only emits `ThemeChanged` while a + /// window is following the operating system. + nebula_window_theme_override: Option, + pub nebula_settings_open: bool, + pub nebula_special_tab_active: bool, + nebula_language_preference: LanguagePreference, + nebula_language: UiLanguage, + /// Paths from the last successful app configuration generation. + nebula_config_paths: Vec, + /// Settings content scroll offset in scaled px (0 = top of the section). + nebula_settings_scroll: f32, + /// Command palette (Ctrl+Shift+P): fuzzy launcher model + UI state. + nebula_palette: command_palette::CommandPalette, + /// Installed shells, detected once (registry + filesystem scan) and cached + /// for the new-tab dropdown. `None` until the first menu open. + nebula_detected_shells: Option>, + /// Right-side drawer: directory tree / git status of the focused cwd. + pub nebula_side_panel: side_panel::SidePanel, + /// Remote file drawer opened from an SSH destination context menu. + pub nebula_sftp_panel: Option, + /// Shared chrome animation state. All sidebar/drawer transitions step here + /// so easing/timing does not get scattered across render code. + nebula_ui_anims: NebulaUiAnims, + /// Active sidebar section inside the settings panel. + nebula_settings_section: NebulaSettingsSection, + nebula_chrome_hover: ChromeHit, + /// Bottom-docked queue affordance. The entry state lives separately from + /// Tabs/SSH so real Agent events can be connected without changing chrome + /// geometry or input contracts again. + nebula_message_queue_entry: message_queue_entry::MessageQueueEntry, + nebula_settings_hover: SettingsHit, + /// Active settings opacity drag: target plus the screen-space track used + /// for pointer-to-value mapping. Values persist only when the drag ends. + pub nebula_settings_opacity_drag: Option<(settings::SettingsOpacityTarget, f32, f32)>, + /// Unified native right-click menu shared by tab and SSH rows. The menu + /// owns its short open/close animation so no input path needs timers. + nebula_context_menu: Option, + nebula_tab_labels: Vec, + /// Per-tab custom accent. `None` follows the live theme accent. + nebula_tab_colors: Vec>, + nebula_tab_bells: Vec, + /// Per-tab "command is running" flags driving the sidebar spinners. + nebula_tab_running: Vec, + /// Per-tab real AI brand logo (claude/codex), textured over the icon slot. + nebula_tab_logos: Vec>, + /// Decoded (+theme-tinted) logo pixels with stable renderer texture ids, + /// keyed by (logo, ink). Decode and tint run once per key. + nebula_ai_logo_cache: + std::collections::HashMap<(AiLogo, [u8; 3]), (u64, std::sync::Arc>, (u32, u32))>, + /// Decoded shell icons (full-color PNGs) with stable texture ids, keyed by + /// shell id (pwsh/cmd/nu/wsl:Ubuntu). Decode runs once per id. + nebula_shell_icon_cache: + std::collections::HashMap>, (u32, u32))>, + /// Brand logos staged by the chrome pass, drawn AFTER all chrome text. + /// draw_inline_image flips viewport/blend around its draw; interleaving + /// it with chrome text kills every glyph batch after it, so the textured + /// icons get their own pass at the very end of the frame. + nebula_chrome_logo_draws: Vec<(u64, std::sync::Arc>, (u32, u32), (f32, f32, f32, f32))>, + nebula_active_tab: usize, + /// In-progress tab reorder drag, if the pointer is grabbing a tab. + nebula_tab_drag: Option, + /// Whether the tab bar may be reordered right now (false during a split, + /// where the bar hides a pane and reordering is ambiguous). + nebula_tabs_reorderable: bool, + /// Whether the tab sidebar is folded away. When collapsed the grid + /// reclaims the full width and only a reveal button remains in the top bar. + nebula_sidebar_collapsed: bool, + /// SSH host aliases from `~/.ssh/config` for the sidebar's "SSH HOSTS" + /// section, pinned entries first (see `nebula_pinned_hosts`). + pub nebula_ssh_hosts: Vec, + /// Host names the user pinned to the top (right-click), persisted in the + /// runtime settings file so the order survives restarts. + nebula_pinned_hosts: Vec, + /// Destinations auto-saved from typed `ssh` commands once the connection + /// confirmed (see `NebulaPaneState::pending_ssh_host`), most recent + /// first, persisted. Merged into `nebula_ssh_hosts` after the pinned + /// block, before the `~/.ssh/config` aliases. + nebula_saved_hosts: Vec, + /// User-deleted SSH config aliases. Config files remain untouched; hiding + /// them here makes Delete stable instead of letting the next merge revive + /// the row immediately. + nebula_hidden_hosts: Vec, + /// Accordion fold state of the two sidebar sections. + nebula_tabs_section_open: bool, + nebula_hosts_section_open: bool, + /// Per-section scroll offsets, in whole rows (clamped by the layout). + nebula_tabs_scroll: usize, + nebula_hosts_scroll: usize, + /// A grid resize happened whose PTY notification is deferred until the + /// interactive resize settles (see `Topic::NebulaResizeSettle`): the + /// in-box ConPTY repaints the whole viewport per resize, so notifying it + /// on every drag tick floods the scrollback with shredded repaints. + pub nebula_pty_resize_pending: bool, + /// Whether inline ghost-text suggestions are shown at all. + pub nebula_ghost_enabled: bool, + /// Which key accepts a ghost suggestion. + pub nebula_accept: AcceptKey, + /// Default executor used by new sessions when no explicit shell is configured. + pub nebula_shell: NebulaShell, + /// Raw default-shell id when the user picked a detected shell the 2-value + /// `nebula_shell` enum can't represent (cmd/pwsh/nu/wsl:X). Drives the + /// settings row label and is persisted verbatim. + pub nebula_shell_id: Option, + /// User-selected working directory for newly created terminal tabs. + pub nebula_startup_directory: Option, + /// Whether new sessions print the Nebula welcome/fetch screen. + pub nebula_fetch_enabled: bool, + /// Whether the injected prompt uses Nebula's powerline segments. + pub nebula_powerline_enabled: bool, + /// Closing a window detaches its panes into the resident process for + /// re-attach (multiplexer restore). Off = close kills the shells. + pub nebula_keep_session: bool, + /// Runtime window opacity controlled from Nebula settings. + pub nebula_window_opacity: f32, + /// Which settings combobox (floating option list) is expanded, if any. + /// One field for every dropdown: shell, font, wallpaper fit/alignment, + /// language, accept key and cursor shape all share the widget. + pub nebula_settings_dropdown: Option, + /// Default cursor shape/blink from settings. Programs may still override + /// the shape with DECSCUSR escapes (vim's mode cursor keeps working). + pub nebula_cursor_shape: CursorShape, + pub nebula_cursor_blink: bool, + /// 交互: WT-style copyOnSelect. Off = right-click copies / pastes instead. + pub nebula_copy_on_select: bool, + /// 全宽字形(CJK 等)bold run 用 Regular 字形(粗体提亮不加粗,#4)。 + pub nebula_cjk_bold_regular: bool, + /// User keybinding overrides, raw `(combo, action)` from + /// `nebula_settings.txt` in file order (persisted verbatim). + pub(crate) nebula_keybinds: Vec<(String, String)>, + /// 快速终端全局快捷键的持久值;系统注册由顶层 Processor 负责。 + pub nebula_quick_terminal_hotkey: String, + /// 等待 Processor 确认注册的新值。设置页不会绕过全局管理器自行假设成功。 + pub(crate) nebula_quick_hotkey_request: Option, + pub(crate) nebula_quick_hotkey_error: Option, + /// Parsed override table (newest-first); consulted by + /// `process_key_bindings` BEFORE the config table (spec 002). + pub nebula_keymap: Vec, + /// When `Some(row)`, the Keymap settings page is capturing a new combo + /// for `keymap::EDITABLE_ACTIONS[row]` and the keyboard is owned by it. + pub nebula_keymap_capture: Option, + /// 捕获态实时回显:当前按住的修饰键前缀("Ctrl+Shift+")。松开清空。 + pub nebula_keymap_capture_preview: String, + pub nebula_font_family: String, + nebula_font_families: Vec, + nebula_font_notice: Option, + /// Optional runtime clear/background color controlled from settings. + pub nebula_background: Option, + /// Optional background image path drawn as a full-window wallpaper. + pub nebula_background_image: Option, + /// Wallpaper alpha, separate from the window opacity to preserve text contrast. + pub nebula_background_image_opacity: f32, + /// Windows Terminal-compatible wallpaper sizing and anchor settings. + pub nebula_background_image_fit: BackgroundImageFit, + pub nebula_background_image_alignment: BackgroundImageAlignment, + /// Off by default: wallpapers stay inside terminal content. Enabling this + /// requires an explicit warning confirmation because it reduces chrome contrast. + pub nebula_background_image_cover_chrome: bool, + nebula_settings_mtime: Option, + nebula_bg_palette_index: usize, + /// 背景色浮层的 16 进制草稿与聚焦态(浮层关闭时归零)。 + nebula_bg_hex_input: String, + pub(crate) nebula_bg_hex_active: bool, + /// 设置→高级→同步(WebDAV)的四个输入草稿:url、用户名、WebDAV + /// 密码、E2E 口令。密码/口令只是「待保存」缓冲——提交即入凭据 + /// 管理器并清空,明文从不驻留。 + nebula_sync_inputs: [String; 4], + /// 聚焦的同步输入框(0..4,对应 [`nebula_sync_inputs`] 下标)。 + pub(crate) nebula_sync_focus: Option, + nebula_sync_auto_pull: bool, + /// 凭据管理器里已有 [密码, 口令](只存在性,绝不回读明文进 UI)。 + nebula_sync_secret_set: [bool; 2], + /// 最近一次同步动作的结果 `(message, is_error)`,画在按钮行下方。 + pub(crate) nebula_sync_status: Option<(String, bool)>, + nebula_sync_busy: bool, + + /// Tab rename state: when `Some(index, text)`, a text input is shown over + /// tab `index` with the current edit buffer `text`. The user types to edit, + /// Enter commits, Esc cancels (Windows Terminal double-click rename). + pub nebula_tab_rename: Option<(usize, String)>, + /// True for the instant after a rename begins: the whole existing name + /// reads as "selected" (nushell-style blue fill) and the first typed + /// character replaces it wholesale. Cleared on the first edit. + pub nebula_tab_rename_select_all: bool, + /// Insertion caret inside the rename buffer, as a CHAR index (0..=chars). + /// Click-to-place, arrow keys, and mid-string insert/delete all go + /// through this — a rename is a real text field, not append-only. + pub nebula_tab_rename_caret: usize, + /// Left pixel of the rename buffer's first glyph, stashed by `draw_chrome` + /// each frame the box shows. Click-to-place-caret maps pointer X through + /// this — recomputing the draw-side layout in the input path would just + /// let the two drift. + pub nebula_tab_rename_text_x: f32, + + pub visual_bell: VisualBell, + + /// Mapped RGB values for each terminal color. + pub colors: List, + /// The user's configured color scheme, untouched by theme restyling — + /// the base every `apply_term_colors` starts from. + nebula_default_colors: List, + /// Draw-time adaptation for application-owned RGB colors. The terminal + /// grid retains the original values so protocol state and copying are exact. + terminal_color_resolver: terminal_color::TerminalColorResolver, + + /// State of the keyboard hints. + pub hint_state: HintState, + + /// Unprocessed display updates. + pub pending_update: DisplayUpdate, + + /// The renderer update that takes place only once before the actual rendering. + pub pending_renderer_update: Option, + + /// The ime on the given display. + pub ime: Ime, + + /// The state of the timer for frame scheduling. + pub frame_timer: FrameTimer, + + /// Damage tracker for the given display. + pub damage_tracker: DamageTracker, + + /// Font size used by the window. + pub font_size: FontSize, + + /// UI 字体角色(wezterm `window_frame` 范式):chrome 排版锚定在这个 + /// 状态上,永不跟随终端缩放。Ctrl+滚轮 / 设置 spinner 只改 + /// `font_size`(终端网格与跟随它的文档查看器)。阶段 3 将把 + /// family/size 暴露为独立配置。 + nebula_ui_font: NebulaUiFont, + + /// 抽屉视图是否路由到 SFTP(聚焦 pane 的 SSH 身份与面板连接匹配)。 + /// 见 [`Self::route_side_panel`]。 + nebula_sftp_routed: bool, + + // Mouse point position when highlighting hints. + hint_mouse_point: Option, + + renderer: ManuallyDrop, + renderer_preference: Option, + + surface: ManuallyDrop>, + + context: ManuallyDrop, + + glyph_cache: GlyphCache, + meter: Meter, +} + +/// 计算全屏 TUI 在网格之外需要补齐的垂直背景带。内部边缘必须停在 Pane 边界, +/// 只有接触终端外沿的 Pane 才能继续延伸到圆角卡片边缘。 +fn alt_screen_vertical_padding_bands( + window: &SizeInfo, + pane: &SizeInfo, + card_y: f32, + card_height: f32, +) -> [Option<(f32, f32)>; 2] { + const EDGE_EPSILON: f32 = 0.5; + + let window_grid_top = window.padding_y(); + let window_grid_bottom = window.height() - window.padding_bottom(); + let pane_top = pane.padding_y(); + let pane_bottom = pane.height() - pane.padding_bottom(); + let grid_bottom = pane_top + pane.screen_lines() as f32 * pane.cell_height(); + + let band = |start: f32, end: f32| { + let height = (end - start).max(0.0); + (height > f32::EPSILON).then_some((start, height)) + }; + + let top = if (pane_top - window_grid_top).abs() <= EDGE_EPSILON { + band(card_y, pane_top) + } else { + None + }; + let bottom_limit = if (pane_bottom - window_grid_bottom).abs() <= EDGE_EPSILON { + card_y + card_height + } else { + pane_bottom + }; + + [top, band(grid_bottom, bottom_limit)] +} + +/// 本地文件/目录送回收站(Windows:`SHFileOperationW` + `FOF_ALLOWUNDO`)。 +/// 不弹系统确认(Nebula 自己的确认弹窗已经问过了),不弹系统错误框—— +/// 失败以 Err 返回给面板提示条。 +#[cfg(windows)] +fn send_to_recycle_bin(path: &std::path::Path) -> Result<(), String> { + use std::os::windows::ffi::OsStrExt; + + use windows_sys::Win32::UI::Shell::{ + FO_DELETE, FOF_ALLOWUNDO, FOF_NOCONFIRMATION, FOF_NOERRORUI, FOF_SILENT, SHFILEOPSTRUCTW, + SHFileOperationW, + }; + + if !path.exists() { + return Err("路径已不存在".to_owned()); + } + // pFrom 是双 NUL 结尾的路径列表。 + let mut from: Vec = path.as_os_str().encode_wide().collect(); + from.push(0); + from.push(0); + let mut op = SHFILEOPSTRUCTW { + hwnd: std::ptr::null_mut(), + wFunc: FO_DELETE as u32, + pFrom: from.as_ptr(), + pTo: std::ptr::null(), + fFlags: (FOF_ALLOWUNDO | FOF_NOCONFIRMATION | FOF_SILENT | FOF_NOERRORUI) as u16, + fAnyOperationsAborted: 0, + hNameMappings: std::ptr::null_mut(), + lpszProgressTitle: std::ptr::null(), + }; + let code = unsafe { SHFileOperationW(&mut op) }; + if code == 0 && op.fAnyOperationsAborted == 0 { + Ok(()) + } else { + Err(format!("系统拒绝(代码 {code})")) + } +} + +#[cfg(not(windows))] +fn send_to_recycle_bin(path: &std::path::Path) -> Result<(), String> { + // 非 Windows 暂无回收站集成:递归删除前置确认已由弹窗承担。 + let result = if path.is_dir() { + std::fs::remove_dir_all(path) + } else { + std::fs::remove_file(path) + }; + result.map_err(|err| err.to_string()) +} + +/// Prefer the event loop's system-wide appearance over the window theme. +/// +/// On Windows, `Window::theme()` is a cached per-window value and can still +/// contain the previous manual override immediately after `set_theme(None)`. +fn system_theme_snapshot( + event_loop_theme: Option, + window_theme: Option, +) -> Option { + event_loop_theme.or(window_theme) +} + +impl Display { + pub fn new( + window: Window, + gl_context: NotCurrentContext, + config: &UiConfig, + system_theme: Option, + _tabbed: bool, + ) -> Result { + let raw_window_handle = window.raw_window_handle(); + + let scale_factor = window.scale_factor as f32; + let settings_init = settings::nebula_settings_load(config); + let rasterizer = Rasterizer::new()?; + crate::boot_trace("rasterizer ready"); + + // 设置里保存过字号则优先生效(逻辑 px × 缩放),否则跟随配置文件; + // Ctrl+滚轮 / 设置 spinner 改过的字号因此在重启后保持。 + let font_size = settings_init + .font_size + .map(|px| FontSize::from_px(px * scale_factor)) + .unwrap_or_else(|| config.font.size().scale(scale_factor)); + // UI 锚定字号始终取配置字号(0.7 默认):终端字号的持久化缩放不 + // 影响 chrome。2026-07-28 曾试过锚定 settings 保存字号,实测 16.3px + // 让 chrome 明显过大,用户裁定回到 0.7 默认——当时「图标变小」的 + // 真凶是 ambiguous 宽度缩放误伤 PUA 图标,已在 glyph_cache 排除。 + let ui_font_px = config.font.size().scale(scale_factor).as_px(); + #[cfg(windows)] + let (rasterizer, required_font_install) = { + let mut rasterizer = rasterizer; + let installed = GlyphCache::font_family_available( + &mut rasterizer, + crate::font_install::REQUIRED_FONT_FAMILY, + font_size, + ); + let required = (!installed).then(|| NebulaConfirm::InstallRequiredFont { + directory: crate::font_install::bundled_font_directory(), + }); + (rasterizer, required) + }; + #[cfg(not(windows))] + let required_font_install = None; + + debug!("Loading \"{}\" font", &settings_init.font_family); + let font = + config.font.clone().with_family(settings_init.font_family.clone()).with_size(font_size); + let mut glyph_cache = GlyphCache::new(rasterizer, &font)?; + glyph_cache.wide_bold_use_regular = settings_init.cjk_bold_regular; + #[cfg(windows)] + let mut nebula_font_families = glyph_cache.private_font_families(); + #[cfg(not(windows))] + let mut nebula_font_families = vec![settings_init.font_family.clone()]; + nebula_font_families.retain(|family| family != crate::font_install::REQUIRED_FONT_FAMILY); + nebula_font_families.insert(0, crate::font_install::REQUIRED_FONT_FAMILY.to_owned()); + if !nebula_font_families.iter().any(|family| family == &settings_init.font_family) { + nebula_font_families.push(settings_init.font_family.clone()); + } + crate::boot_trace("glyph cache (font faces loaded)"); + + let metrics = glyph_cache.font_metrics(); + let (cell_width, cell_height) = compute_cell_size(config, &metrics); + + // Resize the window to the user-configured size, or a Windows + // Terminal-like default when unset. A 116-column by 30-row canvas is + // the standard startup size. Two inputs are deliberately excluded: + // the session file's saved window size (stale/wrong-domain values + // kept resurfacing as near-fullscreen launches), and the persisted + // terminal zoom — the startup grid is priced at the CONFIG base font + // size, because 116 columns of a Ctrl+wheel-enlarged cell is itself + // a near-fullscreen window. The zoomed font still renders; it just + // shows fewer columns in the standard-sized window. + let dimensions = config + .window + .dimensions() + .unwrap_or(crate::config::window::Dimensions { columns: 116, lines: 30 }); + 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)) + .unwrap_or((cell_width, cell_height)); + let size = + window_size(config, dimensions, base_cell_width, base_cell_height, scale_factor); + window.request_inner_size(size); + + // Create the GL surface to draw into. + let surface = platform::create_gl_surface( + &gl_context, + window.inner_size(), + window.raw_window_handle(), + )?; + + // Make the context current. + let context = gl_context.make_current(&surface)?; + crate::boot_trace("surface + context current"); + + // Create renderer. + let mut renderer = Renderer::new(&context, config.debug.renderer)?; + crate::boot_trace("renderer (shaders compiled)"); + + // Load font common glyphs to accelerate rendering. + debug!("Filling glyph cache with common glyphs"); + renderer.with_loader(|mut api| { + glyph_cache.reset_glyph_cache(&mut api); + }); + crate::boot_trace("glyph cache warmed"); + + let padding = config.window.padding(window.scale_factor as f32); + let chrome = chrome_reserve(window.scale_factor as f32); + let viewport_size = window.inner_size(); + + // Create new size with at least one column and row. + // Asymmetric from the start: the sidebar is expanded on launch, so the + // left padding carries it while the right keeps the plain content + // margin. Dynamic padding is dropped — the sidebar fixes the left edge. + let scale = window.scale_factor as f32; + let content_pad = content_pad_x(scale); + let size_info = SizeInfo::new_fully_asymmetric( + viewport_size.width as f32, + viewport_size.height as f32, + cell_width, + cell_height, + padding.0 + content_pad + sidebar_width(scale, false), + padding.0 + content_pad, + padding.1 + chrome, + padding.1 + bottom_content_reserve(scale), + ); + + info!("Cell size: {cell_width} x {cell_height}"); + info!("Padding: {} x {}", size_info.padding_x(), size_info.padding_y()); + info!("Width: {}, Height: {}", size_info.width(), size_info.height()); + + // Update OpenGL projection. + renderer.resize(&size_info); + + // Clear screen. + let nebula_window_theme_override = config.window.theme(); + if settings_init.follow_system_theme { + window.set_theme(None); + } + let nebula_system_theme = system_theme_snapshot(system_theme, window.theme()); + let nebula_theme = if settings_init.follow_system_theme { + nebula_system_theme + .map(|theme| { + settings_init.theme.for_system_appearance(matches!(theme, WinitTheme::Light)) + }) + .unwrap_or(settings_init.theme) + } else { + settings_init.theme + }; + let background_color = if settings_init.follow_system_theme { + nebula_theme.palette().term_bg + } else { + settings_init.background.unwrap_or(config.colors.primary.background) + }; + renderer.clear(background_color, settings_init.opacity); + window.set_transparent(settings_init.opacity < 1.0); + + // Disable shadows for transparent windows on macOS. + #[cfg(target_os = "macos")] + window.set_has_shadow(settings_init.opacity >= 1.0); + + let is_wayland = matches!(raw_window_handle, RawWindowHandle::Wayland(_)); + + // On Wayland we can safely ignore this call, since the window isn't visible until you + // actually draw something into it and commit those changes. + if !is_wayland { + surface.swap_buffers(&context).expect("failed to swap buffers."); + renderer.finish(); + } + crate::boot_trace("first swap done"); + + // Set resize increments for the newly created window. + if config.window.resize_increments { + window.set_resize_increments(Some(PhysicalSize::new(cell_width, cell_height))); + } + + window.set_visible(true); + crate::boot_trace("window visible"); + + // Always focus new windows, even if no Nebula window is currently focused. + #[cfg(target_os = "macos")] + window.focus_window(); + + if !_tabbed { + match config.window.startup_mode { + #[cfg(target_os = "macos")] + StartupMode::SimpleFullscreen => window.set_simple_fullscreen(true), + StartupMode::Maximized if !is_wayland => window.set_maximized(true), + #[cfg(windows)] + StartupMode::Fullscreen => window.set_fullscreen(true), + _ => (), + } + } + + let hint_state = HintState::new(config.hints.alphabet()); + // Publish the RESTORED theme to the prompt bridge (writing the default + // here used to reset the powerline colors on every launch). + write_nebula_prompt_theme(nebula_theme); + + let mut damage_tracker = DamageTracker::new(size_info.screen_lines(), size_info.columns()); + damage_tracker.debug = config.debug.highlight_damage; + + // Disable vsync. + if let Err(err) = surface.set_swap_interval(&context, SwapInterval::DontWait) { + info!("Failed to disable vsync: {err}"); + } + crate::boot_trace("swap interval set"); + + // Terminal color table: the user's configured scheme, restyled by the + // restored theme (light themes swap in a readable light ANSI set, and + // the background OSC 11 reports must match the theme from frame one). + let nebula_default_colors = List::from(&config.colors); + let mut initial_colors = nebula_default_colors; + nebula_theme.apply_term_colors(&mut initial_colors, &nebula_default_colors); + + let mut display = Self { + context: ManuallyDrop::new(context), + visual_bell: VisualBell::from(&config.bell), + renderer: ManuallyDrop::new(renderer), + renderer_preference: config.debug.renderer, + surface: ManuallyDrop::new(surface), + colors: initial_colors, + nebula_default_colors, + terminal_color_resolver: Default::default(), + frame_timer: FrameTimer::new(), + raw_window_handle, + damage_tracker, + glyph_cache, + hint_state, + size_info, + font_size, + nebula_ui_font: NebulaUiFont { px: ui_font_px, cell: (0.0, 0.0) }, + nebula_sftp_routed: true, + window, + pending_renderer_update: Default::default(), + vi_highlighted_hint_age: Default::default(), + highlighted_hint_age: Default::default(), + vi_highlighted_hint: Default::default(), + highlighted_hint: Default::default(), + hint_mouse_point: Default::default(), + pending_update: Default::default(), + cursor_hidden: Default::default(), + nebula_pane_view: None, + nebula_resize_hud: None, + nebula_resize_hud_armed: false, + nebula_history: { + let history = crate::nebula_history::NebulaHistory::load(); + crate::boot_trace("history loaded"); + history + }, + directory_history: crate::directory_history::global(), + nebula_commands: nebula_commands_handle(), + nebula_tab_anim: Vec::new(), + nebula_scrollbar_drag: None, + nebula_split_reveal: None, + nebula_confirm: required_font_install, + nebula_confirm_buttons: None, + nebula_ssh_delete_undo: None, + nebula_ai_fix_bar: None, + nebula_ssh_delete_undo_rect: None, + nebula_ssh_delete_undo_hover: false, + nebula_ssh_editor: None, + nebula_ssh_editor_rects: None, + nebula_ssh_editor_open: false, + nebula_ssh_editor_hover: SshEditorHit::None, + nebula_ssh_test_request: None, + nebula_ssh_test_seq: 0, + nebula_frame_images: Vec::new(), + nebula_theme, + nebula_theme_preference: settings_init.theme, + nebula_follow_system_theme: settings_init.follow_system_theme, + nebula_system_theme, + nebula_window_theme_override, + nebula_settings_open: false, + nebula_special_tab_active: false, + nebula_language_preference: settings_init.language, + nebula_language: settings_init.language.resolved(), + nebula_config_paths: config.config_paths.clone(), + nebula_settings_scroll: 0.0, + nebula_palette: { + let mut palette = command_palette::CommandPalette::new(); + palette.set_language(settings_init.language.resolved()); + palette + }, + nebula_detected_shells: None, + nebula_side_panel: side_panel::SidePanel::new(), + nebula_sftp_panel: None, + nebula_ui_anims: NebulaUiAnims::new(), + nebula_settings_section: NebulaSettingsSection::default(), + nebula_chrome_hover: ChromeHit::None, + nebula_message_queue_entry: message_queue_entry::MessageQueueEntry::default(), + nebula_settings_hover: SettingsHit::None, + nebula_settings_opacity_drag: None, + nebula_context_menu: None, + nebula_settings_dropdown: None, + nebula_cursor_shape: settings_init.cursor_shape, + nebula_cursor_blink: settings_init.cursor_blink, + nebula_copy_on_select: settings_init.copy_on_select, + nebula_cjk_bold_regular: settings_init.cjk_bold_regular, + nebula_keymap: keymap::build_bindings(&settings_init.keybinds), + nebula_keybinds: settings_init.keybinds, + nebula_quick_terminal_hotkey: settings_init.quick_terminal_hotkey, + nebula_quick_hotkey_request: None, + nebula_quick_hotkey_error: None, + nebula_keymap_capture: None, + nebula_keymap_capture_preview: String::new(), + nebula_font_family: settings_init.font_family, + nebula_font_families, + nebula_font_notice: None, + nebula_tab_labels: vec![".".to_owned()], + nebula_tab_colors: vec![None], + nebula_tab_bells: vec![false], + nebula_tab_running: vec![false], + nebula_tab_logos: vec![None], + nebula_ai_logo_cache: Default::default(), + nebula_shell_icon_cache: Default::default(), + nebula_chrome_logo_draws: Vec::new(), + nebula_active_tab: 0, + nebula_tab_drag: None, + nebula_tabs_reorderable: true, + nebula_sidebar_collapsed: false, + nebula_ssh_hosts: merge_ssh_hosts( + &settings_init.saved_hosts, + &settings_init.pinned_hosts, + &settings_init.hidden_hosts, + ), + nebula_pinned_hosts: settings_init.pinned_hosts.clone(), + nebula_saved_hosts: settings_init.saved_hosts.clone(), + nebula_hidden_hosts: settings_init.hidden_hosts.clone(), + nebula_tabs_section_open: true, + nebula_hosts_section_open: true, + nebula_tabs_scroll: 0, + nebula_hosts_scroll: 0, + nebula_tab_rename: None, + nebula_tab_rename_select_all: false, + nebula_tab_rename_caret: 0, + nebula_tab_rename_text_x: 0.0, + nebula_pty_resize_pending: false, + nebula_ghost_enabled: settings_init.ghost, + nebula_accept: settings_init.accept, + nebula_shell: settings_init.shell, + nebula_shell_id: settings_init.shell_id.clone(), + nebula_startup_directory: settings_init.startup_directory, + nebula_fetch_enabled: settings_init.fetch, + nebula_powerline_enabled: settings_init.powerline, + nebula_keep_session: settings_init.keep_session, + nebula_window_opacity: settings_init.opacity, + nebula_background: if settings_init.follow_system_theme { + Some(nebula_theme.palette().term_bg) + } else { + settings_init.background + }, + nebula_background_image: settings_init.background_image, + nebula_background_image_opacity: settings_init.background_image_opacity, + nebula_background_image_fit: settings_init.background_image_fit, + nebula_background_image_alignment: settings_init.background_image_alignment, + nebula_background_image_cover_chrome: settings_init.background_image_cover_chrome, + nebula_settings_mtime: settings::nebula_settings_mtime(), + nebula_bg_palette_index: 0, + nebula_bg_hex_input: String::new(), + nebula_bg_hex_active: false, + nebula_sync_inputs: Default::default(), + nebula_sync_focus: None, + nebula_sync_auto_pull: false, + nebula_sync_secret_set: [false; 2], + nebula_sync_status: None, + nebula_sync_busy: false, + meter: Default::default(), + ime: Default::default(), + }; + // A persisted zoom means the very first frame already runs off the UI + // base size — the font role must be pinned NOW, not after the first + // font change funnels through handle_update. + display.refresh_ui_font(config); + Ok(display) + } + + pub fn settings_open(&self) -> bool { + self.nebula_settings_open + } + + pub fn ui_language(&self) -> UiLanguage { + self.nebula_language + } + + pub fn settings_section(&self) -> NebulaSettingsSection { + self.nebula_settings_section + } + + pub fn select_settings_section(&mut self, section: NebulaSettingsSection) { + self.close_shell_picker(); + self.close_font_picker(); + if self.nebula_settings_section != section { + self.nebula_settings_section = section; + // Each section starts reading from its top. + self.nebula_settings_scroll = 0.0; + self.pending_update.dirty = true; + } + } + + /// Scroll the settings content by `delta` px (positive = content moves + /// up). Clamped against the active section's overflow; no-op while the + /// panel is closed. + pub fn settings_scroll_by(&mut self, delta: f32) { + if !self.nebula_settings_open { + return; + } + let area = self.terminal_card_rect(); + let max = settings::settings_max_scroll( + &self.size_info, + self.window.scale_factor as f32, + area, + self.nebula_settings_section, + self.nebula_hidden_hosts.len(), + ); + let next = (self.nebula_settings_scroll + delta).clamp(0.0, max); + if (next - self.nebula_settings_scroll).abs() > f32::EPSILON { + self.nebula_settings_scroll = next; + self.pending_update.dirty = true; + self.window.request_redraw(); + } + } + + pub fn settings_scroll(&self) -> f32 { + self.nebula_settings_scroll + } + + pub fn shell_picker_count(&self) -> usize { + self.nebula_detected_shells.as_ref().map_or(0, Vec::len) + } + + pub fn font_picker_count(&self) -> usize { + self.nebula_font_families.len() + 1 + } + + pub fn hidden_ssh_host_count(&self) -> usize { + self.nebula_hidden_hosts.len() + } + + pub fn open_ssh_editor(&mut self) { + self.nebula_ssh_editor = Some(SshHostEditor { + original_destination: None, + error: None, + destination_selection: Default::default(), + password_selection: Default::default(), + destination: String::new(), + password: String::new(), + save_password: true, + show_password: false, + auth: crate::ssh_profiles::SshAuthMode::Auto, + private_keys: Vec::new(), + field: SshEditorField::Destination, + focus: crate::ux::FocusIndex::default(), + test: Default::default(), + }); + self.nebula_ssh_editor_rects = None; + self.nebula_ssh_editor_open = true; + self.nebula_ssh_editor_hover = SshEditorHit::None; + // 每次打开都从零开始,避免上一次退出动画的残余进度造成闪跳。 + self.nebula_ui_anims.ssh_editor = UiAnim::new(0.0); + self.pending_update.dirty = true; + } + + pub fn edit_ssh_host(&mut self, index: usize) { + let Some(destination) = self.nebula_ssh_hosts.get(index).cloned() else { return }; + let profile = + crate::ssh_profiles::SshProfiles::load(&nebula_data_dir().join("ssh_profiles.json")) + .unwrap_or_else(|err| { + warn!("加载 SSH Profile 失败,编辑器使用自动认证: {err}"); + crate::ssh_profiles::SshProfiles::default() + }) + .for_destination(&destination); + self.nebula_ssh_editor = Some(SshHostEditor { + original_destination: Some(destination.clone()), + error: None, + destination_selection: Default::default(), + password_selection: Default::default(), + destination, + // Never pull a stored secret back into a text field. Leaving this + // blank preserves the existing credential when the address stays + // unchanged; typing a new value explicitly replaces it. + password: String::new(), + save_password: true, + show_password: false, + auth: profile.auth, + private_keys: profile.private_keys, + field: SshEditorField::Destination, + focus: crate::ux::FocusIndex::default(), + test: Default::default(), + }); + self.nebula_ssh_editor_rects = None; + self.nebula_ssh_editor_open = true; + self.nebula_ssh_editor_hover = SshEditorHit::None; + self.nebula_ui_anims.ssh_editor = UiAnim::new(0.0); + self.pending_update.dirty = true; + self.window.request_redraw(); + } + + pub fn ssh_editor_active(&self) -> bool { + self.nebula_ssh_editor_open && self.nebula_ssh_editor.is_some() + } + + pub fn close_ssh_editor(&mut self) { + if self.nebula_ssh_editor.is_some() { + self.nebula_ssh_editor_open = false; + self.nebula_ssh_editor_hover = SshEditorHit::None; + self.pending_update.dirty = true; + self.window.request_redraw(); + } + } + + pub fn ssh_editor_hit(&self, x: f32, y: f32) -> SshEditorHit { + let Some(rects) = self.nebula_ssh_editor_rects.as_ref() else { + return SshEditorHit::None; + }; + let hit = |r: (f32, f32, f32, f32)| x >= r.0 && x < r.0 + r.2 && y >= r.1 && y < r.1 + r.3; + if hit(rects.close) { + SshEditorHit::Close + } else if hit(rects.destination) { + SshEditorHit::Destination + } else if let Some((mode, _)) = rects.auth.iter().find(|(_, rect)| hit(*rect)) { + SshEditorHit::Auth(*mode) + } else if hit(rects.add_private_key) { + SshEditorHit::AddPrivateKey + } else if let Some((index, _)) = rects.private_key_rows.iter().find(|(_, rect)| hit(*rect)) + { + SshEditorHit::RemovePrivateKey(*index) + } else if hit(rects.password_toggle) { + SshEditorHit::PasswordToggle + } else if hit(rects.password) { + SshEditorHit::Password + } else if hit(rects.save_checkbox) { + SshEditorHit::SaveToggleBox + } else if hit(rects.save_toggle) { + SshEditorHit::SaveToggleLabel + } else if hit(rects.test) { + SshEditorHit::Test + } else if hit(rects.test_status) { + SshEditorHit::TestStatus + } else if hit(rects.cancel) { + SshEditorHit::Cancel + } else if hit(rects.primary) { + SshEditorHit::Primary + } else { + SshEditorHit::None + } + } + + pub fn set_ssh_editor_hover(&mut self, hover: SshEditorHit) { + if self.nebula_ssh_editor_hover != hover { + self.nebula_ssh_editor_hover = hover; + self.pending_update.dirty = true; + } + } + + pub fn ssh_editor_insert(&mut self, text: &str) { + if let Some(editor) = self.nebula_ssh_editor.as_mut() { + editor.error = None; + match editor.field { + SshEditorField::Destination => { + editor.destination_selection.insert(&mut editor.destination, text) + }, + SshEditorField::Password => { + editor.password_selection.insert(&mut editor.password, text) + }, + } + editor.test = Default::default(); + } + } + pub fn ssh_editor_backspace(&mut self) { + if let Some(editor) = self.nebula_ssh_editor.as_mut() { + let before = match editor.field { + SshEditorField::Destination => editor.destination.len(), + SshEditorField::Password => editor.password.len(), + }; + match editor.field { + SshEditorField::Destination => { + editor.destination_selection.backspace(&mut editor.destination) + }, + SshEditorField::Password => { + editor.password_selection.backspace(&mut editor.password) + }, + } + let after = match editor.field { + SshEditorField::Destination => editor.destination.len(), + SshEditorField::Password => editor.password.len(), + }; + if before != after { + editor.test = Default::default(); + } + } + } + + pub fn ssh_editor_select_all(&mut self) { + if let Some(editor) = self.nebula_ssh_editor.as_mut() { + match editor.field { + SshEditorField::Destination => { + editor.destination_selection.select(&editor.destination) + }, + SshEditorField::Password => editor.password_selection.select(&editor.password), + } + } + } + + /// Copying an invisible password would persist a secret in the system + /// clipboard without visible intent, so it is enabled only after Reveal. + pub fn ssh_editor_selected_text(&self) -> Option { + let editor = self.nebula_ssh_editor.as_ref()?; + match editor.field { + SshEditorField::Destination => { + editor.destination_selection.selected_text(&editor.destination) + }, + SshEditorField::Password if editor.show_password => { + editor.password_selection.selected_text(&editor.password) + }, + SshEditorField::Password => None, + } + } + + pub fn ssh_editor_next_field(&mut self, reverse: bool) { + if let Some(editor) = self.nebula_ssh_editor.as_mut() { + editor.destination_selection.clear(); + editor.password_selection.clear(); + let shows_password = ssh_ui::auth_sections(editor.auth).0; + // Destination, optional password, test, cancel, save. + let count = if shows_password { 5 } else { 4 }; + editor.focus.advance(count, reverse); + editor.field = match (shows_password, editor.focus.current()) { + (_, 0) => SshEditorField::Destination, + (true, 1) => SshEditorField::Password, + _ => editor.field, + }; + } + } + + pub fn ssh_editor_activate_focus(&mut self) { + let Some(editor) = self.nebula_ssh_editor.as_ref() else { return }; + let shows_password = ssh_ui::auth_sections(editor.auth).0; + match (shows_password, editor.focus.current()) { + (true, 2) | (false, 1) => self.queue_ssh_test(), + (true, 3) | (false, 2) => self.close_ssh_editor(), + (true, 4) | (false, 3) => self.save_ssh_editor(), + _ => {}, + } + } + + pub fn ssh_editor_toggle_save(&mut self) { + if let Some(editor) = self.nebula_ssh_editor.as_mut() { + editor.save_password = !editor.save_password; + editor.test = Default::default(); + } + } + + /// input 层在处理完编辑器点击后调用:取走「测试连接」暂存的请求。 + pub fn take_ssh_test_request(&mut self) -> Option { + self.nebula_ssh_test_request.take() + } + + /// Validate the current draft and stage an exact, numbered test request. + /// The input layer owns the event proxy and takes this request immediately + /// after mouse/keyboard activation. + fn queue_ssh_test(&mut self) { + let Some(editor) = self.nebula_ssh_editor.as_mut() else { return }; + let destination = editor.destination.trim().to_owned(); + let valid = !destination.is_empty() + && !destination + .chars() + .any(|c| c.is_whitespace() || c.is_control() || ";&|<>\"'`".contains(c)); + if !valid { + editor.error = Some(if destination.is_empty() { + "请输入 SSH 地址,例如 user@example.com".to_owned() + } else { + "地址不能包含空白、控制字符或 shell 分隔符".to_owned() + }); + editor.test = Default::default(); + return; + } + if matches!(editor.test, ssh_ui::SshTestState::Running { .. }) { + return; + } + + let shows_password = ssh_ui::auth_sections(editor.auth).0; + editor.focus.set(if shows_password { 2 } else { 1 }, if shows_password { 5 } else { 4 }); + self.nebula_ssh_test_seq = self.nebula_ssh_test_seq.wrapping_add(1).max(1); + let request_id = self.nebula_ssh_test_seq; + editor.error = None; + editor.test = ssh_ui::SshTestState::Running { request_id }; + self.nebula_ssh_test_request = Some(crate::ssh_session::SshTestRequest { + request_id, + destination, + auth: editor.auth, + private_keys: editor.private_keys.clone(), + password: (!editor.password.is_empty()).then(|| editor.password.clone()), + }); + } + + /// 后台测试完成回报。结果只属于发起时的草稿:地址已改或状态已非 + /// Running(用户改过字段被清位)时直接丢弃,不让旧结果背书新配置。 + pub fn ssh_test_done( + &mut self, + request_id: u64, + destination: &str, + ok: bool, + message: &str, + elapsed_ms: u64, + ) { + let Some(editor) = self.nebula_ssh_editor.as_mut() else { return }; + if editor.destination.trim() != destination + || editor.test != (ssh_ui::SshTestState::Running { request_id }) + { + return; + } + editor.test = if ok { + ssh_ui::SshTestState::Ok { elapsed_ms } + } else { + ssh_ui::SshTestState::Failed { summary: message.to_owned() } + }; + self.pending_update.dirty = true; + self.window.request_redraw(); + } + + pub fn ssh_editor_click(&mut self, x: f32, y: f32) -> bool { + match self.ssh_editor_hit(x, y) { + SshEditorHit::Destination => { + if let Some(editor) = self.nebula_ssh_editor.as_mut() { + let count = if ssh_ui::auth_sections(editor.auth).0 { 5 } else { 4 }; + editor.focus.set(0, count); + editor.destination_selection.clear(); + editor.password_selection.clear(); + editor.field = SshEditorField::Destination; + } + }, + SshEditorHit::PasswordToggle => { + if let Some(editor) = self.nebula_ssh_editor.as_mut() { + editor.show_password = !editor.show_password; + } + }, + SshEditorHit::Password => { + if let Some(editor) = self.nebula_ssh_editor.as_mut() { + editor.focus.set(1, 5); + editor.destination_selection.clear(); + editor.password_selection.clear(); + editor.field = SshEditorField::Password; + } + }, + SshEditorHit::Auth(mode) => { + if let Some(editor) = self.nebula_ssh_editor.as_mut() { + editor.auth = mode; + editor.error = None; + editor.test = Default::default(); + if !ssh_ui::auth_sections(mode).0 { + editor.field = SshEditorField::Destination; + } + } + }, + SshEditorHit::AddPrivateKey => { + if let Some(result) = file_dialog::pick_private_key_file(&self.window) { + if let Some(editor) = self.nebula_ssh_editor.as_mut() { + match result { + Ok(path) => { + ssh_ui::push_private_key(&mut editor.private_keys, path); + editor.error = None; + editor.test = Default::default(); + }, + Err(err) => editor.error = Some(err), + } + } + } + }, + SshEditorHit::RemovePrivateKey(index) => { + if let Some(editor) = self.nebula_ssh_editor.as_mut() { + if index < editor.private_keys.len() { + editor.private_keys.remove(index); + editor.test = Default::default(); + } + } + }, + SshEditorHit::SaveToggleBox | SshEditorHit::SaveToggleLabel => { + self.ssh_editor_toggle_save(); + }, + SshEditorHit::Test => self.queue_ssh_test(), + SshEditorHit::TestStatus => {}, + SshEditorHit::Close => { + self.close_ssh_editor(); + }, + SshEditorHit::Cancel => { + if let Some(editor) = self.nebula_ssh_editor.as_mut() { + let shows_password = ssh_ui::auth_sections(editor.auth).0; + editor.focus.set( + if shows_password { 3 } else { 2 }, + if shows_password { 5 } else { 4 }, + ); + } + self.close_ssh_editor(); + }, + SshEditorHit::Primary => { + if let Some(editor) = self.nebula_ssh_editor.as_mut() { + let shows_password = ssh_ui::auth_sections(editor.auth).0; + editor.focus.set( + if shows_password { 4 } else { 3 }, + if shows_password { 5 } else { 4 }, + ); + } + self.save_ssh_editor(); + }, + SshEditorHit::None => return false, + } + self.pending_update.dirty = true; + self.window.request_redraw(); + true + } + + pub fn save_ssh_editor(&mut self) { + let Some(mut editor) = self.nebula_ssh_editor.take() else { return }; + let destination = editor.destination.trim().to_owned(); + let valid = !destination.is_empty() + && !destination + .chars() + .any(|c| c.is_whitespace() || c.is_control() || ";&|<>\"'`".contains(c)); + if !valid { + editor.error = Some(if destination.is_empty() { + "请输入 SSH 地址,例如 user@example.com".to_owned() + } else { + "地址不能包含空白、控制字符或 shell 分隔符".to_owned() + }); + editor.field = SshEditorField::Destination; + self.nebula_ssh_editor = Some(editor); + self.pending_update.dirty = true; + self.window.request_redraw(); + return; + } + if let Some(original) = editor.original_destination.as_deref() { + if original != destination { + self.nebula_saved_hosts.retain(|host| host != original); + self.nebula_pinned_hosts.retain(|host| host != original); + if !self.nebula_hidden_hosts.iter().any(|host| host == original) { + self.nebula_hidden_hosts.push(original.to_owned()); + } + #[cfg(windows)] + { + let _ = crate::ssh_credentials::forget_password(original); + } + } + } + // Saving/editing is an explicit request to surface this destination, + // so it also undoes a previous Delete of the same address. + self.nebula_hidden_hosts.retain(|host| host != &destination); + self.nebula_saved_hosts.retain(|host| host != &destination); + self.nebula_saved_hosts.insert(0, destination.clone()); + self.nebula_saved_hosts.truncate(20); + self.nebula_ssh_hosts = merge_ssh_hosts( + &self.nebula_saved_hosts, + &self.nebula_pinned_hosts, + &self.nebula_hidden_hosts, + ); + let profile_path = nebula_data_dir().join("ssh_profiles.json"); + let mut profiles = + crate::ssh_profiles::SshProfiles::load(&profile_path).unwrap_or_else(|err| { + warn!("加载 SSH Profile 失败,将创建新文件: {err}"); + crate::ssh_profiles::SshProfiles::default() + }); + if let Some(original) = editor.original_destination.as_deref() { + if original != destination { + profiles.rename(original, &destination); + } + } + profiles.upsert(crate::ssh_profiles::SshProfileAuth { + destination: destination.clone(), + auth: editor.auth, + private_keys: editor.private_keys.clone(), + }); + if let Err(err) = profiles.save(&profile_path) { + editor.error = Some(format!("保存 SSH Profile 失败: {err}")); + self.nebula_ssh_editor = Some(editor); + self.pending_update.dirty = true; + self.window.request_redraw(); + return; + } + if ssh_ui::auth_sections(editor.auth).0 + && editor.save_password + && !editor.password.is_empty() + { + #[cfg(windows)] + { + let _ = crate::ssh_credentials::store_password( + &destination, + editor.password.as_bytes(), + ); + } + } + // 凭据落盘后立即清除内存中的明文,但保留其余内容完成短退出动画。 + editor.password.clear(); + self.persist_nebula_settings(); + self.nebula_ssh_editor = Some(editor); + self.close_ssh_editor(); + } + + /// Ask before removing a saved destination. Config aliases use different + /// wording because Delete hides them inside Nebula and never edits + /// `~/.ssh/config` itself. + pub fn request_delete_ssh_host(&mut self, index: usize) { + let Some(host) = self.nebula_ssh_hosts.get(index).cloned() else { return }; + let from_config = crate::ssh::ssh_config_hosts().contains(&host); + self.nebula_confirm = Some(NebulaConfirm::DeleteSsh { host, from_config }); + self.pending_update.dirty = true; + self.window.request_redraw(); + } + + /// Apply a confirmed deletion and arm a complete, credential-safe Undo. + /// Replacing an older Undo finalizes that older credential deletion first. + pub fn confirm_delete_ssh_host(&mut self, host: &str) -> bool { + if !self.nebula_ssh_hosts.iter().any(|entry| entry == host) { + return false; + } + + let from_config = crate::ssh::ssh_config_hosts().iter().any(|entry| entry == host); + + // Taking the previous record commits its pending Credential Manager + // deletion through Drop. Only the most recent destructive action is + // reversible, matching standard snackbar Undo behavior. + self.nebula_ssh_delete_undo.take(); + + let (saved_index, pinned_index, was_hidden) = remove_ssh_host_from_lists( + host, + &mut self.nebula_saved_hosts, + &mut self.nebula_pinned_hosts, + &mut self.nebula_hidden_hosts, + ); + self.nebula_ssh_hosts = merge_ssh_hosts( + &self.nebula_saved_hosts, + &self.nebula_pinned_hosts, + &self.nebula_hidden_hosts, + ); + self.nebula_ssh_delete_undo = Some(SshDeleteUndo { + host: host.to_owned(), + saved_index, + pinned_index, + was_hidden, + from_config, + started_at: std::time::Instant::now(), + delete_credential_on_drop: true, + }); + self.persist_nebula_settings(); + self.pending_update.dirty = true; + self.window.request_redraw(); + true + } + + /// Reverse the complete host-list mutation. The credential was intentionally + /// kept alive during the grace period, so disarming Drop restores it without + /// ever copying secret bytes into the UI process. + pub fn undo_delete_ssh_host(&mut self) -> bool { + let Some(mut undo) = self.nebula_ssh_delete_undo.take() else { return false }; + if undo.started_at.elapsed() >= SSH_DELETE_UNDO_DURATION { + // Drop commits the pending credential deletion. + return false; + } + + undo.delete_credential_on_drop = false; + restore_ssh_host_to_lists( + &undo.host, + undo.saved_index, + undo.pinned_index, + undo.was_hidden, + &mut self.nebula_saved_hosts, + &mut self.nebula_pinned_hosts, + &mut self.nebula_hidden_hosts, + ); + self.nebula_ssh_hosts = merge_ssh_hosts( + &self.nebula_saved_hosts, + &self.nebula_pinned_hosts, + &self.nebula_hidden_hosts, + ); + self.nebula_ssh_delete_undo_rect = None; + self.nebula_ssh_delete_undo_hover = false; + self.persist_nebula_settings(); + self.pending_update.dirty = true; + self.window.request_redraw(); + true + } + + /// Commit the pending Credential Manager deletion when the Undo timer ends. + pub fn expire_ssh_delete_undo(&mut self) { + self.nebula_ssh_delete_undo.take(); + self.nebula_ssh_delete_undo_rect = None; + self.nebula_ssh_delete_undo_hover = false; + self.pending_update.dirty = true; + self.window.request_redraw(); + } + + pub fn ssh_delete_undo_available(&self) -> bool { + self.nebula_ssh_delete_undo + .as_ref() + .is_some_and(|undo| undo.started_at.elapsed() < SSH_DELETE_UNDO_DURATION) + } + + pub fn ssh_delete_undo_hit(&self, x: f32, y: f32) -> bool { + self.ssh_delete_undo_available() + && self.nebula_ssh_delete_undo_rect.is_some_and(|rect| { + x >= rect.0 && x < rect.0 + rect.2 && y >= rect.1 && y < rect.1 + rect.3 + }) + } + + pub fn set_ssh_delete_undo_hover(&mut self, hovered: bool) { + if self.nebula_ssh_delete_undo_hover != hovered { + self.nebula_ssh_delete_undo_hover = hovered; + self.pending_update.dirty = true; + self.window.request_redraw(); + } + } + + pub fn set_chrome_tabs( + &mut self, + labels: Vec, + mut colors: Vec>, + mut dots: Vec, + mut running: Vec, + mut logos: Vec>, + active: usize, + reorderable: bool, + ) { + self.nebula_tab_labels = if labels.is_empty() { vec![".".to_owned()] } else { labels }; + colors.truncate(self.nebula_tab_labels.len()); + colors.resize(self.nebula_tab_labels.len(), None); + self.nebula_tab_colors = colors; + dots.truncate(self.nebula_tab_labels.len()); + dots.resize(self.nebula_tab_labels.len(), false); + self.nebula_tab_bells = dots; + running.truncate(self.nebula_tab_labels.len()); + running.resize(self.nebula_tab_labels.len(), false); + self.nebula_tab_running = running; + logos.truncate(self.nebula_tab_labels.len()); + logos.resize(self.nebula_tab_labels.len(), None); + self.nebula_tab_logos = logos; + self.nebula_active_tab = active.min(self.nebula_tab_labels.len().saturating_sub(1)); + self.nebula_tabs_reorderable = reorderable; + // A tab count change (close/open) mid-drag invalidates the grabbed slot. + if self.nebula_tab_drag.map_or(false, |d| d.source >= self.nebula_tab_labels.len()) { + self.nebula_tab_drag = None; + } + } + + /// Whether any sidebar tab currently shows a running spinner. Only this + /// state raises the chrome clock to display-rate frames. + pub fn any_tab_running(&self) -> bool { + self.nebula_tab_running.iter().any(|running| *running) + } + + /// A chrome text editor (tab rename / drawer filter / commit message / SSH + /// host editor) has keyboard focus — the window context bumps the redraw + /// tick to the fast cadence so the insertion caret visibly blinks. + pub fn chrome_editor_active(&self) -> bool { + self.nebula_tab_rename.is_some() + || self.nebula_side_panel.search_focus + || self.nebula_side_panel.commit_focus + || self.nebula_sftp_panel.as_ref().is_some_and(sftp_panel::SftpPanel::editor_active) + || self.ssh_editor_active() + } + + /// Decoded (and theme-tinted) pixels for an AI brand logo, plus a stable + /// texture id for the renderer's inline cache. Decode + tint run once per + /// (logo, ink); the GPU upload happens lazily inside the renderer. + fn ai_logo_pixels( + &mut self, + logo: AiLogo, + ink: Rgb, + ) -> Option<(u64, std::sync::Arc>, (u32, u32))> { + // Claude's mark ships in Anthropic coral and is used as-is (only its + // alpha matters), so its cache key ignores the ink. The OpenAI mark + // is black-on-alpha and follows the chrome ink to stay visible on + // every theme. + let key = match logo { + AiLogo::Claude => (logo, [0, 0, 0]), + AiLogo::OpenAi | AiLogo::OpenCode | AiLogo::Pi => (logo, [ink.r, ink.g, ink.b]), + }; + if let Some(cached) = self.nebula_ai_logo_cache.get(&key) { + return Some(cached.clone()); + } + let bytes: &[u8] = match logo { + AiLogo::Claude => AI_LOGO_CLAUDE_PNG, + AiLogo::OpenAi => AI_LOGO_OPENAI_PNG, + AiLogo::OpenCode => AI_LOGO_OPENCODE_PNG, + AiLogo::Pi => AI_LOGO_PI_PNG, + }; + let (width, height, mut rgba) = match crate::renderer::image::decode_png_bytes(bytes) { + Ok(decoded) => decoded, + // Unreachable for a valid embedded asset; degrade to no icon. + Err(err) => { + log::warn!("failed to decode embedded AI logo: {err}"); + return None; + }, + }; + match logo { + AiLogo::OpenAi | AiLogo::Pi => { + for px in rgba.chunks_exact_mut(4) { + (px[0], px[1], px[2]) = (ink.r, ink.g, ink.b); + } + }, + AiLogo::OpenCode => { + // Stored grayscale = luma map (frame 255, screen-block 90). + // Tint to theme ink scaled by luma: frame → full ink, inner + // block → ~35% ink, keeping the two-tone mark on every theme. + for px in rgba.chunks_exact_mut(4) { + let luma = px[0] as u16; // R==G==B in the asset + px[0] = (ink.r as u16 * luma / 255) as u8; + px[1] = (ink.g as u16 * luma / 255) as u8; + px[2] = (ink.b as u16 * luma / 255) as u8; + } + }, + AiLogo::Claude => {}, + } + let id = AI_LOGO_ID_BASE + self.nebula_ai_logo_cache.len() as u64; + let entry = (id, std::sync::Arc::new(rgba), (width, height)); + self.nebula_ai_logo_cache.insert(key, entry.clone()); + Some(entry) + } + + /// Decoded pixels for a full-color shell icon (128×128 PNG embedded from + /// extra/shell-icons), plus a stable texture id for the renderer's inline + /// cache. Decode runs once per shell id; the GPU upload happens lazily + /// inside the renderer. Returns `None` when the id has no brand asset. + fn shell_icon_pixels( + &mut self, + shell_id: &str, + ) -> Option<(u64, std::sync::Arc>, (u32, u32))> { + if let Some(cached) = self.nebula_shell_icon_cache.get(shell_id) { + return Some(cached.clone()); + } + let bytes = crate::shell_detect::color_icon_png(shell_id)?; + let (width, height, rgba) = match crate::renderer::image::decode_png_bytes(bytes) { + Ok(decoded) => decoded, + Err(err) => { + log::warn!("failed to decode shell icon for {shell_id}: {err}"); + return None; + }, + }; + // Shell icons ship in brand colors and are used as-is (no tint). + let id = AI_LOGO_ID_BASE + 1000 + self.nebula_shell_icon_cache.len() as u64; + let entry = (id, std::sync::Arc::new(rgba), (width, height)); + self.nebula_shell_icon_cache.insert(shell_id.to_owned(), entry.clone()); + Some(entry) + } + + /// Arm a potential tab drag from a press on displayed tab `source`. Always + /// arms (even single-tab), because the release decides between click / + /// reorder / dock — selection itself is deferred to the release. + pub fn arm_tab_drag(&mut self, source: usize, x: f32, y: f32) { + self.nebula_tab_drag = + Some(TabDrag { source, origin_x: x, origin: y, current: y, active: false, dock: None }); + } + + /// Whether a tab drag is currently armed (pressed, possibly not yet moved). + pub fn tab_drag_armed(&self) -> bool { + self.nebula_tab_drag.is_some() + } + + /// Feed the pointer into an armed drag. Y drives the in-sidebar reorder; + /// crossing into the terminal area computes the dock side. Returns `true` + /// once the drag is active (past threshold on either axis), signalling the + /// caller to show the grab cursor and repaint. + pub fn update_tab_drag(&mut self, x: f32, y: f32) -> bool { + let threshold = 6.0 * self.window.scale_factor as f32; + // Compute before the mutable borrow below. + let dock = self.dock_nav_at(x, y); + match self.nebula_tab_drag.as_mut() { + Some(drag) => { + drag.current = y; + if !drag.active + && ((y - drag.origin).abs() > threshold + || (x - drag.origin_x).abs() > threshold) + { + drag.active = true; + } + if drag.active { + drag.dock = dock; + } + drag.active + }, + None => false, + } + } + + /// Dock side for a pointer inside the terminal area, `None` outside it. + /// The area is quartered along its diagonals: the nearest edge wins, which + /// gives the natural triangular dock zones. + fn dock_nav_at(&self, x: f32, y: f32) -> Option { + let gx = self.size_info.padding_x(); + let gy = self.size_info.padding_y(); + let gw = self.size_info.width() - gx - self.size_info.padding_right(); + let gh = self.size_info.height() - gy - self.size_info.padding_bottom(); + if gw <= 0.0 || gh <= 0.0 || x < gx || y < gy || x > gx + gw || y > gy + gh { + return None; + } + let nx = (x - gx) / gw; + let ny = (y - gy) / gh; + let (dl, dr, dt, db) = (nx, 1.0 - nx, ny, 1.0 - ny); + let min = dl.min(dr).min(dt).min(db); + Some(if min == dl { + SplitNav::Left + } else if min == dr { + SplitNav::Right + } else if min == dt { + SplitNav::Up + } else { + SplitNav::Down + }) + } + + /// Finish a tab drag, deciding what the release means. + pub fn end_tab_drag(&mut self) -> Option { + let drag = self.nebula_tab_drag.take()?; + if !drag.active { + // Never moved: a plain click — select on release. + return Some(TabDropAction::Click(drag.source)); + } + if let Some(nav) = drag.dock { + return Some(TabDropAction::Dock { source: drag.source, nav }); + } + if !self.nebula_tabs_reorderable || self.nebula_tab_labels.len() < 2 { + return Some(TabDropAction::Click(drag.source)); + } + let target = self.tab_drop_index(drag.source, drag.current); + if target != drag.source + && drag.source < self.nebula_tab_anim.len() + && target < self.nebula_tab_anim.len() + { + // Reorder the animated draw-y values alongside the tabs so each + // pill keeps its on-screen position and *eases* into its new slot + // instead of snapping when the drop commits. + let v = self.nebula_tab_anim.remove(drag.source); + self.nebula_tab_anim.insert(target, v); + } + if target != drag.source { + Some(TabDropAction::Reorder { from: drag.source, to: target }) + } else { + Some(TabDropAction::Click(drag.source)) + } + } + + /// Displayed slot the grabbed tab would drop into for pointer X: the number + /// of *other* tabs whose centre the pointer has passed. This yields the + /// correct remove-then-insert target index for a single-tab move. + fn tab_drop_index(&self, source: usize, y: f32) -> usize { + let scale = self.window.scale_factor as f32; + let sidebar_expand = self.left_sidebar_progress(); + let layout = + chrome_tab_layout(&self.ui_size_info(), scale, self.sidebar_model(), sidebar_expand); + // Tabs stack vertically now: count rows whose vertical centre the + // pointer has passed to get the remove-then-insert target slot. + let passed = layout + .tabs + .iter() + .enumerate() + .filter(|(i, rect)| *i != source && y > rect.1 + rect.3 * 0.5) + .count(); + passed.min(self.nebula_tab_labels.len().saturating_sub(1)) + } + + /// Draw-X for a tab's pill/label during a reorder drag. The grabbed pill + /// follows the pointer (clamped to the strip); every other tab between the + /// grabbed slot and the current drop target slides one slot toward the + /// vacated source, opening a gap for the drop ("让位"). No shift when idle. + fn tab_drag_draw_y(&self, index: usize, tab_y: f32, layout: &ChromeTabLayout) -> f32 { + let Some(d) = self.nebula_tab_drag.filter(|d| d.active) else { return tab_y }; + + // The grabbed pill tracks the pointer, clamped to the tab column. + if d.source == index { + let lo = layout.tabs.first().map_or(tab_y, |t| t.1); + let hi = layout.tabs.last().map_or(tab_y, |t| t.1); + return (tab_y + d.current - d.origin).clamp(lo, hi); + } + + // Other tabs make way. Slot pitch = distance between adjacent rows + // (uniform height + gap); needs at least two tabs, which a drag implies. + let Some(second) = layout.tabs.get(1) else { return tab_y }; + let slot = second.1 - layout.tabs[0].1; + let target = self.tab_drop_index(d.source, d.current); + if d.source < target && index > d.source && index <= target { + tab_y - slot // dragging down: rows in (source, target] slide up + } else if d.source > target && index >= target && index < d.source { + tab_y + slot // dragging up: rows in [target, source) slide down + } else { + tab_y + } + } + + pub fn set_chrome_hover(&mut self, chrome: ChromeHit, settings: SettingsHit) { + if self.nebula_chrome_hover != chrome || self.nebula_settings_hover != settings { + self.nebula_chrome_hover = chrome; + self.nebula_settings_hover = settings; + self.pending_update.dirty = true; + } + } + + pub fn context_menu_interactive(&self) -> bool { + self.nebula_context_menu.as_ref().is_some_and(context_menu::ContextMenu::interactive) + } + + /// 右键菜单是否已在同一目标上开着(开着就别重开——反复右键不该让菜单 + /// 因指针位置或底边 clamp 而漂移)。 + fn context_menu_open_for(&self, target: ContextMenuTarget) -> bool { + self.nebula_context_menu + .as_ref() + .is_some_and(|menu| menu.interactive() && menu.target() == target) + } + + /// 侧栏行右键菜单的锚点:贴行矩形右缘、与行顶对齐——菜单与被点的行 + /// 强相关,而不是跟着指针走。行不存在时回落指针位置。 + fn sidebar_row_anchor(&self, tab: bool, index: usize, fallback: (f32, f32)) -> (f32, f32) { + let size = self.ui_size_info(); + let scale = self.window.scale_factor as f32; + let expand = if self.nebula_sidebar_collapsed { 0.0 } else { 1.0 }; + let layout = chrome::chrome_tab_layout(&size, scale, self.sidebar_model(), expand); + let rows = if tab { &layout.tabs } else { &layout.hosts }; + rows.get(index).map_or(fallback, |(rx, ry, rw, _)| (rx + rw + 4.0 * scale, *ry)) + } + + pub fn open_tab_context_menu(&mut self, index: usize, x: f32, y: f32) { + if index >= self.nebula_tab_labels.len() + || self.context_menu_open_for(ContextMenuTarget::Tab(index)) + { + return; + } + let anchor = self.sidebar_row_anchor(true, index, (x, y)); + let color = self.nebula_tab_colors.get(index).copied().flatten(); + self.nebula_context_menu = + Some(context_menu::ContextMenu::new(ContextMenuTarget::Tab(index), anchor, color)); + self.nebula_tab_drag = None; + self.pending_update.dirty = true; + self.window.request_redraw(); + } + + pub fn open_ssh_context_menu(&mut self, index: usize, x: f32, y: f32) { + if index >= self.nebula_ssh_hosts.len() + || self.context_menu_open_for(ContextMenuTarget::Ssh(index)) + { + return; + } + let anchor = self.sidebar_row_anchor(false, index, (x, y)); + self.nebula_context_menu = + Some(context_menu::ContextMenu::new(ContextMenuTarget::Ssh(index), anchor, None)); + self.pending_update.dirty = true; + self.window.request_redraw(); + } + + pub fn open_sftp_context_menu(&mut self, index: usize, x: f32, y: f32) { + let Some(panel) = self.nebula_sftp_panel.as_ref() else { return }; + if panel.visible_entry(index).is_none() { + return; + } + self.nebula_context_menu = + Some(context_menu::ContextMenu::new(ContextMenuTarget::Sftp(index), (x, y), None)); + self.pending_update.dirty = true; + self.window.request_redraw(); + } + + /// 本地文件树行的右键菜单。`..` 导航行不给菜单;打开时把该行设为 + /// 持久选中,菜单与行的关联在视觉上立得住。 + pub fn open_file_tree_context_menu(&mut self, row: usize, x: f32, y: f32) { + let Some((path, is_dir, is_parent)) = self + .nebula_side_panel + .visible_row(row) + .map(|r| (r.path.clone(), r.is_dir, r.is_parent)) + else { + return; + }; + if is_parent { + return; + } + let target = ContextMenuTarget::FileTree { row, is_dir }; + if self.context_menu_open_for(target) { + return; + } + self.nebula_side_panel.selected = Some(path); + self.nebula_context_menu = Some(context_menu::ContextMenu::new(target, (x, y), None)); + self.pending_update.dirty = true; + self.window.request_redraw(); + } + + /// 菜单动作执行时按行索引回查路径(树可能在菜单打开期间被节流刷新, + /// 拿不到就当无操作,绝不落在别的行上)。 + pub fn file_tree_row_path(&self, row: usize) -> Option<(std::path::PathBuf, bool)> { + self.nebula_side_panel + .visible_row(row) + .filter(|r| !r.is_parent) + .map(|r| (r.path.clone(), r.is_dir)) + } + + pub fn request_delete_file_tree(&mut self, row: usize) { + let Some((path, is_dir)) = self.file_tree_row_path(row) else { return }; + self.nebula_confirm = Some(NebulaConfirm::DeleteFileTreePath { path, is_dir }); + self.pending_update.dirty = true; + self.window.request_redraw(); + } + + /// 确认后的本地删除:送回收站(`FOF_ALLOWUNDO`),不是永久删除—— + /// 树紧挨终端、误触成本高,回收站是最后一道保险。 + pub fn confirm_delete_file_tree(&mut self, path: &std::path::Path) { + self.nebula_confirm = None; + match send_to_recycle_bin(path) { + Ok(()) => self.nebula_side_panel.request_refresh(), + Err(err) => self.nebula_side_panel.set_notice(format!("删除失败:{err}")), + } + self.pending_update.dirty = true; + self.window.request_redraw(); + } + + pub fn open_sftp_panel_context_menu(&mut self, x: f32, y: f32) { + if self.nebula_sftp_panel.is_none() { + return; + } + self.nebula_context_menu = + Some(context_menu::ContextMenu::new(ContextMenuTarget::SftpPanel, (x, y), None)); + self.pending_update.dirty = true; + self.window.request_redraw(); + } + + pub fn context_menu_hit(&self, x: f32, y: f32) -> ContextMenuHit { + self.nebula_context_menu.as_ref().map_or(ContextMenuHit::Outside, |menu| { + context_menu::hit_test(menu, self.ui_size_info(), self.window.scale_factor as f32, x, y) + }) + } + + pub fn context_menu_hover(&mut self, x: f32, y: f32) -> ContextMenuHit { + let hit = self.context_menu_hit(x, y); + let action = match hit { + ContextMenuHit::Action(action) => Some(action), + ContextMenuHit::Outside | ContextMenuHit::Panel => None, + }; + if self.nebula_context_menu.as_mut().is_some_and(|menu| menu.set_hover(action)) { + self.pending_update.dirty = true; + self.window.request_redraw(); + } + hit + } + + /// Resolve one menu click and start the close animation. A click inside + /// the panel but between targets is swallowed without dismissing it. + pub fn context_menu_click(&mut self, x: f32, y: f32) -> ContextMenuHit { + let hit = self.context_menu_hit(x, y); + if matches!(hit, ContextMenuHit::Action(_) | ContextMenuHit::Outside) { + self.close_context_menu(); + } + hit + } + + pub fn close_context_menu(&mut self) { + if let Some(menu) = self.nebula_context_menu.as_mut() { + menu.begin_close(); + self.pending_update.dirty = true; + self.window.request_redraw(); + } + } + + pub fn chrome_hit(&self, x: f32, y: f32) -> ChromeHit { + // Hit-testing must read the SAME layout the chrome was drawn with — + // the UI-anchored SizeInfo — or clicks drift off their targets as + // soon as the terminal is zoomed away from the base font size. + chrome_hit_with_tabs( + &self.ui_size_info(), + self.window.scale_factor as f32, + self.sidebar_model(), + self.nebula_sidebar_collapsed, + x, + y, + ) + } + + /// Fold the tab sidebar in or out. Toggling changes the grid's usable width, + /// so it re-runs the resize/reflow path by re-feeding the current window + /// size — `handle_update` then recomputes the asymmetric padding split. + pub fn toggle_sidebar(&mut self) { + self.nebula_sidebar_collapsed = !self.nebula_sidebar_collapsed; + let size = PhysicalSize::new(self.size_info.width() as u32, self.size_info.height() as u32); + self.pending_update.set_dimensions(size); + self.window.request_redraw(); + self.pending_update.dirty = true; + } + + /// Snapshot of the state the settings render reads, owning the wallpaper + /// path so `draw_chrome` can still borrow `&mut renderer` afterwards. + fn settings_view(&self) -> settings::SettingsView { + settings::SettingsView { + area: self.terminal_card_rect(), + language_preference: self.nebula_language_preference, + language: self.nebula_language, + section: self.nebula_settings_section, + hover: self.nebula_settings_hover, + theme: self.nebula_theme, + follow_system_theme: self.nebula_follow_system_theme, + ghost: self.nebula_ghost_enabled, + accept: self.nebula_accept, + shell_label: { + // Rich picked id (cmd/pwsh/nu/wsl:X) wins; else the 2-value + // enum label. Icon comes from the same table the dropdown + // rows use, so the setting always mirrors the menu. + let id = self.nebula_shell_id.as_deref(); + let name = id + .map(crate::shell_detect::display_name_for_id) + .unwrap_or_else(|| self.nebula_shell.label().to_owned()); + let icon = crate::shell_detect::icon_for_id( + id.unwrap_or_else(|| self.nebula_shell.settings_value()), + ); + format!("{icon} {name}") + }, + dropdown: self.nebula_settings_dropdown, + shells: self + .nebula_detected_shells + .as_ref() + .map(|shells| { + shells + .iter() + .map(|s| (s.id.clone(), s.name.clone(), s.program.clone())) + .collect() + }) + .unwrap_or_default(), + shell_id: self.nebula_shell_id.clone(), + startup_directory: self + .nebula_startup_directory + .as_ref() + .map(|path| path.to_string_lossy().into_owned()), + font_family: self.nebula_font_family.clone(), + font_size_px: self.font_size.as_px() / self.window.scale_factor as f32, + fonts: self.nebula_font_families.clone(), + font_notice: self.nebula_font_notice.clone(), + hidden_hosts: self.nebula_hidden_hosts.clone(), + fetch: self.nebula_fetch_enabled, + powerline: self.nebula_powerline_enabled, + keep_session: self.nebula_keep_session, + opacity: self.nebula_window_opacity, + dragging_opacity: self.nebula_settings_opacity_drag.map(|(target, _, _)| target), + cursor_shape: self.nebula_cursor_shape, + cursor_blink: self.nebula_cursor_blink, + copy_on_select: self.nebula_copy_on_select, + cjk_bold_regular: self.nebula_cjk_bold_regular, + preview_bg: self.preview_terminal_bg(), + preview_fg: { + let bg = self.preview_terminal_bg(); + // 亮底配深字、暗底配浅字:预览要在任何自定义背景色上可读。 + let luma = 0.299 * bg.r as f32 + 0.587 * bg.g as f32 + 0.114 * bg.b as f32; + if luma > 140.0 { Rgb::new(40, 44, 52) } else { Rgb::new(225, 228, 240) } + }, + background: self.nebula_background, + bg_hex_input: self.nebula_bg_hex_input.clone(), + bg_hex_active: self.nebula_bg_hex_active, + background_image: self.nebula_background_image.clone(), + background_image_opacity: self.nebula_background_image_opacity, + background_image_fit: self.nebula_background_image_fit, + background_image_alignment: self.nebula_background_image_alignment, + background_image_cover_chrome: self.nebula_background_image_cover_chrome, + scroll: self.nebula_settings_scroll, + keymap: keymap::EDITABLE_ACTIONS + .iter() + .map(|(action, ..)| keymap::effective_combo(action, &self.nebula_keymap)) + .collect(), + quick_terminal_hotkey: self.nebula_quick_terminal_hotkey.clone(), + quick_hotkey_error: self.nebula_quick_hotkey_error.clone(), + keymap_capture: self.nebula_keymap_capture, + keymap_capture_preview: self.nebula_keymap_capture_preview.clone(), + sync_inputs: self.nebula_sync_inputs.clone(), + sync_focus: self.nebula_sync_focus, + sync_auto_pull: self.nebula_sync_auto_pull, + sync_secret_set: self.nebula_sync_secret_set, + sync_status: self.nebula_sync_status.clone(), + sync_busy: self.nebula_sync_busy, + } + } + + pub fn set_settings_tab_active(&mut self, active: bool) { + if self.nebula_settings_open == active { + return; + } + self.nebula_settings_open = active; + self.nebula_special_tab_active = active; + if !active { + self.commit_sync_field(); + self.nebula_settings_dropdown = None; + self.nebula_settings_hover = SettingsHit::None; + self.nebula_keymap_capture = None; + } else { + self.load_sync_state(); + // Each explicit visit starts at a predictable page origin. + self.nebula_settings_scroll = 0.0; + } + self.pending_update.dirty = true; + } + + pub fn set_special_tab_active(&mut self, active: bool) { + self.nebula_special_tab_active = active; + if !active { + self.nebula_settings_open = false; + } + } + + pub fn set_ui_language(&mut self, preference: LanguagePreference) { + if self.nebula_language_preference == preference { + return; + } + self.nebula_language_preference = preference; + self.nebula_language = preference.resolved(); + self.nebula_palette.set_language(self.nebula_language); + self.persist_nebula_settings(); + self.pending_update.dirty = true; + } + + fn apply_nebula_theme(&mut self, theme: NebulaTheme) { + let previous_theme = self.nebula_theme; + let theme_changed = previous_theme != theme; + self.nebula_theme = theme; + // A theme carries its terminal background (the light themes are + // unusable without it). Switching theme IS choosing the look, so it + // overwrites a previous custom color by design. + self.nebula_background = Some(theme.palette().term_bg); + // Restyle the terminal color table: OSC 11 must report the new + // background (TUIs key light/dark off it) and light themes need the + // light ANSI set to stay readable. + let defaults = self.nebula_default_colors; + theme.apply_term_colors(&mut self.colors, &defaults); + if theme_changed { + // 旧 pane 可能持有应用通过 OSC 写入的上一主题颜色;交给窗口层在 + // 未持有任何终端锁时统一清理,避免只刷新当前焦点 pane。 + self.terminal_color_resolver + .theme_changed(previous_theme.palette().term_bg, theme.palette().term_bg); + self.pending_update.set_terminal_colors_dirty(); + } + write_nebula_prompt_theme(theme); + self.pending_update.dirty = true; + } + + pub fn select_nebula_theme(&mut self, theme: NebulaTheme) { + self.nebula_theme_preference = theme; + // Clicking a concrete theme is an explicit manual choice. Automatic + // mode must step aside instead of changing it again on the next OS + // appearance event. + self.nebula_follow_system_theme = false; + self.window.set_theme(self.nebula_window_theme_override); + self.apply_nebula_theme(theme); + self.persist_nebula_settings(); + // Panel stays open so users can adjust several settings at once. + } + + pub fn toggle_system_theme_following(&mut self) { + self.nebula_follow_system_theme = !self.nebula_follow_system_theme; + if self.nebula_follow_system_theme { + // winit explicitly suppresses ThemeChanged for overridden + // windows, so automatic mode must let the OS own this value. + self.window.set_theme(None); + self.nebula_system_theme = + system_theme_snapshot(self.nebula_system_theme, self.window.theme()); + } else { + self.window.set_theme(self.nebula_window_theme_override); + } + let theme = if self.nebula_follow_system_theme { + self.nebula_system_theme + .map(|system| { + self.nebula_theme_preference + .for_system_appearance(matches!(system, WinitTheme::Light)) + }) + .unwrap_or(self.nebula_theme_preference) + } else { + self.nebula_theme_preference + }; + self.apply_nebula_theme(theme); + self.persist_nebula_settings(); + } + + /// Apply a live operating-system appearance change without rewriting the + /// stored theme family. This is intentionally a no-op in manual mode. + pub fn system_theme_changed(&mut self, system_theme: WinitTheme) { + self.sync_system_theme(Some(system_theme)); + } + + /// Refresh the system appearance independently from the window's cached + /// theme. This also keeps manual-mode windows ready to switch immediately + /// when the user enables automatic following. + pub fn sync_system_theme(&mut self, system_theme: Option) { + let Some(system_theme) = system_theme else { return }; + if self.nebula_system_theme == Some(system_theme) { + return; + } + + self.nebula_system_theme = Some(system_theme); + if self.nebula_follow_system_theme { + let theme = self + .nebula_theme_preference + .for_system_appearance(matches!(system_theme, WinitTheme::Light)); + self.apply_nebula_theme(theme); + } + } + + /// Remember a reloaded window-decoration preference without allowing it + /// to suppress OS theme notifications while automatic mode is enabled. + pub fn update_window_theme_override(&mut self, theme: Option) { + self.nebula_window_theme_override = theme; + self.window.set_theme(if self.nebula_follow_system_theme { None } else { theme }); + } + + pub fn toggle_ghost(&mut self) { + self.nebula_ghost_enabled = !self.nebula_ghost_enabled; + self.persist_nebula_settings(); + self.pending_update.dirty = true; + } + + pub fn cycle_accept(&mut self) { + self.nebula_accept = self.nebula_accept.cycle(); + self.persist_nebula_settings(); + self.pending_update.dirty = true; + } + + /// Open the "默认 Shell" picker (the settings row click): the same + /// Toggle the inline shell picker in settings (expand/collapse the list). + /// Toggle a settings combobox. All dropdowns share one field so opening + /// one always closes the others. + pub fn toggle_settings_dropdown(&mut self, dropdown: settings::SettingsDropdown) { + if self.nebula_settings_dropdown == Some(dropdown) { + self.nebula_settings_dropdown = None; + } else { + if dropdown == settings::SettingsDropdown::Shell { + // Ensure shells are detected before opening. + let _ = self + .nebula_detected_shells + .get_or_insert_with(crate::shell_detect::detect_shells); + } + if dropdown == settings::SettingsDropdown::Font { + self.nebula_font_notice = None; + } + self.nebula_settings_dropdown = Some(dropdown); + } + self.pending_update.dirty = true; + } + + pub fn close_settings_dropdown(&mut self) -> bool { + if self.nebula_settings_dropdown.take().is_none() { + return false; + } + self.nebula_bg_hex_active = false; + self.pending_update.dirty = true; + self.window.request_redraw(); + true + } + + pub fn set_background_image_fit_option(&mut self, index: usize) { + if let Some(fit) = settings::BACKGROUND_FIT_OPTIONS.get(index) { + self.nebula_background_image_fit = *fit; + self.persist_nebula_settings(); + } + self.nebula_settings_dropdown = None; + self.pending_update.dirty = true; + } + + pub fn set_background_image_alignment_option(&mut self, index: usize) { + if let Some(alignment) = settings::BACKGROUND_ALIGNMENT_OPTIONS.get(index) { + self.nebula_background_image_alignment = *alignment; + self.persist_nebula_settings(); + } + self.nebula_settings_dropdown = None; + self.pending_update.dirty = true; + } + + pub fn set_accept_option(&mut self, index: usize) { + if let Some(accept) = settings::ACCEPT_OPTIONS.get(index) { + self.nebula_accept = *accept; + self.persist_nebula_settings(); + } + 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 { + self.nebula_settings_dropdown = None; + self.pending_update.dirty = true; + let Some(shape) = settings::CURSOR_SHAPE_OPTIONS.get(index).copied() else { + return false; + }; + if self.nebula_cursor_shape == shape { + return false; + } + self.nebula_cursor_shape = shape; + self.persist_nebula_settings(); + true + } + + pub fn toggle_cursor_blink(&mut self) { + self.nebula_cursor_blink = !self.nebula_cursor_blink; + self.persist_nebula_settings(); + self.pending_update.dirty = true; + } + + pub fn toggle_copy_on_select(&mut self) { + self.nebula_copy_on_select = !self.nebula_copy_on_select; + self.persist_nebula_settings(); + self.pending_update.dirty = true; + } + + /// 同步拉到新历史后热加载(spec 003):ghost 补全的内存副本只在 + /// 启动时 load,这里手动重读一次。 + pub fn reload_nebula_history(&mut self) { + self.nebula_history = crate::nebula_history::NebulaHistory::load(); + self.pending_update.dirty = true; + } + + pub fn toggle_cjk_bold_regular(&mut self) { + self.nebula_cjk_bold_regular = !self.nebula_cjk_bold_regular; + self.glyph_cache.wide_bold_use_regular = self.nebula_cjk_bold_regular; + // 已缓存的 bold CJK 位图立即作废:切换要当场可见,不能等重启。 + self.reset_glyph_cache(); + self.persist_nebula_settings(); + self.pending_update.dirty = true; + } + + /// The default cursor style (shape + blink) every terminal should fall + /// back to when no escape has overridden it. + pub fn nebula_default_cursor_style(&self) -> nebula_terminal::vte::ansi::CursorStyle { + nebula_terminal::vte::ansi::CursorStyle { + shape: self.nebula_cursor_shape, + blinking: self.nebula_cursor_blink, + } + } + + /// Terminal background color the live settings preview should show: the + /// custom background wins, else the active theme's terminal background. + fn preview_terminal_bg(&self) -> Rgb { + self.nebula_background.unwrap_or(self.nebula_theme.palette().term_bg) + } + + pub fn toggle_shell_picker(&mut self) { + self.toggle_settings_dropdown(settings::SettingsDropdown::Shell); + } + + pub fn close_shell_picker(&mut self) { + if self.nebula_settings_dropdown == Some(settings::SettingsDropdown::Shell) { + self.close_settings_dropdown(); + } + } + + pub fn pick_startup_directory(&mut self) { + let Some(path) = file_dialog::pick_startup_directory(&self.window) else { return }; + if !path.is_dir() { + return; + } + + self.nebula_startup_directory = Some(path); + self.persist_nebula_settings(); + self.pending_update.dirty = true; + self.window.request_redraw(); + } + + pub fn clear_startup_directory(&mut self) { + if self.nebula_startup_directory.take().is_none() { + return; + } + + self.persist_nebula_settings(); + self.pending_update.dirty = true; + self.window.request_redraw(); + } + + pub(crate) fn startup_directory(&self) -> Option { + self.nebula_startup_directory.as_ref().filter(|path| path.is_dir()).cloned() + } + + pub fn toggle_font_picker(&mut self) { + self.toggle_settings_dropdown(settings::SettingsDropdown::Font); + } + + pub fn close_font_picker(&mut self) { + if self.nebula_settings_dropdown == Some(settings::SettingsDropdown::Font) { + self.close_settings_dropdown(); + } + } + + pub fn effective_font(&self, base: &Font) -> Font { + base.clone().with_family(self.nebula_font_family.clone()) + } + + fn apply_font_family(&mut self, family: String, base: &Font) { + self.nebula_font_family = family; + self.nebula_font_notice = None; + let font = self.effective_font(base).with_size(self.font_size); + self.pending_update.set_font(font); + self.persist_nebula_settings(); + self.window.request_redraw(); + } + + pub fn set_terminal_font_by_index(&mut self, index: usize, base: &Font) { + if let Some(family) = self.nebula_font_families.get(index).cloned() { + self.apply_font_family(family, base); + self.nebula_settings_dropdown = None; + return; + } + if index != self.nebula_font_families.len() { + return; + } + + #[cfg(windows)] + { + let Some(source) = file_dialog::pick_font_file(&self.window) else { return }; + let stored = match crate::font_install::store_imported_font(&source) { + Ok(stored) => stored, + Err(error) => { + self.nebula_font_notice = Some(error); + self.nebula_settings_dropdown = None; + self.pending_update.dirty = true; + return; + }, + }; + match self.glyph_cache.add_private_font(&stored.path) { + Ok(families) => { + for family in &families { + if !self.nebula_font_families.iter().any(|known| known == family) { + self.nebula_font_families.push(family.clone()); + } + } + self.nebula_font_families[1..] + .sort_by_key(|family| family.to_ascii_lowercase()); + if let Some(family) = families.into_iter().next() { + self.apply_font_family(family, base); + } + }, + Err(error) => { + if stored.created { + let _ = std::fs::remove_file(&stored.path); + } + self.nebula_font_notice = Some(format!("字体无法加载:{error}")); + self.pending_update.dirty = true; + }, + } + self.nebula_settings_dropdown = None; + } + #[cfg(not(windows))] + self.open_user_config_file(); + } + + /// WT-style default-shell picker (command palette mode). Kept for compatibility. + /// detected-shell dropdown as the "+" chevron, but confirming SETS the + /// default instead of launching a tab. Replaces the old 2-value cycle. + pub fn open_default_shell_picker(&mut self) { + let shells = + self.nebula_detected_shells.get_or_insert_with(crate::shell_detect::detect_shells); + self.nebula_palette.set_default_shell_menu(shells); + self.nebula_palette.open_default_picker(); + self.pending_update.dirty = true; + } + + /// Apply a picked default shell: keep the raw id for persistence and the + /// spawn override, and track the PTY-integrated executor family in the + /// enum so the prompt bootstrap picks the right base. + pub fn set_default_shell(&mut self, shell: &crate::shell_detect::DetectedShell) { + if let Some(family) = NebulaShell::from_settings(&shell.id) { + self.nebula_shell = family; + } + self.nebula_shell_id = Some(shell.id.clone()); + self.persist_nebula_settings(); + self.pending_update.dirty = true; + } + + pub fn set_default_shell_by_index(&mut self, index: usize) { + let shell = + self.nebula_detected_shells.as_ref().and_then(|shells| shells.get(index)).cloned(); + if let Some(shell) = shell { + self.set_default_shell(&shell); + } + self.nebula_settings_dropdown = None; + self.pending_update.dirty = true; + } + + /// Restore a destination after the short Undo period. Config aliases only + /// need to leave `hidden_hosts`; manually saved addresses are re-added to + /// the saved list. Expired credentials intentionally remain deleted. + pub fn restore_hidden_ssh_host(&mut self, index: usize) { + let Some(host) = self.nebula_hidden_hosts.get(index).cloned() else { return }; + let pending_same_host = + self.nebula_ssh_delete_undo.as_ref().is_some_and(|undo| undo.host == host); + if pending_same_host && self.undo_delete_ssh_host() { + return; + } + + self.nebula_hidden_hosts.retain(|entry| entry != &host); + let from_config = crate::ssh::ssh_config_hosts().iter().any(|entry| entry == &host); + if !from_config && !self.nebula_saved_hosts.iter().any(|entry| entry == &host) { + self.nebula_saved_hosts.insert(0, host); + } + self.nebula_ssh_hosts = merge_ssh_hosts( + &self.nebula_saved_hosts, + &self.nebula_pinned_hosts, + &self.nebula_hidden_hosts, + ); + self.persist_nebula_settings(); + self.pending_update.dirty = true; + self.window.request_redraw(); + } + + pub fn toggle_fetch(&mut self) { + self.nebula_fetch_enabled = !self.nebula_fetch_enabled; + self.persist_nebula_settings(); + self.pending_update.dirty = true; + } + + pub fn toggle_powerline(&mut self) { + self.nebula_powerline_enabled = !self.nebula_powerline_enabled; + self.persist_nebula_settings(); + self.pending_update.dirty = true; + } + + /// 高级→会话: whether closing a window keeps its shells in the resident + /// process (detach / re-attach restore) or kills them outright. + pub fn toggle_keep_session(&mut self) { + self.nebula_keep_session = !self.nebula_keep_session; + self.persist_nebula_settings(); + self.pending_update.dirty = true; + } + + pub fn begin_settings_opacity_drag(&mut self, target: SettingsOpacityTarget, pointer_x: f32) { + let slider = settings::opacity_slider_rect( + &self.ui_size_info(), + self.window.scale_factor as f32, + self.terminal_card_rect(), + self.nebula_settings_scroll, + target, + ); + self.nebula_settings_opacity_drag = Some((target, slider.0, slider.2)); + self.update_settings_opacity_drag(pointer_x); + } + + pub fn update_settings_opacity_drag(&mut self, pointer_x: f32) -> bool { + let Some((target, track_x, track_width)) = self.nebula_settings_opacity_drag else { + return false; + }; + let value = settings::opacity_from_pointer(pointer_x, (track_x, 0.0, track_width, 0.0)); + match target { + SettingsOpacityTarget::Terminal => { + if (self.nebula_window_opacity - value).abs() <= f32::EPSILON { + return true; + } + self.nebula_window_opacity = value; + self.update_window_transparency(); + }, + SettingsOpacityTarget::BackgroundImage => { + if (self.nebula_background_image_opacity - value).abs() <= f32::EPSILON { + return true; + } + self.nebula_background_image_opacity = value; + self.update_window_transparency(); + }, + } + self.pending_update.dirty = true; + self.window.request_redraw(); + true + } + + pub fn finish_settings_opacity_drag(&mut self) -> bool { + if self.nebula_settings_opacity_drag.take().is_none() { + return false; + } + // 拖动过程只刷新画面,松手后集中落盘,避免连续写设置文件。 + self.persist_nebula_settings(); + self.pending_update.dirty = true; + true + } + + /// 键盘快捷键仍可循环预设背景色(与色盘同一色板)。 + pub fn cycle_background_color(&mut self) { + self.nebula_bg_palette_index = + (self.nebula_bg_palette_index + 1) % settings::BACKGROUND_SWATCHES.len(); + self.nebula_background = Some(settings::BACKGROUND_SWATCHES[self.nebula_bg_palette_index]); + self.persist_nebula_settings(); + self.pending_update.dirty = true; + } + + /// 打开/关闭背景色浮层(色板 + 16 进制输入),草稿预填当前生效色。 + pub fn open_background_color_picker(&mut self) { + let current = self.nebula_background.unwrap_or(self.colors[NamedColor::Background]); + self.nebula_bg_hex_input = format!("#{:02X}{:02X}{:02X}", current.r, current.g, current.b); + self.nebula_bg_hex_active = false; + self.toggle_settings_dropdown(settings::SettingsDropdown::BackgroundColor); + } + + /// 点选色板某格:应用、落盘并收起浮层。 + pub fn set_background_color_option(&mut self, index: usize) { + if let Some(color) = settings::BACKGROUND_SWATCHES.get(index) { + self.nebula_bg_palette_index = index; + self.nebula_background = Some(*color); + self.persist_nebula_settings(); + } + self.close_settings_dropdown(); + self.pending_update.dirty = true; + } + + pub fn focus_bg_hex_input(&mut self) { + self.nebula_bg_hex_active = true; + self.pending_update.dirty = true; + } + + /// 追加 hex 字符:只收 `#` 与 16 进制位,总长 ≤ 7(`#RRGGBB`)。 + pub fn bg_hex_push(&mut self, ch: char) { + let ok = ch == '#' || ch.is_ascii_hexdigit(); + if ok && self.nebula_bg_hex_input.chars().count() < 7 { + self.nebula_bg_hex_input.push(ch); + self.pending_update.dirty = true; + } + } + + pub fn bg_hex_backspace(&mut self) { + if self.nebula_bg_hex_input.pop().is_some() { + self.pending_update.dirty = true; + } + } + + /// 回车应用 16 进制草稿;解析失败保持浮层与草稿原样。 + pub fn bg_hex_commit(&mut self) -> bool { + let Some(color) = settings::parse_hex_rgb(self.nebula_bg_hex_input.trim()) else { + return false; + }; + self.nebula_background = Some(color); + self.persist_nebula_settings(); + self.close_settings_dropdown(); + self.pending_update.dirty = true; + true + } + + // ---- 设置→高级→同步(WebDAV) ---- + + /// 打开设置时装载同步状态:url/username/auto_pull 来自 + /// `nebula_sync.txt`,密码/口令只查存在性(明文不进 UI 状态)。 + pub fn load_sync_state(&mut self) { + let cfg = crate::sync::SyncConfig::load(); + self.nebula_sync_inputs[0] = cfg.url; + self.nebula_sync_inputs[1] = cfg.username; + self.nebula_sync_inputs[2].clear(); + self.nebula_sync_inputs[3].clear(); + self.nebula_sync_auto_pull = cfg.auto_pull; + self.nebula_sync_secret_set = + [crate::sync::has_password(), crate::sync::has_passphrase()]; + self.nebula_sync_focus = None; + } + + /// 聚焦某个同步输入框;先提交上一个(点击切换即失焦保存)。 + pub fn focus_sync_field(&mut self, index: usize) { + if self.nebula_sync_focus == Some(index) { + return; + } + self.commit_sync_field(); + self.nebula_sync_focus = Some(index.min(3)); + self.pending_update.dirty = true; + } + + pub fn sync_field_push(&mut self, ch: char) { + let Some(index) = self.nebula_sync_focus else { return }; + if ch.is_control() { + return; + } + // url/username 拒绝空白;密码/口令允许内部空格(trim 在保存侧)。 + if index < 2 && ch.is_whitespace() { + return; + } + if self.nebula_sync_inputs[index].chars().count() < 256 { + self.nebula_sync_inputs[index].push(ch); + self.pending_update.dirty = true; + } + } + + pub fn sync_field_paste(&mut self, text: &str) { + for ch in text.chars() { + self.sync_field_push(ch); + } + } + + pub fn sync_field_backspace(&mut self) { + let Some(index) = self.nebula_sync_focus else { return }; + if self.nebula_sync_inputs[index].pop().is_some() { + self.pending_update.dirty = true; + } + } + + /// 失焦提交:url/username 写 `nebula_sync.txt`;密码/口令若有输入则 + /// 存入凭据管理器并清空缓冲。口令被弱口令闸拒绝时留在状态行。 + pub fn commit_sync_field(&mut self) { + let Some(index) = self.nebula_sync_focus.take() else { return }; + self.pending_update.dirty = true; + match index { + 0 | 1 => { + let mut cfg = crate::sync::SyncConfig::load(); + cfg.url = self.nebula_sync_inputs[0].trim().to_owned(); + cfg.username = self.nebula_sync_inputs[1].trim().to_owned(); + cfg.auto_pull = self.nebula_sync_auto_pull; + if let Err(err) = cfg.save() { + self.nebula_sync_status = Some((err, true)); + } + }, + 2 | 3 => { + let secret = std::mem::take(&mut self.nebula_sync_inputs[index]); + if secret.trim().is_empty() { + return; + } + let username = self.nebula_sync_inputs[1].trim().to_owned(); + let result = if index == 2 { + crate::sync::store_password(&username, &secret) + } else { + crate::sync::store_passphrase(&username, &secret) + }; + match result { + Ok(()) => { + self.nebula_sync_secret_set[index - 2] = true; + self.nebula_sync_status = Some(( + if index == 2 { + "WebDAV 密码已保存到凭据管理器".to_owned() + } else { + "同步口令已保存到凭据管理器".to_owned() + }, + false, + )); + }, + Err(err) => self.nebula_sync_status = Some((err, true)), + } + }, + _ => {}, + } + } + + /// Esc:丢弃当前草稿并失焦(url/username 还原为文件值)。 + pub fn cancel_sync_field(&mut self) { + let Some(index) = self.nebula_sync_focus.take() else { return }; + let cfg = crate::sync::SyncConfig::load(); + match index { + 0 => self.nebula_sync_inputs[0] = cfg.url, + 1 => self.nebula_sync_inputs[1] = cfg.username, + _ => self.nebula_sync_inputs[index].clear(), + } + self.pending_update.dirty = true; + } + + pub fn toggle_sync_auto_pull(&mut self) { + self.nebula_sync_auto_pull = !self.nebula_sync_auto_pull; + let mut cfg = crate::sync::SyncConfig::load(); + cfg.url = self.nebula_sync_inputs[0].trim().to_owned(); + cfg.username = self.nebula_sync_inputs[1].trim().to_owned(); + cfg.auto_pull = self.nebula_sync_auto_pull; + if let Err(err) = cfg.save() { + self.nebula_sync_status = Some((err, true)); + } + self.pending_update.dirty = true; + } + + /// 推/拉按钮按下:提交草稿、置忙。实际网络动作由调用侧发事件。 + pub fn begin_sync_action(&mut self) -> bool { + if self.nebula_sync_busy { + return false; + } + self.commit_sync_field(); + self.nebula_sync_busy = true; + self.nebula_sync_status = Some(("同步中…".to_owned(), false)); + self.pending_update.dirty = true; + true + } + + /// 后台同步线程回报(`NebulaSyncDone`)。 + pub fn sync_action_done(&mut self, message: &str, error: bool) { + self.nebula_sync_busy = false; + self.nebula_sync_status = Some((message.to_owned(), error)); + // 拉取可能改写了设置文件的凭据外字段;存在性也可能被首存翻转。 + self.nebula_sync_secret_set = + [crate::sync::has_password(), crate::sync::has_passphrase()]; + self.pending_update.dirty = true; + self.window.request_redraw(); + } + + /// Pick a background image through the OS file dialog, then persist it and + /// refresh the renderer's cached wallpaper. On non-Windows platforms the + /// native dialog isn't wired up, so we fall back to opening the settings + /// file for the path to be entered by hand. + /// Native save dialog for a workspace export, pre-filled with + /// `default_name`. `None` when the user cancels. + pub fn save_workspace_dialog(&self, default_name: &str) -> Option { + file_dialog::save_workspace_file(&self.window, default_name) + } + + /// Native open dialog for a workspace import. `None` when cancelled. + pub fn pick_workspace_dialog(&self) -> Option { + file_dialog::pick_workspace_file(&self.window) + } + + pub fn pick_background_image(&mut self) { + #[cfg(windows)] + { + if let Some(path) = file_dialog::pick_image_file(&self.window) { + self.nebula_background_image = Some(path); + self.persist_nebula_settings(); + self.renderer.invalidate_background_image(); + self.update_window_transparency(); + self.pending_update.dirty = true; + } + } + #[cfg(not(windows))] + { + self.open_user_config_file(); + } + } + + pub fn clear_background_image(&mut self) { + if self.nebula_background_image.take().is_some() { + self.persist_nebula_settings(); + self.renderer.invalidate_background_image(); + self.update_window_transparency(); + self.pending_update.dirty = true; + } + } + + pub fn request_toggle_background_image_cover_chrome(&mut self) { + if self.nebula_background_image_cover_chrome { + self.nebula_background_image_cover_chrome = false; + self.persist_nebula_settings(); + } else { + self.nebula_confirm = Some(NebulaConfirm::EnableBackgroundImageCoverChrome); + } + self.pending_update.dirty = true; + } + + pub fn confirm_background_image_cover_chrome(&mut self) { + self.nebula_confirm = None; + self.nebula_background_image_cover_chrome = true; + self.persist_nebula_settings(); + self.pending_update.dirty = true; + } + + pub fn open_user_config_file(&mut self) { + self.persist_nebula_settings(); + let active_lua = self.nebula_config_paths.first().filter(|path| { + path.extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| extension.eq_ignore_ascii_case("lua")) + }); + let path = active_lua.cloned().or_else(|| crate::config::source::default_lua_path().ok()); + let Some(path) = path else { + log::error!( + target: crate::logging::LOG_TARGET_CONFIG, + "Unable to determine Lua config path" + ); + return; + }; + if !path.exists() { + let language = crate::config::template::resolve_template_language( + Some(self.nebula_language_preference.as_str()), + None, + crate::config::template::system_locale().as_deref(), + ) + .unwrap_or(crate::config::template::TemplateLanguage::EnUs); + if let Err(error) = crate::config::template::ensure_user_lua_config(&path, language) { + log::error!( + target: crate::logging::LOG_TARGET_CONFIG, + "Unable to create Lua config {:?}: {error}", + path + ); + return; + } + } + #[cfg(windows)] + let _ = std::process::Command::new("notepad.exe").arg(&path).spawn(); + #[cfg(target_os = "macos")] + let _ = std::process::Command::new("open").arg(&path).spawn(); + #[cfg(all(not(windows), not(target_os = "macos")))] + let _ = std::process::Command::new("xdg-open").arg(&path).spawn(); + self.pending_update.dirty = true; + } + + pub fn reset_appearance_settings(&mut self) { + self.nebula_theme_preference = NebulaTheme::default(); + self.nebula_follow_system_theme = false; + self.window.set_theme(self.nebula_window_theme_override); + self.nebula_theme = self.nebula_theme_preference; + let defaults = self.nebula_default_colors; + self.nebula_theme.apply_term_colors(&mut self.colors, &defaults); + write_nebula_prompt_theme(self.nebula_theme); + self.nebula_window_opacity = 1.0; + self.nebula_background = None; + self.nebula_background_image = None; + self.nebula_background_image_opacity = 0.38; + self.nebula_background_image_fit = BackgroundImageFit::default(); + self.nebula_background_image_alignment = BackgroundImageAlignment::default(); + self.nebula_background_image_cover_chrome = false; + self.window.set_transparent(false); + self.persist_nebula_settings(); + self.pending_update.dirty = true; + } + + /// Toggle the command palette (Ctrl+Shift+P). `profiles` are the config's + /// quick-launch profile names, refreshed on every open so live config + /// reloads are reflected. + pub fn toggle_command_palette(&mut self, profiles: &[String]) { + self.nebula_palette.set_profiles(profiles); + self.nebula_palette.toggle(); + self.pending_update.dirty = true; + } + + /// Open the new-tab dropdown: detected shells (installed-shell order) plus + /// any config profiles. Detection runs once and is cached — the chevron + /// beside the "+" opens this, mirroring Windows Terminal's profile menu. + pub fn open_shell_menu(&mut self, profiles: &[String]) { + let shells = + self.nebula_detected_shells.get_or_insert_with(crate::shell_detect::detect_shells); + let default_shell = self + .nebula_shell_id + .as_deref() + .unwrap_or_else(|| self.nebula_shell.settings_value()); + self.nebula_palette.set_shell_menu(shells, profiles, default_shell); + self.nebula_palette.open_profiles(); + self.pending_update.dirty = true; + } + + /// Ctrl+K:开/关 shell picker——与 "+" 旁 chevron 打开的是同一份列表 + /// (settings 页的 shell 下拉是另一回事,见 `toggle_shell_picker`)。 + /// 已开着的 shell picker 再按一次收起;其他 palette 模式则切换过来。 + pub fn toggle_shell_menu(&mut self, profiles: &[String]) { + let picker_open = self.nebula_palette.is_picker() + && !self.nebula_palette.is_picking_default() + && !self.nebula_palette.is_picking_directory(); + if picker_open { + self.nebula_palette.close(); + self.pending_update.dirty = true; + } else { + self.open_shell_menu(profiles); + } + } + + /// Open a terminal-directory picker backed by the same frecency model as + /// ghost text and filesystem completion. No shell command is installed. + pub fn open_directory_picker(&mut self) { + let paths = self.directory_history.search("", 128); + self.nebula_palette.set_directories(paths); + self.nebula_palette.open_directories(); + self.pending_update.dirty = true; + } + + fn refresh_directory_picker(&mut self) { + if !self.nebula_palette.is_picking_directory() { + return; + } + let query = self.nebula_palette.query().to_owned(); + let paths = self.directory_history.search(&query, 128); + self.nebula_palette.set_directories(paths); + } + + pub fn command_palette_open(&self) -> bool { + self.nebula_palette.is_open() + } + + /// One geometry contract for palette rendering and pointer input. Picker + /// height depends on the live filtered row count, so callers must not + /// reconstruct this from window dimensions alone. + pub fn command_palette_layout(&self) -> command_palette::PaletteLayout { + let size = self.ui_size_info(); + command_palette::palette_layout( + &self.nebula_palette, + size.width(), + size.height(), + self.window.scale_factor as f32, + ) + } + + pub fn command_palette_picking_default(&self) -> bool { + self.nebula_palette.is_picking_default() + } + + pub fn command_palette_picker_open(&self) -> bool { + self.nebula_palette.is_picker() + } + + pub fn close_command_palette(&mut self) { + self.nebula_palette.close(); + self.pending_update.dirty = true; + } + + pub fn palette_input_char(&mut self, c: char) { + self.nebula_palette.input_char(c); + self.refresh_directory_picker(); + self.pending_update.dirty = true; + } + + pub fn palette_input_text(&mut self, text: &str) { + self.nebula_palette.input_text(text); + self.refresh_directory_picker(); + self.pending_update.dirty = true; + } + + pub fn palette_select_all(&mut self) { + self.nebula_palette.select_all(); + self.pending_update.dirty = true; + } + + pub fn palette_selected_text(&self) -> Option { + self.nebula_palette.selected_text() + } + + pub fn palette_backspace(&mut self) { + self.nebula_palette.backspace(); + self.refresh_directory_picker(); + self.pending_update.dirty = true; + } + + pub fn palette_move(&mut self, delta: i32) { + self.nebula_palette.move_selection(delta); + self.pending_update.dirty = true; + } + + /// Confirm the palette selection; returns the action for the input layer to + /// dispatch (only it can reach both the display and the window context). + pub fn palette_confirm(&mut self) -> Option { + let action = self.nebula_palette.confirm(); + self.pending_update.dirty = true; + action + } + + /// Mouse click on the palette's visible row `row` (0 = topmost visible): + /// select and confirm it, returning the action to dispatch. + pub fn palette_click( + &mut self, + row: usize, + max_rows: usize, + ) -> Option { + let action = self.nebula_palette.click(row, max_rows); + self.pending_update.dirty = true; + action + } + + /// Update palette hover state. `row` is the visual row index, or `None` when + /// the mouse left the palette area. + pub fn palette_hover(&mut self, pos: (f32, f32), row: Option) -> bool { + if self.nebula_palette.pointer_hover(pos, row) { + self.pending_update.dirty = true; + return true; + } + false + } + + /// Toggle the right-side drawer (directory tree / git status). + pub fn toggle_side_panel(&mut self, view: side_panel::PanelView) { + let was_open = self.nebula_side_panel.open; + if self.nebula_sftp_panel.take().is_some() { + self.nebula_side_panel.open = true; + self.nebula_side_panel.view = view; + } else { + self.nebula_side_panel.toggle(view); + } + // The drawer reserves real grid width, so opening/closing it (not + // just switching views) must reflow the grid like the left sidebar. + if self.nebula_side_panel.open != was_open { + let size = + PhysicalSize::new(self.size_info.width() as u32, self.size_info.height() as u32); + self.pending_update.set_dimensions(size); + } + self.window.request_redraw(); + self.pending_update.dirty = true; + } + + // ---- tab rename caret editing (the rename box is a real text field) ---- + + /// Insert `text` at the caret. A pending select-all is replaced wholesale + /// (type-to-overwrite), matching every native text field. + pub fn tab_rename_insert(&mut self, text: &str) { + let text: String = text.chars().filter(|character| !character.is_control()).collect(); + if text.is_empty() { + return; + } + let select_all = self.nebula_tab_rename_select_all; + let caret = self.nebula_tab_rename_caret; + let Some((_, buf)) = self.nebula_tab_rename.as_mut() else { return }; + if select_all { + buf.clear(); + self.nebula_tab_rename_select_all = false; + self.nebula_tab_rename_caret = 0; + } + let caret = if select_all { 0 } else { caret.min(buf.chars().count()) }; + let byte = buf.char_indices().nth(caret).map(|(b, _)| b).unwrap_or(buf.len()); + buf.insert_str(byte, &text); + self.nebula_tab_rename_caret = caret + text.chars().count(); + self.pending_update.dirty = true; + } + + /// Backspace at the caret; a pending select-all clears the whole name. + pub fn tab_rename_backspace(&mut self) { + let select_all = self.nebula_tab_rename_select_all; + let caret = self.nebula_tab_rename_caret; + let Some((_, buf)) = self.nebula_tab_rename.as_mut() else { return }; + if select_all { + buf.clear(); + self.nebula_tab_rename_select_all = false; + self.nebula_tab_rename_caret = 0; + } else if caret > 0 { + let caret = caret.min(buf.chars().count()); + if let Some((byte, _)) = buf.char_indices().nth(caret - 1) { + buf.remove(byte); + self.nebula_tab_rename_caret = caret - 1; + } + } + self.pending_update.dirty = true; + } + + pub fn tab_rename_select_all(&mut self) { + if let Some((_, text)) = self.nebula_tab_rename.as_ref() { + self.nebula_tab_rename_select_all = !text.is_empty(); + self.nebula_tab_rename_caret = text.chars().count(); + self.pending_update.dirty = true; + } + } + + pub fn tab_rename_selected_text(&self) -> Option { + self.nebula_tab_rename_select_all + .then(|| self.nebula_tab_rename.as_ref().map(|(_, text)| text.clone())) + .flatten() + } + + /// Move the caret by `delta` chars. A select-all collapses to the matching + /// end first (left → start, right → end) without moving further. + pub fn tab_rename_move_caret(&mut self, delta: i32) { + let Some((_, buf)) = self.nebula_tab_rename.as_ref() else { return }; + let len = buf.chars().count(); + if self.nebula_tab_rename_select_all { + self.nebula_tab_rename_select_all = false; + self.nebula_tab_rename_caret = if delta < 0 { 0 } else { len }; + } else { + let caret = self.nebula_tab_rename_caret.min(len) as i64 + delta as i64; + self.nebula_tab_rename_caret = caret.clamp(0, len as i64) as usize; + } + self.pending_update.dirty = true; + } + + /// Jump the caret to the start/end (Home/End). + pub fn tab_rename_caret_edge(&mut self, end: bool) { + let Some((_, buf)) = self.nebula_tab_rename.as_ref() else { return }; + self.nebula_tab_rename_select_all = false; + self.nebula_tab_rename_caret = if end { buf.chars().count() } else { 0 }; + self.pending_update.dirty = true; + } + + /// Place the caret from a pointer press at window-space `x`: map the + /// pixel offset from the buffer's first glyph (stashed by `draw_chrome`) + /// into a char index, honoring CJK double-width glyphs. This is what lets + /// users click where they want to edit instead of retyping the name. + pub fn tab_rename_click(&mut self, x: f32) { + let text_x = self.nebula_tab_rename_text_x; + let cell_w = self.size_info.cell_width(); + let Some((_, buf)) = self.nebula_tab_rename.as_ref() else { return }; + let mut col = ((x - text_x) / cell_w).round().max(0.0) as usize; + let mut caret = 0usize; + for c in buf.chars() { + let w = c.width().unwrap_or(0).max(1); + if col < w { + break; + } + col -= w; + caret += 1; + } + self.nebula_tab_rename_select_all = false; + self.nebula_tab_rename_caret = caret; + self.pending_update.dirty = true; + } + + /// Adopt the focused pane's cwd into the drawer (per drawn frame; cheap + /// no-op unless the drawer is open and something changed). + pub fn side_panel_sync(&mut self, cwd: Option) { + if self.sftp_view_active() { + return; + } + if self.nebula_side_panel.sync(cwd) { + self.pending_update.dirty = true; + } + } + + /// Whether the drawer currently shows the SFTP view. The SFTP panel is a + /// window-level object, but its VIEW follows the focused pane: focusing a + /// local tab flips the drawer back to the directory tree (the SFTP + /// connection stays warm for the next switch), so the tree keeps + /// following tab switches instead of being captured by one SSH session. + pub(crate) fn sftp_view_active(&self) -> bool { + self.nebula_sftp_panel.is_some() && self.nebula_sftp_routed + } + + /// Re-route the drawer to the focused pane's identity, called every draw. + /// `focused_ssh` is the pane's stable SSH destination, `None` for local + /// panes. + pub fn route_side_panel(&mut self, focused_ssh: Option<&str>) { + let routed = match (self.nebula_sftp_panel.as_ref(), focused_ssh) { + (Some(panel), Some(destination)) => panel.snapshot().destination == destination, + _ => false, + }; + if self.nebula_sftp_routed != routed { + self.nebula_sftp_routed = routed; + self.pending_update.dirty = true; + self.window.request_redraw(); + } + } + + pub fn choose_side_panel_directory(&mut self) { + let Some(path) = file_dialog::pick_side_panel_directory(&self.window) else { + return; + }; + if self.nebula_side_panel.set_custom_root(path) { + self.pending_update.dirty = true; + self.window.request_redraw(); + } + } + + pub fn follow_focused_directory(&mut self) { + if self.nebula_side_panel.clear_custom_root() { + self.pending_update.dirty = true; + self.window.request_redraw(); + } + } + + /// Geometry of the drawer for the current window size. + pub fn side_panel_layout(&self) -> side_panel::PanelLayout { + let size = self.size_info; + let scale = self.window.scale_factor as f32; + let reserve = chrome_reserve(scale); + side_panel::panel_layout( + size.width(), + size.height(), + reserve, + reserve, + scale, + self.nebula_ui_anims.right_drawer.value(), + ) + } + + pub fn open_sftp_panel( + &mut self, + destination: String, + proxy: winit::event_loop::EventLoopProxy, + ) -> Result<(), String> { + let was_open = self.nebula_side_panel.open; + let controller = crate::ssh_sftp::SftpController::new(destination, proxy, self.window.id()) + .map_err(|err| format!("无法打开 SFTP: {err}"))?; + self.nebula_side_panel.search_unfocus(false); + self.nebula_side_panel.commit_unfocus(); + self.nebula_side_panel.open = true; + self.nebula_sftp_panel = Some(sftp_panel::SftpPanel::new(controller)); + if !was_open { + let size = + PhysicalSize::new(self.size_info.width() as u32, self.size_info.height() as u32); + self.pending_update.set_dimensions(size); + } + self.pending_update.dirty = true; + self.window.request_redraw(); + Ok(()) + } + + pub fn close_sftp_panel(&mut self) { + if let Some(panel) = self.nebula_sftp_panel.take() { + panel.cancel_transfer(); + } + if self.nebula_side_panel.open { + self.nebula_side_panel.open = false; + let size = + PhysicalSize::new(self.size_info.width() as u32, self.size_info.height() as u32); + self.pending_update.set_dimensions(size); + } + self.pending_update.dirty = true; + self.window.request_redraw(); + } + + pub fn sftp_layout(&self) -> sftp_panel::SftpLayout { + sftp_panel::layout(&self.side_panel_layout(), self.window.scale_factor as f32) + } + + pub fn sftp_hit(&self, x: f32, y: f32) -> sftp_panel::SftpHit { + // A hidden SFTP view (drawer re-routed to a local pane) must not eat + // clicks that belong to the directory tree drawn in its place. + if !self.sftp_view_active() { + return sftp_panel::SftpHit::None; + } + let Some(panel) = self.nebula_sftp_panel.as_ref() else { + return sftp_panel::SftpHit::None; + }; + let working = panel.snapshot().phase == crate::ssh_sftp::SftpPhase::Working; + sftp_panel::hit_test(&self.sftp_layout(), working, x, y) + } + + pub fn sftp_set_hover(&mut self, hit: sftp_panel::SftpHit) -> bool { + self.nebula_sftp_panel.as_mut().is_some_and(|panel| panel.set_hover(hit)) + } + + pub fn sftp_click(&mut self, hit: sftp_panel::SftpHit) { + use sftp_panel::SftpHit; + match hit { + SftpHit::Close => self.close_sftp_panel(), + SftpHit::Path => { + if let Some(panel) = self.nebula_sftp_panel.as_mut() { + panel.begin_path(); + } + }, + SftpHit::Filter => { + if let Some(panel) = self.nebula_sftp_panel.as_mut() { + panel.begin_filter(); + } + }, + SftpHit::Row(index) => { + let selected = + self.nebula_sftp_panel.as_mut().and_then(|panel| panel.select_row(index)); + if let Some((entry, true)) = selected { + let navigated = + self.nebula_sftp_panel.as_mut().is_some_and(|panel| panel.navigate(&entry)); + if !navigated { + self.sftp_download_entry(entry); + } + } + }, + SftpHit::Cancel => { + if let Some(panel) = self.nebula_sftp_panel.as_ref() { + panel.cancel_transfer(); + } + }, + SftpHit::None | SftpHit::Inside => {}, + } + self.pending_update.dirty = true; + self.window.request_redraw(); + } + + pub fn sftp_refresh(&mut self) { + if let Some(panel) = self.nebula_sftp_panel.as_ref() { + panel.refresh(); + } + } + + pub fn sftp_pick_upload_files(&mut self) { + let paths = file_dialog::pick_upload_files(&self.window); + if !paths.is_empty() + && let Some(panel) = self.nebula_sftp_panel.as_ref() + { + panel.upload_paths(paths); + } + } + + pub fn sftp_pick_upload_directory(&mut self) { + if let Some(path) = file_dialog::pick_upload_directory(&self.window) + && let Some(panel) = self.nebula_sftp_panel.as_ref() + { + panel.upload_paths(vec![path]); + } + } + + pub fn sftp_begin_create_directory(&mut self) { + if let Some(panel) = self.nebula_sftp_panel.as_mut() { + panel.begin_create_directory(); + } + } + + pub fn sftp_upload_dropped_paths(&mut self, paths: Vec) -> bool { + if paths.is_empty() { + return false; + } + let Some(panel) = self.nebula_sftp_panel.as_ref() else { return false }; + panel.upload_paths(paths); + self.pending_update.dirty = true; + self.window.request_redraw(); + true + } + + pub fn sftp_download_row(&mut self, index: usize) { + if let Some(entry) = + self.nebula_sftp_panel.as_ref().and_then(|panel| panel.visible_entry(index)) + { + self.sftp_download_entry(entry); + } + } + + fn sftp_download_entry(&mut self, entry: crate::ssh_sftp::SftpEntry) { + let Some(directory) = file_dialog::pick_download_directory(&self.window) else { + return; + }; + if let Some(panel) = self.nebula_sftp_panel.as_ref() { + panel.download(entry, directory); + } + } + + pub fn sftp_begin_rename_row(&mut self, index: usize) { + let entry = self.nebula_sftp_panel.as_ref().and_then(|panel| panel.visible_entry(index)); + if let (Some(panel), Some(entry)) = (self.nebula_sftp_panel.as_mut(), entry) { + panel.begin_rename(entry); + } + } + + pub fn sftp_request_delete_row(&mut self, index: usize) { + if let Some(entry) = + self.nebula_sftp_panel.as_ref().and_then(|panel| panel.visible_entry(index)) + { + self.nebula_confirm = Some(NebulaConfirm::DeleteSftp { entry }); + } + } + + pub fn sftp_confirm_delete(&mut self, entry: crate::ssh_sftp::SftpEntry) { + self.nebula_confirm = None; + if let Some(panel) = self.nebula_sftp_panel.as_ref() { + panel.delete(entry); + } + } + + pub fn step_chrome_anims(&mut self) { + self.nebula_ui_anims.step( + !self.nebula_sidebar_collapsed, + self.nebula_side_panel.open, + self.nebula_ssh_editor_open, + ); + } + + pub fn chrome_animating(&self) -> bool { + self.nebula_ui_anims.animating(!self.nebula_sidebar_collapsed, self.nebula_side_panel.open) + } + + pub fn left_sidebar_progress(&self) -> f32 { + self.nebula_ui_anims.left_sidebar.value() + } + + pub fn left_sidebar_visible(&self) -> bool { + self.nebula_ui_anims.left_sidebar.visible(!self.nebula_sidebar_collapsed) + } + + /// DPI 变化时按同一比例重标 UI 角色字号(等价于配置字号 × 新缩放)。 + pub(crate) fn rescale_ui_font(&mut self, factor: f32) { + if factor.is_finite() && factor > 0.0 { + self.nebula_ui_font.px *= factor; + } + } + + /// UI 角色字号相对当前终端字号的比率。仅供尚未角色化的历史缩放路径 + /// (`begin_chrome_text_scaled`、链接预览的手动锚定)使用;新代码一律 + /// 走 `draw_chrome_text*` / `draw_ui_text*`,它们直接从字体角色取真实 + /// 字号与 metrics。 + pub(crate) fn ui_text_scale(&self) -> f32 { + let cur = self.font_size.as_px(); + if cur <= 0.0 || self.nebula_ui_font.px <= 0.0 { + return 1.0; + } + self.nebula_ui_font.px / cur + } + + /// [`Self::size_info`] 的 UI 版本:cell 尺寸来自 UI 字体角色的真实 + /// 栅格 metrics([`Self::refresh_ui_font`] 量取)。chrome / 设置 / + /// 浮层的布局与命中测试统一用它,与按同一角色栅格化的 UI 文本严格 + /// 同源;终端网格、damage、光标继续用原 `size_info`。 + pub(crate) fn ui_size_info(&self) -> SizeInfo { + let mut ui = self.size_info; + let (cell_w, cell_h) = self.nebula_ui_font.cell; + ui.cell_width = cell_w; + ui.cell_height = cell_h; + ui + } + + /// Pin the glyph cache's UI font role to the anchor size and refresh the + /// cell the chrome layout steps by. Runs at construction and on every + /// terminal font change — zoom, family, DPI all funnel through the font + /// update, so this is the single place the role can go stale. + 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); + 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 + // from USER instances, which never run with NEBULA_DEBUG_LOG set. + let line = format!( + "[{}] ui_anchor ratio={ratio:.3} ui_font_px={:.1} font_px={:.1} scale={:.2} term_cell={}x{} ui_cell={:?}\n", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0), + self.nebula_ui_font.px, + self.font_size.as_px(), + self.window.scale_factor, + self.size_info.cell_width, + self.size_info.cell_height, + self.nebula_ui_font.cell + ); + if let Ok(mut file) = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(nebula_data_dir().join("ui_anchor.log")) + { + use std::io::Write as _; + let _ = file.write_all(line.as_bytes()); + } + } + + /// Geometry of the rounded terminal card in physical pixels `(x, y, w, h)`. + /// The card floats on the shell backdrop: flush-ish against the sidebar on + /// the left and the top bar above (they share the shell color, so no seam + /// is needed there), with a visible [`UI_CARD_SEAM_LOGICAL`] gap of shell + /// color on the right and bottom edges. The grid's own padding + /// (`content_pad_x` / `chrome_reserve`) is larger than the card inset, so + /// all cell content lands inside the card. + pub(crate) fn terminal_card_rect(&self) -> (f32, f32, f32, f32) { + let scale = self.window.scale_factor as f32; + let s = |v: f32| (v * scale).round(); + let seam = s(UI_CARD_SEAM_LOGICAL); + // Left edge rides the sidebar's fold animation (same swift-out cubic + // as the panel slide in `chrome_tab_layout`), so collapsing the + // sidebar reads as the terminal card gliding left to claim the space + // instead of snapping. Resting expanded: just past the sidebar + // panel's right edge (`sw - 12` logical, see `chrome_tab_layout`); + // resting collapsed: the chrome margin. + let t = self.left_sidebar_progress().clamp(0.0, 1.0); + let sw = (SIDEBAR_W_LOGICAL * scale).round(); + let x = s(8.0) + t * (sw - s(4.0) - s(8.0)); + // Top edge: the top bar's bottom (margin 8 + bar height 40, matching + // `draw_chrome`), plus a seam so the card visibly floats below it. + let y = s(8.0 + 40.0) + seam; + // Right edge follows the file/git drawer the same way: as it slides + // in, the card cedes its width (drawer width + margin) plus the seam. + let dt = self.nebula_ui_anims.right_drawer.value().clamp(0.0, 1.0); + let drawer = dt * (side_panel::PANEL_W_LOGICAL * scale + s(8.0)); + let w = (self.size_info.width() - drawer - seam - x).max(0.0); + let h = (self.size_info.height() - seam - y).max(0.0); + (x, y, w, h) + } + + pub fn side_panel_visible(&self) -> bool { + self.nebula_ui_anims.right_drawer.visible(self.nebula_side_panel.open) + } + + /// Sidebar content model for `chrome_tab_layout` — the single place the + /// section states are read, so drawing / hit-testing / wheel agree. + pub(super) fn sidebar_model(&self) -> chrome::SidebarModel { + chrome::SidebarModel { + tab_count: self.nebula_tab_labels.len().max(1), + host_count: self.nebula_ssh_hosts.len(), + tabs_open: self.nebula_tabs_section_open, + hosts_open: self.nebula_hosts_section_open, + tabs_scroll: self.nebula_tabs_scroll, + hosts_scroll: self.nebula_hosts_scroll, + } + } + + /// Toggle a sidebar section's accordion fold (click on its caption). + pub fn toggle_sidebar_section(&mut self, hosts: bool) { + if hosts { + self.nebula_hosts_section_open = !self.nebula_hosts_section_open; + } else { + self.nebula_tabs_section_open = !self.nebula_tabs_section_open; + } + self.pending_update.dirty = true; + } + + /// Toggle the queue entry now; the expanded panel will consume the same + /// state in the next integration stage, so the entry's hit contract does + /// not need to change when real queue content lands. + pub fn toggle_message_queue_entry(&mut self) { + self.nebula_message_queue_entry.toggle(); + self.pending_update.dirty = true; + self.window.request_redraw(); + } + + /// Route a mouse-wheel tick over the sidebar into the section under the + /// pointer. Returns true when consumed (pointer was over a section band). + pub fn sidebar_wheel(&mut self, x: f32, y: f32, rows: i32) -> bool { + if !self.left_sidebar_visible() { + return false; + } + let layout = chrome_tab_layout( + &self.ui_size_info(), + self.window.scale_factor as f32, + self.sidebar_model(), + self.left_sidebar_progress(), + ); + let (px, _, pw, _) = layout.panel; + if pw <= 0.0 || x < px || x > px + pw { + return false; + } + let scroll = + |cur: usize, max: usize| -> usize { (cur as i32 + rows).clamp(0, max as i32) as usize }; + // Band membership includes each section's header so the wheel works + // right up against the caption. + if y >= layout.tabs_header.1 && y <= layout.tabs_band.1 { + self.nebula_tabs_scroll = scroll(self.nebula_tabs_scroll, layout.tabs_max_scroll); + } else if y >= layout.hosts_header.1 + && y <= layout.hosts_band.1.max(layout.hosts_header.1 + layout.hosts_header.3) + { + self.nebula_hosts_scroll = scroll(self.nebula_hosts_scroll, layout.hosts_max_scroll); + } else { + return false; + } + self.pending_update.dirty = true; + true + } + + /// Auto-save an SSH destination the user typed and successfully connected + /// to — armed at OSC 133;C, confirmed by a remote `NEBULA|` title or a + /// session that outlived [`crate::ssh::SAVE_MIN_SESSION`]. Tabby-style + /// recents: most recent first, deduped, capped. An already-listed host + /// only refreshes its recency (for the next launch) — the visible list + /// never jumps while the user is looking at it. + pub fn nebula_save_ssh_host(&mut self, host: &str) { + const SAVED_HOSTS_CAP: usize = 20; + if host.is_empty() { + return; + } + self.nebula_saved_hosts.retain(|h| h != host); + self.nebula_hidden_hosts.retain(|h| h != host); + self.nebula_saved_hosts.insert(0, host.to_owned()); + self.nebula_saved_hosts.truncate(SAVED_HOSTS_CAP); + if !self.nebula_ssh_hosts.iter().any(|h| h == host) { + // New host: insert below the pinned block, above everything else. + let at = self + .nebula_ssh_hosts + .iter() + .take_while(|h| self.nebula_pinned_hosts.contains(h)) + .count(); + self.nebula_ssh_hosts.insert(at, host.to_owned()); + } + self.persist_nebula_settings(); + self.pending_update.dirty = true; + } + + // ---- 键位自定义(spec 002)---- + + /// 设置页点击某行的 keycap:进入捕获态(下一次按键成为新绑定)。 + pub fn keymap_begin_capture(&mut self, row: usize) { + if row < keymap::editable_row_count() { + self.nebula_keymap_capture = Some(row); + self.nebula_keymap_capture_preview.clear(); + self.nebula_quick_hotkey_error = None; + self.pending_update.dirty = true; + } + } + + pub fn keymap_cancel_capture(&mut self) { + if self.nebula_keymap_capture.take().is_some() { + self.nebula_keymap_capture_preview.clear(); + self.pending_update.dirty = true; + } + } + + /// 捕获态的实时修饰键回显(ModifiersChanged 与纯修饰键按下都会走到 + /// 这里):按住 Ctrl 立即显示 "Ctrl+…",全部松开回到占位提示。 + pub fn keymap_capture_preview(&mut self, mods: winit::keyboard::ModifiersState) { + if self.nebula_keymap_capture.is_none() { + return; + } + let prefix = keymap::mods_prefix(mods); + if self.nebula_keymap_capture_preview != prefix { + self.nebula_keymap_capture_preview = prefix; + self.pending_update.dirty = true; + self.window.request_redraw(); + } + } + + /// 捕获完成:`combo` 归属该行动作。同 combo 的旧自定义行被移除(键随 + /// 最后写入者),该动作旧的自定义行也移除(一动作一自定义键)。 + pub fn keymap_assign(&mut self, row: usize, combo: String) { + if row == keymap::QUICK_TERMINAL_ROW { + self.nebula_keymap_capture = None; + self.nebula_keymap_capture_preview.clear(); + self.nebula_quick_terminal_hotkey = combo; + self.nebula_quick_hotkey_error = None; + self.nebula_quick_hotkey_request = Some(self.nebula_quick_terminal_hotkey.clone()); + self.persist_nebula_settings(); + self.pending_update.dirty = true; + return; + } + let action_row = row.saturating_sub(1); + let Some((action, ..)) = keymap::EDITABLE_ACTIONS.get(action_row) else { return }; + let name = keymap::action_storage_name(action); + self.nebula_keybinds.retain(|(c, a)| c != &combo && !a.eq_ignore_ascii_case(&name)); + self.nebula_keybinds.push((combo, name)); + self.keymap_commit(); + } + + /// 捕获态里按 Backspace:删除该动作的自定义绑定,回落内置默认。 + pub fn keymap_clear_custom(&mut self, row: usize) { + if row == keymap::QUICK_TERMINAL_ROW { + self.nebula_keymap_capture = None; + self.nebula_keymap_capture_preview.clear(); + self.nebula_quick_terminal_hotkey = keymap::DEFAULT_QUICK_TERMINAL_HOTKEY.to_owned(); + self.nebula_quick_hotkey_error = None; + self.nebula_quick_hotkey_request = Some(self.nebula_quick_terminal_hotkey.clone()); + self.persist_nebula_settings(); + self.pending_update.dirty = true; + return; + } + let action_row = row.saturating_sub(1); + let Some((action, ..)) = keymap::EDITABLE_ACTIONS.get(action_row) else { return }; + let name = keymap::action_storage_name(action); + self.nebula_keybinds.retain(|(_, a)| !a.eq_ignore_ascii_case(&name)); + self.keymap_commit(); + } + + fn keymap_commit(&mut self) { + self.nebula_keymap_capture = None; + self.nebula_keymap_capture_preview.clear(); + self.nebula_keymap = keymap::build_bindings(&self.nebula_keybinds); + self.persist_nebula_settings(); + self.pending_update.dirty = true; + } + + /// 取走一次性全局快捷键更新请求,由输入层送到 Processor 的全局管理器。 + pub(crate) fn take_quick_hotkey_request(&mut self) -> Option { + self.nebula_quick_hotkey_request.take() + } + + /// Processor 完成注册后的确认/回滚。失败时恢复磁盘与界面中的旧值, + /// 防止设置页把未注册的组合误显示成当前快捷键。 + pub(crate) fn quick_hotkey_registration_done( + &mut self, + requested: &str, + accepted: bool, + error: Option<&str>, + fallback: &str, + ) { + if accepted { + self.nebula_quick_hotkey_error = None; + return; + } + if self.nebula_quick_terminal_hotkey == requested { + self.nebula_quick_terminal_hotkey = fallback.to_owned(); + self.persist_nebula_settings(); + } + self.nebula_quick_hotkey_error = error.map(str::to_owned); + self.pending_update.dirty = true; + self.window.request_redraw(); + } + + pub(crate) fn persist_nebula_settings(&mut self) { + settings::nebula_settings_write(&settings::NebulaRuntimeSettings { + language: self.nebula_language_preference, + ghost: self.nebula_ghost_enabled, + accept: self.nebula_accept, + shell: self.nebula_shell, + shell_id: self.nebula_shell_id.clone(), + startup_directory: self.nebula_startup_directory.clone(), + font_family: self.nebula_font_family.clone(), + fetch: self.nebula_fetch_enabled, + powerline: self.nebula_powerline_enabled, + keep_session: self.nebula_keep_session, + opacity: self.nebula_window_opacity, + background: self.nebula_background, + background_image: self.nebula_background_image.clone(), + background_image_opacity: self.nebula_background_image_opacity, + background_image_fit: self.nebula_background_image_fit, + background_image_alignment: self.nebula_background_image_alignment, + background_image_cover_chrome: self.nebula_background_image_cover_chrome, + font_size: Some(self.font_size.as_px() / self.window.scale_factor as f32), + cursor_shape: self.nebula_cursor_shape, + cursor_blink: self.nebula_cursor_blink, + copy_on_select: self.nebula_copy_on_select, + cjk_bold_regular: self.nebula_cjk_bold_regular, + theme: self.nebula_theme_preference, + follow_system_theme: self.nebula_follow_system_theme, + pinned_hosts: self.nebula_pinned_hosts.clone(), + saved_hosts: self.nebula_saved_hosts.clone(), + hidden_hosts: self.nebula_hidden_hosts.clone(), + keybinds: self.nebula_keybinds.clone(), + quick_terminal_hotkey: self.nebula_quick_terminal_hotkey.clone(), + }); + self.nebula_settings_mtime = settings::nebula_settings_mtime(); + } + + fn reload_nebula_settings_if_changed(&mut self, config: &UiConfig) { + let mtime = settings::nebula_settings_mtime(); + if mtime == self.nebula_settings_mtime { + return; + } + + let settings = settings::nebula_settings_load(config); + self.nebula_language_preference = settings.language; + self.nebula_language = settings.language.resolved(); + self.nebula_palette.set_language(self.nebula_language); + let image_changed = settings.background_image != self.nebula_background_image; + let font_changed = settings.font_family != self.nebula_font_family; + self.nebula_theme_preference = settings.theme; + let follow_system_changed = self.nebula_follow_system_theme != settings.follow_system_theme; + self.nebula_follow_system_theme = settings.follow_system_theme; + if follow_system_changed { + self.window.set_theme(if settings.follow_system_theme { + None + } else { + self.nebula_window_theme_override + }); + if settings.follow_system_theme { + self.nebula_system_theme = + system_theme_snapshot(self.nebula_system_theme, self.window.theme()); + } + } + let active_theme = if settings.follow_system_theme { + self.nebula_system_theme + .map(|system| { + settings.theme.for_system_appearance(matches!(system, WinitTheme::Light)) + }) + .unwrap_or(settings.theme) + } else { + settings.theme + }; + if active_theme != self.nebula_theme { + // Hand-edited theme or automatic-mode setting: apply and publish + // it exactly like an in-panel selection would. + self.apply_nebula_theme(active_theme); + } + self.nebula_ghost_enabled = settings.ghost; + self.nebula_accept = settings.accept; + self.nebula_shell = settings.shell; + self.nebula_shell_id = settings.shell_id; + self.nebula_startup_directory = settings.startup_directory; + self.nebula_font_family = settings.font_family; + if font_changed { + #[cfg(windows)] + { + self.nebula_font_families = self.glyph_cache.refresh_private_fonts(); + self.nebula_font_families + .retain(|family| family != crate::font_install::REQUIRED_FONT_FAMILY); + self.nebula_font_families + .insert(0, crate::font_install::REQUIRED_FONT_FAMILY.to_owned()); + } + let font = self.effective_font(&config.font).with_size(self.font_size); + self.pending_update.set_font(font); + } + self.nebula_fetch_enabled = settings.fetch; + self.nebula_powerline_enabled = settings.powerline; + self.nebula_keep_session = settings.keep_session; + if self.nebula_cjk_bold_regular != settings.cjk_bold_regular { + // 字形层策略变了:已缓存的 bold CJK 位图作废,清缓存重栅格。 + self.nebula_cjk_bold_regular = settings.cjk_bold_regular; + self.glyph_cache.wide_bold_use_regular = settings.cjk_bold_regular; + self.reset_glyph_cache(); + } + self.nebula_window_opacity = settings.opacity; + self.nebula_background = if settings.follow_system_theme { + Some(active_theme.palette().term_bg) + } else { + settings.background + }; + self.nebula_background_image = settings.background_image; + self.nebula_background_image_opacity = settings.background_image_opacity; + self.nebula_background_image_fit = settings.background_image_fit; + self.nebula_background_image_alignment = settings.background_image_alignment; + self.nebula_background_image_cover_chrome = settings.background_image_cover_chrome; + // Sync the host lists too: another window shares the settings file, + // and skipping this would let this window's next persist overwrite a + // host that window just saved or pinned. + self.nebula_pinned_hosts = settings.pinned_hosts; + self.nebula_saved_hosts = settings.saved_hosts; + self.nebula_hidden_hosts = settings.hidden_hosts; + // Hand-edited keybind lines take effect on the next keypress; an + // in-flight capture is dropped so it can't overwrite the file edit. + self.nebula_keymap = keymap::build_bindings(&settings.keybinds); + self.nebula_keybinds = settings.keybinds; + if self.nebula_quick_terminal_hotkey != settings.quick_terminal_hotkey { + self.nebula_quick_terminal_hotkey = settings.quick_terminal_hotkey.clone(); + self.nebula_quick_hotkey_request = Some(self.nebula_quick_terminal_hotkey.clone()); + self.nebula_quick_hotkey_error = None; + } + self.nebula_keymap_capture = None; + self.nebula_ssh_hosts = merge_ssh_hosts( + &self.nebula_saved_hosts, + &self.nebula_pinned_hosts, + &self.nebula_hidden_hosts, + ); + if image_changed { + self.renderer.invalidate_background_image(); + } + self.nebula_settings_mtime = mtime; + self.update_window_transparency(); + self.pending_update.dirty = true; + } + + fn draw_background_image(&mut self) { + let Some(path) = self.nebula_background_image.as_deref() else { + return; + }; + let path = path.trim().trim_matches('"'); + if path.is_empty() { + return; + } + + // Keep PNG wallpaper loading in the renderer cache. The setting stores a + // user path verbatim (usually `D:\...` on Windows); `cover` scaling and + // alpha are handled by the image renderer. + let target = if self.nebula_background_image_cover_chrome { + (0.0, 0.0, self.size_info.width(), self.size_info.height()) + } else { + self.terminal_card_rect() + }; + // 卡片模式必须携带卡片圆角:矩形壁纸盖上去会吃掉终端卡的圆角。 + let clip_radius = if self.nebula_background_image_cover_chrome { + 0.0 + } else { + (UI_SHELL_RADIUS_LOGICAL * self.window.scale_factor as f32).round() + }; + self.renderer.draw_background_image( + &self.size_info, + Path::new(path), + self.nebula_background_image_opacity, + self.nebula_background_image_fit, + self.nebula_background_image_alignment, + target, + target, + clip_radius, + ); + } + + /// Compose the stable frame backdrop once per frame. + /// + /// 层模型(2026-07-24 修订):清屏完全透明,终端卡底永远先铺主题底 + /// 色(用户透明度),壁纸再以自身不透明度叠在其上——降低壁纸不透 + /// 明度时图片淡向主题底色(浅色主题→白、深色主题→黑),而不是透出 + /// 窗口后面的桌面(旧模型有壁纸时不画卡底,深色主题下低不透明度会 + /// 透出刺眼的白)。卡以外的壳由 chrome pass 的一体化壳层负责(同一 + /// 用户透明度)。 + fn draw_window_backdrop(&mut self, terminal_background: Rgb) { + // rgb 保留壳色:不透明窗口下 DWM 忽略 alpha,尚未被壳/卡覆盖的 + // 像素以壳色兜底而不是黑底。 + self.renderer.clear(self.nebula_theme.palette().shell_bg, 0.0); + { + let (card_x, card_y, card_w, card_h) = self.terminal_card_rect(); + let scale = self.window.scale_factor as f32; + let alpha = (self.nebula_window_opacity * 255.0).round().clamp(0.0, 255.0) as u8; + // 与 chrome 壳层的凹角同径同 round:半径差出小数像素会让两侧 + // 的圆弧 AA 错位成一圈细缝。 + let card = UiQuad::solid( + card_x, + card_y, + card_w, + card_h, + (UI_SHELL_RADIUS_LOGICAL * scale).round(), + Rgba::new( + terminal_background.r, + terminal_background.g, + terminal_background.b, + alpha, + ), + ); + self.renderer.draw_ui(&self.size_info, &[card]); + } + + // The image is intentionally independent of the terminal tint: its own + // opacity means image strength, not a value that disappears at 100% + // terminal opacity. + self.draw_background_image(); + } + + /// Whether a wallpaper path is configured for the terminal card. + fn has_background_image(&self) -> bool { + self.nebula_background_image + .as_deref() + .map(|p| !p.trim().trim_matches('"').is_empty()) + .unwrap_or(false) + } + + /// Sync the OS transparency flag with the user opacity slider. 壁纸不 + /// 透明度不再参与:卡底永远先铺主题底色,壁纸变淡是淡向主题色而非 + /// 透出窗口后面的桌面。 + fn update_window_transparency(&mut self) { + let transparent = self.nebula_window_opacity < 1.0; + self.window.set_transparent(transparent); + #[cfg(target_os = "macos")] + self.window.set_has_shadow(!transparent); + } + + #[inline] + pub fn gl_context(&self) -> &PossiblyCurrentContext { + &self.context + } + + pub fn make_not_current(&mut self) { + if self.context.is_current() { + self.context.make_not_current_in_place().expect("failed to disable context"); + } + } + + pub fn make_current(&mut self) { + let is_current = self.context.is_current(); + + // Attempt to make the context current if it's not. + let context_loss = if is_current { + self.renderer.was_context_reset() + } else { + match self.context.make_current(&self.surface) { + Err(err) if err.error_kind() == ErrorKind::ContextLost => { + info!("Context lost for window {:?}", self.window.id()); + true + }, + _ => false, + } + }; + + if !context_loss { + return; + } + + let gl_display = self.context.display(); + let gl_config = self.context.config(); + let raw_window_handle = Some(self.window.raw_window_handle()); + let context = platform::create_gl_context(&gl_display, &gl_config, raw_window_handle) + .expect("failed to recreate context."); + + // Drop the old context and renderer. + unsafe { + ManuallyDrop::drop(&mut self.renderer); + ManuallyDrop::drop(&mut self.context); + } + + // Activate new context. + let context = context.treat_as_possibly_current(); + self.context = ManuallyDrop::new(context); + self.context.make_current(&self.surface).expect("failed to reativate context after reset."); + + // Recreate renderer. + let renderer = Renderer::new(&self.context, self.renderer_preference) + .expect("failed to recreate renderer after reset"); + self.renderer = ManuallyDrop::new(renderer); + + // Resize the renderer. + self.renderer.resize(&self.size_info); + + self.reset_glyph_cache(); + self.damage_tracker.frame().mark_fully_damaged(); + + debug!("Recovered window {:?} from gpu reset", self.window.id()); + } + + fn swap_buffers(&self) { + #[allow(clippy::single_match)] + let res = match (self.surface.deref(), &self.context.deref()) { + #[cfg(not(any(target_os = "macos", windows)))] + (Surface::Egl(surface), PossiblyCurrentContext::Egl(context)) + if matches!(self.raw_window_handle, RawWindowHandle::Wayland(_)) + && !self.damage_tracker.debug => + { + let damage = self.damage_tracker.shape_frame_damage(self.size_info.into()); + surface.swap_buffers_with_damage(context, &damage) + }, + (surface, context) => surface.swap_buffers(context), + }; + if let Err(err) = res { + debug!("error calling swap_buffers: {err}"); + } + } + + /// Update font size and cell dimensions. + /// + /// This will return a tuple of the cell width and height. + fn update_font_size( + glyph_cache: &mut GlyphCache, + config: &UiConfig, + font: &Font, + ) -> (f32, f32) { + let _ = glyph_cache.update_font_size(font); + + // Compute new cell sizes. + compute_cell_size(config, &glyph_cache.font_metrics()) + } + + /// Reset glyph cache. + fn reset_glyph_cache(&mut self) { + let cache = &mut self.glyph_cache; + self.renderer.with_loader(|mut api| { + cache.reset_glyph_cache(&mut api); + }); + } + + // XXX: this function must not call to any `OpenGL` related tasks. Renderer updates are + // performed in [`Self::process_renderer_update`] right before drawing. + // + /// Process update events. + pub fn handle_update( + &mut self, + terminal: &mut Term, + // PTY resizes are deferred to the settle timer (see + // `nebula_pty_resize_pending`); the handle stays in the signature so + // the call sites don't churn if an immediate path returns. + _pty_resize_handle: &mut dyn OnResize, + message_buffer: &MessageBuffer, + search_state: &mut SearchState, + config: &UiConfig, + ) where + T: EventListener, + { + let pending_update = mem::take(&mut self.pending_update); + + let (mut cell_width, mut cell_height) = + (self.size_info.cell_width(), self.size_info.cell_height()); + + if pending_update.font().is_some() || pending_update.cursor_dirty() { + let renderer_update = self.pending_renderer_update.get_or_insert(Default::default()); + renderer_update.clear_font_cache = true + } + + // 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); + cell_width = cell_dimensions.0; + cell_height = cell_dimensions.1; + + info!("Cell size: {cell_width} x {cell_height}"); + + // Every zoom / font / DPI change funnels through a font update, + // so this is the single point where the UI font role can go + // stale. + self.refresh_ui_font(config); + + // Mark entire terminal as damaged since glyph size could change without cell size + // changes. + self.damage_tracker.frame().mark_fully_damaged(); + } + + let (mut width, mut height) = (self.size_info.width(), self.size_info.height()); + if let Some(dimensions) = pending_update.dimensions() { + width = dimensions.width as f32; + height = dimensions.height as f32; + } + + let padding = config.window.padding(self.window.scale_factor as f32); + let chrome = chrome_reserve(self.window.scale_factor as f32); + + let scale = self.window.scale_factor as f32; + let content_pad = content_pad_x(scale); + let sidebar = sidebar_width(scale, self.nebula_sidebar_collapsed); + // The file/git drawer occupies real layout space: the grid cedes its + // width (plus the window margin) on the right, exactly like the left + // sidebar reserve — it does not float over the terminal. + let drawer = if self.nebula_side_panel.open { + (side_panel::PANEL_W_LOGICAL * scale + 8.0 * scale).round() + } else { + 0.0 + }; + let mut new_size = SizeInfo::new_fully_asymmetric( + width, + height, + cell_width, + cell_height, + padding.0 + content_pad + sidebar, + padding.0 + content_pad + drawer, + padding.1 + chrome, + padding.1 + bottom_content_reserve(scale), + ); + + // Update number of column/lines in the viewport. + let search_active = search_state.history_index.is_some(); + let message_bar_lines = message_buffer.message().map_or(0, |m| m.text(&new_size).len()); + let search_lines = usize::from(search_active); + new_size.reserve_lines(message_bar_lines + search_lines); + + // Update resize increments. + if config.window.resize_increments { + let increments = self + .window + .allows_drag_resize() + .then_some(PhysicalSize::new(cell_width, cell_height)); + self.window.set_resize_increments(increments); + } + + // Resize when terminal when its dimensions have changed. + if self.size_info.screen_lines() != new_size.screen_lines + || self.size_info.columns() != new_size.columns() + { + // Defer the PTY resize to the settle timer instead of notifying + // per tick: the in-box ConPTY repaints its entire viewport on + // every resize, so drag-resizing would flood the scrollback with + // dozens of shredded repaints (and TUIs like Claude Code redraw + // storms). The grid resizes live below, so rendering stays exact; + // only the child's SIGWINCH-equivalent waits for the drag to end. + self.nebula_pty_resize_pending = true; + + // Resize terminal. + terminal.resize(new_size); + + // Resize damage tracking. + self.damage_tracker.resize(new_size.screen_lines(), new_size.columns()); + + // Flash a transient "cols × rows" HUD, skipping the first (startup) + // resize so nothing flashes when the window is first created. + if self.nebula_resize_hud_armed { + self.nebula_resize_hud = + Some(ResizeHud::new(new_size.columns(), new_size.screen_lines())); + } + self.nebula_resize_hud_armed = true; + nebula_link_log(format!( + "grid_resize {}x{} px={width}x{height} pad_x={} pad_r={} pad_y={} \ + cell={cell_width}x{cell_height} drawer={drawer} sidebar={sidebar} \ + reserved={}", + new_size.columns(), + new_size.screen_lines(), + new_size.padding_x(), + new_size.padding_right(), + new_size.padding_y(), + message_bar_lines + search_lines, + )); + } + + // Check if dimensions have changed. + if new_size != self.size_info { + // Queue renderer update. + let renderer_update = self.pending_renderer_update.get_or_insert(Default::default()); + renderer_update.resize = true; + + // Clear focused search match. + search_state.clear_focused_match(); + } + self.size_info = new_size; + } + + // NOTE: Renderer updates are split off, since platforms like Wayland require resize and other + // OpenGL operations to be performed right before rendering. Otherwise they could lock the + // back buffer and render with the previous state. This also solves flickering during resizes. + // + /// Update the state of the renderer. + pub fn process_renderer_update(&mut self) { + let renderer_update = match self.pending_renderer_update.take() { + Some(renderer_update) => renderer_update, + _ => return, + }; + + // Resize renderer. + if renderer_update.resize { + let width = NonZeroU32::new(self.size_info.width() as u32).unwrap(); + let height = NonZeroU32::new(self.size_info.height() as u32).unwrap(); + self.surface.resize(&self.context, width, height); + } + + // Ensure we're modifying the correct OpenGL context. + self.make_current(); + + if renderer_update.clear_font_cache { + self.reset_glyph_cache(); + } + + self.renderer.resize(&self.size_info); + + info!("Padding: {} x {}", self.size_info.padding_x(), self.size_info.padding_y()); + info!("Width: {}, Height: {}", self.size_info.width(), self.size_info.height()); + } + + /// Draw the screen. + /// + /// A reference to Term whose state is being drawn must be provided. + /// + /// This call may block if vsync is enabled. + /// Render a single terminal into the region described by `view`. + /// + /// This paints grid cells, cursor, overlays (search/IME/message bar) and the + /// inline ghost suggestion, but it does NOT clear (unless `clear_first`), + /// draw the window chrome, or present — those are the caller's job so that + /// multiple panes can share one frame. `force_focus` overrides the terminal's + /// own focus state for split panes (`None` keeps the real window focus). + #[allow(clippy::too_many_arguments)] + fn draw_pane( + &mut self, + mut terminal: MutexGuard<'_, Term>, + message_buffer: &MessageBuffer, + config: &UiConfig, + search_state: &mut SearchState, + pane_state: &mut NebulaPaneState, + view: SizeInfo, + force_focus: Option, + clear_first: bool, + ) { + // Override focus for split panes so the unfocused side shows a hollow + // cursor; in single-pane mode keep the real window focus state. + if let Some(focused) = force_focus { + terminal.is_focused = focused; + } + + // 把设置页的光标默认值同步进每一个被渲染的终端。事件路径只覆盖 + // "当前聚焦"的那一个 Term,新建 tab、分屏或后台 pane 都会漏掉; + // set_default_cursor_style 内部有相等短路,逐帧调用无重绘代价。 + terminal.set_default_cursor_style(self.nebula_default_cursor_style()); + + // Tell the renderer the full window height so pane viewports flip + // correctly into OpenGL's bottom-left origin — matters for top/bottom + // splits, where panes occupy different vertical bands of the window. + self.renderer.set_window_height(self.size_info.height()); + + pane_state.terminal_math.observe_program(pane_state.running_program.as_deref()); + + // Collect renderable content before the terminal is dropped. + let custom_background = self.nebula_background; + let clickable_matches = hint::visible_clickable_matches(&terminal, config); + let mut content = RenderableContent::new(config, self, &terminal, search_state, &view); + let mut grid_cells = Vec::new(); + let mut grid_pad_bg = None; + for cell in &mut content { + if grid_pad_bg.is_none() && cell.bg_alpha > 0.0 { + grid_pad_bg = Some(cell.bg); + } + grid_cells.push(cell); + } + let selection_range = content.selection_range(); + nebula_debug_log(format!( + "render_pane clear_first={clear_first} view={}x{} pad=({:.0},{:.0},{:.0},{:.0}) selection={selection_range:?}", + view.width(), + view.height(), + view.padding_x(), + view.padding_right(), + view.padding_y(), + view.padding_bottom(), + )); + let foreground_color = content.color(NamedColor::Foreground as usize); + let background_color = + custom_background.unwrap_or_else(|| content.color(NamedColor::Background as usize)); + let display_offset = content.display_offset(); + let cursor = content.cursor(); + + let cursor_point = terminal.grid().cursor.point; + // Anchors for OSC 1337 inline images (absolute-line bookkeeping). + let grid_scrolled_out = terminal.grid().scrolled_out(); + let image_anchor = grid_scrolled_out + terminal.grid().history_size(); + // Ghost text is suppressed on the alt screen (vim/less/etc.). + let alt_screen = terminal.mode().contains(TermMode::ALT_SCREEN); + let total_lines = terminal.grid().total_lines(); + let metrics = self.glyph_cache.font_metrics(); + let size_info = view; + + let vi_mode = terminal.mode().contains(TermMode::VI); + let vi_cursor_point = if vi_mode { Some(terminal.vi_mode_cursor.point) } else { None }; + #[cfg(windows)] + let line_override = if alt_screen || vi_mode || search_state.regex().is_some() { + None + } else { + Self::nebula_input_from_raw_grid(&terminal, cursor_point) + }; + #[cfg(windows)] + let row_preview = if alt_screen || vi_mode || search_state.regex().is_some() { + None + } else { + Some(Self::nebula_raw_grid_row_preview(&terminal, cursor_point)) + }; + + // 打字(含 IME 组词)不影响网格内容,扫描保持开启;一旦这里随 + // preedit 关断,中文输入的每次拼音组合都会让全部公式闪回原文。 + // + // 扫描本身不按 AI CLI 探测门控:$$/\[ \]/\( \) 定界符加上 + // plausible_math_source 已经足够抗误报,而 WSL/SSH 里跑的 AI CLI + // 在本机进程树上只留下 wsl.exe/ssh.exe,按进程门控会把远程会话的 + // 公式全部关掉。只有误报率最高的裸 $…$ 仍要求 AI CLI 上下文 + // (进程探测命中,或本 pane 已渲染过块级公式)。 + let allow_inline_dollar = pane_state.terminal_math.inline_dollar_enabled(); + let terminal_math_overlays = if !alt_screen + && !vi_mode + && search_state.regex().is_none() + && selection_range.is_none() + { + let visible_cursor = term::point_to_viewport(display_offset, cursor_point); + terminal_math::scan_visible( + &mut pane_state.terminal_math, + &terminal, + &view, + &grid_cells, + allow_inline_dollar, + visible_cursor, + foreground_color, + ) + } else { + Vec::new() + }; + let math_pixel_size = self.glyph_cache.font_size.as_px(); + let math_pixels_per_point = self.window.scale_factor as f32 * 96.0 / 72.27; + let prepared_math = terminal_math::prepare_overlays( + &mut pane_state.terminal_math, + &terminal_math_overlays, + &view, + math_pixel_size, + math_pixels_per_point, + ); + let math_coverage = + terminal_math::CoverageMask::build(&terminal_math_overlays, &prepared_math); + + // Add damage from the terminal, keeping a pane-local copy: the shared + // tracker gets flooded with a full-window mark every frame further + // down, so "did the grid actually change?" (hint invalidation) must + // be judged from the terminal's own report captured here. + let mut term_damage_full = false; + let mut term_damage_lines = Vec::new(); + match terminal.damage() { + TermDamage::Full => { + term_damage_full = true; + self.damage_tracker.frame().mark_fully_damaged(); + }, + TermDamage::Partial(damaged_lines) => { + for damage in damaged_lines { + self.damage_tracker.frame().damage_line(damage); + term_damage_lines.push(damage); + } + }, + } + terminal.reset_damage(); + + // Drop terminal as early as possible to free lock. + drop(terminal); + + // Invalidate highlighted hints if grid has changed. Only the pane + // that owns the hover may judge that: `highlighted_hint` is hit-tested + // against the focused pane, so a background pane's output (build log, + // `top`) must not tear down the foreground's highlight. + if force_focus != Some(false) { + self.validate_hint_highlights(display_offset, term_damage_full, &term_damage_lines); + } + + // OSC 1337 inline images: prune rows that scrolled out of history for + // good, then collect the ones visible in this pane's viewport for the + // single full-window draw pass in `present_frame`. + if !pane_state.inline_images.is_empty() { + let cell_h = view.cell_height(); + pane_state.inline_images.retain(|img| { + let rows = (img.height / cell_h).ceil().max(1.0) as usize; + img.abs_line + rows >= grid_scrolled_out + }); + let top_abs = (image_anchor - display_offset) as f32; + for img in &pane_state.inline_images { + let y = view.padding_y() + (img.abs_line as f32 - top_abs) * cell_h; + // Cull images entirely outside this pane's band. + if y + img.height <= view.padding_y() - cell_h + || y >= view.padding_y() + view.height() + { + continue; + } + self.nebula_frame_images.push(( + img.id, + img.rgba.clone(), + (img.px_w, img.px_h), + (view.padding_x(), y, img.width, img.height), + )); + } + } + + // Refresh the inline ghost-text suggestion. On Windows the input is read + // off the grid (screen truth, never desyncs); elsewhere the tracked + // `line_buf` is used. Only on the primary screen, never during vi/search + // overlays. + if alt_screen || vi_mode || search_state.regex().is_some() { + pane_state.suggestion.clear(); + pane_state.suggestion_key.clear(); + } else { + #[cfg(windows)] + { + // No prompt arrow before the cursor (or a mid-line edit) means we + // cannot trust a hint here — clear it rather than guess. + if !pane_state.line_buf.is_empty() + || line_override.as_ref().is_some_and(|s| !s.is_empty()) + { + nebula_debug_log(format!( + "grid_input cwd={:?} line_buf={:?} raw={:?} cursor=line:{} col:{} row={:?}", + pane_state.cwd, + pane_state.line_buf, + line_override, + cursor_point.line.0, + cursor_point.column.0, + row_preview + )); + } + match line_override { + Some(line) => { + pane_state.screen_line = line.clone(); + self.nebula_update_suggestion(pane_state, Some(line)); + }, + None => { + pane_state.screen_line.clear(); + pane_state.suggestion.clear(); + pane_state.suggestion_key.clear(); + }, + } + } + #[cfg(not(windows))] + self.nebula_update_suggestion(pane_state, None); + } + + // Add damage from nebula's UI elements overlapping terminal. + + // Nebula always redraws and presents the full window: the chrome + // (clock, ambient glow, gradient border) is painted every frame, and + // partial damage would leave terminal content (prompt, scrollback) + // stale after the window is occluded or sent to the background. + let _ = (self.visual_bell.intensity(), self.hint_state.active(), search_state.regex()); + self.damage_tracker.frame().mark_fully_damaged(); + self.damage_tracker.next_frame().mark_fully_damaged(); + + let vi_cursor_viewport_point = + vi_cursor_point.and_then(|cursor| term::point_to_viewport(display_offset, cursor)); + self.damage_tracker.damage_vi_cursor(vi_cursor_viewport_point); + self.damage_tracker.damage_selection(selection_range, display_offset); + + // Make sure this window's OpenGL context is active. The caller is + // expected to have already activated it; calling again is cheap and + // keeps `draw_pane` safe to invoke standalone. + self.make_current(); + + // Only the first pane of a frame clears the whole window; subsequent + // panes paint on top of the shared, already-cleared backdrop. + if clear_first { + // Layer model: the window clears to the opaque shell color (the + // chrome backdrop), then the terminal is painted as a rounded + // `term_bg` card floating on it. Default-background cells draw no + // background of their own (bg_alpha == 0), so they show the card. + nebula_debug_log(format!( + "render_clear path=pane window={}x{} alpha={:.3}", + self.size_info.width(), + self.size_info.height(), + self.nebula_window_opacity, + )); + self.draw_window_backdrop(background_color); + } + + // 分屏渲染时每个 pane 都有独立的 viewport/projection;否则右侧内容会沿用上一帧 + // 或左侧 pane 的坐标系,最终叠到左边而不是显示在右边。 + self.renderer.resize(&size_info); + + let mut lines = RenderLines::new(); + + // Optimize loop hint comparator. + let has_highlighted_hint = + self.highlighted_hint.is_some() || self.vi_highlighted_hint.is_some(); + + // Draw grid. + let mut powerline_icons = Vec::new(); + { + let _sampler = self.meter.sampler(); + + // Ensure macOS hasn't reset our viewport. + #[cfg(target_os = "macos")] + self.renderer.set_viewport(&size_info); + + let glyph_cache = &mut self.glyph_cache; + let highlighted_hint = &self.highlighted_hint; + let vi_highlighted_hint = &self.vi_highlighted_hint; + let damage_tracker = &mut self.damage_tracker; + let mut clickable_index = 0usize; + + let cells = grid_cells.into_iter().filter_map(|mut cell| { + // 会被公式覆盖的源码字形直接不画:公式落在真实窗口背景上, + // 不再需要事后用不透明色块把源文本盖掉。 + if !math_coverage.is_empty() && math_coverage.covers(cell.point) { + return None; + } + match cell.character { + NEBULA_FOLDER_ICON_MARKER => { + powerline_icons.push(NebulaPowerlineIcon { + kind: NebulaPowerlineIconKind::Folder, + point: cell.point, + }); + cell.character = ' '; + }, + NEBULA_GIT_BRANCH_ICON_MARKER => { + powerline_icons.push(NebulaPowerlineIcon { + kind: NebulaPowerlineIconKind::GitBranch, + point: cell.point, + }); + cell.character = ' '; + }, + _ => (), + } + + let point = term::viewport_to_point(display_offset, cell.point); + while clickable_matches + .get(clickable_index) + .is_some_and(|bounds| bounds.end() < &point) + { + clickable_index += 1; + } + let is_clickable = clickable_matches + .get(clickable_index) + .is_some_and(|bounds| bounds.contains(&point)); + if is_clickable { + // 点击目标的虚线直接继承每个 cell 的文字色;不能统一成主题色, + // 否则 ls 的目录/可执行文件颜色语义会被下划线悄悄抹平。 + cell.flags.remove(Flags::ALL_UNDERLINES); + cell.flags.insert(Flags::DASHED_UNDERLINE); + cell.underline = cell.fg; + } + + // Underline hints hovered by mouse or vi mode cursor. Persistent + // clickable ranges stay dashed; other hint states retain the + // stronger solid underline used by keyboard/vi highlighting. + if has_highlighted_hint { + let hyperlink = cell.extra.as_ref().and_then(|extra| extra.hyperlink.as_ref()); + + let should_highlight = |hint: &Option| { + hint.as_ref().is_some_and(|hint| hint.should_highlight(point, hyperlink)) + }; + if should_highlight(highlighted_hint) || should_highlight(vi_highlighted_hint) { + damage_tracker.frame().damage_point(cell.point); + if !is_clickable { + cell.flags.insert(Flags::UNDERLINE); + } + } + } + + // Update underline/strikeout. + lines.update(&cell); + + Some(cell) + }); + self.renderer.draw_cells(&size_info, glyph_cache, cells); + } + + let mut rects = lines.rects(&metrics, &size_info); + + if alt_screen { + if let Some(pad_bg) = grid_pad_bg { + let (_, card_y, _, card_h) = self.terminal_card_rect(); + let x = size_info.padding_x(); + let w = size_info.width() - size_info.padding_x() - size_info.padding_right(); + // 备用屏幕会给整张网格着色。补齐背景时只能填当前 Pane 的边缘; + // 下方 Pane 若从整张卡片顶部开始填,会在最后绘制时盖住上方 Pane。 + for (y, height) in + alt_screen_vertical_padding_bands(&self.size_info, &size_info, card_y, card_h) + .into_iter() + .flatten() + { + rects.push(RenderRect::new(x, y, w, height, pad_bg, 1.0)); + } + } + } + + if let Some(vi_cursor_point) = vi_cursor_point { + // Indicate vi mode by showing the cursor's position in the top right corner. + let line = (-vi_cursor_point.line.0 + size_info.bottommost_line().0) as usize; + let obstructed_column = Some(vi_cursor_point) + .filter(|point| point.line == -(display_offset as i32)) + .map(|point| point.column); + self.draw_line_indicator(config, total_lines, obstructed_column, line); + } else if search_state.regex().is_some() { + // Show current display offset in vi-less search to indicate match position. + self.draw_line_indicator(config, total_lines, None, display_offset); + }; + + // Draw cursor. + rects.extend(cursor.rects(&size_info, config.cursor.thickness())); + + // Push visual bell after url/underline/strikeout rects. + let visual_bell_intensity = self.visual_bell.intensity(); + if visual_bell_intensity != 0. { + let visual_bell_rect = RenderRect::new( + 0., + 0., + size_info.width(), + size_info.height(), + config.bell.color, + visual_bell_intensity as f32, + ); + rects.push(visual_bell_rect); + } + + // Handle IME positioning and search bar rendering. + let ime_position = match search_state.regex() { + Some(regex) => { + let search_label = match search_state.direction() { + Direction::Right => FORWARD_SEARCH_LABEL, + Direction::Left => BACKWARD_SEARCH_LABEL, + }; + + let search_text = Self::format_search(regex, search_label, size_info.columns()); + + // Render the search bar. + self.draw_search(config, &search_text); + + // Draw search bar cursor. + let line = size_info.screen_lines(); + let column = Column(search_text.chars().count() - 1); + + // Add cursor to search bar if IME is not active. + if self.ime.preedit().is_none() { + let fg = config.colors.footer_bar_foreground(); + let shape = CursorShape::Underline; + let cursor_width = NonZeroU32::new(1).unwrap(); + let cursor = + RenderableCursor::new(Point::new(line, column), shape, fg, cursor_width); + rects.extend(cursor.rects(&size_info, config.cursor.thickness())); + } + + Some(Point::new(line, column)) + }, + None => { + let num_lines = size_info.screen_lines(); + match vi_cursor_viewport_point { + None => term::point_to_viewport(display_offset, cursor_point) + .filter(|point| point.line < num_lines), + point => point, + } + }, + }; + + // Handle IME. + if self.ime.is_enabled() { + if let Some(point) = ime_position { + let (fg, bg) = if search_state.regex().is_some() { + (config.colors.footer_bar_foreground(), config.colors.footer_bar_background()) + } else { + (foreground_color, background_color) + }; + + self.draw_ime_preview(point, fg, bg, &mut rects, config); + } + } + + if let Some(message) = message_buffer.message() { + let search_offset = usize::from(search_state.regex().is_some()); + let text = message.text(&size_info); + + // Create a new rectangle for the background. + let start_line = size_info.screen_lines() + search_offset; + let bar = message_bar::message_bar_rect(&size_info, search_offset != 0); + + let bg = match message.ty() { + MessageType::Error => config.colors.normal.red, + MessageType::Warning => config.colors.normal.yellow, + }; + + let x = bar.x as i32; + let y = bar.y as i32; + let width = bar.width as i32; + let height = bar.height as i32; + let message_bar_rect = RenderRect::new(bar.x, bar.y, bar.width, bar.height, bg, 1.); + + // Push message_bar in the end, so it'll be above all other content. + rects.push(message_bar_rect); + + // Always damage message bar, since it could have messages of the same size in it. + self.damage_tracker.frame().add_viewport_rect(&size_info, x, y, width, height); + + // Draw rectangles. + self.renderer.draw_rects(&size_info, &metrics, rects); + + // Relay messages to the user. + let glyph_cache = &mut self.glyph_cache; + let fg = config.colors.primary.background; + for (i, message_text) in text.iter().enumerate() { + let point = Point::new(start_line + i, Column(0)); + self.renderer.draw_string( + point, + fg, + bg, + message_text.chars(), + &size_info, + glyph_cache, + ); + } + } else { + // Draw rectangles. + self.renderer.draw_rects(&size_info, &metrics, rects); + } + + terminal_math::draw_overlays( + &mut self.renderer, + &mut self.glyph_cache, + &mut pane_state.terminal_math, + &terminal_math_overlays, + &prepared_math, + &size_info, + math_pixels_per_point, + ); + + self.draw_powerline_icons(&powerline_icons, size_info); + // `draw_powerline_icons` uses the full-window UI renderer and restores + // a full-window viewport; bind the pane projection again before drawing + // the inline ghost suggestion. + self.renderer.resize(&size_info); + + // Draw inline ghost-text autosuggestion directly after the cursor, + // once everything else for the cell row is on screen. The color is + // the theme's faintest ink (not a fixed gray), so on light themes it + // stays clearly weaker than the near-black real input instead of + // colliding with it. + if !pane_state.suggestion.is_empty() && self.ime.preedit().is_none() { + if let Some(point) = term::point_to_viewport(display_offset, cursor_point) + .filter(|p| p.line < size_info.screen_lines() && p.column.0 < size_info.columns()) + { + let avail = size_info.columns() - point.column.0; + let ghost: String = pane_state.suggestion.chars().take(avail).collect(); + let ghost_fg = self.nebula_theme.skin().ink_faint; + let glyph_cache = &mut self.glyph_cache; + self.renderer.draw_string( + point, + ghost_fg, + background_color, + ghost.chars(), + &size_info, + glyph_cache, + ); + } + } + + self.draw_render_timer(config); + + // Draw hyperlink uri preview. + if has_highlighted_hint { + let cursor_point = vi_cursor_point.or(Some(cursor_point)); + self.draw_hyperlink_preview(config, cursor_point, display_offset); + } + + // Overlay scrollbar on the right edge while scrolled into history. + self.draw_scrollbar(&size_info, display_offset, total_lines); + } + + /// Draw the screen for a single, full-window terminal. + /// + /// A reference to the Term whose state is being drawn must be provided. + /// This call may block if vsync is enabled. + pub fn draw( + &mut self, + terminal: MutexGuard<'_, Term>, + scheduler: &mut Scheduler, + message_buffer: &MessageBuffer, + config: &UiConfig, + search_state: &mut SearchState, + pane_state: &mut NebulaPaneState, + ) { + let view = self.size_info; + self.make_current(); + self.reload_nebula_settings_if_changed(config); + // `None` focus → keep the terminal's real window-focus state. + self.draw_pane( + terminal, + message_buffer, + config, + search_state, + pane_state, + view, + None, + true, + ); + self.present_frame(scheduler); + } + + /// Begin a multi-pane frame: bind the GL context and refresh themed + /// settings before the per-pane draws. + pub fn begin_pane_frame(&mut self, config: &UiConfig) { + self.reload_nebula_settings_if_changed(config); + self.make_current(); + } + + /// Draw a document-viewer tab's frame: the shell backdrop and terminal + /// card exactly like a pane frame (same layer model), then the document + /// instead of a grid, then the normal chrome via `present_frame`. + pub fn draw_doc_frame( + &mut self, + doc: &mut markdown_view::DocView, + view: SizeInfo, + scheduler: &mut Scheduler, + ) { + self.renderer.set_window_height(self.size_info.height()); + + nebula_debug_log(format!( + "render_clear path=document window={}x{} alpha={:.3}", + self.size_info.width(), + self.size_info.height(), + self.nebula_window_opacity, + )); + let card_bg = self.nebula_background.unwrap_or(self.colors[NamedColor::Background]); + self.draw_window_backdrop(card_bg); + let (cx, cy, cw, ch) = self.terminal_card_rect(); + let scale = self.window.scale_factor as f32; + + // The document reads inside the card, inset off its rounded corners. + let area = ( + (cx + 4.0 * scale).max(view.padding_x()), + (cy + 4.0 * scale).max(view.padding_y()), + (cw - 8.0 * scale).min(view.width()), + (ch - 8.0 * scale).min(view.height()), + ); + let skin = self.nebula_theme.skin(); + let size = self.size_info; + markdown_view::draw( + doc, + &mut self.renderer, + &mut self.glyph_cache, + &size, + &skin, + area, + scale, + ); + + self.present_frame(scheduler); + } + + /// Draw the Settings special tab. Its controls are emitted by the chrome + /// pass so they retain the same hit geometry and icon texture pipeline as + /// the rest of Nebula, but the base is a normal tab content card. + pub fn draw_settings_frame(&mut self, scheduler: &mut Scheduler) { + self.renderer.set_window_height(self.size_info.height()); + + nebula_debug_log(format!( + "render_clear path=settings window={}x{} alpha={:.3}", + self.size_info.width(), + self.size_info.height(), + self.nebula_window_opacity, + )); + let card_bg = self.nebula_background.unwrap_or(self.colors[NamedColor::Background]); + self.draw_window_backdrop(card_bg); + + self.present_frame(scheduler); + } + + /// Draw one pane of a multi-pane layout into `view`. `clear_first` clears + /// the whole window before the first pane; later panes paint on top. + #[allow(clippy::too_many_arguments)] + pub fn draw_pane_view( + &mut self, + terminal: MutexGuard<'_, Term>, + message_buffer: &MessageBuffer, + config: &UiConfig, + search_state: &mut SearchState, + pane_state: &mut NebulaPaneState, + view: SizeInfo, + focused: bool, + clear_first: bool, + ) { + self.draw_pane( + terminal, + message_buffer, + config, + search_state, + pane_state, + view, + Some(focused), + clear_first, + ); + } + + /// Overlay split chrome over the drawn panes: dim every unfocused pane and + /// paint divider hairlines. Rectangles are screen-space `(x, y, w, h)` with + /// a top-left origin. Focus reads as a brightness difference + /// `unfocused-split-opacity`) rather than an outline. + pub fn draw_split_overlays( + &mut self, + dim_rects: &[(f32, f32, f32, f32)], + divider_rects: &[(f32, f32, f32, f32)], + ) { + let palette = self.nebula_theme.palette(); + let veil = Rgba::new(0, 0, 0, 0).with_alpha(NEBULA_UNFOCUSED_SPLIT_DIM); + let line_color = palette.edge_l.with_alpha(0.35); + + let mut quads: Vec = Vec::with_capacity(dim_rects.len() + divider_rects.len() + 1); + for &(x, y, w, h) in dim_rects { + if w > 0.0 && h > 0.0 { + quads.push(UiQuad::solid(x, y, w, h, 0.0, veil)); + } + } + for &(x, y, w, h) in divider_rects { + if w > 0.0 && h > 0.0 { + quads.push(UiQuad::solid(x, y, w, h, 0.0, line_color)); + } + } + + // Freshly split pane slides in: a bg-coloured cover anchored at the + // pane's far edge shrinks away over ~160ms (ease-out), so the new pane + // wipes in from the divider instead of popping. Timestamp-derived, no + // per-frame allocation (same discipline as the quick-terminal slide). + if let Some(mut reveal) = self.nebula_split_reveal { + reveal.motion.step(self.nebula_ui_anims.frame()); + let e = reveal.motion.value(); + if !reveal.motion.is_active() { + self.nebula_split_reveal = None; + } else { + self.nebula_split_reveal = Some(reveal); + let (x, y, w, h) = reveal.rect; + let bg = self.nebula_background.unwrap_or(Rgb::new(15, 17, 26)); + let cover = Rgba::new(bg.r, bg.g, bg.b, 255); + let (cx, cy, cw, chh) = match reveal.direction { + SplitDirection::LeftRight => (x + w * e, y, w * (1.0 - e), h), + SplitDirection::TopBottom => (x, y + h * e, w, h * (1.0 - e)), + }; + if cw > 0.5 && chh > 0.5 { + quads.push(UiQuad::solid(cx, cy, cw, chh, 0.0, cover)); + } + self.window.request_redraw(); + } + } + + self.renderer.draw_ui(&self.size_info, &quads); + } + + /// Finish a multi-pane frame: draw window chrome and present. + pub fn finish_pane_frame(&mut self, scheduler: &mut Scheduler) { + self.present_frame(scheduler); + } + + /// Paint the divider between two split panes and dim the unfocused one. + /// (Removed: superseded by `draw_split_overlays` + the layout tree in + /// `window_context/split.rs`.) + #[cfg(any())] + fn _removed_split_helpers() {} + + /// Overlay scrollbar on the right edge of a pane, shown only while scrolled + /// up into the scrollback (auto-hides at the bottom). + /// overlay-style `scrollbar`: a thin, semi-transparent thumb floating over + /// the grid's right edge, sized to the visible fraction of total content. + fn draw_scrollbar(&mut self, view: &SizeInfo, display_offset: usize, total_lines: usize) { + let Some(geo) = self.scrollbar_geometry(view, display_offset, total_lines) else { return }; + let (thumb_x, thumb_y, thumb_w, thumb_h) = geo; + + // Skinned so it reads as chrome on both light and dark themes; a bit + // more opaque while grabbed so the drag has visible feedback. + let alpha = if self.nebula_scrollbar_drag.is_some() { 0.62 } else { 0.40 }; + let thumb_color = self.nebula_theme.skin().scrollbar_thumb.with_alpha(alpha); + let quad = UiQuad::solid(thumb_x, thumb_y, thumb_w, thumb_h, thumb_w * 0.5, thumb_color); + self.renderer.draw_ui(&self.size_info, &[quad]); + } + + /// Scrollbar thumb rect `(x, y, w, h)` for a pane `view` — the single + /// source of truth shared by rendering and input hit-testing. `None` while + /// the bar is hidden (at the bottom, or no history). + fn scrollbar_geometry( + &self, + view: &SizeInfo, + display_offset: usize, + total_lines: usize, + ) -> Option<(f32, f32, f32, f32)> { + let screen_lines = view.screen_lines(); + // Nothing to show when sitting at the bottom or when there's no history. + if display_offset == 0 || total_lines <= screen_lines { + return None; + } + + let scale = self.window.scale_factor as f32; + let total = total_lines as f32; + let track_top = view.padding_y(); + let track_h = screen_lines as f32 * view.cell_height(); + if track_h <= 1.0 { + return None; + } + + // Thumb height = visible fraction of total content, with a sane minimum. + let min_thumb = (24.0 * scale).min(track_h); + let thumb_h = (track_h * (screen_lines as f32 / total)).clamp(min_thumb, track_h); + + // Lines of history above the current viewport top (0 = top, history = bottom). + let history = total_lines - screen_lines; + let above = (history - display_offset) as f32; + let max_y = (track_h - thumb_h).max(0.0); + let thumb_y = track_top + (track_h * (above / total)).clamp(0.0, max_y); + + // Float over the grid's right edge (overlay style, like macOS scrollbars). + let thumb_w = (4.0 * scale).max(2.0); + let grid_right = view.padding_x() + view.columns() as f32 * view.cell_width(); + let thumb_x = grid_right - thumb_w; + + Some((thumb_x, thumb_y, thumb_w, thumb_h)) + } + + /// Hit-test a press against the scrollbar. The 4px thumb gets a widened + /// grab zone; a hit returns the pointer's y-offset inside the thumb so the + /// drag doesn't jump. A press on the track (above/below the thumb) recenters + /// the thumb there (`grab = thumb_h / 2`). + pub fn scrollbar_grab( + &self, + view: &SizeInfo, + display_offset: usize, + total_lines: usize, + x: f32, + y: f32, + ) -> Option { + let (thumb_x, thumb_y, thumb_w, thumb_h) = + self.scrollbar_geometry(view, display_offset, total_lines)?; + let scale = self.window.scale_factor as f32; + let slop = 8.0 * scale; + // Horizontal band around the thumb column. + if x < thumb_x - slop || x > thumb_x + thumb_w + slop { + return None; + } + // Vertical: inside the track at all? + let track_top = view.padding_y(); + let track_h = view.screen_lines() as f32 * view.cell_height(); + if y < track_top || y > track_top + track_h { + return None; + } + if y >= thumb_y && y <= thumb_y + thumb_h { + Some(y - thumb_y) // grab inside the thumb + } else { + Some(thumb_h / 2.0) // track press: jump so the thumb centers on it + } + } + + /// Map a dragged pointer `y` back to a scrollback `display_offset`, + /// inverting the thumb-position math (`grab` = offset captured at press). + pub fn scrollbar_target_offset( + &self, + view: &SizeInfo, + total_lines: usize, + y: f32, + grab: f32, + ) -> usize { + let screen_lines = view.screen_lines(); + let history = total_lines.saturating_sub(screen_lines); + if history == 0 { + return 0; + } + let track_top = view.padding_y(); + let track_h = (screen_lines as f32 * view.cell_height()).max(1.0); + let above = ((y - grab - track_top) / track_h * total_lines as f32).round(); + let above = above.clamp(0.0, history as f32) as usize; + history - above + } + + /// Centered modal for confirmations and mandatory setup gates. + fn draw_confirm_modal(&mut self) { + let Some(confirm) = self.nebula_confirm.clone() else { + self.nebula_confirm_buttons = None; + return; + }; + let size = self.ui_size_info(); + let scale = self.window.scale_factor as f32; + let s = |v: f32| v * scale; + let cell_w = size.cell_width(); + let cell_h = size.cell_height(); + + // Same tokens as the settings shell (design discipline: one flat + // surface, hairline stroke, semantic color only on the primary + // action). Danger red for destructive closes, theme accent for paste. + // All from the theme skin, so light themes get a pale card + dark ink. + let sk = self.nebula_theme.skin(); + let accent = Rgba::new(sk.accent.r, sk.accent.g, sk.accent.b, 255); + let txt = sk.ink; + let dim = sk.ink_dim; + + let (title, body, danger) = match &confirm { + NebulaConfirm::EnableBackgroundImageCoverChrome => ( + "让背景图覆盖窗口控件区域?".to_owned(), + "背景图会延伸到标题栏、窗口按钮、Tab 与 SSH 侧栏下方,低对比度图片可能影响操作可见性;界面仍会保留最低不透明度保护。".to_owned(), + false, + ), + NebulaConfirm::InstallRequiredFont { .. } => ( + "建议安装终端字体".to_owned(), + "未检测到 Maple Mono Nerd Font;缺少图标时可安装后重启 Nebula。".to_owned(), + false, + ), + NebulaConfirm::ClosePane { process, .. } => ( + "关闭此分栏?".to_owned(), + format!("{process} 仍在运行,关闭会中止它。"), + true, + ), + NebulaConfirm::CloseTab { process, .. } => ( + "关闭此标签页?".to_owned(), + format!("{process} 仍在运行,关闭会中止它。"), + true, + ), + NebulaConfirm::CloseWindow { process } => ( + "关闭整个窗口?".to_owned(), + format!("{process} 仍在运行,关闭会中止它。"), + true, + ), + NebulaConfirm::Paste { lines, .. } => ( + format!("粘贴 {lines} 行文本?"), + "多行粘贴会被 shell 逐行执行,请确认来源可信。".to_owned(), + false, + ), + NebulaConfirm::DeleteSsh { host, from_config } => { + let host = truncate_tab_label(host, 28); + if *from_config { + ( + format!("隐藏 SSH 主机 {host}?"), + "只从 Nebula 隐藏;~/.ssh/config 不会修改,保存的密码将在撤销期后清除。" + .to_owned(), + true, + ) + } else { + ( + format!("删除 SSH 主机 {host}?"), + "会从主机列表移除,保存的 Windows 密码将在撤销期后清除。".to_owned(), + true, + ) + } + }, + NebulaConfirm::DeleteSftp { entry } => ( + format!("删除远端项目 {}?", truncate_tab_label(&entry.name, 28)), + if entry.kind == crate::ssh_sftp::SftpEntryKind::Directory { + "文件夹及其全部远端内容会被递归删除,此操作无法撤销。".to_owned() + } else { + "远端文件会被永久删除,此操作无法撤销。".to_owned() + }, + true, + ), + NebulaConfirm::DeleteFileTreePath { path, is_dir } => { + let name = path + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| path.display().to_string()); + ( + format!("删除 {}?", truncate_tab_label(&name, 28)), + if *is_dir { + "文件夹及其全部内容会移入回收站。".to_owned() + } else { + "文件会移入回收站。".to_owned() + }, + true, + ) + }, + }; + + let text_w = |t: &str| -> f32 { + let cols: usize = t.chars().map(|c| c.width().unwrap_or(1)).sum(); + cols as f32 * cell_w + }; + + // Buttons: right-aligned row, primary rightmost (Windows order). 文案 + // 统一"是 / 否"(2026-07-23 用户裁定)。 + // + // 2026-07-27 用户反馈:Enter / Esc 一直生效,但按钮上只有"是""否" + // 两个字,键位从没画出来——同文件的 SSH 撤销条却老实写着 Ctrl+Z, + // 标准不一致。`can_dismiss()` 恒真,故两个键位都名副其实。 + // + // 键位画成键帽(描边小方框)而不是裸文字:v0.5/v0.6 起 welcome 页的 + // 快捷键就是灰底药丸 + 亮墨的键帽(`welcome.rs` 的 `kbd`),用户记 + // 的"白色的框"就是它。那边是终端文本(ANSI + powerline 圆头字形), + // 这里走 draw_ui + chrome text,没法照搬,所以用本文件既有的惯用法 + // 手画:外圈描边 quad + 内层填充 quad,只露 1px 圆环。描边取按钮 + // 自己的墨色,深色主题下自然读作白框,浅色主题下是深框。 + let language = self.ui_language(); + let primary_label = language.pick("是", "Yes"); + let cancel_label = language.pick("否", "No"); + let primary_key = "Enter"; + let cancel_key = "Esc"; + let btn_h = s(34.0); + let btn_pad = s(18.0); + let btn_min_w = s(88.0); + // Keycap: text plus breathing room, and a hair taller than the glyph so + // the ring never clips ascenders. Gap sits between label and cap. + let cap_pad = s(6.0); + let cap_h = cell_h + s(6.0); + let key_gap = s(8.0); + let cap_w = |key: &str| text_w(key) + 2.0 * cap_pad; + let btn_w = |label: &str, key: &str| -> f32 { + (text_w(label) + key_gap + cap_w(key) + 2.0 * btn_pad).max(btn_min_w) + }; + let primary_w = btn_w(primary_label, primary_key); + let cancel_w = btn_w(cancel_label, cancel_key); + + // Card sized to title/buttons, clamped into the window, and capped at + // 520 logical px: a long body WRAPS instead of stretching the card + // into a full-width banner (2026-07-23 用户反馈"警告框太宽"). + let pad = s(26.0); + let head_w = text_w(&title).max(primary_w + s(12.0) + cancel_w); + let box_w = (head_w + 2.0 * pad).max(s(380.0)).min(s(520.0)).min(size.width() - s(32.0)); + let body_cols = (((box_w - 2.0 * pad) / cell_w).floor() as usize).max(8); + let body_lines = wrap_display_cols(&body, body_cols); + let line_h = cell_h + s(6.0); + let body_h = body_lines.len() as f32 * line_h - s(6.0); + let box_h = pad + cell_h + s(10.0) + body_h + s(24.0) + btn_h + pad * 0.75; + let bx = ((size.width() - box_w) * 0.5).max(s(16.0)); + let by = ((size.height() - box_h) * 0.5).max(s(16.0)); + + // Veil dims the whole window so the modal reads as, well, modal + // (white fog on light themes, black dim on dark — sk.veil). + let veil = UiQuad::solid(0.0, 0.0, size.width(), size.height(), 0.0, sk.veil); + // Hairline edge + flat themed card (no glow, no gradient). + let edge = UiQuad::solid( + bx - s(1.0), + by - s(1.0), + box_w + s(2.0), + box_h + s(2.0), + s(13.0), + sk.hairline, + ); + let card = UiQuad::solid(bx, by, box_w, box_h, s(12.0), sk.panel); + + // Button geometry (kept for the mouse hit-test). + let btn_y = by + box_h - pad * 0.75 - btn_h; + let primary_x = bx + box_w - pad + s(2.0) - primary_w; + let cancel_x = primary_x - s(12.0) - cancel_w; + let primary_rect = (primary_x, btn_y, primary_w, btn_h); + let cancel_rect = (cancel_x, btn_y, cancel_w, btn_h); + self.nebula_confirm_buttons = Some((primary_rect, cancel_rect)); + + let primary_fill = if danger { sk.danger } else { accent }; + // Ink first: the keycap ring is derived from the ink it wraps, so both + // buttons' text colors have to exist before the quads are built. + let on_primary = if danger { Rgb::new(255, 244, 246) } else { sk.ink_on_accent }; + // Keycap geometry, shared by the ring quads and the glyph runs below. + let cap_y = btn_y + (btn_h - cap_h) / 2.0; + let cancel_cap_x = cancel_x + btn_pad + text_w(cancel_label) + key_gap; + let primary_cap_x = primary_x + btn_pad + text_w(primary_label) + key_gap; + + let mut quads = vec![veil, edge, card]; + // Cancel: quiet ghost button (hairline + faint fill). + quads.push(UiQuad::solid( + cancel_x - s(1.0), + btn_y - s(1.0), + cancel_w + s(2.0), + btn_h + s(2.0), + s(9.0), + sk.hairline, + )); + quads.push(UiQuad::solid(cancel_x, btn_y, cancel_w, btn_h, s(8.0), sk.panel)); + quads.push(UiQuad::solid(cancel_x, btn_y, cancel_w, btn_h, s(8.0), sk.surface)); + // Primary: the single loud element on the card. + quads.push(UiQuad::solid(primary_x, btn_y, primary_w, btn_h, s(8.0), primary_fill)); + + // Keycaps: 图8 键帽规范(2026-07-29)——与 Ctrl+K/设置页共用 + // `keycap::push_chip` 配方(hairline 圈 + panel/surface 叠底), + // 不再按按钮墨色自造描边圈。chip 底是中性 panel,所以放在 accent + // 主按钮上天然成为浅色小块(图3 mockup 的 Enter 键帽即此形态)。 + // Returns its quads instead of pushing: a closure capturing `quads` + // mutably would lock it for the rest of the batch. + let cap = |x: f32, key: &str| -> Vec { + let mut out = Vec::new(); + ui::keycap::push_chip(&mut out, &sk, x, cap_y, cap_w(key), cap_h, scale); + out + }; + quads.extend(cap(cancel_cap_x, cancel_key)); + quads.extend(cap(primary_cap_x, primary_key)); + self.renderer.draw_ui(&size, &quads); + + // Text: free-pixel chrome text (no opaque cell backgrounds), left + // aligned like a native Windows dialog. + let glyph_cache = &mut self.glyph_cache; + let tx = bx + pad; + self.renderer.draw_chrome_text(&size, tx, by + pad, txt, &title, glyph_cache); + let btn_text_y = btn_y + (btn_h - cell_h) / 2.0; + // Body wraps to the card's inner width; lines carry a small leading. + let mut line_y = by + pad + cell_h + s(10.0); + for line in &body_lines { + self.renderer.draw_chrome_text(&size, tx, line_y, dim, line, glyph_cache); + line_y += line_h; + } + self.renderer.draw_chrome_text( + &size, + cancel_x + btn_pad, + btn_text_y, + txt, + cancel_label, + glyph_cache, + ); + // Key text is centered in its cap. The cap shares the button's text + // centerline by construction, so `btn_text_y` needs no adjustment. + // Ink stays full strength: the ring already marks this run as a key, + // dimming it too would push it under the contrast floor. + self.renderer.draw_chrome_text( + &size, + cancel_cap_x + cap_pad, + btn_text_y, + txt, + cancel_key, + glyph_cache, + ); + // Danger keeps pale ink (red is dark in both modes); the accent + // button contrast flips with the theme. + self.renderer.draw_chrome_text( + &size, + primary_x + btn_pad, + btn_text_y, + on_primary, + primary_label, + glyph_cache, + ); + // 键帽文字统一走 chip 自己的墨(sk.ink):chip 底是中性 panel, + // 不随按钮填充翻转,主按钮上的键名也保持可读。 + self.renderer.draw_chrome_text( + &size, + primary_cap_x + cap_pad, + btn_text_y, + txt, + primary_key, + glyph_cache, + ); + } + + /// Bottom-center reversible-action bar for SSH deletion. Its action rect is + /// published to input after layout, keeping hover/click geometry identical + /// to the pixels on screen. + /// 助手建议条(spec 001):底部居中浮条,SSH 撤销条同款组件语言(中性 + /// 壳、accent/danger 只在 ✦/⚠ 一处,渐变预算不动)。Pending 一行"正在 + /// 分析",Ready 是图标 + 命令 + 暗色解释 + 键位提示;一律只贴不执行。 + /// 撤销条在场时让位——它 8 秒自清,之后建议条自然浮现。 + fn draw_ai_fix_bar(&mut self) { + use crate::ai_assistant::AiFixState; + if self.nebula_ssh_delete_undo.is_some() { + return; + } + let Some(state) = self.nebula_ai_fix_bar.clone() else { return }; + + let size = self.ui_size_info(); + let scale = self.window.scale_factor as f32; + let s = |value: f32| value * scale; + let cell_w = size.cell_width(); + let cell_h = size.cell_height(); + let sk = self.nebula_theme.skin(); + let language = self.ui_language(); + let text_cols = + |text: &str| -> usize { text.chars().map(|ch| ch.width().unwrap_or(1).max(1)).sum() }; + + let accent = Rgba::new(sk.accent.r, sk.accent.g, sk.accent.b, 255); + let (icon, icon_color, command, explain, hint) = match &state { + AiFixState::Pending { .. } => ( + "✦", + accent, + language.pick("正在分析失败原因…", "Analyzing failure…").to_owned(), + String::new(), + String::new(), + ), + AiFixState::Ready { fix, .. } => ( + if fix.danger { "⚠" } else { "✦" }, + if fix.danger { sk.danger } else { accent }, + fix.command.clone(), + fix.explain.clone(), + language.pick("Ctrl+. 贴入 · Esc 关闭", "Ctrl+. paste · Esc dismiss").to_owned(), + ), + }; + + // Budget: icon + command are non-negotiable; the explain is the first + // thing dropped, then the command itself is HEAD-truncated (unlike + // paths, a command's identity lives at its start). + let pad = s(14.0); + let gap = s(10.0); + let max_w = size.width() - s(24.0); + let fixed = pad * 2.0 + cell_w * 2.0 + gap + text_cols(&hint) as f32 * cell_w; + let cmd_budget = (((max_w - fixed) / cell_w) as usize).max(12); + let command = truncate_tab_label(&command, cmd_budget.min(96)); + let explain_budget = + cmd_budget.saturating_sub(text_cols(&command)).saturating_sub(3).min(60); + let explain = if text_cols(&explain) + 8 > explain_budget { + String::new() + } else { + explain + }; + + let mut content_cols = 2 + text_cols(&command); + if !explain.is_empty() { + content_cols += 3 + text_cols(&explain); + } + if !hint.is_empty() { + content_cols += 2 + text_cols(&hint); + } + let bar_h = s(44.0).max(cell_h + s(12.0)); + let bar_w = (pad * 2.0 + content_cols as f32 * cell_w).max(s(320.0)).min(max_w); + let bar_x = (size.width() - bar_w) * 0.5; + let bar_y = size.height() - bar_h - s(18.0); + + let quads = vec![ + UiQuad::glow( + bar_x - s(10.0), + bar_y - s(8.0), + bar_w + s(20.0), + bar_h + s(16.0), + Rgba::new(0, 0, 0, 72), + ), + UiQuad::solid( + bar_x - s(1.0), + bar_y - s(1.0), + bar_w + s(2.0), + bar_h + s(2.0), + s(11.0), + sk.hairline, + ), + UiQuad::solid(bar_x, bar_y, bar_w, bar_h, s(10.0), sk.panel), + ]; + self.renderer.draw_ui(&size, &quads); + + let text_y = bar_y + (bar_h - cell_h) * 0.5; + let mut x = bar_x + pad; + let gc = &mut self.glyph_cache; + self.renderer.draw_chrome_text( + &size, + x, + text_y, + Rgb::new(icon_color.r, icon_color.g, icon_color.b), + icon, + gc, + ); + x += cell_w * 2.0; + self.renderer.draw_chrome_text(&size, x, text_y, sk.ink_strong, &command, gc); + x += text_cols(&command) as f32 * cell_w; + if !explain.is_empty() { + self.renderer.draw_chrome_text( + &size, + x + cell_w, + text_y, + sk.ink_dim, + &format!("— {explain}"), + gc, + ); + x += (3 + text_cols(&explain)) as f32 * cell_w; + } + if !hint.is_empty() { + let hint_x = (bar_x + bar_w - pad - text_cols(&hint) as f32 * cell_w).max(x + gap); + self.renderer.draw_chrome_text(&size, hint_x, text_y, sk.ink_dim, &hint, gc); + } + } + + fn draw_ssh_delete_undo(&mut self) { + let Some(undo) = self.nebula_ssh_delete_undo.as_ref() else { + self.nebula_ssh_delete_undo_rect = None; + self.nebula_ssh_delete_undo_hover = false; + return; + }; + if undo.started_at.elapsed() >= SSH_DELETE_UNDO_DURATION { + self.expire_ssh_delete_undo(); + return; + } + + let size = self.ui_size_info(); + let scale = self.window.scale_factor as f32; + let s = |value: f32| value * scale; + let cell_w = size.cell_width(); + let cell_h = size.cell_height(); + let sk = self.nebula_theme.skin(); + + let fixed_cols = 20usize; + let host_budget = (((size.width() - s(300.0)).max(cell_w * 8.0) / cell_w) as usize) + .saturating_sub(fixed_cols) + .max(8); + let host = truncate_tab_label(&undo.host, host_budget.min(28)); + let message = if undo.from_config { + format!("已隐藏 {host}(SSH config 未修改)") + } else { + format!("已移除 {host}") + }; + let hint = "Ctrl+Z"; + let action = "撤销"; + let text_cols = + |text: &str| -> usize { text.chars().map(|ch| ch.width().unwrap_or(1).max(1)).sum() }; + + let pad = s(14.0); + let gap = s(12.0); + let action_w = s(76.0); + let bar_h = s(48.0).max(cell_h + s(12.0)); + let content_w = (text_cols(&message) + text_cols(hint) + 2) as f32 * cell_w; + let bar_w = + (pad * 2.0 + content_w + gap + action_w).max(s(360.0)).min(size.width() - s(24.0)); + let bar_x = (size.width() - bar_w) * 0.5; + let bar_y = size.height() - bar_h - s(18.0); + let action_rect = + (bar_x + bar_w - pad - action_w, bar_y + (bar_h - s(34.0)) * 0.5, action_w, s(34.0)); + self.nebula_ssh_delete_undo_rect = Some(action_rect); + + let mut quads = vec![ + UiQuad::glow( + bar_x - s(10.0), + bar_y - s(8.0), + bar_w + s(20.0), + bar_h + s(16.0), + Rgba::new(0, 0, 0, 72), + ), + UiQuad::solid( + bar_x - s(1.0), + bar_y - s(1.0), + bar_w + s(2.0), + bar_h + s(2.0), + s(11.0), + sk.hairline, + ), + UiQuad::solid(bar_x, bar_y, bar_w, bar_h, s(10.0), sk.panel), + UiQuad::solid( + action_rect.0, + action_rect.1, + action_rect.2, + action_rect.3, + s(7.0), + if self.nebula_ssh_delete_undo_hover { sk.hover_strong } else { sk.surface }, + ), + ]; + if self.nebula_ssh_delete_undo_hover { + quads.push(UiQuad::solid( + action_rect.0, + action_rect.1 + action_rect.3 - s(2.0), + action_rect.2, + s(2.0), + s(1.0), + Rgba::new(sk.accent.r, sk.accent.g, sk.accent.b, 220), + )); + } + self.renderer.draw_ui(&size, &quads); + + let text_y = bar_y + (bar_h - cell_h) * 0.5; + let message_x = bar_x + pad; + self.renderer.draw_chrome_text( + &size, + message_x, + text_y, + sk.ink, + &message, + &mut self.glyph_cache, + ); + let hint_x = action_rect.0 - gap - text_cols(hint) as f32 * cell_w; + self.renderer.draw_chrome_text( + &size, + hint_x, + text_y, + sk.ink_faint, + hint, + &mut self.glyph_cache, + ); + let action_x = action_rect.0 + (action_rect.2 - text_cols(action) as f32 * cell_w) * 0.5; + let action_y = action_rect.1 + (action_rect.3 - cell_h) * 0.5; + self.renderer.draw_chrome_text_styled( + &size, + action_x, + action_y, + if self.nebula_ssh_delete_undo_hover { sk.ink_strong } else { sk.accent }, + nebula_terminal::term::cell::Flags::BOLD, + action, + &mut self.glyph_cache, + ); + } + + /// Draw the window chrome and present the accumulated frame. + /// Overlay a transient, fading "cols × rows" HUD centered in the window, + /// shown briefly after a resize (a resize overlay HUD). Keeps requesting + /// redraws until it fades out, then clears itself. + fn draw_resize_hud(&mut self) { + let Some(mut hud) = self.nebula_resize_hud else { return }; + hud.opacity.step(self.nebula_ui_anims.frame()); + if !hud.opacity.is_active() { + self.nebula_resize_hud = None; + return; + } + self.nebula_resize_hud = Some(hud); + let cols = hud.columns; + let rows = hud.rows; + let fade = hud.opacity.value().clamp(0.0, 1.0); + + // UI-anchored metrics: the HUD is chrome, so its box and label must + // not inflate with the terminal zoom it is reporting. + let size = self.ui_size_info(); + let scale = self.window.scale_factor as f32; + let cw = size.cell_width(); + let ch = size.cell_height(); + + let text = format!("{cols} × {rows}"); + let text_cols: usize = text.chars().map(|c| c.width().unwrap_or(1)).sum(); + + // Centered translucent rounded box (fades out), skinned by the theme + // so it reads as chrome on light panels too. + let sk = self.nebula_theme.skin(); + let hud_rgb = Rgb::new(sk.panel.r, sk.panel.g, sk.panel.b); + let pad = 12.0 * scale; + let box_w = text_cols as f32 * cw + 2.0 * pad; + let box_h = ch + 2.0 * pad; + let box_x = ((size.width() - box_w) * 0.5).max(0.0); + let box_y = ((size.height() - box_h) * 0.5).max(0.0); + let bg = Rgba::new(hud_rgb.r, hud_rgb.g, hud_rgb.b, 0).with_alpha(0.85 * fade); + let quad = UiQuad::solid(box_x, box_y, box_w, box_h, 8.0 * scale, bg); + self.renderer.draw_ui(&size, &[quad]); + + // The label shares the box's pixel coordinate system — the old + // grid-cell placement centered on the TERMINAL area (whose origin + // carries the asymmetric sidebar padding), so the text drifted out of + // the window-centered box whenever the sidebar was open. Ink fades + // with the box by mixing toward the panel color. + let mix = |a: u8, b: u8| (a as f32 * fade + b as f32 * (1.0 - fade)).round() as u8; + let ink = Rgb::new( + mix(sk.ink_strong.r, hud_rgb.r), + mix(sk.ink_strong.g, hud_rgb.g), + mix(sk.ink_strong.b, hud_rgb.b), + ); + let glyph_cache = &mut self.glyph_cache; + self.renderer.draw_chrome_text( + &size, + box_x + pad, + box_y + pad, + ink, + &text, + glyph_cache, + ); + + // Keep the frame loop alive so the HUD animates out. + self.window.request_redraw(); + } + + fn present_frame(&mut self, scheduler: &mut Scheduler) { + // 本帧的 UI 锚定比率:chrome/设置/浮层文本按它反向补偿终端缩放。 + // 终端网格与文档正文不经过 chrome-text 路径,不受影响。 + let ui_scale = self.ui_text_scale(); + self.renderer.set_ui_text_scale(ui_scale); + nebula_debug_log(format!( + "render_present window={}x{} pane_view={} frame_images={} chrome_logos={}", + self.size_info.width(), + self.size_info.height(), + self.nebula_pane_view.is_some(), + self.nebula_frame_images.len(), + self.nebula_chrome_logo_draws.len(), + )); + // OSC 1337 inline images collected by the pane passes: draw above the + // cells, below the chrome/modals. + if !self.nebula_frame_images.is_empty() { + let size = self.size_info; + let images = std::mem::take(&mut self.nebula_frame_images); + for (id, rgba, px, rect) in &images { + self.renderer.draw_inline_image(&size, *id, rgba, *px, *rect); + } + } + + // Draw Nebula window chrome (title bar and tab sidebar). + chrome::draw_chrome(self); + + // AI brand logos staged by the chrome pass: drawn only now, after the + // last chrome text flush, because draw_inline_image's viewport/blend + // round-trip poisons any glyph batch that follows it. + if !self.nebula_chrome_logo_draws.is_empty() { + let size = self.size_info; + let logos = std::mem::take(&mut self.nebula_chrome_logo_draws); + for (id, rgba, px, rect) in &logos { + self.renderer.draw_inline_image(&size, *id, rgba, *px, *rect); + } + } + + // Transient resize HUD painted on top of the chrome. + self.draw_resize_hud(); + context_menu::draw(self); + self.draw_ssh_delete_undo(); + self.draw_ai_fix_bar(); + self.draw_ssh_editor_modal(); + self.draw_confirm_modal(); + + // Notify winit that we're about to present. + self.window.pre_present_notify(); + + // Highlight damage for debugging. + if self.damage_tracker.debug { + let metrics = self.glyph_cache.font_metrics(); + let damage = self.damage_tracker.shape_frame_damage(self.size_info.into()); + let mut rects = Vec::with_capacity(damage.len()); + self.highlight_damage(&mut rects); + self.renderer.draw_rects(&self.size_info, &metrics, rects); + } + + // Clearing debug highlights from the previous frame requires full redraw. + self.swap_buffers(); + + if matches!(self.raw_window_handle, RawWindowHandle::Xcb(_) | RawWindowHandle::Xlib(_)) { + // On X11 `swap_buffers` does not block for vsync. However the next OpenGl command + // will block to synchronize (this is `glClear` in Nebula), which causes a + // permanent one frame delay. + self.renderer.finish(); + } + + // XXX: Request the new frame after swapping buffers, so the + // time to finish OpenGL operations is accounted for in the timeout. + if !matches!(self.raw_window_handle, RawWindowHandle::Wayland(_)) { + self.request_frame(scheduler); + } + + self.damage_tracker.swap_damage(); + } + + /// Geometry that input and hint hit-testing should use: the focused pane's + /// half-width view when a split is active, otherwise the full window. + #[inline] + pub fn pane_view(&self) -> SizeInfo { + self.nebula_pane_view.unwrap_or(self.size_info) + } + + /// Update to a new configuration. + pub fn update_config(&mut self, config: &UiConfig) { + self.nebula_config_paths.clone_from(&config.config_paths); + self.damage_tracker.debug = config.debug.highlight_damage; + self.visual_bell.update_config(&config.bell); + // Refresh the base scheme, then re-apply the active theme's restyle. + self.nebula_default_colors = List::from(&config.colors); + let defaults = self.nebula_default_colors; + self.nebula_theme.apply_term_colors(&mut self.colors, &defaults); + } + + /// Update the mouse/vi mode cursor hint highlighting. + /// + /// This will return whether the highlighted hints changed. + pub fn update_highlighted_hints( + &mut self, + term: &Term, + config: &UiConfig, + mouse: &Mouse, + modifiers: ModifiersState, + ) -> bool { + // Update vi mode cursor hint. + let vi_highlighted_hint = if term.mode().contains(TermMode::VI) { + let mods = ModifiersState::all(); + let point = term.vi_mode_cursor.point; + hint::highlighted_at(term, config, point, mods) + } else { + None + }; + let mut dirty = vi_highlighted_hint != self.vi_highlighted_hint; + self.vi_highlighted_hint = vi_highlighted_hint; + self.vi_highlighted_hint_age = 0; + + // Force full redraw if the vi mode highlight was cleared. + if dirty { + self.damage_tracker.frame().mark_fully_damaged(); + } + + // Abort if mouse highlighting conditions are not met. + if !self.window.mouse_visible() + || !mouse.inside_text_area + || !term.selection.as_ref().is_none_or(Selection::is_empty) + { + if self.highlighted_hint.take().is_some() { + self.damage_tracker.frame().mark_fully_damaged(); + dirty = true; + } + return dirty; + } + + // Find highlighted hint at mouse position. + let point = mouse.point(&self.pane_view(), term.grid().display_offset()); + let highlighted_hint = hint::highlighted_at(term, config, point, modifiers); + + // Update cursor shape. + if highlighted_hint.is_some() { + // If mouse changed the line, we should update the hyperlink preview, since the + // highlighted hint could be disrupted by the old preview. + dirty = self.hint_mouse_point.is_some_and(|p| p.line != point.line); + self.hint_mouse_point = Some(point); + self.window.set_mouse_cursor(CursorIcon::Pointer); + } else if self.highlighted_hint.is_some() { + self.hint_mouse_point = None; + if term.mode().intersects(TermMode::MOUSE_MODE) && !term.mode().contains(TermMode::VI) { + self.window.set_mouse_cursor(CursorIcon::Default); + } else { + // Nebula: normal arrow over the terminal area (no I-beam). + self.window.set_mouse_cursor(CursorIcon::Default); + } + } + + let mouse_highlight_dirty = self.highlighted_hint != highlighted_hint; + dirty |= mouse_highlight_dirty; + self.highlighted_hint = highlighted_hint; + self.highlighted_hint_age = 0; + + // Force full redraw if the mouse cursor highlight was changed. + if mouse_highlight_dirty { + self.damage_tracker.frame().mark_fully_damaged(); + } + + dirty + } + + #[inline(never)] + /// Append a typed character to the prompt-line buffer that backs the + /// history hint. + pub fn nebula_input_char(state: &mut NebulaPaneState, c: char) { + state.line_buf.push(c); + state.touched = true; + state.suggestion.clear(); + state.suggestion_key.clear(); + nebula_debug_log(format!("input_char c={c:?} line_buf={:?}", state.line_buf)); + } + + /// Drop the last character (Backspace) from the prompt-line buffer. + pub fn nebula_input_backspace(state: &mut NebulaPaneState) { + state.line_buf.pop(); + state.touched = true; + state.suggestion.clear(); + state.suggestion_key.clear(); + nebula_debug_log(format!("input_backspace line_buf={:?}", state.line_buf)); + } + + /// Drop the previous whitespace-delimited token, matching the common + /// Ctrl+W/Ctrl+Backspace shell behavior closely enough for prompt hints. + pub fn nebula_input_delete_word(state: &mut NebulaPaneState) { + state.touched = true; + while state.line_buf.ends_with(char::is_whitespace) { + state.line_buf.pop(); + } + while state.line_buf.chars().last().is_some_and(|c| !c.is_whitespace()) { + state.line_buf.pop(); + } + state.suggestion.clear(); + state.suggestion_key.clear(); + nebula_debug_log(format!("input_delete_word line_buf={:?}", state.line_buf)); + } + + /// Merge pasted literal text into the prompt-line buffer. Multi-line or + /// escape-bearing paste can execute commands or move the cursor, so we + /// invalidate instead of guessing; Nushell avoids this class of bug by + /// owning the editor buffer directly via Reedline. + pub fn nebula_input_text(state: &mut NebulaPaneState, text: &str) { + if text.contains(['\r', '\n']) || text.chars().any(|c| c.is_control() && c != '\t') { + nebula_debug_log(format!("input_text_clear text={text:?}")); + Self::nebula_clear_line(state); + return; + } + + state.line_buf.push_str(text); + state.touched = true; + state.suggestion.clear(); + state.suggestion_key.clear(); + nebula_debug_log(format!("input_text text={text:?} line_buf={:?}", state.line_buf)); + } + + /// Commit the current line to history (on Enter) and reset the buffer. + /// + /// `screen_line` (the input read off the grid, i.e. what the shell's own + /// editor really contained) wins over the keystroke-reconstructed + /// `line_buf`: the latter desyncs on cursor motion / completion / history + /// recall and used to commit spliced garbage like "laudeclaude", which the + /// hint would then resurface as a command the user never typed. + pub fn nebula_commit_line(&mut self, state: &mut NebulaPaneState) { + // On Windows the grid read is the only source that sees tab + // completions; when it failed (no prompt arrow — cmd/ssh/REPL — or a + // mid-line edit) the keystroke buffer likely holds spliced garbage, + // and recording that would resurface it forever as a bogus ghost hint + // (truncated CJK paths were the visible symptom). Better no history + // entry than a corrupted one. + #[cfg(windows)] + let line = state.screen_line.trim().to_owned(); + #[cfg(not(windows))] + let line = if state.screen_line.trim().is_empty() { + state.line_buf.trim() + } else { + state.screen_line.trim() + } + .to_owned(); + nebula_debug_log(format!( + "input_commit cwd={:?} line={line:?} line_buf={:?} screen_line={:?}", + state.cwd, state.line_buf, state.screen_line + )); + self.nebula_history.record(&line, &state.cwd); + // Kept for CommandStart (OSC 133;C): by the time it arrives from the + // PTY these buffers are already cleared, so the program identity for + // the tab icon has to be captured here. Fall back to the keystroke + // buffer so the icon still resolves when the grid read failed — its + // first token is good enough for program identity. + state.last_committed = + if line.is_empty() { state.line_buf.trim().to_owned() } else { line }; + Self::nebula_clear_line(state); + } + + /// Feed the shared directory model from an authoritative shell report. + pub fn nebula_record_directory(&self, cwd: &str) { + self.directory_history.record(cwd); + } + + /// Reset the prompt-line buffer (Enter, Ctrl-C, or any non-text key). + pub fn nebula_clear_line(state: &mut NebulaPaneState) { + if !state.line_buf.is_empty() || !state.suggestion.is_empty() { + nebula_debug_log(format!( + "input_clear line_buf={:?} suggestion={:?}", + state.line_buf, state.suggestion + )); + } + state.line_buf.clear(); + state.screen_line.clear(); + state.suggestion.clear(); + state.suggestion_key.clear(); + } + + /// Read the real, echoed input off the cursor's grid row on Windows. + /// + /// The PowerShell profile renders the active input line as `❯ `, so + /// the user's current input is exactly the run of cells from just past the + /// last prompt arrow up to the cursor column. Reading the grid (the screen + /// truth PSReadLine itself produced) sidesteps the keystroke-reconstructed + /// `line_buf`, which desyncs the instant the cursor moves, Tab-completes or + /// recalls history — the "hint flickers in and out" bug. + /// + /// Returns `None` (suppressing the hint) when no prompt arrow precedes the + /// cursor, or when non-space cells sit after the cursor on the same row — + /// i.e. a mid-line edit, where a trailing ghost would be misplaced (fish and + /// PSReadLine suppress the hint there too). + #[cfg(windows)] + fn nebula_raw_grid_row_preview(terminal: &Term, cursor: Point) -> String { + let grid = terminal.grid(); + let columns = grid.columns(); + let mut text = String::with_capacity(columns); + let mut arrow_cols = Vec::new(); + + for col in 0..columns { + let cell: &Cell = &grid[cursor.line][Column(col)]; + if cell.flags.intersects(Flags::WIDE_CHAR_SPACER | Flags::LEADING_WIDE_CHAR_SPACER) { + continue; + } + if cell.c == NEBULA_PROMPT_ARROW { + arrow_cols.push(col); + } + text.push(cell.c); + } + + while text.ends_with(' ') { + text.pop(); + } + + format!( + "line={} col={} arrows={arrow_cols:?} text={text:?}", + cursor.line.0, cursor.column.0 + ) + } + + #[cfg(windows)] + pub(crate) fn nebula_input_from_raw_grid( + terminal: &Term, + cursor: Point, + ) -> Option { + let grid = terminal.grid(); + let columns = grid.columns(); + let cursor_col = cursor.column.0.min(columns); + + // A cursor mid-wrap means the input continues on the row below (a + // mid-line edit); a hint anchored here would be wrong. + if grid[cursor.line][Column(columns - 1)].flags.contains(Flags::WRAPLINE) { + return None; + } + + // Suppress when anything non-space follows the cursor on this row. + for col in cursor_col..columns { + let cell: &Cell = &grid[cursor.line][Column(col)]; + if cell.flags.intersects(Flags::WIDE_CHAR_SPACER | Flags::LEADING_WIDE_CHAR_SPACER) { + continue; + } + if !cell.c.is_whitespace() { + return None; + } + } + + // A long input soft-wraps — CJK paths hit the margin fast at two + // columns per char — leaving the prompt arrow rows ABOVE the cursor. + // Walk up while the row above ends in WRAPLINE to the logical line's + // first row (bounded, so a pathological grid can't spin every frame). + let topmost = grid.topmost_line().0; + let mut first_row = cursor.line.0; + while first_row > topmost + && cursor.line.0 - first_row < 64 + && grid[Line(first_row - 1)][Column(columns - 1)].flags.contains(Flags::WRAPLINE) + { + first_row -= 1; + } + + // Rebuild the logical line from its first row down to the cursor, + // remembering the last prompt arrow before it. This deliberately reads + // the raw terminal grid, not RenderableCell: renderables omit + // default-background spaces, and that was collapsing `cd D:\te` into + // `cdD:\te`, making directory-history hints impossible to parse. + let mut text = String::with_capacity(columns); + let mut arrow_pos = None; + for row in first_row..=cursor.line.0 { + let row_end = if row == cursor.line.0 { cursor_col } else { columns }; + for col in 0..row_end { + let cell: &Cell = &grid[Line(row)][Column(col)]; + if cell.flags.intersects(Flags::WIDE_CHAR_SPACER | Flags::LEADING_WIDE_CHAR_SPACER) + { + continue; + } + if cell.c == NEBULA_PROMPT_ARROW { + arrow_pos = Some(text.len()); + } + text.push(cell.c); + } + } + + let input = &text[arrow_pos? + NEBULA_PROMPT_ARROW.len_utf8()..]; + + // Drop the single space that follows the arrow. + Some(input.strip_prefix(' ').unwrap_or(input).to_owned()) + } + + /// Recompute the inline ghost-text suggestion. `line_override` carries the + /// grid-read input on Windows (the authoritative screen truth); when `None` + /// the keystroke-tracked `line_buf` is used (other platforms). A whole + /// previous command sharing the prefix wins (fish-style history hint); + /// otherwise the final token gets path completion against the shell-reported + /// cwd. Cached on `cwd\0buffer` so disk is only touched when the line + /// changes — not every frame. + fn nebula_update_suggestion( + &mut self, + state: &mut NebulaPaneState, + line_override: Option, + ) { + let line = line_override.unwrap_or_else(|| state.line_buf.clone()); + if !self.nebula_ghost_enabled || line.is_empty() { + state.suggestion.clear(); + state.suggestion_key.clear(); + nebula_debug_log(format!( + "suggest_skip enabled={} cwd={:?} line={:?} line_buf={:?}", + self.nebula_ghost_enabled, state.cwd, line, state.line_buf + )); + return; + } + + let key = format!("{}\u{0}{line}", state.cwd); + if key == state.suggestion_key { + return; + } + state.suggestion_key = key; + state.suggestion.clear(); + + nebula_debug_log(format!( + "suggest_begin cwd={:?} line={:?} line_buf={:?}", + state.cwd, line, state.line_buf + )); + + // History first: newest command that extends the whole line (indexed + // prefix lookup — scales with matches, not history size). + if let Some(rem) = self.nebula_history.hint(&line) { + state.suggestion = Self::nebula_clamp_ghost(rem); + nebula_debug_log(format!("suggest_result kind=history rem={:?}", state.suggestion)); + return; + } + + // Directory-history hint for cd-like commands. This normalizes Windows + // slash style/case, so `cd D:\te` can pick a previous + // `cd D:/temp_build/wuwei` before generic filesystem completion falls + // back to alphabetic candidates like `D:\Telegram\`. + if let Some(rem) = self.directory_history.hint(&line, &state.cwd) { + state.suggestion = Self::nebula_clamp_ghost(&rem); + nebula_debug_log(format!("suggest_result kind=dir rem={:?}", state.suggestion)); + return; + } + + // First token with no path separators is a command position. Reuse the + // process PATH inherited by the shell so typing `ca` can ghost `rgo` + // even before that command has appeared in Nebula/Nushell history. + if nebula_is_command_position(&line) { + if let Ok(commands) = self.nebula_commands.lock() { + if let Some(rem) = nebula_command_hint(commands.as_slice(), &line) { + state.suggestion = Self::nebula_clamp_ghost(rem); + nebula_debug_log(format!( + "suggest_result kind=command rem={:?}", + state.suggestion + )); + return; + } + } + } + + // Otherwise complete the final path token. Absolute tokens (drive, + // root or `~`) resolve without a cwd; relative ones need the tracked + // cwd, so bail if it is unknown. + let token = line.rsplit([' ', '\t']).next().unwrap_or(""); + if token.is_empty() { + return; + } + let absolute = + token.starts_with(['/', '\\', '~']) || token.as_bytes().get(1) == Some(&b':'); // Windows drive, e.g. `D:` + if !absolute && state.cwd.is_empty() { + return; + } + + // Case-insensitive so `mor` completes `MoRealm` on Windows; prefer + // directories for the common directory-changing commands. + let options = CompletionOptions { case_sensitive: false, ..CompletionOptions::default() }; + let want_dir = nebula_path_wants_directory(&line); + let span = Span::new(0, token.len()); + let cwd = state.cwd.clone(); + let cwd_slot = [cwd.as_str()]; + let cwds: &[&str] = if cwd.is_empty() { &[] } else { &cwd_slot }; + let matches = complete_item(want_dir, span, token, cwds, &options, false, None); + let matches = if want_dir { + self.directory_history.rank_file_suggestions(matches, &state.cwd) + } else { + matches + }; + let candidates: Vec<_> = matches + .iter() + .take(6) + .map(|s| s.display_override.as_deref().unwrap_or(&s.path).to_owned()) + .collect(); + let remainder = matches.into_iter().find_map(|s| { + let path = s.display_override.as_deref().unwrap_or(&s.path); + // The match was case-insensitive, so the suggestion is the slice of + // `path` past what the user typed. Compare the head ignoring ASCII + // case (so `mor` matches `MoRealm`) and guard the byte split against + // multibyte boundaries. + if path.len() <= token.len() || !path.is_char_boundary(token.len()) { + return None; + } + let (head, rem) = path.split_at(token.len()); + if !head.eq_ignore_ascii_case(token) { + return None; + } + // Stop at the first separator so a single deep match doesn't drill + // the whole tree into the ghost; suggest one segment. + Some(match rem.find(['/', '\\']) { + Some(i) => rem[..=i].to_owned(), + None => rem.to_owned(), + }) + }); + if let Some(rem) = remainder { + state.suggestion = Self::nebula_clamp_ghost(&rem); + nebula_debug_log(format!( + "suggest_result kind=path token={:?} candidates={:?} rem={:?}", + token, candidates, state.suggestion + )); + } else { + nebula_debug_log(format!( + "suggest_result kind=none token={:?} candidates={:?}", + token, candidates + )); + } + } + + /// Cap ghost length so a long path/command can't spill into the chrome. + fn nebula_clamp_ghost(rem: &str) -> String { + rem.chars().take(NEBULA_GHOST_MAX).collect() + } + + fn draw_powerline_icons(&mut self, icons: &[NebulaPowerlineIcon], view: SizeInfo) { + if icons.is_empty() { + return; + } + + let size = view; + let cell_w = size.cell_width(); + let cell_h = size.cell_height(); + let pad_x = size.padding_x(); + let pad_y = size.padding_y(); + let palette = self.nebula_theme.palette(); + let folder_color = Rgb::new(palette.edge_r.r, palette.edge_r.g, palette.edge_r.b); + let branch_color = Rgb::new(palette.edge_l.r, palette.edge_l.g, palette.edge_l.b); + + let mut quads = Vec::with_capacity(icons.len() * 8); + for icon in icons { + if icon.point.line >= size.screen_lines() { + continue; + } + + let x = pad_x + icon.point.column.0 as f32 * cell_w; + let y = pad_y + icon.point.line as f32 * cell_h; + + match icon.kind { + NebulaPowerlineIconKind::Folder => { + Self::push_folder_icon(&mut quads, x, y, cell_w, cell_h, folder_color); + }, + NebulaPowerlineIconKind::GitBranch => { + Self::push_git_branch_icon(&mut quads, x, y, cell_w, cell_h, branch_color); + }, + } + } + + self.renderer.draw_ui(&self.size_info, &quads); + } + + fn push_folder_icon( + quads: &mut Vec, + cell_x: f32, + cell_y: f32, + cell_w: f32, + cell_h: f32, + color: Rgb, + ) { + let icon_w = (cell_w * 1.18).clamp(8.0, cell_h * 0.72); + let icon_h = (icon_w * 0.74).clamp(6.0, cell_h * 0.58); + let x = cell_x + (cell_w - icon_w) * 0.5; + let y = cell_y + (cell_h - icon_h) * 0.5 + cell_h * 0.02; + let radius = (icon_h * 0.16).max(1.4); + + let glow = Self::rgba_from_rgb(color, 46); + let main = Self::rgba_towards_white(color, 0.16, 236); + let light = Self::rgba_towards_white(color, 0.34, 246); + let shade = Self::rgba_towards_black(color, 0.16, 230); + let shine = Rgba::new(255, 255, 255, 82); + + quads.push(UiQuad::glow( + x - icon_w * 0.20, + y - icon_h * 0.22, + icon_w * 1.40, + icon_h * 1.45, + glow, + )); + quads.push(UiQuad::gradient( + x + icon_w * 0.03, + y + icon_h * 0.08, + icon_w * 0.48, + icon_h * 0.30, + radius * 0.70, + light, + main, + Gradient::Axis([0.9, 0.35]), + )); + quads.push(UiQuad::gradient( + x, + y + icon_h * 0.25, + icon_w, + icon_h * 0.68, + radius, + main, + shade, + Gradient::Axis([0.85, 0.45]), + )); + quads.push(UiQuad::solid( + x + icon_w * 0.14, + y + icon_h * 0.48, + icon_w * 0.72, + (cell_h * 0.035).max(1.0), + 0.8, + shine, + )); + } + + fn push_git_branch_icon( + quads: &mut Vec, + cell_x: f32, + cell_y: f32, + cell_w: f32, + cell_h: f32, + color: Rgb, + ) { + let icon = (cell_w * 1.12).clamp(7.0, cell_h * 0.68); + let x = cell_x + (cell_w - icon) * 0.5; + let y = cell_y + (cell_h - icon) * 0.5; + let stroke = (icon * 0.13).clamp(1.15, 2.4); + let node = (icon * 0.27).clamp(2.8, 5.0); + let radius = node * 0.5; + + let main = Self::rgba_towards_white(color, 0.12, 240); + let glow = Self::rgba_from_rgb(color, 42); + let line = Self::rgba_towards_black(color, 0.08, 218); + + let trunk_x = x + icon * 0.34; + let top_y = y + icon * 0.23; + let mid_y = y + icon * 0.43; + let bottom_y = y + icon * 0.78; + let branch_x = x + icon * 0.70; + + quads.push(UiQuad::glow(x - icon * 0.20, y - icon * 0.18, icon * 1.42, icon * 1.40, glow)); + Self::push_icon_line(quads, trunk_x, top_y, trunk_x, bottom_y, stroke, line); + Self::push_icon_line(quads, trunk_x, mid_y, branch_x, top_y, stroke, line); + + for (cx, cy) in [(trunk_x, top_y), (branch_x, top_y), (trunk_x, bottom_y)] { + quads.push(UiQuad::solid(cx - node * 0.5, cy - node * 0.5, node, node, radius, main)); + } + } + + fn push_icon_line( + quads: &mut Vec, + x0: f32, + y0: f32, + x1: f32, + y1: f32, + width: f32, + color: Rgba, + ) { + let dx = x1 - x0; + let dy = y1 - y0; + let len = (dx * dx + dy * dy).sqrt(); + if len <= f32::EPSILON { + return; + } + + let nx = -dy / len * width * 0.5; + let ny = dx / len * width * 0.5; + quads.push(UiQuad::poly( + [[x0 + nx, y0 + ny], [x0 - nx, y0 - ny], [x1 + nx, y1 + ny], [x1 - nx, y1 - ny]], + color, + color, + Gradient::None, + )); + } + + fn rgba_from_rgb(color: Rgb, alpha: u8) -> Rgba { + Rgba::new(color.r, color.g, color.b, alpha) + } + + fn rgba_towards_white(color: Rgb, amount: f32, alpha: u8) -> Rgba { + Self::rgba_mix(color, Rgb::new(255, 255, 255), amount, alpha) + } + + fn rgba_towards_black(color: Rgb, amount: f32, alpha: u8) -> Rgba { + Self::rgba_mix(color, Rgb::new(0, 0, 0), amount, alpha) + } + + fn rgba_mix(from: Rgb, to: Rgb, amount: f32, alpha: u8) -> Rgba { + let t = amount.clamp(0.0, 1.0); + let mix = |a: u8, b: u8| (f32::from(a) + (f32::from(b) - f32::from(a)) * t).round() as u8; + Rgba::new(mix(from.r, to.r), mix(from.g, to.g), mix(from.b, to.b), alpha) + } + + #[inline(never)] + fn draw_ime_preview( + &mut self, + point: Point, + fg: Rgb, + bg: Rgb, + rects: &mut Vec, + config: &UiConfig, + ) { + let preedit = match self.ime.preedit() { + Some(preedit) => preedit, + None => { + // In case we don't have preedit, just set the popup point. + self.window.update_ime_position(point, &self.size_info); + return; + }, + }; + + let num_cols = self.size_info.columns(); + + // Get the visible preedit. + let visible_text: String = match (preedit.cursor_byte_offset, preedit.cursor_end_offset) { + (Some(byte_offset), Some(end_offset)) if end_offset.0 > num_cols => StrShortener::new( + &preedit.text[byte_offset.0..], + num_cols, + ShortenDirection::Right, + Some(SHORTENER), + ), + _ => { + StrShortener::new(&preedit.text, num_cols, ShortenDirection::Left, Some(SHORTENER)) + }, + } + .collect(); + + let visible_len = visible_text.chars().count(); + + let end = cmp::min(point.column.0 + visible_len, num_cols); + let start = end.saturating_sub(visible_len); + + let start = Point::new(point.line, Column(start)); + let end = Point::new(point.line, Column(end - 1)); + + let glyph_cache = &mut self.glyph_cache; + let metrics = glyph_cache.font_metrics(); + + self.renderer.draw_string( + start, + fg, + bg, + visible_text.chars(), + &self.size_info, + glyph_cache, + ); + + // Damage preedit inside the terminal viewport. + if point.line < self.size_info.screen_lines() { + let damage = LineDamageBounds::new(start.line, 0, num_cols); + self.damage_tracker.frame().damage_line(damage); + self.damage_tracker.next_frame().damage_line(damage); + } + + // Add underline for preedit text. + let underline = RenderLine { start, end, color: fg }; + rects.extend(underline.rects(Flags::UNDERLINE, &metrics, &self.size_info)); + + let ime_popup_point = match preedit.cursor_end_offset { + Some(cursor_end_offset) => { + // Use hollow block when multiple characters are changed at once. + let (shape, width) = if let Some(width) = + NonZeroU32::new((cursor_end_offset.0 - cursor_end_offset.1) as u32) + { + (CursorShape::HollowBlock, width) + } else { + (CursorShape::Beam, NonZeroU32::new(1).unwrap()) + }; + + let cursor_column = Column( + (end.column.0 as isize - cursor_end_offset.0 as isize + 1).max(0) as usize, + ); + let cursor_point = Point::new(point.line, cursor_column); + let cursor = RenderableCursor::new(cursor_point, shape, fg, width); + rects.extend(cursor.rects(&self.size_info, config.cursor.thickness())); + cursor_point + }, + _ => end, + }; + + self.window.update_ime_position(ime_popup_point, &self.size_info); + } + + /// Format search regex to account for the cursor and fullwidth characters. + fn format_search(search_regex: &str, search_label: &str, max_width: usize) -> String { + let label_len = search_label.len(); + + // Skip `search_regex` formatting if only label is visible. + if label_len > max_width { + return search_label[..max_width].to_owned(); + } + + // The search string consists of `search_label` + `search_regex` + `cursor`. + let mut bar_text = String::from(search_label); + bar_text.extend(StrShortener::new( + search_regex, + max_width.wrapping_sub(label_len + 1), + ShortenDirection::Left, + Some(SHORTENER), + )); + + // Add place for cursor. + bar_text.push(' '); + + bar_text + } + + /// Draw preview for the currently highlighted `Hyperlink`. + #[inline(never)] + /// Draw a compact "open this link" tooltip near the hovered hint. + /// + /// 2026-07-23 用户反馈重构:上一版是整行 opaque `draw_string`,锚在鼠 + /// 标 cell 上——指针沿链接滑动时提示逐格跳动(被感知为"闪烁"),路 + /// 径还能占满整行("显示太长")。现在锚定到 hint 自己的起始 cell(指 + /// 针滑动时纹丝不动)、提示词压缩为 `Ctrl+点击`,并以 0.85× UI 锚定 + /// 字号画进圆角小气泡。 + /// + /// 2026-07-26 用户反馈"显示不全"三连修:① file URI percent-decode 后 + /// 再展示(见 [`strip_file_scheme`]);② 48 列硬帽退役,预算放开到整 + /// 个视口宽(fit_tail 仍兜底真溢出);③ 气泡宽度按渲染器真实步进 + /// `average_advance × scale` 量取——`cell_w` 是 floor 后的值,48 列累 + /// 积下来尾部文字会戳出气泡右缘。 + fn draw_hyperlink_preview( + &mut self, + config: &UiConfig, + _cursor_point: Option, + display_offset: usize, + ) { + let num_cols = self.size_info.columns(); + + // The destination under the mouse (first highlighted hint with a URI) + // plus that hint's start cell as the anchor. + let Some((uri, hint_start)) = self + .highlighted_hint + .iter() + .chain(&self.vi_highlighted_hint) + .find_map(|hint| { + hint.hyperlink().map(|h| (h.uri().to_owned(), *hint.bounds().start())) + }) + else { + return; + }; + // Hint start scrolled out of the viewport → fall back to the mouse cell. + let anchor = term::point_to_viewport(display_offset, hint_start).or_else(|| { + self.hint_mouse_point.and_then(|p| term::point_to_viewport(display_offset, p)) + }); + let Some(anchor) = anchor else { + return; + }; + + // Strip the `file://` scheme (and its leading slash before a Windows + // drive) so a local path reads as a path, not a URL. + let target = strip_file_scheme(&uri); + const HINT: &str = " · Ctrl+点击"; + let width = |s: &str| -> usize { s.chars().map(|c| c.width().unwrap_or(0)).sum() }; + let hint_w = width(HINT); + let target_budget = num_cols.saturating_sub(hint_w + 1); + let target = fit_tail(&target, target_budget); + let label = format!("{target}{HINT}"); + + // Position: one row below the hint's first cell, or above on the last row. + let line = if anchor.line + 1 < self.size_info.screen_lines() { + anchor.line + 1 + } else { + anchor.line.saturating_sub(1) + }; + + // Damage every row the bubble touches, this frame and next (it can + // appear/vanish). The bubble is taller than one cell row (0.85·cell + // plus padding and border, centered on its row), so it bleeds into + // both neighbours — an un-damaged neighbour ghosts the bubble's edges + // on partial-present paths. + let last_line = self.size_info.screen_lines().saturating_sub(1); + for touched in line.saturating_sub(1)..=(line + 1).min(last_line) { + let damage = LineDamageBounds::new(touched, 0, num_cols); + self.damage_tracker.frame().damage_line(damage); + self.damage_tracker.next_frame().damage_line(damage); + } + + let scale_px = self.window.scale_factor as f32; + let s = |v: f32| v * scale_px; + let text_scale = 0.85 * self.ui_text_scale(); + let cell_w = self.size_info.cell_width(); + let cell_h = self.size_info.cell_height(); + // Measure with the renderer's REAL step for scaled doc text — the + // unfloored design advance (`draw_doc_text_tracked` walks + // `average_advance × scale`). `cell_w` is that advance floored; the + // fraction lost per column made long labels poke out of the bubble. + let advance = self.glyph_cache.font_metrics().average_advance as f32 * text_scale; + let label_px = width(&label) as f32 * advance; + let pad_x = s(8.0); + let bubble_w = label_px + 2.0 * pad_x; + let bubble_h = cell_h * text_scale + s(8.0); + let max_x = (self.size_info.width() - self.size_info.padding_right() - bubble_w).max(0.0); + let x = (self.size_info.padding_x() + anchor.column.0 as f32 * cell_w).min(max_x); + let y = self.size_info.padding_y() + line as f32 * cell_h + (cell_h - bubble_h) * 0.5; + + let fg = config.colors.footer_bar_foreground(); + let bg = config.colors.footer_bar_background(); + let quads = [ + UiQuad::solid( + x - s(1.0), + y - s(1.0), + bubble_w + s(2.0), + bubble_h + s(2.0), + s(7.0), + Rgba::new(fg.r, fg.g, fg.b, 46), + ), + UiQuad::solid(x, y, bubble_w, bubble_h, s(6.0), Rgba::new(bg.r, bg.g, bg.b, 240)), + ]; + self.renderer.draw_ui(&self.size_info, &quads); + + let glyph_cache = &mut self.glyph_cache; + let size = self.size_info; + self.renderer.draw_doc_text_tracked( + &size, + x + pad_x, + y + (bubble_h - cell_h * text_scale) * 0.5, + text_scale, + 0.0, + fg, + Flags::empty(), + &label, + glyph_cache, + ); + } + + /// Draw current search regex. + #[inline(never)] + fn draw_search(&mut self, config: &UiConfig, text: &str) { + // Assure text length is at least num_cols. + let num_cols = self.size_info.columns(); + let text = format!("{text:, + line: usize, + ) { + let columns = self.size_info.columns(); + let text = format!("[{}/{}]", line, total_lines - 1); + let column = Column(self.size_info.columns().saturating_sub(text.len())); + let point = Point::new(0, column); + + // Damage the line indicator for current and next frame. + let damage = LineDamageBounds::new(point.line, point.column.0, columns - 1); + self.damage_tracker.frame().damage_line(damage); + self.damage_tracker.next_frame().damage_line(damage); + + let colors = &config.colors; + let fg = colors.line_indicator.foreground.unwrap_or(colors.primary.background); + let bg = colors.line_indicator.background.unwrap_or(colors.primary.foreground); + + // Do not render anything if it would obscure the vi mode cursor. + if obstructed_column.is_none_or(|obstructed_column| obstructed_column < column) { + let glyph_cache = &mut self.glyph_cache; + self.renderer.draw_string(point, fg, bg, text.chars(), &self.size_info, glyph_cache); + } + } + + /// Highlight damaged rects. + /// + /// This function is for debug purposes only. + fn highlight_damage(&self, render_rects: &mut Vec) { + for damage_rect in &self.damage_tracker.shape_frame_damage(self.size_info.into()) { + let x = damage_rect.x as f32; + let height = damage_rect.height as f32; + let width = damage_rect.width as f32; + let y = damage_y_to_viewport_y(&self.size_info, damage_rect) as f32; + let render_rect = RenderRect::new(x, y, width, height, DAMAGE_RECT_COLOR, 0.5); + + render_rects.push(render_rect); + } + } + + /// Check whether a hint highlight needs to be cleared. + /// + /// 2026-07-26 闪烁根因:这里原本拿共享 damage tracker 的 `intersects` + /// 判断"hint 底下的网格变没变",可 Nebula 每帧把 frame/next_frame 都标 + /// 成全窗 damage(全窗重绘呈现模型),`intersects` 的 `full ||` 短路恒 + /// 真——悬停高亮活不过两帧就被掐灭,鼠标一动重新点亮又立刻熄灭,ls + /// 里扫过文件时下划线和气泡狂闪。改判终端自己上报的本帧 damage + /// (`draw_pane` 在污染 tracker 之前捕获),语义回到上游本意。 + fn validate_hint_highlights( + &mut self, + display_offset: usize, + term_damage_full: bool, + term_damage_lines: &[LineDamageBounds], + ) { + let hints = [ + (&mut self.highlighted_hint, &mut self.highlighted_hint_age, true), + (&mut self.vi_highlighted_hint, &mut self.vi_highlighted_hint_age, false), + ]; + + let num_lines = self.size_info.screen_lines(); + for (hint, hint_age, reset_mouse) in hints { + let (start, end) = match hint { + Some(hint) => (*hint.bounds().start(), *hint.bounds().end()), + None => continue, + }; + + // Ignore hints that were created this frame. + *hint_age += 1; + if *hint_age == 1 { + continue; + } + + // Convert hint bounds to viewport coordinates. + let start = term::point_to_viewport(display_offset, start) + .filter(|point| point.line < num_lines) + .unwrap_or_default(); + let end = term::point_to_viewport(display_offset, end) + .filter(|point| point.line < num_lines) + .unwrap_or_else(|| Point::new(num_lines - 1, self.size_info.last_column())); + + // Clear hints whose underlying grid content actually changed. + let grid_changed = term_damage_full + || term_damage_lines.iter().any(|l| { + l.line >= start.line + && l.line <= end.line + // On the hint's first/last line only the hint's own + // column span counts; interior lines count wholly. + && (l.line != start.line || l.right >= start.column.0) + && (l.line != end.line || l.left <= end.column.0) + }); + if grid_changed { + if reset_mouse { + self.window.set_mouse_cursor(CursorIcon::Default); + } + self.damage_tracker.frame().mark_fully_damaged(); + *hint = None; + } + } + } + + /// Request a new frame for a window on Wayland. + fn request_frame(&mut self, scheduler: &mut Scheduler) { + // Mark that we've used a frame. + self.window.has_frame = false; + + // Get the display vblank interval. + let monitor_vblank_interval = 1_000_000. + / self + .window + .current_monitor() + .and_then(|monitor| monitor.refresh_rate_millihertz()) + .unwrap_or(60_000) as f64; + + // Now convert it to micro seconds. + let monitor_vblank_interval = + Duration::from_micros((1000. * monitor_vblank_interval) as u64); + + let swap_timeout = self.frame_timer.compute_timeout(monitor_vblank_interval); + + let window_id = self.window.id(); + let timer_id = TimerId::new(Topic::Frame, window_id); + let event = Event::new(EventType::Frame, window_id); + + scheduler.schedule(event, swap_timeout, false, timer_id); + } +} + +impl Drop for Display { + fn drop(&mut self) { + // Switch OpenGL context before dropping, otherwise objects (like programs) from other + // contexts might be deleted when dropping renderer. + self.make_current(); + unsafe { + ManuallyDrop::drop(&mut self.renderer); + ManuallyDrop::drop(&mut self.context); + ManuallyDrop::drop(&mut self.surface); + } + } +} + +/// Input method state. +#[derive(Debug, Default)] +pub struct Ime { + /// Whether the IME is enabled. + enabled: bool, + + /// Current IME preedit. + preedit: Option, +} + +impl Ime { + #[inline] + pub fn set_enabled(&mut self, is_enabled: bool) { + if is_enabled { + self.enabled = is_enabled + } else { + // Clear state when disabling IME. + *self = Default::default(); + } + } + + #[inline] + pub fn is_enabled(&self) -> bool { + self.enabled + } + + #[inline] + pub fn set_preedit(&mut self, preedit: Option) { + self.preedit = preedit; + } + + #[inline] + pub fn preedit(&self) -> Option<&Preedit> { + self.preedit.as_ref() + } +} + +#[derive(Debug, Default, PartialEq, Eq)] +pub struct Preedit { + /// The preedit text. + text: String, + + /// Byte offset for cursor start into the preedit text. + /// + /// `None` means that the cursor is invisible. + cursor_byte_offset: Option<(usize, usize)>, + + /// The cursor offset from the end of the start of the preedit in char width. + cursor_end_offset: Option<(usize, usize)>, +} + +impl Preedit { + pub fn new(text: String, cursor_byte_offset: Option<(usize, usize)>) -> Self { + let cursor_end_offset = if let Some(byte_offset) = cursor_byte_offset { + // Convert byte offset into char offset. + let start_to_end_offset = + text[byte_offset.0..].chars().fold(0, |acc, ch| acc + ch.width().unwrap_or(1)); + let end_to_end_offset = + text[byte_offset.1..].chars().fold(0, |acc, ch| acc + ch.width().unwrap_or(1)); + + Some((start_to_end_offset, end_to_end_offset)) + } else { + None + }; + + Self { text, cursor_byte_offset, cursor_end_offset } + } +} + +/// Pending renderer updates. +/// +/// All renderer updates are cached to be applied just before rendering, to avoid platform-specific +/// rendering issues. +#[derive(Debug, Default, Copy, Clone)] +pub struct RendererUpdate { + /// Should resize the window. + resize: bool, + + /// Clear font caches. + clear_font_cache: bool, +} + +/// The frame timer state. +pub struct FrameTimer { + /// Base timestamp used to compute sync points. + base: Instant, + + /// The last timestamp we synced to. + last_synced_timestamp: Instant, + + /// The refresh rate we've used to compute sync timestamps. + refresh_interval: Duration, +} + +impl FrameTimer { + pub fn new() -> Self { + let now = Instant::now(); + Self { base: now, last_synced_timestamp: now, refresh_interval: Duration::ZERO } + } + + /// Compute the delay that we should use to achieve the target frame + /// rate. + pub fn compute_timeout(&mut self, refresh_interval: Duration) -> Duration { + let now = Instant::now(); + + // Handle refresh rate change. + if self.refresh_interval != refresh_interval { + self.base = now; + self.last_synced_timestamp = now; + self.refresh_interval = refresh_interval; + return refresh_interval; + } + + let next_frame = self.last_synced_timestamp + self.refresh_interval; + + if next_frame < now { + // Redraw immediately if we haven't drawn in over `refresh_interval` microseconds. + let elapsed_micros = (now - self.base).as_micros() as u64; + let refresh_micros = self.refresh_interval.as_micros() as u64; + self.last_synced_timestamp = + now - Duration::from_micros(elapsed_micros % refresh_micros); + Duration::ZERO + } else { + // Redraw on the next `refresh_interval` clock tick. + self.last_synced_timestamp = next_frame; + next_frame - now + } + } +} + +/// Calculate the cell dimensions based on font metrics. +/// +/// This will return a tuple of the cell width and height. +#[inline] +fn compute_cell_size(config: &UiConfig, metrics: &crossfont::Metrics) -> (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, + ) +} + +/// Calculate the size of the window given padding, terminal dimensions and cell size. +fn window_size( + config: &UiConfig, + dimensions: Dimensions, + cell_width: f32, + cell_height: f32, + scale_factor: f32, +) -> PhysicalSize { + let padding = config.window.padding(scale_factor); + let chrome = chrome_reserve(scale_factor); + + let grid_width = cell_width * dimensions.columns.max(MIN_COLUMNS) as f32; + let grid_height = cell_height * dimensions.lines.max(MIN_SCREEN_LINES) as f32; + + // Left absorbs the sidebar (expanded by default), right is the plain + // content margin, matching the asymmetric grid the sidebar produces. + let pad_left = padding.0 + content_pad_x(scale_factor) + sidebar_width(scale_factor, false); + let pad_right = padding.0 + content_pad_x(scale_factor); + let width = (grid_width + pad_left + pad_right).floor(); + let pad_top = padding.1 + chrome; + let pad_bottom = padding.1 + bottom_content_reserve(scale_factor); + let height = (pad_top + grid_height + pad_bottom).floor(); + + PhysicalSize::new(width as u32, height as u32) +} + +#[cfg(test)] +mod nebula_ux_tests { + use nebula_terminal::grid::Dimensions; + use winit::window::Theme as WinitTheme; + + use super::{ + NebulaConfirm, SizeInfo, alt_screen_vertical_padding_bands, nebula_command_hint, + percent_decode_lossy, remove_ssh_host_from_lists, replays_untrusted_terminal_output, + restore_ssh_host_to_lists, strip_file_scheme, system_theme_snapshot, + }; + + fn strings(values: &[&str]) -> Vec { + values.iter().map(|value| (*value).to_owned()).collect() + } + + #[test] + fn file_uri_tooltip_shows_decoded_path() { + // `ls --hyperlink` percent-encodes CJK names; the tooltip must not. + assert_eq!( + strip_file_scheme("file:///D:/%E6%98%9F%E9%9B%B2/read%20me.txt"), + "D:/星雲/read me.txt" + ); + // Non-file URIs keep their encoding — it is part of their identity. + assert_eq!(strip_file_scheme("https://a.b/c%20d"), "https://a.b/c%20d"); + // Malformed escapes and non-UTF-8 decodes survive verbatim. + assert_eq!(percent_decode_lossy("100%"), "100%"); + assert_eq!(percent_decode_lossy("%zz"), "%zz"); + assert_eq!(percent_decode_lossy("%ff%fe"), "%ff%fe"); + } + + #[test] + fn log_replay_commands_do_not_receive_terminal_query_answers() { + for command in [ + "docker logs app", + "docker compose logs -f api", + "podman logs app", + "kubectl logs pod/api", + "journalctl -f -u nebula", + ] { + assert!(replays_untrusted_terminal_output(command), "{command}"); + } + for command in ["docker run app", "kubectl exec pod -- sh", "cargo test", "nvim"] { + assert!(!replays_untrusted_terminal_output(command), "{command}"); + } + } + + #[test] + fn exact_path_command_suppresses_longer_neighbor_completion() { + let commands = strings(&["claude", "claude-agent-acp"]); + assert_eq!(nebula_command_hint(&commands, "clau"), Some("de")); + assert_eq!(nebula_command_hint(&commands, "claude"), None); + + #[cfg(windows)] + assert_eq!(nebula_command_hint(&commands, "CLAUDE"), None); + } + + #[test] + fn system_theme_snapshot_beats_a_stale_window_override() { + assert_eq!( + system_theme_snapshot(Some(WinitTheme::Dark), Some(WinitTheme::Light)), + Some(WinitTheme::Dark) + ); + assert_eq!(system_theme_snapshot(None, Some(WinitTheme::Light)), Some(WinitTheme::Light)); + } + + #[test] + fn ssh_delete_undo_restores_saved_and_pinned_order() { + let mut saved = strings(&["alpha", "target", "omega"]); + let mut pinned = strings(&["target", "alpha"]); + let mut hidden = strings(&["already-hidden"]); + + let snapshot = remove_ssh_host_from_lists("target", &mut saved, &mut pinned, &mut hidden); + assert_eq!(snapshot, (Some(1), Some(0), false)); + assert_eq!(saved, strings(&["alpha", "omega"])); + assert_eq!(pinned, strings(&["alpha"])); + assert_eq!(hidden, strings(&["already-hidden", "target"])); + + restore_ssh_host_to_lists( + "target", + snapshot.0, + snapshot.1, + snapshot.2, + &mut saved, + &mut pinned, + &mut hidden, + ); + assert_eq!(saved, strings(&["alpha", "target", "omega"])); + assert_eq!(pinned, strings(&["target", "alpha"])); + assert_eq!(hidden, strings(&["already-hidden"])); + } + + #[test] + fn ssh_config_only_hide_is_fully_reversible() { + let mut saved = Vec::new(); + let mut pinned = Vec::new(); + let mut hidden = Vec::new(); + + let snapshot = + remove_ssh_host_from_lists("config-alias", &mut saved, &mut pinned, &mut hidden); + assert_eq!(snapshot, (None, None, false)); + assert_eq!(hidden, strings(&["config-alias"])); + + restore_ssh_host_to_lists( + "config-alias", + snapshot.0, + snapshot.1, + snapshot.2, + &mut saved, + &mut pinned, + &mut hidden, + ); + assert!(saved.is_empty()); + assert!(pinned.is_empty()); + assert!(hidden.is_empty()); + } + + #[test] + fn asymmetric_bottom_reserve_recovers_rows_hidden_by_top_chrome() { + let size = SizeInfo::new_fully_asymmetric(1000.0, 1000.0, 10.0, 20.0, 0.0, 0.0, 64.0, 16.0); + assert_eq!(size.screen_lines(), 46); + assert_eq!(size.padding_y(), 64.0); + assert_eq!(size.padding_bottom(), 16.0); + + let old_symmetric = SizeInfo::new_asymmetric(1000.0, 1000.0, 10.0, 20.0, 0.0, 0.0, 64.0); + assert_eq!(old_symmetric.screen_lines(), 43); + } + + #[test] + fn alternate_screen_padding_stays_inside_stacked_panes() { + let window = + SizeInfo::new_fully_asymmetric(1000.0, 700.0, 10.0, 20.0, 100.0, 20.0, 80.0, 20.0); + let top = + SizeInfo::new_fully_asymmetric(1000.0, 700.0, 10.0, 20.0, 100.0, 20.0, 80.0, 324.0); + let bottom = + SizeInfo::new_fully_asymmetric(1000.0, 700.0, 10.0, 20.0, 100.0, 20.0, 384.0, 20.0); + + assert_eq!( + alt_screen_vertical_padding_bands(&window, &top, 56.0, 636.0), + [Some((56.0, 24.0)), Some((360.0, 16.0))] + ); + assert_eq!( + alt_screen_vertical_padding_bands(&window, &bottom, 56.0, 636.0), + [None, Some((664.0, 28.0))] + ); + } + + #[test] + fn missing_font_notice_can_be_dismissed() { + let confirm = + NebulaConfirm::InstallRequiredFont { directory: std::path::PathBuf::from("fonts") }; + + assert!(confirm.can_dismiss()); + } +} diff --git a/nebula_app/src/display/settings.rs b/nebula_app/src/display/settings.rs index c2fd6b4..e5af099 100644 --- a/nebula_app/src/display/settings.rs +++ b/nebula_app/src/display/settings.rs @@ -1,3591 +1,3592 @@ -//! Settings special tab for Nebula's runtime appearance and completion settings. -//! -//! Mirrors the `command_palette` split, but goes one step further: besides the -//! *model* (sections, hit-testing, geometry, and the `nebula_settings.txt` -//! runtime store) this module also owns the panel's *rendering* — both the -//! background [`push_quads`] and the [`draw_text`] labels — so the giant -//! `display::mod` no longer carries the settings UI. The input layer stays the -//! only place that mutates state, reaching the `Display` methods that wrap this -//! model; rendering reads a snapshot [`SettingsView`] handed in each frame. -//! -//! Being a descendant module of `display`, this file can freely use the parent's -//! private helpers (`contains_rect`, `truncate_tab_label`, `nebula_data_dir`, -//! `NebulaTheme::palette`, `AcceptKey`, …) via `super::` — no visibility -//! churn needed in `mod.rs`. - -use unicode_width::UnicodeWidthChar; - -use nebula_terminal::vte::ansi::CursorShape; - -use crate::config::UiConfig; -use crate::display::color::Rgb; -use crate::renderer::image::{BackgroundImageAlignment, BackgroundImageFit}; -use crate::renderer::ui::{Rgba, UiQuad}; -use crate::renderer::{GlyphCache, Renderer}; - -use super::theme::Skin; -use super::{icons, keymap, widgets}; -use super::{ - AcceptKey, LanguagePreference, NebulaShell, NebulaTheme, SizeInfo, UiLanguage, - chrome_settings_button_rect, contains_rect, nebula_data_dir, truncate_tab_label, -}; - -// Visual language: one flat panel color, one hairline, three text grays, ONE -// accent — hierarchy comes from typography and spacing. Every color is a -// [`Skin`] token from `display::theme` (single source of truth), so the page -// flips correctly between the light and dark theme families. - -/// WebDAV 同步还处于内部迭代阶段。保留状态、持久化与后端实现,但在交互闭环 -/// 完成前不向用户暴露入口;集中守门可避免绘制、命中和滚动高度各自遗漏。 -const SHOW_WEBDAV_SYNC_SETTINGS: bool = false; - -/// Sidebar sections of the settings panel. Deliberately small: only sections -/// with real functionality behind them are listed. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub enum NebulaSettingsSection { - /// Themes, custom colors, wallpaper, cursor and window opacity. - #[default] - Appearance, - /// Completion behaviour plus the raw `nebula_settings.txt` config file. - Profiles, - /// Selection/clipboard behaviour (Windows Terminal's "交互" page). - Interaction, - /// Read-only shortcut sheet + pointer to `[[keyboard.bindings]]` remapping. - Keymap, - /// Power-user switches (session residency on close, …). - Advanced, -} - -impl NebulaSettingsSection { - fn label(self, language: UiLanguage) -> &'static str { - match self { - Self::Appearance => language.pick("外观", "Appearance"), - Self::Profiles => language.pick("配置文件", "Profiles"), - Self::Interaction => language.pick("交互", "Interaction"), - Self::Keymap => language.pick("按键映射", "Key bindings"), - Self::Advanced => language.pick("高级", "Advanced"), - } - } -} - -/// Shortcut sheet shown in 设置→按键映射. Editable rows live in -/// [`keymap::EDITABLE_ACTIONS`]; the read-only extras in -/// [`keymap::READONLY_ROWS`] (spec 002). - -/// Which independently draggable opacity control is being adjusted. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum SettingsOpacityTarget { - Terminal, - BackgroundImage, -} - -/// Which inline dropdown (combobox) is currently expanded. At most one at a -/// time; the option list floats over later rows instead of pushing them down. -/// 用户范式(2026-07-23):凡是多选项的设置一律做成 WT 风格内嵌下拉框, -/// 不再用"点击循环切换"——所有选项必须先可见再选择。 -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum SettingsDropdown { - Shell, - Font, - BackgroundFit, - BackgroundAlignment, - Language, - Accept, - CursorShape, - /// 背景色:色板网格 + 16 进制输入的专用浮层(不是通用行列表)。 - BackgroundColor, -} - -/// 背景色色盘的预设色板(2 行 × 6 列)。前排偏星云/终端惯用暗底,尾部 -/// 提供近黑与亮底;任意颜色可用下方的 16 进制输入框手动指定。 -pub(crate) const BACKGROUND_SWATCHES: [Rgb; 12] = [ - Rgb::new(8, 10, 24), - Rgb::new(12, 16, 28), - Rgb::new(18, 14, 32), - Rgb::new(24, 24, 37), - Rgb::new(0, 43, 54), - Rgb::new(6, 26, 28), - Rgb::new(40, 42, 54), - Rgb::new(30, 30, 30), - Rgb::new(12, 12, 12), - Rgb::new(0, 0, 0), - Rgb::new(253, 246, 227), - Rgb::new(255, 255, 255), -]; - -pub(super) const BACKGROUND_FIT_OPTIONS: [BackgroundImageFit; 4] = [ - BackgroundImageFit::Fill, - BackgroundImageFit::Uniform, - BackgroundImageFit::UniformToFill, - BackgroundImageFit::None, -]; - -pub(super) const BACKGROUND_ALIGNMENT_OPTIONS: [BackgroundImageAlignment; 9] = [ - BackgroundImageAlignment::TopLeft, - BackgroundImageAlignment::Top, - BackgroundImageAlignment::TopRight, - BackgroundImageAlignment::Left, - BackgroundImageAlignment::Center, - BackgroundImageAlignment::Right, - BackgroundImageAlignment::BottomLeft, - BackgroundImageAlignment::Bottom, - BackgroundImageAlignment::BottomRight, -]; - -pub(super) const LANGUAGE_OPTIONS: [LanguagePreference; 3] = - [LanguagePreference::System, LanguagePreference::ZhCn, LanguagePreference::EnUs]; - -pub(super) const ACCEPT_OPTIONS: [AcceptKey; 3] = - [AcceptKey::Both, AcceptKey::Tab, AcceptKey::Right]; - -/// Order mirrors the Windows Terminal appearance page the user referenced. -pub(super) const CURSOR_SHAPE_OPTIONS: [CursorShape; 4] = - [CursorShape::Beam, CursorShape::Underline, CursorShape::Block, CursorShape::HollowBlock]; - -pub(super) fn cursor_shape_label(shape: CursorShape, language: UiLanguage) -> &'static str { - match shape { - CursorShape::Beam => language.pick("条形(│)", "Bar (│)"), - CursorShape::Underline => language.pick("下划线(_)", "Underscore (_)"), - CursorShape::Block => language.pick("实心框(█)", "Filled box (█)"), - CursorShape::HollowBlock => language.pick("空心框(□)", "Empty box (□)"), - CursorShape::Hidden => language.pick("隐藏", "Hidden"), - } -} - -fn accept_label(accept: AcceptKey, language: UiLanguage) -> &'static str { - match accept { - AcceptKey::Right => language.pick("右方向键", "Right arrow"), - AcceptKey::Tab => "Tab", - AcceptKey::Both => language.pick("Tab 或右方向键", "Tab or Right arrow"), - } -} - -fn language_label(preference: LanguagePreference, language: UiLanguage) -> &'static str { - match preference { - LanguagePreference::System => language.pick("跟随系统", "Follow system"), - LanguagePreference::ZhCn => "简体中文", - LanguagePreference::EnUs => "English", - } -} - -/// Hit result for the top-left Nebula settings affordance. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum SettingsHit { - None, - Toggle, - Panel, - Nav(NebulaSettingsSection), - Theme(NebulaTheme), - Language(LanguagePreference), - SystemThemeToggle, - GhostToggle, - AcceptCycle, - ShellCycle, - StartupDirectory, - StartupDirectoryClear, - /// One of the expanded shell picker rows (index into detected_shells). - ShellPickerRow(usize), - FontCycle, - /// Imported-font picker rows; the final row is always "导入字体…". - FontPickerRow(usize), - /// Font-size spinner steppers on the "字号" row. - FontSizeUp, - FontSizeDown, - /// Cursor group: shape dropdown + its option rows, and the blink toggle. - CursorShapeDropdown, - CursorShapeOption(usize), - CursorBlinkToggle, - /// 交互: copy-on-select toggle row. - CopyOnSelectToggle, - /// 交互: 全宽字形 bold run 用 Regular 字形(粗体提亮不加粗)。 - CjkBoldToggle, - /// Language combobox trigger (options resolve to [`SettingsHit::Language`]). - LanguageDropdown, - /// Expanded dropdown option rows for the cycle-style settings. - AcceptOption(usize), - FitOption(usize), - AlignOption(usize), - /// Restore one address from the persistent hidden-host list. - RestoreHiddenSsh(usize), - FetchToggle, - PowerlineToggle, - OpacitySlider, - BackgroundColor, - /// 背景色浮层:色板网格里的一格。 - BackgroundSwatch(usize), - /// 背景色浮层:16 进制输入框。 - BackgroundHexInput, - /// 背景色浮层内部的空白(吞掉点击且不关闭浮层)。 - BackgroundPopupPanel, - BackgroundImage, - BackgroundImageClear, - BackgroundImageFit, - BackgroundImageAlignment, - BackgroundImageCoverChrome, - BackgroundImageOpacitySlider, - OpenConfigFile, - Reset, - /// 高级: keep the resident server (detach) on window close. - KeepSessionToggle, - /// 高级→同步: 输入框(0=url 1=用户名 2=WebDAV 密码 3=E2E 口令)。 - SyncInput(usize), - SyncAutoPullToggle, - SyncPushButton, - SyncPullButton, - /// 按键映射: one editable action row (click → capture a new combo). - KeymapRow(usize), -} - -// ---- runtime settings store (`Nebula/nebula_settings.txt`) ---- - -pub(super) struct NebulaRuntimeSettings { - pub(super) language: LanguagePreference, - pub(super) ghost: bool, - pub(super) accept: AcceptKey, - pub(super) shell: NebulaShell, - /// Raw default-shell id (`shell=`), when the user picked a detected - /// shell the 2-value `shell` enum can't represent (cmd, pwsh, nushell, a - /// WSL distro). `None` = the enum value is authoritative. Written verbatim - /// so `shell_detect::resolve_id` and the PTY layer both see the real id. - pub(super) shell_id: Option, - /// Default working directory for fresh terminal tabs. `None` inherits the - /// focused pane (or the process cwd for the first window). - pub(super) startup_directory: Option, - pub(super) font_family: String, - /// Terminal font size in LOGICAL px (`None` = follow the config file). - /// Persisted so the settings spinner and Ctrl+wheel zoom survive restarts. - pub(super) font_size: Option, - /// Default cursor shape; escape sequences (vim, claude) still override. - pub(super) cursor_shape: CursorShape, - /// Default-on: a static cursor reads as a hang ("没有活动感"). - pub(super) cursor_blink: bool, - /// 交互: selecting text copies it to the clipboard immediately (WT's - /// copyOnSelect). Off = right-click copies instead. - pub(super) copy_on_select: bool, - /// 全宽字形(CJK 等)在 bold run 里用 Regular 字形(粗体提亮不加粗)。 - /// 默认开:小字号下雅黑 Bold fallback 与 Regular 混排发闷(任务 #4)。 - pub(super) cjk_bold_regular: bool, - pub(super) fetch: bool, - pub(super) powerline: bool, - /// Window close keeps the PTYs alive in the resident process (detach / - /// re-attach session restore). Off = closing a window kills its shells. - pub(super) keep_session: bool, - pub(super) opacity: f32, - pub(super) background: Option, - pub(super) background_image: Option, - pub(super) background_image_opacity: f32, - pub(super) background_image_fit: BackgroundImageFit, - pub(super) background_image_alignment: BackgroundImageAlignment, - pub(super) background_image_cover_chrome: bool, - /// Chrome theme. Persisted so a restart keeps the chosen look AND the - /// powerline bridge file gets rewritten with the right name on boot - /// (it used to be reset to the default theme every launch). - pub(super) theme: NebulaTheme, - /// Automatically choose the light/dark member of the selected theme - /// family when the operating system appearance changes. - pub(super) follow_system_theme: bool, - /// SSH host aliases pinned to the top of the sidebar's "SSH HOSTS" - /// section (right-click a host row), in pinned order. - pub(super) pinned_hosts: Vec, - /// SSH destinations auto-saved after a successful typed `ssh` connection, - /// most recent first (see `Display::nebula_save_ssh_host`). - pub(super) saved_hosts: Vec, - /// SSH aliases explicitly removed from the sidebar. This is separate from - /// `saved_hosts` because entries discovered in `~/.ssh/config` would - /// otherwise reappear on the very next merge. - pub(super) hidden_hosts: Vec, - /// User keybinding overrides, raw `(combo, action)` pairs in file order - /// (spec 002). Kept verbatim so unknown-but-valid future actions survive a - /// load/save cycle; `display::keymap::build_bindings` parses them. - pub(super) keybinds: Vec<(String, String)>, - /// 快速终端全局切换键,和普通动作绑定分开持久化。 - pub(super) quick_terminal_hotkey: String, -} - -/// Load runtime UI settings from `Nebula/nebula_settings.txt`; defaults when -/// absent. Format is one `key=value` per line so power users can edit it while -/// the graphical settings page catches up. -pub(super) fn nebula_settings_load(config: &UiConfig) -> NebulaRuntimeSettings { - let path = nebula_data_dir().join("nebula_settings.txt"); - let mut settings = NebulaRuntimeSettings { - language: LanguagePreference::System, - ghost: true, - accept: AcceptKey::Both, - shell: NebulaShell::PowerShell, - shell_id: None, - startup_directory: None, - font_family: config.font.normal().family.clone(), - font_size: None, - cursor_shape: CursorShape::Beam, - cursor_blink: true, - copy_on_select: true, - cjk_bold_regular: true, - // 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). - fetch: false, - powerline: true, - // Off by default (user ruling 2026-07-12): a plain terminal should die - // clean on close. Residency leaves shells running in the background, - // which reads as "the app didn't really exit" — opt IN, not out. - keep_session: false, - opacity: config.window_opacity(), - background: None, - background_image: None, - background_image_opacity: 0.38, - background_image_fit: BackgroundImageFit::default(), - background_image_alignment: BackgroundImageAlignment::default(), - background_image_cover_chrome: false, - theme: NebulaTheme::default(), - // Preserve existing installations: automatic switching is opt-in so - // an update never replaces an explicitly selected theme unexpectedly. - follow_system_theme: false, - pinned_hosts: Vec::new(), - saved_hosts: Vec::new(), - hidden_hosts: Vec::new(), - keybinds: Vec::new(), - quick_terminal_hotkey: keymap::DEFAULT_QUICK_TERMINAL_HOTKEY.to_owned(), - }; - if let Ok(data) = std::fs::read_to_string(path) { - for line in data.lines() { - match line.split_once('=') { - Some(("language", v)) => { - if let Some(language) = LanguagePreference::parse(v) { - settings.language = language; - } - }, - Some(("ghost", v)) => settings.ghost = v.trim() != "0", - Some(("theme", v)) => { - if let Some(theme) = NebulaTheme::from_prompt_name(v.trim()) { - settings.theme = theme; - } - }, - Some(("accept", "right")) => settings.accept = AcceptKey::Right, - Some(("accept", "tab")) => settings.accept = AcceptKey::Tab, - Some(("accept", "both")) => settings.accept = AcceptKey::Both, - Some(("shell" | "executor", v)) => { - let v = v.trim(); - if let Some(shell) = NebulaShell::from_settings(v) { - settings.shell = shell; - } - // Preserve the raw id for detected shells the enum can't - // represent (cmd, pwsh, nushell, wsl:); the enum - // still tracks the PTY-integrated executor family so the - // prompt bootstrap picks the right base. - if !v.is_empty() { - settings.shell_id = Some(v.to_owned()); - } - }, - Some(("font_family", v)) => { - let family = v.trim(); - if !family.is_empty() { - settings.font_family = family.to_owned(); - } - }, - Some(("font_size", v)) => { - if let Ok(size) = v.trim().parse::() { - settings.font_size = Some(size.clamp(6.0, 72.0)); - } - }, - Some(("cursor_shape", v)) => { - if let Some(shape) = parse_cursor_shape(v) { - settings.cursor_shape = shape; - } - }, - Some(("cursor_blink", v)) => settings.cursor_blink = parse_bool(v, true), - Some(("copy_on_select", v)) => settings.copy_on_select = parse_bool(v, true), - Some(("cjk_bold_regular", v)) => { - settings.cjk_bold_regular = parse_bool(v, true) - }, - Some(("startup_directory", v)) => { - let path = std::path::PathBuf::from(v.trim()); - if path.is_dir() { - settings.startup_directory = Some(path); - } - }, - Some(("fetch", v)) => settings.fetch = parse_bool(v, true), - Some(("powerline", v)) => settings.powerline = parse_bool(v, true), - Some(("keep_session", v)) => settings.keep_session = parse_bool(v, false), - Some(("opacity", v)) => { - if let Ok(opacity) = v.trim().parse::() { - settings.opacity = opacity.clamp(0.0, 1.0); - } - }, - Some(("background", v)) => settings.background = parse_hex_rgb(v.trim()), - Some(("background_image", v)) => { - let v = v.trim(); - settings.background_image = (!v.is_empty()).then(|| v.to_owned()); - }, - Some(("background_image_opacity", v)) => { - if let Ok(opacity) = v.trim().parse::() { - settings.background_image_opacity = opacity.clamp(0.0, 1.0); - } - }, - Some(("background_image_fit", v)) => { - if let Some(fit) = BackgroundImageFit::parse(v) { - settings.background_image_fit = fit; - } - }, - Some(("background_image_alignment", v)) => { - if let Some(alignment) = BackgroundImageAlignment::parse(v) { - settings.background_image_alignment = alignment; - } - }, - Some(("background_image_cover_chrome", v)) => { - settings.background_image_cover_chrome = parse_bool(v, false); - }, - Some(("pinned_hosts", v)) => { - settings.pinned_hosts = v - .split(',') - .map(str::trim) - .filter(|s| !s.is_empty()) - .map(str::to_owned) - .collect(); - }, - Some(("saved_hosts", v)) => { - settings.saved_hosts = v - .split(',') - .map(str::trim) - .filter(|s| !s.is_empty()) - .map(str::to_owned) - .collect(); - }, - Some(("follow_system_theme", v)) => { - settings.follow_system_theme = parse_bool(v, false) - }, - Some(("hidden_hosts", v)) => { - settings.hidden_hosts = v - .split(',') - .map(str::trim) - .filter(|s| !s.is_empty()) - .map(str::to_owned) - .collect(); - }, - Some(("keybind", v)) => { - // `keybind=:`;解析验证归 keymap 模块,这里 - // 只收原文——非法行在构建绑定表时静默丢弃。 - if let Some((combo, action)) = v.split_once(':') { - let (combo, action) = (combo.trim(), action.trim()); - if !combo.is_empty() && !action.is_empty() { - settings.keybinds.push((combo.to_lowercase(), action.to_owned())); - } - } - }, - Some(("quick_terminal_hotkey", v)) => { - let value = v.trim(); - if value.parse::().is_ok() { - settings.quick_terminal_hotkey = value.to_owned(); - } - }, - _ => {}, - } - } - } - settings -} - -fn parse_bool(value: &str, default: bool) -> bool { - match value.trim().to_ascii_lowercase().as_str() { - "1" | "true" | "yes" | "on" => true, - "0" | "false" | "no" | "off" => false, - _ => default, - } -} - -fn parse_cursor_shape(value: &str) -> Option { - match value.trim().to_ascii_lowercase().as_str() { - "block" => Some(CursorShape::Block), - "beam" | "bar" => Some(CursorShape::Beam), - "underline" => Some(CursorShape::Underline), - "hollow" => Some(CursorShape::HollowBlock), - _ => None, - } -} - -pub(super) fn cursor_shape_settings_value(shape: CursorShape) -> &'static str { - match shape { - CursorShape::Beam => "beam", - CursorShape::Underline => "underline", - CursorShape::HollowBlock => "hollow", - CursorShape::Block | CursorShape::Hidden => "block", - } -} - -pub(crate) fn parse_hex_rgb(value: &str) -> Option { - let hex = value.strip_prefix('#').unwrap_or(value); - if hex.len() != 6 { - 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(Rgb::new(r, g, b)) -} - -fn format_hex_rgb(rgb: Rgb) -> String { - format!("#{:02x}{:02x}{:02x}", rgb.r, rgb.g, rgb.b) -} - -fn background_image_fit_label(fit: BackgroundImageFit, language: UiLanguage) -> &'static str { - match fit { - BackgroundImageFit::Fill => language.pick("拉伸", "Fill"), - BackgroundImageFit::Uniform => language.pick("适应", "Uniform"), - BackgroundImageFit::UniformToFill => language.pick("填充", "Uniform to fill"), - BackgroundImageFit::None => language.pick("原始尺寸", "None"), - } -} - -fn background_image_alignment_label( - alignment: BackgroundImageAlignment, - language: UiLanguage, -) -> &'static str { - match alignment { - BackgroundImageAlignment::TopLeft => language.pick("左上", "Top left"), - BackgroundImageAlignment::Top => language.pick("顶部", "Top"), - BackgroundImageAlignment::TopRight => language.pick("右上", "Top right"), - BackgroundImageAlignment::Left => language.pick("左侧", "Left"), - BackgroundImageAlignment::Center => language.pick("居中", "Center"), - BackgroundImageAlignment::Right => language.pick("右侧", "Right"), - BackgroundImageAlignment::BottomLeft => language.pick("左下", "Bottom left"), - BackgroundImageAlignment::Bottom => language.pick("底部", "Bottom"), - BackgroundImageAlignment::BottomRight => language.pick("右下", "Bottom right"), - } -} - -pub(super) fn nebula_settings_mtime() -> Option { - std::fs::metadata(nebula_data_dir().join("nebula_settings.txt")) - .and_then(|meta| meta.modified()) - .ok() -} - -/// Persist runtime settings next to the history file. -pub(super) fn nebula_settings_write(settings: &NebulaRuntimeSettings) { - let accept = match settings.accept { - AcceptKey::Right => "right", - AcceptKey::Tab => "tab", - AcceptKey::Both => "both", - }; - let background = settings.background.map(format_hex_rgb).unwrap_or_default(); - let background_image = settings.background_image.as_deref().unwrap_or(""); - // A picked detected-shell id (cmd/pwsh/nu/wsl:X) is written verbatim; the - // 2-value enum is the fallback for the built-in powershell/bash choice. - let shell = - settings.shell_id.clone().unwrap_or_else(|| settings.shell.settings_value().to_owned()); - let startup_directory = settings - .startup_directory - .as_ref() - .map(|path| path.to_string_lossy().into_owned()) - .unwrap_or_default(); - let theme = settings.theme.prompt_name(); - let path = nebula_data_dir().join("nebula_settings.txt"); - let pinned_hosts = settings.pinned_hosts.join(","); - let saved_hosts = settings.saved_hosts.join(","); - let hidden_hosts = settings.hidden_hosts.join(","); - let font_size = - settings.font_size.map(|size| format!("{size:.1}")).unwrap_or_default(); - let mut keybinds = String::new(); - for (combo, action) in &settings.keybinds { - keybinds.push_str(&format!("keybind={combo}:{action}\n")); - } - let quick_terminal_hotkey = settings.quick_terminal_hotkey.trim(); - let _ = std::fs::write( - 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={}\nfetch={}\npowerline={}\nkeep_session={}\nopacity={:.2}\nbackground={background}\nbackground_image={background_image}\nbackground_image_opacity={:.2}\nbackground_image_fit={}\nbackground_image_alignment={}\nbackground_image_cover_chrome={}\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, - settings.font_family, - cursor_shape_settings_value(settings.cursor_shape), - settings.cursor_blink as u8, - settings.copy_on_select as u8, - settings.cjk_bold_regular as u8, - settings.fetch as u8, - settings.powerline as u8, - settings.keep_session as u8, - settings.opacity, - settings.background_image_opacity, - settings.background_image_fit.settings_value(), - settings.background_image_alignment.settings_value(), - settings.background_image_cover_chrome as u8, - ), - ); -} - -// ---- geometry + hit-testing ---- - -#[derive(Debug, Clone, Copy)] -struct SettingsGeometry { - gear: (f32, f32, f32, f32), - popup: (f32, f32, f32, f32), - sidebar: (f32, f32, f32, f32), - content: (f32, f32, f32, f32), - nav: [(NebulaSettingsSection, f32, f32, f32, f32); 5], - options: [(NebulaTheme, f32, f32, f32, f32); 7], - /// Live terminal preview card at the top of Appearance: configure → - /// immediately see (font, size, colors, wallpaper opacity, cursor). - preview: (f32, f32, f32, f32), - system_theme: (f32, f32, f32, f32), - shell: (f32, f32, f32, f32), - startup_directory: (f32, f32, f32, f32), - startup_directory_clear: (f32, f32, f32, f32), - font: (f32, f32, f32, f32), - /// "字号" spinner row; the value box + steppers derive via `widgets`. - font_size_row: (f32, f32, f32, f32), - fetch: (f32, f32, f32, f32), - powerline: (f32, f32, f32, f32), - ghost: (f32, f32, f32, f32), - accept: (f32, f32, f32, f32), - open_config_file: (f32, f32, f32, f32), - hidden_host_row0: (f32, f32, f32, f32), - hidden_host_count: usize, - /// Full-width "窗口透明度" row and its draggable track. - language_row: (f32, f32, f32, f32), - opacity_row: (f32, f32, f32, f32), - opacity_slider: (f32, f32, f32, f32), - /// Cursor group: shape combobox row + blink toggle row. - cursor_shape_row: (f32, f32, f32, f32), - cursor_blink_row: (f32, f32, f32, f32), - background: (f32, f32, f32, f32), - background_image: (f32, f32, f32, f32), - background_image_clear: (f32, f32, f32, f32), - background_image_fit: (f32, f32, f32, f32), - background_image_alignment: (f32, f32, f32, f32), - background_image_cover_chrome: (f32, f32, f32, f32), - background_image_opacity_row: (f32, f32, f32, f32), - background_image_opacity_slider: (f32, f32, f32, f32), - /// 交互: copy-on-select toggle row. - copy_on_select: (f32, f32, f32, f32), - /// 交互: CJK 粗体策略 toggle row. - cjk_bold: (f32, f32, f32, f32), - reset: (f32, f32, f32, f32), - /// Top edge of the scrollable content viewport (just below the fixed - /// header band); everything above it never scrolls. - content_top: f32, - /// Total designed content height per section (scaled px, measured from - /// `content_top`). `max_scroll = (height - viewport).max(0)`. - appearance_h: f32, - profiles_h: f32, - interaction_h: f32, - keymap_h: f32, - /// First keymap row rect; row `i` sits `i * row_h` below it. - keymap_row0: (f32, f32, f32, f32), - /// First row of the read-only shortcut group below the editable block. - keymap_readonly_row0: (f32, f32, f32, f32), - keymap_row_h: f32, - advanced_h: f32, - keep_session: (f32, f32, f32, f32), - /// 高级→同步(WebDAV):url/用户名/密码/口令 四行 + 自动拉取开关行 - /// + 动作按钮行。输入框矩形由 [`sync_input_rect`] 从行矩形推导。 - sync_rows: [(f32, f32, f32, f32); 4], - sync_auto_pull: (f32, f32, f32, f32), - sync_actions: (f32, f32, f32, f32), -} - -/// Scrollable-content viewport height for the Settings tab. -fn settings_viewport_h(popup_h: f32, scale_factor: f32) -> f32 { - popup_h - 72.0 * scale_factor -} - -fn advanced_content_end(advanced_y0: f32, sync_y0: f32, row_h: f32) -> f32 { - if SHOW_WEBDAV_SYNC_SETTINGS { sync_y0 + 7.0 * row_h } else { advanced_y0 + row_h } -} - -/// Max scroll offset for `section` at the current window size. The input -/// layer clamps its accumulated wheel delta with this. Dropdown popups float -/// over rows, so they never change a section's content height. -pub(super) fn settings_max_scroll( - size_info: &SizeInfo, - scale_factor: f32, - area: (f32, f32, f32, f32), - section: NebulaSettingsSection, - hidden_host_count: usize, -) -> f32 { - let geometry = settings_geometry(size_info, scale_factor, area, 0.0, hidden_host_count); - let (_, _, _, ph) = geometry.popup; - let content_h = match section { - NebulaSettingsSection::Appearance => geometry.appearance_h, - NebulaSettingsSection::Profiles => geometry.profiles_h, - NebulaSettingsSection::Interaction => geometry.interaction_h, - NebulaSettingsSection::Keymap => geometry.keymap_h, - NebulaSettingsSection::Advanced => geometry.advanced_h, - }; - (content_h - settings_viewport_h(ph, scale_factor)).max(0.0) -} - -fn settings_geometry( - size_info: &SizeInfo, - scale_factor: f32, - area: (f32, f32, f32, f32), - scroll: f32, - hidden_host_count: usize, -) -> SettingsGeometry { - let s = |v: f32| v * scale_factor; - let gear = chrome_settings_button_rect(size_info, scale_factor); - - // The settings surface is the active tab's content card. Keeping the - // geometry rooted in that card makes sidebar/drawer animations and DPI - // changes follow the exact same bounds as terminal and document tabs. - let (popup_x, popup_y, popup_w, popup_h) = area; - let sidebar_w = s(220.0).min(popup_w * 0.30); - let sidebar = (popup_x, popup_y, sidebar_w, popup_h); - let content_x = popup_x + sidebar_w + s(16.0); - let content_w = (popup_w - sidebar_w - s(16.0)).max(s(240.0)); - let content = (content_x, popup_y, content_w, popup_h); - - // The header band (big section title) is fixed; everything below it - // scrolls by `scroll` px. `at` maps a design-space Y to screen space. - let content_top = popup_y + s(72.0); - let at = |design_y: f32| popup_y + s(design_y) - scroll; - - // ---- vertical rhythm (design px from the popup top) ---- - // Mirrors the HTML design sheet's breathing room: a group title hangs - // 42px above its first row (title + 16px gap); rows inside a group are - // CONTIGUOUS — one hairline frame around the block, hairline separators - // between rows — and a finished group leaves 32px before the next title, - // so `74 = 32 (section gap) + 42 (hanging title)`. - const ROW_H: f32 = 44.0; - const GROUP_ADVANCE: f32 = 74.0; - // 预览卡设计高度:两行示例文本 + 光标演示的呼吸空间。 - const PREVIEW_H: f32 = 150.0; - - // Live preview leads the page: configure → see it immediately. - let preview_y0 = 146.0; - let preview = (content_x + s(24.0), at(preview_y0), content_w - s(48.0), s(PREVIEW_H)); - - // Theme cards flow as a 4 + 3 grid with the design sheet's 20px gaps: - // slot i sits at column i%4 on row i/4. The row pitch reserves a strip - // under each card for its label (12px gap + text + 20px grid row gap). - let card_gap = s(20.0); - let card_w = ((content_w - s(48.0) - 3.0 * card_gap) / 4.0).clamp(s(88.0), s(170.0)); - let card_h = s(64.0); - let card_y0 = preview_y0 + PREVIEW_H + GROUP_ADVANCE; - let card_x = content_x + s(24.0); - let card_row_pitch = card_h + s(48.0); - let card = |i: f32| card_x + (i % 4.0) * (card_w + card_gap); - let card_slot_y = |i: f32| at(card_y0) + (i / 4.0).floor() * card_row_pitch; - - let row_x = content_x + s(24.0); - let row_w = content_w - s(48.0); - let row_h = s(ROW_H); - - // Appearance: preview, cards, colors, cursor and interface groups. - let system_theme_y0 = card_y0 + 2.0 * (64.0 + 48.0) + GROUP_ADVANCE; - let color_y0 = system_theme_y0 + ROW_H + GROUP_ADVANCE; - // Match Windows Terminal's background-image controls: path, stretch, - // alignment and an independent image-opacity slider. - let background_image_y0 = color_y0 + ROW_H; - let background_image_fit_y0 = background_image_y0 + ROW_H; - let background_image_alignment_y0 = background_image_fit_y0 + ROW_H; - let background_image_opacity_y0 = background_image_alignment_y0 + ROW_H; - let background_image_cover_chrome_y0 = background_image_opacity_y0 + ROW_H; - // 光标组:形状下拉 + 闪烁开关。 - let cursor_y0 = color_y0 + 6.0 * ROW_H + GROUP_ADVANCE; - let iface_y0 = cursor_y0 + 2.0 * ROW_H + GROUP_ADVANCE; - let opacity_y0 = iface_y0 + ROW_H; - let appearance_h = s(opacity_y0 + ROW_H + 32.0 - 72.0); - // 宽命中区包住细轨道,拖拽时无需精确点中 4px 线条。 - let opacity_row = (row_x, at(opacity_y0), row_w, row_h); - let slider_x = row_x + row_w - s(212.0); - let slider_w = s(188.0).min(row_w * 0.42).max(s(96.0)); - let opacity_slider = (slider_x, at(opacity_y0) + s(4.0), slider_w, s(36.0)); - let background_image_opacity_row = (row_x, at(background_image_opacity_y0), row_w, row_h); - let background_image_opacity_slider = - (slider_x, at(background_image_opacity_y0) + s(4.0), slider_w, s(36.0)); - - // Sidebar navigation rows. The rects line up with the active-row - // highlight drawn while rendering. 4px between items — same breathing as - // the design sheet's nav menu. - let nav_x = popup_x + s(24.0); - let nav_w = sidebar_w - s(48.0); - let nav_h = s(44.0); - let nav_gap = s(4.0); - let nav_y0 = popup_y + s(88.0); - let nav_slot = |i: f32| nav_y0 + i * (nav_h + nav_gap); - let nav = [ - (NebulaSettingsSection::Appearance, nav_x, nav_slot(0.0), nav_w, nav_h), - (NebulaSettingsSection::Profiles, nav_x, nav_slot(1.0), nav_w, nav_h), - (NebulaSettingsSection::Interaction, nav_x, nav_slot(2.0), nav_w, nav_h), - (NebulaSettingsSection::Keymap, nav_x, nav_slot(3.0), nav_w, nav_h), - (NebulaSettingsSection::Advanced, nav_x, nav_slot(4.0), nav_w, nav_h), - ]; - - // Profiles: dropdown popups FLOAT over later rows (Windows 11 combobox), - // so every row keeps a fixed offset — no picker shove, no scroll jumps. - let shell_y0 = 146.0; - 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 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; - let profiles_end = if hidden_host_count == 0 { - open_y0 + ROW_H - } else { - hidden_y0 + hidden_host_count as f32 * ROW_H - }; - let profiles_h = s(profiles_end + 32.0 - 72.0); - - // 交互: copy-on-select + 文本渲染(CJK 粗体策略)两组。 - let interaction_y0 = 146.0; - let cjk_bold_y0 = interaction_y0 + ROW_H + GROUP_ADVANCE; - let interaction_h = s(cjk_bold_y0 + ROW_H + 32.0 - 72.0); - let copy_on_select = (row_x, at(interaction_y0), row_w, row_h); - let cjk_bold = (row_x, at(cjk_bold_y0), row_w, row_h); - - // Keymap: editable action rows, then a read-only extras group (spec 002). - let keymap_y0 = 146.0; - let keymap_readonly_y0 = - keymap_y0 + keymap::editable_row_count() as f32 * ROW_H + GROUP_ADVANCE; - let keymap_h = - s(keymap_readonly_y0 + keymap::READONLY_ROWS.len() as f32 * ROW_H + 32.0 - 72.0); - let keymap_row0 = (row_x, at(keymap_y0), row_w, row_h); - let keymap_readonly_row0 = (row_x, at(keymap_readonly_y0), row_w, row_h); - - // Advanced: session residency, then the gated WebDAV sync group (spec 003). - // 隐藏期间同步几何仍保留,方便后续继续完善,但页面高度只计算可见内容。 - let advanced_y0 = 146.0; - let keep_session = (row_x, at(advanced_y0), row_w, row_h); - let sync_y0 = advanced_y0 + ROW_H + GROUP_ADVANCE; - let sync_row = |i: f32| (row_x, at(sync_y0 + i * ROW_H), row_w, row_h); - let sync_rows = [sync_row(0.0), sync_row(1.0), sync_row(2.0), sync_row(3.0)]; - let sync_auto_pull = sync_row(4.0); - let sync_actions = sync_row(5.0); - let advanced_end = advanced_content_end(advanced_y0, sync_y0, ROW_H); - let advanced_h = s(advanced_end + 32.0 - 72.0); - - SettingsGeometry { - gear, - popup: (popup_x, popup_y, popup_w, popup_h), - sidebar, - content, - nav, - options: [ - (NebulaTheme::Nebula, card(0.0), card_slot_y(0.0), card_w, card_h), - (NebulaTheme::SilverLight, card(1.0), card_slot_y(1.0), card_w, card_h), - (NebulaTheme::SteelDark, card(2.0), card_slot_y(2.0), card_w, card_h), - (NebulaTheme::LimestoneLight, card(3.0), card_slot_y(3.0), card_w, card_h), - (NebulaTheme::CoalDark, card(4.0), card_slot_y(4.0), card_w, card_h), - (NebulaTheme::LinenLight, card(5.0), card_slot_y(5.0), card_w, card_h), - (NebulaTheme::MossDark, card(6.0), card_slot_y(6.0), card_w, card_h), - ], - preview, - system_theme: (row_x, at(system_theme_y0), row_w, row_h), - background: (row_x, at(color_y0), row_w, row_h), - background_image: (row_x, at(background_image_y0), row_w, row_h), - background_image_clear: ( - row_x + row_w - s(48.0), - at(background_image_y0) + s(5.0), - s(36.0), - s(34.0), - ), - background_image_fit: (row_x, at(background_image_fit_y0), row_w, row_h), - background_image_alignment: (row_x, at(background_image_alignment_y0), row_w, row_h), - background_image_cover_chrome: (row_x, at(background_image_cover_chrome_y0), row_w, row_h), - background_image_opacity_row, - background_image_opacity_slider, - cursor_shape_row: (row_x, at(cursor_y0), row_w, row_h), - cursor_blink_row: (row_x, at(cursor_y0 + ROW_H), row_w, row_h), - language_row: (row_x, at(iface_y0), row_w, row_h), - opacity_row, - opacity_slider, - shell: (row_x, at(shell_y0), row_w, row_h), - startup_directory: (row_x, at(startup_directory_y0), row_w, row_h), - startup_directory_clear: ( - row_x + row_w - s(82.0), - at(startup_directory_y0) + s(5.0), - s(72.0), - s(34.0), - ), - font: (row_x, at(font_y0), row_w, row_h), - font_size_row: (row_x, at(font_size_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), - accept: (row_x, at(ghost_y0 + ROW_H), row_w, row_h), - open_config_file: (row_x, at(open_y0), row_w, row_h), - hidden_host_row0: (row_x, at(hidden_y0), row_w, row_h), - hidden_host_count, - copy_on_select, - cjk_bold, - reset: (popup_x + popup_w - s(170.0), popup_y + s(24.0), s(150.0), s(42.0)), - content_top, - appearance_h, - profiles_h, - interaction_h, - keymap_h, - keymap_row0, - keymap_readonly_row0, - keymap_row_h: row_h, - advanced_h, - keep_session, - sync_rows, - sync_auto_pull, - sync_actions, - } -} - -pub(crate) fn opacity_slider_rect( - size_info: &SizeInfo, - scale_factor: f32, - area: (f32, f32, f32, f32), - scroll: f32, - target: SettingsOpacityTarget, -) -> (f32, f32, f32, f32) { - let geometry = settings_geometry(size_info, scale_factor, area, scroll, 0); - match target { - SettingsOpacityTarget::Terminal => geometry.opacity_slider, - SettingsOpacityTarget::BackgroundImage => geometry.background_image_opacity_slider, - } -} - -pub(crate) fn opacity_from_pointer(pointer_x: f32, slider: (f32, f32, f32, f32)) -> f32 { - ((pointer_x - slider.0) / slider.2.max(1.0)).clamp(0.0, 1.0) -} - -/// Appearance 预览卡的壁纸绘制矩形:`(fit 目标, 实际允许触碰的裁剪带)`。 -/// 裁剪带是预览与设置卡内容区的竖向交集——预览滚到 header 之下时壁纸不 -/// 能跟着涂出去。完全滚出可视区时返回 `None`。 -pub(super) fn appearance_preview_wallpaper_rects( - size_info: &SizeInfo, - scale_factor: f32, - area: (f32, f32, f32, f32), - scroll: f32, - hidden_hosts: usize, -) -> Option<((f32, f32, f32, f32), (f32, f32, f32, f32))> { - let geometry = settings_geometry(size_info, scale_factor, area, scroll, hidden_hosts); - let (vx, vy, vw, vh) = geometry.preview; - let (_, content_y, _, _) = geometry.content; - let (_, py, _, ph) = geometry.popup; - let top = vy.max(content_y); - let bottom = (vy + vh).min(py + ph); - if bottom <= top || vw <= 0.0 { - return None; - } - Some(((vx, vy, vw, vh), (vx, top, vw, bottom - top))) -} - -/// The combobox anchor rect + option count for `dropdown`, IF it belongs to -/// the active section. Hit-testing, popup quads and popup text all resolve -/// the floating list through this one helper so the three can never disagree. -/// 背景色浮层的几何:面板矩形、12 个色板格、16 进制输入框。绘制与命中 -/// 测试共用这一个来源(组件化范式:几何同源,控件与点击区不漂移)。 -pub(super) struct BackgroundColorPopup { - pub(super) rect: (f32, f32, f32, f32), - pub(super) swatch: [(f32, f32, f32, f32); 12], - pub(super) hex: (f32, f32, f32, f32), -} - -pub(super) fn background_color_popup( - geometry: &SettingsGeometry, - scale: f32, -) -> BackgroundColorPopup { - let s = |v: f32| v * scale; - let (ax, ay, aw, ah) = widgets::combobox_rect(geometry.background, scale); - const COLS: usize = 6; - let cell = s(30.0); - let gap = s(8.0); - let pad = s(12.0); - let grid_w = COLS as f32 * cell + (COLS - 1) as f32 * gap; - let grid_h = 2.0 * cell + gap; - let hex_h = s(34.0); - let w = (grid_w + 2.0 * pad).max(aw); - let h = pad + grid_h + gap + hex_h + pad; - // 与 combobox 浮层同规则:锚行右缘对齐,紧贴行下方展开。 - let x = ax + aw - w; - let y = ay + ah + s(6.0); - let mut swatch = [(0.0, 0.0, 0.0, 0.0); 12]; - for (i, rect) in swatch.iter_mut().enumerate() { - let row = i / COLS; - let col = i % COLS; - *rect = ( - x + pad + col as f32 * (cell + gap), - y + pad + row as f32 * (cell + gap), - cell, - cell, - ); - } - let hex = (x + pad, y + pad + grid_h + gap, w - 2.0 * pad, hex_h); - BackgroundColorPopup { rect: (x, y, w, h), swatch, hex } -} - -fn dropdown_anchor( - geometry: &SettingsGeometry, - section: NebulaSettingsSection, - dropdown: SettingsDropdown, - shell_count: usize, - font_count: usize, - scale: f32, -) -> Option<((f32, f32, f32, f32), usize)> { - use NebulaSettingsSection as Section; - let anchor = |row| widgets::combobox_rect(row, scale); - match (section, dropdown) { - (Section::Profiles, SettingsDropdown::Shell) => Some((anchor(geometry.shell), shell_count)), - (Section::Profiles, SettingsDropdown::Font) => Some((anchor(geometry.font), font_count)), - (Section::Profiles, SettingsDropdown::Accept) => { - Some((anchor(geometry.accept), ACCEPT_OPTIONS.len())) - }, - (Section::Appearance, SettingsDropdown::BackgroundFit) => { - Some((anchor(geometry.background_image_fit), BACKGROUND_FIT_OPTIONS.len())) - }, - (Section::Appearance, SettingsDropdown::BackgroundAlignment) => { - Some((anchor(geometry.background_image_alignment), BACKGROUND_ALIGNMENT_OPTIONS.len())) - }, - (Section::Appearance, SettingsDropdown::Language) => { - Some((anchor(geometry.language_row), LANGUAGE_OPTIONS.len())) - }, - (Section::Appearance, SettingsDropdown::CursorShape) => { - Some((anchor(geometry.cursor_shape_row), CURSOR_SHAPE_OPTIONS.len())) - }, - _ => None, - } -} - -/// Hit-test the top-left settings button and its popup. `scroll` must be the -/// same offset the renderer used, so hits land on what the user actually sees; -/// rows scrolled out of the content viewport don't respond. -#[allow(clippy::too_many_arguments)] -pub fn settings_hit( - size_info: &SizeInfo, - scale_factor: f32, - area: (f32, f32, f32, f32), - x: f32, - y: f32, - popup_open: bool, - section: NebulaSettingsSection, - scroll: f32, - dropdown: Option, - shell_count: usize, - font_count: usize, - hidden_host_count: usize, -) -> SettingsHit { - let geometry = settings_geometry(size_info, scale_factor, area, scroll, hidden_host_count); - let s = |v: f32| v * scale_factor; - - if contains_rect(geometry.gear, x, y) { - return SettingsHit::Toggle; - } - - if !popup_open { - return SettingsHit::None; - } - - // Scrolled content only responds inside its viewport (below the fixed - // header, above the popup's bottom edge). - let (_, py, _, ph) = geometry.popup; - let in_viewport = y >= geometry.content_top && y <= py + ph; - - // An expanded dropdown owns the pointer first: its floating option list - // covers later rows, and those must not react through it. - if let Some(dropdown) = dropdown { - // 背景色是专用浮层(色板网格 + hex 输入),不走通用行列表。 - if dropdown == SettingsDropdown::BackgroundColor { - if section == NebulaSettingsSection::Appearance { - let popup = background_color_popup(&geometry, scale_factor); - for (index, rect) in popup.swatch.iter().enumerate() { - if contains_rect(*rect, x, y) { - return SettingsHit::BackgroundSwatch(index); - } - } - if contains_rect(popup.hex, x, y) { - return SettingsHit::BackgroundHexInput; - } - if contains_rect(popup.rect, x, y) { - return SettingsHit::BackgroundPopupPanel; - } - } - } else if let Some((anchor, count)) = - dropdown_anchor(&geometry, section, dropdown, shell_count, font_count, scale_factor) - { - let popup = widgets::combobox_popup_rect( - anchor, - count, - scale_factor, - geometry.content_top, - py + ph - s(6.0), - ); - if let Some(index) = widgets::popup_row_at(popup, count, scale_factor, x, y) { - return match dropdown { - SettingsDropdown::Shell => SettingsHit::ShellPickerRow(index), - SettingsDropdown::Font => SettingsHit::FontPickerRow(index), - SettingsDropdown::BackgroundFit => SettingsHit::FitOption(index), - SettingsDropdown::BackgroundAlignment => SettingsHit::AlignOption(index), - SettingsDropdown::Language => SettingsHit::Language(LANGUAGE_OPTIONS[index]), - SettingsDropdown::Accept => SettingsHit::AcceptOption(index), - SettingsDropdown::CursorShape => SettingsHit::CursorShapeOption(index), - // 背景色浮层在上方特判处理,走不到通用行列表。 - SettingsDropdown::BackgroundColor => SettingsHit::Panel, - }; - } - if contains_rect(popup, x, y) { - // Padding strip inside the floating list: swallow the click - // so rows underneath cannot react through the popup. - return SettingsHit::Panel; - } - } - } - - // Sidebar navigation and the header reset button are available from every - // section. - for (nav_section, nx, ny, nw, nh) in geometry.nav { - if contains_rect((nx, ny, nw, nh), x, y) { - return SettingsHit::Nav(nav_section); - } - } - if contains_rect(geometry.reset, x, y) { - return SettingsHit::Reset; - } - - if in_viewport { - match section { - NebulaSettingsSection::Appearance => { - for (theme, ox, oy, ow, oh) in geometry.options { - if contains_rect((ox, oy, ow, oh), x, y) { - return SettingsHit::Theme(theme); - } - } - if contains_rect(geometry.system_theme, x, y) { - return SettingsHit::SystemThemeToggle; - } - if contains_rect(geometry.background, x, y) { - return SettingsHit::BackgroundColor; - } - if contains_rect(geometry.background_image_clear, x, y) { - return SettingsHit::BackgroundImageClear; - } - if contains_rect(geometry.background_image, x, y) { - return SettingsHit::BackgroundImage; - } - if contains_rect(geometry.background_image_fit, x, y) { - return SettingsHit::BackgroundImageFit; - } - if contains_rect(geometry.background_image_alignment, x, y) { - return SettingsHit::BackgroundImageAlignment; - } - if contains_rect(geometry.background_image_opacity_slider, x, y) { - return SettingsHit::BackgroundImageOpacitySlider; - } - if contains_rect(geometry.background_image_cover_chrome, x, y) { - return SettingsHit::BackgroundImageCoverChrome; - } - if contains_rect(geometry.cursor_shape_row, x, y) { - return SettingsHit::CursorShapeDropdown; - } - if contains_rect(geometry.cursor_blink_row, x, y) { - return SettingsHit::CursorBlinkToggle; - } - if contains_rect(geometry.language_row, x, y) { - return SettingsHit::LanguageDropdown; - } - if contains_rect(geometry.opacity_slider, x, y) { - return SettingsHit::OpacitySlider; - } - }, - NebulaSettingsSection::Profiles => { - if contains_rect(geometry.shell, x, y) { - return SettingsHit::ShellCycle; - } - if contains_rect(geometry.startup_directory_clear, x, y) { - return SettingsHit::StartupDirectoryClear; - } - if contains_rect(geometry.startup_directory, x, y) { - return SettingsHit::StartupDirectory; - } - if contains_rect(geometry.font, x, y) { - return SettingsHit::FontCycle; - } - { - let (_, up, down) = - widgets::spinner_rects(geometry.font_size_row, scale_factor); - if contains_rect(up, x, y) { - return SettingsHit::FontSizeUp; - } - if contains_rect(down, x, y) { - return SettingsHit::FontSizeDown; - } - } - if contains_rect(geometry.fetch, x, y) { - return SettingsHit::FetchToggle; - } - if contains_rect(geometry.powerline, x, y) { - return SettingsHit::PowerlineToggle; - } - if contains_rect(geometry.ghost, x, y) { - return SettingsHit::GhostToggle; - } - if contains_rect(geometry.accept, x, y) { - return SettingsHit::AcceptCycle; - } - if contains_rect(geometry.open_config_file, x, y) { - return SettingsHit::OpenConfigFile; - } - let (row_x, row_y, row_w, row_h) = geometry.hidden_host_row0; - for index in 0..geometry.hidden_host_count { - let rect = (row_x, row_y + index as f32 * row_h, row_w, row_h); - if contains_rect(rect, x, y) { - return SettingsHit::RestoreHiddenSsh(index); - } - } - }, - NebulaSettingsSection::Interaction => { - if contains_rect(geometry.copy_on_select, x, y) { - return SettingsHit::CopyOnSelectToggle; - } - if contains_rect(geometry.cjk_bold, x, y) { - return SettingsHit::CjkBoldToggle; - } - }, - NebulaSettingsSection::Keymap => { - let (row_x, row_y, row_w, row_h) = geometry.keymap_row0; - for index in 0..keymap::editable_row_count() { - let rect = (row_x, row_y + index as f32 * row_h, row_w, row_h); - if contains_rect(rect, x, y) { - return SettingsHit::KeymapRow(index); - } - } - }, - NebulaSettingsSection::Advanced => { - if contains_rect(geometry.keep_session, x, y) { - return SettingsHit::KeepSessionToggle; - } - if SHOW_WEBDAV_SYNC_SETTINGS { - for (index, rect) in geometry.sync_rows.iter().enumerate() { - // 命中整行都算输入框:行左侧是它的 label,点标签聚焦 - // 输入是 Windows 设置页的惯例。 - if contains_rect(*rect, x, y) { - return SettingsHit::SyncInput(index); - } - } - if contains_rect(geometry.sync_auto_pull, x, y) { - return SettingsHit::SyncAutoPullToggle; - } - let [push, pull] = sync_button_rects(geometry.sync_actions, scale_factor); - if contains_rect(push, x, y) { - return SettingsHit::SyncPushButton; - } - if contains_rect(pull, x, y) { - return SettingsHit::SyncPullButton; - } - } - }, - } - } - - if contains_rect(geometry.popup, x, y) { SettingsHit::Panel } else { SettingsHit::None } -} - -// ---- rendering ---- - -/// A per-frame snapshot of the display state the settings render reads. Owns its -/// data (notably the wallpaper path) so the caller can hand it in by reference -/// while still borrowing `&mut renderer` for [`draw_text`]. -pub(super) struct SettingsView { - /// The active tab's content card in physical pixels. Settings fills this - /// area like any other tab instead of inventing a second floating window. - pub(super) area: (f32, f32, f32, f32), - pub(super) language_preference: LanguagePreference, - pub(super) language: UiLanguage, - pub(super) section: NebulaSettingsSection, - pub(super) hover: SettingsHit, - pub(super) theme: NebulaTheme, - pub(super) follow_system_theme: bool, - pub(super) ghost: bool, - pub(super) accept: AcceptKey, - /// Pre-rendered "默认 Shell" value (icon + name) — resolved by `Display` - /// from the rich `shell_id` when set, else the 2-value enum label. - pub(super) shell_label: String, - /// Which combobox is expanded, if any (floating option list). - pub(super) dropdown: Option, - /// Detected shells for the picker (cached once per process). - pub(super) shells: Vec<(String, String, String)>, // (id, name, program) - pub(super) shell_id: Option, - pub(super) startup_directory: Option, - pub(super) font_family: String, - /// Current terminal font size in LOGICAL px, for the spinner value box. - pub(super) font_size_px: f32, - /// Private families plus Maple; the import action is rendered separately. - pub(super) fonts: Vec, - pub(super) font_notice: Option, - /// Persistent soft-deleted destinations. Rows provide a discoverable - /// recovery path after the short Undo bar has expired. - pub(super) hidden_hosts: Vec, - pub(super) fetch: bool, - pub(super) powerline: bool, - pub(super) keep_session: bool, - pub(super) opacity: f32, - /// Which opacity slider is mid-drag, for thumb-dot grow feedback. - pub(super) dragging_opacity: Option, - pub(super) cursor_shape: CursorShape, - pub(super) cursor_blink: bool, - pub(super) copy_on_select: bool, - pub(super) cjk_bold_regular: bool, - /// 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, - pub(super) preview_fg: Rgb, - pub(super) background: Option, - /// 背景色浮层的 16 进制草稿(形如 `#0A0C18`)与输入聚焦态。 - pub(super) bg_hex_input: String, - pub(super) bg_hex_active: bool, - pub(super) background_image: Option, - pub(super) background_image_opacity: f32, - pub(super) background_image_fit: BackgroundImageFit, - pub(super) background_image_alignment: BackgroundImageAlignment, - pub(super) background_image_cover_chrome: bool, - /// Content scroll offset in scaled px (0 = top). Owned by `Display`, - /// clamped there against [`settings_max_scroll`]. - pub(super) scroll: f32, - /// 按键映射: per editable row `(display combo, customized)`; `None` = - /// unbound. Precomputed by `Display` from the override + default tables. - pub(super) keymap: Vec>, - /// 快速终端行使用独立的全局快捷键字符串。 - pub(super) quick_terminal_hotkey: String, - pub(super) quick_hotkey_error: Option, - /// Row currently capturing a new combo, if any. - pub(super) keymap_capture: Option, - /// 捕获态按住的修饰键前缀("Ctrl+"),实时回显。 - pub(super) keymap_capture_preview: String, - /// 高级→同步:四个输入草稿(url、用户名、WebDAV 密码、E2E 口令)。 - pub(super) sync_inputs: [String; 4], - /// 聚焦的同步输入框下标(0..4)。 - pub(super) sync_focus: Option, - pub(super) sync_auto_pull: bool, - /// 凭据管理器里已有 [密码, 口令],决定密码框占位文案。 - pub(super) sync_secret_set: [bool; 2], - /// 最近一次同步动作的结果 `(message, is_error)`。 - pub(super) sync_status: Option<(String, bool)>, - pub(super) sync_busy: bool, -} - -/// 同步行右侧的输入框矩形(quad/text/hit 三处共用)。行左侧留给标签。 -fn sync_input_rect((rx, ry, rw, rh): (f32, f32, f32, f32), scale: f32) -> (f32, f32, f32, f32) { - let s = |v: f32| v * scale; - let w = rw * 0.56; - let h = rh - s(12.0); - (rx + rw - s(16.0) - w, ry + (rh - h) / 2.0, w, h) -} - -/// 同步输入框的展示内容:`(文本, 是否占位, 列数)`。密码/口令显示为 -/// 掩码点;超宽时截尾部显示(编辑总发生在末尾)。列数供 caret 定位。 -fn sync_input_display(view: &SettingsView, index: usize, max_cols: usize) -> (String, bool, usize) { - let language = view.language; - let raw = &view.sync_inputs[index]; - if raw.is_empty() { - let text = match index { - 0 => language.pick("https://dav.example.com/nebula.sync", "https://dav.example.com/nebula.sync"), - 1 => language.pick("WebDAV 用户名", "WebDAV username"), - 2 if view.sync_secret_set[0] => { - language.pick("已保存(输入以更换)", "Saved (type to replace)") - }, - 3 if view.sync_secret_set[1] => { - language.pick("已保存(输入以更换)", "Saved (type to replace)") - }, - _ => language.pick("未设置", "Not set"), - }; - return (text.to_owned(), true, 0); - } - if index >= 2 { - let dots = raw.chars().count().min(24); - return ("●".repeat(dots), false, dots); - } - // 从尾部收集不超过 max_cols 列的字符(中文占 2 列)。 - let mut cols = 0usize; - let mut chars: Vec = Vec::new(); - for ch in raw.chars().rev() { - let w = ch.width().unwrap_or(1).max(1); - if cols + w > max_cols { - break; - } - cols += w; - chars.push(ch); - } - chars.reverse(); - (chars.into_iter().collect(), false, cols) -} - -/// 动作行的 [立即推送, 立即拉取] 按钮矩形。 -fn sync_button_rects((rx, ry, _, rh): (f32, f32, f32, f32), scale: f32) -> [(f32, f32, f32, f32); 2] { - let s = |v: f32| v * scale; - let w = s(150.0); - let h = rh - s(12.0); - let y = ry + (rh - h) / 2.0; - [(rx, y, w, h), (rx + w + s(12.0), y, w, h)] -} - -/// 键位行的 keycap 矩形(quad 与 text 两个 pass 共用同一几何)。 -fn keymap_keycap_rect( - (rx, ry, rw, rh): (f32, f32, f32, f32), - label: &str, - cell_w: f32, - scale: f32, -) -> (f32, f32, f32, f32) { - let s = |v: f32| v * scale; - let cols: usize = label.chars().map(|c| c.width().unwrap_or(0)).sum(); - let cap_w = cols as f32 * cell_w + s(24.0); - let cap_h = rh - s(14.0); - (rx + rw - s(16.0) - cap_w, ry + (rh - cap_h) / 2.0, cap_w, cap_h) -} - -/// 键位行右侧的展示文本:(文本, 是否自定义, 是否有绑定)。捕获态的 -/// 「按下新按键…」由调用侧替换。 -fn keymap_row_value(view: &SettingsView, index: usize) -> (String, bool, bool) { - if view.keymap_capture == Some(index) { - // 按住修饰键时实时回显("Ctrl+…"),否则给占位 + 取消提示。 - let text = if view.keymap_capture_preview.is_empty() { - view.language.pick("按下新按键…(Esc 取消)", "Press new keys… (Esc cancels)").to_owned() - } else { - format!("{}…", view.keymap_capture_preview) - }; - return (text, false, false); - } - if index == keymap::QUICK_TERMINAL_ROW { - return ( - keymap::display_stored_combo(&view.quick_terminal_hotkey), - view.quick_terminal_hotkey != keymap::DEFAULT_QUICK_TERMINAL_HOTKEY, - true, - ); - } - let action_index = index - 1; - match view.keymap.get(action_index).and_then(|slot| slot.as_ref()) { - Some((combo, customized)) => (combo.clone(), *customized, true), - None => (view.language.pick("未绑定", "Unbound").to_owned(), false, false), - } -} - -/// Preview sample layout shared by the quad pass (cursor demo) and the text -/// pass (sample lines): 16px inner pad, 1.4× line pitch. -fn preview_line_y(top: f32, cell_h: f32, line: f32, scale: f32) -> f32 { - top + 16.0 * scale + line * (cell_h * 1.4) -} -/// Columns of "❯ " before the demo cursor on the preview's prompt line. -const PREVIEW_PROMPT_COLS: usize = 2; - -fn dropdown_selected_index(view: &SettingsView, dropdown: SettingsDropdown) -> Option { - match dropdown { - SettingsDropdown::Shell => view - .shells - .iter() - .position(|(id, _, _)| view.shell_id.as_deref() == Some(id.as_str())), - SettingsDropdown::Font => view.fonts.iter().position(|family| family == &view.font_family), - SettingsDropdown::BackgroundFit => { - BACKGROUND_FIT_OPTIONS.iter().position(|fit| *fit == view.background_image_fit) - }, - SettingsDropdown::BackgroundAlignment => BACKGROUND_ALIGNMENT_OPTIONS - .iter() - .position(|alignment| *alignment == view.background_image_alignment), - SettingsDropdown::Language => { - LANGUAGE_OPTIONS.iter().position(|preference| *preference == view.language_preference) - }, - SettingsDropdown::Accept => ACCEPT_OPTIONS.iter().position(|key| *key == view.accept), - SettingsDropdown::CursorShape => { - CURSOR_SHAPE_OPTIONS.iter().position(|shape| *shape == view.cursor_shape) - }, - SettingsDropdown::BackgroundColor => view - .background - .and_then(|current| BACKGROUND_SWATCHES.iter().position(|color| *color == current)), - } -} - -fn dropdown_hover_index(hover: SettingsHit, dropdown: SettingsDropdown) -> Option { - match (dropdown, hover) { - (SettingsDropdown::Shell, SettingsHit::ShellPickerRow(index)) => Some(index), - (SettingsDropdown::Font, SettingsHit::FontPickerRow(index)) => Some(index), - (SettingsDropdown::BackgroundFit, SettingsHit::FitOption(index)) => Some(index), - (SettingsDropdown::BackgroundAlignment, SettingsHit::AlignOption(index)) => Some(index), - (SettingsDropdown::Language, SettingsHit::Language(preference)) => { - LANGUAGE_OPTIONS.iter().position(|option| *option == preference) - }, - (SettingsDropdown::Accept, SettingsHit::AcceptOption(index)) => Some(index), - (SettingsDropdown::CursorShape, SettingsHit::CursorShapeOption(index)) => Some(index), - _ => None, - } -} - -/// Push the Settings tab's background quads, navigation, rows and controls. -pub(super) fn push_quads( - view: &SettingsView, - quads: &mut Vec, - size: &SizeInfo, - scale: f32, -) { - let s = |v: f32| v * scale; - let sk = view.theme.skin(); - - let geometry = settings_geometry(size, scale, view.area, view.scroll, view.hidden_hosts.len()); - let (px, py, pw, ph) = geometry.popup; - // Header band height: the title row sits above the content, and the header - // separator + big title are all measured from here. - let header_h = s(72.0); - // Scrolled content is clipped EXACTLY at the viewport edges: quads that - // cross the fixed header separator or the popup's bottom edge are cut at - // the line via [`UiQuad::clip_y`] (uv-remapped, so rounded corners and - // glows are truncated mid-shape instead of bleeding past the hairline). - let clip_top = geometry.content_top; - let clip_bot = py + ph - s(6.0); - let clip = |quads: &mut Vec, quad: UiQuad| { - if let Some(quad) = quad.clip_y(clip_top, clip_bot) { - quads.push(quad); - } - }; - // 通用组件(widgets)不感知视口裁剪:输出先落到 staged,再统一过 clip。 - let mut staged: Vec = Vec::new(); - - // The page is flush with the active tab card. No veil, drop shadow or - // second window outline: depth belongs to the app shell, not this page. - quads.push(UiQuad::solid(px, py, pw, ph, s(12.0), sk.panel)); - let (side_x, side_y, side_w, side_h) = geometry.sidebar; - - // Sidebar: no fill of its own — just a hairline separator on its right - // edge. Hierarchy comes from the nav rows, not a competing surface. - quads.push(UiQuad::solid( - side_x + side_w - s(1.0), - side_y + s(16.0), - s(1.0), - side_h - s(32.0), - 0.0, - sk.hairline, - )); - - // Header separator under the panel title row, only in the content area - // (right of the sidebar). The sidebar's own separator runs full height. - let content_x = side_x + side_w; - quads.push(UiQuad::solid( - content_x, - py + header_h, - px + pw - content_x - s(1.0), - s(1.0), - 0.0, - sk.hairline, - )); - - // Sidebar navigation: the active row is a floating pill — a soft accent - // wash inside a hairline accent border (design language: no accent bar, - // no vertical line); hover stays a quiet wash. - let section = view.section; - for (nav_section, nx, ny, nw, nh) in geometry.nav { - if nav_section == section { - quads.push(UiQuad::solid( - nx - s(1.0), - ny - s(1.0), - nw + s(2.0), - nh + s(2.0), - s(9.0), - Rgba::new(sk.accent.r, sk.accent.g, sk.accent.b, 40), - )); - quads.push(UiQuad::solid(nx, ny, nw, nh, s(8.0), sk.panel)); - quads.push(UiQuad::solid(nx, ny, nw, nh, s(8.0), sk.accent_soft)); - } else if view.hover == SettingsHit::Nav(nav_section) { - quads.push(UiQuad::solid(nx, ny, nw, nh, s(8.0), sk.hover)); - } - } - - // Reset: a quiet ghost button in the header (hairline, no fill until hover). - { - let (rx, ry, rw, rh) = geometry.reset; - quads.push(UiQuad::solid( - rx - s(1.0), - ry - s(1.0), - rw + s(2.0), - rh + s(2.0), - s(9.0), - sk.hairline, - )); - quads.push(UiQuad::solid(rx, ry, rw, rh, s(8.0), sk.surface)); - if view.hover == SettingsHit::Reset { - quads.push(UiQuad::solid(rx, ry, rw, rh, s(8.0), sk.hover)); - } - } - - // One framed group of rows: a hairline border around the whole block and - // hairline separators between rows — the block reads as ONE plate (the - // design sheet's `.settings-list`), not a stack of separate pills. Rows - // stay transparent; hover marks the row with an inset rounded wash. - let row_h = geometry.background.3; - let group_frame = |quads: &mut Vec, first_row: (f32, f32, f32, f32), rows: usize| { - let (gx, gy, gw, _) = first_row; - let gh = rows as f32 * row_h; - clip( - quads, - UiQuad::solid(gx - s(1.0), gy - s(1.0), gw + s(2.0), gh + s(2.0), s(9.0), sk.hairline), - ); - clip(quads, UiQuad::solid(gx, gy, gw, gh, s(8.0), sk.panel)); - for i in 1..rows { - clip( - quads, - UiQuad::solid( - gx + s(1.0), - gy + i as f32 * row_h, - gw - s(2.0), - s(1.0), - 0.0, - sk.hairline, - ), - ); - } - }; - let row_hover = |quads: &mut Vec, rect: (f32, f32, f32, f32), hovered: bool| { - if hovered { - let (rx, ry, rw, rh) = rect; - clip( - quads, - UiQuad::solid(rx + s(2.0), ry + s(2.0), rw - s(4.0), rh - s(4.0), s(6.0), sk.hover), - ); - } - }; - // Widget wrappers: stage → viewport-clip → push. Every multi-option row - // shares ONE combobox component (user ruling 2026-07-23), hover/press - // feedback included, so no page ever hand-rolls its own control again. - let combobox = - |quads: &mut Vec, staged: &mut Vec, row, hot: bool, open: bool| { - widgets::push_combobox(staged, widgets::combobox_rect(row, scale), scale, &sk, hot, open); - for quad in staged.drain(..) { - clip(quads, quad); - } - }; - let slider = |quads: &mut Vec, staged: &mut Vec, hit, value: f32, hot: bool| { - widgets::push_slider(staged, hit, value, scale, &sk, hot); - for quad in staged.drain(..) { - clip(quads, quad); - } - }; - let toggle = |quads: &mut Vec, staged: &mut Vec, row, on: bool| { - widgets::push_toggle(staged, row, on, scale, &sk); - for quad in staged.drain(..) { - clip(quads, quad); - } - }; - - match section { - NebulaSettingsSection::Appearance => { - // ---- Live preview card: configure → immediately see ---- - // Terminal colors, font family/size (text pass), and the demo - // cursor all read the same state the real grid uses. - { - let (vx, vy, vw, vh) = geometry.preview; - clip( - quads, - UiQuad::solid( - vx - s(1.0), - vy - s(1.0), - vw + s(2.0), - vh + s(2.0), - s(11.0), - sk.hairline, - ), - ); - clip( - quads, - UiQuad::solid( - vx, - vy, - vw, - vh, - s(10.0), - Rgba::new(view.preview_bg.r, view.preview_bg.g, view.preview_bg.b, 255), - ), - ); - // Demo cursor on the prompt line, driven by the REAL shape + - // blink settings (shares the UI caret's 500ms phase). - if !view.cursor_blink || super::caret_blink_on() { - let cell_w = size.cell_width(); - let cell_h = size.cell_height(); - let cursor_x = vx + s(16.0) + PREVIEW_PROMPT_COLS as f32 * cell_w; - let cursor_y = preview_line_y(vy, cell_h, 2.0, scale); - let ink = - Rgba::new(view.preview_fg.r, view.preview_fg.g, view.preview_fg.b, 235); - let bg = - Rgba::new(view.preview_bg.r, view.preview_bg.g, view.preview_bg.b, 255); - let stroke = (1.5 * scale).max(1.0); - let beam_w = (2.0 * scale).max(1.0); - match view.cursor_shape { - CursorShape::Beam => { - clip(quads, UiQuad::solid(cursor_x, cursor_y, beam_w, cell_h, 0.0, ink)); - }, - CursorShape::Underline => { - clip( - quads, - UiQuad::solid( - cursor_x, - cursor_y + cell_h - beam_w, - cell_w, - beam_w, - 0.0, - ink, - ), - ); - }, - CursorShape::HollowBlock => { - clip(quads, UiQuad::solid(cursor_x, cursor_y, cell_w, cell_h, 0.0, ink)); - clip( - quads, - UiQuad::solid( - cursor_x + stroke, - cursor_y + stroke, - cell_w - 2.0 * stroke, - cell_h - 2.0 * stroke, - 0.0, - bg, - ), - ); - }, - CursorShape::Hidden => {}, - CursorShape::Block => { - clip(quads, UiQuad::solid(cursor_x, cursor_y, cell_w, cell_h, 0.0, ink)); - }, - } - } - } - - // Theme cards are MINIATURE TERMINAL WINDOWS, each painted in its - // own theme's colors: shell_bg window shell, a rounded term_bg - // "terminal card" floating inside it (the real window's model, - // shrunk), and fake prompt/output lines in the theme's own inks. - // A flat panel swatch only answered "what color is the chrome"; - // the mini window answers what the picker is really asked: how do - // background, text and highlights look TOGETHER. (2026-07-28 用户 - // 裁定;窗控红绿灯明确不画——各平台窗控样式不同,预览不预设 - // 任何一家。) Selection = accent ring + halo; hover = 2px lift — - // no wash, so the preview colors stay true. - for (theme, ox, oy, ow, oh) in geometry.options { - let selected = theme == view.theme; - let hovered = view.hover == SettingsHit::Theme(theme); - let lift = if hovered && !selected { s(2.0) } else { 0.0 }; - let oy = oy - lift; - let stroke = if selected { - Rgba::new(sk.accent.r, sk.accent.g, sk.accent.b, 255) - } else { - sk.hairline - }; - let stroke_w = if selected { s(2.0) } else { s(1.0) }; - if selected { - // Selected card glows softly: the accent ring plus a - // diffuse halo, per the design sheet's lit-control look. - clip( - quads, - UiQuad::glow( - ox - s(14.0), - oy - s(14.0), - ow + s(28.0), - oh + s(28.0), - Rgba::new(sk.accent.r, sk.accent.g, sk.accent.b, 66), - ), - ); - } else if hovered { - // Hover halo: same shape, fainter — enough 辉光 to read - // as "lit up" without competing with the selected card. - clip( - quads, - UiQuad::glow( - ox - s(12.0), - oy - s(10.0), - ow + s(24.0), - oh + s(26.0), - Rgba::new(sk.accent.r, sk.accent.g, sk.accent.b, 38), - ), - ); - } - clip( - quads, - UiQuad::solid( - ox - stroke_w, - oy - stroke_w, - ow + 2.0 * stroke_w, - oh + 2.0 * stroke_w, - s(9.0), - stroke, - ), - ); - let p = theme.palette(); - let ink = theme.card_ink(); - let shell = Rgba::new(p.shell_bg.r, p.shell_bg.g, p.shell_bg.b, 255); - clip(quads, UiQuad::solid(ox, oy, ow, oh, s(8.0), shell)); - // Inner terminal card. The taller top margin reads as a title - // bar without drawing one. - let (tx, ty) = (ox + s(10.0), oy + s(14.0)); - let (tw, th) = (ow - s(20.0), oh - s(22.0)); - let term = Rgba::new(p.term_bg.r, p.term_bg.g, p.term_bg.b, 255); - clip(quads, UiQuad::solid(tx, ty, tw, th, s(5.0), term)); - // Three fake lines as pill bars: a prompt command in fg (the - // `❯` itself is a real glyph, drawn in the text pass), two - // highlight tokens, a dim trailing line. Widths are fractions - // of the card so narrow cards keep the proportions. - let bar_h = s(3.0); - let line_x = tx + s(8.0); - let line_pitch = s(11.0); - let y0 = ty + s(8.0); - let inner_w = tw - s(16.0); - let bar = |x: f32, y: f32, w: f32, c: Rgb, a: u8| { - UiQuad::solid(x, y, w, bar_h, s(1.5), Rgba::new(c.r, c.g, c.b, a)) - }; - let prompt_w = s(9.0); // room the text-pass ❯ occupies - let cmd_w = inner_w * 0.42; - clip(quads, bar(line_x + prompt_w, y0, cmd_w, ink.fg, 230)); - // A block caret hugging the command's end, in the theme's - // accent — the one "alive" spark on the card. - let acc = theme.accent(); - clip( - quads, - UiQuad::solid( - line_x + prompt_w + cmd_w + s(3.0), - y0 - s(2.0), - s(3.0), - bar_h + s(4.0), - s(1.0), - Rgba::new(acc.r, acc.g, acc.b, 255), - ), - ); - // 第二行是各主题的品牌双色(edge_l→edge_r,即侧栏品牌 - // 渐变对):固定 ANSI green/blue 让 4 张暗卡 3 张亮卡两两 - // 同色(2026-07-28 用户反馈「预览颜色都一样」),身份色带 - // 才是卡片间唯一稳定的区分资产。 - clip( - quads, - bar( - line_x, - y0 + line_pitch, - inner_w * 0.28, - Rgb::new(p.edge_l.r, p.edge_l.g, p.edge_l.b), - 235, - ), - ); - clip( - quads, - bar( - line_x + inner_w * 0.28 + s(5.0), - y0 + line_pitch, - inner_w * 0.20, - Rgb::new(p.edge_r.r, p.edge_r.g, p.edge_r.b), - 235, - ), - ); - clip(quads, bar(line_x, y0 + 2.0 * line_pitch, inner_w * 0.55, ink.fg, 96)); - } - - group_frame(quads, geometry.system_theme, 1); - row_hover(quads, geometry.system_theme, view.hover == SettingsHit::SystemThemeToggle); - toggle(quads, &mut staged, geometry.system_theme, view.follow_system_theme); - - // 自定义背景和界面都使用连续分组,避免设置 Tab 内再次出现 - // 漂浮卡片语言。 - group_frame(quads, geometry.background, 6); - row_hover(quads, geometry.background, view.hover == SettingsHit::BackgroundColor); - // 背景色也是多选项设置:同一 combobox 组件,浮层换成色板+hex。 - combobox( - quads, - &mut staged, - geometry.background, - view.hover == SettingsHit::BackgroundColor, - view.dropdown == Some(SettingsDropdown::BackgroundColor), - ); - row_hover(quads, geometry.background_image, view.hover == SettingsHit::BackgroundImage); - if view.background_image.is_some() { - row_hover( - quads, - geometry.background_image_clear, - view.hover == SettingsHit::BackgroundImageClear, - ); - } - combobox( - quads, - &mut staged, - geometry.background_image_fit, - view.hover == SettingsHit::BackgroundImageFit, - view.dropdown == Some(SettingsDropdown::BackgroundFit), - ); - combobox( - quads, - &mut staged, - geometry.background_image_alignment, - view.hover == SettingsHit::BackgroundImageAlignment, - view.dropdown == Some(SettingsDropdown::BackgroundAlignment), - ); - slider( - quads, - &mut staged, - geometry.background_image_opacity_slider, - view.background_image_opacity, - view.hover == SettingsHit::BackgroundImageOpacitySlider - || view.dragging_opacity == Some(SettingsOpacityTarget::BackgroundImage), - ); - row_hover( - quads, - geometry.background_image_cover_chrome, - view.hover == SettingsHit::BackgroundImageCoverChrome, - ); - toggle( - quads, - &mut staged, - geometry.background_image_cover_chrome, - view.background_image_cover_chrome, - ); - - // 光标组:形状下拉 + 闪烁开关。 - group_frame(quads, geometry.cursor_shape_row, 2); - row_hover( - quads, - geometry.cursor_shape_row, - view.hover == SettingsHit::CursorShapeDropdown, - ); - combobox( - quads, - &mut staged, - geometry.cursor_shape_row, - view.hover == SettingsHit::CursorShapeDropdown, - view.dropdown == Some(SettingsDropdown::CursorShape), - ); - row_hover(quads, geometry.cursor_blink_row, view.hover == SettingsHit::CursorBlinkToggle); - toggle(quads, &mut staged, geometry.cursor_blink_row, view.cursor_blink); - - // 界面组:语言(同一通用下拉组件)+ 终端不透明度。 - group_frame(quads, geometry.language_row, 2); - row_hover(quads, geometry.language_row, view.hover == SettingsHit::LanguageDropdown); - combobox( - quads, - &mut staged, - geometry.language_row, - view.hover == SettingsHit::LanguageDropdown, - view.dropdown == Some(SettingsDropdown::Language), - ); - slider( - quads, - &mut staged, - geometry.opacity_slider, - view.opacity, - view.hover == SettingsHit::OpacitySlider - || view.dragging_opacity == Some(SettingsOpacityTarget::Terminal), - ); - }, - NebulaSettingsSection::Profiles => { - // 终端组:Shell / 启动目录 / 字体+字号。下拉列表是浮层,行的 - // hairline 分组保持固定,不再被展开的列表推开。 - group_frame(quads, geometry.shell, 1); - group_frame(quads, geometry.startup_directory, 1); - group_frame(quads, geometry.font, 2); - group_frame(quads, geometry.fetch, 2); - group_frame(quads, geometry.ghost, 2); - group_frame(quads, geometry.open_config_file, 1); - if geometry.hidden_host_count > 0 { - group_frame(quads, geometry.hidden_host_row0, geometry.hidden_host_count); - for index in 0..geometry.hidden_host_count { - let mut rect = geometry.hidden_host_row0; - rect.1 += index as f32 * rect.3; - row_hover(quads, rect, view.hover == SettingsHit::RestoreHiddenSsh(index)); - } - } - for (hit, rect) in [ - (SettingsHit::ShellCycle, geometry.shell), - (SettingsHit::StartupDirectory, geometry.startup_directory), - (SettingsHit::FontCycle, geometry.font), - (SettingsHit::FetchToggle, geometry.fetch), - (SettingsHit::PowerlineToggle, geometry.powerline), - (SettingsHit::GhostToggle, geometry.ghost), - (SettingsHit::AcceptCycle, geometry.accept), - (SettingsHit::OpenConfigFile, geometry.open_config_file), - ] { - row_hover(quads, rect, view.hover == hit); - } - if view.startup_directory.is_some() { - row_hover( - quads, - geometry.startup_directory_clear, - view.hover == SettingsHit::StartupDirectoryClear, - ); - } - combobox( - quads, - &mut staged, - geometry.shell, - view.hover == SettingsHit::ShellCycle, - view.dropdown == Some(SettingsDropdown::Shell), - ); - combobox( - quads, - &mut staged, - geometry.font, - view.hover == SettingsHit::FontCycle, - view.dropdown == Some(SettingsDropdown::Font), - ); - widgets::push_spinner( - &mut staged, - geometry.font_size_row, - scale, - &sk, - view.hover == SettingsHit::FontSizeUp, - view.hover == SettingsHit::FontSizeDown, - ); - for quad in staged.drain(..) { - clip(quads, quad); - } - combobox( - quads, - &mut staged, - geometry.accept, - view.hover == SettingsHit::AcceptCycle, - view.dropdown == Some(SettingsDropdown::Accept), - ); - // Boolean rows render a real switch instead of an "On/Off" string. - for (rect, on) in [ - (geometry.fetch, view.fetch), - (geometry.powerline, view.powerline), - (geometry.ghost, view.ghost), - ] { - toggle(quads, &mut staged, rect, on); - } - }, - NebulaSettingsSection::Interaction => { - group_frame(quads, geometry.copy_on_select, 1); - row_hover( - quads, - geometry.copy_on_select, - view.hover == SettingsHit::CopyOnSelectToggle, - ); - toggle(quads, &mut staged, geometry.copy_on_select, view.copy_on_select); - group_frame(quads, geometry.cjk_bold, 1); - row_hover(quads, geometry.cjk_bold, view.hover == SettingsHit::CjkBoldToggle); - toggle(quads, &mut staged, geometry.cjk_bold, view.cjk_bold_regular); - }, - NebulaSettingsSection::Keymap => { - let cell_w = size.cell_width(); - group_frame(quads, geometry.keymap_row0, keymap::editable_row_count()); - group_frame(quads, geometry.keymap_readonly_row0, keymap::READONLY_ROWS.len()); - let (kx, ky, kw, kh) = geometry.keymap_row0; - for index in 0..keymap::editable_row_count() { - let rect = (kx, ky + index as f32 * geometry.keymap_row_h, kw, kh); - row_hover(quads, rect, view.hover == SettingsHit::KeymapRow(index)); - // Keycap 底座:有效键围 hairline 徽标;捕获中的行换 accent - // 描边 + 软填充提示「正在等待按键」;未绑定行不画底座。 - let capturing = view.keymap_capture == Some(index); - let (label, _, bound) = keymap_row_value(view, index); - let cap = keymap_keycap_rect(rect, &label, cell_w, scale); - let (cx, cy, cw, ch) = cap; - if capturing { - clip( - quads, - UiQuad::solid( - cx - s(1.0), - cy - s(1.0), - cw + s(2.0), - ch + s(2.0), - s(7.0), - Rgba::new(sk.accent.r, sk.accent.g, sk.accent.b, 255), - ), - ); - clip(quads, UiQuad::solid(cx, cy, cw, ch, s(6.0), sk.panel)); - clip(quads, UiQuad::solid(cx, cy, cw, ch, s(6.0), sk.accent_soft)); - } else if bound { - // 图8 键帽规范:组合键拆逐键 chip(几何与文字 pass 同 - // 源自 keycap::layout_combo)。 - let combo = super::keycap::layout_combo( - &label, - rect.0 + rect.2 - s(16.0), - rect.1 + rect.3 / 2.0, - cell_w, - scale, - ); - let mut chip_quads = Vec::new(); - super::keycap::push_combo(&mut chip_quads, &sk, &combo, scale); - for quad in chip_quads { - clip(quads, quad); - } - } - } - }, - NebulaSettingsSection::Advanced => { - group_frame(quads, geometry.keep_session, 1); - row_hover(quads, geometry.keep_session, view.hover == SettingsHit::KeepSessionToggle); - toggle(quads, &mut staged, geometry.keep_session, view.keep_session); - - if SHOW_WEBDAV_SYNC_SETTINGS { - // ---- 同步(WebDAV,spec 003):4 输入行 + 自动拉取开关 ---- - group_frame(quads, geometry.sync_rows[0], 5); - let cell_w = size.cell_width(); - for (index, row) in geometry.sync_rows.iter().enumerate() { - row_hover(quads, *row, view.hover == SettingsHit::SyncInput(index)); - let (ix, iy, iw, ih) = sync_input_rect(*row, scale); - let focused = view.sync_focus == Some(index); - let border = if focused { sk.accent } else { sk.ink_dim }; - let border_alpha = if focused { 255 } else { 90 }; - clip( - quads, - UiQuad::solid( - ix - s(1.0), - iy - s(1.0), - iw + s(2.0), - ih + s(2.0), - s(8.0), - Rgba::new(border.r, border.g, border.b, border_alpha), - ), - ); - clip(quads, UiQuad::solid(ix, iy, iw, ih, s(7.0), sk.surface)); - if focused && super::caret_blink_on() { - let max_cols = (((iw - s(24.0)) / cell_w) as usize).max(1); - let (_, placeholder, cols) = sync_input_display(view, index, max_cols); - let cols = if placeholder { 0 } else { cols }; - let caret_h = ih - s(10.0); - clip( - quads, - UiQuad::solid( - (ix + s(12.0) + cols as f32 * cell_w).min(ix + iw - s(6.0)), - iy + (ih - caret_h) / 2.0, - (1.5 * scale).max(1.0), - caret_h, - 0.0, - Rgba::new(sk.accent.r, sk.accent.g, sk.accent.b, 255), - ), - ); - } - } - row_hover( - quads, - geometry.sync_auto_pull, - view.hover == SettingsHit::SyncAutoPullToggle, - ); - toggle(quads, &mut staged, geometry.sync_auto_pull, view.sync_auto_pull); - - // 动作行:两个独立按钮(同步中变灰、不吃 hover)。 - let [push_rect, pull_rect] = sync_button_rects(geometry.sync_actions, scale); - for (rect, hit) in [ - (push_rect, SettingsHit::SyncPushButton), - (pull_rect, SettingsHit::SyncPullButton), - ] { - let (bx, by, bw, bh) = rect; - let hot = view.hover == hit && !view.sync_busy; - clip( - quads, - UiQuad::solid( - bx - s(1.0), - by - s(1.0), - bw + s(2.0), - bh + s(2.0), - s(9.0), - sk.hairline, - ), - ); - clip( - quads, - UiQuad::solid( - bx, - by, - bw, - bh, - s(8.0), - if hot { sk.hover } else { sk.panel }, - ), - ); - } - } - }, - } - - // Overlay scrollbar on the content viewport's right edge, only when the - // section actually overflows (same style as the pane scrollbar: thin - // rounded thumb, no track). - let content_h = match section { - NebulaSettingsSection::Appearance => geometry.appearance_h, - NebulaSettingsSection::Profiles => geometry.profiles_h, - NebulaSettingsSection::Interaction => geometry.interaction_h, - NebulaSettingsSection::Keymap => geometry.keymap_h, - NebulaSettingsSection::Advanced => geometry.advanced_h, - }; - let viewport_h = settings_viewport_h(ph, scale); - if content_h > viewport_h { - let max_scroll = content_h - viewport_h; - let frac = (view.scroll / max_scroll).clamp(0.0, 1.0); - let track_h = viewport_h - s(12.0); - let thumb_h = (track_h * viewport_h / content_h).max(s(28.0)); - let ty = clip_top + s(6.0) + (track_h - thumb_h) * frac; - let tx = px + pw - s(7.0); - quads.push(UiQuad::solid( - tx, - ty, - s(4.0), - thumb_h, - s(2.0), - sk.scrollbar_thumb.with_alpha(0.45), - )); - } -} - -/// The floating dropdown option list. `draw_chrome` paints these AFTER the -/// base text pass (a separate `draw_ui` call), so page labels can never bleed -/// through the popup plate — the same modal layering rule the command palette -/// needed. -pub(super) fn push_popup_quads( - view: &SettingsView, - quads: &mut Vec, - size: &SizeInfo, - scale: f32, -) { - let Some(dropdown) = view.dropdown else { return }; - let s = |v: f32| v * scale; - let sk = view.theme.skin(); - let geometry = settings_geometry(size, scale, view.area, view.scroll, view.hidden_hosts.len()); - // 背景色专用浮层:色板网格 + hex 输入框(几何与 hit 同源)。 - if dropdown == SettingsDropdown::BackgroundColor { - if view.section != NebulaSettingsSection::Appearance { - return; - } - let popup = background_color_popup(&geometry, scale); - let (px2, py2, pw2, ph2) = popup.rect; - // 与通用 combobox 浮层同一套皮肤:柔和投影 + hairline + 不透明面板。 - quads.push(UiQuad::glow( - px2 - s(14.0), - py2 - s(10.0), - pw2 + s(28.0), - ph2 + s(26.0), - Rgba::new(0, 0, 0, 70), - )); - quads.push(UiQuad::solid( - px2 - s(1.0), - py2 - s(1.0), - pw2 + s(2.0), - ph2 + s(2.0), - s(11.0), - sk.hairline, - )); - let mut plate = sk.panel; - plate.a = 255; - quads.push(UiQuad::solid(px2, py2, pw2, ph2, s(10.0), plate)); - quads.push(UiQuad::solid(px2, py2, pw2, ph2, s(10.0), sk.surface)); - - let selected = dropdown_selected_index(view, dropdown); - for (index, rect) in popup.swatch.iter().enumerate() { - let (sx, sy, sw2, sh2) = *rect; - let hovered = view.hover == SettingsHit::BackgroundSwatch(index); - if selected == Some(index) || hovered { - let ring = if selected == Some(index) { sk.accent } else { sk.ink_dim }; - quads.push(UiQuad::solid( - sx - s(2.0), - sy - s(2.0), - sw2 + s(4.0), - sh2 + s(4.0), - s(8.0), - Rgba::new(ring.r, ring.g, ring.b, 255), - )); - } - // 每格带 1px hairline 描边:亮色格在浅色面板上也有边界。 - quads.push(UiQuad::solid( - sx - s(1.0), - sy - s(1.0), - sw2 + s(2.0), - sh2 + s(2.0), - s(7.0), - sk.hairline, - )); - let color = BACKGROUND_SWATCHES[index]; - quads.push(UiQuad::solid(sx, sy, sw2, sh2, s(6.0), Rgba::new(color.r, color.g, color.b, 255))); - } - - // hex 输入框:聚焦态用主题色描边;caret 与 UI 光标共用 500ms 相位。 - let (hx, hy, hw, hh) = popup.hex; - let focused = view.bg_hex_active; - let border = if focused { sk.accent } else { sk.ink_dim }; - let border_alpha = if focused { 255 } else { 120 }; - quads.push(UiQuad::solid( - hx - s(1.0), - hy - s(1.0), - hw + s(2.0), - hh + s(2.0), - s(8.0), - Rgba::new(border.r, border.g, border.b, border_alpha), - )); - quads.push(UiQuad::solid(hx, hy, hw, hh, s(7.0), sk.surface)); - if focused && super::caret_blink_on() { - let cell_w = size.cell_width(); - let caret_x = hx + s(12.0) + view.bg_hex_input.chars().count() as f32 * cell_w; - let caret_h = hh - s(12.0); - quads.push(UiQuad::solid( - caret_x.min(hx + hw - s(6.0)), - hy + (hh - caret_h) / 2.0, - (1.5 * scale).max(1.0), - caret_h, - 0.0, - Rgba::new(sk.accent.r, sk.accent.g, sk.accent.b, 255), - )); - } - return; - } - let (_, py, _, ph) = geometry.popup; - let Some((anchor, count)) = dropdown_anchor( - &geometry, - view.section, - dropdown, - view.shells.len(), - view.fonts.len() + 1, - scale, - ) else { - return; - }; - let popup = - widgets::combobox_popup_rect(anchor, count, scale, geometry.content_top, py + ph - s(6.0)); - let selected = dropdown_selected_index(view, dropdown); - let hover = dropdown_hover_index(view.hover, dropdown); - widgets::push_combobox_popup(quads, popup, count, selected, hover, scale, &sk); - if let Some(index) = selected { - let (rx, ry, rw, rh) = widgets::popup_row_rect(popup, index, scale); - icons::push_check( - quads, - rx + rw - s(16.0), - ry + rh * 0.5, - scale, - Rgba::new(sk.accent.r, sk.accent.g, sk.accent.b, 255), - ); - } -} - -/// Option labels for the floating dropdown; returns shell brand-icon draw -/// requests like [`draw_text`]. Must run AFTER `push_popup_quads`'s quads are -/// painted so the labels sit on top of the popup plate. -pub(super) fn draw_popup_text( - view: &SettingsView, - r: &mut Renderer, - gc: &mut GlyphCache, - size: &SizeInfo, - scale: f32, -) -> Vec<(String, (f32, f32, f32, f32))> { - let mut icon_draws = Vec::new(); - let Some(dropdown) = view.dropdown else { return icon_draws }; - let s = |v: f32| v * scale; - let sk = view.theme.skin(); - let language = view.language; - let cell_w = size.cell_width(); - let cell_h = size.cell_height(); - let geometry = settings_geometry(size, scale, view.area, view.scroll, view.hidden_hosts.len()); - // 背景色浮层:hex 草稿(或占位提示)画进输入框,色板格无文字。 - if dropdown == SettingsDropdown::BackgroundColor { - if view.section != NebulaSettingsSection::Appearance { - return icon_draws; - } - let popup = background_color_popup(&geometry, scale); - let (hx, hy, hw, hh) = popup.hex; - let ty = hy + (hh - cell_h) / 2.0; - if view.bg_hex_input.is_empty() { - r.draw_chrome_text(size, hx + s(12.0), ty, sk.ink_dim, "#RRGGBB", gc); - } else { - r.draw_chrome_text(size, hx + s(12.0), ty, sk.ink, &view.bg_hex_input, gc); - } - // 输入框右侧给一个动作提示(回车应用)。 - let hint = language.pick("回车应用", "Enter applies"); - let hint_cols: usize = hint.chars().map(|c| c.width().unwrap_or(0)).sum(); - let hint_x = hx + hw - s(12.0) - hint_cols as f32 * cell_w; - if hint_x > hx + s(12.0) + 9.0 * cell_w { - r.draw_chrome_text(size, hint_x, ty, sk.ink_dim, hint, gc); - } - return icon_draws; - } - let (_, py, _, ph) = geometry.popup; - let Some((anchor, count)) = dropdown_anchor( - &geometry, - view.section, - dropdown, - view.shells.len(), - view.fonts.len() + 1, - scale, - ) else { - return icon_draws; - }; - let popup = - widgets::combobox_popup_rect(anchor, count, scale, geometry.content_top, py + ph - s(6.0)); - let selected = dropdown_selected_index(view, dropdown); - for index in 0..count { - let (rx, ry, rw, rh) = widgets::popup_row_rect(popup, index, scale); - let ty = ry + (rh - cell_h) / 2.0; - // Shell rows lead with the brand icon; every other list is text-only. - let mut text_x = rx + s(12.0); - let label: String = match dropdown { - SettingsDropdown::Shell => { - let Some((id, name, program)) = view.shells.get(index) else { continue }; - icon_draws - .push((id.clone(), (rx + s(8.0), ry + (rh - s(24.0)) / 2.0, s(24.0), s(24.0)))); - text_x = rx + s(40.0); - if program.is_empty() { name.clone() } else { format!("{name} · {program}") } - }, - SettingsDropdown::Font => match view.fonts.get(index) { - Some(family) => family.clone(), - None => language.pick("+ 导入字体…", "+ Import font...").to_owned(), - }, - SettingsDropdown::BackgroundFit => { - background_image_fit_label(BACKGROUND_FIT_OPTIONS[index], language).to_owned() - }, - SettingsDropdown::BackgroundAlignment => { - background_image_alignment_label(BACKGROUND_ALIGNMENT_OPTIONS[index], language) - .to_owned() - }, - SettingsDropdown::Language => { - language_label(LANGUAGE_OPTIONS[index], language).to_owned() - }, - SettingsDropdown::Accept => accept_label(ACCEPT_OPTIONS[index], language).to_owned(), - SettingsDropdown::CursorShape => { - cursor_shape_label(CURSOR_SHAPE_OPTIONS[index], language).to_owned() - }, - // 上方特判提前返回;此臂只为 match 完备。 - SettingsDropdown::BackgroundColor => continue, - }; - let import_row = - matches!(dropdown, SettingsDropdown::Font) && view.fonts.get(index).is_none(); - let color = if selected == Some(index) || import_row { sk.accent } else { sk.ink }; - let max_chars = - (((rx + rw - s(28.0)) - text_x).max(cell_w) / cell_w).floor().max(1.0) as usize; - let label = truncate_tab_label(&label, max_chars); - r.draw_chrome_text(size, text_x, ty, color, &label, gc); - } - icon_draws -} - -/// Draw a chrome title at `mult`× the terminal font size. Rasterized at the -/// REAL target size (`draw_doc_text`), not GPU-stretched from the base atlas — -/// stretching is what made every modal title fuzzy with ragged edges. The -/// title still grows down and to the right from the (x, y) top-left anchor. -fn draw_big_text( - r: &mut Renderer, - gc: &mut GlyphCache, - size: &SizeInfo, - _scale: f32, - x: f32, - y: f32, - mult: f32, - ink: Rgb, - text: &str, -) { - r.draw_ui_text(size, x, y, mult, ink, nebula_terminal::term::cell::Flags::empty(), text, gc); -} - -/// A group heading inside the content pane: clearly larger than row labels -/// (strict size hierarchy: page title 1.6× > group 1.2× > rows 1.0×) and in -/// the strong ink. One helper so every group shares one size/rhythm. -fn section_title( - r: &mut Renderer, - gc: &mut GlyphCache, - size: &SizeInfo, - scale: f32, - sk: &Skin, - x: f32, - y: f32, - text: &str, -) { - draw_big_text(r, gc, size, scale, x, y, 1.2, sk.ink_strong, text); -} - -/// Draw a settings row: a left-aligned label and a right-aligned, truncated -/// value, both vertically centered. Labels are single-line by design — any -/// explanation must fit the label itself (rows with obvious semantics carry -/// no description at all). Inks come from the active theme's [`Skin`]. -#[allow(clippy::too_many_arguments)] -fn row_label( - r: &mut Renderer, - gc: &mut GlyphCache, - size: &SizeInfo, - scale: f32, - sk: &Skin, - (rx, ry, rw, rh): (f32, f32, f32, f32), - k: &str, - v: &str, - value_ink: Rgb, -) { - row_label_with_right_inset(r, gc, size, scale, sk, (rx, ry, rw, rh), k, v, value_ink, 0.0); -} - -#[allow(clippy::too_many_arguments)] -fn row_label_with_right_inset( - r: &mut Renderer, - gc: &mut GlyphCache, - size: &SizeInfo, - scale: f32, - sk: &Skin, - (rx, ry, rw, rh): (f32, f32, f32, f32), - k: &str, - v: &str, - value_ink: Rgb, - right_inset: f32, -) { - let s = |val: f32| val * scale; - let cell_w = size.cell_width(); - let cell_h = size.cell_height(); - let ty = ry + (rh - cell_h) / 2.0; - r.draw_chrome_text(size, rx + s(16.0), ty, sk.ink, k, gc); - let value_left = rx + rw * 0.42; - let value_right = rx + rw - s(16.0) - right_inset; - let max_chars = ((value_right - value_left).max(cell_w) / cell_w).floor().max(1.0) as usize; - let value = truncate_tab_label(v, max_chars); - let value_cols: usize = value.chars().map(|c| c.width().unwrap_or(0)).sum(); - let vx = value_right - value_cols as f32 * cell_w; - r.draw_chrome_text(size, vx.max(value_left), ty, value_ink, &value, gc); -} - -/// Draw the Settings tab's text labels on top of its quads. -pub(super) fn draw_text( - view: &SettingsView, - r: &mut Renderer, - gc: &mut GlyphCache, - size: &SizeInfo, - scale: f32, -) -> Vec<(String, (f32, f32, f32, f32))> { - let s = |v: f32| v * scale; - let cell_w = size.cell_width(); - let cell_h = size.cell_height(); - let sk = view.theme.skin(); - let language = view.language; - - let geometry = settings_geometry(size, scale, view.area, view.scroll, view.hidden_hosts.len()); - // Kept for parity with [`draw_popup_text`]'s shell icons; the base page - // currently stages no icon draws of its own. - let icon_draws = Vec::new(); - let (px, py, _pw, ph) = geometry.popup; - let (content_x, content_y, _content_w, _) = geometry.content; - // Text has no scissor, so unlike the quad pass (which cuts quads at the - // viewport edges) a text block is drawn only when it fits ENTIRELY inside - // the viewport — a glyph must never cross the header hairline. - let clip_top = geometry.content_top; - let clip_bot = py + ph - s(6.0); - let visible = |ry: f32, rh: f32| ry >= clip_top && ry + rh <= clip_bot; - // 通用 combobox 的当前值:控件框内左对齐,截断在 chevron 井之前。 - let combobox_value = |r: &mut Renderer, - gc: &mut GlyphCache, - row: (f32, f32, f32, f32), - value: &str, - ink: Rgb| { - let rect = widgets::combobox_rect(row, scale); - let tx = widgets::combobox_text_x(rect, scale); - let right = widgets::combobox_text_right(rect, scale); - let max_chars = ((right - tx).max(cell_w) / cell_w).floor().max(1.0) as usize; - let value = truncate_tab_label(value, max_chars); - r.draw_chrome_text(size, tx, rect.1 + (rect.3 - cell_h) / 2.0, ink, &value, gc); - }; - // Group titles hang 42px above their first row (title + 16px gap) and - // scroll with it. - let group_y = |row_y: f32| row_y - s(42.0); - let title_h = s(26.0); - - // Brand title in the sidebar header. Drawn large via the scaled-glyph path - // so it anchors the panel instead of reading as just another row label. - draw_big_text( - r, - gc, - size, - scale, - px + s(24.0), - py + s(22.0), - 1.5, - sk.ink_strong, - language.pick("Nebula 设置", "Nebula Settings"), - ); - { - // Center the reset label inside its ghost button. - let (rx, ry, rw, rh) = geometry.reset; - let label = language.pick("恢复默认设置", "Restore defaults"); - let cols: usize = label.chars().map(|c| c.width().unwrap_or(0)).sum(); - let tx = rx + (rw - cols as f32 * cell_w) / 2.0; - r.draw_chrome_text(size, tx, ry + (rh - cell_h) / 2.0, sk.ink_dim, label, gc); - } - let section = view.section; - // Sidebar navigation labels — only the two wired-up sections. - for (nav_section, nx, ny, _nw, nh) in geometry.nav { - let active = nav_section == section; - let hovered = view.hover == SettingsHit::Nav(nav_section); - r.draw_chrome_text( - size, - nx + s(18.0), - ny + (nh - cell_h) / 2.0, - if active { - sk.accent - } else if hovered { - sk.ink - } else { - sk.ink_dim - }, - nav_section.label(view.language), - gc, - ); - } - // Content header: the big section title alone. (No subtitle — the nav - // label + title already say everything; the old dim sentence only added - // noise under the heading.) - draw_big_text( - r, - gc, - size, - scale, - content_x + s(24.0), - content_y + s(20.0), - 1.6, - sk.ink_strong, - section.label(view.language), - ); - - match section { - NebulaSettingsSection::Appearance => { - // Live preview: sample lines in the CURRENT font/size on the - // CURRENT terminal colors; the demo cursor quad shares this - // layout via `preview_line_y`. - { - let (vx, vy, _, vh) = geometry.preview; - if visible(group_y(vy), title_h) { - section_title( - r, - gc, - size, - scale, - &sk, - content_x + s(24.0), - group_y(vy), - language.pick("预览", "Preview"), - ); - } - if visible(vy, vh) { - let fg = view.preview_fg; - r.draw_chrome_text( - size, - vx + s(16.0), - preview_line_y(vy, cell_h, 0.0, scale), - fg, - "user@nebula ~ $ nebula --version", - gc, - ); - let sample = format!( - "Nebula Terminal · {} · {:.0}px", - view.font_family, view.font_size_px - ); - r.draw_chrome_text( - size, - vx + s(16.0), - preview_line_y(vy, cell_h, 1.0, scale), - fg, - &sample, - gc, - ); - r.draw_chrome_text( - size, - vx + s(16.0), - preview_line_y(vy, cell_h, 2.0, scale), - fg, - "❯", - gc, - ); - } - } - let cards_y = geometry.options[0].2; - if visible(group_y(cards_y), title_h) { - section_title( - r, - gc, - size, - scale, - &sk, - content_x + s(24.0), - group_y(cards_y), - language.pick("主题", "Themes"), - ); - } - for (theme, ox, oy, ow, oh) in geometry.options { - let selected = theme == view.theme; - let hovered = view.hover == SettingsHit::Theme(theme); - // The label rides the card's 2px hover lift (quads do the - // same), and hides only when IT would cross the viewport edge - // — a half-clipped card keeps its fully-visible label. - let lift = if hovered && !selected { s(2.0) } else { 0.0 }; - // The mini window's prompt glyph, in the card's own accent — - // the quads pass carries the fake-output bars, this is the one - // real glyph. draw_ui_text rasterizes at the true tiny size - // (GPU-stretched atlas bitmaps would go fuzzy), and its cell - // top is placed so the glyph's midline meets the command bar's. - let prompt_y = oy + s(18.0) - lift; - if visible(prompt_y, cell_h) { - r.draw_ui_text( - size, - ox + s(18.0), - prompt_y, - 0.55, - theme.accent(), - nebula_terminal::term::cell::Flags::BOLD, - "❯", - gc, - ); - } - let text_y = oy + oh + s(12.0) - lift; - if !visible(text_y, cell_h) { - continue; - } - let card_label = theme.short_label(); - r.draw_chrome_text( - size, - ox + (ow - card_label.chars().count() as f32 * cell_w) / 2.0, - text_y, - if selected { - sk.accent - } else if hovered { - sk.ink - } else { - sk.ink_dim - }, - card_label, - gc, - ); - } - let (st_x, st_y, _, st_h) = geometry.system_theme; - if visible(group_y(st_y), title_h) { - section_title( - r, - gc, - size, - scale, - &sk, - st_x, - group_y(st_y), - language.pick("主题模式", "Theme mode"), - ); - } - if visible(st_y, st_h) { - r.draw_chrome_text( - size, - st_x + s(16.0), - st_y + (st_h - cell_h) / 2.0, - sk.ink, - language.pick("跟随系统明暗模式", "Follow system appearance"), - gc, - ); - } - let (bg_x, bg_y, _, bg_h) = geometry.background; - if visible(group_y(bg_y), title_h) { - section_title( - r, - gc, - size, - scale, - &sk, - bg_x, - group_y(bg_y), - language.pick("自定义背景", "Custom background"), - ); - } - if visible(bg_y, bg_h) { - let background_v = view - .background - .map(format_hex_rgb) - .unwrap_or_else(|| language.pick("主题默认", "Theme default").to_owned()); - row_label( - r, - gc, - size, - scale, - &sk, - geometry.background, - language.pick("背景色", "Background color"), - "", - sk.accent, - ); - // 值画进 combobox 控件框内(chevron 井之前),右对齐到行缘 - // 会压住下拉箭头(浅色模式下重叠尤其明显)。 - combobox_value(r, gc, geometry.background, &background_v, sk.accent); - } - let (img_x, img_y, _, img_h) = geometry.background_image; - let _ = img_x; - if visible(img_y, img_h) { - let image_v = view - .background_image - .as_deref() - .map(str::to_owned) - .unwrap_or_else(|| language.pick("未设置", "Not set").to_owned()); - row_label_with_right_inset( - r, - gc, - size, - scale, - &sk, - geometry.background_image, - language.pick("背景图片", "Background image"), - &image_v, - sk.accent, - if view.background_image.is_some() { s(48.0) } else { 0.0 }, - ); - if view.background_image.is_some() { - let (cx, cy, cw, ch) = geometry.background_image_clear; - r.draw_chrome_text( - size, - cx + (cw - cell_w) / 2.0, - cy + (ch - cell_h) / 2.0, - if view.hover == SettingsHit::BackgroundImageClear { - sk.ink - } else { - sk.ink_dim - }, - "↶", - gc, - ); - } - } - let (_, fit_y, _, fit_h) = geometry.background_image_fit; - if visible(fit_y, fit_h) { - row_label( - r, - gc, - size, - scale, - &sk, - geometry.background_image_fit, - language.pick("背景图像拉伸模式", "Background image stretch mode"), - "", - sk.ink, - ); - combobox_value( - r, - gc, - geometry.background_image_fit, - background_image_fit_label(view.background_image_fit, language), - sk.accent, - ); - } - let (_, align_y, _, align_h) = geometry.background_image_alignment; - if visible(align_y, align_h) { - row_label( - r, - gc, - size, - scale, - &sk, - geometry.background_image_alignment, - language.pick("背景图像对齐", "Background image alignment"), - "", - sk.ink, - ); - combobox_value( - r, - gc, - geometry.background_image_alignment, - background_image_alignment_label(view.background_image_alignment, language), - sk.accent, - ); - } - let (_, image_opacity_y, _, image_opacity_h) = geometry.background_image_opacity_row; - if visible(image_opacity_y, image_opacity_h) { - r.draw_chrome_text( - size, - geometry.background_image_opacity_row.0 + s(16.0), - image_opacity_y + (image_opacity_h - cell_h) / 2.0, - sk.ink, - language.pick("背景图像不透明度", "Background image opacity"), - gc, - ); - let image_opacity_v = format!("{:.0}%", view.background_image_opacity * 100.0); - let image_opacity_cols: usize = - image_opacity_v.chars().map(|c| c.width().unwrap_or(0)).sum(); - r.draw_chrome_text( - size, - geometry.background_image_opacity_slider.0 - - s(10.0) - - image_opacity_cols as f32 * cell_w, - image_opacity_y + (image_opacity_h - cell_h) / 2.0, - sk.accent, - &image_opacity_v, - gc, - ); - } - let (_, cover_y, _, cover_h) = geometry.background_image_cover_chrome; - if visible(cover_y, cover_h) { - row_label( - r, - gc, - size, - scale, - &sk, - geometry.background_image_cover_chrome, - language.pick( - "将背景图扩展到标题栏和侧边栏", - "Extend background image into title bar and sidebar", - ), - "", - sk.ink, - ); - } - // ---- 光标组 ---- - let (cs_x, cs_y, _, cs_h) = geometry.cursor_shape_row; - if visible(group_y(cs_y), title_h) { - section_title( - r, - gc, - size, - scale, - &sk, - cs_x, - group_y(cs_y), - language.pick("光标", "Cursor"), - ); - } - if visible(cs_y, cs_h) { - row_label( - r, - gc, - size, - scale, - &sk, - geometry.cursor_shape_row, - language.pick("光标形状", "Cursor shape"), - "", - sk.ink, - ); - combobox_value( - r, - gc, - geometry.cursor_shape_row, - cursor_shape_label(view.cursor_shape, language), - sk.accent, - ); - } - let (_, blink_y, _, blink_h) = geometry.cursor_blink_row; - if visible(blink_y, blink_h) { - row_label( - r, - gc, - size, - scale, - &sk, - geometry.cursor_blink_row, - language.pick("光标闪烁", "Cursor blinking"), - "", - sk.ink, - ); - } - let (or_x, or_y, _, or_h) = geometry.opacity_row; - let (lr_x, lr_y, _, lr_h) = geometry.language_row; - if visible(group_y(lr_y), title_h) { - section_title( - r, - gc, - size, - scale, - &sk, - content_x + s(24.0), - group_y(lr_y), - language.pick("界面", "Interface"), - ); - } - if visible(lr_y, lr_h) { - r.draw_chrome_text( - size, - lr_x + s(16.0), - lr_y + (lr_h - cell_h) / 2.0, - sk.ink, - language.pick("语言", "Language"), - gc, - ); - combobox_value( - r, - gc, - geometry.language_row, - language_label(view.language_preference, language), - sk.accent, - ); - } - if visible(or_y, or_h) { - r.draw_chrome_text( - size, - or_x + s(16.0), - or_y + (or_h - cell_h) / 2.0, - sk.ink, - language.pick("终端正文不透明度", "Terminal content opacity"), - gc, - ); - let opacity_v = format!("{:.0}%", view.opacity * 100.0); - let opacity_cols: usize = opacity_v.chars().map(|c| c.width().unwrap_or(0)).sum(); - r.draw_chrome_text( - size, - geometry.opacity_slider.0 - s(10.0) - opacity_cols as f32 * cell_w, - or_y + (or_h - cell_h) / 2.0, - sk.accent, - &opacity_v, - gc, - ); - } - }, - NebulaSettingsSection::Profiles => { - // Rows carry single, self-explanatory Chinese labels — the old - // second-line descriptions overflowed the 44px rows and collided - // with the next group's title. - let (sh_x, sh_y, _, sh_h) = geometry.shell; - if visible(group_y(sh_y), title_h) { - section_title( - r, - gc, - size, - scale, - &sk, - sh_x, - group_y(sh_y), - language.pick("终端", "Terminal"), - ); - } - if visible(sh_y, sh_h) { - row_label( - r, - gc, - size, - scale, - &sk, - geometry.shell, - language.pick("默认 Shell", "Default shell"), - "", - sk.ink, - ); - combobox_value(r, gc, geometry.shell, &view.shell_label, sk.accent); - } - if visible(geometry.startup_directory.1, geometry.startup_directory.3) { - row_label( - r, - gc, - size, - scale, - &sk, - geometry.startup_directory, - language.pick("启动目录", "Startup directory"), - "", - sk.ink, - ); - - let (dx, dy, dw, dh) = geometry.startup_directory; - // 与"默认 Shell / 终端字体"同一右对齐基线;有清除按钮时向左避让。 - let value_left = dx + dw * 0.42; - let value_right = if view.startup_directory.is_some() { - geometry.startup_directory_clear.0 - s(12.0) - } else { - dx + dw - s(16.0) - }; - let max_chars = ((value_right - value_left).max(cell_w) / cell_w).floor() as usize; - let value = view - .startup_directory - .as_deref() - .unwrap_or_else(|| language.pick("继承当前目录", "Inherit current directory")); - let value = truncate_tab_label(value, max_chars.max(1)); - let value_cols: usize = value.chars().map(|c| c.width().unwrap_or(0)).sum(); - let value_x = (value_right - value_cols as f32 * cell_w).max(value_left); - - r.draw_chrome_text( - size, - value_x, - dy + (dh - cell_h) / 2.0, - if view.startup_directory.is_some() { sk.accent } else { sk.ink_dim }, - &value, - gc, - ); - - if view.startup_directory.is_some() { - let (cx, cy, cw, ch) = geometry.startup_directory_clear; - let clear = language.pick("清除", "Clear"); - let clear_cols: usize = - clear.chars().map(|character| character.width().unwrap_or(0)).sum(); - r.draw_chrome_text( - size, - cx + (cw - clear_cols as f32 * cell_w) / 2.0, - cy + (ch - cell_h) / 2.0, - sk.accent, - clear, - gc, - ); - } - } - if visible(geometry.font.1, geometry.font.3) { - let font_value = view.font_notice.as_deref().unwrap_or(&view.font_family); - row_label( - r, - gc, - size, - scale, - &sk, - geometry.font, - language.pick("终端字体", "Terminal font"), - "", - sk.ink, - ); - combobox_value( - r, - gc, - geometry.font, - font_value, - if view.font_notice.is_some() { sk.ink_dim } else { sk.accent }, - ); - } - if visible(geometry.font_size_row.1, geometry.font_size_row.3) { - row_label( - r, - gc, - size, - scale, - &sk, - geometry.font_size_row, - language.pick("终端字号(Ctrl+滚轮缩放)", "Font size (Ctrl+wheel zooms)"), - "", - sk.ink, - ); - let (value_box, _, _) = widgets::spinner_rects(geometry.font_size_row, scale); - let value = format!("{:.0}", view.font_size_px); - let cols: usize = value.chars().map(|c| c.width().unwrap_or(0)).sum(); - r.draw_chrome_text( - size, - value_box.0 + (value_box.2 - cols as f32 * cell_w) / 2.0, - value_box.1 + (value_box.3 - cell_h) / 2.0, - sk.ink, - &value, - gc, - ); - } - // 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) { - row_label( - r, - gc, - size, - scale, - &sk, - geometry.fetch, - language.pick("启动欢迎信息", "Startup welcome"), - "", - sk.ink, - ); - } - if visible(geometry.powerline.1, geometry.powerline.3) { - row_label( - r, - gc, - size, - scale, - &sk, - geometry.powerline, - language.pick("Powerline 提示符", "Powerline prompt"), - "", - sk.ink, - ); - } - - let (gh_x, gh_y, _, gh_h) = geometry.ghost; - if visible(group_y(gh_y), title_h) { - section_title( - r, - gc, - size, - scale, - &sk, - gh_x, - group_y(gh_y), - language.pick("补全", "Completion"), - ); - } - if visible(gh_y, gh_h) { - row_label( - r, - gc, - size, - scale, - &sk, - geometry.ghost, - language.pick("历史补全灰字", "History ghost text"), - "", - sk.ink, - ); - } - if visible(geometry.accept.1, geometry.accept.3) { - row_label( - r, - gc, - size, - scale, - &sk, - geometry.accept, - language.pick("补全接受键", "Completion accept key"), - "", - sk.ink, - ); - combobox_value( - r, - gc, - geometry.accept, - accept_label(view.accept, language), - sk.accent, - ); - } - - let (ocx, ocy, _ocw, och) = geometry.open_config_file; - if visible(group_y(ocy), title_h) { - section_title( - r, - gc, - size, - scale, - &sk, - ocx, - group_y(ocy), - language.pick("配置文件", "Configuration"), - ); - } - if visible(ocy, och) { - r.draw_chrome_text( - size, - ocx + s(16.0), - ocy + (och - cell_h) / 2.0, - sk.accent, - language.pick("打开配置文件", "Open configuration file"), - gc, - ); - } - - if geometry.hidden_host_count > 0 { - let (hx, hy, hw, hh) = geometry.hidden_host_row0; - if visible(group_y(hy), title_h) { - section_title( - r, - gc, - size, - scale, - &sk, - hx, - group_y(hy), - language.pick( - "已隐藏 SSH 主机 · 密码不会恢复", - "Hidden SSH hosts · passwords are not restored", - ), - ); - } - for (index, host) in view.hidden_hosts.iter().enumerate() { - let rect = (hx, hy + index as f32 * hh, hw, hh); - if visible(rect.1, rect.3) { - row_label( - r, - gc, - size, - scale, - &sk, - rect, - host, - language.pick("恢复", "Restore"), - sk.accent, - ); - } - } - } - }, - NebulaSettingsSection::Interaction => { - let (ix, iy, _, ih) = geometry.copy_on_select; - if visible(group_y(iy), title_h) { - section_title( - r, - gc, - size, - scale, - &sk, - ix, - group_y(iy), - language.pick("剪贴板", "Clipboard"), - ); - } - if visible(iy, ih) { - // The switch (drawn in `push_quads`) carries the state; the - // label spells out the OFF fallback so both modes are clear. - row_label( - r, - gc, - size, - scale, - &sk, - geometry.copy_on_select, - language.pick( - "自动将所选内容复制到剪贴板(关闭时右键复制 / 粘贴)", - "Copy selection to clipboard (off: right-click copies / pastes)", - ), - "", - sk.ink, - ); - } - let (bx, by, _, bh) = geometry.cjk_bold; - if visible(group_y(by), title_h) { - section_title( - r, - gc, - size, - scale, - &sk, - bx, - group_y(by), - language.pick("文本渲染", "Text rendering"), - ); - } - if visible(by, bh) { - row_label( - r, - gc, - size, - scale, - &sk, - geometry.cjk_bold, - language.pick( - "中文粗体只提亮不加粗(避免小字号下笔画发闷)", - "Render CJK bold with regular glyphs (avoids muddy strokes)", - ), - "", - sk.ink, - ); - } - }, - NebulaSettingsSection::Keymap => { - let (kx, ky, kw, kh) = geometry.keymap_row0; - if visible(group_y(ky), title_h) { - section_title( - r, - gc, - size, - scale, - &sk, - kx, - group_y(ky), - language.pick( - "快捷键(点击右侧按键重新绑定,Backspace 恢复默认)", - "Shortcuts (click a key to rebind, Backspace restores default)", - ), - ); - } - // 行矩形按文字行剔除:quad 走 scissor 能画半行,文字只要居中 - // 的字盒仍完整落在视口内就照画——否则底部半行只剩空 keycap。 - let line_visible = |ry: f32, rh: f32| { - let ty = ry + (rh - cell_h) / 2.0; - ty >= clip_top && ty + cell_h <= clip_bot - }; - for i in 0..keymap::editable_row_count() { - let rect = (kx, ky + i as f32 * geometry.keymap_row_h, kw, kh); - if !line_visible(rect.1, rect.3) { - continue; - } - let ty = rect.1 + (kh - cell_h) / 2.0; - let (zh_label, en_label) = if i == keymap::QUICK_TERMINAL_ROW { - if view.quick_hotkey_error.is_some() { - ("快速终端(注册失败)", "Quick terminal (failed)") - } else { - ("快速终端", "Quick terminal") - } - } else { - let (_, zh, en) = keymap::EDITABLE_ACTIONS[i - 1]; - (zh, en) - }; - r.draw_chrome_text( - size, - rect.0 + s(16.0), - ty, - if i == keymap::QUICK_TERMINAL_ROW && view.quick_hotkey_error.is_some() { - if sk.is_light { Rgb::new(207, 34, 46) } else { Rgb::new(248, 81, 73) } - } else { - sk.ink - }, - language.pick(zh_label, en_label), - gc, - ); - let capturing = view.keymap_capture == Some(i); - let (value, customized, bound) = keymap_row_value(view, i); - // 墨色分级:捕获中 accent、自定义键 accent、默认键正常墨、 - // 未绑定弱墨——一眼看出哪些行动过。 - let ink = if capturing || customized { - sk.accent - } else if bound { - sk.ink - } else { - sk.ink_dim - }; - if capturing || !bound { - // 捕获提示 / 未绑定占位仍是整段文本(不是键位展示)。 - let (cap_x, ..) = keymap_keycap_rect(rect, &value, cell_w, scale); - r.draw_chrome_text(size, cap_x + s(12.0), ty, ink, &value, gc); - } else { - // 图8 键帽规范:逐键 chip 内居中键名,弱墨 "+" 分隔。 - let combo = super::keycap::layout_combo( - &value, - rect.0 + rect.2 - s(16.0), - rect.1 + rect.3 / 2.0, - cell_w, - scale, - ); - for (chip_x, chip_w, key) in &combo.chips { - let key_cols: usize = - key.chars().map(|c| c.width().unwrap_or(0)).sum(); - r.draw_chrome_text( - size, - chip_x + (chip_w - key_cols as f32 * cell_w) / 2.0, - ty, - ink, - key, - gc, - ); - } - for plus_x in &combo.pluses { - r.draw_chrome_text(size, *plus_x, ty, sk.ink_faint, "+", gc); - } - } - } - let (rx, ry, rw, rh) = geometry.keymap_readonly_row0; - if visible(group_y(ry), title_h) { - section_title( - r, - gc, - size, - scale, - &sk, - rx, - group_y(ry), - language.pick("固定快捷键", "Fixed shortcuts"), - ); - } - for (i, (zh_label, en_label, combo)) in keymap::READONLY_ROWS.iter().enumerate() { - let rect = (rx, ry + i as f32 * geometry.keymap_row_h, rw, rh); - if line_visible(rect.1, rect.3) { - row_label( - r, - gc, - size, - scale, - &sk, - rect, - view.language.pick(zh_label, en_label), - combo, - sk.ink_dim, - ); - } - } - }, - NebulaSettingsSection::Advanced => { - let (ax, ay, _, ah) = geometry.keep_session; - if visible(group_y(ay), title_h) { - section_title( - r, - gc, - size, - scale, - &sk, - ax, - group_y(ay), - language.pick("会话", "Sessions"), - ); - } - if visible(ay, ah) { - // The switch (drawn in `push_quads`) carries the state; the - // label says what closing a window keeps alive while it is ON. - row_label( - r, - gc, - size, - scale, - &sk, - geometry.keep_session, - language.pick( - "关闭窗口后保留会话(后台驻留,可恢复对话)", - "Keep sessions after closing the window (resident and restorable)", - ), - "", - sk.ink, - ); - } - - if SHOW_WEBDAV_SYNC_SETTINGS { - // ---- 同步(WebDAV)---- - let (sx, sy, ..) = geometry.sync_rows[0]; - if visible(group_y(sy), title_h) { - section_title( - r, - gc, - size, - scale, - &sk, - sx, - group_y(sy), - language.pick("同步(WebDAV)", "Sync (WebDAV)"), - ); - } - let labels = [ - language.pick("服务器文件 URL", "Server file URL"), - language.pick("用户名", "Username"), - language.pick("WebDAV 密码", "WebDAV password"), - language.pick("端到端口令", "End-to-end passphrase"), - ]; - let cell_w = size.cell_width(); - let cell_h = size.cell_height(); - for (index, row) in geometry.sync_rows.iter().enumerate() { - if !visible(row.1, row.3) { - continue; - } - row_label(r, gc, size, scale, &sk, *row, labels[index], "", sk.ink); - let (ix, iy, iw, ih) = sync_input_rect(*row, scale); - let max_cols = (((iw - s(24.0)) / cell_w) as usize).max(1); - let (text, placeholder, _) = sync_input_display(view, index, max_cols); - let ink = if placeholder { sk.ink_dim } else { sk.ink }; - r.draw_chrome_text( - size, - ix + s(12.0), - iy + (ih - cell_h) / 2.0, - ink, - &text, - gc, - ); - } - if visible(geometry.sync_auto_pull.1, geometry.sync_auto_pull.3) { - row_label( - r, - gc, - size, - scale, - &sk, - geometry.sync_auto_pull, - language.pick("启动时自动拉取", "Pull automatically on startup"), - "", - sk.ink, - ); - } - let (_, by, _, bh) = geometry.sync_actions; - if visible(by, bh + s(30.0)) { - let [push_rect, pull_rect] = sync_button_rects(geometry.sync_actions, scale); - let captions = [ - (push_rect, language.pick("立即推送", "Push now")), - (pull_rect, language.pick("立即拉取", "Pull now")), - ]; - for ((bx, byy, bw, bhh), caption) in captions { - let cols: usize = - caption.chars().map(|c| c.width().unwrap_or(1).max(1)).sum(); - let ink = if view.sync_busy { sk.ink_dim } else { sk.ink }; - r.draw_chrome_text( - size, - bx + (bw - cols as f32 * cell_w) / 2.0, - byy + (bhh - cell_h) / 2.0, - ink, - caption, - gc, - ); - } - // 状态行:最近一次动作结果(错误红、成功淡墨)。 - if let Some((message, error)) = &view.sync_status { - let ink = if *error { - Rgb::new(sk.danger.r, sk.danger.g, sk.danger.b) - } else { - sk.ink_dim - }; - r.draw_chrome_text(size, sx, by + bh + s(8.0), ink, message, gc); - } - } - } - }, - } - icon_draws -} - -#[cfg(test)] -mod tests { - use super::{SHOW_WEBDAV_SYNC_SETTINGS, advanced_content_end, opacity_from_pointer}; - - #[test] - fn slider_pointer_maps_to_clamped_fraction() { - let slider = (100.0, 20.0, 200.0, 36.0); - assert_eq!(opacity_from_pointer(50.0, slider), 0.0); - assert_eq!(opacity_from_pointer(100.0, slider), 0.0); - assert_eq!(opacity_from_pointer(200.0, slider), 0.5); - assert_eq!(opacity_from_pointer(300.0, slider), 1.0); - assert_eq!(opacity_from_pointer(350.0, slider), 1.0); - } - - #[test] - fn hidden_webdav_group_does_not_extend_advanced_content() { - assert!(!SHOW_WEBDAV_SYNC_SETTINGS); - assert_eq!(advanced_content_end(146.0, 264.0, 44.0), 190.0); - } -} +//! Settings special tab for Nebula's runtime appearance and completion settings. +//! +//! Mirrors the `command_palette` split, but goes one step further: besides the +//! *model* (sections, hit-testing, geometry, and the `nebula_settings.txt` +//! runtime store) this module also owns the panel's *rendering* — both the +//! background [`push_quads`] and the [`draw_text`] labels — so the giant +//! `display::mod` no longer carries the settings UI. The input layer stays the +//! only place that mutates state, reaching the `Display` methods that wrap this +//! model; rendering reads a snapshot [`SettingsView`] handed in each frame. +//! +//! Being a descendant module of `display`, this file can freely use the parent's +//! private helpers (`contains_rect`, `truncate_tab_label`, `nebula_data_dir`, +//! `NebulaTheme::palette`, `AcceptKey`, …) via `super::` — no visibility +//! churn needed in `mod.rs`. + +use unicode_width::UnicodeWidthChar; + +use nebula_terminal::vte::ansi::CursorShape; + +use crate::config::UiConfig; +use crate::display::color::Rgb; +use crate::renderer::image::{BackgroundImageAlignment, BackgroundImageFit}; +use crate::renderer::ui::{Rgba, UiQuad}; +use crate::renderer::{GlyphCache, Renderer}; + +use super::keymap; +use super::ui::theme::Skin; +use super::ui::{icons, widgets}; +use super::{ + AcceptKey, LanguagePreference, NebulaShell, NebulaTheme, SizeInfo, UiLanguage, + chrome_settings_button_rect, contains_rect, nebula_data_dir, truncate_tab_label, +}; + +// Visual language: one flat panel color, one hairline, three text grays, ONE +// accent — hierarchy comes from typography and spacing. Every color is a +// [`Skin`] token from `display::theme` (single source of truth), so the page +// flips correctly between the light and dark theme families. + +/// WebDAV 同步还处于内部迭代阶段。保留状态、持久化与后端实现,但在交互闭环 +/// 完成前不向用户暴露入口;集中守门可避免绘制、命中和滚动高度各自遗漏。 +const SHOW_WEBDAV_SYNC_SETTINGS: bool = false; + +/// Sidebar sections of the settings panel. Deliberately small: only sections +/// with real functionality behind them are listed. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum NebulaSettingsSection { + /// Themes, custom colors, wallpaper, cursor and window opacity. + #[default] + Appearance, + /// Completion behaviour plus the raw `nebula_settings.txt` config file. + Profiles, + /// Selection/clipboard behaviour (Windows Terminal's "交互" page). + Interaction, + /// Read-only shortcut sheet + pointer to `[[keyboard.bindings]]` remapping. + Keymap, + /// Power-user switches (session residency on close, …). + Advanced, +} + +impl NebulaSettingsSection { + fn label(self, language: UiLanguage) -> &'static str { + match self { + Self::Appearance => language.pick("外观", "Appearance"), + Self::Profiles => language.pick("配置文件", "Profiles"), + Self::Interaction => language.pick("交互", "Interaction"), + Self::Keymap => language.pick("按键映射", "Key bindings"), + Self::Advanced => language.pick("高级", "Advanced"), + } + } +} + +/// Shortcut sheet shown in 设置→按键映射. Editable rows live in +/// [`keymap::EDITABLE_ACTIONS`]; the read-only extras in +/// [`keymap::READONLY_ROWS`] (spec 002). + +/// Which independently draggable opacity control is being adjusted. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum SettingsOpacityTarget { + Terminal, + BackgroundImage, +} + +/// Which inline dropdown (combobox) is currently expanded. At most one at a +/// time; the option list floats over later rows instead of pushing them down. +/// 用户范式(2026-07-23):凡是多选项的设置一律做成 WT 风格内嵌下拉框, +/// 不再用"点击循环切换"——所有选项必须先可见再选择。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SettingsDropdown { + Shell, + Font, + BackgroundFit, + BackgroundAlignment, + Language, + Accept, + CursorShape, + /// 背景色:色板网格 + 16 进制输入的专用浮层(不是通用行列表)。 + BackgroundColor, +} + +/// 背景色色盘的预设色板(2 行 × 6 列)。前排偏星云/终端惯用暗底,尾部 +/// 提供近黑与亮底;任意颜色可用下方的 16 进制输入框手动指定。 +pub(crate) const BACKGROUND_SWATCHES: [Rgb; 12] = [ + Rgb::new(8, 10, 24), + Rgb::new(12, 16, 28), + Rgb::new(18, 14, 32), + Rgb::new(24, 24, 37), + Rgb::new(0, 43, 54), + Rgb::new(6, 26, 28), + Rgb::new(40, 42, 54), + Rgb::new(30, 30, 30), + Rgb::new(12, 12, 12), + Rgb::new(0, 0, 0), + Rgb::new(253, 246, 227), + Rgb::new(255, 255, 255), +]; + +pub(super) const BACKGROUND_FIT_OPTIONS: [BackgroundImageFit; 4] = [ + BackgroundImageFit::Fill, + BackgroundImageFit::Uniform, + BackgroundImageFit::UniformToFill, + BackgroundImageFit::None, +]; + +pub(super) const BACKGROUND_ALIGNMENT_OPTIONS: [BackgroundImageAlignment; 9] = [ + BackgroundImageAlignment::TopLeft, + BackgroundImageAlignment::Top, + BackgroundImageAlignment::TopRight, + BackgroundImageAlignment::Left, + BackgroundImageAlignment::Center, + BackgroundImageAlignment::Right, + BackgroundImageAlignment::BottomLeft, + BackgroundImageAlignment::Bottom, + BackgroundImageAlignment::BottomRight, +]; + +pub(super) const LANGUAGE_OPTIONS: [LanguagePreference; 3] = + [LanguagePreference::System, LanguagePreference::ZhCn, LanguagePreference::EnUs]; + +pub(super) const ACCEPT_OPTIONS: [AcceptKey; 3] = + [AcceptKey::Both, AcceptKey::Tab, AcceptKey::Right]; + +/// Order mirrors the Windows Terminal appearance page the user referenced. +pub(super) const CURSOR_SHAPE_OPTIONS: [CursorShape; 4] = + [CursorShape::Beam, CursorShape::Underline, CursorShape::Block, CursorShape::HollowBlock]; + +pub(super) fn cursor_shape_label(shape: CursorShape, language: UiLanguage) -> &'static str { + match shape { + CursorShape::Beam => language.pick("条形(│)", "Bar (│)"), + CursorShape::Underline => language.pick("下划线(_)", "Underscore (_)"), + CursorShape::Block => language.pick("实心框(█)", "Filled box (█)"), + CursorShape::HollowBlock => language.pick("空心框(□)", "Empty box (□)"), + CursorShape::Hidden => language.pick("隐藏", "Hidden"), + } +} + +fn accept_label(accept: AcceptKey, language: UiLanguage) -> &'static str { + match accept { + AcceptKey::Right => language.pick("右方向键", "Right arrow"), + AcceptKey::Tab => "Tab", + AcceptKey::Both => language.pick("Tab 或右方向键", "Tab or Right arrow"), + } +} + +fn language_label(preference: LanguagePreference, language: UiLanguage) -> &'static str { + match preference { + LanguagePreference::System => language.pick("跟随系统", "Follow system"), + LanguagePreference::ZhCn => "简体中文", + LanguagePreference::EnUs => "English", + } +} + +/// Hit result for the top-left Nebula settings affordance. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SettingsHit { + None, + Toggle, + Panel, + Nav(NebulaSettingsSection), + Theme(NebulaTheme), + Language(LanguagePreference), + SystemThemeToggle, + GhostToggle, + AcceptCycle, + ShellCycle, + StartupDirectory, + StartupDirectoryClear, + /// One of the expanded shell picker rows (index into detected_shells). + ShellPickerRow(usize), + FontCycle, + /// Imported-font picker rows; the final row is always "导入字体…". + FontPickerRow(usize), + /// Font-size spinner steppers on the "字号" row. + FontSizeUp, + FontSizeDown, + /// Cursor group: shape dropdown + its option rows, and the blink toggle. + CursorShapeDropdown, + CursorShapeOption(usize), + CursorBlinkToggle, + /// 交互: copy-on-select toggle row. + CopyOnSelectToggle, + /// 交互: 全宽字形 bold run 用 Regular 字形(粗体提亮不加粗)。 + CjkBoldToggle, + /// Language combobox trigger (options resolve to [`SettingsHit::Language`]). + LanguageDropdown, + /// Expanded dropdown option rows for the cycle-style settings. + AcceptOption(usize), + FitOption(usize), + AlignOption(usize), + /// Restore one address from the persistent hidden-host list. + RestoreHiddenSsh(usize), + FetchToggle, + PowerlineToggle, + OpacitySlider, + BackgroundColor, + /// 背景色浮层:色板网格里的一格。 + BackgroundSwatch(usize), + /// 背景色浮层:16 进制输入框。 + BackgroundHexInput, + /// 背景色浮层内部的空白(吞掉点击且不关闭浮层)。 + BackgroundPopupPanel, + BackgroundImage, + BackgroundImageClear, + BackgroundImageFit, + BackgroundImageAlignment, + BackgroundImageCoverChrome, + BackgroundImageOpacitySlider, + OpenConfigFile, + Reset, + /// 高级: keep the resident server (detach) on window close. + KeepSessionToggle, + /// 高级→同步: 输入框(0=url 1=用户名 2=WebDAV 密码 3=E2E 口令)。 + SyncInput(usize), + SyncAutoPullToggle, + SyncPushButton, + SyncPullButton, + /// 按键映射: one editable action row (click → capture a new combo). + KeymapRow(usize), +} + +// ---- runtime settings store (`Nebula/nebula_settings.txt`) ---- + +pub(super) struct NebulaRuntimeSettings { + pub(super) language: LanguagePreference, + pub(super) ghost: bool, + pub(super) accept: AcceptKey, + pub(super) shell: NebulaShell, + /// Raw default-shell id (`shell=`), when the user picked a detected + /// shell the 2-value `shell` enum can't represent (cmd, pwsh, nushell, a + /// WSL distro). `None` = the enum value is authoritative. Written verbatim + /// so `shell_detect::resolve_id` and the PTY layer both see the real id. + pub(super) shell_id: Option, + /// Default working directory for fresh terminal tabs. `None` inherits the + /// focused pane (or the process cwd for the first window). + pub(super) startup_directory: Option, + pub(super) font_family: String, + /// Terminal font size in LOGICAL px (`None` = follow the config file). + /// Persisted so the settings spinner and Ctrl+wheel zoom survive restarts. + pub(super) font_size: Option, + /// Default cursor shape; escape sequences (vim, claude) still override. + pub(super) cursor_shape: CursorShape, + /// Default-on: a static cursor reads as a hang ("没有活动感"). + pub(super) cursor_blink: bool, + /// 交互: selecting text copies it to the clipboard immediately (WT's + /// copyOnSelect). Off = right-click copies instead. + pub(super) copy_on_select: bool, + /// 全宽字形(CJK 等)在 bold run 里用 Regular 字形(粗体提亮不加粗)。 + /// 默认开:小字号下雅黑 Bold fallback 与 Regular 混排发闷(任务 #4)。 + pub(super) cjk_bold_regular: bool, + pub(super) fetch: bool, + pub(super) powerline: bool, + /// Window close keeps the PTYs alive in the resident process (detach / + /// re-attach session restore). Off = closing a window kills its shells. + pub(super) keep_session: bool, + pub(super) opacity: f32, + pub(super) background: Option, + pub(super) background_image: Option, + pub(super) background_image_opacity: f32, + pub(super) background_image_fit: BackgroundImageFit, + pub(super) background_image_alignment: BackgroundImageAlignment, + pub(super) background_image_cover_chrome: bool, + /// Chrome theme. Persisted so a restart keeps the chosen look AND the + /// powerline bridge file gets rewritten with the right name on boot + /// (it used to be reset to the default theme every launch). + pub(super) theme: NebulaTheme, + /// Automatically choose the light/dark member of the selected theme + /// family when the operating system appearance changes. + pub(super) follow_system_theme: bool, + /// SSH host aliases pinned to the top of the sidebar's "SSH HOSTS" + /// section (right-click a host row), in pinned order. + pub(super) pinned_hosts: Vec, + /// SSH destinations auto-saved after a successful typed `ssh` connection, + /// most recent first (see `Display::nebula_save_ssh_host`). + pub(super) saved_hosts: Vec, + /// SSH aliases explicitly removed from the sidebar. This is separate from + /// `saved_hosts` because entries discovered in `~/.ssh/config` would + /// otherwise reappear on the very next merge. + pub(super) hidden_hosts: Vec, + /// User keybinding overrides, raw `(combo, action)` pairs in file order + /// (spec 002). Kept verbatim so unknown-but-valid future actions survive a + /// load/save cycle; `display::keymap::build_bindings` parses them. + pub(super) keybinds: Vec<(String, String)>, + /// 快速终端全局切换键,和普通动作绑定分开持久化。 + pub(super) quick_terminal_hotkey: String, +} + +/// Load runtime UI settings from `Nebula/nebula_settings.txt`; defaults when +/// absent. Format is one `key=value` per line so power users can edit it while +/// the graphical settings page catches up. +pub(super) fn nebula_settings_load(config: &UiConfig) -> NebulaRuntimeSettings { + let path = nebula_data_dir().join("nebula_settings.txt"); + let mut settings = NebulaRuntimeSettings { + language: LanguagePreference::System, + ghost: true, + accept: AcceptKey::Both, + shell: NebulaShell::PowerShell, + shell_id: None, + startup_directory: None, + font_family: config.font.normal().family.clone(), + font_size: None, + cursor_shape: CursorShape::Beam, + cursor_blink: true, + copy_on_select: true, + cjk_bold_regular: true, + // 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). + fetch: false, + powerline: true, + // Off by default (user ruling 2026-07-12): a plain terminal should die + // clean on close. Residency leaves shells running in the background, + // which reads as "the app didn't really exit" — opt IN, not out. + keep_session: false, + opacity: config.window_opacity(), + background: None, + background_image: None, + background_image_opacity: 0.38, + background_image_fit: BackgroundImageFit::default(), + background_image_alignment: BackgroundImageAlignment::default(), + background_image_cover_chrome: false, + theme: NebulaTheme::default(), + // Preserve existing installations: automatic switching is opt-in so + // an update never replaces an explicitly selected theme unexpectedly. + follow_system_theme: false, + pinned_hosts: Vec::new(), + saved_hosts: Vec::new(), + hidden_hosts: Vec::new(), + keybinds: Vec::new(), + quick_terminal_hotkey: keymap::DEFAULT_QUICK_TERMINAL_HOTKEY.to_owned(), + }; + if let Ok(data) = std::fs::read_to_string(path) { + for line in data.lines() { + match line.split_once('=') { + Some(("language", v)) => { + if let Some(language) = LanguagePreference::parse(v) { + settings.language = language; + } + }, + Some(("ghost", v)) => settings.ghost = v.trim() != "0", + Some(("theme", v)) => { + if let Some(theme) = NebulaTheme::from_prompt_name(v.trim()) { + settings.theme = theme; + } + }, + Some(("accept", "right")) => settings.accept = AcceptKey::Right, + Some(("accept", "tab")) => settings.accept = AcceptKey::Tab, + Some(("accept", "both")) => settings.accept = AcceptKey::Both, + Some(("shell" | "executor", v)) => { + let v = v.trim(); + if let Some(shell) = NebulaShell::from_settings(v) { + settings.shell = shell; + } + // Preserve the raw id for detected shells the enum can't + // represent (cmd, pwsh, nushell, wsl:); the enum + // still tracks the PTY-integrated executor family so the + // prompt bootstrap picks the right base. + if !v.is_empty() { + settings.shell_id = Some(v.to_owned()); + } + }, + Some(("font_family", v)) => { + let family = v.trim(); + if !family.is_empty() { + settings.font_family = family.to_owned(); + } + }, + Some(("font_size", v)) => { + if let Ok(size) = v.trim().parse::() { + settings.font_size = Some(size.clamp(6.0, 72.0)); + } + }, + Some(("cursor_shape", v)) => { + if let Some(shape) = parse_cursor_shape(v) { + settings.cursor_shape = shape; + } + }, + Some(("cursor_blink", v)) => settings.cursor_blink = parse_bool(v, true), + Some(("copy_on_select", v)) => settings.copy_on_select = parse_bool(v, true), + Some(("cjk_bold_regular", v)) => { + settings.cjk_bold_regular = parse_bool(v, true) + }, + Some(("startup_directory", v)) => { + let path = std::path::PathBuf::from(v.trim()); + if path.is_dir() { + settings.startup_directory = Some(path); + } + }, + Some(("fetch", v)) => settings.fetch = parse_bool(v, true), + Some(("powerline", v)) => settings.powerline = parse_bool(v, true), + Some(("keep_session", v)) => settings.keep_session = parse_bool(v, false), + Some(("opacity", v)) => { + if let Ok(opacity) = v.trim().parse::() { + settings.opacity = opacity.clamp(0.0, 1.0); + } + }, + Some(("background", v)) => settings.background = parse_hex_rgb(v.trim()), + Some(("background_image", v)) => { + let v = v.trim(); + settings.background_image = (!v.is_empty()).then(|| v.to_owned()); + }, + Some(("background_image_opacity", v)) => { + if let Ok(opacity) = v.trim().parse::() { + settings.background_image_opacity = opacity.clamp(0.0, 1.0); + } + }, + Some(("background_image_fit", v)) => { + if let Some(fit) = BackgroundImageFit::parse(v) { + settings.background_image_fit = fit; + } + }, + Some(("background_image_alignment", v)) => { + if let Some(alignment) = BackgroundImageAlignment::parse(v) { + settings.background_image_alignment = alignment; + } + }, + Some(("background_image_cover_chrome", v)) => { + settings.background_image_cover_chrome = parse_bool(v, false); + }, + Some(("pinned_hosts", v)) => { + settings.pinned_hosts = v + .split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_owned) + .collect(); + }, + Some(("saved_hosts", v)) => { + settings.saved_hosts = v + .split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_owned) + .collect(); + }, + Some(("follow_system_theme", v)) => { + settings.follow_system_theme = parse_bool(v, false) + }, + Some(("hidden_hosts", v)) => { + settings.hidden_hosts = v + .split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_owned) + .collect(); + }, + Some(("keybind", v)) => { + // `keybind=:`;解析验证归 keymap 模块,这里 + // 只收原文——非法行在构建绑定表时静默丢弃。 + if let Some((combo, action)) = v.split_once(':') { + let (combo, action) = (combo.trim(), action.trim()); + if !combo.is_empty() && !action.is_empty() { + settings.keybinds.push((combo.to_lowercase(), action.to_owned())); + } + } + }, + Some(("quick_terminal_hotkey", v)) => { + let value = v.trim(); + if value.parse::().is_ok() { + settings.quick_terminal_hotkey = value.to_owned(); + } + }, + _ => {}, + } + } + } + settings +} + +fn parse_bool(value: &str, default: bool) -> bool { + match value.trim().to_ascii_lowercase().as_str() { + "1" | "true" | "yes" | "on" => true, + "0" | "false" | "no" | "off" => false, + _ => default, + } +} + +fn parse_cursor_shape(value: &str) -> Option { + match value.trim().to_ascii_lowercase().as_str() { + "block" => Some(CursorShape::Block), + "beam" | "bar" => Some(CursorShape::Beam), + "underline" => Some(CursorShape::Underline), + "hollow" => Some(CursorShape::HollowBlock), + _ => None, + } +} + +pub(super) fn cursor_shape_settings_value(shape: CursorShape) -> &'static str { + match shape { + CursorShape::Beam => "beam", + CursorShape::Underline => "underline", + CursorShape::HollowBlock => "hollow", + CursorShape::Block | CursorShape::Hidden => "block", + } +} + +pub(crate) fn parse_hex_rgb(value: &str) -> Option { + let hex = value.strip_prefix('#').unwrap_or(value); + if hex.len() != 6 { + 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(Rgb::new(r, g, b)) +} + +fn format_hex_rgb(rgb: Rgb) -> String { + format!("#{:02x}{:02x}{:02x}", rgb.r, rgb.g, rgb.b) +} + +fn background_image_fit_label(fit: BackgroundImageFit, language: UiLanguage) -> &'static str { + match fit { + BackgroundImageFit::Fill => language.pick("拉伸", "Fill"), + BackgroundImageFit::Uniform => language.pick("适应", "Uniform"), + BackgroundImageFit::UniformToFill => language.pick("填充", "Uniform to fill"), + BackgroundImageFit::None => language.pick("原始尺寸", "None"), + } +} + +fn background_image_alignment_label( + alignment: BackgroundImageAlignment, + language: UiLanguage, +) -> &'static str { + match alignment { + BackgroundImageAlignment::TopLeft => language.pick("左上", "Top left"), + BackgroundImageAlignment::Top => language.pick("顶部", "Top"), + BackgroundImageAlignment::TopRight => language.pick("右上", "Top right"), + BackgroundImageAlignment::Left => language.pick("左侧", "Left"), + BackgroundImageAlignment::Center => language.pick("居中", "Center"), + BackgroundImageAlignment::Right => language.pick("右侧", "Right"), + BackgroundImageAlignment::BottomLeft => language.pick("左下", "Bottom left"), + BackgroundImageAlignment::Bottom => language.pick("底部", "Bottom"), + BackgroundImageAlignment::BottomRight => language.pick("右下", "Bottom right"), + } +} + +pub(super) fn nebula_settings_mtime() -> Option { + std::fs::metadata(nebula_data_dir().join("nebula_settings.txt")) + .and_then(|meta| meta.modified()) + .ok() +} + +/// Persist runtime settings next to the history file. +pub(super) fn nebula_settings_write(settings: &NebulaRuntimeSettings) { + let accept = match settings.accept { + AcceptKey::Right => "right", + AcceptKey::Tab => "tab", + AcceptKey::Both => "both", + }; + let background = settings.background.map(format_hex_rgb).unwrap_or_default(); + let background_image = settings.background_image.as_deref().unwrap_or(""); + // A picked detected-shell id (cmd/pwsh/nu/wsl:X) is written verbatim; the + // 2-value enum is the fallback for the built-in powershell/bash choice. + let shell = + settings.shell_id.clone().unwrap_or_else(|| settings.shell.settings_value().to_owned()); + let startup_directory = settings + .startup_directory + .as_ref() + .map(|path| path.to_string_lossy().into_owned()) + .unwrap_or_default(); + let theme = settings.theme.prompt_name(); + let path = nebula_data_dir().join("nebula_settings.txt"); + let pinned_hosts = settings.pinned_hosts.join(","); + let saved_hosts = settings.saved_hosts.join(","); + let hidden_hosts = settings.hidden_hosts.join(","); + let font_size = + settings.font_size.map(|size| format!("{size:.1}")).unwrap_or_default(); + let mut keybinds = String::new(); + for (combo, action) in &settings.keybinds { + keybinds.push_str(&format!("keybind={combo}:{action}\n")); + } + let quick_terminal_hotkey = settings.quick_terminal_hotkey.trim(); + let _ = std::fs::write( + 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={}\nfetch={}\npowerline={}\nkeep_session={}\nopacity={:.2}\nbackground={background}\nbackground_image={background_image}\nbackground_image_opacity={:.2}\nbackground_image_fit={}\nbackground_image_alignment={}\nbackground_image_cover_chrome={}\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, + settings.font_family, + cursor_shape_settings_value(settings.cursor_shape), + settings.cursor_blink as u8, + settings.copy_on_select as u8, + settings.cjk_bold_regular as u8, + settings.fetch as u8, + settings.powerline as u8, + settings.keep_session as u8, + settings.opacity, + settings.background_image_opacity, + settings.background_image_fit.settings_value(), + settings.background_image_alignment.settings_value(), + settings.background_image_cover_chrome as u8, + ), + ); +} + +// ---- geometry + hit-testing ---- + +#[derive(Debug, Clone, Copy)] +struct SettingsGeometry { + gear: (f32, f32, f32, f32), + popup: (f32, f32, f32, f32), + sidebar: (f32, f32, f32, f32), + content: (f32, f32, f32, f32), + nav: [(NebulaSettingsSection, f32, f32, f32, f32); 5], + options: [(NebulaTheme, f32, f32, f32, f32); 7], + /// Live terminal preview card at the top of Appearance: configure → + /// immediately see (font, size, colors, wallpaper opacity, cursor). + preview: (f32, f32, f32, f32), + system_theme: (f32, f32, f32, f32), + shell: (f32, f32, f32, f32), + startup_directory: (f32, f32, f32, f32), + startup_directory_clear: (f32, f32, f32, f32), + font: (f32, f32, f32, f32), + /// "字号" spinner row; the value box + steppers derive via `widgets`. + font_size_row: (f32, f32, f32, f32), + fetch: (f32, f32, f32, f32), + powerline: (f32, f32, f32, f32), + ghost: (f32, f32, f32, f32), + accept: (f32, f32, f32, f32), + open_config_file: (f32, f32, f32, f32), + hidden_host_row0: (f32, f32, f32, f32), + hidden_host_count: usize, + /// Full-width "窗口透明度" row and its draggable track. + language_row: (f32, f32, f32, f32), + opacity_row: (f32, f32, f32, f32), + opacity_slider: (f32, f32, f32, f32), + /// Cursor group: shape combobox row + blink toggle row. + cursor_shape_row: (f32, f32, f32, f32), + cursor_blink_row: (f32, f32, f32, f32), + background: (f32, f32, f32, f32), + background_image: (f32, f32, f32, f32), + background_image_clear: (f32, f32, f32, f32), + background_image_fit: (f32, f32, f32, f32), + background_image_alignment: (f32, f32, f32, f32), + background_image_cover_chrome: (f32, f32, f32, f32), + background_image_opacity_row: (f32, f32, f32, f32), + background_image_opacity_slider: (f32, f32, f32, f32), + /// 交互: copy-on-select toggle row. + copy_on_select: (f32, f32, f32, f32), + /// 交互: CJK 粗体策略 toggle row. + cjk_bold: (f32, f32, f32, f32), + reset: (f32, f32, f32, f32), + /// Top edge of the scrollable content viewport (just below the fixed + /// header band); everything above it never scrolls. + content_top: f32, + /// Total designed content height per section (scaled px, measured from + /// `content_top`). `max_scroll = (height - viewport).max(0)`. + appearance_h: f32, + profiles_h: f32, + interaction_h: f32, + keymap_h: f32, + /// First keymap row rect; row `i` sits `i * row_h` below it. + keymap_row0: (f32, f32, f32, f32), + /// First row of the read-only shortcut group below the editable block. + keymap_readonly_row0: (f32, f32, f32, f32), + keymap_row_h: f32, + advanced_h: f32, + keep_session: (f32, f32, f32, f32), + /// 高级→同步(WebDAV):url/用户名/密码/口令 四行 + 自动拉取开关行 + /// + 动作按钮行。输入框矩形由 [`sync_input_rect`] 从行矩形推导。 + sync_rows: [(f32, f32, f32, f32); 4], + sync_auto_pull: (f32, f32, f32, f32), + sync_actions: (f32, f32, f32, f32), +} + +/// Scrollable-content viewport height for the Settings tab. +fn settings_viewport_h(popup_h: f32, scale_factor: f32) -> f32 { + popup_h - 72.0 * scale_factor +} + +fn advanced_content_end(advanced_y0: f32, sync_y0: f32, row_h: f32) -> f32 { + if SHOW_WEBDAV_SYNC_SETTINGS { sync_y0 + 7.0 * row_h } else { advanced_y0 + row_h } +} + +/// Max scroll offset for `section` at the current window size. The input +/// layer clamps its accumulated wheel delta with this. Dropdown popups float +/// over rows, so they never change a section's content height. +pub(super) fn settings_max_scroll( + size_info: &SizeInfo, + scale_factor: f32, + area: (f32, f32, f32, f32), + section: NebulaSettingsSection, + hidden_host_count: usize, +) -> f32 { + let geometry = settings_geometry(size_info, scale_factor, area, 0.0, hidden_host_count); + let (_, _, _, ph) = geometry.popup; + let content_h = match section { + NebulaSettingsSection::Appearance => geometry.appearance_h, + NebulaSettingsSection::Profiles => geometry.profiles_h, + NebulaSettingsSection::Interaction => geometry.interaction_h, + NebulaSettingsSection::Keymap => geometry.keymap_h, + NebulaSettingsSection::Advanced => geometry.advanced_h, + }; + (content_h - settings_viewport_h(ph, scale_factor)).max(0.0) +} + +fn settings_geometry( + size_info: &SizeInfo, + scale_factor: f32, + area: (f32, f32, f32, f32), + scroll: f32, + hidden_host_count: usize, +) -> SettingsGeometry { + let s = |v: f32| v * scale_factor; + let gear = chrome_settings_button_rect(size_info, scale_factor); + + // The settings surface is the active tab's content card. Keeping the + // geometry rooted in that card makes sidebar/drawer animations and DPI + // changes follow the exact same bounds as terminal and document tabs. + let (popup_x, popup_y, popup_w, popup_h) = area; + let sidebar_w = s(220.0).min(popup_w * 0.30); + let sidebar = (popup_x, popup_y, sidebar_w, popup_h); + let content_x = popup_x + sidebar_w + s(16.0); + let content_w = (popup_w - sidebar_w - s(16.0)).max(s(240.0)); + let content = (content_x, popup_y, content_w, popup_h); + + // The header band (big section title) is fixed; everything below it + // scrolls by `scroll` px. `at` maps a design-space Y to screen space. + let content_top = popup_y + s(72.0); + let at = |design_y: f32| popup_y + s(design_y) - scroll; + + // ---- vertical rhythm (design px from the popup top) ---- + // Mirrors the HTML design sheet's breathing room: a group title hangs + // 42px above its first row (title + 16px gap); rows inside a group are + // CONTIGUOUS — one hairline frame around the block, hairline separators + // between rows — and a finished group leaves 32px before the next title, + // so `74 = 32 (section gap) + 42 (hanging title)`. + const ROW_H: f32 = 44.0; + const GROUP_ADVANCE: f32 = 74.0; + // 预览卡设计高度:两行示例文本 + 光标演示的呼吸空间。 + const PREVIEW_H: f32 = 150.0; + + // Live preview leads the page: configure → see it immediately. + let preview_y0 = 146.0; + let preview = (content_x + s(24.0), at(preview_y0), content_w - s(48.0), s(PREVIEW_H)); + + // Theme cards flow as a 4 + 3 grid with the design sheet's 20px gaps: + // slot i sits at column i%4 on row i/4. The row pitch reserves a strip + // under each card for its label (12px gap + text + 20px grid row gap). + let card_gap = s(20.0); + let card_w = ((content_w - s(48.0) - 3.0 * card_gap) / 4.0).clamp(s(88.0), s(170.0)); + let card_h = s(64.0); + let card_y0 = preview_y0 + PREVIEW_H + GROUP_ADVANCE; + let card_x = content_x + s(24.0); + let card_row_pitch = card_h + s(48.0); + let card = |i: f32| card_x + (i % 4.0) * (card_w + card_gap); + let card_slot_y = |i: f32| at(card_y0) + (i / 4.0).floor() * card_row_pitch; + + let row_x = content_x + s(24.0); + let row_w = content_w - s(48.0); + let row_h = s(ROW_H); + + // Appearance: preview, cards, colors, cursor and interface groups. + let system_theme_y0 = card_y0 + 2.0 * (64.0 + 48.0) + GROUP_ADVANCE; + let color_y0 = system_theme_y0 + ROW_H + GROUP_ADVANCE; + // Match Windows Terminal's background-image controls: path, stretch, + // alignment and an independent image-opacity slider. + let background_image_y0 = color_y0 + ROW_H; + let background_image_fit_y0 = background_image_y0 + ROW_H; + let background_image_alignment_y0 = background_image_fit_y0 + ROW_H; + let background_image_opacity_y0 = background_image_alignment_y0 + ROW_H; + let background_image_cover_chrome_y0 = background_image_opacity_y0 + ROW_H; + // 光标组:形状下拉 + 闪烁开关。 + let cursor_y0 = color_y0 + 6.0 * ROW_H + GROUP_ADVANCE; + let iface_y0 = cursor_y0 + 2.0 * ROW_H + GROUP_ADVANCE; + let opacity_y0 = iface_y0 + ROW_H; + let appearance_h = s(opacity_y0 + ROW_H + 32.0 - 72.0); + // 宽命中区包住细轨道,拖拽时无需精确点中 4px 线条。 + let opacity_row = (row_x, at(opacity_y0), row_w, row_h); + let slider_x = row_x + row_w - s(212.0); + let slider_w = s(188.0).min(row_w * 0.42).max(s(96.0)); + let opacity_slider = (slider_x, at(opacity_y0) + s(4.0), slider_w, s(36.0)); + let background_image_opacity_row = (row_x, at(background_image_opacity_y0), row_w, row_h); + let background_image_opacity_slider = + (slider_x, at(background_image_opacity_y0) + s(4.0), slider_w, s(36.0)); + + // Sidebar navigation rows. The rects line up with the active-row + // highlight drawn while rendering. 4px between items — same breathing as + // the design sheet's nav menu. + let nav_x = popup_x + s(24.0); + let nav_w = sidebar_w - s(48.0); + let nav_h = s(44.0); + let nav_gap = s(4.0); + let nav_y0 = popup_y + s(88.0); + let nav_slot = |i: f32| nav_y0 + i * (nav_h + nav_gap); + let nav = [ + (NebulaSettingsSection::Appearance, nav_x, nav_slot(0.0), nav_w, nav_h), + (NebulaSettingsSection::Profiles, nav_x, nav_slot(1.0), nav_w, nav_h), + (NebulaSettingsSection::Interaction, nav_x, nav_slot(2.0), nav_w, nav_h), + (NebulaSettingsSection::Keymap, nav_x, nav_slot(3.0), nav_w, nav_h), + (NebulaSettingsSection::Advanced, nav_x, nav_slot(4.0), nav_w, nav_h), + ]; + + // Profiles: dropdown popups FLOAT over later rows (Windows 11 combobox), + // so every row keeps a fixed offset — no picker shove, no scroll jumps. + let shell_y0 = 146.0; + 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 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; + let profiles_end = if hidden_host_count == 0 { + open_y0 + ROW_H + } else { + hidden_y0 + hidden_host_count as f32 * ROW_H + }; + let profiles_h = s(profiles_end + 32.0 - 72.0); + + // 交互: copy-on-select + 文本渲染(CJK 粗体策略)两组。 + let interaction_y0 = 146.0; + let cjk_bold_y0 = interaction_y0 + ROW_H + GROUP_ADVANCE; + let interaction_h = s(cjk_bold_y0 + ROW_H + 32.0 - 72.0); + let copy_on_select = (row_x, at(interaction_y0), row_w, row_h); + let cjk_bold = (row_x, at(cjk_bold_y0), row_w, row_h); + + // Keymap: editable action rows, then a read-only extras group (spec 002). + let keymap_y0 = 146.0; + let keymap_readonly_y0 = + keymap_y0 + keymap::editable_row_count() as f32 * ROW_H + GROUP_ADVANCE; + let keymap_h = + s(keymap_readonly_y0 + keymap::READONLY_ROWS.len() as f32 * ROW_H + 32.0 - 72.0); + let keymap_row0 = (row_x, at(keymap_y0), row_w, row_h); + let keymap_readonly_row0 = (row_x, at(keymap_readonly_y0), row_w, row_h); + + // Advanced: session residency, then the gated WebDAV sync group (spec 003). + // 隐藏期间同步几何仍保留,方便后续继续完善,但页面高度只计算可见内容。 + let advanced_y0 = 146.0; + let keep_session = (row_x, at(advanced_y0), row_w, row_h); + let sync_y0 = advanced_y0 + ROW_H + GROUP_ADVANCE; + let sync_row = |i: f32| (row_x, at(sync_y0 + i * ROW_H), row_w, row_h); + let sync_rows = [sync_row(0.0), sync_row(1.0), sync_row(2.0), sync_row(3.0)]; + let sync_auto_pull = sync_row(4.0); + let sync_actions = sync_row(5.0); + let advanced_end = advanced_content_end(advanced_y0, sync_y0, ROW_H); + let advanced_h = s(advanced_end + 32.0 - 72.0); + + SettingsGeometry { + gear, + popup: (popup_x, popup_y, popup_w, popup_h), + sidebar, + content, + nav, + options: [ + (NebulaTheme::Nebula, card(0.0), card_slot_y(0.0), card_w, card_h), + (NebulaTheme::SilverLight, card(1.0), card_slot_y(1.0), card_w, card_h), + (NebulaTheme::SteelDark, card(2.0), card_slot_y(2.0), card_w, card_h), + (NebulaTheme::LimestoneLight, card(3.0), card_slot_y(3.0), card_w, card_h), + (NebulaTheme::CoalDark, card(4.0), card_slot_y(4.0), card_w, card_h), + (NebulaTheme::LinenLight, card(5.0), card_slot_y(5.0), card_w, card_h), + (NebulaTheme::MossDark, card(6.0), card_slot_y(6.0), card_w, card_h), + ], + preview, + system_theme: (row_x, at(system_theme_y0), row_w, row_h), + background: (row_x, at(color_y0), row_w, row_h), + background_image: (row_x, at(background_image_y0), row_w, row_h), + background_image_clear: ( + row_x + row_w - s(48.0), + at(background_image_y0) + s(5.0), + s(36.0), + s(34.0), + ), + background_image_fit: (row_x, at(background_image_fit_y0), row_w, row_h), + background_image_alignment: (row_x, at(background_image_alignment_y0), row_w, row_h), + background_image_cover_chrome: (row_x, at(background_image_cover_chrome_y0), row_w, row_h), + background_image_opacity_row, + background_image_opacity_slider, + cursor_shape_row: (row_x, at(cursor_y0), row_w, row_h), + cursor_blink_row: (row_x, at(cursor_y0 + ROW_H), row_w, row_h), + language_row: (row_x, at(iface_y0), row_w, row_h), + opacity_row, + opacity_slider, + shell: (row_x, at(shell_y0), row_w, row_h), + startup_directory: (row_x, at(startup_directory_y0), row_w, row_h), + startup_directory_clear: ( + row_x + row_w - s(82.0), + at(startup_directory_y0) + s(5.0), + s(72.0), + s(34.0), + ), + font: (row_x, at(font_y0), row_w, row_h), + font_size_row: (row_x, at(font_size_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), + accept: (row_x, at(ghost_y0 + ROW_H), row_w, row_h), + open_config_file: (row_x, at(open_y0), row_w, row_h), + hidden_host_row0: (row_x, at(hidden_y0), row_w, row_h), + hidden_host_count, + copy_on_select, + cjk_bold, + reset: (popup_x + popup_w - s(170.0), popup_y + s(24.0), s(150.0), s(42.0)), + content_top, + appearance_h, + profiles_h, + interaction_h, + keymap_h, + keymap_row0, + keymap_readonly_row0, + keymap_row_h: row_h, + advanced_h, + keep_session, + sync_rows, + sync_auto_pull, + sync_actions, + } +} + +pub(crate) fn opacity_slider_rect( + size_info: &SizeInfo, + scale_factor: f32, + area: (f32, f32, f32, f32), + scroll: f32, + target: SettingsOpacityTarget, +) -> (f32, f32, f32, f32) { + let geometry = settings_geometry(size_info, scale_factor, area, scroll, 0); + match target { + SettingsOpacityTarget::Terminal => geometry.opacity_slider, + SettingsOpacityTarget::BackgroundImage => geometry.background_image_opacity_slider, + } +} + +pub(crate) fn opacity_from_pointer(pointer_x: f32, slider: (f32, f32, f32, f32)) -> f32 { + ((pointer_x - slider.0) / slider.2.max(1.0)).clamp(0.0, 1.0) +} + +/// Appearance 预览卡的壁纸绘制矩形:`(fit 目标, 实际允许触碰的裁剪带)`。 +/// 裁剪带是预览与设置卡内容区的竖向交集——预览滚到 header 之下时壁纸不 +/// 能跟着涂出去。完全滚出可视区时返回 `None`。 +pub(super) fn appearance_preview_wallpaper_rects( + size_info: &SizeInfo, + scale_factor: f32, + area: (f32, f32, f32, f32), + scroll: f32, + hidden_hosts: usize, +) -> Option<((f32, f32, f32, f32), (f32, f32, f32, f32))> { + let geometry = settings_geometry(size_info, scale_factor, area, scroll, hidden_hosts); + let (vx, vy, vw, vh) = geometry.preview; + let (_, content_y, _, _) = geometry.content; + let (_, py, _, ph) = geometry.popup; + let top = vy.max(content_y); + let bottom = (vy + vh).min(py + ph); + if bottom <= top || vw <= 0.0 { + return None; + } + Some(((vx, vy, vw, vh), (vx, top, vw, bottom - top))) +} + +/// The combobox anchor rect + option count for `dropdown`, IF it belongs to +/// the active section. Hit-testing, popup quads and popup text all resolve +/// the floating list through this one helper so the three can never disagree. +/// 背景色浮层的几何:面板矩形、12 个色板格、16 进制输入框。绘制与命中 +/// 测试共用这一个来源(组件化范式:几何同源,控件与点击区不漂移)。 +pub(super) struct BackgroundColorPopup { + pub(super) rect: (f32, f32, f32, f32), + pub(super) swatch: [(f32, f32, f32, f32); 12], + pub(super) hex: (f32, f32, f32, f32), +} + +pub(super) fn background_color_popup( + geometry: &SettingsGeometry, + scale: f32, +) -> BackgroundColorPopup { + let s = |v: f32| v * scale; + let (ax, ay, aw, ah) = widgets::combobox_rect(geometry.background, scale); + const COLS: usize = 6; + let cell = s(30.0); + let gap = s(8.0); + let pad = s(12.0); + let grid_w = COLS as f32 * cell + (COLS - 1) as f32 * gap; + let grid_h = 2.0 * cell + gap; + let hex_h = s(34.0); + let w = (grid_w + 2.0 * pad).max(aw); + let h = pad + grid_h + gap + hex_h + pad; + // 与 combobox 浮层同规则:锚行右缘对齐,紧贴行下方展开。 + let x = ax + aw - w; + let y = ay + ah + s(6.0); + let mut swatch = [(0.0, 0.0, 0.0, 0.0); 12]; + for (i, rect) in swatch.iter_mut().enumerate() { + let row = i / COLS; + let col = i % COLS; + *rect = ( + x + pad + col as f32 * (cell + gap), + y + pad + row as f32 * (cell + gap), + cell, + cell, + ); + } + let hex = (x + pad, y + pad + grid_h + gap, w - 2.0 * pad, hex_h); + BackgroundColorPopup { rect: (x, y, w, h), swatch, hex } +} + +fn dropdown_anchor( + geometry: &SettingsGeometry, + section: NebulaSettingsSection, + dropdown: SettingsDropdown, + shell_count: usize, + font_count: usize, + scale: f32, +) -> Option<((f32, f32, f32, f32), usize)> { + use NebulaSettingsSection as Section; + let anchor = |row| widgets::combobox_rect(row, scale); + match (section, dropdown) { + (Section::Profiles, SettingsDropdown::Shell) => Some((anchor(geometry.shell), shell_count)), + (Section::Profiles, SettingsDropdown::Font) => Some((anchor(geometry.font), font_count)), + (Section::Profiles, SettingsDropdown::Accept) => { + Some((anchor(geometry.accept), ACCEPT_OPTIONS.len())) + }, + (Section::Appearance, SettingsDropdown::BackgroundFit) => { + Some((anchor(geometry.background_image_fit), BACKGROUND_FIT_OPTIONS.len())) + }, + (Section::Appearance, SettingsDropdown::BackgroundAlignment) => { + Some((anchor(geometry.background_image_alignment), BACKGROUND_ALIGNMENT_OPTIONS.len())) + }, + (Section::Appearance, SettingsDropdown::Language) => { + Some((anchor(geometry.language_row), LANGUAGE_OPTIONS.len())) + }, + (Section::Appearance, SettingsDropdown::CursorShape) => { + Some((anchor(geometry.cursor_shape_row), CURSOR_SHAPE_OPTIONS.len())) + }, + _ => None, + } +} + +/// Hit-test the top-left settings button and its popup. `scroll` must be the +/// same offset the renderer used, so hits land on what the user actually sees; +/// rows scrolled out of the content viewport don't respond. +#[allow(clippy::too_many_arguments)] +pub fn settings_hit( + size_info: &SizeInfo, + scale_factor: f32, + area: (f32, f32, f32, f32), + x: f32, + y: f32, + popup_open: bool, + section: NebulaSettingsSection, + scroll: f32, + dropdown: Option, + shell_count: usize, + font_count: usize, + hidden_host_count: usize, +) -> SettingsHit { + let geometry = settings_geometry(size_info, scale_factor, area, scroll, hidden_host_count); + let s = |v: f32| v * scale_factor; + + if contains_rect(geometry.gear, x, y) { + return SettingsHit::Toggle; + } + + if !popup_open { + return SettingsHit::None; + } + + // Scrolled content only responds inside its viewport (below the fixed + // header, above the popup's bottom edge). + let (_, py, _, ph) = geometry.popup; + let in_viewport = y >= geometry.content_top && y <= py + ph; + + // An expanded dropdown owns the pointer first: its floating option list + // covers later rows, and those must not react through it. + if let Some(dropdown) = dropdown { + // 背景色是专用浮层(色板网格 + hex 输入),不走通用行列表。 + if dropdown == SettingsDropdown::BackgroundColor { + if section == NebulaSettingsSection::Appearance { + let popup = background_color_popup(&geometry, scale_factor); + for (index, rect) in popup.swatch.iter().enumerate() { + if contains_rect(*rect, x, y) { + return SettingsHit::BackgroundSwatch(index); + } + } + if contains_rect(popup.hex, x, y) { + return SettingsHit::BackgroundHexInput; + } + if contains_rect(popup.rect, x, y) { + return SettingsHit::BackgroundPopupPanel; + } + } + } else if let Some((anchor, count)) = + dropdown_anchor(&geometry, section, dropdown, shell_count, font_count, scale_factor) + { + let popup = widgets::combobox_popup_rect( + anchor, + count, + scale_factor, + geometry.content_top, + py + ph - s(6.0), + ); + if let Some(index) = widgets::popup_row_at(popup, count, scale_factor, x, y) { + return match dropdown { + SettingsDropdown::Shell => SettingsHit::ShellPickerRow(index), + SettingsDropdown::Font => SettingsHit::FontPickerRow(index), + SettingsDropdown::BackgroundFit => SettingsHit::FitOption(index), + SettingsDropdown::BackgroundAlignment => SettingsHit::AlignOption(index), + SettingsDropdown::Language => SettingsHit::Language(LANGUAGE_OPTIONS[index]), + SettingsDropdown::Accept => SettingsHit::AcceptOption(index), + SettingsDropdown::CursorShape => SettingsHit::CursorShapeOption(index), + // 背景色浮层在上方特判处理,走不到通用行列表。 + SettingsDropdown::BackgroundColor => SettingsHit::Panel, + }; + } + if contains_rect(popup, x, y) { + // Padding strip inside the floating list: swallow the click + // so rows underneath cannot react through the popup. + return SettingsHit::Panel; + } + } + } + + // Sidebar navigation and the header reset button are available from every + // section. + for (nav_section, nx, ny, nw, nh) in geometry.nav { + if contains_rect((nx, ny, nw, nh), x, y) { + return SettingsHit::Nav(nav_section); + } + } + if contains_rect(geometry.reset, x, y) { + return SettingsHit::Reset; + } + + if in_viewport { + match section { + NebulaSettingsSection::Appearance => { + for (theme, ox, oy, ow, oh) in geometry.options { + if contains_rect((ox, oy, ow, oh), x, y) { + return SettingsHit::Theme(theme); + } + } + if contains_rect(geometry.system_theme, x, y) { + return SettingsHit::SystemThemeToggle; + } + if contains_rect(geometry.background, x, y) { + return SettingsHit::BackgroundColor; + } + if contains_rect(geometry.background_image_clear, x, y) { + return SettingsHit::BackgroundImageClear; + } + if contains_rect(geometry.background_image, x, y) { + return SettingsHit::BackgroundImage; + } + if contains_rect(geometry.background_image_fit, x, y) { + return SettingsHit::BackgroundImageFit; + } + if contains_rect(geometry.background_image_alignment, x, y) { + return SettingsHit::BackgroundImageAlignment; + } + if contains_rect(geometry.background_image_opacity_slider, x, y) { + return SettingsHit::BackgroundImageOpacitySlider; + } + if contains_rect(geometry.background_image_cover_chrome, x, y) { + return SettingsHit::BackgroundImageCoverChrome; + } + if contains_rect(geometry.cursor_shape_row, x, y) { + return SettingsHit::CursorShapeDropdown; + } + if contains_rect(geometry.cursor_blink_row, x, y) { + return SettingsHit::CursorBlinkToggle; + } + if contains_rect(geometry.language_row, x, y) { + return SettingsHit::LanguageDropdown; + } + if contains_rect(geometry.opacity_slider, x, y) { + return SettingsHit::OpacitySlider; + } + }, + NebulaSettingsSection::Profiles => { + if contains_rect(geometry.shell, x, y) { + return SettingsHit::ShellCycle; + } + if contains_rect(geometry.startup_directory_clear, x, y) { + return SettingsHit::StartupDirectoryClear; + } + if contains_rect(geometry.startup_directory, x, y) { + return SettingsHit::StartupDirectory; + } + if contains_rect(geometry.font, x, y) { + return SettingsHit::FontCycle; + } + { + let (_, up, down) = + widgets::spinner_rects(geometry.font_size_row, scale_factor); + if contains_rect(up, x, y) { + return SettingsHit::FontSizeUp; + } + if contains_rect(down, x, y) { + return SettingsHit::FontSizeDown; + } + } + if contains_rect(geometry.fetch, x, y) { + return SettingsHit::FetchToggle; + } + if contains_rect(geometry.powerline, x, y) { + return SettingsHit::PowerlineToggle; + } + if contains_rect(geometry.ghost, x, y) { + return SettingsHit::GhostToggle; + } + if contains_rect(geometry.accept, x, y) { + return SettingsHit::AcceptCycle; + } + if contains_rect(geometry.open_config_file, x, y) { + return SettingsHit::OpenConfigFile; + } + let (row_x, row_y, row_w, row_h) = geometry.hidden_host_row0; + for index in 0..geometry.hidden_host_count { + let rect = (row_x, row_y + index as f32 * row_h, row_w, row_h); + if contains_rect(rect, x, y) { + return SettingsHit::RestoreHiddenSsh(index); + } + } + }, + NebulaSettingsSection::Interaction => { + if contains_rect(geometry.copy_on_select, x, y) { + return SettingsHit::CopyOnSelectToggle; + } + if contains_rect(geometry.cjk_bold, x, y) { + return SettingsHit::CjkBoldToggle; + } + }, + NebulaSettingsSection::Keymap => { + let (row_x, row_y, row_w, row_h) = geometry.keymap_row0; + for index in 0..keymap::editable_row_count() { + let rect = (row_x, row_y + index as f32 * row_h, row_w, row_h); + if contains_rect(rect, x, y) { + return SettingsHit::KeymapRow(index); + } + } + }, + NebulaSettingsSection::Advanced => { + if contains_rect(geometry.keep_session, x, y) { + return SettingsHit::KeepSessionToggle; + } + if SHOW_WEBDAV_SYNC_SETTINGS { + for (index, rect) in geometry.sync_rows.iter().enumerate() { + // 命中整行都算输入框:行左侧是它的 label,点标签聚焦 + // 输入是 Windows 设置页的惯例。 + if contains_rect(*rect, x, y) { + return SettingsHit::SyncInput(index); + } + } + if contains_rect(geometry.sync_auto_pull, x, y) { + return SettingsHit::SyncAutoPullToggle; + } + let [push, pull] = sync_button_rects(geometry.sync_actions, scale_factor); + if contains_rect(push, x, y) { + return SettingsHit::SyncPushButton; + } + if contains_rect(pull, x, y) { + return SettingsHit::SyncPullButton; + } + } + }, + } + } + + if contains_rect(geometry.popup, x, y) { SettingsHit::Panel } else { SettingsHit::None } +} + +// ---- rendering ---- + +/// A per-frame snapshot of the display state the settings render reads. Owns its +/// data (notably the wallpaper path) so the caller can hand it in by reference +/// while still borrowing `&mut renderer` for [`draw_text`]. +pub(super) struct SettingsView { + /// The active tab's content card in physical pixels. Settings fills this + /// area like any other tab instead of inventing a second floating window. + pub(super) area: (f32, f32, f32, f32), + pub(super) language_preference: LanguagePreference, + pub(super) language: UiLanguage, + pub(super) section: NebulaSettingsSection, + pub(super) hover: SettingsHit, + pub(super) theme: NebulaTheme, + pub(super) follow_system_theme: bool, + pub(super) ghost: bool, + pub(super) accept: AcceptKey, + /// Pre-rendered "默认 Shell" value (icon + name) — resolved by `Display` + /// from the rich `shell_id` when set, else the 2-value enum label. + pub(super) shell_label: String, + /// Which combobox is expanded, if any (floating option list). + pub(super) dropdown: Option, + /// Detected shells for the picker (cached once per process). + pub(super) shells: Vec<(String, String, String)>, // (id, name, program) + pub(super) shell_id: Option, + pub(super) startup_directory: Option, + pub(super) font_family: String, + /// Current terminal font size in LOGICAL px, for the spinner value box. + pub(super) font_size_px: f32, + /// Private families plus Maple; the import action is rendered separately. + pub(super) fonts: Vec, + pub(super) font_notice: Option, + /// Persistent soft-deleted destinations. Rows provide a discoverable + /// recovery path after the short Undo bar has expired. + pub(super) hidden_hosts: Vec, + pub(super) fetch: bool, + pub(super) powerline: bool, + pub(super) keep_session: bool, + pub(super) opacity: f32, + /// Which opacity slider is mid-drag, for thumb-dot grow feedback. + pub(super) dragging_opacity: Option, + pub(super) cursor_shape: CursorShape, + pub(super) cursor_blink: bool, + pub(super) copy_on_select: bool, + pub(super) cjk_bold_regular: bool, + /// 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, + pub(super) preview_fg: Rgb, + pub(super) background: Option, + /// 背景色浮层的 16 进制草稿(形如 `#0A0C18`)与输入聚焦态。 + pub(super) bg_hex_input: String, + pub(super) bg_hex_active: bool, + pub(super) background_image: Option, + pub(super) background_image_opacity: f32, + pub(super) background_image_fit: BackgroundImageFit, + pub(super) background_image_alignment: BackgroundImageAlignment, + pub(super) background_image_cover_chrome: bool, + /// Content scroll offset in scaled px (0 = top). Owned by `Display`, + /// clamped there against [`settings_max_scroll`]. + pub(super) scroll: f32, + /// 按键映射: per editable row `(display combo, customized)`; `None` = + /// unbound. Precomputed by `Display` from the override + default tables. + pub(super) keymap: Vec>, + /// 快速终端行使用独立的全局快捷键字符串。 + pub(super) quick_terminal_hotkey: String, + pub(super) quick_hotkey_error: Option, + /// Row currently capturing a new combo, if any. + pub(super) keymap_capture: Option, + /// 捕获态按住的修饰键前缀("Ctrl+"),实时回显。 + pub(super) keymap_capture_preview: String, + /// 高级→同步:四个输入草稿(url、用户名、WebDAV 密码、E2E 口令)。 + pub(super) sync_inputs: [String; 4], + /// 聚焦的同步输入框下标(0..4)。 + pub(super) sync_focus: Option, + pub(super) sync_auto_pull: bool, + /// 凭据管理器里已有 [密码, 口令],决定密码框占位文案。 + pub(super) sync_secret_set: [bool; 2], + /// 最近一次同步动作的结果 `(message, is_error)`。 + pub(super) sync_status: Option<(String, bool)>, + pub(super) sync_busy: bool, +} + +/// 同步行右侧的输入框矩形(quad/text/hit 三处共用)。行左侧留给标签。 +fn sync_input_rect((rx, ry, rw, rh): (f32, f32, f32, f32), scale: f32) -> (f32, f32, f32, f32) { + let s = |v: f32| v * scale; + let w = rw * 0.56; + let h = rh - s(12.0); + (rx + rw - s(16.0) - w, ry + (rh - h) / 2.0, w, h) +} + +/// 同步输入框的展示内容:`(文本, 是否占位, 列数)`。密码/口令显示为 +/// 掩码点;超宽时截尾部显示(编辑总发生在末尾)。列数供 caret 定位。 +fn sync_input_display(view: &SettingsView, index: usize, max_cols: usize) -> (String, bool, usize) { + let language = view.language; + let raw = &view.sync_inputs[index]; + if raw.is_empty() { + let text = match index { + 0 => language.pick("https://dav.example.com/nebula.sync", "https://dav.example.com/nebula.sync"), + 1 => language.pick("WebDAV 用户名", "WebDAV username"), + 2 if view.sync_secret_set[0] => { + language.pick("已保存(输入以更换)", "Saved (type to replace)") + }, + 3 if view.sync_secret_set[1] => { + language.pick("已保存(输入以更换)", "Saved (type to replace)") + }, + _ => language.pick("未设置", "Not set"), + }; + return (text.to_owned(), true, 0); + } + if index >= 2 { + let dots = raw.chars().count().min(24); + return ("●".repeat(dots), false, dots); + } + // 从尾部收集不超过 max_cols 列的字符(中文占 2 列)。 + let mut cols = 0usize; + let mut chars: Vec = Vec::new(); + for ch in raw.chars().rev() { + let w = ch.width().unwrap_or(1).max(1); + if cols + w > max_cols { + break; + } + cols += w; + chars.push(ch); + } + chars.reverse(); + (chars.into_iter().collect(), false, cols) +} + +/// 动作行的 [立即推送, 立即拉取] 按钮矩形。 +fn sync_button_rects((rx, ry, _, rh): (f32, f32, f32, f32), scale: f32) -> [(f32, f32, f32, f32); 2] { + let s = |v: f32| v * scale; + let w = s(150.0); + let h = rh - s(12.0); + let y = ry + (rh - h) / 2.0; + [(rx, y, w, h), (rx + w + s(12.0), y, w, h)] +} + +/// 键位行的 keycap 矩形(quad 与 text 两个 pass 共用同一几何)。 +fn keymap_keycap_rect( + (rx, ry, rw, rh): (f32, f32, f32, f32), + label: &str, + cell_w: f32, + scale: f32, +) -> (f32, f32, f32, f32) { + let s = |v: f32| v * scale; + let cols: usize = label.chars().map(|c| c.width().unwrap_or(0)).sum(); + let cap_w = cols as f32 * cell_w + s(24.0); + let cap_h = rh - s(14.0); + (rx + rw - s(16.0) - cap_w, ry + (rh - cap_h) / 2.0, cap_w, cap_h) +} + +/// 键位行右侧的展示文本:(文本, 是否自定义, 是否有绑定)。捕获态的 +/// 「按下新按键…」由调用侧替换。 +fn keymap_row_value(view: &SettingsView, index: usize) -> (String, bool, bool) { + if view.keymap_capture == Some(index) { + // 按住修饰键时实时回显("Ctrl+…"),否则给占位 + 取消提示。 + let text = if view.keymap_capture_preview.is_empty() { + view.language.pick("按下新按键…(Esc 取消)", "Press new keys… (Esc cancels)").to_owned() + } else { + format!("{}…", view.keymap_capture_preview) + }; + return (text, false, false); + } + if index == keymap::QUICK_TERMINAL_ROW { + return ( + keymap::display_stored_combo(&view.quick_terminal_hotkey), + view.quick_terminal_hotkey != keymap::DEFAULT_QUICK_TERMINAL_HOTKEY, + true, + ); + } + let action_index = index - 1; + match view.keymap.get(action_index).and_then(|slot| slot.as_ref()) { + Some((combo, customized)) => (combo.clone(), *customized, true), + None => (view.language.pick("未绑定", "Unbound").to_owned(), false, false), + } +} + +/// Preview sample layout shared by the quad pass (cursor demo) and the text +/// pass (sample lines): 16px inner pad, 1.4× line pitch. +fn preview_line_y(top: f32, cell_h: f32, line: f32, scale: f32) -> f32 { + top + 16.0 * scale + line * (cell_h * 1.4) +} +/// Columns of "❯ " before the demo cursor on the preview's prompt line. +const PREVIEW_PROMPT_COLS: usize = 2; + +fn dropdown_selected_index(view: &SettingsView, dropdown: SettingsDropdown) -> Option { + match dropdown { + SettingsDropdown::Shell => view + .shells + .iter() + .position(|(id, _, _)| view.shell_id.as_deref() == Some(id.as_str())), + SettingsDropdown::Font => view.fonts.iter().position(|family| family == &view.font_family), + SettingsDropdown::BackgroundFit => { + BACKGROUND_FIT_OPTIONS.iter().position(|fit| *fit == view.background_image_fit) + }, + SettingsDropdown::BackgroundAlignment => BACKGROUND_ALIGNMENT_OPTIONS + .iter() + .position(|alignment| *alignment == view.background_image_alignment), + SettingsDropdown::Language => { + LANGUAGE_OPTIONS.iter().position(|preference| *preference == view.language_preference) + }, + SettingsDropdown::Accept => ACCEPT_OPTIONS.iter().position(|key| *key == view.accept), + SettingsDropdown::CursorShape => { + CURSOR_SHAPE_OPTIONS.iter().position(|shape| *shape == view.cursor_shape) + }, + SettingsDropdown::BackgroundColor => view + .background + .and_then(|current| BACKGROUND_SWATCHES.iter().position(|color| *color == current)), + } +} + +fn dropdown_hover_index(hover: SettingsHit, dropdown: SettingsDropdown) -> Option { + match (dropdown, hover) { + (SettingsDropdown::Shell, SettingsHit::ShellPickerRow(index)) => Some(index), + (SettingsDropdown::Font, SettingsHit::FontPickerRow(index)) => Some(index), + (SettingsDropdown::BackgroundFit, SettingsHit::FitOption(index)) => Some(index), + (SettingsDropdown::BackgroundAlignment, SettingsHit::AlignOption(index)) => Some(index), + (SettingsDropdown::Language, SettingsHit::Language(preference)) => { + LANGUAGE_OPTIONS.iter().position(|option| *option == preference) + }, + (SettingsDropdown::Accept, SettingsHit::AcceptOption(index)) => Some(index), + (SettingsDropdown::CursorShape, SettingsHit::CursorShapeOption(index)) => Some(index), + _ => None, + } +} + +/// Push the Settings tab's background quads, navigation, rows and controls. +pub(super) fn push_quads( + view: &SettingsView, + quads: &mut Vec, + size: &SizeInfo, + scale: f32, +) { + let s = |v: f32| v * scale; + let sk = view.theme.skin(); + + let geometry = settings_geometry(size, scale, view.area, view.scroll, view.hidden_hosts.len()); + let (px, py, pw, ph) = geometry.popup; + // Header band height: the title row sits above the content, and the header + // separator + big title are all measured from here. + let header_h = s(72.0); + // Scrolled content is clipped EXACTLY at the viewport edges: quads that + // cross the fixed header separator or the popup's bottom edge are cut at + // the line via [`UiQuad::clip_y`] (uv-remapped, so rounded corners and + // glows are truncated mid-shape instead of bleeding past the hairline). + let clip_top = geometry.content_top; + let clip_bot = py + ph - s(6.0); + let clip = |quads: &mut Vec, quad: UiQuad| { + if let Some(quad) = quad.clip_y(clip_top, clip_bot) { + quads.push(quad); + } + }; + // 通用组件(widgets)不感知视口裁剪:输出先落到 staged,再统一过 clip。 + let mut staged: Vec = Vec::new(); + + // The page is flush with the active tab card. No veil, drop shadow or + // second window outline: depth belongs to the app shell, not this page. + quads.push(UiQuad::solid(px, py, pw, ph, s(12.0), sk.panel)); + let (side_x, side_y, side_w, side_h) = geometry.sidebar; + + // Sidebar: no fill of its own — just a hairline separator on its right + // edge. Hierarchy comes from the nav rows, not a competing surface. + quads.push(UiQuad::solid( + side_x + side_w - s(1.0), + side_y + s(16.0), + s(1.0), + side_h - s(32.0), + 0.0, + sk.hairline, + )); + + // Header separator under the panel title row, only in the content area + // (right of the sidebar). The sidebar's own separator runs full height. + let content_x = side_x + side_w; + quads.push(UiQuad::solid( + content_x, + py + header_h, + px + pw - content_x - s(1.0), + s(1.0), + 0.0, + sk.hairline, + )); + + // Sidebar navigation: the active row is a floating pill — a soft accent + // wash inside a hairline accent border (design language: no accent bar, + // no vertical line); hover stays a quiet wash. + let section = view.section; + for (nav_section, nx, ny, nw, nh) in geometry.nav { + if nav_section == section { + quads.push(UiQuad::solid( + nx - s(1.0), + ny - s(1.0), + nw + s(2.0), + nh + s(2.0), + s(9.0), + Rgba::new(sk.accent.r, sk.accent.g, sk.accent.b, 40), + )); + quads.push(UiQuad::solid(nx, ny, nw, nh, s(8.0), sk.panel)); + quads.push(UiQuad::solid(nx, ny, nw, nh, s(8.0), sk.accent_soft)); + } else if view.hover == SettingsHit::Nav(nav_section) { + quads.push(UiQuad::solid(nx, ny, nw, nh, s(8.0), sk.hover)); + } + } + + // Reset: a quiet ghost button in the header (hairline, no fill until hover). + { + let (rx, ry, rw, rh) = geometry.reset; + quads.push(UiQuad::solid( + rx - s(1.0), + ry - s(1.0), + rw + s(2.0), + rh + s(2.0), + s(9.0), + sk.hairline, + )); + quads.push(UiQuad::solid(rx, ry, rw, rh, s(8.0), sk.surface)); + if view.hover == SettingsHit::Reset { + quads.push(UiQuad::solid(rx, ry, rw, rh, s(8.0), sk.hover)); + } + } + + // One framed group of rows: a hairline border around the whole block and + // hairline separators between rows — the block reads as ONE plate (the + // design sheet's `.settings-list`), not a stack of separate pills. Rows + // stay transparent; hover marks the row with an inset rounded wash. + let row_h = geometry.background.3; + let group_frame = |quads: &mut Vec, first_row: (f32, f32, f32, f32), rows: usize| { + let (gx, gy, gw, _) = first_row; + let gh = rows as f32 * row_h; + clip( + quads, + UiQuad::solid(gx - s(1.0), gy - s(1.0), gw + s(2.0), gh + s(2.0), s(9.0), sk.hairline), + ); + clip(quads, UiQuad::solid(gx, gy, gw, gh, s(8.0), sk.panel)); + for i in 1..rows { + clip( + quads, + UiQuad::solid( + gx + s(1.0), + gy + i as f32 * row_h, + gw - s(2.0), + s(1.0), + 0.0, + sk.hairline, + ), + ); + } + }; + let row_hover = |quads: &mut Vec, rect: (f32, f32, f32, f32), hovered: bool| { + if hovered { + let (rx, ry, rw, rh) = rect; + clip( + quads, + UiQuad::solid(rx + s(2.0), ry + s(2.0), rw - s(4.0), rh - s(4.0), s(6.0), sk.hover), + ); + } + }; + // Widget wrappers: stage → viewport-clip → push. Every multi-option row + // shares ONE combobox component (user ruling 2026-07-23), hover/press + // feedback included, so no page ever hand-rolls its own control again. + let combobox = + |quads: &mut Vec, staged: &mut Vec, row, hot: bool, open: bool| { + widgets::push_combobox(staged, widgets::combobox_rect(row, scale), scale, &sk, hot, open); + for quad in staged.drain(..) { + clip(quads, quad); + } + }; + let slider = |quads: &mut Vec, staged: &mut Vec, hit, value: f32, hot: bool| { + widgets::push_slider(staged, hit, value, scale, &sk, hot); + for quad in staged.drain(..) { + clip(quads, quad); + } + }; + let toggle = |quads: &mut Vec, staged: &mut Vec, row, on: bool| { + widgets::push_toggle(staged, row, on, scale, &sk); + for quad in staged.drain(..) { + clip(quads, quad); + } + }; + + match section { + NebulaSettingsSection::Appearance => { + // ---- Live preview card: configure → immediately see ---- + // Terminal colors, font family/size (text pass), and the demo + // cursor all read the same state the real grid uses. + { + let (vx, vy, vw, vh) = geometry.preview; + clip( + quads, + UiQuad::solid( + vx - s(1.0), + vy - s(1.0), + vw + s(2.0), + vh + s(2.0), + s(11.0), + sk.hairline, + ), + ); + clip( + quads, + UiQuad::solid( + vx, + vy, + vw, + vh, + s(10.0), + Rgba::new(view.preview_bg.r, view.preview_bg.g, view.preview_bg.b, 255), + ), + ); + // Demo cursor on the prompt line, driven by the REAL shape + + // blink settings (shares the UI caret's 500ms phase). + if !view.cursor_blink || super::caret_blink_on() { + let cell_w = size.cell_width(); + let cell_h = size.cell_height(); + let cursor_x = vx + s(16.0) + PREVIEW_PROMPT_COLS as f32 * cell_w; + let cursor_y = preview_line_y(vy, cell_h, 2.0, scale); + let ink = + Rgba::new(view.preview_fg.r, view.preview_fg.g, view.preview_fg.b, 235); + let bg = + Rgba::new(view.preview_bg.r, view.preview_bg.g, view.preview_bg.b, 255); + let stroke = (1.5 * scale).max(1.0); + let beam_w = (2.0 * scale).max(1.0); + match view.cursor_shape { + CursorShape::Beam => { + clip(quads, UiQuad::solid(cursor_x, cursor_y, beam_w, cell_h, 0.0, ink)); + }, + CursorShape::Underline => { + clip( + quads, + UiQuad::solid( + cursor_x, + cursor_y + cell_h - beam_w, + cell_w, + beam_w, + 0.0, + ink, + ), + ); + }, + CursorShape::HollowBlock => { + clip(quads, UiQuad::solid(cursor_x, cursor_y, cell_w, cell_h, 0.0, ink)); + clip( + quads, + UiQuad::solid( + cursor_x + stroke, + cursor_y + stroke, + cell_w - 2.0 * stroke, + cell_h - 2.0 * stroke, + 0.0, + bg, + ), + ); + }, + CursorShape::Hidden => {}, + CursorShape::Block => { + clip(quads, UiQuad::solid(cursor_x, cursor_y, cell_w, cell_h, 0.0, ink)); + }, + } + } + } + + // Theme cards are MINIATURE TERMINAL WINDOWS, each painted in its + // own theme's colors: shell_bg window shell, a rounded term_bg + // "terminal card" floating inside it (the real window's model, + // shrunk), and fake prompt/output lines in the theme's own inks. + // A flat panel swatch only answered "what color is the chrome"; + // the mini window answers what the picker is really asked: how do + // background, text and highlights look TOGETHER. (2026-07-28 用户 + // 裁定;窗控红绿灯明确不画——各平台窗控样式不同,预览不预设 + // 任何一家。) Selection = accent ring + halo; hover = 2px lift — + // no wash, so the preview colors stay true. + for (theme, ox, oy, ow, oh) in geometry.options { + let selected = theme == view.theme; + let hovered = view.hover == SettingsHit::Theme(theme); + let lift = if hovered && !selected { s(2.0) } else { 0.0 }; + let oy = oy - lift; + let stroke = if selected { + Rgba::new(sk.accent.r, sk.accent.g, sk.accent.b, 255) + } else { + sk.hairline + }; + let stroke_w = if selected { s(2.0) } else { s(1.0) }; + if selected { + // Selected card glows softly: the accent ring plus a + // diffuse halo, per the design sheet's lit-control look. + clip( + quads, + UiQuad::glow( + ox - s(14.0), + oy - s(14.0), + ow + s(28.0), + oh + s(28.0), + Rgba::new(sk.accent.r, sk.accent.g, sk.accent.b, 66), + ), + ); + } else if hovered { + // Hover halo: same shape, fainter — enough 辉光 to read + // as "lit up" without competing with the selected card. + clip( + quads, + UiQuad::glow( + ox - s(12.0), + oy - s(10.0), + ow + s(24.0), + oh + s(26.0), + Rgba::new(sk.accent.r, sk.accent.g, sk.accent.b, 38), + ), + ); + } + clip( + quads, + UiQuad::solid( + ox - stroke_w, + oy - stroke_w, + ow + 2.0 * stroke_w, + oh + 2.0 * stroke_w, + s(9.0), + stroke, + ), + ); + let p = theme.palette(); + let ink = theme.card_ink(); + let shell = Rgba::new(p.shell_bg.r, p.shell_bg.g, p.shell_bg.b, 255); + clip(quads, UiQuad::solid(ox, oy, ow, oh, s(8.0), shell)); + // Inner terminal card. The taller top margin reads as a title + // bar without drawing one. + let (tx, ty) = (ox + s(10.0), oy + s(14.0)); + let (tw, th) = (ow - s(20.0), oh - s(22.0)); + let term = Rgba::new(p.term_bg.r, p.term_bg.g, p.term_bg.b, 255); + clip(quads, UiQuad::solid(tx, ty, tw, th, s(5.0), term)); + // Three fake lines as pill bars: a prompt command in fg (the + // `❯` itself is a real glyph, drawn in the text pass), two + // highlight tokens, a dim trailing line. Widths are fractions + // of the card so narrow cards keep the proportions. + let bar_h = s(3.0); + let line_x = tx + s(8.0); + let line_pitch = s(11.0); + let y0 = ty + s(8.0); + let inner_w = tw - s(16.0); + let bar = |x: f32, y: f32, w: f32, c: Rgb, a: u8| { + UiQuad::solid(x, y, w, bar_h, s(1.5), Rgba::new(c.r, c.g, c.b, a)) + }; + let prompt_w = s(9.0); // room the text-pass ❯ occupies + let cmd_w = inner_w * 0.42; + clip(quads, bar(line_x + prompt_w, y0, cmd_w, ink.fg, 230)); + // A block caret hugging the command's end, in the theme's + // accent — the one "alive" spark on the card. + let acc = theme.accent(); + clip( + quads, + UiQuad::solid( + line_x + prompt_w + cmd_w + s(3.0), + y0 - s(2.0), + s(3.0), + bar_h + s(4.0), + s(1.0), + Rgba::new(acc.r, acc.g, acc.b, 255), + ), + ); + // 第二行是各主题的品牌双色(edge_l→edge_r,即侧栏品牌 + // 渐变对):固定 ANSI green/blue 让 4 张暗卡 3 张亮卡两两 + // 同色(2026-07-28 用户反馈「预览颜色都一样」),身份色带 + // 才是卡片间唯一稳定的区分资产。 + clip( + quads, + bar( + line_x, + y0 + line_pitch, + inner_w * 0.28, + Rgb::new(p.edge_l.r, p.edge_l.g, p.edge_l.b), + 235, + ), + ); + clip( + quads, + bar( + line_x + inner_w * 0.28 + s(5.0), + y0 + line_pitch, + inner_w * 0.20, + Rgb::new(p.edge_r.r, p.edge_r.g, p.edge_r.b), + 235, + ), + ); + clip(quads, bar(line_x, y0 + 2.0 * line_pitch, inner_w * 0.55, ink.fg, 96)); + } + + group_frame(quads, geometry.system_theme, 1); + row_hover(quads, geometry.system_theme, view.hover == SettingsHit::SystemThemeToggle); + toggle(quads, &mut staged, geometry.system_theme, view.follow_system_theme); + + // 自定义背景和界面都使用连续分组,避免设置 Tab 内再次出现 + // 漂浮卡片语言。 + group_frame(quads, geometry.background, 6); + row_hover(quads, geometry.background, view.hover == SettingsHit::BackgroundColor); + // 背景色也是多选项设置:同一 combobox 组件,浮层换成色板+hex。 + combobox( + quads, + &mut staged, + geometry.background, + view.hover == SettingsHit::BackgroundColor, + view.dropdown == Some(SettingsDropdown::BackgroundColor), + ); + row_hover(quads, geometry.background_image, view.hover == SettingsHit::BackgroundImage); + if view.background_image.is_some() { + row_hover( + quads, + geometry.background_image_clear, + view.hover == SettingsHit::BackgroundImageClear, + ); + } + combobox( + quads, + &mut staged, + geometry.background_image_fit, + view.hover == SettingsHit::BackgroundImageFit, + view.dropdown == Some(SettingsDropdown::BackgroundFit), + ); + combobox( + quads, + &mut staged, + geometry.background_image_alignment, + view.hover == SettingsHit::BackgroundImageAlignment, + view.dropdown == Some(SettingsDropdown::BackgroundAlignment), + ); + slider( + quads, + &mut staged, + geometry.background_image_opacity_slider, + view.background_image_opacity, + view.hover == SettingsHit::BackgroundImageOpacitySlider + || view.dragging_opacity == Some(SettingsOpacityTarget::BackgroundImage), + ); + row_hover( + quads, + geometry.background_image_cover_chrome, + view.hover == SettingsHit::BackgroundImageCoverChrome, + ); + toggle( + quads, + &mut staged, + geometry.background_image_cover_chrome, + view.background_image_cover_chrome, + ); + + // 光标组:形状下拉 + 闪烁开关。 + group_frame(quads, geometry.cursor_shape_row, 2); + row_hover( + quads, + geometry.cursor_shape_row, + view.hover == SettingsHit::CursorShapeDropdown, + ); + combobox( + quads, + &mut staged, + geometry.cursor_shape_row, + view.hover == SettingsHit::CursorShapeDropdown, + view.dropdown == Some(SettingsDropdown::CursorShape), + ); + row_hover(quads, geometry.cursor_blink_row, view.hover == SettingsHit::CursorBlinkToggle); + toggle(quads, &mut staged, geometry.cursor_blink_row, view.cursor_blink); + + // 界面组:语言(同一通用下拉组件)+ 终端不透明度。 + group_frame(quads, geometry.language_row, 2); + row_hover(quads, geometry.language_row, view.hover == SettingsHit::LanguageDropdown); + combobox( + quads, + &mut staged, + geometry.language_row, + view.hover == SettingsHit::LanguageDropdown, + view.dropdown == Some(SettingsDropdown::Language), + ); + slider( + quads, + &mut staged, + geometry.opacity_slider, + view.opacity, + view.hover == SettingsHit::OpacitySlider + || view.dragging_opacity == Some(SettingsOpacityTarget::Terminal), + ); + }, + NebulaSettingsSection::Profiles => { + // 终端组:Shell / 启动目录 / 字体+字号。下拉列表是浮层,行的 + // hairline 分组保持固定,不再被展开的列表推开。 + group_frame(quads, geometry.shell, 1); + group_frame(quads, geometry.startup_directory, 1); + group_frame(quads, geometry.font, 2); + group_frame(quads, geometry.fetch, 2); + group_frame(quads, geometry.ghost, 2); + group_frame(quads, geometry.open_config_file, 1); + if geometry.hidden_host_count > 0 { + group_frame(quads, geometry.hidden_host_row0, geometry.hidden_host_count); + for index in 0..geometry.hidden_host_count { + let mut rect = geometry.hidden_host_row0; + rect.1 += index as f32 * rect.3; + row_hover(quads, rect, view.hover == SettingsHit::RestoreHiddenSsh(index)); + } + } + for (hit, rect) in [ + (SettingsHit::ShellCycle, geometry.shell), + (SettingsHit::StartupDirectory, geometry.startup_directory), + (SettingsHit::FontCycle, geometry.font), + (SettingsHit::FetchToggle, geometry.fetch), + (SettingsHit::PowerlineToggle, geometry.powerline), + (SettingsHit::GhostToggle, geometry.ghost), + (SettingsHit::AcceptCycle, geometry.accept), + (SettingsHit::OpenConfigFile, geometry.open_config_file), + ] { + row_hover(quads, rect, view.hover == hit); + } + if view.startup_directory.is_some() { + row_hover( + quads, + geometry.startup_directory_clear, + view.hover == SettingsHit::StartupDirectoryClear, + ); + } + combobox( + quads, + &mut staged, + geometry.shell, + view.hover == SettingsHit::ShellCycle, + view.dropdown == Some(SettingsDropdown::Shell), + ); + combobox( + quads, + &mut staged, + geometry.font, + view.hover == SettingsHit::FontCycle, + view.dropdown == Some(SettingsDropdown::Font), + ); + widgets::push_spinner( + &mut staged, + geometry.font_size_row, + scale, + &sk, + view.hover == SettingsHit::FontSizeUp, + view.hover == SettingsHit::FontSizeDown, + ); + for quad in staged.drain(..) { + clip(quads, quad); + } + combobox( + quads, + &mut staged, + geometry.accept, + view.hover == SettingsHit::AcceptCycle, + view.dropdown == Some(SettingsDropdown::Accept), + ); + // Boolean rows render a real switch instead of an "On/Off" string. + for (rect, on) in [ + (geometry.fetch, view.fetch), + (geometry.powerline, view.powerline), + (geometry.ghost, view.ghost), + ] { + toggle(quads, &mut staged, rect, on); + } + }, + NebulaSettingsSection::Interaction => { + group_frame(quads, geometry.copy_on_select, 1); + row_hover( + quads, + geometry.copy_on_select, + view.hover == SettingsHit::CopyOnSelectToggle, + ); + toggle(quads, &mut staged, geometry.copy_on_select, view.copy_on_select); + group_frame(quads, geometry.cjk_bold, 1); + row_hover(quads, geometry.cjk_bold, view.hover == SettingsHit::CjkBoldToggle); + toggle(quads, &mut staged, geometry.cjk_bold, view.cjk_bold_regular); + }, + NebulaSettingsSection::Keymap => { + let cell_w = size.cell_width(); + group_frame(quads, geometry.keymap_row0, keymap::editable_row_count()); + group_frame(quads, geometry.keymap_readonly_row0, keymap::READONLY_ROWS.len()); + let (kx, ky, kw, kh) = geometry.keymap_row0; + for index in 0..keymap::editable_row_count() { + let rect = (kx, ky + index as f32 * geometry.keymap_row_h, kw, kh); + row_hover(quads, rect, view.hover == SettingsHit::KeymapRow(index)); + // Keycap 底座:有效键围 hairline 徽标;捕获中的行换 accent + // 描边 + 软填充提示「正在等待按键」;未绑定行不画底座。 + let capturing = view.keymap_capture == Some(index); + let (label, _, bound) = keymap_row_value(view, index); + let cap = keymap_keycap_rect(rect, &label, cell_w, scale); + let (cx, cy, cw, ch) = cap; + if capturing { + clip( + quads, + UiQuad::solid( + cx - s(1.0), + cy - s(1.0), + cw + s(2.0), + ch + s(2.0), + s(7.0), + Rgba::new(sk.accent.r, sk.accent.g, sk.accent.b, 255), + ), + ); + clip(quads, UiQuad::solid(cx, cy, cw, ch, s(6.0), sk.panel)); + clip(quads, UiQuad::solid(cx, cy, cw, ch, s(6.0), sk.accent_soft)); + } else if bound { + // 图8 键帽规范:组合键拆逐键 chip(几何与文字 pass 同 + // 源自 keycap::layout_combo)。 + let combo = super::ui::keycap::layout_combo( + &label, + rect.0 + rect.2 - s(16.0), + rect.1 + rect.3 / 2.0, + cell_w, + scale, + ); + let mut chip_quads = Vec::new(); + super::ui::keycap::push_combo(&mut chip_quads, &sk, &combo, scale); + for quad in chip_quads { + clip(quads, quad); + } + } + } + }, + NebulaSettingsSection::Advanced => { + group_frame(quads, geometry.keep_session, 1); + row_hover(quads, geometry.keep_session, view.hover == SettingsHit::KeepSessionToggle); + toggle(quads, &mut staged, geometry.keep_session, view.keep_session); + + if SHOW_WEBDAV_SYNC_SETTINGS { + // ---- 同步(WebDAV,spec 003):4 输入行 + 自动拉取开关 ---- + group_frame(quads, geometry.sync_rows[0], 5); + let cell_w = size.cell_width(); + for (index, row) in geometry.sync_rows.iter().enumerate() { + row_hover(quads, *row, view.hover == SettingsHit::SyncInput(index)); + let (ix, iy, iw, ih) = sync_input_rect(*row, scale); + let focused = view.sync_focus == Some(index); + let border = if focused { sk.accent } else { sk.ink_dim }; + let border_alpha = if focused { 255 } else { 90 }; + clip( + quads, + UiQuad::solid( + ix - s(1.0), + iy - s(1.0), + iw + s(2.0), + ih + s(2.0), + s(8.0), + Rgba::new(border.r, border.g, border.b, border_alpha), + ), + ); + clip(quads, UiQuad::solid(ix, iy, iw, ih, s(7.0), sk.surface)); + if focused && super::caret_blink_on() { + let max_cols = (((iw - s(24.0)) / cell_w) as usize).max(1); + let (_, placeholder, cols) = sync_input_display(view, index, max_cols); + let cols = if placeholder { 0 } else { cols }; + let caret_h = ih - s(10.0); + clip( + quads, + UiQuad::solid( + (ix + s(12.0) + cols as f32 * cell_w).min(ix + iw - s(6.0)), + iy + (ih - caret_h) / 2.0, + (1.5 * scale).max(1.0), + caret_h, + 0.0, + Rgba::new(sk.accent.r, sk.accent.g, sk.accent.b, 255), + ), + ); + } + } + row_hover( + quads, + geometry.sync_auto_pull, + view.hover == SettingsHit::SyncAutoPullToggle, + ); + toggle(quads, &mut staged, geometry.sync_auto_pull, view.sync_auto_pull); + + // 动作行:两个独立按钮(同步中变灰、不吃 hover)。 + let [push_rect, pull_rect] = sync_button_rects(geometry.sync_actions, scale); + for (rect, hit) in [ + (push_rect, SettingsHit::SyncPushButton), + (pull_rect, SettingsHit::SyncPullButton), + ] { + let (bx, by, bw, bh) = rect; + let hot = view.hover == hit && !view.sync_busy; + clip( + quads, + UiQuad::solid( + bx - s(1.0), + by - s(1.0), + bw + s(2.0), + bh + s(2.0), + s(9.0), + sk.hairline, + ), + ); + clip( + quads, + UiQuad::solid( + bx, + by, + bw, + bh, + s(8.0), + if hot { sk.hover } else { sk.panel }, + ), + ); + } + } + }, + } + + // Overlay scrollbar on the content viewport's right edge, only when the + // section actually overflows (same style as the pane scrollbar: thin + // rounded thumb, no track). + let content_h = match section { + NebulaSettingsSection::Appearance => geometry.appearance_h, + NebulaSettingsSection::Profiles => geometry.profiles_h, + NebulaSettingsSection::Interaction => geometry.interaction_h, + NebulaSettingsSection::Keymap => geometry.keymap_h, + NebulaSettingsSection::Advanced => geometry.advanced_h, + }; + let viewport_h = settings_viewport_h(ph, scale); + if content_h > viewport_h { + let max_scroll = content_h - viewport_h; + let frac = (view.scroll / max_scroll).clamp(0.0, 1.0); + let track_h = viewport_h - s(12.0); + let thumb_h = (track_h * viewport_h / content_h).max(s(28.0)); + let ty = clip_top + s(6.0) + (track_h - thumb_h) * frac; + let tx = px + pw - s(7.0); + quads.push(UiQuad::solid( + tx, + ty, + s(4.0), + thumb_h, + s(2.0), + sk.scrollbar_thumb.with_alpha(0.45), + )); + } +} + +/// The floating dropdown option list. `draw_chrome` paints these AFTER the +/// base text pass (a separate `draw_ui` call), so page labels can never bleed +/// through the popup plate — the same modal layering rule the command palette +/// needed. +pub(super) fn push_popup_quads( + view: &SettingsView, + quads: &mut Vec, + size: &SizeInfo, + scale: f32, +) { + let Some(dropdown) = view.dropdown else { return }; + let s = |v: f32| v * scale; + let sk = view.theme.skin(); + let geometry = settings_geometry(size, scale, view.area, view.scroll, view.hidden_hosts.len()); + // 背景色专用浮层:色板网格 + hex 输入框(几何与 hit 同源)。 + if dropdown == SettingsDropdown::BackgroundColor { + if view.section != NebulaSettingsSection::Appearance { + return; + } + let popup = background_color_popup(&geometry, scale); + let (px2, py2, pw2, ph2) = popup.rect; + // 与通用 combobox 浮层同一套皮肤:柔和投影 + hairline + 不透明面板。 + quads.push(UiQuad::glow( + px2 - s(14.0), + py2 - s(10.0), + pw2 + s(28.0), + ph2 + s(26.0), + Rgba::new(0, 0, 0, 70), + )); + quads.push(UiQuad::solid( + px2 - s(1.0), + py2 - s(1.0), + pw2 + s(2.0), + ph2 + s(2.0), + s(11.0), + sk.hairline, + )); + let mut plate = sk.panel; + plate.a = 255; + quads.push(UiQuad::solid(px2, py2, pw2, ph2, s(10.0), plate)); + quads.push(UiQuad::solid(px2, py2, pw2, ph2, s(10.0), sk.surface)); + + let selected = dropdown_selected_index(view, dropdown); + for (index, rect) in popup.swatch.iter().enumerate() { + let (sx, sy, sw2, sh2) = *rect; + let hovered = view.hover == SettingsHit::BackgroundSwatch(index); + if selected == Some(index) || hovered { + let ring = if selected == Some(index) { sk.accent } else { sk.ink_dim }; + quads.push(UiQuad::solid( + sx - s(2.0), + sy - s(2.0), + sw2 + s(4.0), + sh2 + s(4.0), + s(8.0), + Rgba::new(ring.r, ring.g, ring.b, 255), + )); + } + // 每格带 1px hairline 描边:亮色格在浅色面板上也有边界。 + quads.push(UiQuad::solid( + sx - s(1.0), + sy - s(1.0), + sw2 + s(2.0), + sh2 + s(2.0), + s(7.0), + sk.hairline, + )); + let color = BACKGROUND_SWATCHES[index]; + quads.push(UiQuad::solid(sx, sy, sw2, sh2, s(6.0), Rgba::new(color.r, color.g, color.b, 255))); + } + + // hex 输入框:聚焦态用主题色描边;caret 与 UI 光标共用 500ms 相位。 + let (hx, hy, hw, hh) = popup.hex; + let focused = view.bg_hex_active; + let border = if focused { sk.accent } else { sk.ink_dim }; + let border_alpha = if focused { 255 } else { 120 }; + quads.push(UiQuad::solid( + hx - s(1.0), + hy - s(1.0), + hw + s(2.0), + hh + s(2.0), + s(8.0), + Rgba::new(border.r, border.g, border.b, border_alpha), + )); + quads.push(UiQuad::solid(hx, hy, hw, hh, s(7.0), sk.surface)); + if focused && super::caret_blink_on() { + let cell_w = size.cell_width(); + let caret_x = hx + s(12.0) + view.bg_hex_input.chars().count() as f32 * cell_w; + let caret_h = hh - s(12.0); + quads.push(UiQuad::solid( + caret_x.min(hx + hw - s(6.0)), + hy + (hh - caret_h) / 2.0, + (1.5 * scale).max(1.0), + caret_h, + 0.0, + Rgba::new(sk.accent.r, sk.accent.g, sk.accent.b, 255), + )); + } + return; + } + let (_, py, _, ph) = geometry.popup; + let Some((anchor, count)) = dropdown_anchor( + &geometry, + view.section, + dropdown, + view.shells.len(), + view.fonts.len() + 1, + scale, + ) else { + return; + }; + let popup = + widgets::combobox_popup_rect(anchor, count, scale, geometry.content_top, py + ph - s(6.0)); + let selected = dropdown_selected_index(view, dropdown); + let hover = dropdown_hover_index(view.hover, dropdown); + widgets::push_combobox_popup(quads, popup, count, selected, hover, scale, &sk); + if let Some(index) = selected { + let (rx, ry, rw, rh) = widgets::popup_row_rect(popup, index, scale); + icons::push_check( + quads, + rx + rw - s(16.0), + ry + rh * 0.5, + scale, + Rgba::new(sk.accent.r, sk.accent.g, sk.accent.b, 255), + ); + } +} + +/// Option labels for the floating dropdown; returns shell brand-icon draw +/// requests like [`draw_text`]. Must run AFTER `push_popup_quads`'s quads are +/// painted so the labels sit on top of the popup plate. +pub(super) fn draw_popup_text( + view: &SettingsView, + r: &mut Renderer, + gc: &mut GlyphCache, + size: &SizeInfo, + scale: f32, +) -> Vec<(String, (f32, f32, f32, f32))> { + let mut icon_draws = Vec::new(); + let Some(dropdown) = view.dropdown else { return icon_draws }; + let s = |v: f32| v * scale; + let sk = view.theme.skin(); + let language = view.language; + let cell_w = size.cell_width(); + let cell_h = size.cell_height(); + let geometry = settings_geometry(size, scale, view.area, view.scroll, view.hidden_hosts.len()); + // 背景色浮层:hex 草稿(或占位提示)画进输入框,色板格无文字。 + if dropdown == SettingsDropdown::BackgroundColor { + if view.section != NebulaSettingsSection::Appearance { + return icon_draws; + } + let popup = background_color_popup(&geometry, scale); + let (hx, hy, hw, hh) = popup.hex; + let ty = hy + (hh - cell_h) / 2.0; + if view.bg_hex_input.is_empty() { + r.draw_chrome_text(size, hx + s(12.0), ty, sk.ink_dim, "#RRGGBB", gc); + } else { + r.draw_chrome_text(size, hx + s(12.0), ty, sk.ink, &view.bg_hex_input, gc); + } + // 输入框右侧给一个动作提示(回车应用)。 + let hint = language.pick("回车应用", "Enter applies"); + let hint_cols: usize = hint.chars().map(|c| c.width().unwrap_or(0)).sum(); + let hint_x = hx + hw - s(12.0) - hint_cols as f32 * cell_w; + if hint_x > hx + s(12.0) + 9.0 * cell_w { + r.draw_chrome_text(size, hint_x, ty, sk.ink_dim, hint, gc); + } + return icon_draws; + } + let (_, py, _, ph) = geometry.popup; + let Some((anchor, count)) = dropdown_anchor( + &geometry, + view.section, + dropdown, + view.shells.len(), + view.fonts.len() + 1, + scale, + ) else { + return icon_draws; + }; + let popup = + widgets::combobox_popup_rect(anchor, count, scale, geometry.content_top, py + ph - s(6.0)); + let selected = dropdown_selected_index(view, dropdown); + for index in 0..count { + let (rx, ry, rw, rh) = widgets::popup_row_rect(popup, index, scale); + let ty = ry + (rh - cell_h) / 2.0; + // Shell rows lead with the brand icon; every other list is text-only. + let mut text_x = rx + s(12.0); + let label: String = match dropdown { + SettingsDropdown::Shell => { + let Some((id, name, program)) = view.shells.get(index) else { continue }; + icon_draws + .push((id.clone(), (rx + s(8.0), ry + (rh - s(24.0)) / 2.0, s(24.0), s(24.0)))); + text_x = rx + s(40.0); + if program.is_empty() { name.clone() } else { format!("{name} · {program}") } + }, + SettingsDropdown::Font => match view.fonts.get(index) { + Some(family) => family.clone(), + None => language.pick("+ 导入字体…", "+ Import font...").to_owned(), + }, + SettingsDropdown::BackgroundFit => { + background_image_fit_label(BACKGROUND_FIT_OPTIONS[index], language).to_owned() + }, + SettingsDropdown::BackgroundAlignment => { + background_image_alignment_label(BACKGROUND_ALIGNMENT_OPTIONS[index], language) + .to_owned() + }, + SettingsDropdown::Language => { + language_label(LANGUAGE_OPTIONS[index], language).to_owned() + }, + SettingsDropdown::Accept => accept_label(ACCEPT_OPTIONS[index], language).to_owned(), + SettingsDropdown::CursorShape => { + cursor_shape_label(CURSOR_SHAPE_OPTIONS[index], language).to_owned() + }, + // 上方特判提前返回;此臂只为 match 完备。 + SettingsDropdown::BackgroundColor => continue, + }; + let import_row = + matches!(dropdown, SettingsDropdown::Font) && view.fonts.get(index).is_none(); + let color = if selected == Some(index) || import_row { sk.accent } else { sk.ink }; + let max_chars = + (((rx + rw - s(28.0)) - text_x).max(cell_w) / cell_w).floor().max(1.0) as usize; + let label = truncate_tab_label(&label, max_chars); + r.draw_chrome_text(size, text_x, ty, color, &label, gc); + } + icon_draws +} + +/// Draw a chrome title at `mult`× the terminal font size. Rasterized at the +/// REAL target size (`draw_doc_text`), not GPU-stretched from the base atlas — +/// stretching is what made every modal title fuzzy with ragged edges. The +/// title still grows down and to the right from the (x, y) top-left anchor. +fn draw_big_text( + r: &mut Renderer, + gc: &mut GlyphCache, + size: &SizeInfo, + _scale: f32, + x: f32, + y: f32, + mult: f32, + ink: Rgb, + text: &str, +) { + r.draw_ui_text(size, x, y, mult, ink, nebula_terminal::term::cell::Flags::empty(), text, gc); +} + +/// A group heading inside the content pane: clearly larger than row labels +/// (strict size hierarchy: page title 1.6× > group 1.2× > rows 1.0×) and in +/// the strong ink. One helper so every group shares one size/rhythm. +fn section_title( + r: &mut Renderer, + gc: &mut GlyphCache, + size: &SizeInfo, + scale: f32, + sk: &Skin, + x: f32, + y: f32, + text: &str, +) { + draw_big_text(r, gc, size, scale, x, y, 1.2, sk.ink_strong, text); +} + +/// Draw a settings row: a left-aligned label and a right-aligned, truncated +/// value, both vertically centered. Labels are single-line by design — any +/// explanation must fit the label itself (rows with obvious semantics carry +/// no description at all). Inks come from the active theme's [`Skin`]. +#[allow(clippy::too_many_arguments)] +fn row_label( + r: &mut Renderer, + gc: &mut GlyphCache, + size: &SizeInfo, + scale: f32, + sk: &Skin, + (rx, ry, rw, rh): (f32, f32, f32, f32), + k: &str, + v: &str, + value_ink: Rgb, +) { + row_label_with_right_inset(r, gc, size, scale, sk, (rx, ry, rw, rh), k, v, value_ink, 0.0); +} + +#[allow(clippy::too_many_arguments)] +fn row_label_with_right_inset( + r: &mut Renderer, + gc: &mut GlyphCache, + size: &SizeInfo, + scale: f32, + sk: &Skin, + (rx, ry, rw, rh): (f32, f32, f32, f32), + k: &str, + v: &str, + value_ink: Rgb, + right_inset: f32, +) { + let s = |val: f32| val * scale; + let cell_w = size.cell_width(); + let cell_h = size.cell_height(); + let ty = ry + (rh - cell_h) / 2.0; + r.draw_chrome_text(size, rx + s(16.0), ty, sk.ink, k, gc); + let value_left = rx + rw * 0.42; + let value_right = rx + rw - s(16.0) - right_inset; + let max_chars = ((value_right - value_left).max(cell_w) / cell_w).floor().max(1.0) as usize; + let value = truncate_tab_label(v, max_chars); + let value_cols: usize = value.chars().map(|c| c.width().unwrap_or(0)).sum(); + let vx = value_right - value_cols as f32 * cell_w; + r.draw_chrome_text(size, vx.max(value_left), ty, value_ink, &value, gc); +} + +/// Draw the Settings tab's text labels on top of its quads. +pub(super) fn draw_text( + view: &SettingsView, + r: &mut Renderer, + gc: &mut GlyphCache, + size: &SizeInfo, + scale: f32, +) -> Vec<(String, (f32, f32, f32, f32))> { + let s = |v: f32| v * scale; + let cell_w = size.cell_width(); + let cell_h = size.cell_height(); + let sk = view.theme.skin(); + let language = view.language; + + let geometry = settings_geometry(size, scale, view.area, view.scroll, view.hidden_hosts.len()); + // Kept for parity with [`draw_popup_text`]'s shell icons; the base page + // currently stages no icon draws of its own. + let icon_draws = Vec::new(); + let (px, py, _pw, ph) = geometry.popup; + let (content_x, content_y, _content_w, _) = geometry.content; + // Text has no scissor, so unlike the quad pass (which cuts quads at the + // viewport edges) a text block is drawn only when it fits ENTIRELY inside + // the viewport — a glyph must never cross the header hairline. + let clip_top = geometry.content_top; + let clip_bot = py + ph - s(6.0); + let visible = |ry: f32, rh: f32| ry >= clip_top && ry + rh <= clip_bot; + // 通用 combobox 的当前值:控件框内左对齐,截断在 chevron 井之前。 + let combobox_value = |r: &mut Renderer, + gc: &mut GlyphCache, + row: (f32, f32, f32, f32), + value: &str, + ink: Rgb| { + let rect = widgets::combobox_rect(row, scale); + let tx = widgets::combobox_text_x(rect, scale); + let right = widgets::combobox_text_right(rect, scale); + let max_chars = ((right - tx).max(cell_w) / cell_w).floor().max(1.0) as usize; + let value = truncate_tab_label(value, max_chars); + r.draw_chrome_text(size, tx, rect.1 + (rect.3 - cell_h) / 2.0, ink, &value, gc); + }; + // Group titles hang 42px above their first row (title + 16px gap) and + // scroll with it. + let group_y = |row_y: f32| row_y - s(42.0); + let title_h = s(26.0); + + // Brand title in the sidebar header. Drawn large via the scaled-glyph path + // so it anchors the panel instead of reading as just another row label. + draw_big_text( + r, + gc, + size, + scale, + px + s(24.0), + py + s(22.0), + 1.5, + sk.ink_strong, + language.pick("Nebula 设置", "Nebula Settings"), + ); + { + // Center the reset label inside its ghost button. + let (rx, ry, rw, rh) = geometry.reset; + let label = language.pick("恢复默认设置", "Restore defaults"); + let cols: usize = label.chars().map(|c| c.width().unwrap_or(0)).sum(); + let tx = rx + (rw - cols as f32 * cell_w) / 2.0; + r.draw_chrome_text(size, tx, ry + (rh - cell_h) / 2.0, sk.ink_dim, label, gc); + } + let section = view.section; + // Sidebar navigation labels — only the two wired-up sections. + for (nav_section, nx, ny, _nw, nh) in geometry.nav { + let active = nav_section == section; + let hovered = view.hover == SettingsHit::Nav(nav_section); + r.draw_chrome_text( + size, + nx + s(18.0), + ny + (nh - cell_h) / 2.0, + if active { + sk.accent + } else if hovered { + sk.ink + } else { + sk.ink_dim + }, + nav_section.label(view.language), + gc, + ); + } + // Content header: the big section title alone. (No subtitle — the nav + // label + title already say everything; the old dim sentence only added + // noise under the heading.) + draw_big_text( + r, + gc, + size, + scale, + content_x + s(24.0), + content_y + s(20.0), + 1.6, + sk.ink_strong, + section.label(view.language), + ); + + match section { + NebulaSettingsSection::Appearance => { + // Live preview: sample lines in the CURRENT font/size on the + // CURRENT terminal colors; the demo cursor quad shares this + // layout via `preview_line_y`. + { + let (vx, vy, _, vh) = geometry.preview; + if visible(group_y(vy), title_h) { + section_title( + r, + gc, + size, + scale, + &sk, + content_x + s(24.0), + group_y(vy), + language.pick("预览", "Preview"), + ); + } + if visible(vy, vh) { + let fg = view.preview_fg; + r.draw_chrome_text( + size, + vx + s(16.0), + preview_line_y(vy, cell_h, 0.0, scale), + fg, + "user@nebula ~ $ nebula --version", + gc, + ); + let sample = format!( + "Nebula Terminal · {} · {:.0}px", + view.font_family, view.font_size_px + ); + r.draw_chrome_text( + size, + vx + s(16.0), + preview_line_y(vy, cell_h, 1.0, scale), + fg, + &sample, + gc, + ); + r.draw_chrome_text( + size, + vx + s(16.0), + preview_line_y(vy, cell_h, 2.0, scale), + fg, + "❯", + gc, + ); + } + } + let cards_y = geometry.options[0].2; + if visible(group_y(cards_y), title_h) { + section_title( + r, + gc, + size, + scale, + &sk, + content_x + s(24.0), + group_y(cards_y), + language.pick("主题", "Themes"), + ); + } + for (theme, ox, oy, ow, oh) in geometry.options { + let selected = theme == view.theme; + let hovered = view.hover == SettingsHit::Theme(theme); + // The label rides the card's 2px hover lift (quads do the + // same), and hides only when IT would cross the viewport edge + // — a half-clipped card keeps its fully-visible label. + let lift = if hovered && !selected { s(2.0) } else { 0.0 }; + // The mini window's prompt glyph, in the card's own accent — + // the quads pass carries the fake-output bars, this is the one + // real glyph. draw_ui_text rasterizes at the true tiny size + // (GPU-stretched atlas bitmaps would go fuzzy), and its cell + // top is placed so the glyph's midline meets the command bar's. + let prompt_y = oy + s(18.0) - lift; + if visible(prompt_y, cell_h) { + r.draw_ui_text( + size, + ox + s(18.0), + prompt_y, + 0.55, + theme.accent(), + nebula_terminal::term::cell::Flags::BOLD, + "❯", + gc, + ); + } + let text_y = oy + oh + s(12.0) - lift; + if !visible(text_y, cell_h) { + continue; + } + let card_label = theme.short_label(); + r.draw_chrome_text( + size, + ox + (ow - card_label.chars().count() as f32 * cell_w) / 2.0, + text_y, + if selected { + sk.accent + } else if hovered { + sk.ink + } else { + sk.ink_dim + }, + card_label, + gc, + ); + } + let (st_x, st_y, _, st_h) = geometry.system_theme; + if visible(group_y(st_y), title_h) { + section_title( + r, + gc, + size, + scale, + &sk, + st_x, + group_y(st_y), + language.pick("主题模式", "Theme mode"), + ); + } + if visible(st_y, st_h) { + r.draw_chrome_text( + size, + st_x + s(16.0), + st_y + (st_h - cell_h) / 2.0, + sk.ink, + language.pick("跟随系统明暗模式", "Follow system appearance"), + gc, + ); + } + let (bg_x, bg_y, _, bg_h) = geometry.background; + if visible(group_y(bg_y), title_h) { + section_title( + r, + gc, + size, + scale, + &sk, + bg_x, + group_y(bg_y), + language.pick("自定义背景", "Custom background"), + ); + } + if visible(bg_y, bg_h) { + let background_v = view + .background + .map(format_hex_rgb) + .unwrap_or_else(|| language.pick("主题默认", "Theme default").to_owned()); + row_label( + r, + gc, + size, + scale, + &sk, + geometry.background, + language.pick("背景色", "Background color"), + "", + sk.accent, + ); + // 值画进 combobox 控件框内(chevron 井之前),右对齐到行缘 + // 会压住下拉箭头(浅色模式下重叠尤其明显)。 + combobox_value(r, gc, geometry.background, &background_v, sk.accent); + } + let (img_x, img_y, _, img_h) = geometry.background_image; + let _ = img_x; + if visible(img_y, img_h) { + let image_v = view + .background_image + .as_deref() + .map(str::to_owned) + .unwrap_or_else(|| language.pick("未设置", "Not set").to_owned()); + row_label_with_right_inset( + r, + gc, + size, + scale, + &sk, + geometry.background_image, + language.pick("背景图片", "Background image"), + &image_v, + sk.accent, + if view.background_image.is_some() { s(48.0) } else { 0.0 }, + ); + if view.background_image.is_some() { + let (cx, cy, cw, ch) = geometry.background_image_clear; + r.draw_chrome_text( + size, + cx + (cw - cell_w) / 2.0, + cy + (ch - cell_h) / 2.0, + if view.hover == SettingsHit::BackgroundImageClear { + sk.ink + } else { + sk.ink_dim + }, + "↶", + gc, + ); + } + } + let (_, fit_y, _, fit_h) = geometry.background_image_fit; + if visible(fit_y, fit_h) { + row_label( + r, + gc, + size, + scale, + &sk, + geometry.background_image_fit, + language.pick("背景图像拉伸模式", "Background image stretch mode"), + "", + sk.ink, + ); + combobox_value( + r, + gc, + geometry.background_image_fit, + background_image_fit_label(view.background_image_fit, language), + sk.accent, + ); + } + let (_, align_y, _, align_h) = geometry.background_image_alignment; + if visible(align_y, align_h) { + row_label( + r, + gc, + size, + scale, + &sk, + geometry.background_image_alignment, + language.pick("背景图像对齐", "Background image alignment"), + "", + sk.ink, + ); + combobox_value( + r, + gc, + geometry.background_image_alignment, + background_image_alignment_label(view.background_image_alignment, language), + sk.accent, + ); + } + let (_, image_opacity_y, _, image_opacity_h) = geometry.background_image_opacity_row; + if visible(image_opacity_y, image_opacity_h) { + r.draw_chrome_text( + size, + geometry.background_image_opacity_row.0 + s(16.0), + image_opacity_y + (image_opacity_h - cell_h) / 2.0, + sk.ink, + language.pick("背景图像不透明度", "Background image opacity"), + gc, + ); + let image_opacity_v = format!("{:.0}%", view.background_image_opacity * 100.0); + let image_opacity_cols: usize = + image_opacity_v.chars().map(|c| c.width().unwrap_or(0)).sum(); + r.draw_chrome_text( + size, + geometry.background_image_opacity_slider.0 + - s(10.0) + - image_opacity_cols as f32 * cell_w, + image_opacity_y + (image_opacity_h - cell_h) / 2.0, + sk.accent, + &image_opacity_v, + gc, + ); + } + let (_, cover_y, _, cover_h) = geometry.background_image_cover_chrome; + if visible(cover_y, cover_h) { + row_label( + r, + gc, + size, + scale, + &sk, + geometry.background_image_cover_chrome, + language.pick( + "将背景图扩展到标题栏和侧边栏", + "Extend background image into title bar and sidebar", + ), + "", + sk.ink, + ); + } + // ---- 光标组 ---- + let (cs_x, cs_y, _, cs_h) = geometry.cursor_shape_row; + if visible(group_y(cs_y), title_h) { + section_title( + r, + gc, + size, + scale, + &sk, + cs_x, + group_y(cs_y), + language.pick("光标", "Cursor"), + ); + } + if visible(cs_y, cs_h) { + row_label( + r, + gc, + size, + scale, + &sk, + geometry.cursor_shape_row, + language.pick("光标形状", "Cursor shape"), + "", + sk.ink, + ); + combobox_value( + r, + gc, + geometry.cursor_shape_row, + cursor_shape_label(view.cursor_shape, language), + sk.accent, + ); + } + let (_, blink_y, _, blink_h) = geometry.cursor_blink_row; + if visible(blink_y, blink_h) { + row_label( + r, + gc, + size, + scale, + &sk, + geometry.cursor_blink_row, + language.pick("光标闪烁", "Cursor blinking"), + "", + sk.ink, + ); + } + let (or_x, or_y, _, or_h) = geometry.opacity_row; + let (lr_x, lr_y, _, lr_h) = geometry.language_row; + if visible(group_y(lr_y), title_h) { + section_title( + r, + gc, + size, + scale, + &sk, + content_x + s(24.0), + group_y(lr_y), + language.pick("界面", "Interface"), + ); + } + if visible(lr_y, lr_h) { + r.draw_chrome_text( + size, + lr_x + s(16.0), + lr_y + (lr_h - cell_h) / 2.0, + sk.ink, + language.pick("语言", "Language"), + gc, + ); + combobox_value( + r, + gc, + geometry.language_row, + language_label(view.language_preference, language), + sk.accent, + ); + } + if visible(or_y, or_h) { + r.draw_chrome_text( + size, + or_x + s(16.0), + or_y + (or_h - cell_h) / 2.0, + sk.ink, + language.pick("终端正文不透明度", "Terminal content opacity"), + gc, + ); + let opacity_v = format!("{:.0}%", view.opacity * 100.0); + let opacity_cols: usize = opacity_v.chars().map(|c| c.width().unwrap_or(0)).sum(); + r.draw_chrome_text( + size, + geometry.opacity_slider.0 - s(10.0) - opacity_cols as f32 * cell_w, + or_y + (or_h - cell_h) / 2.0, + sk.accent, + &opacity_v, + gc, + ); + } + }, + NebulaSettingsSection::Profiles => { + // Rows carry single, self-explanatory Chinese labels — the old + // second-line descriptions overflowed the 44px rows and collided + // with the next group's title. + let (sh_x, sh_y, _, sh_h) = geometry.shell; + if visible(group_y(sh_y), title_h) { + section_title( + r, + gc, + size, + scale, + &sk, + sh_x, + group_y(sh_y), + language.pick("终端", "Terminal"), + ); + } + if visible(sh_y, sh_h) { + row_label( + r, + gc, + size, + scale, + &sk, + geometry.shell, + language.pick("默认 Shell", "Default shell"), + "", + sk.ink, + ); + combobox_value(r, gc, geometry.shell, &view.shell_label, sk.accent); + } + if visible(geometry.startup_directory.1, geometry.startup_directory.3) { + row_label( + r, + gc, + size, + scale, + &sk, + geometry.startup_directory, + language.pick("启动目录", "Startup directory"), + "", + sk.ink, + ); + + let (dx, dy, dw, dh) = geometry.startup_directory; + // 与"默认 Shell / 终端字体"同一右对齐基线;有清除按钮时向左避让。 + let value_left = dx + dw * 0.42; + let value_right = if view.startup_directory.is_some() { + geometry.startup_directory_clear.0 - s(12.0) + } else { + dx + dw - s(16.0) + }; + let max_chars = ((value_right - value_left).max(cell_w) / cell_w).floor() as usize; + let value = view + .startup_directory + .as_deref() + .unwrap_or_else(|| language.pick("继承当前目录", "Inherit current directory")); + let value = truncate_tab_label(value, max_chars.max(1)); + let value_cols: usize = value.chars().map(|c| c.width().unwrap_or(0)).sum(); + let value_x = (value_right - value_cols as f32 * cell_w).max(value_left); + + r.draw_chrome_text( + size, + value_x, + dy + (dh - cell_h) / 2.0, + if view.startup_directory.is_some() { sk.accent } else { sk.ink_dim }, + &value, + gc, + ); + + if view.startup_directory.is_some() { + let (cx, cy, cw, ch) = geometry.startup_directory_clear; + let clear = language.pick("清除", "Clear"); + let clear_cols: usize = + clear.chars().map(|character| character.width().unwrap_or(0)).sum(); + r.draw_chrome_text( + size, + cx + (cw - clear_cols as f32 * cell_w) / 2.0, + cy + (ch - cell_h) / 2.0, + sk.accent, + clear, + gc, + ); + } + } + if visible(geometry.font.1, geometry.font.3) { + let font_value = view.font_notice.as_deref().unwrap_or(&view.font_family); + row_label( + r, + gc, + size, + scale, + &sk, + geometry.font, + language.pick("终端字体", "Terminal font"), + "", + sk.ink, + ); + combobox_value( + r, + gc, + geometry.font, + font_value, + if view.font_notice.is_some() { sk.ink_dim } else { sk.accent }, + ); + } + if visible(geometry.font_size_row.1, geometry.font_size_row.3) { + row_label( + r, + gc, + size, + scale, + &sk, + geometry.font_size_row, + language.pick("终端字号(Ctrl+滚轮缩放)", "Font size (Ctrl+wheel zooms)"), + "", + sk.ink, + ); + let (value_box, _, _) = widgets::spinner_rects(geometry.font_size_row, scale); + let value = format!("{:.0}", view.font_size_px); + let cols: usize = value.chars().map(|c| c.width().unwrap_or(0)).sum(); + r.draw_chrome_text( + size, + value_box.0 + (value_box.2 - cols as f32 * cell_w) / 2.0, + value_box.1 + (value_box.3 - cell_h) / 2.0, + sk.ink, + &value, + gc, + ); + } + // 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) { + row_label( + r, + gc, + size, + scale, + &sk, + geometry.fetch, + language.pick("启动欢迎信息", "Startup welcome"), + "", + sk.ink, + ); + } + if visible(geometry.powerline.1, geometry.powerline.3) { + row_label( + r, + gc, + size, + scale, + &sk, + geometry.powerline, + language.pick("Powerline 提示符", "Powerline prompt"), + "", + sk.ink, + ); + } + + let (gh_x, gh_y, _, gh_h) = geometry.ghost; + if visible(group_y(gh_y), title_h) { + section_title( + r, + gc, + size, + scale, + &sk, + gh_x, + group_y(gh_y), + language.pick("补全", "Completion"), + ); + } + if visible(gh_y, gh_h) { + row_label( + r, + gc, + size, + scale, + &sk, + geometry.ghost, + language.pick("历史补全灰字", "History ghost text"), + "", + sk.ink, + ); + } + if visible(geometry.accept.1, geometry.accept.3) { + row_label( + r, + gc, + size, + scale, + &sk, + geometry.accept, + language.pick("补全接受键", "Completion accept key"), + "", + sk.ink, + ); + combobox_value( + r, + gc, + geometry.accept, + accept_label(view.accept, language), + sk.accent, + ); + } + + let (ocx, ocy, _ocw, och) = geometry.open_config_file; + if visible(group_y(ocy), title_h) { + section_title( + r, + gc, + size, + scale, + &sk, + ocx, + group_y(ocy), + language.pick("配置文件", "Configuration"), + ); + } + if visible(ocy, och) { + r.draw_chrome_text( + size, + ocx + s(16.0), + ocy + (och - cell_h) / 2.0, + sk.accent, + language.pick("打开配置文件", "Open configuration file"), + gc, + ); + } + + if geometry.hidden_host_count > 0 { + let (hx, hy, hw, hh) = geometry.hidden_host_row0; + if visible(group_y(hy), title_h) { + section_title( + r, + gc, + size, + scale, + &sk, + hx, + group_y(hy), + language.pick( + "已隐藏 SSH 主机 · 密码不会恢复", + "Hidden SSH hosts · passwords are not restored", + ), + ); + } + for (index, host) in view.hidden_hosts.iter().enumerate() { + let rect = (hx, hy + index as f32 * hh, hw, hh); + if visible(rect.1, rect.3) { + row_label( + r, + gc, + size, + scale, + &sk, + rect, + host, + language.pick("恢复", "Restore"), + sk.accent, + ); + } + } + } + }, + NebulaSettingsSection::Interaction => { + let (ix, iy, _, ih) = geometry.copy_on_select; + if visible(group_y(iy), title_h) { + section_title( + r, + gc, + size, + scale, + &sk, + ix, + group_y(iy), + language.pick("剪贴板", "Clipboard"), + ); + } + if visible(iy, ih) { + // The switch (drawn in `push_quads`) carries the state; the + // label spells out the OFF fallback so both modes are clear. + row_label( + r, + gc, + size, + scale, + &sk, + geometry.copy_on_select, + language.pick( + "自动将所选内容复制到剪贴板(关闭时右键复制 / 粘贴)", + "Copy selection to clipboard (off: right-click copies / pastes)", + ), + "", + sk.ink, + ); + } + let (bx, by, _, bh) = geometry.cjk_bold; + if visible(group_y(by), title_h) { + section_title( + r, + gc, + size, + scale, + &sk, + bx, + group_y(by), + language.pick("文本渲染", "Text rendering"), + ); + } + if visible(by, bh) { + row_label( + r, + gc, + size, + scale, + &sk, + geometry.cjk_bold, + language.pick( + "中文粗体只提亮不加粗(避免小字号下笔画发闷)", + "Render CJK bold with regular glyphs (avoids muddy strokes)", + ), + "", + sk.ink, + ); + } + }, + NebulaSettingsSection::Keymap => { + let (kx, ky, kw, kh) = geometry.keymap_row0; + if visible(group_y(ky), title_h) { + section_title( + r, + gc, + size, + scale, + &sk, + kx, + group_y(ky), + language.pick( + "快捷键(点击右侧按键重新绑定,Backspace 恢复默认)", + "Shortcuts (click a key to rebind, Backspace restores default)", + ), + ); + } + // 行矩形按文字行剔除:quad 走 scissor 能画半行,文字只要居中 + // 的字盒仍完整落在视口内就照画——否则底部半行只剩空 keycap。 + let line_visible = |ry: f32, rh: f32| { + let ty = ry + (rh - cell_h) / 2.0; + ty >= clip_top && ty + cell_h <= clip_bot + }; + for i in 0..keymap::editable_row_count() { + let rect = (kx, ky + i as f32 * geometry.keymap_row_h, kw, kh); + if !line_visible(rect.1, rect.3) { + continue; + } + let ty = rect.1 + (kh - cell_h) / 2.0; + let (zh_label, en_label) = if i == keymap::QUICK_TERMINAL_ROW { + if view.quick_hotkey_error.is_some() { + ("快速终端(注册失败)", "Quick terminal (failed)") + } else { + ("快速终端", "Quick terminal") + } + } else { + let (_, zh, en) = keymap::EDITABLE_ACTIONS[i - 1]; + (zh, en) + }; + r.draw_chrome_text( + size, + rect.0 + s(16.0), + ty, + if i == keymap::QUICK_TERMINAL_ROW && view.quick_hotkey_error.is_some() { + if sk.is_light { Rgb::new(207, 34, 46) } else { Rgb::new(248, 81, 73) } + } else { + sk.ink + }, + language.pick(zh_label, en_label), + gc, + ); + let capturing = view.keymap_capture == Some(i); + let (value, customized, bound) = keymap_row_value(view, i); + // 墨色分级:捕获中 accent、自定义键 accent、默认键正常墨、 + // 未绑定弱墨——一眼看出哪些行动过。 + let ink = if capturing || customized { + sk.accent + } else if bound { + sk.ink + } else { + sk.ink_dim + }; + if capturing || !bound { + // 捕获提示 / 未绑定占位仍是整段文本(不是键位展示)。 + let (cap_x, ..) = keymap_keycap_rect(rect, &value, cell_w, scale); + r.draw_chrome_text(size, cap_x + s(12.0), ty, ink, &value, gc); + } else { + // 图8 键帽规范:逐键 chip 内居中键名,弱墨 "+" 分隔。 + let combo = super::ui::keycap::layout_combo( + &value, + rect.0 + rect.2 - s(16.0), + rect.1 + rect.3 / 2.0, + cell_w, + scale, + ); + for (chip_x, chip_w, key) in &combo.chips { + let key_cols: usize = + key.chars().map(|c| c.width().unwrap_or(0)).sum(); + r.draw_chrome_text( + size, + chip_x + (chip_w - key_cols as f32 * cell_w) / 2.0, + ty, + ink, + key, + gc, + ); + } + for plus_x in &combo.pluses { + r.draw_chrome_text(size, *plus_x, ty, sk.ink_faint, "+", gc); + } + } + } + let (rx, ry, rw, rh) = geometry.keymap_readonly_row0; + if visible(group_y(ry), title_h) { + section_title( + r, + gc, + size, + scale, + &sk, + rx, + group_y(ry), + language.pick("固定快捷键", "Fixed shortcuts"), + ); + } + for (i, (zh_label, en_label, combo)) in keymap::READONLY_ROWS.iter().enumerate() { + let rect = (rx, ry + i as f32 * geometry.keymap_row_h, rw, rh); + if line_visible(rect.1, rect.3) { + row_label( + r, + gc, + size, + scale, + &sk, + rect, + view.language.pick(zh_label, en_label), + combo, + sk.ink_dim, + ); + } + } + }, + NebulaSettingsSection::Advanced => { + let (ax, ay, _, ah) = geometry.keep_session; + if visible(group_y(ay), title_h) { + section_title( + r, + gc, + size, + scale, + &sk, + ax, + group_y(ay), + language.pick("会话", "Sessions"), + ); + } + if visible(ay, ah) { + // The switch (drawn in `push_quads`) carries the state; the + // label says what closing a window keeps alive while it is ON. + row_label( + r, + gc, + size, + scale, + &sk, + geometry.keep_session, + language.pick( + "关闭窗口后保留会话(后台驻留,可恢复对话)", + "Keep sessions after closing the window (resident and restorable)", + ), + "", + sk.ink, + ); + } + + if SHOW_WEBDAV_SYNC_SETTINGS { + // ---- 同步(WebDAV)---- + let (sx, sy, ..) = geometry.sync_rows[0]; + if visible(group_y(sy), title_h) { + section_title( + r, + gc, + size, + scale, + &sk, + sx, + group_y(sy), + language.pick("同步(WebDAV)", "Sync (WebDAV)"), + ); + } + let labels = [ + language.pick("服务器文件 URL", "Server file URL"), + language.pick("用户名", "Username"), + language.pick("WebDAV 密码", "WebDAV password"), + language.pick("端到端口令", "End-to-end passphrase"), + ]; + let cell_w = size.cell_width(); + let cell_h = size.cell_height(); + for (index, row) in geometry.sync_rows.iter().enumerate() { + if !visible(row.1, row.3) { + continue; + } + row_label(r, gc, size, scale, &sk, *row, labels[index], "", sk.ink); + let (ix, iy, iw, ih) = sync_input_rect(*row, scale); + let max_cols = (((iw - s(24.0)) / cell_w) as usize).max(1); + let (text, placeholder, _) = sync_input_display(view, index, max_cols); + let ink = if placeholder { sk.ink_dim } else { sk.ink }; + r.draw_chrome_text( + size, + ix + s(12.0), + iy + (ih - cell_h) / 2.0, + ink, + &text, + gc, + ); + } + if visible(geometry.sync_auto_pull.1, geometry.sync_auto_pull.3) { + row_label( + r, + gc, + size, + scale, + &sk, + geometry.sync_auto_pull, + language.pick("启动时自动拉取", "Pull automatically on startup"), + "", + sk.ink, + ); + } + let (_, by, _, bh) = geometry.sync_actions; + if visible(by, bh + s(30.0)) { + let [push_rect, pull_rect] = sync_button_rects(geometry.sync_actions, scale); + let captions = [ + (push_rect, language.pick("立即推送", "Push now")), + (pull_rect, language.pick("立即拉取", "Pull now")), + ]; + for ((bx, byy, bw, bhh), caption) in captions { + let cols: usize = + caption.chars().map(|c| c.width().unwrap_or(1).max(1)).sum(); + let ink = if view.sync_busy { sk.ink_dim } else { sk.ink }; + r.draw_chrome_text( + size, + bx + (bw - cols as f32 * cell_w) / 2.0, + byy + (bhh - cell_h) / 2.0, + ink, + caption, + gc, + ); + } + // 状态行:最近一次动作结果(错误红、成功淡墨)。 + if let Some((message, error)) = &view.sync_status { + let ink = if *error { + Rgb::new(sk.danger.r, sk.danger.g, sk.danger.b) + } else { + sk.ink_dim + }; + r.draw_chrome_text(size, sx, by + bh + s(8.0), ink, message, gc); + } + } + } + }, + } + icon_draws +} + +#[cfg(test)] +mod tests { + use super::{SHOW_WEBDAV_SYNC_SETTINGS, advanced_content_end, opacity_from_pointer}; + + #[test] + fn slider_pointer_maps_to_clamped_fraction() { + let slider = (100.0, 20.0, 200.0, 36.0); + assert_eq!(opacity_from_pointer(50.0, slider), 0.0); + assert_eq!(opacity_from_pointer(100.0, slider), 0.0); + assert_eq!(opacity_from_pointer(200.0, slider), 0.5); + assert_eq!(opacity_from_pointer(300.0, slider), 1.0); + assert_eq!(opacity_from_pointer(350.0, slider), 1.0); + } + + #[test] + fn hidden_webdav_group_does_not_extend_advanced_content() { + assert!(!SHOW_WEBDAV_SYNC_SETTINGS); + assert_eq!(advanced_content_end(146.0, 264.0, 44.0), 190.0); + } +} diff --git a/nebula_app/src/display/ssh_editor_render.rs b/nebula_app/src/display/ssh_editor_render.rs index 673209b..11ee962 100644 --- a/nebula_app/src/display/ssh_editor_render.rs +++ b/nebula_app/src/display/ssh_editor_render.rs @@ -2,6 +2,7 @@ use unicode_width::UnicodeWidthChar; use super::ssh_ui::{SshTestState, auth_sections}; use super::*; +use super::ui::theme; use crate::ssh_profiles::SshAuthMode; type Rect = (f32, f32, f32, f32); diff --git a/nebula_app/src/display/terminal_color.rs b/nebula_app/src/display/terminal_color.rs index e3f0263..4b857c2 100644 --- a/nebula_app/src/display/terminal_color.rs +++ b/nebula_app/src/display/terminal_color.rs @@ -4,7 +4,7 @@ use nebula_terminal::term::color::Colors; use nebula_terminal::vte::ansi::Color; use super::color::Rgb; -use super::design_tokens::terminal_feedback::FIXED_TEXT_MIN_CONTRAST; +use super::ui::tokens::terminal_feedback::FIXED_TEXT_MIN_CONTRAST; const CACHE_ENTRY_LIMIT: usize = 4096; const SURFACE_CHANNEL_TOLERANCE: u8 = 32; @@ -205,7 +205,7 @@ mod tests { use super::{TerminalColorResolver, is_fixed_color}; use crate::display::color::Rgb; - use crate::display::design_tokens::terminal_feedback::FIXED_TEXT_MIN_CONTRAST; + use crate::display::ui::tokens::terminal_feedback::FIXED_TEXT_MIN_CONTRAST; const LIGHT_FOREGROUND: Rgb = Rgb::new(36, 41, 47); const LIGHT_BACKGROUND: Rgb = Rgb::new(255, 255, 255); diff --git a/nebula_app/src/display/icons.rs b/nebula_app/src/display/ui/icons.rs similarity index 100% rename from nebula_app/src/display/icons.rs rename to nebula_app/src/display/ui/icons.rs diff --git a/nebula_app/src/display/keycap.rs b/nebula_app/src/display/ui/keycap.rs similarity index 94% rename from nebula_app/src/display/keycap.rs rename to nebula_app/src/display/ui/keycap.rs index 13967ad..471ee2d 100644 --- a/nebula_app/src/display/keycap.rs +++ b/nebula_app/src/display/ui/keycap.rs @@ -11,14 +11,14 @@ use super::theme::Skin; use crate::renderer::ui::UiQuad; /// Chip height in logical px(与 palette 底栏既有键帽一致)。 -pub(super) const KEY_H: f32 = 20.0; +pub const KEY_H: f32 = 20.0; fn cols(text: &str) -> usize { text.chars().map(|c| c.width().unwrap_or(0)).sum() } /// One rendered key chip: `(x, w, key)`;`pluses` 是分隔加号的绘制 X。 -pub(super) struct ComboChips { +pub struct ComboChips { pub chips: Vec<(f32, f32, String)>, pub pluses: Vec, /// Bounding box `(x, y, w, h)` across every chip. @@ -27,7 +27,7 @@ pub(super) struct ComboChips { /// Lay out a "Ctrl+Shift+P" style combo as per-key chips, right-aligned so /// the last chip ends at `right_x`, vertically centered on `center_y`. -pub(super) fn layout_combo( +pub fn layout_combo( combo: &str, right_x: f32, center_y: f32, @@ -57,7 +57,7 @@ pub(super) fn layout_combo( } /// The one chip recipe: hairline ring + panel + surface, radius 4. -pub(super) fn push_chip( +pub fn push_chip( quads: &mut Vec, sk: &Skin, x: f32, @@ -73,7 +73,7 @@ pub(super) fn push_chip( } /// Push every chip of a laid-out combo. -pub(super) fn push_combo(quads: &mut Vec, sk: &Skin, combo: &ComboChips, scale: f32) { +pub fn push_combo(quads: &mut Vec, sk: &Skin, combo: &ComboChips, scale: f32) { let (_, y, _, h) = combo.bounds; for &(x, w, _) in &combo.chips { push_chip(quads, sk, x, y, w, h, scale); diff --git a/nebula_app/src/display/ui/mod.rs b/nebula_app/src/display/ui/mod.rs new file mode 100644 index 0000000..2794e00 --- /dev/null +++ b/nebula_app/src/display/ui/mod.rs @@ -0,0 +1,48 @@ +//! Nebula 的 UI 层:所有自绘 chrome 的唯一配方来源。 +//! +//! # 这一层为什么存在 +//! +//! 2026-07-29 的侦察结果:`(x-1, y-1, w+2, h+2)` 的手写描边配方在 `display/` +//! 下出现 **95 处**;八个浮层用了 **六种**不同圆角(14/12/12/10/9/8); +//! `tokens` 里明明定义好了 `space::M = 16`,代码里却有 26 个手写的 `s(16.0)`。 +//! +//! 后果是改一条视觉规则要动十个文件,且必然漏掉几处——「统一配色」在这种结构 +//! 下不可能做到。本层把配方收敛成函数,让视觉规则只有一个落点。 +//! +//! # 层契约(新增代码必须遵守) +//! +//! 1. **纯几何 + quad 输出。** 不持有 [`Display`](crate::display::Display) 状态, +//! 不做 hover 决策,不读配置。输入是矩形和 [`Skin`](theme::Skin),输出是 quad。 +//! +//! 2. **布局、绘制、命中检测调同一个 rect helper。** 一个控件的可点击区域永远 +//! 不会与它的绘制区域漂移——这类 bug 曾让手写的逐页控件极难调试。 +//! +//! 3. **文字不在这里画。** 渲染器的文字是独立 pass(避免不透明 cell 背景), +//! 本层只暴露几何(`*_rect`、`layout_*`),文字 pass 复用同一份坐标。 +//! +//! 4. **不写魔数。** 尺寸走 [`tokens::space`],圆角走 [`tokens::radius`], +//! 阴影走 [`tokens::elevation`]。`tokens` 没有的值说明该往 `tokens` 加。 +//! +//! # 模块分工 +//! +//! - [`tokens`] —— 间距、圆角、字阶、动效、层级的常量目录 +//! - [`theme`] —— [`Skin`](theme::Skin):主题派生的墨色与表面色 +//! - [`surface`] —— 面板/卡片/描边/遮罩的配方(本层的核心) +//! - [`widgets`] —— slider / toggle / combobox / spinner +//! - [`keycap`] —— 键帽 chip +//! - [`icons`] —— 图标墨迹 +//! +//! # 未来 +//! +//! 本目录的内容就是将来 `nebula_ui` crate 的内容。抽 crate 的前置条件是打破 +//! `renderer` ↔ `display` 的循环依赖(`renderer/ui.rs` 用 `display::SizeInfo` +//! 和 `display::color::Rgb`,而 `theme.rs` 反过来用 `renderer::ui::Rgba`), +//! 需要先把 `Rgb`/`Rgba`/`UiQuad`/`SizeInfo` 这批纯数据类型下沉到共享底座。 +//! 那一步的收益是编译器强制单向依赖 + 独立编译单元(改 UI 不再重编译整个 app)。 + +pub mod icons; +pub mod keycap; +pub mod surface; +pub mod theme; +pub mod tokens; +pub mod widgets; diff --git a/nebula_app/src/display/ui/surface.rs b/nebula_app/src/display/ui/surface.rs new file mode 100644 index 0000000..1d270e2 --- /dev/null +++ b/nebula_app/src/display/ui/surface.rs @@ -0,0 +1,247 @@ +//! 浮层配方:面板、卡片、描边、遮罩。本层的核心。 +//! +//! # 为什么需要它 +//! +//! 2026-07-29 侦察:`(x-1, y-1, w+2, h+2)` + hairline + 填充这套描边配方在 +//! `display/` 下手写了 **95 处**,八个浮层用了**六种**圆角。结果是任何一条视觉 +//! 规则(明度阶梯、阴影、遮罩、圆角)都无法一次改全——这正是"配色改不干净"的 +//! 结构性原因。 +//! +//! 这里的函数是那些配方的唯一来源。新的浮层不应该再手写 quad 序列。 +//! +//! # 遮罩的二分(重要) +//! +//! [`Elevation`] 把「是否画遮罩」编码进类型,而不是留给调用方决定。理由是遮罩 +//! 传达的是一份**模态承诺**——「我打断了你,请先处理我」。随手开关的浮层给出这 +//! 份承诺,会凭空抬高用户的心理成本,还会遮住它自己经常需要被参考的上下文。 +//! +//! otty 的命令面板有像素证据:面板打开时背景终端文字和侧栏亮度完全未变, +//! 浮起靠的是「明度对比 + 大扩散阴影」,不是压暗背景。 +//! 详见 `docs/nebula-visual-design-language.md` 裁定三。 + +use super::theme::Skin; +use super::tokens::{control, elevation, radius}; +use crate::renderer::ui::{Rgba, UiQuad}; + +/// `(x, y, width, height)`,逻辑像素。 +pub type Rect = (f32, f32, f32, f32); + +/// 浮层的高度层级。决定阴影扩散、圆角,以及**是否画遮罩**。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Elevation { + /// 小浮层:右键菜单、下拉 popup、tooltip。不画遮罩。 + Menu, + /// 大浮层:命令面板、shell 选择器。可 Esc、无后果、随手开关—— + /// **不画遮罩**,靠明度对比与大扩散阴影浮起。 + Popover, + /// 模态:确认框、设置页、SSH 编辑器。要求决策、有后果, + /// 画遮罩(冷调压暗,不是白雾)。 + Modal, +} + +impl Elevation { + /// `(blur, offset_y)`,逻辑像素。阴影扩散必须与被托举的面积成比例: + /// 一组适合小菜单的参数放在整块命令面板下面就托不住。 + #[inline] + fn shadow(self) -> (f32, f32) { + match self { + Self::Menu => (elevation::MENU_BLUR, elevation::MENU_OFFSET_Y), + Self::Popover => (elevation::POPOVER_BLUR, elevation::POPOVER_OFFSET_Y), + Self::Modal => (elevation::MODAL_BLUR, elevation::MODAL_OFFSET_Y), + } + } + + /// 只有模态阻断交互,所以只有模态画遮罩。 + #[inline] + pub fn dims_background(self) -> bool { + matches!(self, Self::Modal) + } +} + +/// 浮层内容块的状态。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CardState { + Default, + Hover, + /// 当前选中项。强调色预算一屏只花一次,就花在这里—— + /// 不要同时给推荐卡、导航 pill 也染色。 + Selected, +} + +/// 按入场进度衰减 alpha。`progress` 取 0..1,1 为完全不透明。 +#[inline] +pub fn fade(color: Rgba, progress: f32) -> Rgba { + if progress >= 1.0 { + return color; + } + Rgba::new(color.r, color.g, color.b, (color.a as f32 * progress.clamp(0.0, 1.0)).round() as u8) +} + +/// 发丝描边环。替代散落各处的手写 `(x-1, y-1, w+2, h+2)`。 +/// +/// 外圆角自动取 `radius + hairline`,保证内外弧**同心**——手写处常常内外用同一 +/// 个半径,那样描边在圆角处会变粗,是"边缘发毛"的来源之一。 +/// +/// `radius` 传入的是**内**圆角(已乘过 scale)。 +pub fn push_stroke(quads: &mut Vec, rect: Rect, radius: f32, scale: f32, color: Rgba) { + let hairline = (control::HAIRLINE * scale).max(1.0); + let (x, y, w, h) = rect; + quads.push(UiQuad::solid( + x - hairline, + y - hairline, + w + hairline * 2.0, + h + hairline * 2.0, + radius + hairline, + color, + )); +} + +/// 浮层的底:遮罩(仅 [`Elevation::Modal`])→ 外阴影 → 发丝环 → 面板填充。 +/// +/// `viewport` 是整窗尺寸 `(width, height)`,只有模态用得上。 +/// `progress` 是入场动画进度,所有颜色按它衰减。 +pub fn push_surface( + quads: &mut Vec, + rect: Rect, + viewport: (f32, f32), + scale: f32, + sk: &Skin, + level: Elevation, + progress: f32, +) { + let (x, y, w, h) = rect; + let corner = radius::OVERLAY * scale; + + if level.dims_background() { + quads.push(UiQuad::solid(0.0, 0.0, viewport.0, viewport.1, 0.0, fade(sk.veil, progress))); + } + + // 外阴影承担 Z 轴层级;发丝环只负责在浅色背景上定义边界。两者分工不同, + // 缺了阴影就只剩一条线,浮层会"贴"在背景上而不是浮起来。 + let (blur, offset_y) = level.shadow(); + let shadow_alpha = if sk.is_light { + elevation::SHADOW_ALPHA_LIGHT + } else { + elevation::SHADOW_ALPHA_DARK + }; + quads.push(UiQuad::shadow( + x, + y, + w, + h, + corner, + blur * scale, + offset_y * scale, + fade(Rgba::new(0, 0, 0, shadow_alpha), progress), + )); + + push_stroke(quads, rect, corner, scale, fade(sk.hairline, progress)); + quads.push(UiQuad::solid(x, y, w, h, corner, fade(sk.panel, progress))); +} + +/// 浮层内部的内容块。 +pub fn push_card(quads: &mut Vec, rect: Rect, scale: f32, sk: &Skin, state: CardState) { + let (x, y, w, h) = rect; + let corner = radius::OVERLAY * scale; + let fill = match state { + CardState::Default => sk.card, + CardState::Hover => sk.hover, + CardState::Selected => sk.accent_soft, + }; + quads.push(UiQuad::solid(x, y, w, h, corner, fill)); +} + +/// 下沉表面(文本输入框)。聚焦时描边换成强调色——焦点不能只靠颜色深浅表示。 +pub fn push_input(quads: &mut Vec, rect: Rect, scale: f32, sk: &Skin, focused: bool) { + let (x, y, w, h) = rect; + let corner = radius::CONTROL * scale; + let stroke = if focused { + Rgba::new(sk.accent.r, sk.accent.g, sk.accent.b, 255) + } else { + sk.hairline + }; + push_stroke(quads, rect, corner, scale, stroke); + quads.push(UiQuad::solid(x, y, w, h, corner, sk.input)); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::display::ui::theme::NebulaTheme; + + fn light() -> Skin { + NebulaTheme::SilverLight.skin() + } + + #[test] + fn stroke_ring_stays_concentric_with_its_fill() { + // 内外弧同心的判据:外半径 - 内半径 == 描边宽度。不满足时描边在圆角 + // 处会变粗,边缘发毛。 + let mut quads = Vec::new(); + let rect = (100.0, 50.0, 200.0, 80.0); + push_stroke(&mut quads, rect, 8.0, 1.0, Rgba::new(0, 0, 0, 40)); + + let ring = &quads[0]; + assert_eq!(ring.radius - 8.0, 1.0, "外圆角必须等于内圆角 + 描边宽"); + assert_eq!((ring.x, ring.y), (99.0, 49.0)); + assert_eq!((ring.width, ring.height), (202.0, 82.0)); + } + + #[test] + fn stroke_hairline_never_falls_below_one_physical_pixel() { + // 缩放小于 1 时 hairline 会算出亚像素宽度,在整数像素栅格上会消失。 + let mut quads = Vec::new(); + push_stroke(&mut quads, (0.0, 0.0, 10.0, 10.0), 4.0, 0.5, Rgba::new(0, 0, 0, 40)); + assert_eq!(quads[0].width, 12.0, "描边宽应被钳到 1px,而不是 0.5px"); + } + + #[test] + fn only_modal_dims_the_background() { + // 裁定三的回归防线:popover 加遮罩会凭空给出「我打断了你」的承诺, + // 并遮住它自己经常需要被参考的上下文。 + assert!(!Elevation::Menu.dims_background()); + assert!(!Elevation::Popover.dims_background()); + assert!(Elevation::Modal.dims_background()); + + let sk = light(); + for level in [Elevation::Menu, Elevation::Popover] { + let mut quads = Vec::new(); + push_surface(&mut quads, (10.0, 10.0, 100.0, 60.0), (800.0, 600.0), 1.0, &sk, level, 1.0); + assert!( + !quads.iter().any(|q| q.width >= 800.0 && q.height >= 600.0), + "{level:?} 不该产生任何整窗遮罩 quad", + ); + assert_eq!(quads.len(), 3, "{level:?} 应只有阴影 + 发丝环 + 填充"); + } + + let mut quads = Vec::new(); + push_surface( + &mut quads, + (10.0, 10.0, 100.0, 60.0), + (800.0, 600.0), + 1.0, + &sk, + Elevation::Modal, + 1.0, + ); + let veil = &quads[0]; + assert_eq!((veil.x, veil.y, veil.width, veil.height), (0.0, 0.0, 800.0, 600.0)); + } + + #[test] + fn shadow_spread_scales_with_surface_size() { + // 小菜单的阴影参数放在整块面板下面托不住,所以按层级分档。 + let (menu_blur, _) = Elevation::Menu.shadow(); + let (popover_blur, _) = Elevation::Popover.shadow(); + let (modal_blur, _) = Elevation::Modal.shadow(); + assert!(menu_blur < popover_blur && popover_blur < modal_blur); + } + + #[test] + fn fade_is_identity_at_full_progress() { + let c = Rgba::new(10, 20, 30, 200); + assert_eq!(fade(c, 1.0).a, 200); + assert_eq!(fade(c, 0.5).a, 100); + assert_eq!(fade(c, 0.0).a, 0); + } +} diff --git a/nebula_app/src/display/theme.rs b/nebula_app/src/display/ui/theme.rs similarity index 87% rename from nebula_app/src/display/theme.rs rename to nebula_app/src/display/ui/theme.rs index 49d75e7..cc286b8 100644 --- a/nebula_app/src/display/theme.rs +++ b/nebula_app/src/display/ui/theme.rs @@ -430,34 +430,52 @@ impl NebulaTheme { let t = p.term_bg; if p.is_light { Skin { - // 2026-07-29 用户裁定(图6 吸色):浅色主题的浮层统一"白色 - // 打底"——面板近白、卡片纯白,SSH 编辑器与 shell 选择器共 - // 用这一组,不再继承主题族的银/岩灰底。 - panel: Rgba::new(250, 250, 251, 252), + // 明度阶梯(2026-07-29 裁定,见 docs/nebula-visual-design-language.md + // §3):相邻层必须差 8–14 级,低于 8 级肉眼分不开,界面就只能靠 + // 画边框救场——边框一多画面就碎,这才是"脏"的观感来源。 + // + // 浅色下浮层的方向是**比内容更暗**(otty 浅色命令面板底是 + // #F7F7F7,盖住的终端区是纯白;Fluent 同构:base #F3F3F3 → + // layer #FFFFFF)。此前 panel 250 与 card 255 只差 5 级,等于 + // 没有层级。现在 241 → 254 差 13,且比纯白终端暗 14。 + // + // 这不推翻"白色打底"的裁定:slate-100 依然是近白冷调,不是回到 + // 主题族的银/岩灰底;变的是它与卡片之间终于有了可见的台阶。 + panel: Rgba::new(241, 245, 249, 252), // slate-100 // A white inset on light panels: the hairline carries the // "sunken" read, the fill stays cleaner than a gray wash. input: Rgba::new(255, 255, 255, 240), card: Rgba::new(255, 255, 255, 244), - veil: Rgba::new(255, 255, 255, 190), - ink: Rgb::new(55, 65, 81), // #374151 (ui-text-main) - ink_dim: Rgb::new(107, 114, 128), // #6b7280 (ui-text-muted) - ink_strong: Rgb::new(18, 22, 30), - ink_faint: Rgb::new(148, 153, 163), + // modal 专用(popover 不画遮罩,见设计文档裁定三)。冷调压暗 + // 而不是白雾:白雾 75% 把背景提亮到和白弹窗同明度,弹窗反而 + // 浮不起来,且糊掉全部上下文。20% 压暗下背景仍然可读。 + veil: Rgba::new(15, 23, 42, 51), // slate-900 @ 20% + ink: Rgb::new(51, 65, 85), // slate-700 + ink_dim: Rgb::new(100, 116, 139), // slate-500 + ink_strong: Rgb::new(15, 23, 42), // slate-900 + ink_faint: Rgb::new(148, 163, 184), // slate-400 // Light accents are dark grays — pale ink on top. ink_on_accent: Rgb::new(248, 250, 252), - icon: Rgb::new(73, 80, 87), - icon_hover: Rgb::new(18, 22, 26), + icon: Rgb::new(71, 85, 105), // slate-600 + icon_hover: Rgb::new(15, 23, 42), // slate-900 accent: a, accent_soft: Rgba::new(a.r, a.g, a.b, 34), danger: Rgba::new(196, 74, 88, 255), - hairline: Rgba::new(0, 0, 0, 20), // rgba(0,0,0,.08) — hairline borders - surface: Rgba::new(0, 0, 0, 12), - hover: Rgba::new(0, 0, 0, 20), - hover_strong: Rgba::new(0, 0, 0, 32), - track_off: Rgba::new(0, 0, 0, 48), + // 2026-07-29 用户裁定:中性灰在屏幕上永远显脏。此前这几个 + // 叠加色是纯黑 rgba(0,0,0,.05~.19),而纯黑叠在白底上只能得 + // 到**零色相**的死灰——这就是"不干净"的物理来源。现在改叠 + // Slate(掺 3–6% 蓝的冷调灰):浅色主题叠深 slate,深色主题 + // 叠浅 slate,两边用不同基色,叠加后色相才不会被纯黑/纯白冲 + // 淡。alpha 按 (255-底)/(基色-底) 换算过,明度与旧值持平, + // 变的只是色温。 + hairline: Rgba::new(51, 65, 85, 30), // slate-700 @ 12% + surface: Rgba::new(100, 116, 139, 20), // slate-500 @ 8% + hover: Rgba::new(100, 116, 139, 33), + hover_strong: Rgba::new(100, 116, 139, 52), + track_off: Rgba::new(100, 116, 139, 78), knob_off: Rgba::new(255, 255, 255, 255), - knob_on: Rgba::new(250, 250, 250, 255), - scrollbar_thumb: Rgba::new(60, 66, 80, 0), + knob_on: Rgba::new(248, 250, 252, 255), // slate-50 + scrollbar_thumb: Rgba::new(71, 85, 105, 0), // slate-600 is_light: true, } } else { @@ -469,25 +487,28 @@ impl NebulaTheme { input: Rgba::new(t.r, t.g, t.b, 220), card: Rgba::new(255, 255, 255, 16), veil: Rgba::new(0, 0, 0, 150), - ink: Rgb::new(228, 231, 246), - ink_dim: Rgb::new(158, 164, 188), - ink_strong: Rgb::new(255, 255, 255), - ink_faint: Rgb::new(118, 124, 148), + ink: Rgb::new(226, 232, 240), // slate-200 + ink_dim: Rgb::new(148, 163, 184), // slate-400 + ink_strong: Rgb::new(248, 250, 252), // slate-50 + ink_faint: Rgb::new(100, 116, 139), // slate-500 // Dark accents are light — near-black ink on top. - ink_on_accent: Rgb::new(12, 16, 22), - icon: Rgb::new(205, 210, 230), - icon_hover: Rgb::new(244, 246, 255), + ink_on_accent: Rgb::new(15, 23, 42), // slate-900 + icon: Rgb::new(203, 213, 225), // slate-300 + icon_hover: Rgb::new(248, 250, 252), // slate-50 accent: a, accent_soft: Rgba::new(a.r, a.g, a.b, 46), danger: Rgba::new(196, 74, 88, 255), - hairline: Rgba::new(255, 255, 255, 22), - surface: Rgba::new(255, 255, 255, 11), - hover: Rgba::new(255, 255, 255, 26), - hover_strong: Rgba::new(255, 255, 255, 40), - track_off: Rgba::new(255, 255, 255, 34), - knob_off: Rgba::new(210, 214, 228, 255), - knob_on: Rgba::new(12, 16, 22, 255), - scrollbar_thumb: Rgba::new(180, 184, 200, 0), + // 深色主题叠 slate-300 而不是纯白:白是中性色,叠上去会把 + // 底色的色相**冲淡**,一叠就回到死灰。浅色主题叠深 slate、 + // 深色主题叠浅 slate——这就是"深浅两套灰不一样"的技术原因。 + hairline: Rgba::new(203, 213, 225, 30), + surface: Rgba::new(203, 213, 225, 15), + hover: Rgba::new(203, 213, 225, 35), + hover_strong: Rgba::new(203, 213, 225, 53), + track_off: Rgba::new(203, 213, 225, 45), + knob_off: Rgba::new(203, 213, 225, 255), // slate-300 + knob_on: Rgba::new(15, 23, 42, 255), // slate-900 + scrollbar_thumb: Rgba::new(148, 163, 184, 0), // slate-400 is_light: false, } } diff --git a/nebula_app/src/display/design_tokens.rs b/nebula_app/src/display/ui/tokens.rs similarity index 60% rename from nebula_app/src/display/design_tokens.rs rename to nebula_app/src/display/ui/tokens.rs index 02918f9..6d1e32b 100644 --- a/nebula_app/src/display/design_tokens.rs +++ b/nebula_app/src/display/ui/tokens.rs @@ -1,11 +1,12 @@ -//! Nebula's shared UI contract: spacing, typography, motion and control state. +//! Nebula 的共享 UI 契约:间距、圆角、字阶、层级、动效、控件状态。 //! -//! Values are logical pixels/scales. Callers apply the window DPI exactly once -//! at layout time; draw, hover, focus and hit-testing must derive from the same -//! resulting rectangle. +//! 值是逻辑像素/倍率。调用方在布局时**恰好**应用一次窗口 DPI;绘制、hover、 +//! 焦点和命中检测必须从同一个结果矩形派生。 -// Token adoption is intentionally incremental: keeping the complete scale in -// one catalog prevents new local magic numbers while existing screens migrate. +// 这个目录是设计系统的完整表达,允许存在暂未采用的条目——补全一个色阶或间距 +// 阶梯不该被 dead_code 拦住。约束的方向是反过来的:**代码里不允许出现本目录 +// 已经覆盖的魔数**。侦察数据(2026-07-29):仅 3 个文件就有 26 个手写的 +// `s(16.0)`(= space::M)、31 个 `s(8.0)`(= radius::OVERLAY)。 #![allow(dead_code)] pub mod space { @@ -17,6 +18,22 @@ pub mod space { pub const XL: f32 = 32.0; } +/// 圆角阶梯。逐层递减:浮层 → 控件 → chip。 +/// +/// 参照 Windows 11 Fluent 2(`OverlayCornerRadius` = 8,`ControlCornerRadius` = 4), +/// 不是 macOS 的 10–12。2026-07-29 侦察发现八个浮层用了六种圆角 +/// (命令面板 14、设置模态 12、确认框 12、AI 条 10、右键菜单 9、popup 8), +/// 这是界面"不成体系"的直接来源。 +pub mod radius { + /// 浮层:命令面板、模态、右键菜单、下拉 popup。 + pub const OVERLAY: f32 = 8.0; + /// 控件:combobox、spinner、输入框、按钮。 + /// Fluent 本身是 4;我们的控件高 32px,6 更耐看,实机再校。 + pub const CONTROL: f32 = 6.0; + /// chip / 键帽 / 徽标。 + pub const CHIP: f32 = 4.0; +} + pub mod type_scale { /// 11px-style sidebar/group caption from the Maple base face. pub const SECTION_CAPTION: f32 = 0.75; @@ -33,18 +50,26 @@ pub mod control { /// remain visible without making the input visually dominate the content. pub const COMPACT_ROW: f32 = 38.0; pub const ROW: f32 = 44.0; - pub const RADIUS: f32 = 8.0; pub const HAIRLINE: f32 = 1.0; } -/// Z-axis treatment for transient surfaces rendered above normal content. +/// 浮层的 Z 轴处理。阴影按浮层面积分档——同一组参数放在小菜单上刚好, +/// 放在整块命令面板上就托不住(阴影扩散必须与被托举的面积成比例)。 +/// +/// 调用方通常不直接用这些常量,而是走 [`super::surface::Elevation`]。 pub mod elevation { - /// Soft edge around a floating menu/card. - pub const FLOATING_BLUR: f32 = 12.0; - /// A small downward bias makes the card read as lifted rather than glowing. - pub const FLOATING_OFFSET_Y: f32 = 4.0; - pub const FLOATING_SHADOW_ALPHA_LIGHT: u8 = 54; - pub const FLOATING_SHADOW_ALPHA_DARK: u8 = 86; + /// 小浮层:右键菜单、下拉 popup、tooltip。 + pub const MENU_BLUR: f32 = 12.0; + pub const MENU_OFFSET_Y: f32 = 4.0; + /// 大浮层:命令面板、shell 选择器。 + pub const POPOVER_BLUR: f32 = 28.0; + pub const POPOVER_OFFSET_Y: f32 = 8.0; + /// 模态:确认框、设置页、SSH 编辑器。 + pub const MODAL_BLUR: f32 = 40.0; + pub const MODAL_OFFSET_Y: f32 = 12.0; + /// 阴影浓度。浅色底上的黑阴影必须更淡,否则边缘发脏。 + pub const SHADOW_ALPHA_LIGHT: u8 = 54; + pub const SHADOW_ALPHA_DARK: u8 = 86; } /// Terminal-grid feedback colors are rendered through the cell/rect alpha diff --git a/nebula_app/src/display/widgets.rs b/nebula_app/src/display/ui/widgets.rs similarity index 98% rename from nebula_app/src/display/widgets.rs rename to nebula_app/src/display/ui/widgets.rs index a11f486..ce2c3e8 100644 --- a/nebula_app/src/display/widgets.rs +++ b/nebula_app/src/display/ui/widgets.rs @@ -186,7 +186,7 @@ pub(crate) fn popup_row_rect(popup: Rect, index: usize, scale: f32) -> Rect { } pub(crate) fn popup_row_at(popup: Rect, count: usize, scale: f32, x: f32, y: f32) -> Option { - (0..count).find(|&index| super::contains_rect(popup_row_rect(popup, index, scale), x, y)) + (0..count).find(|&index| crate::display::contains_rect(popup_row_rect(popup, index, scale), x, y)) } /// The floating option list: opaque plate + soft shadow so it reads as a