diff --git a/extra/logo/README.md b/extra/logo/README.md new file mode 100644 index 0000000..23b1bdb --- /dev/null +++ b/extra/logo/README.md @@ -0,0 +1,20 @@ +# Third-Party Logo Assets + +## Grok + +`ai_grok_dark.png` and `ai_grok_light.png` are unmodified copies of +`Grok_Logomark_Dark.png` and `Grok_Logomark_Light.png` from the official xAI +asset archive: + +- Brand guidelines: https://x.ai/legal/brand-guidelines +- Asset archive: https://data.x.ai/logos/xAI_Grok_Assets.zip +- Archive SHA-256: `F41A93923A85047B4B5A9571B7EC73339F562C3E58ACD096E25584AB0AE2A1FB` +- Dark PNG SHA-256: `37DDBCB6E2A7F2E4B3BE78A7D41296A3BC7EDF6926362434EFC00DF5A56A3586` +- Light PNG SHA-256: `359056EE8983CFA0BA7E72795078C7C0DDF6C5D7A1870401AB960ED4F9DF9E53` + +xAI owns the trademark, intellectual property, and branding rights in xAI, +Grok, and their logos. The xAI Brand Guidelines are usage terms, not an +open-source asset license. These files are used only to identify a running +`grok` or `grok-cli` process and do not imply xAI endorsement, approval, or +sponsorship. The application selects the supplied dark or light file for +contrast and scales it only when rendering; the committed files are unchanged. diff --git a/extra/logo/ai_grok_dark.png b/extra/logo/ai_grok_dark.png new file mode 100644 index 0000000..8dd11d2 Binary files /dev/null and b/extra/logo/ai_grok_dark.png differ diff --git a/extra/logo/ai_grok_light.png b/extra/logo/ai_grok_light.png new file mode 100644 index 0000000..adece9a Binary files /dev/null and b/extra/logo/ai_grok_light.png differ diff --git a/nebula_app/src/display/chrome.rs b/nebula_app/src/display/chrome.rs index b724cfc..e3d6698 100644 --- a/nebula_app/src/display/chrome.rs +++ b/nebula_app/src/display/chrome.rs @@ -857,6 +857,7 @@ pub(super) fn draw_chrome(d: &mut Display) { .iter() .map(|tab| crate::motion::Spring::new(tab.1).with_response(0.14)) .collect(); + d.nebula_tab_was_visible = tab_layout.tabs.iter().map(|tab| tab.2 > 0.0).collect(); } let mut tab_anim_active = false; @@ -865,6 +866,7 @@ pub(super) fn draw_chrome(d: &mut Display) { // their eased position so they don't fly in when they reappear. if tab_w <= 0.0 { d.nebula_tab_anim[index].snap_to(row_y); + d.nebula_tab_was_visible[index] = false; continue; } let target_y = d.tab_drag_draw_y(index, row_y, &tab_layout); @@ -872,16 +874,26 @@ pub(super) fn draw_chrome(d: &mut Display) { // laggy); only the rows making way ease toward their slots. let dragging_this = d.nebula_tab_drag.as_ref().is_some_and(|d| d.active && d.source == index); - let tab_y = if dragging_this { - d.nebula_tab_anim[index].snap_to(target_y); - target_y - } else { - let spring = &mut d.nebula_tab_anim[index]; - spring.set_target(target_y, crate::motion::MotionPolicy::Full); - spring.step(motion_frame); - tab_anim_active |= spring.is_active(); - spring.value() + let action = tab_reveal_motion_action( + d.nebula_tab_reveal_motion, + d.nebula_tab_was_visible[index], + true, + dragging_this, + ); + let tab_y = match action { + TabRevealMotionAction::Snap => { + d.nebula_tab_anim[index].snap_to(target_y); + target_y + }, + TabRevealMotionAction::Spring => { + let spring = &mut d.nebula_tab_anim[index]; + spring.set_target(target_y, crate::motion::MotionPolicy::Full); + spring.step(motion_frame); + tab_anim_active |= spring.is_active(); + spring.value() + }, }; + d.nebula_tab_was_visible[index] = true; let tab_hovered = matches!( d.nebula_chrome_hover, ChromeHit::Tab(i) | ChromeHit::TabClose(i) if i == index @@ -1407,22 +1419,31 @@ pub(super) fn draw_chrome(d: &mut Display) { let color = if index == d.nebula_active_tab || tab_hovered { TXT_ON_ACCENT } else { TXT }; let cy = row_text_cy(draw_row_y, tab_h); - // Real AI brand logo (claude/codex): a textured quad in the - // icon slot, sized to the glyph ink height so it reads like + // Real AI brand logo: a textured quad in the icon slot, sized + // to the glyph ink height so it reads like // an icon, not a sticker. Staged here, drawn after ALL chrome // text (see nebula_chrome_logo_draws). Other programs keep // their Nerd Font glyph inside the label text. let mut text_x = tab_x + s(14.0) + hover_lift_x; let mut reserved = s(60.0); if let Some(logo) = d.nebula_tab_logos.get(index).copied().flatten() { - let icon_s = (cell_h * 0.72).round(); - if let Some((id, rgba, px)) = d.ai_logo_pixels(logo, color) { + let standard_icon_s = (cell_h * 0.72).round(); + let icon_s = if logo == super::AiLogo::Grok { + (cell_h * 0.92).round() + } else { + standard_icon_s + }; + if let Some((id, rgba, px)) = d.ai_logo_pixels(logo, color, icon_s as u32) { + // Grok is intentionally larger for legibility. Keep the + // standard icon slot's centre instead of growing only to + // the right from its left edge. + let icon_x = text_x - (icon_s - standard_icon_s) / 2.0; let icon_y = (draw_row_y + (tab_h - icon_s) / 2.0).round(); d.nebula_chrome_logo_draws.push(( id, rgba, px, - (text_x, icon_y, icon_s, icon_s), + (icon_x, icon_y, icon_s, icon_s), )); } text_x += icon_s + s(6.0); @@ -1881,13 +1902,36 @@ pub(super) fn draw_chrome(d: &mut Display) { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum TabRevealMotionAction { + Snap, + Spring, +} + +fn tab_reveal_motion_action( + preference: super::settings::TabRevealMotion, + was_visible: bool, + is_visible: bool, + dragging: bool, +) -> TabRevealMotionAction { + if dragging + || (is_visible && !was_visible && preference == super::settings::TabRevealMotion::Instant) + { + TabRevealMotionAction::Snap + } else { + TabRevealMotionAction::Spring + } +} + #[cfg(test)] mod resize_edge_tests { use super::{ ChromeHit, advance_spinner_phase, chrome_control_centers, contains_rect, in_chrome_bar, - resize_edge, spinner_dot_center, window_control_hit_rect, window_control_visual_rect, + resize_edge, spinner_dot_center, tab_reveal_motion_action, window_control_hit_rect, + window_control_visual_rect, }; use crate::display::SizeInfo; + use crate::display::settings::TabRevealMotion; #[test] fn maximized_or_fullscreen_windows_do_not_expose_drag_resize_edges() { @@ -1952,4 +1996,24 @@ mod resize_edge_tests { assert!((half_turn - 0.5).abs() < f32::EPSILON); assert!((wrapped - 0.01).abs() < 0.000_001); } + + #[test] + fn instant_reveal_snaps_only_when_a_hidden_tab_becomes_visible() { + assert_eq!( + tab_reveal_motion_action(TabRevealMotion::Instant, false, true, false), + super::TabRevealMotionAction::Snap + ); + assert_eq!( + tab_reveal_motion_action(TabRevealMotion::Slide, false, true, false), + super::TabRevealMotionAction::Spring + ); + assert_eq!( + tab_reveal_motion_action(TabRevealMotion::Instant, true, true, false), + super::TabRevealMotionAction::Spring + ); + assert_eq!( + tab_reveal_motion_action(TabRevealMotion::Instant, true, true, true), + super::TabRevealMotionAction::Snap + ); + } } diff --git a/nebula_app/src/display/mod.rs b/nebula_app/src/display/mod.rs index 5b2ff84..f56ba44 100644 --- a/nebula_app/src/display/mod.rs +++ b/nebula_app/src/display/mod.rs @@ -512,10 +512,14 @@ pub enum AiLogo { OpenCode, /// Pi's block-built `pi` mark from pi.dev. Pi, + /// xAI's Grok mark. + Grok, } -/// Official logo assets (Wikimedia SVG renders, 64 px, alpha-transparent). +/// Embedded brand logo assets, kept at their source pixel dimensions. const AI_LOGO_CLAUDE_PNG: &[u8] = include_bytes!("../../../extra/logo/ai_claude.png"); +const AI_LOGO_GROK_DARK_PNG: &[u8] = include_bytes!("../../../extra/logo/ai_grok_dark.png"); +const AI_LOGO_GROK_LIGHT_PNG: &[u8] = include_bytes!("../../../extra/logo/ai_grok_light.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. @@ -526,6 +530,58 @@ const AI_LOGO_PI_PNG: &[u8] = include_bytes!("../../../extra/logo/ai_pi.png"); /// (which starts at 1), so the two id spaces can share the renderer cache. const AI_LOGO_ID_BASE: u64 = 1 << 62; +fn prepare_ai_logo_texture( + rgba: &[u8], + width: u32, + height: u32, + target_size: u32, +) -> (Vec, u32, u32) { + use image::imageops::FilterType; + + let target_size = target_size.max(1); + let source = image::RgbaImage::from_raw(width, height, rgba.to_vec()) + .expect("decoded logo dimensions must match its RGBA buffer"); + let resized = image::imageops::resize(&source, target_size, target_size, FilterType::Lanczos3); + let pixels = resized.as_raw(); + let (mass, weighted_x, weighted_y) = pixels.chunks_exact(4).enumerate().fold( + (0.0, 0.0, 0.0), + |(mass, weighted_x, weighted_y), (index, pixel)| { + let alpha = f64::from(pixel[3]); + let x = (index as u32 % target_size) as f64 + 0.5; + let y = (index as u32 / target_size) as f64 + 0.5; + (mass + alpha, weighted_x + x * alpha, weighted_y + y * alpha) + }, + ); + if mass == 0.0 { + return (resized.into_raw(), target_size, target_size); + } + + let center = f64::from(target_size) / 2.0; + let shift_x = (center - weighted_x / mass).round() as i32; + let shift_y = (center - weighted_y / mass).round() as i32; + if shift_x == 0 && shift_y == 0 { + return (resized.into_raw(), target_size, target_size); + } + + let mut centered = vec![0; pixels.len()]; + for y in 0..target_size as i32 { + for x in 0..target_size as i32 { + let dest_x = x + shift_x; + let dest_y = y + shift_y; + if !(0..target_size as i32).contains(&dest_x) + || !(0..target_size as i32).contains(&dest_y) + { + continue; + } + let source_index = ((y as u32 * target_size + x as u32) * 4) as usize; + let dest_index = ((dest_y as u32 * target_size + dest_x as u32) * 4) as usize; + centered[dest_index..dest_index + 4] + .copy_from_slice(&pixels[source_index..source_index + 4]); + } + } + (centered, target_size, target_size) +} + /// 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. @@ -538,6 +594,7 @@ pub(crate) fn ai_logo(program: &str) -> Option { "codex" => Some(AiLogo::OpenAi), "opencode" => Some(AiLogo::OpenCode), "pi" => Some(AiLogo::Pi), + "grok" | "grok-cli" => Some(AiLogo::Grok), _ => None, } } @@ -1033,6 +1090,7 @@ pub struct Display { /// 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, + nebula_tab_was_visible: 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, @@ -1115,12 +1173,15 @@ pub struct Display { 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. + /// Per-tab real AI brand logo, 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 (and, where appropriate, theme-tinted) logo pixels with stable renderer texture ids, + /// keyed by (logo, ink, target physical size). Decode, tint and high-quality + /// downsampling run once per key. + nebula_ai_logo_cache: std::collections::HashMap< + (AiLogo, [u8; 3], u32), + (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: @@ -1196,6 +1257,7 @@ pub struct Display { pub nebula_cursor_blink: bool, /// 交互: WT-style copyOnSelect. Off = right-click copies / pastes instead. pub nebula_copy_on_select: bool, + pub nebula_tab_reveal_motion: settings::TabRevealMotion, pub nebula_font_family: String, nebula_font_families: Vec, nebula_font_notice: Option, @@ -1589,6 +1651,7 @@ impl Display { directory_history: crate::directory_history::global(), nebula_commands: nebula_commands_handle(), nebula_tab_anim: Vec::new(), + nebula_tab_was_visible: vec![true], nebula_scrollbar_drag: None, nebula_split_reveal: None, nebula_confirm: required_font_install, @@ -1631,6 +1694,7 @@ impl Display { nebula_cursor_shape: settings_init.cursor_shape, nebula_cursor_blink: settings_init.cursor_blink, nebula_copy_on_select: settings_init.copy_on_select, + nebula_tab_reveal_motion: settings_init.tab_reveal, nebula_font_family: settings_init.font_family, nebula_font_families, nebula_font_notice: None, @@ -2280,20 +2344,27 @@ impl Display { &mut self, logo: AiLogo, ink: Rgb, + target_size: u32, ) -> 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. + // Claude and Grok keep their source colors. Grok ships official dark + // and light marks, selected to match the chrome ink without tinting. + let grok_uses_light_mark = + u32::from(ink.r) * 299 + u32::from(ink.g) * 587 + u32::from(ink.b) * 114 >= 128_000; let key = match logo { - AiLogo::Claude => (logo, [0, 0, 0]), - AiLogo::OpenAi | AiLogo::OpenCode | AiLogo::Pi => (logo, [ink.r, ink.g, ink.b]), + AiLogo::Claude => (logo, [0, 0, 0], target_size), + AiLogo::Grok if grok_uses_light_mark => (logo, [255, 255, 255], target_size), + AiLogo::Grok => (logo, [0, 0, 0], target_size), + AiLogo::OpenAi | AiLogo::OpenCode | AiLogo::Pi => { + (logo, [ink.r, ink.g, ink.b], target_size) + }, }; 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::Grok if grok_uses_light_mark => AI_LOGO_GROK_LIGHT_PNG, + AiLogo::Grok => AI_LOGO_GROK_DARK_PNG, AiLogo::OpenAi => AI_LOGO_OPENAI_PNG, AiLogo::OpenCode => AI_LOGO_OPENCODE_PNG, AiLogo::Pi => AI_LOGO_PI_PNG, @@ -2323,8 +2394,13 @@ impl Display { px[2] = (ink.b as u16 * luma / 255) as u8; } }, - AiLogo::Claude => {}, + AiLogo::Claude | AiLogo::Grok => {}, } + let (rgba, width, height) = if logo == AiLogo::Grok { + prepare_ai_logo_texture(&rgba, width, height, target_size) + } else { + (rgba, width, height) + }; 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()); @@ -2669,6 +2745,7 @@ impl Display { cursor_shape: self.nebula_cursor_shape, cursor_blink: self.nebula_cursor_blink, copy_on_select: self.nebula_copy_on_select, + tab_reveal: self.nebula_tab_reveal_motion, preview_bg: self.preview_terminal_bg(), preview_fg: { let bg = self.preview_terminal_bg(); @@ -2885,6 +2962,15 @@ impl Display { self.pending_update.dirty = true; } + pub fn set_tab_reveal_option(&mut self, index: usize) { + if let Some(motion) = settings::TAB_REVEAL_OPTIONS.get(index) { + self.nebula_tab_reveal_motion = *motion; + 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 { @@ -3990,6 +4076,7 @@ impl Display { cursor_shape: self.nebula_cursor_shape, cursor_blink: self.nebula_cursor_blink, copy_on_select: self.nebula_copy_on_select, + tab_reveal: self.nebula_tab_reveal_motion, theme: self.nebula_theme_preference, follow_system_theme: self.nebula_follow_system_theme, pinned_hosts: self.nebula_pinned_hosts.clone(), @@ -4060,6 +4147,7 @@ impl Display { self.nebula_fetch_enabled = settings.fetch; self.nebula_powerline_enabled = settings.powerline; self.nebula_keep_session = settings.keep_session; + self.nebula_tab_reveal_motion = settings.tab_reveal; self.nebula_window_opacity = settings.opacity; self.nebula_background = if settings.follow_system_theme { Some(active_theme.palette().term_bg) @@ -6903,15 +6991,102 @@ mod nebula_ux_tests { use winit::window::Theme as WinitTheme; use super::{ - NebulaConfirm, SizeInfo, alt_screen_vertical_padding_bands, nebula_command_hint, - remove_ssh_host_from_lists, replays_untrusted_terminal_output, restore_ssh_host_to_lists, - system_theme_snapshot, + AI_LOGO_GROK_DARK_PNG, AI_LOGO_GROK_LIGHT_PNG, AiLogo, NebulaConfirm, SizeInfo, ai_logo, + alt_screen_vertical_padding_bands, extract_program, nebula_command_hint, + prepare_ai_logo_texture, program_icon, remove_ssh_host_from_lists, + replays_untrusted_terminal_output, restore_ssh_host_to_lists, system_theme_snapshot, }; fn strings(values: &[&str]) -> Vec { values.iter().map(|value| (*value).to_owned()).collect() } + fn logo_for_command(command: &str) -> Option { + extract_program(command).as_deref().and_then(ai_logo) + } + + #[test] + fn running_program_identity_normalizes_grok_without_changing_fallbacks() { + for command in [ + "grok", + "grok-cli", + "GROK.CMD", + "C:\\Tools\\GROK-CLI.EXE --help", + "/usr/local/bin/grok", + ] { + assert_eq!(logo_for_command(command), Some(AiLogo::Grok), "{command}"); + } + + assert_eq!(logo_for_command("codex"), Some(AiLogo::OpenAi)); + assert_eq!(logo_for_command("C:\\Tools\\CLAUDE.EXE --resume"), Some(AiLogo::Claude)); + assert_eq!(program_icon("cargo"), "\u{e7a8}"); + assert_eq!(logo_for_command("unlisted-program"), None); + assert_eq!(program_icon("unlisted-program"), "\u{f04b}"); + assert_eq!(extract_program(""), None); + } + + #[cfg(all(feature = "png", not(target_os = "macos")))] + #[test] + fn official_grok_logos_are_embedded_unchanged_and_decodable() { + use sha2::{Digest, Sha256}; + + let assets = [ + ( + AI_LOGO_GROK_DARK_PNG, + [ + 0x37, 0xdd, 0xbc, 0xb6, 0xe2, 0xa7, 0xf2, 0xe4, 0xb3, 0xbe, 0x78, 0xa7, 0xd4, + 0x12, 0x96, 0xa3, 0xbc, 0x7e, 0xdf, 0x69, 0x26, 0x36, 0x24, 0x34, 0xef, 0xc0, + 0x0d, 0xf5, 0xa5, 0x6a, 0x35, 0x86, + ], + ), + ( + AI_LOGO_GROK_LIGHT_PNG, + [ + 0x35, 0x90, 0x56, 0xee, 0x89, 0x83, 0xcf, 0xa0, 0xba, 0x7e, 0x72, 0x79, 0x50, + 0x78, 0xc7, 0xc0, 0xdd, 0xf6, 0xc5, 0xd7, 0xa1, 0x87, 0x04, 0x01, 0xab, 0x96, + 0x0e, 0xd4, 0xf9, 0xdf, 0x9e, 0x53, + ], + ), + ]; + + for (png, expected_sha256) in assets { + assert_eq!(Sha256::digest(png).as_slice(), expected_sha256); + let (width, height, rgba) = crate::renderer::image::decode_png_bytes(png).unwrap(); + assert_eq!((width, height), (1024, 1024)); + assert!(rgba.chunks_exact(4).any(|pixel| pixel[3] != 0)); + } + } + + #[cfg(all(feature = "png", not(target_os = "macos")))] + #[test] + fn official_grok_logos_prepare_sharp_optically_centered_physical_textures() { + for png in [AI_LOGO_GROK_DARK_PNG, AI_LOGO_GROK_LIGHT_PNG] { + let (width, height, rgba) = crate::renderer::image::decode_png_bytes(png).unwrap(); + let (rgba, width, height) = prepare_ai_logo_texture(&rgba, width, height, 18); + + assert_eq!((width, height, rgba.len()), (18, 18, 18 * 18 * 4)); + let alpha_levels = rgba + .chunks_exact(4) + .map(|pixel| pixel[3]) + .filter(|alpha| *alpha > 0) + .collect::>(); + assert!(alpha_levels.len() >= 8); + + let (mass, weighted_x, weighted_y) = rgba.chunks_exact(4).enumerate().fold( + (0.0, 0.0, 0.0), + |(mass, weighted_x, weighted_y), (index, pixel)| { + let alpha = f64::from(pixel[3]); + let x = (index as u32 % width) as f64 + 0.5; + let y = (index as u32 / width) as f64 + 0.5; + (mass + alpha, weighted_x + x * alpha, weighted_y + y * alpha) + }, + ); + let center = f64::from(width) / 2.0; + assert!((weighted_x / mass - center).abs() <= 0.5); + assert!((weighted_y / mass - center).abs() <= 0.5); + } + } + #[test] fn log_replay_commands_do_not_receive_terminal_query_answers() { for command in [ diff --git a/nebula_app/src/display/settings.rs b/nebula_app/src/display/settings.rs index ef4b1c1..ce28645 100644 --- a/nebula_app/src/display/settings.rs +++ b/nebula_app/src/display/settings.rs @@ -105,6 +105,7 @@ pub enum SettingsDropdown { Language, Accept, CursorShape, + TabReveal, /// 背景色:色板网格 + 16 进制输入的专用浮层(不是通用行列表)。 BackgroundColor, } @@ -151,10 +152,44 @@ pub(super) const LANGUAGE_OPTIONS: [LanguagePreference; 3] = pub(super) const ACCEPT_OPTIONS: [AcceptKey; 3] = [AcceptKey::Both, AcceptKey::Tab, AcceptKey::Right]; +pub(super) const TAB_REVEAL_OPTIONS: [TabRevealMotion; 2] = + [TabRevealMotion::Slide, TabRevealMotion::Instant]; + /// 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]; +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub(crate) enum TabRevealMotion { + #[default] + Slide, + Instant, +} + +impl TabRevealMotion { + fn parse(value: &str) -> Option { + match value.trim().to_ascii_lowercase().as_str() { + "slide" => Some(Self::Slide), + "instant" => Some(Self::Instant), + _ => None, + } + } + + fn settings_value(self) -> &'static str { + match self { + Self::Slide => "slide", + Self::Instant => "instant", + } + } +} + +fn tab_reveal_label(motion: TabRevealMotion, language: UiLanguage) -> &'static str { + match motion { + TabRevealMotion::Slide => language.pick("滑动", "Slide"), + TabRevealMotion::Instant => language.pick("立即", "Instant"), + } +} + pub(super) fn cursor_shape_label(shape: CursorShape, language: UiLanguage) -> &'static str { match shape { CursorShape::Beam => language.pick("条形(│)", "Bar (│)"), @@ -210,6 +245,8 @@ pub enum SettingsHit { CursorBlinkToggle, /// 交互: copy-on-select toggle row. CopyOnSelectToggle, + TabRevealDropdown, + TabRevealOption(usize), /// Language combobox trigger (options resolve to [`SettingsHit::Language`]). LanguageDropdown, /// Expanded dropdown option rows for the cycle-style settings. @@ -266,6 +303,7 @@ pub(super) struct NebulaRuntimeSettings { /// 交互: selecting text copies it to the clipboard immediately (WT's /// copyOnSelect). Off = right-click copies instead. pub(super) copy_on_select: bool, + pub(super) tab_reveal: TabRevealMotion, pub(super) fetch: bool, pub(super) powerline: bool, /// Window close keeps the PTYs alive in the resident process (detach / @@ -314,6 +352,7 @@ pub(super) fn nebula_settings_load(config: &UiConfig) -> NebulaRuntimeSettings { cursor_shape: CursorShape::Beam, cursor_blink: true, copy_on_select: true, + tab_reveal: TabRevealMotion::Slide, // 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). @@ -386,6 +425,9 @@ pub(super) fn nebula_settings_load(config: &UiConfig) -> NebulaRuntimeSettings { }, 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(("tab_reveal", v)) => { + settings.tab_reveal = TabRevealMotion::parse(v).unwrap_or_default(); + }, Some(("startup_directory", v)) => { let path = std::path::PathBuf::from(v.trim()); if path.is_dir() { @@ -559,7 +601,7 @@ pub(super) fn nebula_settings_write(settings: &NebulaRuntimeSettings) { 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={}\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}\n", + "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={}\ntab_reveal={}\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}\n", settings.language.as_str(), settings.follow_system_theme as u8, settings.ghost as u8, @@ -567,6 +609,7 @@ pub(super) fn nebula_settings_write(settings: &NebulaRuntimeSettings) { cursor_shape_settings_value(settings.cursor_shape), settings.cursor_blink as u8, settings.copy_on_select as u8, + settings.tab_reveal.settings_value(), settings.fetch as u8, settings.powerline as u8, settings.keep_session as u8, @@ -623,6 +666,7 @@ struct SettingsGeometry { background_image_opacity_slider: (f32, f32, f32, f32), /// 交互: copy-on-select toggle row. copy_on_select: (f32, f32, f32, f32), + tab_reveal: (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. @@ -781,10 +825,11 @@ fn settings_geometry( }; let profiles_h = s(profiles_end + 32.0 - 72.0); - // 交互: a single copy-on-select toggle row for now. + // 交互: clipboard behaviour and tab-section reveal motion. let interaction_y0 = 146.0; - let interaction_h = s(interaction_y0 + ROW_H + 32.0 - 72.0); + let interaction_h = s(interaction_y0 + ROW_H * 2.0 + 32.0 - 72.0); let copy_on_select = (row_x, at(interaction_y0), row_w, row_h); + let tab_reveal = (row_x, at(interaction_y0 + ROW_H), row_w, row_h); // Keymap: one contiguous group of read-only shortcut rows. let keymap_y0 = 146.0; @@ -849,6 +894,7 @@ fn settings_geometry( hidden_host_row0: (row_x, at(hidden_y0), row_w, row_h), hidden_host_count, copy_on_select, + tab_reveal, reset: (popup_x + popup_w - s(170.0), popup_y + s(24.0), s(150.0), s(42.0)), content_top, appearance_h, @@ -962,6 +1008,9 @@ fn dropdown_anchor( (Section::Profiles, SettingsDropdown::Accept) => { Some((anchor(geometry.accept), ACCEPT_OPTIONS.len())) }, + (Section::Interaction, SettingsDropdown::TabReveal) => { + Some((anchor(geometry.tab_reveal), TAB_REVEAL_OPTIONS.len())) + }, (Section::Appearance, SettingsDropdown::BackgroundFit) => { Some((anchor(geometry.background_image_fit), BACKGROUND_FIT_OPTIONS.len())) }, @@ -1049,6 +1098,7 @@ pub fn settings_hit( SettingsDropdown::BackgroundAlignment => SettingsHit::AlignOption(index), SettingsDropdown::Language => SettingsHit::Language(LANGUAGE_OPTIONS[index]), SettingsDropdown::Accept => SettingsHit::AcceptOption(index), + SettingsDropdown::TabReveal => SettingsHit::TabRevealOption(index), SettingsDropdown::CursorShape => SettingsHit::CursorShapeOption(index), // 背景色浮层在上方特判处理,走不到通用行列表。 SettingsDropdown::BackgroundColor => SettingsHit::Panel, @@ -1168,6 +1218,9 @@ pub fn settings_hit( if contains_rect(geometry.copy_on_select, x, y) { return SettingsHit::CopyOnSelectToggle; } + if contains_rect(geometry.tab_reveal, x, y) { + return SettingsHit::TabRevealDropdown; + } }, NebulaSettingsSection::Keymap => {}, NebulaSettingsSection::Advanced => { @@ -1225,6 +1278,7 @@ pub(super) struct SettingsView { pub(super) cursor_shape: CursorShape, pub(super) cursor_blink: bool, pub(super) copy_on_select: bool, + pub(super) tab_reveal: TabRevealMotion, /// 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, @@ -1268,6 +1322,9 @@ fn dropdown_selected_index(view: &SettingsView, dropdown: SettingsDropdown) -> O LANGUAGE_OPTIONS.iter().position(|preference| *preference == view.language_preference) }, SettingsDropdown::Accept => ACCEPT_OPTIONS.iter().position(|key| *key == view.accept), + SettingsDropdown::TabReveal => { + TAB_REVEAL_OPTIONS.iter().position(|motion| *motion == view.tab_reveal) + }, SettingsDropdown::CursorShape => { CURSOR_SHAPE_OPTIONS.iter().position(|shape| *shape == view.cursor_shape) }, @@ -1287,6 +1344,7 @@ fn dropdown_hover_index(hover: SettingsHit, dropdown: SettingsDropdown) -> Optio LANGUAGE_OPTIONS.iter().position(|option| *option == preference) }, (SettingsDropdown::Accept, SettingsHit::AcceptOption(index)) => Some(index), + (SettingsDropdown::TabReveal, SettingsHit::TabRevealOption(index)) => Some(index), (SettingsDropdown::CursorShape, SettingsHit::CursorShapeOption(index)) => Some(index), _ => None, } @@ -1757,13 +1815,21 @@ pub(super) fn push_quads( } }, NebulaSettingsSection::Interaction => { - group_frame(quads, geometry.copy_on_select, 1); + group_frame(quads, geometry.copy_on_select, 2); row_hover( quads, geometry.copy_on_select, view.hover == SettingsHit::CopyOnSelectToggle, ); toggle(quads, &mut staged, geometry.copy_on_select, view.copy_on_select); + row_hover(quads, geometry.tab_reveal, view.hover == SettingsHit::TabRevealDropdown); + combobox( + quads, + &mut staged, + geometry.tab_reveal, + view.hover == SettingsHit::TabRevealDropdown, + view.dropdown == Some(SettingsDropdown::TabReveal), + ); }, NebulaSettingsSection::Keymap => { group_frame(quads, geometry.keymap_row0, KEYMAP_ROWS.len()); @@ -2014,6 +2080,9 @@ pub(super) fn draw_popup_text( language_label(LANGUAGE_OPTIONS[index], language).to_owned() }, SettingsDropdown::Accept => accept_label(ACCEPT_OPTIONS[index], language).to_owned(), + SettingsDropdown::TabReveal => { + tab_reveal_label(TAB_REVEAL_OPTIONS[index], language).to_owned() + }, SettingsDropdown::CursorShape => { cursor_shape_label(CURSOR_SHAPE_OPTIONS[index], language).to_owned() }, @@ -2874,6 +2943,26 @@ pub(super) fn draw_text( sk.ink, ); } + if visible(geometry.tab_reveal.1, geometry.tab_reveal.3) { + row_label( + r, + gc, + size, + scale, + &sk, + geometry.tab_reveal, + language.pick("标签展开", "Tab reveal"), + "", + sk.ink, + ); + combobox_value( + r, + gc, + geometry.tab_reveal, + tab_reveal_label(view.tab_reveal, language), + sk.accent, + ); + } }, NebulaSettingsSection::Keymap => { let (kx, ky, kw, kh) = geometry.keymap_row0; @@ -2948,7 +3037,7 @@ pub(super) fn draw_text( #[cfg(test)] mod opacity_tests { - use super::opacity_from_pointer; + use super::{TabRevealMotion, opacity_from_pointer}; #[test] fn slider_pointer_maps_to_clamped_fraction() { @@ -2959,4 +3048,15 @@ mod opacity_tests { assert_eq!(opacity_from_pointer(300.0, slider), 1.0); assert_eq!(opacity_from_pointer(350.0, slider), 1.0); } + + #[test] + fn tab_reveal_motion_defaults_compatibly_and_round_trips() { + assert_eq!(TabRevealMotion::default(), TabRevealMotion::Slide); + assert_eq!(TabRevealMotion::parse("slide"), Some(TabRevealMotion::Slide)); + assert_eq!(TabRevealMotion::parse("INSTANT"), Some(TabRevealMotion::Instant)); + assert_eq!(TabRevealMotion::parse("unknown").unwrap_or_default(), TabRevealMotion::Slide); + for value in [TabRevealMotion::Slide, TabRevealMotion::Instant] { + assert_eq!(TabRevealMotion::parse(value.settings_value()), Some(value)); + } + } } diff --git a/nebula_app/src/input/chrome.rs b/nebula_app/src/input/chrome.rs index 4ce8e41..eb34887 100644 --- a/nebula_app/src/input/chrome.rs +++ b/nebula_app/src/input/chrome.rs @@ -726,6 +726,18 @@ impl> Processor { self.ctx.mark_dirty(); return; }, + crate::display::SettingsHit::TabRevealDropdown => { + self.ctx + .display() + .toggle_settings_dropdown(crate::display::SettingsDropdown::TabReveal); + self.ctx.mark_dirty(); + return; + }, + crate::display::SettingsHit::TabRevealOption(index) => { + self.ctx.display().set_tab_reveal_option(index); + self.ctx.mark_dirty(); + return; + }, crate::display::SettingsHit::FontSizeUp => { let ui_scale = self.ctx.display().window.scale_factor as f32; self.ctx.change_font_size(ui_scale); @@ -1122,6 +1134,8 @@ fn settings_dropdown_keeps_open(hit: crate::display::SettingsHit) -> bool { | Hit::Language(_) | Hit::AcceptCycle | Hit::AcceptOption(_) + | Hit::TabRevealDropdown + | Hit::TabRevealOption(_) | Hit::CursorShapeDropdown | Hit::CursorShapeOption(_) | Hit::BackgroundColor diff --git a/nebula_app/src/input/mouse.rs b/nebula_app/src/input/mouse.rs index 0229d1d..e8a3ae7 100644 --- a/nebula_app/src/input/mouse.rs +++ b/nebula_app/src/input/mouse.rs @@ -291,6 +291,8 @@ impl> Processor { | crate::display::SettingsHit::CursorShapeOption(_) | crate::display::SettingsHit::CursorBlinkToggle | crate::display::SettingsHit::CopyOnSelectToggle + | crate::display::SettingsHit::TabRevealDropdown + | crate::display::SettingsHit::TabRevealOption(_) | crate::display::SettingsHit::FontSizeUp | crate::display::SettingsHit::FontSizeDown | crate::display::SettingsHit::BackgroundImageCoverChrome diff --git a/nebula_app/src/window_context.rs b/nebula_app/src/window_context.rs index 83f9e6e..b4f216b 100644 --- a/nebula_app/src/window_context.rs +++ b/nebula_app/src/window_context.rs @@ -2042,7 +2042,7 @@ impl WindowContext { }; // Program icon (Nerd Font) in front of the label while a command // runs — the sidebar shows WHAT each tab is busy with. AI clients - // with a real brand logo (claude/codex) skip the glyph: the + // with a real brand logo skip the glyph: the // display layer textures the actual mark into the icon slot. let logo = state.and_then(|s| s.running_program.as_deref()).and_then(crate::display::ai_logo);