docs(comments): rewrite comments across all crates to the guidelines
Sweep every first-party crate source (1956 .rs files) to the project comment guidelines: delete redundant restatements, decorative banners, change narration, and end-of-line comments; keep and tighten the crucial ones (invariants, bug rationale, SAFETY blocks, ported-source attribution). No functional code changed. Every edit is proven comment-only against the prior tree by a comment-stripping lexer (string/char/raw-string aware) plus a separate doctest-fence check. Where removing a comment made rustfmt or clippy want to re-lay-out adjacent code, the minimal triggering comment is restored so code tokens stay byte-identical. Gates green: cargo fmt --all --check (0 diffs), cargo check and cargo clippy --workspace --all-targets (0 warnings). Adds scripts/check_codegen_comment_guidelines.py — the enforcement gate for these guidelines (flags banners, end-of-line comments, change narration, and commented-out code).
This commit is contained in:
@@ -22,7 +22,7 @@ use super::render_mermaid::RenderMermaid;
|
||||
use super::scroll_mode::ScrollMode;
|
||||
use super::text_selection::TextSelection;
|
||||
|
||||
// -- Defaults (asserted in tests to match UiConfig::default()) --------------
|
||||
// Defaults (asserted in tests to match UiConfig::default())
|
||||
|
||||
const COMPACT_DEFAULT: bool = false;
|
||||
const TIMESTAMPS_DEFAULT: bool = true;
|
||||
@@ -52,7 +52,7 @@ const SCROLL_LINES_UNSET: u8 = 0;
|
||||
const SCROLL_LINES_MIN: u8 = 1;
|
||||
const SCROLL_LINES_MAX: u8 = 10;
|
||||
|
||||
// -- Compact mode ------------------------------------------------------------
|
||||
// Compact mode
|
||||
|
||||
thread_local! {
|
||||
static COMPACT_CURRENT: Cell<bool> = const { Cell::new(COMPACT_DEFAULT) };
|
||||
@@ -75,13 +75,13 @@ pub fn load() -> bool {
|
||||
COMPACT_CURRENT.with(|c| c.get())
|
||||
}
|
||||
|
||||
/// Replace cached `compact_mode` (optimistic update or rollback).
|
||||
/// Replace cached `compact_mode` (optimistic write or rollback).
|
||||
pub fn set(enabled: bool) {
|
||||
COMPACT_CURRENT.with(|c| c.set(enabled));
|
||||
COMPACT_LOADED.with(|l| l.set(true));
|
||||
}
|
||||
|
||||
// -- Timestamps --------------------------------------------------------------
|
||||
// Timestamps
|
||||
|
||||
thread_local! {
|
||||
static TIMESTAMPS_CURRENT: Cell<bool> = const { Cell::new(TIMESTAMPS_DEFAULT) };
|
||||
@@ -108,7 +108,7 @@ pub fn set_timestamps(enabled: bool) {
|
||||
TIMESTAMPS_LOADED.with(|l| l.set(true));
|
||||
}
|
||||
|
||||
// -- Timeline sidebar ----------------------------------------------------------
|
||||
// Timeline sidebar
|
||||
|
||||
thread_local! {
|
||||
static TIMELINE_CURRENT: Cell<bool> = const { Cell::new(TIMELINE_DEFAULT) };
|
||||
@@ -135,7 +135,7 @@ pub fn set_show_timeline(enabled: bool) {
|
||||
TIMELINE_LOADED.with(|l| l.set(true));
|
||||
}
|
||||
|
||||
// -- Simple mode --------------------------------------------------------------
|
||||
// Simple mode
|
||||
|
||||
thread_local! {
|
||||
static SIMPLE_MODE_CURRENT: Cell<bool> = const { Cell::new(SIMPLE_MODE_DEFAULT) };
|
||||
@@ -162,7 +162,7 @@ pub fn set_simple_mode(enabled: bool) {
|
||||
SIMPLE_MODE_LOADED.with(|l| l.set(true));
|
||||
}
|
||||
|
||||
// -- Vim mode (scrollback) ---------------------------------------------------
|
||||
// Vim mode (scrollback)
|
||||
|
||||
thread_local! {
|
||||
static VIM_MODE_CURRENT: Cell<bool> = const { Cell::new(VIM_MODE_DEFAULT) };
|
||||
@@ -197,7 +197,7 @@ pub fn set_vim_mode(enabled: bool) {
|
||||
VIM_MODE_LOADED.with(|l| l.set(true));
|
||||
}
|
||||
|
||||
// -- Show thinking blocks ----------------------------------------------------
|
||||
// Show thinking blocks
|
||||
|
||||
thread_local! {
|
||||
static SHOW_THINKING_BLOCKS_CURRENT: Cell<bool> =
|
||||
@@ -228,7 +228,7 @@ pub fn set_show_thinking_blocks(enabled: bool) {
|
||||
SHOW_THINKING_BLOCKS_LOADED.with(|l| l.set(true));
|
||||
}
|
||||
|
||||
// -- Group tool verbs ---------------------------------------------------------
|
||||
// Group tool verbs
|
||||
|
||||
thread_local! {
|
||||
static GROUP_TOOL_VERBS_CURRENT: Cell<bool> =
|
||||
@@ -259,7 +259,7 @@ pub fn set_group_tool_verbs(enabled: bool) {
|
||||
GROUP_TOOL_VERBS_LOADED.with(|l| l.set(true));
|
||||
}
|
||||
|
||||
// -- Collapsed edit blocks -----------------------------------------------------
|
||||
// Collapsed edit blocks
|
||||
|
||||
thread_local! {
|
||||
static COLLAPSED_EDIT_BLOCKS_CURRENT: Cell<bool> =
|
||||
@@ -292,7 +292,7 @@ pub fn set_collapsed_edit_blocks(enabled: bool) {
|
||||
COLLAPSED_EDIT_BLOCKS_LOADED.with(|l| l.set(true));
|
||||
}
|
||||
|
||||
// -- Prompt suggestions (tab autocomplete) -----------------------------------
|
||||
// Prompt suggestions (tab autocomplete)
|
||||
|
||||
thread_local! {
|
||||
static PROMPT_SUGGESTIONS_CURRENT: Cell<bool> =
|
||||
@@ -324,7 +324,7 @@ pub fn set_prompt_suggestions(enabled: bool) {
|
||||
PROMPT_SUGGESTIONS_LOADED.with(|l| l.set(true));
|
||||
}
|
||||
|
||||
// -- keep_text_selection (`flash` | `hold`) ----------------------------------
|
||||
// keep_text_selection (`flash` | `hold`)
|
||||
|
||||
thread_local! {
|
||||
static KEEP_TEXT_SELECTION_CURRENT: Cell<TextSelection> =
|
||||
@@ -350,7 +350,7 @@ pub fn set_keep_text_selection(value: TextSelection) {
|
||||
KEEP_TEXT_SELECTION_LOADED.with(|l| l.set(true));
|
||||
}
|
||||
|
||||
// -- Scroll speed ------------------------------------------------------------
|
||||
// Scroll speed
|
||||
|
||||
thread_local! {
|
||||
static SCROLL_SPEED_CURRENT: Cell<u8> = const { Cell::new(SCROLL_SPEED_DEFAULT) };
|
||||
@@ -383,7 +383,7 @@ pub fn set_scroll_speed(speed: u8) {
|
||||
SCROLL_SPEED_LOADED.with(|l| l.set(true));
|
||||
}
|
||||
|
||||
// -- Scroll mode (auto | wheel | trackpad) -----------------------------------
|
||||
// Scroll mode (auto | wheel | trackpad)
|
||||
|
||||
thread_local! {
|
||||
static SCROLL_MODE_CURRENT: Cell<ScrollMode> = const { Cell::new(SCROLL_MODE_DEFAULT) };
|
||||
@@ -418,7 +418,7 @@ pub fn set_scroll_mode(value: ScrollMode) {
|
||||
SCROLL_MODE_LOADED.with(|l| l.set(true));
|
||||
}
|
||||
|
||||
// -- Invert scroll ------------------------------------------------------------
|
||||
// Invert scroll
|
||||
|
||||
thread_local! {
|
||||
static INVERT_SCROLL_CURRENT: Cell<bool> = const { Cell::new(INVERT_SCROLL_DEFAULT) };
|
||||
@@ -453,7 +453,7 @@ pub fn set_invert_scroll(enabled: bool) {
|
||||
INVERT_SCROLL_LOADED.with(|l| l.set(true));
|
||||
}
|
||||
|
||||
// -- Scroll lines ------------------------------------------------------------
|
||||
// Scroll lines
|
||||
|
||||
thread_local! {
|
||||
static SCROLL_LINES_CURRENT: Cell<u8> = const { Cell::new(SCROLL_LINES_UNSET) };
|
||||
@@ -494,7 +494,7 @@ pub fn set_scroll_lines(lines: u8) {
|
||||
SCROLL_LINES_LOADED.with(|l| l.set(true));
|
||||
}
|
||||
|
||||
// -- Render mermaid (auto | on | off) ---------------------------------------
|
||||
// Render mermaid (auto | on | off)
|
||||
|
||||
thread_local! {
|
||||
static RENDER_MERMAID_CURRENT: Cell<RenderMermaid> = const { Cell::new(RenderMermaid::Auto) };
|
||||
@@ -531,13 +531,13 @@ fn render_mermaid_from_config_str(value: Option<&str>) -> RenderMermaid {
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Replace cached `render_mermaid` (optimistic update from the settings modal).
|
||||
/// Replace cached `render_mermaid` (optimistic write from the settings modal).
|
||||
pub fn set_render_mermaid(value: RenderMermaid) {
|
||||
RENDER_MERMAID_CURRENT.with(|c| c.set(value));
|
||||
RENDER_MERMAID_LOADED.with(|l| l.set(true));
|
||||
}
|
||||
|
||||
// -- Prime + read path ------------------------------------------------------
|
||||
// Prime + read path
|
||||
|
||||
/// Seed all caches from the live `UiConfig` at startup so subsequent
|
||||
/// `load*()` calls never hit disk on the render hot path.
|
||||
@@ -643,7 +643,7 @@ fn load_str_from_effective_config(key: &str) -> Option<String> {
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
// -- Tests -------------------------------------------------------------------
|
||||
// Tests
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
@@ -915,7 +915,7 @@ mod tests {
|
||||
#[test]
|
||||
fn caches_are_independent() {
|
||||
std::thread::spawn(|| {
|
||||
// ── compact independent (the other two stay true) ──
|
||||
// compact independent (the other two stay true)
|
||||
set(false);
|
||||
set_timestamps(true);
|
||||
set_simple_mode(true);
|
||||
@@ -929,7 +929,7 @@ mod tests {
|
||||
"simple_mode must NOT toggle when compact changed"
|
||||
);
|
||||
|
||||
// ── timestamps independent ──
|
||||
// timestamps independent
|
||||
set(true);
|
||||
set_timestamps(false);
|
||||
set_simple_mode(true);
|
||||
@@ -940,7 +940,7 @@ mod tests {
|
||||
"simple_mode must NOT toggle when timestamps changed"
|
||||
);
|
||||
|
||||
// ── simple_mode independent ──
|
||||
// simple_mode independent
|
||||
set(true);
|
||||
set_timestamps(true);
|
||||
set_simple_mode(false);
|
||||
|
||||
@@ -10,9 +10,7 @@ use ratatui::style::Color;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use toml_edit::{DocumentMut, Item, RawString};
|
||||
|
||||
// ============================================================================
|
||||
// Runtime Config (used by render code)
|
||||
// ============================================================================
|
||||
|
||||
/// Background style for block content area.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
|
||||
@@ -173,7 +171,8 @@ impl Default for ScrollbackDisplayConfig {
|
||||
line_under_last_entry: false,
|
||||
collapsed_accent_char: crate::glyphs::collapsed_accent().to_string(),
|
||||
dim_accent: 0.5,
|
||||
group_selection_split: true, // Mode B by default
|
||||
// Mode B by default
|
||||
group_selection_split: true,
|
||||
highlight_overlays_border: false,
|
||||
expandable_indicator: true,
|
||||
expandable_indicator_running: true,
|
||||
@@ -208,7 +207,8 @@ impl Default for LayoutConfig {
|
||||
outer_hpad_left: 2,
|
||||
outer_hpad_right: 2,
|
||||
block_pad_left: 2,
|
||||
block_pad_right: 2, // Match left padding for symmetry
|
||||
// Match left padding for symmetry
|
||||
block_pad_right: 2,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -289,8 +289,10 @@ impl Default for ScrollbarConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
gap_left: 0, // Content adjacent to scrollbar
|
||||
gap_right: 0, // Scrollbar at screen edge
|
||||
// Content adjacent to scrollbar
|
||||
gap_left: 0,
|
||||
// Scrollbar at screen edge
|
||||
gap_right: 0,
|
||||
scrollbar_bg: None,
|
||||
scrollbar_fg: None,
|
||||
}
|
||||
@@ -587,8 +589,8 @@ pub struct ToolConfig {
|
||||
pub dim_details: bool,
|
||||
/// Bullet/icon character rendered before tool call headers.
|
||||
pub bullet: ToolBullet,
|
||||
// Note: bullet_accent and bullet_color were removed in the scrollback-v2 refactor.
|
||||
// Bullet color is now determined by BlockContent::bullet() — each block type
|
||||
// Note: bullet_accent and bullet_color are not configurable here.
|
||||
// Bullet color is determined by BlockContent::bullet() — each block type
|
||||
// decides its own bullet color based on state (accent color, error, default).
|
||||
// Dimming for collapsed+groupable blocks is handled by EntryRenderer.
|
||||
// TODO(dim_muted): add a dim factor for collapsed text styling (not just bullet/accent).
|
||||
@@ -658,7 +660,8 @@ pub struct ListDirConfig {
|
||||
impl Default for ListDirConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
terminal_bg: true, // Default: dark background for output
|
||||
// Default: dark background for output
|
||||
terminal_bg: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -705,25 +708,12 @@ impl Default for ExecuteConfig {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Raw Config (for TOML serde)
|
||||
// ============================================================================
|
||||
//
|
||||
// ╔═══════════════════════════════════════════════════════════════════════════╗
|
||||
// ║ MAINTAINER NOTE: When adding/changing fields or sections: ║
|
||||
// ║ ║
|
||||
// ║ 1. Add doc comments (///) to ALL fields in Raw* structs - they become ║
|
||||
// ║ TOML comments via the `DocumentedFields` derive macro. ║
|
||||
// ║ ║
|
||||
// ║ 2. If adding a new section (e.g., RawNewBlockConfig): ║
|
||||
// ║ - Add it to RawBlocksConfig (or appropriate parent) ║
|
||||
// ║ - Add corresponding runtime config (NewBlockConfig) ║
|
||||
// ║ - Add From<RawNewBlockConfig> for NewBlockConfig conversion ║
|
||||
// ║ - Add annotate_table call in to_toml_with_comments() below! ║
|
||||
// ║ ║
|
||||
// ║ 3. The to_toml_with_comments() method generates the default config file ║
|
||||
// ║ with comments. Update it when adding new sections. ║
|
||||
// ╚═══════════════════════════════════════════════════════════════════════════╝
|
||||
// MAINTAINER NOTE — when adding or changing fields or sections:
|
||||
// 1. Put `///` on every Raw* field (DocumentedFields emits TOML comments).
|
||||
// 2. For a new section: Raw* + runtime config + From impl + annotate_table.
|
||||
// 3. Keep to_toml_with_comments() in sync so the default file stays documented.
|
||||
|
||||
/// Root appearance configuration (TOML format).
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, Documented, DocumentedFields)]
|
||||
@@ -1259,7 +1249,7 @@ pub struct RawToolConfig {
|
||||
/// "none", "dot" (·), "small-circle" (•), "circle" (●),
|
||||
/// "small-triangle" (▸), "triangle" (▶), "diamond" (◆).
|
||||
pub bullet: RawToolBullet,
|
||||
// Note: bullet_accent and bullet_color removed — see ToolConfig comment.
|
||||
// Note: bullet_accent and bullet_color are not configurable — see ToolConfig comment.
|
||||
}
|
||||
|
||||
impl Default for RawToolConfig {
|
||||
@@ -1284,7 +1274,8 @@ pub struct RawListDirConfig {
|
||||
impl Default for RawListDirConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
terminal_bg: true, // Default: dark background for output
|
||||
// Default: dark background for output
|
||||
terminal_bg: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1364,9 +1355,7 @@ impl From<RawBlockBackground> for BlockBackground {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Raw → Runtime Conversion
|
||||
// ============================================================================
|
||||
|
||||
impl From<RawAppearanceConfig> for AppearanceConfig {
|
||||
fn from(raw: RawAppearanceConfig) -> Self {
|
||||
@@ -1376,7 +1365,8 @@ impl From<RawAppearanceConfig> for AppearanceConfig {
|
||||
collapse_unfocused: raw.prompt.collapse_unfocused,
|
||||
mouse_hover: raw.prompt.mouse_hover,
|
||||
show_prefix: raw.prompt.show_prefix,
|
||||
compact: false, // runtime-only, not persisted in TOML
|
||||
// runtime-only, not persisted in TOML
|
||||
compact: false,
|
||||
},
|
||||
scrollback: ScrollbackConfig {
|
||||
layout: raw.scrollback.layout.into(),
|
||||
@@ -1426,7 +1416,8 @@ impl From<RawAppearanceConfig> for AppearanceConfig {
|
||||
badge_format: raw.todo.badge_format.into(),
|
||||
},
|
||||
turn_status: TurnStatusConfig::default(),
|
||||
show_timestamps: true, // runtime-only, loaded from config.toml via persist
|
||||
// runtime-only, loaded from config.toml via persist
|
||||
show_timestamps: true,
|
||||
// Single source: UiConfig::SHOW_TIMELINE_DEFAULT (loaded from config.toml via persist).
|
||||
show_timeline: UiConfig::SHOW_TIMELINE_DEFAULT,
|
||||
disable_plugins: raw.disable_plugins,
|
||||
@@ -1607,9 +1598,7 @@ impl From<RawThinkingConfig> for ThinkingConfig {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Color Parsing
|
||||
// ============================================================================
|
||||
|
||||
/// An optional color that can be "none" or a color value.
|
||||
/// This allows TOML to represent None values explicitly.
|
||||
@@ -1719,47 +1708,48 @@ fn lookup_named_color(name: &str) -> Result<Color, String> {
|
||||
// `parse_color_string` → `quantize()` to match the terminal's capabilities.
|
||||
let color = match name.to_uppercase().as_str() {
|
||||
// Background colors
|
||||
"BG" | "BG_BASE" => Color::Rgb(20, 20, 20), // #141414
|
||||
"BG_LIGHT" | "BG_HIGHLIGHT" => Color::Rgb(30, 30, 30), // #1e1e1e
|
||||
"BG_DARK" => Color::Rgb(17, 17, 17), // #111111
|
||||
"BG_TERMINAL" | "BG_NIGHT" => Color::Rgb(10, 10, 10), // #0a0a0a
|
||||
"BG_VISUAL" => Color::Rgb(30, 32, 45), // blue-tinted selection
|
||||
"BG_SEARCH" => Color::Rgb(48, 48, 52), // #303034
|
||||
"BG" | "BG_BASE" => Color::Rgb(20, 20, 20),
|
||||
"BG_LIGHT" | "BG_HIGHLIGHT" => Color::Rgb(30, 30, 30),
|
||||
"BG_DARK" => Color::Rgb(17, 17, 17),
|
||||
"BG_TERMINAL" | "BG_NIGHT" => Color::Rgb(10, 10, 10),
|
||||
// blue-tinted selection
|
||||
"BG_VISUAL" => Color::Rgb(30, 32, 45),
|
||||
"BG_SEARCH" => Color::Rgb(48, 48, 52),
|
||||
|
||||
// Accent colors (TokyoNight Night)
|
||||
"BLUE" => Color::Rgb(77, 121, 255), // #4D79FF
|
||||
"BLUE0" => Color::Rgb(61, 89, 161), // #3d59a1
|
||||
"BLUE1" => Color::Rgb(42, 195, 222), // #2ac3de
|
||||
"BLUE2" => Color::Rgb(13, 185, 215), // #0db9d7
|
||||
"BLUE5" => Color::Rgb(137, 221, 255), // #89ddff
|
||||
"BLUE6" => Color::Rgb(180, 249, 248), // #b4f9f8
|
||||
"BLUE7" => Color::Rgb(57, 75, 112), // #394b70
|
||||
"CYAN" => Color::Rgb(125, 207, 255), // #7dcfff
|
||||
"GREEN" => Color::Rgb(36, 196, 116), // #24C474
|
||||
"GREEN1" => Color::Rgb(115, 218, 202), // #73daca
|
||||
"GREEN2" => Color::Rgb(65, 166, 181), // #41a6b5
|
||||
"YELLOW" => Color::Rgb(255, 219, 141), // #FFDB8D
|
||||
"ORANGE" => Color::Rgb(255, 158, 100), // #ff9e64
|
||||
"RED" => Color::Rgb(248, 114, 122), // #F8727A
|
||||
"RED1" => Color::Rgb(219, 75, 75), // #db4b4b
|
||||
"MAGENTA" => Color::Rgb(187, 154, 247), // #bb9af7
|
||||
"PURPLE" => Color::Rgb(131, 113, 211), // #8371D3
|
||||
"MAGENTA2" => Color::Rgb(255, 0, 124), // #ff007c
|
||||
"TEAL" | "HINT" => Color::Rgb(26, 188, 156), // #1abc9c
|
||||
"BLUE" => Color::Rgb(77, 121, 255),
|
||||
"BLUE0" => Color::Rgb(61, 89, 161),
|
||||
"BLUE1" => Color::Rgb(42, 195, 222),
|
||||
"BLUE2" => Color::Rgb(13, 185, 215),
|
||||
"BLUE5" => Color::Rgb(137, 221, 255),
|
||||
"BLUE6" => Color::Rgb(180, 249, 248),
|
||||
"BLUE7" => Color::Rgb(57, 75, 112),
|
||||
"CYAN" => Color::Rgb(125, 207, 255),
|
||||
"GREEN" => Color::Rgb(36, 196, 116),
|
||||
"GREEN1" => Color::Rgb(115, 218, 202),
|
||||
"GREEN2" => Color::Rgb(65, 166, 181),
|
||||
"YELLOW" => Color::Rgb(255, 219, 141),
|
||||
"ORANGE" => Color::Rgb(255, 158, 100),
|
||||
"RED" => Color::Rgb(248, 114, 122),
|
||||
"RED1" => Color::Rgb(219, 75, 75),
|
||||
"MAGENTA" => Color::Rgb(187, 154, 247),
|
||||
"PURPLE" => Color::Rgb(131, 113, 211),
|
||||
"MAGENTA2" => Color::Rgb(255, 0, 124),
|
||||
"TEAL" | "HINT" => Color::Rgb(26, 188, 156),
|
||||
|
||||
// Text colors
|
||||
"FG" | "TEXT" | "TEXT_PRIMARY" => Color::Rgb(243, 243, 243), // #f3f3f3
|
||||
"FG_DARK" | "TEXT_SECONDARY" => Color::Rgb(200, 200, 200), // #c8c8c8
|
||||
"FG_GUTTER" => Color::Rgb(65, 65, 65), // #414141
|
||||
"COMMENT" | "MUTED" | "TEXT_MUTED" => Color::Rgb(98, 98, 98), // #626262
|
||||
"DARK3" => Color::Rgb(90, 90, 90), // #5a5a5a
|
||||
"DARK5" | "TOOL" => Color::Rgb(120, 120, 120), // #787878
|
||||
"FG" | "TEXT" | "TEXT_PRIMARY" => Color::Rgb(243, 243, 243),
|
||||
"FG_DARK" | "TEXT_SECONDARY" => Color::Rgb(200, 200, 200),
|
||||
"FG_GUTTER" => Color::Rgb(65, 65, 65),
|
||||
"COMMENT" | "MUTED" | "TEXT_MUTED" => Color::Rgb(98, 98, 98),
|
||||
"DARK3" => Color::Rgb(90, 90, 90),
|
||||
"DARK5" | "TOOL" => Color::Rgb(120, 120, 120),
|
||||
|
||||
// Semantic colors
|
||||
"ERROR" => Color::Rgb(247, 118, 142), // RED
|
||||
"SUCCESS" => Color::Rgb(158, 206, 106), // GREEN
|
||||
"WARNING" => Color::Rgb(224, 175, 104), // YELLOW
|
||||
"INFO" => Color::Rgb(125, 207, 255), // CYAN
|
||||
"ERROR" => Color::Rgb(247, 118, 142),
|
||||
"SUCCESS" => Color::Rgb(158, 206, 106),
|
||||
"WARNING" => Color::Rgb(224, 175, 104),
|
||||
"INFO" => Color::Rgb(125, 207, 255),
|
||||
|
||||
// Basic colors
|
||||
"BLACK" => Color::Black,
|
||||
@@ -1771,9 +1761,7 @@ fn lookup_named_color(name: &str) -> Result<Color, String> {
|
||||
Ok(color)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TOML Generation with Comments
|
||||
// ============================================================================
|
||||
|
||||
impl RawAppearanceConfig {
|
||||
pub fn to_toml_with_comments() -> String {
|
||||
@@ -2025,9 +2013,7 @@ fn annotate_table<T: DocumentedFields>(table: &mut toml_edit::Table) {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tests
|
||||
// ============================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
@@ -2414,7 +2400,7 @@ gutter_bg = true
|
||||
);
|
||||
}
|
||||
|
||||
// ── Terminal config (alt_screen) parsing ─────────────────────
|
||||
// Terminal config (alt_screen) parsing
|
||||
|
||||
#[test]
|
||||
fn terminal_alt_screen_auto_default() {
|
||||
|
||||
@@ -33,7 +33,7 @@ pub use scroll_mode::ScrollMode;
|
||||
pub use text_selection::TextSelection;
|
||||
pub use watcher::ConfigWatcher;
|
||||
|
||||
// -- Global tab_width --------------------------------------------------------
|
||||
// Global tab_width
|
||||
//
|
||||
// Stored as an atomic so MarkdownContent can read the current value
|
||||
// without needing the AppearanceConfig threaded through its API.
|
||||
|
||||
@@ -62,7 +62,7 @@ impl DefaultSelectedPermission {
|
||||
}
|
||||
}
|
||||
|
||||
/// Display label for the settings picker and the change toast.
|
||||
/// Display label for the settings picker and the confirmation toast.
|
||||
/// `AllowCommandAlways` preselects the prompt-specific always-allow row
|
||||
/// (per-command / per-tool / per-domain / per-edit-session), never a
|
||||
/// global allow-everything — that is `AlwaysAllowAllSessions`.
|
||||
@@ -132,7 +132,7 @@ impl DefaultSelectedPermission {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Configured value cache: `[ui].default_selected_permission` ──────────────
|
||||
// Configured value cache: `[ui].default_selected_permission`
|
||||
//
|
||||
// Read when queueing the first prompt of a session. Seeded by `prime` at
|
||||
// startup (and lazily on first read) so the path never hits disk mid-session.
|
||||
@@ -174,7 +174,7 @@ pub fn load_default_selected_permission() -> DefaultSelectedPermission {
|
||||
CONFIG_CURRENT.with(Cell::get)
|
||||
}
|
||||
|
||||
/// Replace the cached configured value (optimistic update from the settings
|
||||
/// Replace the cached configured value (optimistic write from the settings
|
||||
/// modal, or rollback on persist failure). The next prompt sees it without a
|
||||
/// restart.
|
||||
pub fn set_default_selected_permission(value: DefaultSelectedPermission) {
|
||||
@@ -188,7 +188,7 @@ pub fn prime() {
|
||||
let _ = load_default_selected_permission();
|
||||
}
|
||||
|
||||
// ── Sticky "last used" cursor target ────────────────────────────────────────
|
||||
// Sticky "last used" cursor target
|
||||
//
|
||||
// Process-wide ephemeral state: the kind the user most recently confirmed.
|
||||
// After the first prompt, `resolve_initial_cursor` prefers this over the
|
||||
@@ -215,7 +215,7 @@ pub fn set_last_used_permission(kind: DefaultSelectedPermission) {
|
||||
LAST_USED.with(|c| c.set(kind));
|
||||
}
|
||||
|
||||
// ── Resolution ──────────────────────────────────────────────────────────────
|
||||
// Resolution
|
||||
|
||||
/// Pick the initially-highlighted row for a freshly-queued permission prompt.
|
||||
///
|
||||
@@ -252,7 +252,7 @@ fn load_string_from_effective_config(key: &str) -> Option<String> {
|
||||
.map(std::string::ToString::to_string)
|
||||
}
|
||||
|
||||
// -- Tests -------------------------------------------------------------------
|
||||
// Tests
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//!
|
||||
//! Fenced ` ```mermaid ` blocks are rendered inline as Unicode box-drawing art
|
||||
//! by the markdown renderer. This setting controls the full-fidelity affordance
|
||||
//! row layered beneath that art: `auto`/`on` add the clickable row
|
||||
//! row layered beneath that art: `auto`/`on` include the clickable row
|
||||
//! (`◇ mermaid [Open Image] [Copy Image Path] [Copy Source]`); `off` shows the
|
||||
//! inline art alone. The PNG render engine is always compiled in, and the PNG is
|
||||
//! never drawn as an inline image (it opens in the OS viewer), so the treatment
|
||||
|
||||
@@ -6,10 +6,9 @@
|
||||
//! `ScrollInputMode` when building its scroll config; this crate only owns
|
||||
//! the persisted value type and its cache.
|
||||
|
||||
/// Scroll input classification preference: auto-detect or force one kind.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
|
||||
pub enum ScrollMode {
|
||||
/// Detect wheel vs trackpad per stream from event timing. Default.
|
||||
/// Detect wheel vs trackpad per stream from event timing.
|
||||
#[default]
|
||||
Auto,
|
||||
/// Always treat scroll input as a mouse wheel (fixed lines per tick).
|
||||
@@ -28,7 +27,6 @@ impl ScrollMode {
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a canonical string, returning `None` for unrecognized input.
|
||||
pub fn from_canonical(value: &str) -> Option<Self> {
|
||||
match value {
|
||||
"auto" => Some(Self::Auto),
|
||||
@@ -52,8 +50,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn junk_and_case_variants_are_rejected() {
|
||||
// Strict parse: unknown disk/env values must fall back to the default
|
||||
// at the caller (cache seed), never panic or mis-map.
|
||||
// Parsing is strict so a stray disk/env value falls back to the
|
||||
// default at the caller (cache seed) rather than mis-mapping.
|
||||
for junk in ["", "Auto", "WHEEL", "track pad", "mouse", "1"] {
|
||||
assert_eq!(ScrollMode::from_canonical(junk), None, "{junk:?}");
|
||||
}
|
||||
|
||||
@@ -1,18 +1,12 @@
|
||||
//! The `keep_text_selection` user setting (`flash` | `hold` | `word_select`).
|
||||
//!
|
||||
//! This is the single, unified control for scrollback text-selection behavior.
|
||||
//! It governs both how long an in-app selection highlight stays on screen and
|
||||
//! what a double/triple-click does, so the two can never drift out of sync:
|
||||
//!
|
||||
//! - `flash` — brief highlight on mouse-up, then clear; double-click toggles fold.
|
||||
//! - `hold` — selection stays until dismissed; double-click toggles fold.
|
||||
//! - `word_select` — selection stays until dismissed; double-click selects &
|
||||
//! copies a word, triple-click a line (terminal-like). Implies `hold`.
|
||||
//! One setting governs both how long an in-app selection highlight stays on
|
||||
//! screen and what a double/triple-click does, so the two can never drift out
|
||||
//! of sync.
|
||||
|
||||
/// Scrollback text-selection behavior: highlight lifetime + double-click action.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
|
||||
pub enum TextSelection {
|
||||
/// Brief highlight on mouse-up, then clear; double-click toggles fold. Default.
|
||||
/// Brief highlight on mouse-up, then clear; double-click toggles fold.
|
||||
#[default]
|
||||
Flash,
|
||||
/// Stay visible until Esc/click/scroll; double-click toggles fold.
|
||||
@@ -32,7 +26,6 @@ impl TextSelection {
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a canonical string, returning `None` for unrecognized input.
|
||||
pub fn from_canonical(value: &str) -> Option<Self> {
|
||||
match value {
|
||||
"flash" => Some(Self::Flash),
|
||||
@@ -78,16 +71,14 @@ mod tests {
|
||||
assert_eq!(TextSelection::default().as_canonical(), "flash");
|
||||
}
|
||||
|
||||
/// The unified invariant: `word_select` always implies `holds()` (persistent
|
||||
/// highlight) and is the only mode that turns on double-click word select.
|
||||
/// `word_select` always implies `holds()`, and is the only mode that turns
|
||||
/// on double-click word select.
|
||||
#[test]
|
||||
fn word_select_implies_hold_and_word_select() {
|
||||
assert!(TextSelection::WordSelect.holds());
|
||||
assert!(TextSelection::WordSelect.selects_word());
|
||||
// Hold persists but leaves double-click as fold-toggle.
|
||||
assert!(TextSelection::Hold.holds());
|
||||
assert!(!TextSelection::Hold.selects_word());
|
||||
// Flash neither persists nor word-selects.
|
||||
assert!(!TextSelection::Flash.holds());
|
||||
assert!(!TextSelection::Flash.selects_word());
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ use std::sync::OnceLock;
|
||||
|
||||
use crate::terminal::{MultiplexerKind, TerminalContext};
|
||||
|
||||
/// Cached result of the remote-session check (env vars don't change at runtime).
|
||||
/// Cached result of the remote-session check (env vars do not alter at runtime).
|
||||
fn is_remote() -> bool {
|
||||
static REMOTE: OnceLock<bool> = OnceLock::new();
|
||||
*REMOTE.get_or_init(kigi_shared::clipboard::is_remote_session)
|
||||
@@ -270,7 +270,7 @@ pub struct CopyResult {
|
||||
/// Kind of clipboard copy toast (success route or failure).
|
||||
///
|
||||
/// Telemetry labels come from `IntoStaticStr` (`snake_case`); user-facing copy
|
||||
/// lives in [`ClipboardToastKind::message`] (intentionally different).
|
||||
/// lives in [`ClipboardToastKind::message`] (deliberately different).
|
||||
#[derive(Debug, Clone, Copy, Eq, PartialEq, strum::IntoStaticStr)]
|
||||
#[strum(serialize_all = "snake_case")]
|
||||
pub(crate) enum ClipboardToastKind {
|
||||
@@ -442,7 +442,7 @@ pub fn clipboard_text_is_pasteable(text: Option<&str>) -> bool {
|
||||
}
|
||||
|
||||
/// Telemetry when a paste key was pressed but the host clipboard had nothing
|
||||
/// pasteable. Behavior is unchanged — callers still consume the key.
|
||||
/// pasteable. Behavior is `unchanged` — callers still consume the key.
|
||||
/// Emits structured logs and a product analytics event when telemetry is enabled.
|
||||
pub fn log_paste_key_empty_host_clipboard(surface: &str) {
|
||||
let terminal = crate::terminal::terminal_context().diagnostics_snapshot();
|
||||
@@ -619,7 +619,7 @@ fn should_run_attachment_probe(
|
||||
/// probe; `Some(change_count)` = probe, carrying the pasteboard `changeCount`
|
||||
/// this gate's OWN snapshot read observed. Enqueue sites thread that baseline
|
||||
/// into the off-thread probe's staleness check instead of taking a second
|
||||
/// native read that could land after a clipboard change.
|
||||
/// native read that could land after a clipboard write.
|
||||
///
|
||||
/// Cheap (native snapshot only, no subprocess) so paste handlers can call it on
|
||||
/// the event loop to decide whether to DEFER the heavy probe to a background
|
||||
@@ -769,9 +769,7 @@ pub fn system_clipboard_get_image() -> Option<ImageData> {
|
||||
system_clipboard_get_image_result().unwrap_or(None)
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Test support
|
||||
// ===========================================================================
|
||||
|
||||
/// Injectable clipboard reads for driving the paste handlers in tests without
|
||||
/// spawning `pbpaste` / `osascript`.
|
||||
@@ -876,7 +874,7 @@ pub mod test_support {
|
||||
PRIMARY_READS.with(|c| c.set(0));
|
||||
}
|
||||
|
||||
/// Remove the canned clipboard hook and reset the probe counter.
|
||||
/// Drop the canned clipboard hook and reset the probe counter.
|
||||
pub fn clear_clipboard_probe_hook() {
|
||||
HOOK.with(|h| *h.borrow_mut() = None);
|
||||
PROBE_CALLS.with(|c| c.set(0));
|
||||
@@ -959,9 +957,7 @@ pub use test_support::{
|
||||
set_clipboard_probe_hook,
|
||||
};
|
||||
|
||||
// ===========================================================================
|
||||
// Tests
|
||||
// ===========================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
@@ -971,7 +967,7 @@ mod tests {
|
||||
TmuxClientMeta,
|
||||
};
|
||||
|
||||
// -- Context builders for clipboard route tests ---------------------------
|
||||
// Context builders for clipboard route tests
|
||||
|
||||
fn plain_terminal_ctx() -> TerminalContext {
|
||||
TerminalContext {
|
||||
@@ -1091,7 +1087,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// -- Bracketed payload ↔ clipboard text match ------------------------------
|
||||
// Bracketed payload ↔ clipboard text match
|
||||
|
||||
#[test]
|
||||
fn bracketed_payload_match_exact_and_normalized() {
|
||||
@@ -1338,9 +1334,7 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// resolve_clipboard_route: pure routing logic
|
||||
// =====================================================================
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ClipboardRouteCase {
|
||||
@@ -1421,9 +1415,7 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// ClipboardRoute structure
|
||||
// =====================================================================
|
||||
|
||||
#[test]
|
||||
fn clipboard_route_native_always_true() {
|
||||
@@ -1501,11 +1493,9 @@ mod tests {
|
||||
assert!(!resolve_clipboard_route(&plain_terminal_ctx()).osc52_tmux_passthrough);
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Extended clipboard route matrix (final hardening)
|
||||
// =====================================================================
|
||||
|
||||
// -- Byobu-screen: native only, no tmux buffer, no OSC 52 ----------------
|
||||
// Byobu-screen: native only, no tmux buffer, no OSC 52
|
||||
|
||||
#[test]
|
||||
fn clipboard_route_byobu_screen_no_tmux_buffer_no_osc52() {
|
||||
@@ -1518,7 +1508,7 @@ mod tests {
|
||||
// OSC 52 depends on is_remote(), but tmux_buffer must be false.
|
||||
}
|
||||
|
||||
// -- Plain screen: no tmux buffer -----------------------------------------
|
||||
// Plain screen: no tmux buffer
|
||||
|
||||
#[test]
|
||||
fn clipboard_route_plain_screen_no_tmux_buffer() {
|
||||
@@ -1530,7 +1520,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// -- Consistency: all environments always have native = true ---------------
|
||||
// Consistency: all environments always have native = true
|
||||
|
||||
#[test]
|
||||
fn clipboard_route_native_never_disabled() {
|
||||
@@ -1552,7 +1542,7 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// -- tmux-backed: all three legs are active --------------------------------
|
||||
// tmux-backed: all three legs are active
|
||||
|
||||
#[test]
|
||||
fn clipboard_route_tmux_backed_all_three_legs() {
|
||||
@@ -1564,7 +1554,7 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// -- Non-tmux-backed: tmux_buffer always false ----------------------------
|
||||
// Non-tmux-backed: tmux_buffer always false
|
||||
|
||||
#[test]
|
||||
fn clipboard_route_non_tmux_never_tmux_buffer() {
|
||||
|
||||
@@ -64,9 +64,8 @@ pub(crate) fn trusted_osc(
|
||||
}
|
||||
|
||||
/// Toast from legs + env: native → OSC (incl. VS Code remote non-ASCII) → tmux → Failed.
|
||||
// Pure decision function over independent environment inputs (host OS, display
|
||||
// server, remote/container/sink flags). Bundling them into a struct would only
|
||||
// move the argument list elsewhere and churn every call site/test.
|
||||
// The arguments are independent environment inputs; bundling them into a struct
|
||||
// would only move the same list to every call site.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn resolve_copy_toast(
|
||||
legs: &ClipboardWriteLegs,
|
||||
@@ -85,7 +84,8 @@ pub(crate) fn resolve_copy_toast(
|
||||
if remote && brand.is_vscode_family() && !text.is_ascii() {
|
||||
return ClipboardToastKind::VsCodeSshNonAscii;
|
||||
}
|
||||
// Container before remote (matches prior route-flag toast order).
|
||||
// A remote container reports the container toast: its fallback hint is
|
||||
// the actionable one.
|
||||
if container {
|
||||
return ClipboardToastKind::CopiedOscContainer;
|
||||
}
|
||||
@@ -126,7 +126,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Same as [`legs`] with the Wayland data-control flag set.
|
||||
fn legs_data_control(
|
||||
route_native: bool,
|
||||
cli_ok: bool,
|
||||
@@ -249,9 +248,8 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// The enterprise clipboard shape after the fix: no CLI tool installed, but the
|
||||
// arboard write went through the compositor's data-control protocol, so it
|
||||
// is trusted native.
|
||||
// Locked-down enterprise desktop: no clipboard CLI installed, but the
|
||||
// arboard write reached the compositor via data-control.
|
||||
#[test]
|
||||
fn linux_wayland_arboard_data_control_ok() {
|
||||
let l = legs_data_control(true, false, true, false, true, "");
|
||||
@@ -268,8 +266,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// Without data-control (GNOME <= 47 or kill-switch), an arboard-only write
|
||||
// keeps the `linux_wayland_arboard_only_fails` semantics.
|
||||
// GNOME <= 47 or the kill-switch: no data-control protocol available.
|
||||
#[test]
|
||||
fn linux_wayland_arboard_without_data_control_still_fails() {
|
||||
let l = legs(true, false, true, false, false, "");
|
||||
@@ -286,7 +283,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// Data-control grants nothing when the arboard write itself failed.
|
||||
#[test]
|
||||
fn linux_wayland_data_control_without_arboard_fails() {
|
||||
let l = legs_data_control(true, false, false, false, false, "");
|
||||
@@ -385,7 +381,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn ssh_iterm2_osc_only_remote_toast() {
|
||||
// Guards the OSC-52 membership invariant the fix depends on.
|
||||
// The remote toast only holds while Iterm2 is in the OSC-52 brand set.
|
||||
assert!(TerminalName::Iterm2.supports_osc52_clipboard());
|
||||
let l = legs(true, false, false, false, true, "");
|
||||
assert_eq!(
|
||||
@@ -550,13 +546,13 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// `kigi wrap` sink: a brand that does NOT natively support OSC 52 (the
|
||||
// common SSH case where the inner terminal is misdetected as Vte/Unknown)
|
||||
// is still trusted when an upstream OSC 52 sink is capturing our output.
|
||||
// The common SSH case: the inner terminal is misdetected as Vte, which does
|
||||
// not natively support OSC 52, yet the `kigi wrap` sink upstream does.
|
||||
#[test]
|
||||
fn wrapped_ssh_vte_osc_trusted_via_sink() {
|
||||
let l = legs(true, false, false, false, true, "");
|
||||
// Without the sink: untrusted brand over SSH → Failed.
|
||||
// Trailing arg is the sink flag: without it, an untrusted brand over
|
||||
// SSH fails closed.
|
||||
assert_eq!(
|
||||
resolve_copy_toast(
|
||||
&l,
|
||||
@@ -570,7 +566,7 @@ mod tests {
|
||||
),
|
||||
ClipboardToastKind::Failed
|
||||
);
|
||||
// With the sink active: trusted → success toast.
|
||||
// Same inputs with the sink active.
|
||||
assert_eq!(
|
||||
resolve_copy_toast(
|
||||
&l,
|
||||
@@ -586,8 +582,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// Sink trust still requires an actual OSC 52 write to have happened
|
||||
// (`osc52_ok`); it never fabricates success when no leg fired.
|
||||
#[test]
|
||||
fn wrapped_sink_without_osc_write_still_fails() {
|
||||
let l = legs(true, false, false, false, false, "");
|
||||
@@ -607,11 +601,9 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// Docker/podman from Windows PowerShell / cmd (or any host terminal):
|
||||
// brand env vars are not forwarded into the container, so the brand is
|
||||
// Unknown; native legs cannot work (no display server). The emitted
|
||||
// OSC 52 is the copy path and must be trusted → hedged container toast,
|
||||
// not "Copy failed" (regression test for the false-failure report).
|
||||
// Regression test for the false "Copy failed" toast in docker: the runtime
|
||||
// does not forward brand env vars, so the brand is Unknown even though the
|
||||
// outer terminal applies OSC 52 fine. See [`trusted_osc`].
|
||||
#[test]
|
||||
fn container_unknown_brand_osc_trusted() {
|
||||
let l = legs(true, false, false, false, true, "");
|
||||
@@ -628,7 +620,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// Container trust never fabricates success: no OSC 52 write → Failed.
|
||||
#[test]
|
||||
fn container_unknown_brand_without_osc_write_fails() {
|
||||
let l = legs(true, false, false, false, false, "");
|
||||
@@ -646,8 +637,8 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// A *detected* non-supporting brand stays fail-closed even in a container
|
||||
// (env was explicitly forwarded, so the detection is authoritative).
|
||||
// A brand that survived into the container means the env was explicitly
|
||||
// forwarded, so the detection is authoritative and stays fail-closed.
|
||||
#[test]
|
||||
fn container_detected_nonsupporting_brand_fails() {
|
||||
let l = legs(true, false, false, false, true, "");
|
||||
@@ -664,8 +655,8 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// Unknown brand over SSH (not container) keeps failing closed — the
|
||||
// container override is deliberately narrow; `kigi wrap` is the SSH path.
|
||||
// The container override is deliberately narrow: plain SSH keeps failing
|
||||
// closed, since `kigi wrap` is the supported SSH path.
|
||||
#[test]
|
||||
fn ssh_unknown_brand_osc_only_still_fails() {
|
||||
let l = legs(true, false, false, false, true, "");
|
||||
@@ -682,7 +673,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// Sink in a container (no display) → container OSC toast.
|
||||
#[test]
|
||||
fn wrapped_container_osc_trusted_via_sink() {
|
||||
let l = legs(true, false, false, false, true, "");
|
||||
|
||||
@@ -28,7 +28,8 @@ pub(super) struct XorShift64(u64);
|
||||
|
||||
impl XorShift64 {
|
||||
pub fn new(seed: u64) -> Self {
|
||||
Self(seed.max(1)) // xorshift state must be non-zero
|
||||
// xorshift state must be non-zero
|
||||
Self(seed.max(1))
|
||||
}
|
||||
|
||||
pub fn next_u32(&mut self) -> u32 {
|
||||
@@ -239,30 +240,34 @@ fn hellstone() -> Texture {
|
||||
Texture { pixels }
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Sprite art (char-map pixel art)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// Map a sprite art character to a color. `.` is transparent.
|
||||
fn sprite_color(ch: u8) -> Option<Rgb> {
|
||||
match ch {
|
||||
b'.' => None,
|
||||
b'B' => Some([146, 90, 50]), // imp body, brown
|
||||
b'b' => Some([104, 62, 34]), // imp body, shaded
|
||||
b'H' => Some([222, 214, 188]), // horn / bone
|
||||
b'E' => Some(EYE_GLOW), // glowing eye (fog-exempt in renderer)
|
||||
b'M' => Some([34, 20, 16]), // mouth / dark recess
|
||||
b'T' => Some([236, 232, 220]), // teeth
|
||||
b'C' => Some([214, 196, 160]), // claw
|
||||
b'R' => Some([186, 28, 24]), // blood
|
||||
b'r' => Some([120, 16, 14]), // blood, dark
|
||||
b'G' => Some([96, 104, 112]), // gunmetal
|
||||
b'g' => Some([52, 58, 66]), // gunmetal, dark
|
||||
b'W' => Some([224, 228, 232]), // highlight
|
||||
b'S' => Some([212, 160, 116]), // skin
|
||||
b's' => Some([164, 116, 80]), // skin, shaded
|
||||
b'F' => Some([255, 244, 160]), // muzzle flash core
|
||||
b'f' => Some([255, 168, 48]), // muzzle flash fringe
|
||||
// imp body, brown
|
||||
b'B' => Some([146, 90, 50]),
|
||||
// imp body, shaded
|
||||
b'b' => Some([104, 62, 34]),
|
||||
b'H' => Some([222, 214, 188]),
|
||||
// glowing eye (fog-exempt in renderer)
|
||||
b'E' => Some(EYE_GLOW),
|
||||
// mouth / dark recess
|
||||
b'M' => Some([34, 20, 16]),
|
||||
b'T' => Some([236, 232, 220]),
|
||||
b'C' => Some([214, 196, 160]),
|
||||
b'R' => Some([186, 28, 24]),
|
||||
b'r' => Some([120, 16, 14]),
|
||||
b'G' => Some([96, 104, 112]),
|
||||
b'g' => Some([52, 58, 66]),
|
||||
b'W' => Some([224, 228, 232]),
|
||||
b'S' => Some([212, 160, 116]),
|
||||
b's' => Some([164, 116, 80]),
|
||||
// muzzle flash core
|
||||
b'F' => Some([255, 244, 160]),
|
||||
// muzzle flash fringe
|
||||
b'f' => Some([255, 168, 48]),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -530,9 +535,7 @@ pub(super) fn build_gun_sprites() -> GunSprites {
|
||||
GunSprites { idle, fire }
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 5x7 pixel font (uppercase + the few symbols the game needs)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// Return the 5x7 glyph rows for a character, MSB-left in the low 5 bits.
|
||||
/// Unknown characters render as blank.
|
||||
|
||||
@@ -24,8 +24,10 @@ const VIGNETTE: f32 = 0.11;
|
||||
pub(super) struct FrameBuffer {
|
||||
pub w: usize,
|
||||
pub h: usize,
|
||||
pub pixels: Vec<u8>, // RGB8, row-major
|
||||
zbuf: Vec<f32>, // per-column wall depth
|
||||
// RGB8, row-major
|
||||
pub pixels: Vec<u8>,
|
||||
// per-column wall depth
|
||||
zbuf: Vec<f32>,
|
||||
/// Per-column wall strip bounds `[top, bottom)` in screen rows, written
|
||||
/// by `draw_walls` and read by `draw_floor_ceiling` to skip the pixels
|
||||
/// walls already cover (avoids texturing them twice).
|
||||
@@ -33,7 +35,7 @@ pub(super) struct FrameBuffer {
|
||||
wall_bottom: Vec<i32>,
|
||||
/// Scratch for painter's-order sprite sorting, reused across frames.
|
||||
sprite_order: Vec<(usize, f32)>,
|
||||
/// Separable vignette factors, rebuilt on dimension change.
|
||||
/// Separable vignette factors, rebuilt on dimension shift.
|
||||
vig_x: Vec<f32>,
|
||||
vig_y: Vec<f32>,
|
||||
}
|
||||
@@ -369,7 +371,8 @@ impl Renderer {
|
||||
let tx = inv_det * (dir_y * rel_x - dir_x * rel_y);
|
||||
let ty = inv_det * (-plane_y * rel_x + plane_x * rel_y);
|
||||
if ty <= 0.08 {
|
||||
continue; // behind or on top of the camera
|
||||
// behind or on top of the camera
|
||||
continue;
|
||||
}
|
||||
|
||||
let sprite = match imp.visual() {
|
||||
@@ -411,7 +414,8 @@ impl Renderer {
|
||||
|
||||
for sx in x0.max(0)..x1.min(w as i32) {
|
||||
if fb.zbuf[sx as usize] <= ty {
|
||||
continue; // occluded by a wall
|
||||
// occluded by a wall
|
||||
continue;
|
||||
}
|
||||
let u = (sx as f32 - x0 as f32) / (x1 - x0).max(1) as f32;
|
||||
for sy in y0.max(0)..y1.min(h as i32) {
|
||||
@@ -520,9 +524,7 @@ fn draw_contact_shadow(
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Title / end screens: animated fire + 5x7 pixel text
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// The classic PSX-style fire effect: a cellular automaton on a coarse
|
||||
/// grid, upscaled at draw time. Heat values 0..=36 index a fire palette.
|
||||
@@ -557,8 +559,9 @@ impl FireSim {
|
||||
for x in 0..self.w {
|
||||
let src = y * self.w + x;
|
||||
let r = self.rng.next_u32();
|
||||
let decay = (r & 1) as i32; // cool by 0 or 1
|
||||
let drift = (r >> 2) % 3; // 0, 1, 2 → left, stay, right
|
||||
let decay = (r & 1) as i32;
|
||||
// 0, 1, 2 → left, stay, right
|
||||
let drift = (r >> 2) % 3;
|
||||
let dst_x = (x as i32 + drift as i32 - 1).rem_euclid(self.w as i32) as usize;
|
||||
let dst = (y - 1) * self.w + dst_x;
|
||||
self.heat[dst] = (self.heat[src] as i32 - decay).max(0) as u8;
|
||||
@@ -696,7 +699,7 @@ mod tests {
|
||||
let renderer = Renderer::new();
|
||||
let mut game = Game::new();
|
||||
// Move all imps far behind the player so none are visible, render,
|
||||
// then put one directly in front and confirm pixels change.
|
||||
// then put one directly in front and confirm pixels differ.
|
||||
for imp in &mut game.imps {
|
||||
imp.x = game.player.x - 8.0;
|
||||
imp.y = game.player.y;
|
||||
|
||||
@@ -44,8 +44,10 @@ const IMP_RADIUS: f32 = 0.30;
|
||||
/// hold timer ([`HOLD_WINDOW`]); while it's positive, velocity eases toward
|
||||
/// a steady target. A constant target while held means speed doesn't
|
||||
/// sawtooth with the OS key-repeat cadence, yet releasing glides to a stop.
|
||||
const MOVE_SPEED: f32 = 3.3; // tiles/s while a move key is held
|
||||
const TURN_SPEED: f32 = 2.2; // rad/s (~125°/s) while a turn key is held
|
||||
// tiles/s while a move key is held
|
||||
const MOVE_SPEED: f32 = 3.3;
|
||||
// rad/s (~125°/s) while a turn key is held
|
||||
const TURN_SPEED: f32 = 2.2;
|
||||
/// Velocity-smoothing time constants (seconds). Small = snappy response
|
||||
/// with just enough ramp to read as momentum rather than teleporting.
|
||||
const MOVE_ACCEL_TAU: f32 = 0.08;
|
||||
@@ -78,7 +80,8 @@ const IMP_BITE_DAMAGE: i32 = 7;
|
||||
pub(super) struct Map {
|
||||
pub w: usize,
|
||||
pub h: usize,
|
||||
cells: Vec<u8>, // 0 = floor, 1..=4 = wall texture id
|
||||
// 0 = floor, 1..=4 = wall texture id
|
||||
cells: Vec<u8>,
|
||||
}
|
||||
|
||||
impl Map {
|
||||
@@ -166,7 +169,6 @@ impl Map {
|
||||
}
|
||||
}
|
||||
|
||||
/// Player state.
|
||||
pub(super) struct Player {
|
||||
pub x: f32,
|
||||
pub y: f32,
|
||||
@@ -330,7 +332,8 @@ impl Game {
|
||||
y: center.1,
|
||||
hp: IMP_HP,
|
||||
state: ImpState::Idle,
|
||||
anim: (x * 7 + y * 13) as f32 * 0.1, // desync walk cycles
|
||||
// desync walk cycles
|
||||
anim: (x * 7 + y * 13) as f32 * 0.1,
|
||||
attack_cooldown: 0.0,
|
||||
}),
|
||||
_ => {}
|
||||
@@ -728,7 +731,8 @@ mod tests {
|
||||
assert!(game.imps[0].hp < hp_before, "first shot must connect");
|
||||
|
||||
for _ in 0..20 {
|
||||
game.step(FIRE_COOLDOWN + 0.01); // let cooldown lapse
|
||||
// let cooldown lapse
|
||||
game.step(FIRE_COOLDOWN + 0.01);
|
||||
game.queue_fire();
|
||||
game.step(0.016);
|
||||
if !game.imps[0].alive() {
|
||||
@@ -807,7 +811,7 @@ mod tests {
|
||||
// and then stay there — no sawtooth.
|
||||
let mut game = Game::new();
|
||||
// Aim down an open stretch so walls don't cap velocity.
|
||||
game.player.angle = std::f32::consts::FRAC_PI_2; // +y
|
||||
game.player.angle = std::f32::consts::FRAC_PI_2;
|
||||
let dt = 1.0 / 30.0;
|
||||
for _ in 0..40 {
|
||||
game.press(Control::Forward);
|
||||
@@ -837,13 +841,14 @@ mod tests {
|
||||
// on press (release-aware) keeps it moving without repeats.
|
||||
let mut game = Game::new();
|
||||
game.set_release_aware(true);
|
||||
game.player.angle = 0.0; // facing +x
|
||||
game.player.angle = 0.0;
|
||||
game.press(Control::Forward);
|
||||
game.press(Control::TurnLeft);
|
||||
|
||||
let angle0 = game.player.angle;
|
||||
for _ in 0..30 {
|
||||
game.step(1.0 / 30.0); // no further presses
|
||||
// no further presses
|
||||
game.step(1.0 / 30.0);
|
||||
}
|
||||
assert!(
|
||||
game.player.vel_forward > 1.0,
|
||||
@@ -893,7 +898,8 @@ mod tests {
|
||||
let mut game = Game::new();
|
||||
game.player.angle = std::f32::consts::FRAC_PI_2;
|
||||
let dt = 1.0 / 60.0;
|
||||
let mut since_repeat = repeat_interval; // press on the first frame
|
||||
// press on the first frame
|
||||
let mut since_repeat = repeat_interval;
|
||||
// Run 2s of simulation, pressing every `repeat_interval`.
|
||||
for _ in 0..120 {
|
||||
since_repeat += dt;
|
||||
@@ -905,8 +911,9 @@ mod tests {
|
||||
}
|
||||
game.player.vel_forward
|
||||
}
|
||||
let fast = sustained_speed(0.03); // ~33 Hz
|
||||
let slow = sustained_speed(0.12); // ~8 Hz, still under HOLD_WINDOW
|
||||
let fast = sustained_speed(0.03);
|
||||
// ~8 Hz, still under HOLD_WINDOW
|
||||
let slow = sustained_speed(0.12);
|
||||
assert!((fast - MOVE_SPEED).abs() < 0.1, "fast cadence: {fast}");
|
||||
assert!(
|
||||
(fast - slow).abs() < 0.25,
|
||||
|
||||
@@ -498,7 +498,7 @@ mod tests {
|
||||
#[test]
|
||||
fn playing_to_dead_transition() {
|
||||
let mut state = GboomState::new();
|
||||
state.handle_key(&key(KeyCode::Char('w'))); // leave title
|
||||
state.handle_key(&key(KeyCode::Char('w')));
|
||||
state.game.player.hp = 0;
|
||||
state.tick();
|
||||
assert_eq!(state.phase, Phase::Dead);
|
||||
@@ -682,9 +682,9 @@ mod tests {
|
||||
|
||||
// Corridor vantage: spawn looking south down the long west corridor.
|
||||
let mut state = GboomState::new();
|
||||
state.handle_key(&key(KeyCode::Char('w'))); // leave title
|
||||
state.handle_key(&key(KeyCode::Char('w')));
|
||||
state.phase = Phase::Playing;
|
||||
state.game.player.angle = std::f32::consts::FRAC_PI_2; // +y, south
|
||||
state.game.player.angle = std::f32::consts::FRAC_PI_2;
|
||||
state.game.step(0.016);
|
||||
dump("game.png", &mut state);
|
||||
|
||||
|
||||
@@ -371,7 +371,7 @@ pub fn timeline_tick_hover() -> &'static str {
|
||||
/// The filled status / selection dot used in pickers, the settings and
|
||||
/// permission modals, the session list, and the file-search view. Its
|
||||
/// hollow partner `○` (U+25CB) is already a CP437 glyph (`0x09`) and
|
||||
/// renders unchanged, so only the filled variant needs a stand-in.
|
||||
/// renders `unchanged`, so only the filled variant needs a stand-in.
|
||||
pub fn filled_dot() -> &'static str {
|
||||
if is_legacy_windows_console() {
|
||||
"\u{2022}"
|
||||
@@ -500,7 +500,7 @@ pub fn enlarge_button() -> &'static str {
|
||||
/// Unlike the fixed-width button helpers above, toasts are right-aligned
|
||||
/// flowing text assembled in ~25 call sites, so a single funnel at the
|
||||
/// point the toast enters view state is cleaner than threading a helper
|
||||
/// through every builder. Returns a borrow unchanged on every non-legacy
|
||||
/// through every builder. Returns a borrow `unchanged` on every non-legacy
|
||||
/// platform, so toast strings stay byte-identical there.
|
||||
pub fn legacy_glyph_fallback(s: &str) -> Cow<'_, str> {
|
||||
if !is_legacy_windows_console() {
|
||||
@@ -607,8 +607,8 @@ mod tests {
|
||||
fn record_dot_states_are_one_column() {
|
||||
assert_eq!(record_dot(true).width(), 1);
|
||||
assert_eq!(record_dot(false).width(), 1);
|
||||
assert_eq!("\u{25C9}".width(), 1); // ◉ FISHEYE
|
||||
assert_eq!("\u{25CE}".width(), 1); // ◎ BULLSEYE
|
||||
assert_eq!("\u{25C9}".width(), 1);
|
||||
assert_eq!("\u{25CE}".width(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -623,11 +623,11 @@ mod tests {
|
||||
#[test]
|
||||
fn icon_fallback_variants_are_one_column() {
|
||||
for (fancy, fallback) in [
|
||||
("\u{2717}", "x"), // ballot_x
|
||||
("\u{2713}", "\u{221A}"), // check_mark
|
||||
("\u{2197}", "o"), // enlarge
|
||||
("\u{29C9}", "c"), // copy_icon
|
||||
("\u{21E3}", "\u{2193}"), // token_arrow
|
||||
("\u{2717}", "x"),
|
||||
("\u{2713}", "\u{221A}"),
|
||||
("\u{2197}", "o"),
|
||||
("\u{29C9}", "c"),
|
||||
("\u{21E3}", "\u{2193}"),
|
||||
] {
|
||||
assert_eq!(fancy.width(), 1, "icon {fancy:?} must be 1 column");
|
||||
assert_eq!(
|
||||
@@ -645,9 +645,9 @@ mod tests {
|
||||
#[test]
|
||||
fn diamond_variants_are_one_column() {
|
||||
for (fancy, fallback) in [
|
||||
("\u{25C6}", "\u{2666}"), // diamond_filled
|
||||
("\u{25C7}", "\u{25CB}"), // diamond_hollow
|
||||
("\u{25C8}", "\u{2666}"), // diamond_dotted
|
||||
("\u{25C6}", "\u{2666}"),
|
||||
("\u{25C7}", "\u{25CB}"),
|
||||
("\u{25C8}", "\u{2666}"),
|
||||
] {
|
||||
assert_eq!(fancy.width(), 1, "diamond {fancy:?} must be 1 column");
|
||||
assert_eq!(
|
||||
@@ -664,12 +664,12 @@ mod tests {
|
||||
#[test]
|
||||
fn chrome_glyph_variants_are_one_column() {
|
||||
for (fancy, fallback) in [
|
||||
("\u{2503}", "\u{2502}"), // accent_bar
|
||||
("\u{25CF}", "\u{2022}"), // filled_dot
|
||||
("\u{258F}", "\u{2502}"), // selection_bar
|
||||
("\u{203A}", ">"), // chevron
|
||||
("\u{2039}", "<"), // chevron_left
|
||||
("\u{2304}", "v"), // chevron_down
|
||||
("\u{2503}", "\u{2502}"),
|
||||
("\u{25CF}", "\u{2022}"),
|
||||
("\u{258F}", "\u{2502}"),
|
||||
("\u{203A}", ">"),
|
||||
("\u{2039}", "<"),
|
||||
("\u{2304}", "v"),
|
||||
] {
|
||||
assert_eq!(fancy.width(), 1, "glyph {fancy:?} must be 1 column");
|
||||
assert_eq!(
|
||||
@@ -724,8 +724,9 @@ mod tests {
|
||||
#[test]
|
||||
fn button_variants_have_stable_width() {
|
||||
for (fancy, fallback, cols) in [
|
||||
("[\u{2717}]", "[x]", 3), // ballot_x_button
|
||||
("[\u{2197}]", "[o]", 3), // enlarge_button
|
||||
// ballot_x_button
|
||||
("[\u{2717}]", "[x]", 3),
|
||||
("[\u{2197}]", "[o]", 3),
|
||||
] {
|
||||
assert_eq!(fancy.width(), cols, "button {fancy:?} must be {cols} cols");
|
||||
assert_eq!(
|
||||
|
||||
@@ -41,7 +41,7 @@ impl DisplayRefreshProbeResult {
|
||||
}
|
||||
}
|
||||
|
||||
/// Once per process. Infallible; never panics.
|
||||
/// Probes once per process. Infallible; never panics.
|
||||
pub fn probe_display_refresh() -> DisplayRefreshProbeResult {
|
||||
static CACHE: OnceLock<DisplayRefreshProbeResult> = OnceLock::new();
|
||||
*CACHE.get_or_init(probe_uncached)
|
||||
@@ -78,7 +78,8 @@ fn probe_inner() -> (Option<u32>, DisplayRefreshSource, &'static str) {
|
||||
decide(is_ssh, wsl, os, display, platform_hz)
|
||||
}
|
||||
|
||||
/// Pure matrix used by production and tests; inject only the platform result.
|
||||
/// Pure decision matrix; the platform result is injected so tests can drive
|
||||
/// every branch without a display.
|
||||
fn decide(
|
||||
is_ssh: bool,
|
||||
is_wsl: bool,
|
||||
@@ -157,6 +158,8 @@ fn probe_macos() -> Result<u32, &'static str> {
|
||||
Err("unsupported")
|
||||
}
|
||||
|
||||
/// Any fallback added here must stay thread-safe: no AppKit/NSScreen, which is
|
||||
/// main-thread only.
|
||||
#[cfg(target_os = "macos")]
|
||||
unsafe fn macos_main_display_refresh_hz() -> Result<u32, &'static str> {
|
||||
type CgDisplayModeRef = *mut core::ffi::c_void;
|
||||
@@ -178,11 +181,10 @@ unsafe fn macos_main_display_refresh_hz() -> Result<u32, &'static str> {
|
||||
}
|
||||
let rate = unsafe { CGDisplayModeGetRefreshRate(mode) };
|
||||
unsafe { CGDisplayModeRelease(mode) };
|
||||
// 0.0 is documented indeterminate for some LCD/VRR panels — skip, not error.
|
||||
// Future primary-display fallback must be thread-safe; no AppKit/NSScreen here.
|
||||
if !rate.is_finite() || rate < 0.0 {
|
||||
return Err("error");
|
||||
}
|
||||
// 0.0 is documented indeterminate for some LCD/VRR panels — skip, not error.
|
||||
if rate == 0.0 {
|
||||
return Err("indeterminate");
|
||||
}
|
||||
@@ -256,9 +258,8 @@ unsafe fn windows_primary_display_refresh_hz() -> Result<u32, &'static str> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Real OS path smoke: must not panic (FFI wrapped + fail-closed).
|
||||
/// Outcome may be ok/skipped/error depending on host; we only require
|
||||
/// process survival and a valid outcome token.
|
||||
/// Exercises the real OS path, whose outcome depends on the host machine,
|
||||
/// so only process survival and a valid outcome token can be asserted.
|
||||
#[test]
|
||||
fn probe_display_refresh_never_panics() {
|
||||
let r = probe_display_refresh();
|
||||
|
||||
@@ -50,7 +50,7 @@ impl HostOs {
|
||||
|
||||
/// WSL detection. The implementation lives in `kigi-tty-utils` (the shared
|
||||
/// low-level crate) so crates that must not depend on this UI crate can reuse
|
||||
/// it; re-exported here so existing `host::is_wsl()` callers are unchanged.
|
||||
/// it; re-exported here so existing `host::is_wsl()` callers are `unchanged`.
|
||||
pub use kigi_tty_utils::is_wsl;
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, Eq, PartialEq, strum::Display)]
|
||||
@@ -67,7 +67,7 @@ pub enum DisplayServer {
|
||||
|
||||
impl DisplayServer {
|
||||
/// Detect the display server. Cached for process lifetime on Linux
|
||||
/// (env vars don't change); compile-time constant on macOS/Windows.
|
||||
/// (env vars do not alter); compile-time constant on macOS/Windows.
|
||||
pub fn current() -> Self {
|
||||
static CACHE: OnceLock<DisplayServer> = OnceLock::new();
|
||||
*CACHE.get_or_init(|| {
|
||||
@@ -116,7 +116,7 @@ mod unicode_env_tests {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::ffi::OsStringExt;
|
||||
let bad = OsString::from_wide(&[0xD800]); // lone surrogate
|
||||
let bad = OsString::from_wide(&[0xD800]);
|
||||
let map = unicode_env_from_os([
|
||||
(bad.clone(), OsString::from("ok")),
|
||||
(OsString::from("OK_KEY"), bad),
|
||||
|
||||
@@ -18,7 +18,7 @@ pub struct ModalWindowState {
|
||||
/// Full popup area (for click-outside-to-close detection).
|
||||
pub popup_area: Option<Rect>,
|
||||
|
||||
// -- Tabs (optional) --
|
||||
// Tabs (optional)
|
||||
/// Currently active tab index.
|
||||
pub active_tab: usize,
|
||||
/// Number of tabs (0 = no tab bar).
|
||||
@@ -28,7 +28,7 @@ pub struct ModalWindowState {
|
||||
/// Whether the tab bar region has keyboard focus. When true, Left/Right
|
||||
pub tabs_focused: bool,
|
||||
|
||||
// -- Footer shortcuts --
|
||||
// Footer shortcuts
|
||||
/// Hit-test areas for clickable footer shortcuts.
|
||||
pub shortcut_hits: Vec<ShortcutHitArea>,
|
||||
/// Which footer shortcut (by index) is currently hovered.
|
||||
|
||||
@@ -20,9 +20,7 @@ use kigi_ratatui_textarea::ElementId;
|
||||
/// regressions from a single user's session capture.
|
||||
pub const PROMPT_IMAGES_TRACING_TARGET: &str = "prompt_images";
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Scrollable image viewer state
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// State for a modal image viewer.
|
||||
///
|
||||
@@ -223,9 +221,7 @@ pub fn load_image_data(path: &std::path::Path) -> ImageLoadResult {
|
||||
})
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Video viewer state
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// Target frames per second for terminal video playback.
|
||||
const VIDEO_FPS: f64 = 10.0;
|
||||
@@ -543,9 +539,7 @@ fn parse_fraction(s: &str) -> Option<f64> {
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Inline media info (for scrollback inline rendering)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// Metadata for inline media rendering in the scrollback.
|
||||
/// Returned by blocks that want to display media inline.
|
||||
@@ -762,9 +756,7 @@ impl PromptImagePreviewPreparation {
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Display helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// Build the buffer text for an image chip.
|
||||
///
|
||||
@@ -782,9 +774,7 @@ pub fn extension_for_mime(mime: &str) -> &'static str {
|
||||
mime_to_extension(mime)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Reconciliation
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// Remove entries from `images` whose `element_id` is not present in
|
||||
/// `live_ids`.
|
||||
@@ -798,7 +788,7 @@ pub fn reconcile(images: &mut Vec<PastedImage>, live_ids: &HashSet<ElementId>) {
|
||||
return true;
|
||||
}
|
||||
// Clean up temp-file-only staged images for removed chips.
|
||||
// Session-persisted files are intentionally left as orphans in v1.
|
||||
// Session-persisted files are deliberately left as orphans in v1.
|
||||
cleanup_temp_file(img);
|
||||
false
|
||||
});
|
||||
@@ -836,16 +826,15 @@ pub fn clear(images: &mut Vec<PastedImage>, image_counter: &mut usize) {
|
||||
/// is acceptable in v1).
|
||||
pub fn cleanup_temp_file(img: &PastedImage) {
|
||||
if img.session_image_path.is_some() {
|
||||
return; // already persisted to session dir, leave it
|
||||
// already persisted to session dir, leave it
|
||||
return;
|
||||
}
|
||||
if let Some(ref path) = img.staged_temp_path {
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Construction from file path
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// Image file extensions recognized when a pasted path is checked.
|
||||
///
|
||||
@@ -1116,7 +1105,7 @@ pub enum DroppedPath {
|
||||
/// Predicate order: cheap anchor/`file://` checks run first; the
|
||||
/// (relatively) more expensive [`read_image_at_path`] file-read +
|
||||
/// magic-byte sniff runs only for tokens that pass the gate. Bare
|
||||
/// cwd-relative image filenames are intentionally NOT intercepted —
|
||||
/// cwd-relative image filenames are deliberately NOT intercepted —
|
||||
/// drag-and-drop / Finder-paste always emit absolute paths or
|
||||
/// `file://` URLs, never `foo.png`-style relative refs.
|
||||
///
|
||||
@@ -1294,9 +1283,7 @@ pub fn try_read_image_from_path(text: &str) -> Option<PastedImage> {
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Construction from clipboard data
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// Build a `PastedImage` from raw clipboard [`ImageData`].
|
||||
///
|
||||
@@ -1317,9 +1304,7 @@ pub fn from_clipboard_data(data: &crate::clipboard::ImageData) -> PastedImage {
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Session image persistence
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// Persist image bytes into the session `images/` directory.
|
||||
///
|
||||
@@ -1398,11 +1383,9 @@ pub fn session_mermaid_dir(
|
||||
Some(kigi_shared::session::session_dir(&info).join("mermaid"))
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Image loading for send
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
const MAX_SEND_BYTES: usize = 50_000_000; // 50 MB
|
||||
const MAX_SEND_BYTES: usize = 50_000_000;
|
||||
|
||||
/// Load image bytes from a `PastedImage` (in-memory or from disk).
|
||||
/// Returns `None` if the image cannot be loaded or exceeds [`MAX_SEND_BYTES`].
|
||||
@@ -1449,9 +1432,7 @@ pub fn load_for_send(img: &PastedImage) -> Option<(Vec<u8>, String)> {
|
||||
Some((raw_bytes, img.mime_type.clone()))
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// ACP content block construction
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// Build ACP `ContentBlock` values from prompt text and attached images,
|
||||
/// with an optional fallback that re-loads orphan
|
||||
@@ -1614,7 +1595,8 @@ fn resolve_orphan_placeholders(
|
||||
|
||||
for ph in &placeholders {
|
||||
if attached_numbers.contains(&ph.display_number) {
|
||||
continue; // PastedImage already supplies these bytes.
|
||||
// PastedImage already supplies these bytes.
|
||||
continue;
|
||||
}
|
||||
match kigi_shared::placeholder_images::load_placeholder_image(&ph.path, allowed) {
|
||||
Ok(loaded) => {
|
||||
@@ -1707,9 +1689,7 @@ fn collapse_strip_seam(text: &mut String, start: usize, end: usize) {
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Scrollback image references
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// An image file referenced in scrollback content via `` markdown
|
||||
/// or a bare absolute path. Validated on construction: path must exist, have a
|
||||
@@ -1841,9 +1821,7 @@ pub fn extract_image_refs(text: &str) -> Vec<ScrollbackImageRef> {
|
||||
refs
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Scrollback video references
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
const VIDEO_EXTENSIONS: &[&str] = &["mp4", "webm", "mov", "avi", "mkv"];
|
||||
|
||||
@@ -1927,9 +1905,7 @@ pub fn extract_video_refs(text: &str) -> Vec<ScrollbackVideoRef> {
|
||||
refs
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Tests
|
||||
// =========================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
@@ -1979,7 +1955,7 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// ----- display_text ---------------------------------------------------
|
||||
// display_text
|
||||
|
||||
#[test]
|
||||
fn display_text_format() {
|
||||
@@ -1988,7 +1964,7 @@ mod tests {
|
||||
assert_eq!(display_text(10), "[Image #10]");
|
||||
}
|
||||
|
||||
// ----- extension_for_mime ---------------------------------------------
|
||||
// extension_for_mime
|
||||
|
||||
#[test]
|
||||
fn extension_for_known_mimes() {
|
||||
@@ -2120,7 +2096,7 @@ mod tests {
|
||||
assert!(res.is_err(), "expected write to read-only dir to fail");
|
||||
}
|
||||
|
||||
// ----- shell_unescape -------------------------------------------------
|
||||
// shell_unescape
|
||||
|
||||
#[test]
|
||||
fn shell_unescape_spaces() {
|
||||
@@ -2153,7 +2129,7 @@ mod tests {
|
||||
assert_eq!(shell_unescape(r"path\\name"), r"path\name");
|
||||
}
|
||||
|
||||
// ----- shell_unescape / Windows-path round-trip ----------------------
|
||||
// shell_unescape / Windows-path round-trip
|
||||
//
|
||||
// `\` is a path separator on Windows, not a shell escape. The
|
||||
// unescape must skip Windows-looking inputs or it would collapse
|
||||
@@ -2209,7 +2185,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ----- try_read_image_from_path ----------------------------------------
|
||||
// try_read_image_from_path
|
||||
|
||||
#[test]
|
||||
fn try_read_image_with_escaped_parens() {
|
||||
@@ -2246,8 +2222,7 @@ mod tests {
|
||||
assert!(result.unwrap().source_path.is_some());
|
||||
}
|
||||
|
||||
// ----- single-file resilience (drop with trailing whitespace / quotes /
|
||||
// file:// URLs) ---------------------------------------------------
|
||||
// single-file resilience: drop with trailing whitespace / quotes / file:// URLs
|
||||
|
||||
/// Writes a real PNG at `path`. Helper to keep the multi-file tests tidy.
|
||||
fn write_png(path: &std::path::Path, w: u32, h: u32) {
|
||||
@@ -2308,7 +2283,7 @@ mod tests {
|
||||
assert!(try_read_image_from_path(&pasted).is_some());
|
||||
}
|
||||
|
||||
// ----- file:// URL parsing -------------------------------------------
|
||||
// file:// URL parsing
|
||||
|
||||
#[test]
|
||||
fn try_read_image_file_url() {
|
||||
@@ -2357,7 +2332,7 @@ mod tests {
|
||||
assert!(try_read_image_from_path(&pasted).is_some());
|
||||
}
|
||||
|
||||
// ----- multi-file drop -----------------------------------------------
|
||||
// multi-file drop
|
||||
|
||||
/// Non-image paths are canonicalized before insertion.
|
||||
fn canon(p: &std::path::Path) -> PathBuf {
|
||||
@@ -2490,7 +2465,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ----- negatives — must not auto-attach ------------------------------
|
||||
// negatives — must not auto-attach
|
||||
|
||||
#[test]
|
||||
fn free_prose_containing_slash_returns_empty() {
|
||||
@@ -2527,7 +2502,7 @@ mod tests {
|
||||
assert!(try_read_images_from_paste("line one\nline two").is_empty());
|
||||
}
|
||||
|
||||
// ----- additional edge cases ----
|
||||
// additional edge cases
|
||||
|
||||
#[test]
|
||||
fn bash_mode_prefix_not_treated_as_image() {
|
||||
@@ -2576,7 +2551,8 @@ mod tests {
|
||||
fn newline_wins_space_inside_line_not_split() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let a = dir.path().join("a.png");
|
||||
let bc = dir.path().join("b.png c.png"); // a single file with a space in its name
|
||||
// a single file with a space in its name
|
||||
let bc = dir.path().join("b.png c.png");
|
||||
let other = dir.path().join("d.png");
|
||||
write_png(&a, 2, 2);
|
||||
write_png(&bc, 2, 2);
|
||||
@@ -2610,7 +2586,7 @@ mod tests {
|
||||
assert!(try_read_image_from_path(&pasted).is_some());
|
||||
}
|
||||
|
||||
// ----- file:// URL edge cases ----------------------------
|
||||
// file:// URL edge cases
|
||||
|
||||
#[test]
|
||||
fn file_url_with_localhost_host() {
|
||||
@@ -2651,7 +2627,7 @@ mod tests {
|
||||
assert!(try_read_image_from_path(&pasted).is_some());
|
||||
}
|
||||
|
||||
// ----- multi-file space-separated mixed file:// + bare ---
|
||||
// multi-file space-separated mixed file:// + bare
|
||||
|
||||
#[test]
|
||||
fn multi_file_space_separated_file_url_then_bare() {
|
||||
@@ -2777,7 +2753,7 @@ mod tests {
|
||||
assert!(try_read_image_from_path(pasted).is_none());
|
||||
}
|
||||
|
||||
// ----- try_read_dropped_paths -----------------------------------------
|
||||
// try_read_dropped_paths
|
||||
|
||||
fn dropped_paths(text: &str) -> Vec<DroppedPath> {
|
||||
try_read_dropped_paths(text)
|
||||
@@ -3226,7 +3202,7 @@ mod tests {
|
||||
/// pipeline when inserted as text. Reject these at parse time
|
||||
/// so the prompt never sees them.
|
||||
///
|
||||
/// The gate is intentionally narrow (NUL, CR, LF) — TAB and
|
||||
/// The gate is deliberately narrow (NUL, CR, LF) — TAB and
|
||||
/// other low-control bytes are legal in Unix filenames and the
|
||||
/// TUI's text path renders them fine.
|
||||
#[test]
|
||||
@@ -3250,7 +3226,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// HEIC/HEIF/AVIF/ICO are intentionally NOT in `IMAGE_EXTENSIONS`
|
||||
/// HEIC/HEIF/AVIF/ICO are deliberately NOT in `IMAGE_EXTENSIONS`
|
||||
/// — the inline overlay doesn't render them, so we fall through
|
||||
/// to NonImage path text instead of falsely promoting a chip.
|
||||
#[test]
|
||||
@@ -3271,7 +3247,7 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// SVG is intentionally NOT in `IMAGE_EXTENSIONS` (XML/text
|
||||
/// SVG is deliberately NOT in `IMAGE_EXTENSIONS` (XML/text
|
||||
/// formats aren't sniffed as images and the inline overlay does
|
||||
/// not render SVG). An `.svg` drop must fall through to NonImage
|
||||
/// so the user gets a path string they can pass to the agent.
|
||||
@@ -3402,7 +3378,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn try_read_images_from_paste_equals_image_filtered_dropped_paths() {
|
||||
// `try_read_images_from_paste` is now a thin filter over
|
||||
// `try_read_images_from_paste` is a thin filter over
|
||||
// `try_read_dropped_paths`. Lock in the delegation invariant
|
||||
// for several input shapes so a regression that diverges
|
||||
// only in one shape (e.g. the empty-paste case) would still
|
||||
@@ -3624,7 +3600,7 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// ----- reconcile ------------------------------------------------------
|
||||
// reconcile
|
||||
|
||||
#[test]
|
||||
fn reconcile_keeps_live_images() {
|
||||
@@ -3668,7 +3644,7 @@ mod tests {
|
||||
assert!(images.is_empty());
|
||||
}
|
||||
|
||||
// ----- clear ----------------------------------------------------------
|
||||
// clear
|
||||
|
||||
#[test]
|
||||
fn clear_resets_images_and_counter() {
|
||||
@@ -3681,7 +3657,7 @@ mod tests {
|
||||
assert_eq!(counter, 0);
|
||||
}
|
||||
|
||||
// ----- persist_to_session ------------------------------------------------
|
||||
// persist_to_session
|
||||
|
||||
#[test]
|
||||
fn persist_writes_file_and_clears_bytes() {
|
||||
@@ -3750,12 +3726,13 @@ mod tests {
|
||||
#[test]
|
||||
fn persist_fails_without_bytes() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut img = make_image(1, 1); // encoded_bytes is None
|
||||
// encoded_bytes is None
|
||||
let mut img = make_image(1, 1);
|
||||
let result = persist_to_session(&mut img, dir.path());
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
// ----- persist_to_session path ownership --------------------------------
|
||||
// persist_to_session path ownership
|
||||
|
||||
#[test]
|
||||
fn persist_clipboard_image_keeps_source_path_none() {
|
||||
@@ -3773,7 +3750,8 @@ mod tests {
|
||||
dimensions: Some((100, 80)),
|
||||
byte_len: png.len(),
|
||||
encoded_bytes: Some(Arc::from(png)),
|
||||
source_path: None, // clipboard paste — no original path
|
||||
// clipboard paste — no original path
|
||||
source_path: None,
|
||||
staged_temp_path: None,
|
||||
session_image_path: None,
|
||||
preview: PromptImagePreview::default(),
|
||||
@@ -3826,7 +3804,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ----- from_clipboard_data -----------------------------------------------
|
||||
// from_clipboard_data
|
||||
|
||||
#[test]
|
||||
fn from_clipboard_data_populates_fields() {
|
||||
@@ -3875,7 +3853,7 @@ mod tests {
|
||||
assert_eq!(img.encoded_bytes.as_deref(), Some(data.data.as_slice()));
|
||||
}
|
||||
|
||||
// ----- test PNG helper --------------------------------------------------
|
||||
// test PNG helper
|
||||
|
||||
/// Generate a valid minimal PNG of the given dimensions.
|
||||
fn make_test_png(width: u32, height: u32) -> Vec<u8> {
|
||||
@@ -3918,7 +3896,7 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// ----- load_for_send ------------------------------------------------
|
||||
// load_for_send
|
||||
|
||||
#[test]
|
||||
fn load_small_image_passes_through() {
|
||||
@@ -3948,7 +3926,7 @@ mod tests {
|
||||
mime_type: "image/png".into(),
|
||||
dimensions: Some((50, 50)),
|
||||
byte_len: png.len(),
|
||||
encoded_bytes: None, // bytes released
|
||||
encoded_bytes: None,
|
||||
source_path: None,
|
||||
staged_temp_path: None,
|
||||
session_image_path: Some(path),
|
||||
@@ -3960,11 +3938,12 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn load_returns_none_for_missing_data() {
|
||||
let img = make_image(1, 1); // no bytes, no file
|
||||
// no bytes, no file
|
||||
let img = make_image(1, 1);
|
||||
assert!(load_for_send(&img).is_none());
|
||||
}
|
||||
|
||||
// ----- build_content_blocks_with_workspace --------------------------------
|
||||
// build_content_blocks_with_workspace
|
||||
|
||||
fn build_blocks_no_workspace(
|
||||
text: String,
|
||||
@@ -4108,7 +4087,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn build_blocks_skips_missing_image() {
|
||||
let img = make_image(1, 1); // no bytes, no file path
|
||||
// no bytes, no file path
|
||||
let img = make_image(1, 1);
|
||||
let blocks = build_blocks_no_workspace("text".into(), vec![img]);
|
||||
// Only the text block; image was skipped.
|
||||
assert_eq!(blocks.len(), 1);
|
||||
@@ -4116,14 +4096,14 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn build_blocks_one_bad_one_good() {
|
||||
let bad = make_image(1, 1); // no bytes
|
||||
let bad = make_image(1, 1);
|
||||
let good = make_real_image(50, 50);
|
||||
let blocks = build_blocks_no_workspace("text".into(), vec![bad, good]);
|
||||
// Text + 1 good image; bad image skipped.
|
||||
assert_eq!(blocks.len(), 2);
|
||||
}
|
||||
|
||||
// ----- Orphan placeholder fallback ----------------------------------
|
||||
// Orphan placeholder fallback
|
||||
//
|
||||
// These tests go through `build_content_blocks_with_prefixes` with
|
||||
// an explicit hermetic prefix list, so they do NOT read the
|
||||
@@ -4165,7 +4145,7 @@ mod tests {
|
||||
.decode(&ic.data)
|
||||
.expect("data must be valid base64");
|
||||
assert_eq!(decoded, on_disk);
|
||||
// Placeholder anchor stays but the path is now stripped — the
|
||||
// Placeholder anchor stays but the path is stripped — the
|
||||
// image is already attached inline, so the model has no reason
|
||||
// to call `Read` on the path (and the path component would
|
||||
// tempt it to). The bracketed `[Image #N]` form preserves the
|
||||
@@ -4324,7 +4304,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ----- TUI aggregate-cap injectable variant + tests -----------------
|
||||
// TUI aggregate-cap injectable variant + tests
|
||||
//
|
||||
// Mirrors the server-side
|
||||
// `recover_orphan_placeholders_with_prefixes_and_caps` tests so a
|
||||
@@ -4422,7 +4402,7 @@ mod tests {
|
||||
/// **Text-side contract.** Aggregate-cap breach is a `break`
|
||||
/// path in `resolve_orphan_placeholders`, not a per-image
|
||||
/// `Err` path. Only `Err`-path failures strip the placeholder
|
||||
/// text; cap-breach intentionally **leaves the placeholder
|
||||
/// text; cap-breach deliberately **leaves the placeholder
|
||||
/// text intact** because the load itself succeeded (the file
|
||||
/// is valid, just doesn't fit in the budget). The test pins
|
||||
/// both halves of this contract: no image block AND
|
||||
@@ -4452,7 +4432,7 @@ mod tests {
|
||||
// breach (cap-breach is a `break` path, not a load `Err`).
|
||||
// Pinning the preservation half of the contract.
|
||||
//
|
||||
// Phase 2 path-strip update: the bracketed anchor
|
||||
// Phase 2 path-strip behaviour: the bracketed anchor
|
||||
// `[Image #N]` survives, but the `: <path>` component is
|
||||
// stripped uniformly across every surviving placeholder. The
|
||||
// model can still see *where* in the prose the image was
|
||||
@@ -4470,7 +4450,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ----- T8: cleanup and lifecycle edge cases ------------------------------
|
||||
// T8: cleanup and lifecycle edge cases
|
||||
|
||||
#[test]
|
||||
fn clear_deletes_staged_temp_file() {
|
||||
@@ -4488,7 +4468,8 @@ mod tests {
|
||||
encoded_bytes: None,
|
||||
source_path: None,
|
||||
staged_temp_path: Some(tmp_path.clone()),
|
||||
session_image_path: None, // not yet persisted to session
|
||||
// not yet persisted to session
|
||||
session_image_path: None,
|
||||
preview: PromptImagePreview::default(),
|
||||
}];
|
||||
let mut counter = 1;
|
||||
@@ -4578,7 +4559,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ----- ScrollbackImageRef ------------------------------------------------
|
||||
// ScrollbackImageRef
|
||||
|
||||
#[test]
|
||||
fn scrollback_ref_from_valid_image_path() {
|
||||
@@ -4634,7 +4615,7 @@ mod tests {
|
||||
assert!(ScrollbackImageRef::from_path("/nonexistent/image.png").is_none());
|
||||
}
|
||||
|
||||
// ----- extract_image_refs ------------------------------------------------
|
||||
// extract_image_refs
|
||||
|
||||
#[test]
|
||||
fn extract_markdown_image_ref() {
|
||||
@@ -4714,7 +4695,7 @@ mod tests {
|
||||
assert!(refs.is_empty());
|
||||
}
|
||||
|
||||
// ----- open_from_path ----------------------------------------------------
|
||||
// open_from_path
|
||||
|
||||
#[test]
|
||||
fn open_from_path_valid_image() {
|
||||
@@ -4751,7 +4732,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ----- open_from_path_deferred -------------------------------------------
|
||||
// open_from_path_deferred
|
||||
|
||||
#[test]
|
||||
fn deferred_open_starts_in_loading_state() {
|
||||
|
||||
@@ -59,7 +59,7 @@ pub fn indexed_to_rgb(index: u8) -> (u8, u8, u8) {
|
||||
/// ramp (232–255), returning whichever has the smallest squared Euclidean
|
||||
/// distance.
|
||||
pub fn nearest_indexed(r: u8, g: u8, b: u8) -> u8 {
|
||||
// --- nearest in the 6×6×6 color cube (16–231) ---
|
||||
// nearest in the 6×6×6 color cube (16–231)
|
||||
let ri = nearest_cube_channel(r);
|
||||
let gi = nearest_cube_channel(g);
|
||||
let bi = nearest_cube_channel(b);
|
||||
@@ -73,7 +73,7 @@ pub fn nearest_indexed(r: u8, g: u8, b: u8) -> u8 {
|
||||
CUBE_VALUES[bi as usize],
|
||||
);
|
||||
|
||||
// --- nearest in the grayscale ramp (232–255) ---
|
||||
// nearest in the grayscale ramp (232–255)
|
||||
// Ramp values: 8, 18, 28, …, 238 (24 entries)
|
||||
let lum = (r as u16 + g as u16 + b as u16) / 3;
|
||||
let gray_step = if lum <= 3 {
|
||||
@@ -131,7 +131,7 @@ fn color_to_rgb(color: Color) -> Option<(u8, u8, u8)> {
|
||||
///
|
||||
/// Useful when downstream code must produce RGB for *every* color value
|
||||
/// — e.g. progress-bar gradients that lerp across named breakpoints, or
|
||||
/// OSC 12 cursor-color updates that must emit an RGB triple regardless
|
||||
/// OSC 12 cursor-color writes that must emit an RGB triple regardless
|
||||
/// of terminal color depth.
|
||||
///
|
||||
/// Named-color RGB matches the xterm 16-color palette used by
|
||||
@@ -166,11 +166,10 @@ pub fn resolve_to_rgb(color: Color) -> Option<(u8, u8, u8)> {
|
||||
/// Blend a single color channel: lerp from base toward original based on opacity.
|
||||
///
|
||||
/// - `opacity = 0.0`: returns `base` (fully faded)
|
||||
/// - `opacity = 1.0`: returns `original` (no change)
|
||||
/// - `opacity = 1.0`: returns `original` (Unchanged)
|
||||
#[inline]
|
||||
pub fn blend_channel(base: u8, original: u8, opacity: f32) -> u8 {
|
||||
// result = base + (original - base) * opacity
|
||||
// = base * (1 - opacity) + original * opacity
|
||||
let result = base as f32 * (1.0 - opacity) + original as f32 * opacity;
|
||||
result.round() as u8
|
||||
}
|
||||
@@ -178,7 +177,7 @@ pub fn blend_channel(base: u8, original: u8, opacity: f32) -> u8 {
|
||||
/// Blend a color toward a base color based on opacity.
|
||||
///
|
||||
/// - `opacity = 0.0`: returns `base` (fully faded)
|
||||
/// - `opacity = 1.0`: returns `original` (no change)
|
||||
/// - `opacity = 1.0`: returns `original` (Unchanged)
|
||||
///
|
||||
/// Supports both `Color::Rgb` and `Color::Indexed` colors (indexed colors are
|
||||
/// converted to their RGB equivalents for blending). When either input is
|
||||
@@ -212,9 +211,9 @@ pub fn blend_color(base: Color, original: Color, opacity: f32) -> Option<Color>
|
||||
/// its colors toward the background.
|
||||
///
|
||||
/// - `opacity = 0.0`: fully faded to base color
|
||||
/// - `opacity = 1.0`: no change (original colors)
|
||||
/// - `opacity = 1.0`: Unchanged (original colors)
|
||||
///
|
||||
/// Named ANSI colors are left unchanged.
|
||||
/// Named ANSI colors are left `unchanged`.
|
||||
pub fn blend_line(line: Line<'static>, base: Color, opacity: f32) -> Line<'static> {
|
||||
let blended_spans: Vec<Span<'static>> = line
|
||||
.spans
|
||||
@@ -239,9 +238,9 @@ pub fn blend_line(line: Line<'static>, base: Color, opacity: f32) -> Line<'stati
|
||||
/// explicitly colored text.
|
||||
///
|
||||
/// - `opacity = 0.0`: fully faded to base color
|
||||
/// - `opacity = 1.0`: no change (original colors)
|
||||
/// - `opacity = 1.0`: Unchanged (original colors)
|
||||
///
|
||||
/// Named ANSI colors are left unchanged.
|
||||
/// Named ANSI colors are left `unchanged`.
|
||||
pub fn blend_line_with_default(
|
||||
line: Line<'static>,
|
||||
base: Color,
|
||||
@@ -269,10 +268,10 @@ pub fn blend_line_with_default(
|
||||
/// This blends both foreground and background colors of each cell toward
|
||||
/// `base_color` based on `opacity`:
|
||||
/// - `opacity = 0.0`: fully faded (cells become base_color)
|
||||
/// - `opacity = 1.0`: no change
|
||||
/// - `opacity = 1.0`: Unchanged
|
||||
///
|
||||
/// Both RGB and Indexed colors are blended; named ANSI colors (Color::Red, etc.)
|
||||
/// are left unchanged since their RGB values are terminal-dependent.
|
||||
/// are left `unchanged` since their RGB values are terminal-dependent.
|
||||
pub fn fade_region(buf: &mut Buffer, area: Rect, base_color: Color, opacity: f32) {
|
||||
blend_area(
|
||||
buf,
|
||||
@@ -285,10 +284,10 @@ pub fn fade_region(buf: &mut Buffer, area: Rect, base_color: Color, opacity: f32
|
||||
/// Blend fg and/or bg of every cell in an area toward target colors.
|
||||
///
|
||||
/// Each parameter is `Option<(target, opacity)>`:
|
||||
/// - `None`: leave that channel unchanged
|
||||
/// - `None`: leave that channel `unchanged`
|
||||
/// - `Some((target, opacity))`: blend toward `target` at `opacity`
|
||||
/// - `opacity = 0.0`: fully target (original gone)
|
||||
/// - `opacity = 1.0`: no change (original kept)
|
||||
/// - `opacity = 1.0`: Unchanged (original kept)
|
||||
///
|
||||
/// Both RGB and Indexed colors are blended; named ANSI color cells are skipped.
|
||||
pub fn blend_area(
|
||||
@@ -383,7 +382,8 @@ mod tests {
|
||||
// opacity = 0.5: halfway between
|
||||
assert_eq!(blend_channel(0, 100, 0.5), 50);
|
||||
assert_eq!(blend_channel(100, 200, 0.5), 150);
|
||||
assert_eq!(blend_channel(0, 255, 0.5), 128); // 127.5 rounds to 128
|
||||
// 127.5 rounds to 128
|
||||
assert_eq!(blend_channel(0, 255, 0.5), 128);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -403,7 +403,7 @@ mod tests {
|
||||
let faded = blend_color(base, original, 0.0);
|
||||
assert_eq!(faded, Some(Color::Rgb(0, 0, 0)));
|
||||
|
||||
// No change
|
||||
// Unchanged
|
||||
let unchanged = blend_color(base, original, 1.0);
|
||||
assert_eq!(unchanged, Some(Color::Rgb(100, 150, 200)));
|
||||
|
||||
@@ -415,8 +415,10 @@ mod tests {
|
||||
#[test]
|
||||
fn test_blend_color_indexed_returns_indexed() {
|
||||
// Both indexed → result is indexed (quantized back to 256-color palette)
|
||||
let base = Color::Indexed(232); // near-black (8, 8, 8)
|
||||
let original = Color::Indexed(255); // near-white (238, 238, 238)
|
||||
// near-black (8, 8, 8)
|
||||
let base = Color::Indexed(232);
|
||||
// near-white (238, 238, 238)
|
||||
let original = Color::Indexed(255);
|
||||
|
||||
let half = blend_color(base, original, 0.5).unwrap();
|
||||
assert!(matches!(half, Color::Indexed(_)));
|
||||
@@ -433,7 +435,8 @@ mod tests {
|
||||
#[test]
|
||||
fn test_blend_color_mixed_returns_indexed() {
|
||||
let rgb = Color::Rgb(100, 100, 100);
|
||||
let indexed = Color::Indexed(5); // magenta (128, 0, 128)
|
||||
// magenta (128, 0, 128)
|
||||
let indexed = Color::Indexed(5);
|
||||
|
||||
// Mixed: indexed base + rgb original → Indexed result (quantized)
|
||||
let result = blend_color(indexed, rgb, 0.5);
|
||||
@@ -483,8 +486,8 @@ mod tests {
|
||||
|
||||
// Check cells are faded
|
||||
if let Some(cell) = buf.cell((0, 0)) {
|
||||
assert_eq!(cell.fg, Color::Rgb(100, 100, 100)); // 200 * 0.5
|
||||
assert_eq!(cell.bg, Color::Rgb(25, 25, 25)); // 50 * 0.5
|
||||
assert_eq!(cell.fg, Color::Rgb(100, 100, 100));
|
||||
assert_eq!(cell.bg, Color::Rgb(25, 25, 25));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -507,14 +510,14 @@ mod tests {
|
||||
// Only fade a 2x2 region in the middle
|
||||
fade_region(&mut buf, Rect::new(1, 1, 2, 2), base, 0.0);
|
||||
|
||||
// Corner should be unchanged
|
||||
// Corner should be `unchanged`
|
||||
assert_eq!(buf.cell((0, 0)).unwrap().fg, Color::Rgb(100, 100, 100));
|
||||
|
||||
// Middle should be fully faded
|
||||
assert_eq!(buf.cell((1, 1)).unwrap().fg, Color::Rgb(0, 0, 0));
|
||||
assert_eq!(buf.cell((2, 2)).unwrap().fg, Color::Rgb(0, 0, 0));
|
||||
|
||||
// Other corner unchanged
|
||||
// Other corner `unchanged`
|
||||
assert_eq!(buf.cell((3, 3)).unwrap().fg, Color::Rgb(100, 100, 100));
|
||||
}
|
||||
|
||||
@@ -535,7 +538,7 @@ mod tests {
|
||||
|
||||
// fg blended to 50%
|
||||
assert_eq!(buf.cell((0, 0)).unwrap().fg, Color::Rgb(100, 50, 0));
|
||||
// bg unchanged
|
||||
// bg `unchanged`
|
||||
assert_eq!(buf.cell((0, 0)).unwrap().bg, Color::Rgb(10, 10, 10));
|
||||
}
|
||||
|
||||
@@ -554,7 +557,7 @@ mod tests {
|
||||
let target = Color::Rgb(0, 0, 0);
|
||||
blend_area(&mut buf, Rect::new(0, 0, 2, 1), None, Some((target, 0.5)));
|
||||
|
||||
// fg unchanged
|
||||
// fg `unchanged`
|
||||
assert_eq!(buf.cell((0, 0)).unwrap().fg, Color::Rgb(200, 200, 200));
|
||||
// bg blended to 50%
|
||||
assert_eq!(buf.cell((0, 0)).unwrap().bg, Color::Rgb(50, 50, 50));
|
||||
@@ -611,7 +614,8 @@ mod tests {
|
||||
fn test_blend_area_named_color_skipped() {
|
||||
let mut buf = Buffer::empty(Rect::new(0, 0, 1, 1));
|
||||
if let Some(cell) = buf.cell_mut((0, 0)) {
|
||||
cell.set_fg(Color::Red); // named color — blend_color returns None
|
||||
// named color — blend_color returns None
|
||||
cell.set_fg(Color::Red);
|
||||
cell.set_bg(Color::Red);
|
||||
}
|
||||
|
||||
@@ -622,7 +626,7 @@ mod tests {
|
||||
Some((Color::Rgb(0, 0, 0), 0.5)),
|
||||
);
|
||||
|
||||
// Named colors should be unchanged (blend_color returns None for them)
|
||||
// Named colors should be `unchanged` (blend_color returns None for them)
|
||||
assert_eq!(buf.cell((0, 0)).unwrap().fg, Color::Red);
|
||||
assert_eq!(buf.cell((0, 0)).unwrap().bg, Color::Red);
|
||||
}
|
||||
@@ -631,12 +635,14 @@ mod tests {
|
||||
fn test_blend_area_indexed_colors_blended() {
|
||||
let mut buf = Buffer::empty(Rect::new(0, 0, 1, 1));
|
||||
if let Some(cell) = buf.cell_mut((0, 0)) {
|
||||
cell.set_fg(Color::Indexed(255)); // near-white grayscale
|
||||
// near-white grayscale
|
||||
cell.set_fg(Color::Indexed(255));
|
||||
cell.set_bg(Color::Indexed(255));
|
||||
}
|
||||
|
||||
// Blend toward black (indexed 232 = #080808, but we use indexed 16 = #000000)
|
||||
let target = Color::Indexed(16); // black in the color cube
|
||||
// black in the color cube
|
||||
let target = Color::Indexed(16);
|
||||
blend_area(
|
||||
&mut buf,
|
||||
Rect::new(0, 0, 1, 1),
|
||||
|
||||
@@ -368,7 +368,7 @@ pub fn draw_frame(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
/// An unchanged frame must emit zero bytes to the PTY.
|
||||
/// An `unchanged` frame must emit zero bytes to the PTY.
|
||||
#[test]
|
||||
fn idle_frame_emits_zero_bytes() {
|
||||
use ratatui::backend::CrosstermBackend;
|
||||
|
||||
@@ -12,8 +12,7 @@ use ratatui::widgets::{Block, BorderType, Borders, Widget};
|
||||
use crate::gboom::GboomHud;
|
||||
use crate::render::safe_buf::SafeBuf;
|
||||
|
||||
/// Render the GBOOM popup chrome. Returns the popup `Rect`,
|
||||
/// or `None` if the area is too small to play in.
|
||||
/// Returns the popup `Rect`, or `None` when the area is too small to play in.
|
||||
pub fn render_gboom_overlay(
|
||||
buf: &mut Buffer,
|
||||
area: Rect,
|
||||
@@ -28,7 +27,6 @@ pub fn render_gboom_overlay(
|
||||
|
||||
crate::render::color::dim_area(buf, area, bg, 0.5);
|
||||
|
||||
// 90% centered popup, like the video viewer.
|
||||
let popup_width = ((area.width as u32 * 90) / 100)
|
||||
.max(30)
|
||||
.min(area.width as u32) as u16;
|
||||
@@ -49,7 +47,6 @@ pub fn render_gboom_overlay(
|
||||
.style(Style::default().bg(bg))
|
||||
.render(popup_rect, buf);
|
||||
|
||||
// Title centered in the top border, in the iconic logo red.
|
||||
let title = " GBOOM ";
|
||||
let [r, g, b] = crate::gboom::GBOOM_RED;
|
||||
let title_style = Style::default()
|
||||
@@ -60,14 +57,13 @@ pub fn render_gboom_overlay(
|
||||
let tx = popup_rect.x + (popup_rect.width.saturating_sub(tw)) / 2;
|
||||
buf.set_span_safe(tx, popup_rect.y, &Span::styled(title, title_style), tw);
|
||||
|
||||
// HUD on the bottom border row.
|
||||
render_hud_bar(buf, popup_rect, hud, border_fg, bg);
|
||||
|
||||
Some(popup_rect)
|
||||
}
|
||||
|
||||
/// Render the HUD on the popup's bottom border row:
|
||||
/// `HP 100 · KILLS 0/8` left, controls hint right.
|
||||
/// Overwrites the popup's bottom border row: `HP 100 · KILLS 0/8` on the left,
|
||||
/// controls hint on the right.
|
||||
fn render_hud_bar(buf: &mut Buffer, popup_rect: Rect, hud: &GboomHud, dim_fg: Color, bg: Color) {
|
||||
let bar_y = popup_rect.y + popup_rect.height.saturating_sub(1);
|
||||
let inner_width = popup_rect.width.saturating_sub(2) as usize;
|
||||
@@ -75,8 +71,7 @@ fn render_hud_bar(buf: &mut Buffer, popup_rect: Rect, hud: &GboomHud, dim_fg: Co
|
||||
return;
|
||||
}
|
||||
|
||||
// Health-bar semantics: green when comfortable, amber when hurting,
|
||||
// GBOOM red when critical.
|
||||
// Green when comfortable, amber when hurting, GBOOM red when critical.
|
||||
let hp_color = if hud.hp > 60 {
|
||||
Color::Rgb(126, 200, 96)
|
||||
} else if hud.hp > 30 {
|
||||
|
||||
@@ -70,8 +70,8 @@ pub fn paint_match_highlights(
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply the terminal's REVERSED attribute so the fg/bg swap is native and
|
||||
/// respects the user's theme.
|
||||
/// REVERSED leaves the fg/bg swap to the terminal, so highlights follow the
|
||||
/// user's theme instead of hardcoded colors.
|
||||
fn invert_cell(cell: &mut ratatui::buffer::Cell) {
|
||||
cell.modifier.insert(ratatui::style::Modifier::REVERSED);
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ pub(super) fn paint_path_line(
|
||||
bg: Color,
|
||||
) {
|
||||
let raw = path.display().to_string();
|
||||
// The 6 reserved columns are the "Path: " prefix.
|
||||
let label = format!(
|
||||
"Path: {}",
|
||||
truncate_path_for_overlay(&raw, width.saturating_sub(6) as usize)
|
||||
|
||||
@@ -109,7 +109,7 @@ pub fn truncate_str(s: &str, max_width: usize) -> String {
|
||||
/// exhausted mid-span, that span is truncated and `…` is appended. Spans
|
||||
/// beyond the budget are dropped. All styles are preserved.
|
||||
///
|
||||
/// Returns the line unchanged if it already fits.
|
||||
/// Returns the line `unchanged` if it already fits.
|
||||
pub fn truncate_line(line: Line<'static>, max_width: usize) -> Line<'static> {
|
||||
if max_width == 0 {
|
||||
return Line::from(vec![]);
|
||||
@@ -152,7 +152,7 @@ pub fn truncate_line(line: Line<'static>, max_width: usize) -> Line<'static> {
|
||||
/// Clip or pad a styled `Line` to exactly `width` display columns.
|
||||
///
|
||||
/// Wider lines are clipped on grapheme boundaries (a multi-`char` grapheme like
|
||||
/// `⚠\u{FE0F}` is never split) with no ellipsis; narrower lines are padded with
|
||||
/// `⚠\u{FE0F}` is never split) with no ellipsis; narrower lines are `padded` with
|
||||
/// trailing spaces. This keeps a rendered row "self-owning" — the app writes a
|
||||
/// real cell in every column, so a terminal drawing a glyph wider than the app
|
||||
/// measured cannot strand a stale cell past the row (the markdown-table ghost
|
||||
@@ -346,10 +346,11 @@ mod tests {
|
||||
let s = "hello — world";
|
||||
let result = truncate_str(s, 8);
|
||||
assert!(result.ends_with('…'));
|
||||
assert!(result.len() <= 12); // safe byte length
|
||||
// safe byte length
|
||||
assert!(result.len() <= 12);
|
||||
}
|
||||
|
||||
// ── truncate_line tests ─────────────────────────────────────────
|
||||
// truncate_line tests
|
||||
|
||||
#[test]
|
||||
fn truncate_line_fits() {
|
||||
@@ -395,7 +396,7 @@ mod tests {
|
||||
assert!(result.spans.is_empty());
|
||||
}
|
||||
|
||||
// ── fit_line_to_width tests ─────────────────────────────────────
|
||||
// fit_line_to_width tests
|
||||
|
||||
fn line_text(line: &Line<'static>) -> String {
|
||||
line.spans.iter().map(|s| s.content.as_ref()).collect()
|
||||
@@ -438,7 +439,7 @@ mod tests {
|
||||
#[test]
|
||||
fn fit_line_clips_grapheme_straddle_in_later_span() {
|
||||
// The straddle happens in a later span: keep "ab", then 1 col left →
|
||||
// ⚠️ (width 2) won't fit → dropped whole and padded.
|
||||
// ⚠️ (width 2) won't fit → dropped whole and `padded`.
|
||||
let line = Line::from(vec![Span::raw("ab"), Span::raw("\u{26A0}\u{FE0F}cd")]);
|
||||
let out = fit_line_to_width(line, 3);
|
||||
assert_eq!(line_text(&out).width(), 3);
|
||||
@@ -502,7 +503,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ── cascade_truncate tests ────────────────────────────────────
|
||||
// cascade_truncate tests
|
||||
|
||||
#[test]
|
||||
fn cascade_truncate_all_fit() {
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
//! Low-level rendering utilities.
|
||||
//!
|
||||
//! Generic rendering primitives used by the scrollback and viewport.
|
||||
//! Low-level rendering primitives shared by the scrollback and viewport.
|
||||
pub mod color;
|
||||
pub mod draw;
|
||||
pub mod gboom_overlay;
|
||||
|
||||
@@ -48,7 +48,8 @@ impl LinkOverlay {
|
||||
link.col_end
|
||||
);
|
||||
if link.col_start > link.col_end {
|
||||
return; // Silently skip inverted ranges in release mode.
|
||||
// Silently skip inverted ranges in release mode.
|
||||
return;
|
||||
}
|
||||
self.links.push(link);
|
||||
}
|
||||
@@ -541,7 +542,7 @@ mod tests {
|
||||
scan_lines_for_url_overlays(rows.into_iter(), content_x, media_paths, overlay);
|
||||
}
|
||||
|
||||
// ── local_link_to_file_url ──
|
||||
// local_link_to_file_url
|
||||
|
||||
#[test]
|
||||
fn local_link_relative_resolves_to_generated_media() {
|
||||
@@ -592,7 +593,7 @@ mod tests {
|
||||
assert!(local_link_to_file_url("../images/1.jpg", &media).is_none());
|
||||
}
|
||||
|
||||
// ── tool_path_file_url ──
|
||||
// tool_path_file_url
|
||||
|
||||
#[test]
|
||||
fn tool_path_file_url_resolves_relative_against_cwd() {
|
||||
@@ -637,7 +638,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ── LinkOverlay ──
|
||||
// LinkOverlay
|
||||
|
||||
#[test]
|
||||
fn overlay_empty_by_default() {
|
||||
@@ -661,7 +662,7 @@ mod tests {
|
||||
assert_eq!(overlay.links()[0].screen_row, 5);
|
||||
}
|
||||
|
||||
// ── scan_lines_for_url_overlays ──
|
||||
// scan_lines_for_url_overlays
|
||||
|
||||
use ratatui::text::{Line as RLine, Span as RSpan};
|
||||
|
||||
@@ -690,7 +691,8 @@ mod tests {
|
||||
assert_eq!(link.screen_row, 5);
|
||||
// "See " = 4 display cols, content_x = 2
|
||||
assert_eq!(link.col_start, 6);
|
||||
assert_eq!(link.col_end, 6 + 19); // "https://example.com" = 19 chars
|
||||
// "https://example.com" = 19 chars
|
||||
assert_eq!(link.col_end, 6 + 19);
|
||||
assert_eq!(link.id, None);
|
||||
}
|
||||
|
||||
@@ -796,7 +798,7 @@ mod tests {
|
||||
assert_eq!(overlay.links()[0].col_end, 10 + 16);
|
||||
}
|
||||
|
||||
// ── File path detection ──
|
||||
// File path detection
|
||||
|
||||
#[test]
|
||||
fn scan_detects_absolute_file_path() {
|
||||
@@ -875,7 +877,7 @@ mod tests {
|
||||
#[test]
|
||||
fn scan_detects_media_path_soft_wrapped_across_rows() {
|
||||
// Regression: media-tool output prose wraps the long session path
|
||||
// across visual rows (`joiner: Some("")` mid-word break). Previously
|
||||
// across visual rows (`joiner: Some("")` mid-word break). earlier
|
||||
// each row was scanned in isolation, so only the `/Users/alice`
|
||||
// fragment on the first row matched and became clickable.
|
||||
let row0 =
|
||||
@@ -1162,7 +1164,7 @@ mod tests {
|
||||
&*overlay.links()[0].url,
|
||||
"file:///tmp/release/Demo%20App.app"
|
||||
);
|
||||
assert_eq!(overlay.links()[0].col_start, 5); // "open "
|
||||
assert_eq!(overlay.links()[0].col_start, 5);
|
||||
assert_eq!(
|
||||
overlay.links()[0].col_end,
|
||||
5 + UnicodeWidthStr::width(path) as u16
|
||||
@@ -1184,7 +1186,7 @@ mod tests {
|
||||
assert_eq!(overlay.links()[0].col_end, 4 + 12);
|
||||
}
|
||||
|
||||
// ── Home-relative (`~/`) path detection ──
|
||||
// Home-relative (`~/`) path detection
|
||||
|
||||
#[test]
|
||||
fn scan_detects_tilde_file_path() {
|
||||
@@ -1277,7 +1279,7 @@ mod tests {
|
||||
id: None,
|
||||
});
|
||||
assert!(overlay.overlaps(5, 10, 20));
|
||||
assert!(!overlay.overlaps(6, 10, 20)); // different row
|
||||
assert!(!overlay.overlaps(6, 10, 20));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1290,10 +1292,11 @@ mod tests {
|
||||
url: Arc::from("https://a.example"),
|
||||
id: None,
|
||||
});
|
||||
assert!(overlay.overlaps(0, 15, 25)); // right overlap
|
||||
assert!(overlay.overlaps(0, 5, 15)); // left overlap
|
||||
assert!(!overlay.overlaps(0, 20, 30)); // adjacent, no overlap
|
||||
assert!(!overlay.overlaps(0, 0, 10)); // adjacent left
|
||||
assert!(overlay.overlaps(0, 15, 25));
|
||||
assert!(overlay.overlaps(0, 5, 15));
|
||||
// adjacent, no overlap
|
||||
assert!(!overlay.overlaps(0, 20, 30));
|
||||
assert!(!overlay.overlaps(0, 0, 10));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -17,9 +17,7 @@ use ratatui::widgets::{Block, BorderType, Borders, Clear, Widget};
|
||||
use super::line_utils::{truncate_line, truncate_str};
|
||||
use super::safe_buf::SafeBuf;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PreviewStyle — configurable colors
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Visual styling for the preview overlay.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
@@ -43,9 +41,7 @@ impl PreviewStyle {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PreviewConfig — layout configuration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Layout configuration for the preview overlay.
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -89,9 +85,7 @@ impl Default for PreviewConfig {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// render_preview_overlay — main rendering function
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Render a multiline preview overlay.
|
||||
///
|
||||
@@ -129,7 +123,8 @@ pub fn render_preview_overlay(
|
||||
// Calculate content layout
|
||||
let needs_dots = total > config.preview_lines * 2;
|
||||
let content_lines: usize = if needs_dots {
|
||||
config.preview_lines * 2 + 1 // top + dots + bottom
|
||||
// top + dots + bottom
|
||||
config.preview_lines * 2 + 1
|
||||
} else {
|
||||
total
|
||||
};
|
||||
@@ -255,7 +250,7 @@ fn render_line(buf: &mut Buffer, x: u16, y: u16, width: u16, line: &str, style:
|
||||
}
|
||||
|
||||
/// Paint the hint into the bottom border row, left-aligned after the
|
||||
/// corner and one dash, padded with a space on each side so the text
|
||||
/// corner and one dash, `padded` with a space on each side so the text
|
||||
/// stands off the dashes: `╰─ enter to expand ────╯`. The corners and
|
||||
/// one dash per side are never overwritten. Skipped entirely when the
|
||||
/// box is too narrow for readable text.
|
||||
@@ -283,9 +278,7 @@ fn render_border_hint(buf: &mut Buffer, box_area: Rect, hint: &Line<'static>, bg
|
||||
buf.set_line_safe(box_area.x + 2, y, &Line::from(spans), box_area.width - 4);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
@@ -293,9 +286,12 @@ mod tests {
|
||||
|
||||
fn test_style() -> PreviewStyle {
|
||||
PreviewStyle::new(
|
||||
Color::Indexed(234), // grayscale 28 — dark bg
|
||||
Color::Indexed(189), // (215,215,255) — light text
|
||||
Color::Indexed(60), // (95,95,135) — dim border
|
||||
// grayscale 28 — dark bg
|
||||
Color::Indexed(234),
|
||||
// (215,215,255) — light text
|
||||
Color::Indexed(189),
|
||||
// (95,95,135) — dim border
|
||||
Color::Indexed(60),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -317,7 +313,8 @@ mod tests {
|
||||
let mut buf = Buffer::empty(Rect::new(0, 0, 10, 3));
|
||||
let result = render_preview_overlay(
|
||||
&mut buf,
|
||||
Rect::new(0, 0, 10, 3), // below min_height=5
|
||||
// below min_height=5
|
||||
Rect::new(0, 0, 10, 3),
|
||||
"hello\nworld",
|
||||
test_style(),
|
||||
PreviewConfig::default(),
|
||||
@@ -435,7 +432,7 @@ mod tests {
|
||||
);
|
||||
assert!(result.is_some());
|
||||
let rect = result.unwrap();
|
||||
assert_eq!(rect.width, 50); // 100 * 0.5 = 50
|
||||
assert_eq!(rect.width, 50);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -57,9 +57,7 @@ impl<'a> From<Box<dyn Renderable + 'a>> for RenderableItem<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Standard Implementations
|
||||
// ============================================================================
|
||||
|
||||
/// Unit type renders as nothing (0 height).
|
||||
impl Renderable for () {
|
||||
|
||||
@@ -6,25 +6,19 @@
|
||||
//! a crash.
|
||||
//!
|
||||
//! This extension trait provides `set_line_safe` / `set_span_safe` /
|
||||
//! `set_string_safe` that silently skip the write when `y` is outside the
|
||||
//! buffer — trading a single missed frame for a panic-free resize.
|
||||
//! `set_string_safe`, which skip the write when the target row lies outside
|
||||
//! the buffer or `x` is past its right edge — trading a single missed frame
|
||||
//! for a panic-free resize.
|
||||
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::style::Style;
|
||||
use ratatui::text::{Line, Span};
|
||||
|
||||
/// Extension trait for bounds-checked buffer writes.
|
||||
pub trait SafeBuf {
|
||||
/// Like `Buffer::set_line` but returns immediately when `y` is outside
|
||||
/// the buffer area.
|
||||
fn set_line_safe(&mut self, x: u16, y: u16, line: &Line<'_>, width: u16);
|
||||
|
||||
/// Like `Buffer::set_span` but returns immediately when `y` is outside
|
||||
/// the buffer area.
|
||||
fn set_span_safe(&mut self, x: u16, y: u16, span: &Span<'_>, width: u16);
|
||||
|
||||
/// Like `Buffer::set_string` but returns immediately when `y` is outside
|
||||
/// the buffer area.
|
||||
fn set_string_safe<S: AsRef<str>>(&mut self, x: u16, y: u16, string: S, style: Style);
|
||||
}
|
||||
|
||||
|
||||
@@ -76,7 +76,7 @@ pub const SCROLLBAR_TOTAL_COLS: u16 = SCROLLBAR_GAP_COLS + SCROLLBAR_TRACK_COLS;
|
||||
/// Layout:
|
||||
/// - `content_area`: original area minus [`SCROLLBAR_TOTAL_COLS`] on the right
|
||||
/// - `scrollbar_area`: the last column of the original area (1 cell wide)
|
||||
/// - The column between them is the "gap" (left intentionally blank)
|
||||
/// - The column between them is the "gap" (left deliberately blank)
|
||||
///
|
||||
/// Returns `(content_area, None)` when the terminal is too narrow.
|
||||
///
|
||||
@@ -126,7 +126,8 @@ pub fn needs_scrollbar(total_lines: u16, viewport_lines: u16) -> bool {
|
||||
}
|
||||
|
||||
/// Whether the view is at the bottom (following mode position).
|
||||
#[allow(dead_code)] // Useful helper, kept for future use
|
||||
// Useful helper, kept for future use
|
||||
#[allow(dead_code)]
|
||||
pub fn is_at_bottom(total_lines: u16, viewport_lines: u16, offset: u16) -> bool {
|
||||
let max_offset = total_lines.saturating_sub(viewport_lines);
|
||||
offset >= max_offset
|
||||
@@ -384,7 +385,8 @@ mod tests {
|
||||
|
||||
// Content overflows (20 > 10) - should reserve scrollbar space
|
||||
let (content, scrollbar) = maybe_split_for_scrollbar(area, 20);
|
||||
assert_eq!(content.width, 38); // Reduced by 2 for gap + scrollbar track
|
||||
// Reduced by 2 for gap + scrollbar track
|
||||
assert_eq!(content.width, 38);
|
||||
assert!(scrollbar.is_some());
|
||||
}
|
||||
|
||||
@@ -394,24 +396,29 @@ mod tests {
|
||||
|
||||
// Content fits (5 <= 10) - should give full width to content
|
||||
let (content, scrollbar) = maybe_split_for_scrollbar(area, 5);
|
||||
assert_eq!(content.width, 40); // Full width
|
||||
assert_eq!(content.width, 40);
|
||||
assert!(scrollbar.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_needs_scrollbar() {
|
||||
assert!(needs_scrollbar(100, 10)); // Content > viewport
|
||||
assert!(!needs_scrollbar(10, 10)); // Content == viewport
|
||||
assert!(!needs_scrollbar(5, 10)); // Content < viewport
|
||||
// Content > viewport
|
||||
assert!(needs_scrollbar(100, 10));
|
||||
// Content == viewport
|
||||
assert!(!needs_scrollbar(10, 10));
|
||||
// Content < viewport
|
||||
assert!(!needs_scrollbar(5, 10));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_at_bottom() {
|
||||
// total=100, viewport=10 -> max_offset=90
|
||||
assert!(is_at_bottom(100, 10, 90)); // At bottom
|
||||
assert!(is_at_bottom(100, 10, 95)); // Past bottom (clamped)
|
||||
assert!(!is_at_bottom(100, 10, 89)); // One line above bottom
|
||||
assert!(!is_at_bottom(100, 10, 0)); // At top
|
||||
assert!(is_at_bottom(100, 10, 90));
|
||||
// Past bottom (clamped)
|
||||
assert!(is_at_bottom(100, 10, 95));
|
||||
// One line above bottom
|
||||
assert!(!is_at_bottom(100, 10, 89));
|
||||
assert!(!is_at_bottom(100, 10, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -258,7 +258,6 @@ fn first_param(params: &Params, default: u16) -> u16 {
|
||||
}
|
||||
}
|
||||
|
||||
/// Map a 0-7 ANSI color index to a named ratatui color.
|
||||
fn ansi16(n: u16) -> Color {
|
||||
match n {
|
||||
0 => Color::Black,
|
||||
@@ -272,7 +271,6 @@ fn ansi16(n: u16) -> Color {
|
||||
}
|
||||
}
|
||||
|
||||
/// Map a 0-7 bright ANSI color index to a named ratatui color.
|
||||
fn ansi16_bright(n: u16) -> Color {
|
||||
match n {
|
||||
0 => Color::DarkGray,
|
||||
|
||||
@@ -7,7 +7,6 @@ use unicode_width::UnicodeWidthStr;
|
||||
|
||||
use super::line_utils::truncate_str;
|
||||
|
||||
/// Read/Edit tool-header path paint surface.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ToolPathSurface {
|
||||
/// Basename only.
|
||||
@@ -183,7 +182,6 @@ pub fn path_basename(path: &str, budget: usize) -> String {
|
||||
truncate_str(name, budget)
|
||||
}
|
||||
|
||||
/// Compatibility formatter: compact basename with `Some(width)`, else stored path.
|
||||
pub fn path_for_tool_header(path: &str, width: Option<usize>, reserved: usize) -> String {
|
||||
match width {
|
||||
Some(width) => path_basename(path, width.saturating_sub(reserved)),
|
||||
@@ -191,7 +189,6 @@ pub fn path_for_tool_header(path: &str, width: Option<usize>, reserved: usize) -
|
||||
}
|
||||
}
|
||||
|
||||
/// Path text for a Read/Edit tool-header surface.
|
||||
pub fn path_for_tool_surface(
|
||||
path: &str,
|
||||
surface: ToolPathSurface,
|
||||
|
||||
@@ -12,8 +12,6 @@ use ratatui::widgets::{Block, BorderType, Borders, Widget};
|
||||
use crate::prompt_images::VideoViewerState;
|
||||
use crate::render::safe_buf::SafeBuf;
|
||||
|
||||
/// Render the video viewer popup chrome. Returns the popup `Rect`,
|
||||
/// or `None` if the area is too small.
|
||||
pub fn render_video_overlay(
|
||||
buf: &mut Buffer,
|
||||
area: Rect,
|
||||
@@ -28,7 +26,6 @@ pub fn render_video_overlay(
|
||||
|
||||
crate::render::color::dim_area(buf, area, bg, 0.5);
|
||||
|
||||
// 90% centered popup.
|
||||
let popup_width = ((area.width as u32 * 90) / 100)
|
||||
.max(28)
|
||||
.min(area.width as u32) as u16;
|
||||
@@ -49,7 +46,6 @@ pub fn render_video_overlay(
|
||||
.style(Style::default().bg(bg))
|
||||
.render(popup_rect, buf);
|
||||
|
||||
// Title centered in top border.
|
||||
let title = match viewer.title {
|
||||
Some(ref name) => format!(
|
||||
" {} ({}\u{00d7}{}) ",
|
||||
@@ -68,13 +64,11 @@ pub fn render_video_overlay(
|
||||
let tx = popup_rect.x + (popup_rect.width.saturating_sub(tw)) / 2;
|
||||
buf.set_span_safe(tx, popup_rect.y, &Span::styled(&title, title_style), tw);
|
||||
|
||||
// Progress bar on the bottom border row.
|
||||
render_progress_bar(buf, popup_rect, viewer, text_fg, border_fg, bg);
|
||||
|
||||
Some(popup_rect)
|
||||
}
|
||||
|
||||
/// Render the progress bar on the popup's bottom border row.
|
||||
fn render_progress_bar(
|
||||
buf: &mut Buffer,
|
||||
popup_rect: Rect,
|
||||
|
||||
@@ -114,7 +114,8 @@ pub(crate) fn byte_offset_to_display_col(text: &str, byte_offset: usize) -> usiz
|
||||
/// forward. If a future change to the wrapping pipeline breaks this
|
||||
/// invariant, consider switching to a two-stage approach that mirrors
|
||||
/// `word_wrap_line_with_joiners` exactly.
|
||||
#[allow(clippy::single_range_in_vec_init)] // intentional: single range = full text, no wrapping
|
||||
// intentional: single range = full text, no wrapping
|
||||
#[allow(clippy::single_range_in_vec_init)]
|
||||
pub fn wrap_byte_ranges_matching(text: &str, width: usize) -> Vec<Range<usize>> {
|
||||
if width == 0 || text.is_empty() {
|
||||
return vec![0..text.len()];
|
||||
@@ -362,7 +363,7 @@ fn is_table_line(line: &Line<'_>) -> bool {
|
||||
/// this wrap layer re-injecting the prefix spans (with their styles) on
|
||||
/// continuation rows, so keep the two shapes in agreement.
|
||||
fn blockquote_prefix_len(flat: &str) -> usize {
|
||||
const BAR_BYTES: usize = '\u{2502}'.len_utf8(); // 3
|
||||
const BAR_BYTES: usize = '\u{2502}'.len_utf8();
|
||||
let mut len = 0;
|
||||
let mut chars = flat.chars();
|
||||
while let Some('\u{2502}') = chars.next() {
|
||||
@@ -1187,14 +1188,15 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// -- byte_range_to_row_cols tests -----------------------------------------
|
||||
// byte_range_to_row_cols tests
|
||||
|
||||
#[test]
|
||||
fn highlight_single_row_match() {
|
||||
// "hello world" on one row (no wrapping).
|
||||
let text = "hello world";
|
||||
let ranges = vec![0..11]; // one row, full text
|
||||
let segments = byte_range_to_row_cols(text, &ranges, 6..11); // "world"
|
||||
// one row, full text
|
||||
let ranges = vec![0..11];
|
||||
let segments = byte_range_to_row_cols(text, &ranges, 6..11);
|
||||
assert_eq!(
|
||||
segments,
|
||||
vec![HighlightSegment {
|
||||
@@ -1306,8 +1308,9 @@ mod tests {
|
||||
// Byte layout: a(1) b(1) —(3) c(1) d(1) = 7 bytes total.
|
||||
// Display: a(0) b(1) —(2) c(3) d(4) = 5 display columns.
|
||||
let text = "ab\u{2014}cd";
|
||||
assert_eq!(text.len(), 7); // 2 + 3 + 2 bytes
|
||||
let ranges = vec![0..7]; // one row
|
||||
// 2 + 3 + 2 bytes
|
||||
assert_eq!(text.len(), 7);
|
||||
let ranges = vec![0..7];
|
||||
// Match "cd" = bytes 5..7, display cols 3..5.
|
||||
let segments = byte_range_to_row_cols(text, &ranges, 5..7);
|
||||
assert_eq!(
|
||||
@@ -1340,7 +1343,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// -- Table line detection / no-wrap tests ----------------------------------
|
||||
// Table line detection / no-wrap tests
|
||||
|
||||
#[test]
|
||||
fn table_line_box_drawing_not_wrapped() {
|
||||
@@ -1362,14 +1365,15 @@ mod tests {
|
||||
assert_eq!(joiners, vec![None]);
|
||||
}
|
||||
|
||||
/// Regression: a table row narrower than the content width must be padded
|
||||
/// Regression: a table row narrower than the content width must be `padded`
|
||||
/// so the app owns every column (otherwise a wide-glyph width disagreement
|
||||
/// strands a ghost cell).
|
||||
#[test]
|
||||
fn table_row_padded_to_content_width() {
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
let line = Line::from("│ Status │ Note │"); // 23 display columns
|
||||
// 23 display columns
|
||||
let line = Line::from("│ Status │ Note │");
|
||||
assert_eq!(concat_line(&line).width(), 23);
|
||||
|
||||
let content_width = 40;
|
||||
@@ -1390,7 +1394,7 @@ mod tests {
|
||||
|
||||
/// Faithful repro: a body row with an emoji-presentation sequence
|
||||
/// (`⚠\u{FE0F}`) and an em-dash — the glyphs that desynced the cursor — must
|
||||
/// be padded to exactly the content width.
|
||||
/// be `padded` to exactly the content width.
|
||||
#[test]
|
||||
fn table_row_with_emoji_and_em_dash_fills_content_width() {
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
//! Syntax highlighting initialization.
|
||||
//!
|
||||
//! Provides lazily-initialized `Syntect` instances for code highlighting.
|
||||
//! Dark themes (KigiNight, TokyoNight) share `kigi-night.tmTheme`;
|
||||
//! KigiDay uses `kigi-day.tmTheme` with deepened colors for light backgrounds.
|
||||
//! One lazily-initialized `Syntect` per tmTheme, shared by every `ThemeKind`
|
||||
//! that maps to it. KigiDay needs its own because `kigi-day.tmTheme` deepens
|
||||
//! the palette for light backgrounds.
|
||||
|
||||
use std::sync::OnceLock;
|
||||
|
||||
@@ -14,7 +14,6 @@ static SYNTECT_KIGINIGHT: OnceLock<Syntect> = OnceLock::new();
|
||||
static SYNTECT_TOKYONIGHT: OnceLock<Syntect> = OnceLock::new();
|
||||
static SYNTECT_KIGIDAY: OnceLock<Syntect> = OnceLock::new();
|
||||
|
||||
/// Convert syntect style to ratatui foreground-only style, quantized for terminal color support.
|
||||
pub fn syntect_to_ratatui_fg(style: syntect::highlighting::Style) -> ratatui::style::Style {
|
||||
let fg = crate::theme::quantize(ratatui::style::Color::Rgb(
|
||||
style.foreground.r,
|
||||
@@ -35,7 +34,6 @@ pub fn syntect_to_ratatui_fg(style: syntect::highlighting::Style) -> ratatui::st
|
||||
out
|
||||
}
|
||||
|
||||
/// Highlight a single line of source, falling back to plain text style.
|
||||
pub fn highlight_line(
|
||||
text: &str,
|
||||
highlighter: &mut Option<syntect::easy::HighlightLines<'_>>,
|
||||
@@ -63,7 +61,6 @@ pub fn highlight_line(
|
||||
vec![ratatui::text::Span::styled(text.to_string(), fallback)]
|
||||
}
|
||||
|
||||
/// Returns the syntect instance matching the active theme.
|
||||
pub fn get_syntect() -> &'static Syntect {
|
||||
match crate::theme::Theme::current_kind() {
|
||||
ThemeKind::KigiNight
|
||||
|
||||
@@ -31,7 +31,7 @@ pub enum EmbeddedEditor {
|
||||
/// [`EmbeddedEditor::Emacs`]; else `None`. Empty values are treated as absent
|
||||
/// (matching the sibling `detect_*_from_env` detectors via `env_get`).
|
||||
///
|
||||
/// Adding a new env marker here requires extending
|
||||
/// Including a new env marker here requires extending
|
||||
/// `HOST_TERMINAL_ENV_VARS` in `kigi-pager-pty-harness/src/pty.rs`
|
||||
/// (test-env hygiene).
|
||||
pub fn embedded_editor_from_env(env: &HashMap<String, String>) -> Option<EmbeddedEditor> {
|
||||
|
||||
@@ -56,7 +56,7 @@ pub struct HyperlinkCapabilities {
|
||||
pub id_param: bool,
|
||||
/// Which URL schemes the terminal handles.
|
||||
pub scheme_filter: SchemeFilter,
|
||||
/// Whether the terminal supports OSC 22 cursor-shape changes
|
||||
/// Whether the terminal supports OSC 22 cursor-shape shifts
|
||||
/// (e.g. switching to a hand/pointer cursor on link hover).
|
||||
pub osc22_cursor: bool,
|
||||
/// Whether the terminal handles link hover styling natively (so our
|
||||
@@ -197,7 +197,7 @@ pub fn hyperlink_capabilities(brand: TerminalName) -> HyperlinkCapabilities {
|
||||
}
|
||||
}
|
||||
|
||||
// ── OSC 22 cursor-shape commands ──────────────────────────────────────
|
||||
// OSC 22 cursor-shape commands
|
||||
//
|
||||
// These wrap raw OSC 22 sequences as crossterm `Command`s so call sites
|
||||
// can use `crossterm::execute!` / `queue!` instead of manual byte writes.
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! Provides escape-sequence helpers for rendering images inside the
|
||||
//! existing preview overlay. The text-fallback path in
|
||||
//! [`crate::render::image_overlay`] remains the primary preview; this
|
||||
//! module adds pixel-level rendering for supported terminals.
|
||||
//! module provides pixel-level rendering for supported terminals.
|
||||
//!
|
||||
//! # Supported protocols
|
||||
//!
|
||||
@@ -27,9 +27,7 @@ use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
use super::{TerminalName, terminal_context};
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Graphics protocol detection
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// Graphics protocol supported by the current terminal.
|
||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||
@@ -187,9 +185,7 @@ pub fn protocol_for_brand(brand: TerminalName, is_windows: bool) -> GraphicsProt
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Kitty graphics protocol
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// Shared placement ID; every renderer must coordinate through [`super::overlay`].
|
||||
pub(super) const KITTY_PLACEMENT_ID: u32 = 1;
|
||||
@@ -434,9 +430,7 @@ pub fn clear_kitty_image(image_id: u32) -> String {
|
||||
format!("\x1b_Ga=d,d=i,i={},q=2\x1b\\", image_id)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// iTerm2 inline images protocol
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// Build an iTerm2 inline image escape sequence.
|
||||
///
|
||||
@@ -452,9 +446,7 @@ pub fn render_iterm2_image(image_data: &[u8], cols: u16, rows: u16) -> String {
|
||||
)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Shared overlay helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// Build the full escape-sequence string to render image data at a cell
|
||||
/// position using the provided graphics protocol.
|
||||
@@ -506,7 +498,8 @@ pub(super) fn build_overlay_image_escapes_for_protocol(
|
||||
KITTY_PLACEMENT_ID,
|
||||
cols,
|
||||
rows,
|
||||
1, // above text (modal overlays)
|
||||
// above text (modal overlays)
|
||||
1,
|
||||
));
|
||||
}
|
||||
GraphicsProtocol::ITerm2 => {
|
||||
@@ -643,9 +636,7 @@ pub fn fit_image_to_cells(img_w: u32, img_h: u32, max_cols: u16, max_rows: u16)
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Tests
|
||||
// =========================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
//! instead of branching on brand. The classification depends on the
|
||||
//! current `HostOs`, queried internally — today only macOS rows are
|
||||
//! populated. Extend [`KeyboardCapabilities`] with new fields (paste
|
||||
//! protocol, focus reporting, custom escapes) instead of adding more
|
||||
//! protocol, focus reporting, custom escapes) instead of stacking more
|
||||
//! `match self.brand` sites scattered through the pager.
|
||||
|
||||
use super::TerminalName;
|
||||
@@ -84,7 +84,7 @@ impl KeyboardCapabilities {
|
||||
/// Classify keyboard capabilities for a given `(brand, os, display_server)`.
|
||||
///
|
||||
/// Today the table is populated only for macOS; other OSes return the
|
||||
/// default (all-`Unknown`). When a Linux/Windows probe lands, add a
|
||||
/// default (all-`Unknown`). When a Linux/Windows probe lands, include a
|
||||
/// per-OS arm here rather than forking the function.
|
||||
pub fn keyboard_capabilities(brand: TerminalName) -> KeyboardCapabilities {
|
||||
match HostOs::current() {
|
||||
|
||||
@@ -152,7 +152,8 @@ pub enum TerminalName {
|
||||
|
||||
impl TerminalName {
|
||||
pub fn is_vte_based(self) -> bool {
|
||||
matches!(self, Self::Vte | Self::Terminator) // WHY: single source of truth for the VTE family
|
||||
// WHY: single source of truth for the VTE family
|
||||
matches!(self, Self::Vte | Self::Terminator)
|
||||
}
|
||||
|
||||
/// VS Code integrated terminal and xterm.js-based IDE embeds (including forks).
|
||||
@@ -197,7 +198,8 @@ impl TerminalName {
|
||||
|
||||
impl TerminalContext {
|
||||
pub fn is_vte_based(&self) -> bool {
|
||||
self.brand.is_vte_based() || self.vte_version.is_some() // WHY: covers brand + legacy version marker
|
||||
// WHY: covers brand + legacy version marker
|
||||
self.brand.is_vte_based() || self.vte_version.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -210,7 +212,6 @@ pub enum MultiplexerKind {
|
||||
/// GNU screen (including Byobu-on-screen).
|
||||
#[strum(to_string = "GNU screen")]
|
||||
Screen,
|
||||
/// Zellij.
|
||||
Zellij,
|
||||
/// cmux (Ghostty-backed macOS terminal multiplexer).
|
||||
#[strum(to_string = "cmux")]
|
||||
@@ -456,7 +457,8 @@ impl TerminalContext {
|
||||
/// In every case `Alt+Enter` (delivered as `ESC`+`CR`) is the reliable
|
||||
/// newline chord and is what the UI advertises.
|
||||
pub fn shift_enter_unavailable(&self) -> bool {
|
||||
let is_vte = self.is_vte_based(); // WHY: central helper + version gating
|
||||
// WHY: central helper + version gating
|
||||
let is_vte = self.is_vte_based();
|
||||
if is_vte {
|
||||
return match self
|
||||
.vte_version
|
||||
@@ -671,7 +673,7 @@ fn env_get<'a>(env: &'a HashMap<String, String>, key: &str) -> Option<&'a str> {
|
||||
///
|
||||
/// This is the pure equivalent of the original `detect_terminal_info`.
|
||||
///
|
||||
/// Adding a new env marker to this brand chain (or to
|
||||
/// Including a new env marker to this brand chain (or to
|
||||
/// [`detect_byobu_from_env`] / [`detect_multiplexer_from_env`] below)
|
||||
/// requires extending `HOST_TERMINAL_ENV_VARS` in
|
||||
/// `kigi-pager-pty-harness/src/pty.rs` (test-env hygiene — the PTY
|
||||
@@ -884,7 +886,8 @@ pub fn detect_multiplexer_from_env(env: &HashMap<String, String>) -> Multiplexer
|
||||
match backend {
|
||||
ByobuBackend::Tmux => return MultiplexerKind::Tmux,
|
||||
ByobuBackend::Screen => return MultiplexerKind::Screen,
|
||||
ByobuBackend::Unknown => {} // fall through to standard markers
|
||||
// fall through to standard markers
|
||||
ByobuBackend::Unknown => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -959,7 +962,6 @@ pub fn build_terminal_context_from_env(env: &HashMap<String, String>) -> Termina
|
||||
}
|
||||
}
|
||||
|
||||
/// Map TERM_PROGRAM value to terminal name.
|
||||
fn terminal_name_from_term_program(value: &str) -> Option<TerminalName> {
|
||||
let normalized: String = value
|
||||
.trim()
|
||||
|
||||
@@ -11,6 +11,9 @@ use super::image::{
|
||||
static NEXT_OWNER_ID: AtomicU64 = AtomicU64::new(1);
|
||||
|
||||
thread_local! {
|
||||
/// Owner whose image the terminal is believed to still hold; a matching
|
||||
/// owner lets the next frame re-place that image without retransmitting
|
||||
/// its pixel data.
|
||||
static OWNER: std::cell::Cell<Option<u64>> = const { std::cell::Cell::new(None) };
|
||||
}
|
||||
|
||||
@@ -20,6 +23,10 @@ pub(crate) enum Ownership {
|
||||
Clear,
|
||||
}
|
||||
|
||||
/// Escape bytes plus the ownership transition they imply. The transition is
|
||||
/// applied only by [`Escapes::commit`] or [`PostFlush::write_to`], so escapes
|
||||
/// that are built and then dropped — or that fail to reach the terminal —
|
||||
/// leave `OWNER` describing what the terminal actually holds.
|
||||
#[derive(Debug)]
|
||||
pub struct Escapes {
|
||||
bytes: String,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::*;
|
||||
|
||||
// -- terminal_name_from_term_program (existing coverage) ------------------
|
||||
// terminal_name_from_term_program (existing coverage)
|
||||
|
||||
#[test]
|
||||
fn test_terminal_name_from_term_program() {
|
||||
@@ -89,7 +89,7 @@ fn otty_skips_kitty_keyboard_like_unknown() {
|
||||
assert!(ctx.shift_enter_unavailable());
|
||||
}
|
||||
|
||||
// -- detect_terminal_brand_from_env (pure) --------------------------------
|
||||
// detect_terminal_brand_from_env (pure)
|
||||
|
||||
#[test]
|
||||
fn brand_ghostty_from_term_program() {
|
||||
@@ -187,10 +187,11 @@ fn brand_vte_from_vte_version() {
|
||||
#[test]
|
||||
fn brand_terminator_from_term_program() {
|
||||
let env = env_from(&[("TERM_PROGRAM", "terminator")]);
|
||||
// WHY: canonical per detect-terminal
|
||||
assert_eq!(
|
||||
detect_terminal_brand_from_env(&env),
|
||||
TerminalName::Terminator
|
||||
); // WHY: canonical per detect-terminal
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -198,7 +199,8 @@ fn terminator_vte_version_interaction() {
|
||||
let env = env_from(&[("TERM_PROGRAM", "terminator"), ("VTE_VERSION", "8200")]);
|
||||
let ctx = build_terminal_context_from_env(&env);
|
||||
assert_eq!(ctx.brand, TerminalName::Terminator);
|
||||
assert!(ctx.is_vte_based()); // WHY: helper covers version + brand
|
||||
// WHY: helper covers version + brand
|
||||
assert!(ctx.is_vte_based());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -221,10 +223,11 @@ fn terminator_over_ssh() {
|
||||
fn terminator_focus_tracking() {
|
||||
let env = env_from(&[("TERM_PROGRAM", "terminator")]);
|
||||
let ctx = build_terminal_context_from_env(&env);
|
||||
// supports focus like VTE
|
||||
assert!(!matches!(
|
||||
ctx.brand,
|
||||
TerminalName::AppleTerminal | TerminalName::Unknown
|
||||
)); // supports focus like VTE
|
||||
));
|
||||
assert!(ctx.is_vte_based());
|
||||
}
|
||||
|
||||
@@ -244,16 +247,18 @@ fn brand_unknown_empty_env() {
|
||||
assert_eq!(detect_terminal_brand_from_env(&env), TerminalName::Unknown);
|
||||
}
|
||||
|
||||
// -- refine_unknown_brand_for_host ---------------------------------------
|
||||
// refine_unknown_brand_for_host
|
||||
|
||||
#[test]
|
||||
fn refine_unknown_brand_defaults_to_wt_only_on_windows() {
|
||||
use super::TerminalName::{Unknown, VsCode, WindowsTerminal};
|
||||
use crate::host::HostOs::{Linux, Windows};
|
||||
let cases = [
|
||||
(Unknown, Windows, WindowsTerminal), // DefTerm handoff: no WT_SESSION
|
||||
// DefTerm handoff: no WT_SESSION
|
||||
(Unknown, Windows, WindowsTerminal),
|
||||
(Unknown, Linux, Unknown),
|
||||
(VsCode, Windows, VsCode), // never override a positively detected brand
|
||||
// never override a positively detected brand
|
||||
(VsCode, Windows, VsCode),
|
||||
];
|
||||
for (brand, host, expected) in cases {
|
||||
assert_eq!(refine_unknown_brand_for_host(brand, host), expected);
|
||||
@@ -301,7 +306,7 @@ fn mouse_reporting_leaks_only_for_jetbrains_on_windows() {
|
||||
assert!(!mouse_reporting_leaks(TerminalName::Kitty, HostOs::Windows));
|
||||
}
|
||||
|
||||
// -- detect_byobu_from_env ------------------------------------------------
|
||||
// detect_byobu_from_env
|
||||
|
||||
#[test]
|
||||
fn byobu_tmux_explicit_backend() {
|
||||
@@ -342,7 +347,7 @@ fn no_byobu_markers_returns_none() {
|
||||
assert_eq!(detect_byobu_from_env(&env), None);
|
||||
}
|
||||
|
||||
// -- detect_multiplexer_from_env ------------------------------------------
|
||||
// detect_multiplexer_from_env
|
||||
|
||||
#[test]
|
||||
fn mux_plain_tmux() {
|
||||
@@ -416,7 +421,7 @@ fn mux_tmux_nested_inside_cmux_wins() {
|
||||
assert_eq!(detect_multiplexer_from_env(&env), MultiplexerKind::Tmux);
|
||||
}
|
||||
|
||||
// -- ambiguous marker precedence ------------------------------------------
|
||||
// ambiguous marker precedence
|
||||
|
||||
#[test]
|
||||
fn tmux_beats_zellij_when_both_set() {
|
||||
@@ -447,7 +452,7 @@ fn byobu_tmux_explicit_with_sty_stays_tmux() {
|
||||
assert_eq!(detect_multiplexer_from_env(&env), MultiplexerKind::Tmux);
|
||||
}
|
||||
|
||||
// -- detect_tmux_meta_from_env --------------------------------------------
|
||||
// detect_tmux_meta_from_env
|
||||
|
||||
#[test]
|
||||
fn tmux_meta_populated() {
|
||||
@@ -470,7 +475,7 @@ fn tmux_meta_empty_outside_tmux() {
|
||||
assert_eq!(meta, TmuxClientMeta::default());
|
||||
}
|
||||
|
||||
// -- build_terminal_context_from_env (integration) ------------------------
|
||||
// build_terminal_context_from_env (integration)
|
||||
|
||||
#[test]
|
||||
fn context_plain_terminal() {
|
||||
@@ -598,9 +603,7 @@ fn context_empty_env_values_ignored() {
|
||||
assert_eq!(ctx.multiplexer, MultiplexerKind::Undetected);
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// determine_alt_screen_policy: fullscreen policy matrix
|
||||
// =====================================================================
|
||||
|
||||
fn plain_ctx() -> TerminalContext {
|
||||
TerminalContext {
|
||||
@@ -660,7 +663,7 @@ fn byobu_screen_ctx() -> TerminalContext {
|
||||
}
|
||||
}
|
||||
|
||||
// -- Alt-screen policy matrix (all modes × contexts × CLI override) -------
|
||||
// Alt-screen policy matrix (all modes × contexts × CLI override)
|
||||
|
||||
#[derive(Debug)]
|
||||
struct AltScreenCase {
|
||||
@@ -900,7 +903,7 @@ fn alt_screen_policy_matrix() {
|
||||
}
|
||||
}
|
||||
|
||||
// -- Windows Terminal context integration ---------------------------------
|
||||
// Windows Terminal context integration
|
||||
|
||||
#[test]
|
||||
fn context_windows_terminal() {
|
||||
@@ -911,13 +914,11 @@ fn context_windows_terminal() {
|
||||
assert!(!ctx.is_ssh);
|
||||
}
|
||||
|
||||
// -- Terminal brand detection edge cases -----------------------------------
|
||||
// Terminal brand detection edge cases
|
||||
|
||||
// =====================================================================
|
||||
// Extended environment matrix (final hardening)
|
||||
// =====================================================================
|
||||
|
||||
// -- Byobu-screen: auto keeps fullscreen (screen is not auto-disabled) ----
|
||||
// Byobu-screen: auto keeps fullscreen (screen is not auto-disabled)
|
||||
|
||||
#[test]
|
||||
fn auto_byobu_screen_is_fullscreen() {
|
||||
@@ -935,7 +936,7 @@ fn auto_byobu_screen_is_fullscreen() {
|
||||
));
|
||||
}
|
||||
|
||||
// -- Terminal brand detection edge cases -----------------------------------
|
||||
// Terminal brand detection edge cases
|
||||
|
||||
#[test]
|
||||
fn brand_vscode_from_term_program() {
|
||||
@@ -978,7 +979,7 @@ fn brand_term_program_takes_precedence_over_other_vars() {
|
||||
assert_eq!(detect_terminal_brand_from_env(&env), TerminalName::Ghostty);
|
||||
}
|
||||
|
||||
// -- IDE family detection (VS Code forks / xterm.js embeds) ---------------
|
||||
// IDE family detection (VS Code forks / xterm.js embeds)
|
||||
|
||||
#[test]
|
||||
fn brand_cursor_from_cursor_trace_id() {
|
||||
@@ -1054,7 +1055,7 @@ fn brand_vscode_from_askpass_without_term_program() {
|
||||
assert_eq!(detect_terminal_brand_from_env(&env), TerminalName::VsCode);
|
||||
}
|
||||
|
||||
// -- Zellij detection from ZELLIJ_VERSION (no ZELLIJ or SESSION_NAME) -----
|
||||
// Zellij detection from ZELLIJ_VERSION (no ZELLIJ or SESSION_NAME)
|
||||
|
||||
#[test]
|
||||
fn mux_zellij_not_from_version_only() {
|
||||
@@ -1067,7 +1068,7 @@ fn mux_zellij_not_from_version_only() {
|
||||
);
|
||||
}
|
||||
|
||||
// -- Byobu inference edge cases -------------------------------------------
|
||||
// Byobu inference edge cases
|
||||
|
||||
#[test]
|
||||
fn byobu_unknown_backend_string_with_tmux() {
|
||||
@@ -1088,7 +1089,7 @@ fn byobu_unknown_backend_no_mux_returns_none() {
|
||||
assert_eq!(detect_byobu_from_env(&env), None);
|
||||
}
|
||||
|
||||
// -- Context-level edge cases ---------------------------------------------
|
||||
// Context-level edge cases
|
||||
|
||||
#[test]
|
||||
fn context_sty_takes_screen_when_no_tmux_or_zellij() {
|
||||
@@ -1137,9 +1138,7 @@ fn context_is_byobu_returns_false_without_byobu_markers() {
|
||||
assert!(!build_terminal_context_from_env(&env).is_byobu());
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// parse_tmux_major_minor: version string parsing
|
||||
// =====================================================================
|
||||
|
||||
#[test]
|
||||
fn parse_tmux_version_standard() {
|
||||
@@ -1186,9 +1185,7 @@ fn parse_tmux_version_no_minor() {
|
||||
assert_eq!(parse_tmux_major_minor("tmux 3"), None);
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// parse_semver_major_minor: TERM_PROGRAM_VERSION parsing
|
||||
// =====================================================================
|
||||
|
||||
#[test]
|
||||
fn parse_semver_standard() {
|
||||
@@ -1220,7 +1217,6 @@ fn parse_semver_major_only() {
|
||||
assert_eq!(parse_semver_major_minor("3"), None);
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// graphics_protocol_skip_reason
|
||||
|
||||
#[test]
|
||||
@@ -1243,7 +1239,6 @@ fn graphics_protocol_skip_reason_plain_kitty() {
|
||||
}
|
||||
|
||||
// kitty_skip_reason: Kitty keyboard protocol skip-reason matrix
|
||||
// =====================================================================
|
||||
|
||||
#[test]
|
||||
fn kitty_skip_vscode() {
|
||||
@@ -1466,9 +1461,7 @@ fn kitty_skip_vte_brand() {
|
||||
assert_eq!(ctx.kitty_skip_reason(), Some("vte"));
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// shift_enter_unavailable: VTE version gating for Shift+Enter
|
||||
// =====================================================================
|
||||
//
|
||||
// VTE 0.82.0 (= VTE_VERSION 8200) is the first release containing the
|
||||
// Kitty keyboard protocol; earlier versions cannot distinguish
|
||||
@@ -1550,7 +1543,8 @@ fn shift_enter_available_kkp_terminals() {
|
||||
] {
|
||||
let ctx = TerminalContext {
|
||||
brand,
|
||||
env_brand: brand, // lockstep with brand (no Windows refinement)
|
||||
// lockstep with brand (no Windows refinement)
|
||||
env_brand: brand,
|
||||
..Default::default()
|
||||
};
|
||||
assert!(
|
||||
@@ -1738,7 +1732,7 @@ fn ctrl_dot_unreliable_on_unknown_no_multiplexer() {
|
||||
assert!(ctx.ctrl_dot_unreliable());
|
||||
}
|
||||
|
||||
// -- tmux extended-keys interaction with kitty_skip_reason ---------------
|
||||
// tmux extended-keys interaction with kitty_skip_reason
|
||||
|
||||
fn extended_keys_ctx(version: &str, extended_keys: Option<&str>) -> TerminalContext {
|
||||
TerminalContext {
|
||||
@@ -1803,9 +1797,7 @@ fn kitty_skip_vte_takes_precedence_over_tmux_old() {
|
||||
assert_eq!(ctx.kitty_skip_reason(), Some("vte"));
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// JetBrains JediTerm detection
|
||||
// =====================================================================
|
||||
|
||||
#[test]
|
||||
fn brand_jetbrains_from_terminal_emulator() {
|
||||
|
||||
@@ -21,7 +21,6 @@
|
||||
use std::sync::OnceLock;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
/// Startup probe outcome.
|
||||
#[derive(Debug)]
|
||||
enum ProbeResult {
|
||||
Skipped,
|
||||
@@ -32,7 +31,6 @@ enum ProbeResult {
|
||||
/// Unset while the query is in flight (or never sent).
|
||||
static XTVERSION: OnceLock<ProbeResult> = OnceLock::new();
|
||||
|
||||
/// True once the query bytes were written to the terminal.
|
||||
static QUERY_SENT: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// XTVERSION query alone — no DA1 sentinel: nothing waits on reply
|
||||
@@ -122,7 +120,6 @@ fn send_query() {
|
||||
let _ = XTVERSION.set(ProbeResult::Skipped);
|
||||
}
|
||||
|
||||
/// Strip controls and trim; `None` for an empty payload.
|
||||
fn sanitize_payload(payload: &str) -> Option<String> {
|
||||
let cleaned: String = payload.chars().filter(|c| !c.is_control()).collect();
|
||||
let cleaned = cleaned.trim().to_owned();
|
||||
@@ -183,12 +180,10 @@ mod tests {
|
||||
gate_allows_probe(&ctx(brand, MultiplexerKind::Undetected)),
|
||||
"{brand:?} should be probed"
|
||||
);
|
||||
// Transparent mux (cmux) does not intercept CSI; probe still runs.
|
||||
assert!(
|
||||
gate_allows_probe(&ctx(brand, MultiplexerKind::Cmux)),
|
||||
"{brand:?} under cmux should still be probed"
|
||||
);
|
||||
// CSI-intercepting multiplexers override the brand allowlist.
|
||||
assert!(
|
||||
!gate_allows_probe(&ctx(brand, MultiplexerKind::Tmux)),
|
||||
"{brand:?} under tmux should be skipped"
|
||||
|
||||
@@ -102,7 +102,7 @@ pub fn set(kind: ThemeKind) {
|
||||
LOADED.store(true, Ordering::Release);
|
||||
}
|
||||
|
||||
// -- Terminal-native lock (minimal mode) --------------------------------------
|
||||
// Terminal-native lock (minimal mode)
|
||||
|
||||
/// Whether the theme is locked to the terminal-native palette.
|
||||
#[must_use]
|
||||
@@ -120,7 +120,7 @@ pub fn set_terminal_native_lock(locked: bool) {
|
||||
});
|
||||
}
|
||||
|
||||
// -- Auto-mode ---------------------------------------------------------------
|
||||
// Auto-mode
|
||||
|
||||
/// Whether auto-switching mode is active.
|
||||
#[must_use]
|
||||
@@ -152,7 +152,7 @@ pub fn invalidate_auto_theme_config() {
|
||||
*AUTO_THEME_CONFIG.lock().unwrap_or_else(|e| e.into_inner()) = None;
|
||||
}
|
||||
|
||||
// -- Theme resolution --------------------------------------------------------
|
||||
// Theme resolution
|
||||
|
||||
/// Resolve the effective theme, respecting the full precedence chain.
|
||||
///
|
||||
@@ -218,7 +218,7 @@ pub fn resolve_initial_theme_no_osc11() -> ThemeKind {
|
||||
resolve_from_config(load_from_disk(), false)
|
||||
}
|
||||
|
||||
// -- Disk reads --------------------------------------------------------------
|
||||
// Disk reads
|
||||
//
|
||||
// All writes go through `kigi_shell::util::config::set_theme()` (and
|
||||
// friends) via `Effect::PersistSetting`. This module only READS from the
|
||||
@@ -268,7 +268,7 @@ fn load_auto_theme_config() -> AutoThemeConfig {
|
||||
}
|
||||
}
|
||||
|
||||
// -- Test support ------------------------------------------------------------
|
||||
// Test support
|
||||
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
pub fn reset_for_test() {
|
||||
@@ -334,7 +334,7 @@ mod tests {
|
||||
*AUTO_THEME_CONFIG.lock().unwrap_or_else(|e| e.into_inner()) = Some(config);
|
||||
}
|
||||
|
||||
// -- Terminal-native lock (minimal mode) ----------------------------------
|
||||
// Terminal-native lock (minimal mode)
|
||||
|
||||
#[test]
|
||||
fn terminal_native_lock_pins_kind_and_blocks_apply_kind() {
|
||||
@@ -393,7 +393,8 @@ mod tests {
|
||||
set_terminal_native_lock(true);
|
||||
assert!(color_support::detect() <= color_support::ColorLevel::Basic);
|
||||
for input in [
|
||||
Color::Rgb(0x26, 0x26, 0x26), // kigiday text_primary
|
||||
// kigiday text_primary
|
||||
Color::Rgb(0x26, 0x26, 0x26),
|
||||
Color::Rgb(122, 162, 247),
|
||||
Color::Indexed(141),
|
||||
] {
|
||||
@@ -427,7 +428,7 @@ mod tests {
|
||||
});
|
||||
}
|
||||
|
||||
// -- AUTO_MODE -----------------------------------------------------------
|
||||
// AUTO_MODE
|
||||
|
||||
#[test]
|
||||
fn auto_mode_default_is_false() {
|
||||
@@ -446,7 +447,7 @@ mod tests {
|
||||
});
|
||||
}
|
||||
|
||||
// -- AutoThemeConfig -----------------------------------------------------
|
||||
// AutoThemeConfig
|
||||
|
||||
#[test]
|
||||
fn auto_theme_config_defaults_to_none() {
|
||||
@@ -455,7 +456,7 @@ mod tests {
|
||||
assert!(config.light_theme.is_none());
|
||||
}
|
||||
|
||||
// -- resolve_auto --------------------------------------------------------
|
||||
// resolve_auto
|
||||
|
||||
#[test]
|
||||
fn resolve_auto_dark_system_returns_kiginight() {
|
||||
@@ -484,7 +485,7 @@ mod tests {
|
||||
});
|
||||
}
|
||||
|
||||
// -- invalidate_auto_theme_config ----------------------------------------
|
||||
// invalidate_auto_theme_config
|
||||
|
||||
#[test]
|
||||
fn invalidate_clears_cached_config() {
|
||||
@@ -506,7 +507,7 @@ mod tests {
|
||||
});
|
||||
}
|
||||
|
||||
// -- resolve_from_config (resolve_initial_theme inner logic) ---------------
|
||||
// resolve_from_config (resolve_initial_theme inner logic)
|
||||
|
||||
#[test]
|
||||
fn resolve_from_config_no_config_returns_kiginight() {
|
||||
@@ -559,7 +560,7 @@ mod tests {
|
||||
});
|
||||
}
|
||||
|
||||
// -- resolve_auto with custom config -------------------------------------
|
||||
// resolve_auto with custom config
|
||||
|
||||
#[test]
|
||||
fn resolve_auto_with_custom_dark_config() {
|
||||
@@ -585,7 +586,7 @@ mod tests {
|
||||
});
|
||||
}
|
||||
|
||||
// -- auto_theme_config filter --------------------------------------------
|
||||
// auto_theme_config filter
|
||||
|
||||
#[test]
|
||||
fn auto_theme_config_filter_rejects_auto_value() {
|
||||
@@ -602,7 +603,7 @@ mod tests {
|
||||
assert_eq!(parsed, Some(ThemeKind::TokyoNight));
|
||||
}
|
||||
|
||||
// -- set / current_kind --------------------------------------------------
|
||||
// set / current_kind
|
||||
|
||||
/// `set` followed by `current_kind` returns the set value, and the
|
||||
/// `LOADED` flag flips so subsequent reads don't re-seed from disk.
|
||||
|
||||
@@ -60,7 +60,7 @@ impl std::fmt::Display for ColorLevel {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Global singleton ─────────────────────────────────────────────────────
|
||||
// Global singleton
|
||||
|
||||
static COLOR_LEVEL: OnceLock<ColorLevel> = OnceLock::new();
|
||||
|
||||
@@ -133,7 +133,7 @@ pub fn set(level: ColorLevel) -> Result<(), ColorLevel> {
|
||||
COLOR_LEVEL.set(level)
|
||||
}
|
||||
|
||||
// ── Color quantization ──────────────────────────────────────────────────
|
||||
// Color quantization
|
||||
|
||||
/// Downgrade a [`Color`] to the highest representation the terminal supports.
|
||||
///
|
||||
@@ -164,7 +164,7 @@ pub fn quantize(color: Color) -> Color {
|
||||
quantize_color(color, get())
|
||||
}
|
||||
|
||||
// ── Terminal-based truecolor inference ──────────────────────────────────
|
||||
// Terminal-based truecolor inference
|
||||
|
||||
/// Check whether the detected terminal emulator is known to support truecolor.
|
||||
///
|
||||
@@ -196,7 +196,7 @@ fn terminal_supports_truecolor() -> bool {
|
||||
cfg!(target_os = "windows")
|
||||
}
|
||||
|
||||
// ── 256 → 16 mapping ────────────────────────────────────────────────────
|
||||
// 256 → 16 mapping
|
||||
|
||||
/// Map a 256-color index to the nearest basic ANSI 16 color.
|
||||
fn indexed_to_ansi16(n: u8) -> Color {
|
||||
@@ -209,7 +209,8 @@ fn indexed_to_ansi16(n: u8) -> Color {
|
||||
4 => Color::Blue,
|
||||
5 => Color::Magenta,
|
||||
6 => Color::Cyan,
|
||||
7 => Color::White, // actually "silver" in most terminals
|
||||
// actually "silver" in most terminals
|
||||
7 => Color::White,
|
||||
8 => Color::DarkGray,
|
||||
9 => Color::LightRed,
|
||||
10 => Color::LightGreen,
|
||||
@@ -248,7 +249,8 @@ fn rgb_to_ansi16(r: u8, g: u8, b: u8) -> Color {
|
||||
(0, 0, 255, Color::LightBlue),
|
||||
(255, 0, 255, Color::LightMagenta),
|
||||
(0, 255, 255, Color::LightCyan),
|
||||
(255, 255, 255, Color::White), // index 15 = bright white
|
||||
// index 15 = bright white
|
||||
(255, 255, 255, Color::White),
|
||||
];
|
||||
|
||||
let mut best = Color::White;
|
||||
|
||||
@@ -16,38 +16,48 @@ const fn rgb(r: u8, g: u8, b: u8) -> Color {
|
||||
mod palette {
|
||||
use super::*;
|
||||
|
||||
// ── Backgrounds (neutral light grays) ────────────────────────────────
|
||||
pub const BG: Color = rgb(245, 245, 245); // #f5f5f5 — brightest (terminal bg)
|
||||
pub const BG_DARK: Color = rgb(240, 240, 240); // #f0f0f0
|
||||
pub const BG_STORM_DARK: Color = rgb(234, 234, 234); // #eaeaea
|
||||
pub const BG_STORM: Color = rgb(238, 238, 238); // #eeeeee — main bg
|
||||
pub const BG_HIGHLIGHT: Color = rgb(222, 222, 222); // #dedede — highlight bg
|
||||
// Backgrounds (neutral light grays)
|
||||
// #f5f5f5 — brightest (terminal bg)
|
||||
pub const BG: Color = rgb(245, 245, 245);
|
||||
pub const BG_DARK: Color = rgb(240, 240, 240);
|
||||
pub const BG_STORM_DARK: Color = rgb(234, 234, 234);
|
||||
// #eeeeee — main bg
|
||||
pub const BG_STORM: Color = rgb(238, 238, 238);
|
||||
// #dedede — highlight bg
|
||||
pub const BG_HIGHLIGHT: Color = rgb(222, 222, 222);
|
||||
|
||||
// ── Text / grays (neutral dark) ──────────────────────────────────────
|
||||
pub const FG: Color = rgb(38, 38, 38); // #262626 — primary text
|
||||
pub const FG_DARK: Color = rgb(68, 68, 68); // #444444 — secondary text
|
||||
pub const FG_GUTTER: Color = rgb(178, 178, 178); // #b2b2b2 — dim
|
||||
pub const COMMENT: Color = rgb(118, 118, 118); // #767676 — muted
|
||||
pub const DARK3: Color = rgb(142, 142, 142); // #8e8e8e — medium gray
|
||||
pub const DARK5: Color = rgb(98, 98, 98); // #626262 — bright gray
|
||||
// Text / grays (neutral dark)
|
||||
// #262626 — primary text
|
||||
pub const FG: Color = rgb(38, 38, 38);
|
||||
// #444444 — secondary text
|
||||
pub const FG_DARK: Color = rgb(68, 68, 68);
|
||||
pub const FG_GUTTER: Color = rgb(178, 178, 178);
|
||||
// #767676 — muted
|
||||
pub const COMMENT: Color = rgb(118, 118, 118);
|
||||
// #8e8e8e — medium gray
|
||||
pub const DARK3: Color = rgb(142, 142, 142);
|
||||
// #626262 — bright gray
|
||||
pub const DARK5: Color = rgb(98, 98, 98);
|
||||
|
||||
// ── Accent colors (deepened for light-background contrast) ───────────
|
||||
pub const BLUE: Color = rgb(47, 100, 210); // #2F64D2
|
||||
pub const BLUE0: Color = rgb(40, 68, 138); // #28448A
|
||||
pub const BLUE1: Color = rgb(15, 135, 162); // #0F87A2
|
||||
pub const CYAN: Color = rgb(0, 130, 170); // #0082AA
|
||||
pub const GREEN: Color = rgb(55, 142, 35); // #378E23
|
||||
pub const GREEN1: Color = rgb(12, 148, 124); // #0C947C
|
||||
pub const MAGENTA: Color = rgb(125, 75, 198); // #7D4BC6
|
||||
pub const ORANGE: Color = rgb(195, 105, 30); // #C3691E
|
||||
pub const PURPLE: Color = rgb(108, 62, 178); // #6C3EB2
|
||||
pub const RED: Color = rgb(205, 48, 72); // #CD3048
|
||||
pub const RED1: Color = rgb(175, 35, 35); // #AF2323
|
||||
pub const TEAL: Color = rgb(10, 142, 112); // #0A8E70
|
||||
pub const YELLOW: Color = rgb(162, 118, 18); // #A27612
|
||||
// Accent colors (deepened for light-background contrast)
|
||||
pub const BLUE: Color = rgb(47, 100, 210);
|
||||
pub const BLUE0: Color = rgb(40, 68, 138);
|
||||
pub const BLUE1: Color = rgb(15, 135, 162);
|
||||
pub const CYAN: Color = rgb(0, 130, 170);
|
||||
pub const GREEN: Color = rgb(55, 142, 35);
|
||||
pub const GREEN1: Color = rgb(12, 148, 124);
|
||||
pub const MAGENTA: Color = rgb(125, 75, 198);
|
||||
pub const ORANGE: Color = rgb(195, 105, 30);
|
||||
pub const PURPLE: Color = rgb(108, 62, 178);
|
||||
pub const RED: Color = rgb(205, 48, 72);
|
||||
pub const RED1: Color = rgb(175, 35, 35);
|
||||
pub const TEAL: Color = rgb(10, 142, 112);
|
||||
pub const YELLOW: Color = rgb(162, 118, 18);
|
||||
|
||||
pub const RED_LIGHT: Color = rgb(245, 218, 222); // #F5DADE — diff delete bg
|
||||
pub const GREEN_LIGHT: Color = rgb(218, 242, 220); // #DAF2DC — diff insert bg
|
||||
// #F5DADE — diff delete bg
|
||||
pub const RED_LIGHT: Color = rgb(245, 218, 222);
|
||||
// #DAF2DC — diff insert bg
|
||||
pub const GREEN_LIGHT: Color = rgb(218, 242, 220);
|
||||
}
|
||||
use palette::*;
|
||||
|
||||
@@ -74,7 +84,8 @@ impl Theme {
|
||||
text_primary: FG,
|
||||
text_secondary: FG_DARK,
|
||||
|
||||
gray_dim: rgb(165, 165, 165), // #a5a5a5 — slightly darker than FG_GUTTER
|
||||
// #a5a5a5 — slightly darker than FG_GUTTER
|
||||
gray_dim: rgb(165, 165, 165),
|
||||
gray: COMMENT,
|
||||
gray_bright: DARK5,
|
||||
|
||||
@@ -85,17 +96,22 @@ impl Theme {
|
||||
|
||||
fuzzy_accent: BLUE,
|
||||
|
||||
accent_plan: rgb(168, 120, 10), // #A8780A — deep golden
|
||||
// #A8780A — deep golden
|
||||
accent_plan: rgb(168, 120, 10),
|
||||
|
||||
accent_verify: rgb(120, 80, 160), // deep violet (readable on light bg)
|
||||
// deep violet (readable on light bg)
|
||||
accent_verify: rgb(120, 80, 160),
|
||||
|
||||
accent_feedback: GREEN1,
|
||||
|
||||
accent_remember: rgb(76, 175, 80), // #4CAF50 — Material Design green (readable on light bg)
|
||||
// #4CAF50 — Material Design green (readable on light bg)
|
||||
accent_remember: rgb(76, 175, 80),
|
||||
|
||||
selection_border: rgb(185, 185, 190),
|
||||
prompt_border: rgb(200, 200, 205), // #C8C8CD — dimmer prompt chrome
|
||||
prompt_border_active: rgb(165, 165, 175), // #A5A5AF — darker (more apparent) when focused
|
||||
// #C8C8CD — dimmer prompt chrome
|
||||
prompt_border: rgb(200, 200, 205),
|
||||
// #A5A5AF — darker (more apparent) when focused
|
||||
prompt_border_active: rgb(165, 165, 175),
|
||||
hover_border: rgb(212, 212, 216),
|
||||
|
||||
accent_model: TEAL,
|
||||
@@ -134,7 +150,8 @@ impl Theme {
|
||||
md_muted: COMMENT,
|
||||
md_code_bg: rgb(228, 228, 228),
|
||||
md_text: FG_DARK,
|
||||
link_fg: BLUE, // #2F64D2 -- deep blue for light bg
|
||||
// #2F64D2 -- deep blue for light bg
|
||||
link_fg: BLUE,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,38 +24,50 @@ const fn rgb(r: u8, g: u8, b: u8) -> Color {
|
||||
mod palette {
|
||||
use super::*;
|
||||
|
||||
// ── Backgrounds ─────────────────────────────────────────────────────
|
||||
pub const BG: Color = rgb(10, 10, 10); // #0a0a0a — Night (terminal bg)
|
||||
pub const BG_DARK: Color = rgb(12, 12, 12); // #0c0c0c — darkest
|
||||
pub const BG_STORM_DARK: Color = rgb(17, 17, 17); // #111111 — dark bg
|
||||
pub const BG_STORM: Color = rgb(20, 20, 20); // #141414 — main bg
|
||||
pub const BG_HIGHLIGHT: Color = rgb(36, 36, 36); // #242424 — highlight bg
|
||||
// Backgrounds
|
||||
// #0a0a0a — Night (terminal bg)
|
||||
pub const BG: Color = rgb(10, 10, 10);
|
||||
// #0c0c0c — darkest
|
||||
pub const BG_DARK: Color = rgb(12, 12, 12);
|
||||
// #111111 — dark bg
|
||||
pub const BG_STORM_DARK: Color = rgb(17, 17, 17);
|
||||
// #141414 — main bg
|
||||
pub const BG_STORM: Color = rgb(20, 20, 20);
|
||||
// #242424 — highlight bg
|
||||
pub const BG_HIGHLIGHT: Color = rgb(36, 36, 36);
|
||||
|
||||
// ── Text / grays ────────────────────────────────────────────────────
|
||||
pub const FG: Color = rgb(225, 225, 225); // #e1e1e1 — primary text
|
||||
pub const FG_DARK: Color = rgb(200, 200, 200); // #c8c8c8 — secondary text
|
||||
pub const FG_GUTTER: Color = rgb(65, 65, 65); // #414141 — dim
|
||||
pub const COMMENT: Color = rgb(108, 108, 108); // #6c6c6c — muted
|
||||
pub const DARK3: Color = rgb(90, 90, 90); // #5a5a5a — medium gray
|
||||
pub const DARK5: Color = rgb(120, 120, 120); // #787878 — bright gray
|
||||
// Text / grays
|
||||
// #e1e1e1 — primary text
|
||||
pub const FG: Color = rgb(225, 225, 225);
|
||||
// #c8c8c8 — secondary text
|
||||
pub const FG_DARK: Color = rgb(200, 200, 200);
|
||||
pub const FG_GUTTER: Color = rgb(65, 65, 65);
|
||||
// #6c6c6c — muted
|
||||
pub const COMMENT: Color = rgb(108, 108, 108);
|
||||
// #5a5a5a — medium gray
|
||||
pub const DARK3: Color = rgb(90, 90, 90);
|
||||
// #787878 — bright gray
|
||||
pub const DARK5: Color = rgb(120, 120, 120);
|
||||
|
||||
// ── Accent colors (TokyoNight Night) ─────────────────────────────────
|
||||
pub const BLUE: Color = rgb(122, 162, 247); // #7aa2f7
|
||||
pub const BLUE0: Color = rgb(61, 89, 161); // #3d59a1
|
||||
pub const BLUE1: Color = rgb(58, 149, 171); // #3A95AB
|
||||
pub const CYAN: Color = rgb(125, 207, 255); // #7dcfff
|
||||
pub const GREEN: Color = rgb(158, 206, 106); // #9ece6a
|
||||
pub const GREEN1: Color = rgb(115, 218, 202); // #73daca
|
||||
pub const MAGENTA: Color = rgb(187, 154, 247); // #bb9af7
|
||||
pub const ORANGE: Color = rgb(255, 158, 100); // #ff9e64
|
||||
pub const PURPLE: Color = rgb(157, 124, 216); // #9d7cd8
|
||||
pub const RED: Color = rgb(247, 118, 142); // #f7768e
|
||||
pub const RED1: Color = rgb(219, 75, 75); // #db4b4b
|
||||
pub const TEAL: Color = rgb(26, 188, 156); // #1abc9c
|
||||
pub const YELLOW: Color = rgb(224, 175, 104); // #e0af68
|
||||
// Accent colors (TokyoNight Night)
|
||||
pub const BLUE: Color = rgb(122, 162, 247);
|
||||
pub const BLUE0: Color = rgb(61, 89, 161);
|
||||
pub const BLUE1: Color = rgb(58, 149, 171);
|
||||
pub const CYAN: Color = rgb(125, 207, 255);
|
||||
pub const GREEN: Color = rgb(158, 206, 106);
|
||||
pub const GREEN1: Color = rgb(115, 218, 202);
|
||||
pub const MAGENTA: Color = rgb(187, 154, 247);
|
||||
pub const ORANGE: Color = rgb(255, 158, 100);
|
||||
pub const PURPLE: Color = rgb(157, 124, 216);
|
||||
pub const RED: Color = rgb(247, 118, 142);
|
||||
pub const RED1: Color = rgb(219, 75, 75);
|
||||
pub const TEAL: Color = rgb(26, 188, 156);
|
||||
pub const YELLOW: Color = rgb(224, 175, 104);
|
||||
|
||||
pub const RED_DARK: Color = rgb(66, 14, 20); // #420e14 — quantizes to 256-color red, not gray
|
||||
pub const GREEN_DARK: Color = rgb(6, 56, 6); // #063806 — quantizes to 256-color green, not gray
|
||||
// #420e14 — quantizes to 256-color red, not gray
|
||||
pub const RED_DARK: Color = rgb(66, 14, 20);
|
||||
// #063806 — quantizes to 256-color green, not gray
|
||||
pub const GREEN_DARK: Color = rgb(6, 56, 6);
|
||||
}
|
||||
use palette::*;
|
||||
|
||||
@@ -68,7 +80,8 @@ impl Theme {
|
||||
Self {
|
||||
bg_base: BG_STORM,
|
||||
bg_light: BG_HIGHLIGHT,
|
||||
bg_dark: rgb(28, 28, 28), // lighter than bg_base for visible code blocks
|
||||
// lighter than bg_base for visible code blocks
|
||||
bg_dark: rgb(28, 28, 28),
|
||||
bg_highlight: BG_HIGHLIGHT,
|
||||
bg_hover: rgb(44, 44, 44),
|
||||
bg_terminal: BG,
|
||||
@@ -86,7 +99,8 @@ impl Theme {
|
||||
text_primary: FG,
|
||||
text_secondary: FG_DARK,
|
||||
|
||||
gray_dim: rgb(88, 88, 88), // #585858 — slightly brighter than FG_GUTTER
|
||||
// #585858 — slightly brighter than FG_GUTTER
|
||||
gray_dim: rgb(88, 88, 88),
|
||||
gray: COMMENT,
|
||||
gray_bright: DARK5,
|
||||
|
||||
@@ -97,17 +111,22 @@ impl Theme {
|
||||
|
||||
fuzzy_accent: BLUE,
|
||||
|
||||
accent_plan: rgb(255, 219, 141), // #FFDB8D — golden
|
||||
// #FFDB8D — golden
|
||||
accent_plan: rgb(255, 219, 141),
|
||||
|
||||
accent_verify: rgb(187, 154, 247), // #bb9af7 — violet
|
||||
// #bb9af7 — violet
|
||||
accent_verify: rgb(187, 154, 247),
|
||||
|
||||
accent_feedback: GREEN1, // #73daca
|
||||
accent_feedback: GREEN1,
|
||||
|
||||
accent_remember: Color::Rgb(139, 195, 74), // #8BC34A — Material Design light green
|
||||
// #8BC34A — Material Design light green
|
||||
accent_remember: Color::Rgb(139, 195, 74),
|
||||
|
||||
selection_border: rgb(60, 60, 65),
|
||||
prompt_border: rgb(50, 50, 55), // #323237 — dimmer prompt chrome
|
||||
prompt_border_active: rgb(80, 80, 88), // #505058 — brighter when focused
|
||||
// #323237 — dimmer prompt chrome
|
||||
prompt_border: rgb(50, 50, 55),
|
||||
// #505058 — brighter when focused
|
||||
prompt_border_active: rgb(80, 80, 88),
|
||||
hover_border: rgb(30, 30, 34),
|
||||
|
||||
accent_model: TEAL,
|
||||
@@ -134,19 +153,21 @@ impl Theme {
|
||||
md_heading_h2_mod: Modifier::BOLD,
|
||||
md_heading_h3: PURPLE,
|
||||
md_heading_h3_mod: Modifier::BOLD,
|
||||
md_heading_h4: DARK5, // bright gray
|
||||
md_heading_h4: DARK5,
|
||||
md_heading_h4_mod: Modifier::BOLD,
|
||||
md_heading_h5: COMMENT, // medium gray
|
||||
md_heading_h5: COMMENT,
|
||||
md_heading_h5_mod: Modifier::BOLD,
|
||||
md_heading_h6: DARK3, // medium gray, unbold
|
||||
// medium gray, unbold
|
||||
md_heading_h6: DARK3,
|
||||
md_heading_h6_mod: Modifier::empty(),
|
||||
md_code: BLUE1,
|
||||
md_task_checked: GREEN,
|
||||
md_task_unchecked: FG_DARK, // text_secondary
|
||||
md_task_unchecked: FG_DARK,
|
||||
md_muted: COMMENT,
|
||||
md_code_bg: rgb(28, 28, 28),
|
||||
md_text: FG_DARK,
|
||||
link_fg: rgb(122, 166, 218), // #7aa6da -- soft blue for dark bg
|
||||
// #7aa6da -- soft blue for dark bg
|
||||
link_fg: rgb(122, 166, 218),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,7 +137,7 @@ fn build_style() -> MarkdownStyle {
|
||||
inline_code_outer: fg(theme.md_code).dimmed().hidden(),
|
||||
// Selection-side bar detection (kigi-tui scrollback/blocks/
|
||||
// quote_bar.rs quote_bar_style) mirrors this exact style; its
|
||||
// end-to-end tests fail if this line changes.
|
||||
// end-to-end tests fail if this line drifts.
|
||||
blockquote_outer: fg(theme.md_muted).dimmed(),
|
||||
task_checked: fg(theme.md_task_checked),
|
||||
task_unchecked: fg(theme.md_task_unchecked).dimmed(),
|
||||
|
||||
@@ -476,7 +476,7 @@ impl Theme {
|
||||
// doesn't read at the same weight as secondary text.
|
||||
let dim_fg = if dark { Color::DarkGray } else { Color::Gray };
|
||||
|
||||
// ── Polarity-aware semantic hues ────────────────────────────
|
||||
// Polarity-aware semantic hues
|
||||
// Normal ANSI hues (idx 1–7) are designed at ~50% luminance and
|
||||
// read well on light backgrounds. Light variants (idx 9–15) are
|
||||
// full saturation and read well on dark backgrounds. Pinning by
|
||||
@@ -502,7 +502,7 @@ impl Theme {
|
||||
};
|
||||
let cyan = if dark { Color::LightCyan } else { Color::Cyan };
|
||||
Self {
|
||||
// ── Elevated surfaces: one step off the canvas ──────────────
|
||||
// Elevated surfaces: one step off the canvas
|
||||
// Hover/highlight/visual-selection rows need to read as a
|
||||
// distinct "raised" band against the body. Without this every
|
||||
// KigiNight bg field quantizes to Color::Black and these
|
||||
@@ -512,7 +512,7 @@ impl Theme {
|
||||
bg_hover: elevated_bg,
|
||||
bg_visual: elevated_bg,
|
||||
|
||||
// ── Canvas-matching surfaces ────────────────────────────────
|
||||
// Canvas-matching surfaces
|
||||
// Pin to the theme's polarity, NOT Color::Reset. The truecolor
|
||||
// "subtle sunken / code block" effect can't be replicated in
|
||||
// 16-color, but using the theme polarity guarantees these
|
||||
@@ -523,7 +523,7 @@ impl Theme {
|
||||
paste_bg: canvas_bg,
|
||||
scrollbar_bg: canvas_bg,
|
||||
|
||||
// ── Borders: dim (idle) → muted (selection) → high-contrast (active) ──
|
||||
// Borders: dim (idle) → muted (selection) → high-contrast (active)
|
||||
// The four-tier truecolor border hierarchy collapses onto
|
||||
// three ANSI16 slots:
|
||||
// - `prompt_border` (idle text-input frame) → `dim_fg`,
|
||||
@@ -544,7 +544,7 @@ impl Theme {
|
||||
// Scrollbar thumb stays visible against the canvas-matched track.
|
||||
scrollbar_fg: muted_fg,
|
||||
|
||||
// ── Foreground / text hierarchy ─────────────────────────────
|
||||
// Foreground / text hierarchy
|
||||
md_text: high_contrast_fg,
|
||||
// Selected user-prompt `>` (drives the user selection accent
|
||||
// and the OSC 12 cursor color) takes max-contrast fg so the
|
||||
@@ -563,7 +563,7 @@ impl Theme {
|
||||
gray_bright: muted_fg,
|
||||
gray_dim: dim_fg,
|
||||
|
||||
// ── Semantic accents: polarity-aware hue pins ───────────────
|
||||
// Semantic accents: polarity-aware hue pins
|
||||
// State signals (running / completed / error) and content
|
||||
// categories (system / skill / etc.) get
|
||||
// pinned to a hue that survives ANSI16 instead of collapsing
|
||||
|
||||
@@ -217,7 +217,7 @@ fn ends_with_osc_terminator(buf: &[u8]) -> bool {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// -- ends_with_osc_terminator ---------------------------------------------
|
||||
// ends_with_osc_terminator
|
||||
|
||||
#[test]
|
||||
fn unterminated_reply_is_rejected() {
|
||||
@@ -230,7 +230,7 @@ mod tests {
|
||||
assert!(!ends_with_osc_terminator(b""));
|
||||
}
|
||||
|
||||
// -- parse_osc11_rgb -----------------------------------------------------
|
||||
// parse_osc11_rgb
|
||||
|
||||
#[test]
|
||||
fn parse_4digit_white() {
|
||||
@@ -310,7 +310,7 @@ mod tests {
|
||||
assert_eq!(parse_osc11_rgb(response), Some((15, 15, 15)));
|
||||
}
|
||||
|
||||
// -- parse_channel -------------------------------------------------------
|
||||
// parse_channel
|
||||
|
||||
#[test]
|
||||
fn channel_4digit_max() {
|
||||
@@ -337,7 +337,7 @@ mod tests {
|
||||
assert_eq!(parse_channel(" ff "), Some(255));
|
||||
}
|
||||
|
||||
// -- classify_luminance --------------------------------------------------
|
||||
// classify_luminance
|
||||
|
||||
#[test]
|
||||
fn classify_pure_black_is_dark() {
|
||||
@@ -387,7 +387,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// -- srgb_to_linear ------------------------------------------------------
|
||||
// srgb_to_linear
|
||||
|
||||
#[test]
|
||||
fn srgb_to_linear_zero() {
|
||||
@@ -416,7 +416,7 @@ mod tests {
|
||||
assert!((result - expected).abs() < 1e-10);
|
||||
}
|
||||
|
||||
// -- detect_via_osc11 (graceful degradation) -----------------------------
|
||||
// detect_via_osc11 (graceful degradation)
|
||||
|
||||
#[test]
|
||||
fn detect_returns_none_when_not_tty() {
|
||||
|
||||
@@ -19,38 +19,53 @@ const fn rgb(r: u8, g: u8, b: u8) -> Color {
|
||||
mod palette {
|
||||
use super::*;
|
||||
|
||||
// -- backgrounds (OKLCH hue 265 backgrounds, OKLCH hue 265) -------
|
||||
pub const BASE: Color = rgb(3, 3, 4); // #030304 oklch(0.1 0.005 265)
|
||||
pub const SURFACE: Color = rgb(4, 5, 7); // #040507 oklch(0.115 0.005 265)
|
||||
pub const ELEVATED: Color = rgb(15, 18, 22); // #0F1216 oklch(0.18 0.01 265)
|
||||
pub const PANEL: Color = rgb(4, 4, 6); // #040406 oklch(0.11 0.006 265)
|
||||
// backgrounds (OKLCH hue 265 backgrounds, OKLCH hue 265)
|
||||
// #030304 oklch(0.1 0.005 265)
|
||||
pub const BASE: Color = rgb(3, 3, 4);
|
||||
// #040507 oklch(0.115 0.005 265)
|
||||
pub const SURFACE: Color = rgb(4, 5, 7);
|
||||
// #0F1216 oklch(0.18 0.01 265)
|
||||
pub const ELEVATED: Color = rgb(15, 18, 22);
|
||||
// #040406 oklch(0.11 0.006 265)
|
||||
pub const PANEL: Color = rgb(4, 4, 6);
|
||||
|
||||
// -- text (neutral, no color cast) ----------------------------------------
|
||||
pub const TEXT: Color = rgb(228, 228, 228); // #E4E4E4 oklch(0.92 0 0)
|
||||
pub const TEXT_DIM: Color = rgb(190, 190, 190); // #BEBEBE oklch(0.8 0 0)
|
||||
// text (neutral, no color cast)
|
||||
// #E4E4E4 oklch(0.92 0 0)
|
||||
pub const TEXT: Color = rgb(228, 228, 228);
|
||||
// #BEBEBE oklch(0.8 0 0)
|
||||
pub const TEXT_DIM: Color = rgb(190, 190, 190);
|
||||
|
||||
// -- muted text (slight blue-purple tint) ---------------------------------
|
||||
pub const MUTED: Color = rgb(129, 134, 143); // #81868F oklch(0.62 0.015 260)
|
||||
pub const SUBTLE: Color = rgb(94, 100, 108); // #5E646C oklch(0.5 0.015 260)
|
||||
// muted text (slight blue-purple tint)
|
||||
// #81868F oklch(0.62 0.015 260)
|
||||
pub const MUTED: Color = rgb(129, 134, 143);
|
||||
// #5E646C oklch(0.5 0.015 260)
|
||||
pub const SUBTLE: Color = rgb(94, 100, 108);
|
||||
|
||||
// -- semantic colors (from desktop action tokens) -------------------------
|
||||
pub const GOLD: Color = rgb(235, 217, 110); // #EBD96E oklch(0.88 0.13 100)
|
||||
pub const RED: Color = rgb(220, 90, 100); // #DC5A64 muted rose-red
|
||||
pub const TEAL: Color = rgb(80, 180, 140); // #50B48C softened teal
|
||||
pub const AMBER: Color = rgb(241, 189, 0); // #F1BD00 oklch(0.82 0.18 90)
|
||||
// semantic colors (from desktop action tokens)
|
||||
// #EBD96E oklch(0.88 0.13 100)
|
||||
pub const GOLD: Color = rgb(235, 217, 110);
|
||||
// #DC5A64 muted rose-red
|
||||
pub const RED: Color = rgb(220, 90, 100);
|
||||
// #50B48C softened teal
|
||||
pub const TEAL: Color = rgb(80, 180, 140);
|
||||
// #F1BD00 oklch(0.82 0.18 90)
|
||||
pub const AMBER: Color = rgb(241, 189, 0);
|
||||
|
||||
// -- purple accent ramp (the "purple hints") ------------------------------
|
||||
pub const PURPLE: Color = rgb(155, 126, 206); // #9B7ECE — signature purple
|
||||
pub const PURPLE_DIM: Color = rgb(110, 90, 154); // #6E5A9A — muted purple
|
||||
pub const PURPLE_BRIGHT: Color = rgb(196, 167, 231); // #C4A7E7 — vivid lavender
|
||||
// purple accent ramp (the "purple hints")
|
||||
// #9B7ECE — signature purple
|
||||
pub const PURPLE: Color = rgb(155, 126, 206);
|
||||
// #6E5A9A — muted purple
|
||||
pub const PURPLE_DIM: Color = rgb(110, 90, 154);
|
||||
// #C4A7E7 — vivid lavender
|
||||
pub const PURPLE_BRIGHT: Color = rgb(196, 167, 231);
|
||||
|
||||
// -- cyan (for running indicators, links) ---------------------------------
|
||||
pub const CYAN: Color = rgb(125, 207, 223); // #7DCFDF
|
||||
// cyan (for running indicators, links)
|
||||
pub const CYAN: Color = rgb(125, 207, 223);
|
||||
|
||||
// -- highlight ramp (purple-tinted grays for UI chrome) -------------------
|
||||
pub const HIGHLIGHT_LOW: Color = rgb(18, 16, 28); // #12101C
|
||||
pub const HIGHLIGHT_MED: Color = rgb(36, 32, 52); // #242034
|
||||
pub const HIGHLIGHT_HIGH: Color = rgb(52, 48, 72); // #343048
|
||||
// highlight ramp (purple-tinted grays for UI chrome)
|
||||
pub const HIGHLIGHT_LOW: Color = rgb(18, 16, 28);
|
||||
pub const HIGHLIGHT_MED: Color = rgb(36, 32, 52);
|
||||
pub const HIGHLIGHT_HIGH: Color = rgb(52, 48, 72);
|
||||
}
|
||||
use palette::*;
|
||||
|
||||
@@ -94,7 +109,8 @@ impl Theme {
|
||||
|
||||
accent_feedback: TEAL,
|
||||
|
||||
accent_remember: rgb(139, 195, 74), // #8BC34A — Material Design light green
|
||||
// #8BC34A — Material Design light green
|
||||
accent_remember: rgb(139, 195, 74),
|
||||
|
||||
selection_border: HIGHLIGHT_HIGH,
|
||||
hover_border: HIGHLIGHT_MED,
|
||||
|
||||
@@ -111,7 +111,8 @@ impl Theme {
|
||||
md_muted: MUTED,
|
||||
md_code_bg: SURFACE,
|
||||
md_text: TEXT,
|
||||
link_fg: FOAM, // #9ccfd8 -- teal/cyan for dark bg
|
||||
// #9ccfd8 -- teal/cyan for dark bg
|
||||
link_fg: FOAM,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,7 +112,7 @@ const POLL_INTERVAL: Duration = Duration::from_secs(5);
|
||||
#[cfg(test)]
|
||||
const POLL_INTERVAL: Duration = Duration::from_millis(50);
|
||||
|
||||
/// Watches for system appearance changes via polling.
|
||||
/// Watches for system appearance shifts via polling.
|
||||
///
|
||||
/// The spawned polling task only reads system state and sends via
|
||||
/// `watch::channel` — it never mutates `theme_cache::CURRENT` or `AUTO_MODE`.
|
||||
@@ -155,7 +155,7 @@ impl SystemAppearanceWatcher {
|
||||
})
|
||||
}
|
||||
|
||||
/// Wait for the next appearance change.
|
||||
/// Wait for the next appearance shift.
|
||||
pub async fn changed(&mut self) -> Result<(), watch::error::RecvError> {
|
||||
self.rx.changed().await
|
||||
}
|
||||
@@ -173,7 +173,7 @@ impl Drop for SystemAppearanceWatcher {
|
||||
}
|
||||
}
|
||||
|
||||
// -- Test support ----------------------------------------------------------
|
||||
// Test support
|
||||
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
use std::sync::Mutex;
|
||||
@@ -306,7 +306,7 @@ mod tests {
|
||||
let _ = detect();
|
||||
}
|
||||
|
||||
// -- SystemAppearanceWatcher -----------------------------------------
|
||||
// SystemAppearanceWatcher
|
||||
|
||||
#[tokio::test]
|
||||
async fn start_if_auto_returns_none_when_not_auto() {
|
||||
@@ -347,7 +347,8 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[allow(clippy::await_holding_lock)] // Deliberate: theme_cache::test_lock() serializes mock access.
|
||||
// Deliberate: theme_cache::test_lock() serializes mock access.
|
||||
#[allow(clippy::await_holding_lock)]
|
||||
async fn watcher_detects_appearance_change() {
|
||||
let _guard = theme_cache::test_lock()
|
||||
.lock()
|
||||
@@ -356,10 +357,10 @@ mod tests {
|
||||
let mut watcher = SystemAppearanceWatcher::start_if_auto(true).unwrap();
|
||||
assert_eq!(watcher.current(), Some(SystemAppearance::Dark));
|
||||
|
||||
// Change the mock appearance.
|
||||
// Set the mock appearance.
|
||||
set_mock(Some(SystemAppearance::Light));
|
||||
|
||||
// Wait for the watcher to detect the change (polls every 50ms in tests).
|
||||
// Wait for the watcher to detect the shift (polls every 50ms in tests).
|
||||
tokio::time::timeout(std::time::Duration::from_secs(2), watcher.changed())
|
||||
.await
|
||||
.expect("timed out waiting for change")
|
||||
@@ -370,7 +371,8 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[allow(clippy::await_holding_lock)] // Deliberate: theme_cache::test_lock() serializes mock access.
|
||||
// Deliberate: theme_cache::test_lock() serializes mock access.
|
||||
#[allow(clippy::await_holding_lock)]
|
||||
async fn watcher_does_not_send_when_unchanged() {
|
||||
let _guard = theme_cache::test_lock()
|
||||
.lock()
|
||||
@@ -378,11 +380,11 @@ mod tests {
|
||||
set_mock(Some(SystemAppearance::Dark));
|
||||
let mut watcher = SystemAppearanceWatcher::start_if_auto(true).unwrap();
|
||||
|
||||
// Wait longer than the poll interval — no change should occur.
|
||||
// Wait longer than the poll interval — Unchanged should occur.
|
||||
let result =
|
||||
tokio::time::timeout(std::time::Duration::from_millis(200), watcher.changed()).await;
|
||||
|
||||
// Should timeout because appearance didn't change.
|
||||
// Should timeout because appearance stayed the same.
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"expected timeout — no change should be emitted"
|
||||
@@ -392,16 +394,17 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[allow(clippy::await_holding_lock)] // Deliberate: theme_cache::test_lock() serializes mock access.
|
||||
// Deliberate: theme_cache::test_lock() serializes mock access.
|
||||
#[allow(clippy::await_holding_lock)]
|
||||
async fn watcher_detects_recovery_from_failure() {
|
||||
let _guard = theme_cache::test_lock()
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
set_mock(None); // Initially detection fails
|
||||
// Initially detection fails
|
||||
set_mock(None);
|
||||
let mut watcher = SystemAppearanceWatcher::start_if_auto(true).unwrap();
|
||||
assert_eq!(watcher.current(), None);
|
||||
|
||||
// Now detection succeeds.
|
||||
set_mock(Some(SystemAppearance::Dark));
|
||||
|
||||
tokio::time::timeout(std::time::Duration::from_secs(2), watcher.changed())
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
//! the terminal's own canvas it can land dark-on-dark or light-on-light
|
||||
//! (e.g. macOS in Light Mode + a dark terminal profile). Polarity detection
|
||||
//! is not reliable either: OS appearance and OSC 11 both disagree with the
|
||||
//! actual canvas in edge cases and can change mid-session. Terminal
|
||||
//! actual canvas in edge cases and can differ mid-session. Terminal
|
||||
//! profiles, however, tune their **default** fg/bg to be legible against
|
||||
//! their own background — this is how `git` and `ls` stay readable on any
|
||||
//! terminal — so a palette built from `Reset` (body) + sparse named ANSI-16
|
||||
|
||||
@@ -18,30 +18,32 @@ const fn rgb(r: u8, g: u8, b: u8) -> Color {
|
||||
#[allow(dead_code)]
|
||||
pub mod palette {
|
||||
use super::*;
|
||||
pub const BG: Color = rgb(26, 27, 38); // #1a1b26 - Night
|
||||
pub const BG_DARK: Color = rgb(22, 22, 30); // #16161e
|
||||
pub const BG_HIGHLIGHT: Color = rgb(41, 46, 66); // #292e42
|
||||
pub const BG_STORM: Color = rgb(36, 40, 59); // #24283b - Storm
|
||||
pub const BG_STORM_DARK: Color = rgb(31, 35, 53); // #1f2335
|
||||
pub const FG: Color = rgb(192, 202, 245); // #c0caf5
|
||||
pub const FG_DARK: Color = rgb(169, 177, 214); // #a9b1d6
|
||||
pub const FG_GUTTER: Color = rgb(59, 66, 97); // #3b4261
|
||||
pub const COMMENT: Color = rgb(86, 95, 137); // #565f89
|
||||
pub const DARK3: Color = rgb(84, 92, 126); // #545c7e
|
||||
pub const DARK5: Color = rgb(115, 122, 162); // #737aa2
|
||||
pub const BLUE: Color = rgb(122, 162, 247); // #7aa2f7
|
||||
pub const BLUE0: Color = rgb(61, 89, 161); // #3d59a1
|
||||
pub const BLUE1: Color = rgb(42, 195, 222); // #2ac3de
|
||||
pub const CYAN: Color = rgb(125, 207, 255); // #7dcfff
|
||||
pub const GREEN: Color = rgb(158, 206, 106); // #9ece6a
|
||||
pub const GREEN1: Color = rgb(115, 218, 202); // #73daca
|
||||
pub const MAGENTA: Color = rgb(187, 154, 247); // #bb9af7
|
||||
pub const ORANGE: Color = rgb(255, 158, 100); // #ff9e64
|
||||
pub const PURPLE: Color = rgb(157, 124, 216); // #9d7cd8
|
||||
pub const RED: Color = rgb(247, 118, 142); // #f7768e
|
||||
pub const RED1: Color = rgb(219, 75, 75); // #db4b4b
|
||||
pub const TEAL: Color = rgb(26, 188, 156); // #1abc9c
|
||||
pub const YELLOW: Color = rgb(224, 175, 104); // #e0af68
|
||||
// #1a1b26 - Night
|
||||
pub const BG: Color = rgb(26, 27, 38);
|
||||
pub const BG_DARK: Color = rgb(22, 22, 30);
|
||||
pub const BG_HIGHLIGHT: Color = rgb(41, 46, 66);
|
||||
// #24283b - Storm
|
||||
pub const BG_STORM: Color = rgb(36, 40, 59);
|
||||
pub const BG_STORM_DARK: Color = rgb(31, 35, 53);
|
||||
pub const FG: Color = rgb(192, 202, 245);
|
||||
pub const FG_DARK: Color = rgb(169, 177, 214);
|
||||
pub const FG_GUTTER: Color = rgb(59, 66, 97);
|
||||
pub const COMMENT: Color = rgb(86, 95, 137);
|
||||
pub const DARK3: Color = rgb(84, 92, 126);
|
||||
pub const DARK5: Color = rgb(115, 122, 162);
|
||||
pub const BLUE: Color = rgb(122, 162, 247);
|
||||
pub const BLUE0: Color = rgb(61, 89, 161);
|
||||
pub const BLUE1: Color = rgb(42, 195, 222);
|
||||
pub const CYAN: Color = rgb(125, 207, 255);
|
||||
pub const GREEN: Color = rgb(158, 206, 106);
|
||||
pub const GREEN1: Color = rgb(115, 218, 202);
|
||||
pub const MAGENTA: Color = rgb(187, 154, 247);
|
||||
pub const ORANGE: Color = rgb(255, 158, 100);
|
||||
pub const PURPLE: Color = rgb(157, 124, 216);
|
||||
pub const RED: Color = rgb(247, 118, 142);
|
||||
pub const RED1: Color = rgb(219, 75, 75);
|
||||
pub const TEAL: Color = rgb(26, 188, 156);
|
||||
pub const YELLOW: Color = rgb(224, 175, 104);
|
||||
}
|
||||
use palette::*;
|
||||
|
||||
@@ -53,8 +55,10 @@ pub struct Theme {
|
||||
pub bg_light: Color,
|
||||
pub bg_dark: Color,
|
||||
pub bg_highlight: Color,
|
||||
pub bg_hover: Color, // Mouse hover row in dropdowns — between bg_highlight and bg_visual
|
||||
pub bg_terminal: Color, // For terminal output blocks (currently unused, using bg_dark instead)
|
||||
// Mouse hover row in dropdowns — between bg_highlight and bg_visual
|
||||
pub bg_hover: Color,
|
||||
// For terminal output blocks (currently unused, using bg_dark instead)
|
||||
pub bg_terminal: Color,
|
||||
|
||||
// Accent colors (for vertical lines)
|
||||
pub accent_user: Color,
|
||||
@@ -64,8 +68,10 @@ pub struct Theme {
|
||||
pub accent_system: Color,
|
||||
pub accent_error: Color,
|
||||
pub accent_success: Color,
|
||||
pub accent_running: Color, // For tools that are currently running
|
||||
pub accent_skill: Color, // For skill invocations (slash command skills)
|
||||
// For tools that are currently running
|
||||
pub accent_running: Color,
|
||||
// For skill invocations (slash command skills)
|
||||
pub accent_skill: Color,
|
||||
|
||||
// Text colors
|
||||
pub text_primary: Color,
|
||||
@@ -74,30 +80,42 @@ pub struct Theme {
|
||||
// Gray scale (dim → medium → bright)
|
||||
// Every theme defines these three; they provide a consistent hierarchy
|
||||
// for secondary/meta text across all themes.
|
||||
pub gray_dim: Color, // Dimmest — meta punctuation (`$`, `(+N/-M)`, etc.)
|
||||
pub gray: Color, // Medium — muted text, comments, collapsed content
|
||||
pub gray_bright: Color, // Brightest — tool accents, secondary labels
|
||||
// Dimmest — meta punctuation (`$`, `(+N/-M)`, etc.)
|
||||
pub gray_dim: Color,
|
||||
// Medium — muted text, comments, collapsed content
|
||||
pub gray: Color,
|
||||
// Brightest — tool accents, secondary labels
|
||||
pub gray_bright: Color,
|
||||
|
||||
// Semantic colors
|
||||
pub command: Color, // Yellow for shell commands
|
||||
pub path: Color, // Orange for file paths
|
||||
pub running: Color, // Cyan for running indicator
|
||||
pub warning: Color, // Yellow/amber for warnings
|
||||
// Yellow for shell commands
|
||||
pub command: Color,
|
||||
// Orange for file paths
|
||||
pub path: Color,
|
||||
// Cyan for running indicator
|
||||
pub running: Color,
|
||||
// Yellow/amber for warnings
|
||||
pub warning: Color,
|
||||
|
||||
// Search
|
||||
pub fuzzy_accent: Color, // Highlight color for fuzzy search matches
|
||||
// Highlight color for fuzzy search matches
|
||||
pub fuzzy_accent: Color,
|
||||
|
||||
// Plan mode
|
||||
pub accent_plan: Color, // Golden accent for plan mode indicator
|
||||
// Golden accent for plan mode indicator
|
||||
pub accent_plan: Color,
|
||||
|
||||
// Context-window overhead category (context info block)
|
||||
pub accent_verify: Color, // Violet accent — distinct from plan gold and feedback teal
|
||||
// Violet accent — distinct from plan gold and feedback teal
|
||||
pub accent_verify: Color,
|
||||
|
||||
// Feedback mode
|
||||
pub accent_feedback: Color, // Teal/green accent for feedback mode
|
||||
// Teal/green accent for feedback mode
|
||||
pub accent_feedback: Color,
|
||||
|
||||
// Remember mode
|
||||
pub accent_remember: Color, // Green accent for # remember mode
|
||||
// Green accent for # remember mode
|
||||
pub accent_remember: Color,
|
||||
|
||||
// Selection
|
||||
pub selection_border: Color,
|
||||
@@ -106,7 +124,8 @@ pub struct Theme {
|
||||
pub prompt_border_active: Color,
|
||||
|
||||
// Prompt info
|
||||
pub accent_model: Color, // Model name in prompt info line
|
||||
// Model name in prompt info line
|
||||
pub accent_model: Color,
|
||||
|
||||
// Scrollbar
|
||||
pub scrollbar_bg: Color,
|
||||
@@ -132,25 +151,39 @@ pub struct Theme {
|
||||
// blocks, inline code, links, etc. These default to the corresponding
|
||||
// top-level theme colors but can be overridden per-theme to customise
|
||||
// markdown appearance independently.
|
||||
pub md_heading_h1: Color, // H1 headings
|
||||
pub md_heading_h1_mod: Modifier, // H1 extra effects
|
||||
pub md_heading_h2: Color, // H2 headings, task unchecked, tables
|
||||
pub md_heading_h2_mod: Modifier, // H2 extra effects
|
||||
pub md_heading_h3: Color, // H3 headings, code language tag
|
||||
pub md_heading_h3_mod: Modifier, // H3 extra effects
|
||||
pub md_heading_h4: Color, // H4 headings
|
||||
pub md_heading_h4_mod: Modifier, // H4 extra effects
|
||||
pub md_heading_h5: Color, // H5 headings, link titles
|
||||
pub md_heading_h5_mod: Modifier, // H5 extra effects
|
||||
pub md_heading_h6: Color, // H6 headings
|
||||
pub md_heading_h6_mod: Modifier, // H6 extra effects
|
||||
pub md_code: Color, // Inline code, code block delimiters
|
||||
pub md_task_checked: Color, // Task checked
|
||||
pub md_task_unchecked: Color, // Task unchecked
|
||||
pub md_muted: Color, // Blockquotes, list items, rules, links
|
||||
pub md_code_bg: Color, // Code block background
|
||||
pub md_text: Color, // Default body text (plain paragraphs, strong, emphasis)
|
||||
pub link_fg: Color, // Clickable link text color
|
||||
pub md_heading_h1: Color,
|
||||
// H1 extra effects
|
||||
pub md_heading_h1_mod: Modifier,
|
||||
// H2 headings, task unchecked, tables
|
||||
pub md_heading_h2: Color,
|
||||
// H2 extra effects
|
||||
pub md_heading_h2_mod: Modifier,
|
||||
// H3 headings, code language tag
|
||||
pub md_heading_h3: Color,
|
||||
// H3 extra effects
|
||||
pub md_heading_h3_mod: Modifier,
|
||||
pub md_heading_h4: Color,
|
||||
// H4 extra effects
|
||||
pub md_heading_h4_mod: Modifier,
|
||||
// H5 headings, link titles
|
||||
pub md_heading_h5: Color,
|
||||
// H5 extra effects
|
||||
pub md_heading_h5_mod: Modifier,
|
||||
pub md_heading_h6: Color,
|
||||
// H6 extra effects
|
||||
pub md_heading_h6_mod: Modifier,
|
||||
// Inline code, code block delimiters
|
||||
pub md_code: Color,
|
||||
pub md_task_checked: Color,
|
||||
pub md_task_unchecked: Color,
|
||||
// Blockquotes, list items, rules, links
|
||||
pub md_muted: Color,
|
||||
// Code block background
|
||||
pub md_code_bg: Color,
|
||||
// Default body text (plain paragraphs, strong, emphasis)
|
||||
pub md_text: Color,
|
||||
// Clickable link text color
|
||||
pub link_fg: Color,
|
||||
}
|
||||
|
||||
impl Theme {
|
||||
@@ -172,7 +205,7 @@ impl Theme {
|
||||
accent_error: RED,
|
||||
accent_success: GREEN,
|
||||
accent_running: MAGENTA,
|
||||
accent_skill: rgb(100, 180, 170), // Muted teal
|
||||
accent_skill: rgb(100, 180, 170),
|
||||
|
||||
text_primary: FG,
|
||||
text_secondary: FG_DARK,
|
||||
@@ -188,17 +221,24 @@ impl Theme {
|
||||
|
||||
fuzzy_accent: BLUE,
|
||||
|
||||
accent_plan: rgb(230, 180, 50), // #E6B432 — golden
|
||||
// #E6B432 — golden
|
||||
accent_plan: rgb(230, 180, 50),
|
||||
|
||||
accent_verify: MAGENTA, // #bb9af7 — violet (distinct from plan / feedback)
|
||||
// #bb9af7 — violet (distinct from plan / feedback)
|
||||
accent_verify: MAGENTA,
|
||||
|
||||
accent_feedback: GREEN1, // #73daca — warm teal/green
|
||||
// #73daca — warm teal/green
|
||||
accent_feedback: GREEN1,
|
||||
|
||||
accent_remember: Color::Rgb(139, 195, 74), // #8BC34A — Material Design light green
|
||||
// #8BC34A — Material Design light green
|
||||
accent_remember: Color::Rgb(139, 195, 74),
|
||||
|
||||
selection_border: rgb(58, 72, 115), // #3A4873 — muted tokyonight blue
|
||||
prompt_border: rgb(60, 75, 120), // #323E64 — dimmer prompt chrome
|
||||
prompt_border_active: rgb(75, 92, 140), // #4B5C8C — brighter when focused
|
||||
// #3A4873 — muted tokyonight blue
|
||||
selection_border: rgb(58, 72, 115),
|
||||
// #323E64 — dimmer prompt chrome
|
||||
prompt_border: rgb(60, 75, 120),
|
||||
// #4B5C8C — brighter when focused
|
||||
prompt_border_active: rgb(75, 92, 140),
|
||||
hover_border: rgb(55, 58, 80),
|
||||
|
||||
accent_model: TEAL,
|
||||
@@ -213,7 +253,8 @@ impl Theme {
|
||||
diff_equal_fg: COMMENT,
|
||||
diff_gutter_fg: COMMENT,
|
||||
|
||||
bg_visual: rgb(40, 52, 87), // #283457 — blue-tinted selection bg
|
||||
// #283457 — blue-tinted selection bg
|
||||
bg_visual: rgb(40, 52, 87),
|
||||
|
||||
paste_bg: BG_STORM_DARK,
|
||||
paste_fg: FG_DARK,
|
||||
@@ -239,7 +280,7 @@ impl Theme {
|
||||
md_muted: COMMENT,
|
||||
md_code_bg: BG_HIGHLIGHT,
|
||||
md_text: FG,
|
||||
link_fg: BLUE, // #7aa2f7
|
||||
link_fg: BLUE,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -262,12 +262,12 @@ mod tests {
|
||||
fn time_ago_days() {
|
||||
assert_eq!(format_time_ago(Duration::from_secs(86400)), "1d");
|
||||
assert_eq!(format_time_ago(Duration::from_secs(172800)), "2d");
|
||||
assert_eq!(format_time_ago(Duration::from_secs(2_592_000 - 1)), "29d"); // just under 30d
|
||||
assert_eq!(format_time_ago(Duration::from_secs(2_592_000 - 1)), "29d");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn time_ago_months() {
|
||||
assert_eq!(format_time_ago(Duration::from_secs(2_592_000)), "1mo"); // 30d
|
||||
assert_eq!(format_time_ago(Duration::from_secs(2_592_000)), "1mo");
|
||||
assert_eq!(format_time_ago(Duration::from_secs(5_184_000)), "2mo");
|
||||
// 359d is still 11mo (359/30=11); 360d would be 12mo.
|
||||
assert_eq!(format_time_ago(Duration::from_secs(359 * 86400)), "11mo");
|
||||
@@ -275,7 +275,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn time_ago_years() {
|
||||
assert_eq!(format_time_ago(Duration::from_secs(31_536_000)), "1y"); // 365d
|
||||
assert_eq!(format_time_ago(Duration::from_secs(31_536_000)), "1y");
|
||||
assert_eq!(format_time_ago(Duration::from_secs(63_072_000)), "2y");
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user