feat: add Grok identity and tab reveal preference
This commit is contained in:
1 parent
ee9ee02819
commit
47513b9764
9 files changed
+412
-37
No files matched your search
@@ -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 |
@@ -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
@@ -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,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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user