feat: add Grok identity and tab reveal preference

This commit is contained in:
Sakyvo committed 2026-07-29 14:08:18 +08:00
1 parent ee9ee02819
commit 47513b9764
9 files changed
+412 -37

No files matched your search

+20
View File
@@ -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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+79 -15
View File
@@ -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
);
}
}
+191 -16
View File
@@ -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<u8>, 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<AiLogo> {
"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<crate::motion::Spring>,
nebula_tab_was_visible: Vec<bool>,
/// 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<f32>,
@@ -1115,12 +1173,15 @@ pub struct Display {
nebula_tab_bells: Vec<bool>,
/// Per-tab "command is running" flags driving the sidebar spinners.
nebula_tab_running: Vec<bool>,
/// 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<Option<AiLogo>>,
/// 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<Vec<u8>>, (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<Vec<u8>>, (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<String>,
nebula_font_notice: Option<String>,
@@ -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<Vec<u8>>, (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<String> {
values.iter().map(|value| (*value).to_owned()).collect()
}
fn logo_for_command(command: &str) -> Option<AiLogo> {
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::<std::collections::HashSet<_>>();
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 [
+105 -5
View File
@@ -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<Self> {
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));
}
}
}
+14
View File
@@ -726,6 +726,18 @@ impl<T: EventListener, A: ActionContext<T>> Processor<T, A> {
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
+2
View File
@@ -291,6 +291,8 @@ impl<T: EventListener, A: ActionContext<T>> Processor<T, A> {
| 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
+1 -1
View File
@@ -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);