M0: compilable skeleton — Kigi 0.1.0 fork surgery

Hard fork of xai-org/grok-build (Apache-2.0) re-targeted as Kigi, an
unofficial Kimi Code CLI community build.

Rename & identity
- 72 xai-*/xai-grok-* crates -> kigi-* (explicit: xai-grok-pager-bin ->
  kigi-bin [binary `kigi`], xai-grok-pager -> kigi-tui; rest mechanical);
  ptyctl, ptyctl-cli, third_party/ unchanged; proto package
  xai.grok.tools.v1 -> kigi.tools.v1
- Config home ~/.kigi (KIGI_SHARE_DIR override), env prefix GROK_* ->
  KIGI_*, `kigi --version` carries the unofficial-community-build notice
- clap identity, help text, startup banner, prompt templates rebranded
  (templates re-encrypted)

Deletions (PRD removal list #5/#6/#7/#9/#10)
- voice input (xai-grok-voice) and all TUI wiring
- telemetry: Mixpanel client, external OTel stream, Sentry, OTLP layers,
  trace/GCS/S3 upload queues (kigi-file-utils halved), workspace upload
  module & dc_log, heap-profile uploader, auth-diagnostics uploader,
  session-analytics halves of feedback; local zero-egress observability
  preserved in new kigi-log crate (unified log, --debug firehose,
  subsystem file logs, opt-in instrumentation)
- announcements (crate, remote-settings fields, TUI surfaces)
- plugin marketplace (crate, sources/browse/CTA/extensions-modal tab);
  direct plugin install/uninstall/update via kigi-agent git_install kept
- relay/gateway/assets endpoints and features (agent relay, headless
  relay transport, gateway bridge, LeaderEnvUrls); leader IPC socket now
  ~/.kigi/leader.sock + KIGI_LEADER_SOCKET, no ws-url derivation
- functional types rehomed instead of deleted: PermissionMode ->
  kigi-config-types, McpInitStrategy -> kigi-mcp, PrCreationSource ->
  session signals, TerminalDiagnostics -> kigi-pager-render, agent_id ->
  shell util

Endpoints
- kigi-env rewritten: single production KigiEndpoints {coding_api_base_url
  https://api.kimi.com/coding/v1 (KIGI_CODE_BASE_URL), oauth_host
  https://auth.kimi.com (KIGI_OAUTH_HOST), update_base_url (GitHub
  Releases API), upgrade_page_url}; GrokBuildEnvironment enum deleted

Toolchain & workspace hygiene
- Rust 1.97.0 pinned; edition 2024; full cargo update; git2 hoisted to
  workspace at 0.21 (Option->Result API migration), quick-xml 0.41
- Root Cargo.toml hand-maintained (PRD §8.1): version 0.1.0 inherited by
  all members, members sorted, unused deps pruned
- cargo-deny advisories gate (deny.toml with documented transitive
  exceptions); CI workflow (check/clippy/fmt/deny/test, macOS+Linux)
- cross-crate test seams re-gated behind `test-support` cargo feature;
  insta snapshot baselines renamed to the kigi_tui prefix
- clippy --workspace --all-targets: zero warnings; fmt clean

Fixes surfaced by the port
- updater probe/installer divergence (bin/kigi vs bin/grok symlink set)
- idle model-metadata refresh dead under KIGI_CODE_BASE_URL override
  (new is_effective_coding_endpoint_url, loopback+override aware)
- macOS symlinked-TMPDIR fixture canonicalization (foreign_sessions,
  fast-worktree); RSS measurement tests serialized via serial_test

Docs & legal (Apache §4)
- NOTICE added (upstream attribution + change statement); THIRD-PARTY
  notices sustained; kigi-tools ported-code notices extended; README,
  CONTRIBUTING, SECURITY, AGENTS.md rewritten

Out of scope for M0 (tracked): Kimi auth/inference (M1), search/fetch,
command parity, config import (M2), Computer Hub excision & final
brand-token sweep (M2), distribution & self-update rewrite (M3).
This commit is contained in:
2026-07-17 05:31:01 -04:00
commit d6c20fc13f
2612 changed files with 1353757 additions and 0 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,54 @@
//! Hot-reloadable appearance configuration.
//!
//! Two unrelated concerns live under this module name:
//!
//! - **`config` + `watcher`**: dev-only `~/.kigi/pager.toml` RenderConfig
//! (200+ fields for terminal rendering tuning). Hot-reloaded in dev mode,
//! static defaults in prod.
//! - **`cache`**: thread-local in-memory caches for the user-facing UI bool
//! settings (`compact_mode`, `show_timestamps`, `simple_mode`). Disk
//! writes happen in `kigi_shell::util::config::set_<field>()` via
//! `Effect::PersistSetting`, NOT here — this is a read-cache only.
//! - **`permission_cursor`**: the `default_selected_permission` value type
//! plus the caches and resolution logic for which row a permission prompt
//! preselects.
pub mod cache;
mod config;
pub mod permission_cursor;
pub mod render_mermaid;
pub mod scroll_mode;
pub mod text_selection;
mod watcher;
pub use config::{
AnimationConfig, AppearanceConfig, BlockBackground, BlocksConfig, EditBlockConfig,
ExecuteHeaderStyle, FollowIndicator, LayoutConfig, PromptConfig, PromptViewConfig,
RawAltScreenMode, RawAppearanceConfig, RawTerminalConfig, ScrollConfig, ScrollbackConfig,
ScrollbarConfig, TodoBadgeFormat, TodoConfig, ToolBullet, ToolConfig,
persist_respect_manual_folds,
};
pub use render_mermaid::RenderMermaid;
pub use scroll_mode::ScrollMode;
pub use text_selection::TextSelection;
pub use watcher::ConfigWatcher;
// -- Global tab_width --------------------------------------------------------
//
// Stored as an atomic so MarkdownContent can read the current value
// without needing the AppearanceConfig threaded through its API.
// Updated by the event loop whenever pager.toml is (re)loaded.
use std::sync::atomic::{AtomicU8, Ordering};
static TAB_WIDTH: AtomicU8 = AtomicU8::new(4);
/// Current tab expansion width (number of spaces per `\t`).
pub fn tab_width() -> u8 {
TAB_WIDTH.load(Ordering::Relaxed)
}
/// Update the global tab width (called when config is loaded/reloaded).
pub fn set_tab_width(w: u8) {
TAB_WIDTH.store(w, Ordering::Relaxed);
}
@@ -0,0 +1,455 @@
//! Permission-prompt cursor preselection.
//!
//! Single home for everything that decides which row the approval menu
//! highlights when the agent asks for permission:
//!
//! - [`DefaultSelectedPermission`] — the value type (config + ACP-kind bridge),
//! - the process-wide caches (configured value + sticky last-used),
//! - [`resolve_initial_cursor`] — the one function the ACP handler calls when
//! queueing a prompt.
//!
//! Deliberately kept out of `views::permission_view` (a renderer) and out of
//! `appearance::cache` (generic bool/u8 setting caches) so this cross-cutting
//! type and its state live together, and the render-hot-path cache module
//! doesn't have to depend upward on the view layer.
use std::cell::Cell;
use agent_client_protocol as acp;
use kigi_workspace::permission::is_enable_always_approve_option;
/// Which row the approval-menu cursor preselects (the highlighted row).
///
/// Persisted as `[ui].default_selected_permission`. The four variants map onto
/// the rows a permission prompt can show:
///
/// - [`AlwaysAllowAllSessions`](Self::AlwaysAllowAllSessions) — the global
/// "enable always-approve" row ("Always allow on all sessions"). This is
/// also the value used when the setting is unset or unrecognised, so it is
/// the effective default.
/// - [`AllowOnce`](Self::AllowOnce) — the plain "Yes" / allow-once row.
/// - [`AllowCommandAlways`](Self::AllowCommandAlways) — the prompt-scoped
/// always-allow row ("Always allow this command" — also covers the
/// per-tool / per-domain / per-edit-session variants of the same ACP kind).
/// - [`Reject`](Self::Reject) — the reject row.
///
/// The configured value only steers the **first** prompt of a session; after
/// the user confirms a prompt, [`resolve_initial_cursor`] sticks to the
/// last-used kind (recorded via [`set_last_used_permission`]).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DefaultSelectedPermission {
/// The global "Always allow on all sessions" (enable-always-approve) row.
/// Also the fallback for an unset / unrecognised config value, so it is the
/// effective default.
AlwaysAllowAllSessions,
AllowOnce,
/// The prompt-scoped always-allow row ("Always allow this command" /
/// tool / domain / edit-session).
AllowCommandAlways,
Reject,
}
impl DefaultSelectedPermission {
/// The single canonical config.toml / registry string for this variant.
/// One accepted value per variant — there are no aliases. `const` so the
/// settings catalog can build its choice table at compile time.
pub const fn as_canonical(self) -> &'static str {
match self {
Self::AlwaysAllowAllSessions => "always_allow_all_sessions",
Self::AllowOnce => "allow_once",
Self::AllowCommandAlways => "allow_command_always",
Self::Reject => "reject",
}
}
/// Display label for the settings picker and the change 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`.
pub const fn display(self) -> &'static str {
match self {
Self::AlwaysAllowAllSessions => "Always allow on all sessions",
Self::AllowOnce => "Allow once",
Self::AllowCommandAlways => "Always allow this command",
Self::Reject => "Reject",
}
}
/// Parse a config.toml / registry value (trimmed, case-insensitive, no
/// aliases). The mapping is total — `always_allow_all_sessions` and any
/// unrecognised / empty value both resolve to
/// [`AlwaysAllowAllSessions`](Self::AlwaysAllowAllSessions), so no `Option`
/// has to be threaded through callers.
pub fn from_config_value(s: &str) -> Self {
match s.trim().to_ascii_lowercase().as_str() {
"allow_once" => Self::AllowOnce,
"allow_command_always" => Self::AllowCommandAlways,
"reject" => Self::Reject,
_ => Self::AlwaysAllowAllSessions,
}
}
/// Whether this preselection targets the given ACP option kind.
///
/// [`AlwaysAllowAllSessions`](Self::AlwaysAllowAllSessions) targets no
/// specific kind — its row is the enable-always-approve option, matched by
/// identity in [`resolve_initial_cursor`], so the cursor falls there when
/// nothing else matches. [`Reject`](Self::Reject) matches **both** reject
/// kinds so a sticky reject lands on whichever reject row a given prompt
/// offers (some prompts carry only `RejectAlways`).
pub fn matches_kind(self, kind: &acp::PermissionOptionKind) -> bool {
matches!(
(self, kind),
(Self::AllowOnce, acp::PermissionOptionKind::AllowOnce)
| (
Self::AllowCommandAlways,
acp::PermissionOptionKind::AllowAlways
)
| (
Self::Reject,
acp::PermissionOptionKind::RejectOnce | acp::PermissionOptionKind::RejectAlways
)
)
}
/// Map a confirmed ACP option kind onto the sticky "last used" target.
/// Both reject kinds collapse to [`Reject`](Self::Reject). Total over every
/// ACP kind, so it never yields
/// [`AlwaysAllowAllSessions`](Self::AlwaysAllowAllSessions) — that row is
/// the enable-always-approve option, which callers exclude from sticky
/// recording.
pub fn from_kind(kind: &acp::PermissionOptionKind) -> Self {
match kind {
acp::PermissionOptionKind::AllowOnce => Self::AllowOnce,
acp::PermissionOptionKind::AllowAlways => Self::AllowCommandAlways,
acp::PermissionOptionKind::RejectOnce | acp::PermissionOptionKind::RejectAlways => {
Self::Reject
}
// TODO(acp-0.10): `PermissionOptionKind` is #[non_exhaustive];
// treat unknown kinds as reject (never auto-allow).
_ => Self::Reject,
}
}
}
// ── 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.
// `AlwaysAllowAllSessions` represents the effective default (unset).
thread_local! {
static CONFIG_CURRENT: Cell<DefaultSelectedPermission> =
const { Cell::new(DefaultSelectedPermission::AlwaysAllowAllSessions) };
static CONFIG_LOADED: Cell<bool> = const { Cell::new(false) };
}
/// Read the cached `[ui].default_selected_permission`, seeding on first call.
///
/// Precedence (mirrors `appearance::cache::load_scroll_speed`):
///
/// 1. `KIGI_DEFAULT_SELECTED_PERMISSION` env var (headless / agent testing —
/// overrides `config.toml` without editing it),
/// 2. `[ui].default_selected_permission` in the layered effective config,
/// 3. [`AlwaysAllowAllSessions`](DefaultSelectedPermission::AlwaysAllowAllSessions)
/// (the effective default).
///
/// Unrecognised / empty values at any layer fall through to the next.
pub fn load_default_selected_permission() -> DefaultSelectedPermission {
CONFIG_LOADED.with(|loaded| {
if !loaded.get() {
let resolved = std::env::var("KIGI_DEFAULT_SELECTED_PERMISSION")
.ok()
.map(|s| DefaultSelectedPermission::from_config_value(&s))
.filter(|p| *p != DefaultSelectedPermission::AlwaysAllowAllSessions)
.or_else(|| {
load_string_from_effective_config("default_selected_permission")
.map(|s| DefaultSelectedPermission::from_config_value(&s))
})
.unwrap_or(DefaultSelectedPermission::AlwaysAllowAllSessions);
CONFIG_CURRENT.with(|c| c.set(resolved));
loaded.set(true);
}
});
CONFIG_CURRENT.with(Cell::get)
}
/// Replace the cached configured value (optimistic update 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) {
CONFIG_CURRENT.with(|c| c.set(value));
CONFIG_LOADED.with(|l| l.set(true));
}
/// Eagerly seed the configured-value cache at startup (called by
/// `appearance::cache::prime`) so the first prompt never hits disk.
pub fn prime() {
let _ = load_default_selected_permission();
}
// ── 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
// configured value. `AlwaysAllowAllSessions` is the sentinel meaning nothing
// has been confirmed yet (`from_kind` never produces it). The TUI renders +
// dispatches on a single thread, so a thread-local `Cell` is fine.
thread_local! {
static LAST_USED: Cell<DefaultSelectedPermission> =
const { Cell::new(DefaultSelectedPermission::AlwaysAllowAllSessions) };
}
/// The kind the user last confirmed this session, or the
/// [`AlwaysAllowAllSessions`](DefaultSelectedPermission::AlwaysAllowAllSessions)
/// sentinel if none yet.
pub fn last_used_permission() -> DefaultSelectedPermission {
LAST_USED.with(Cell::get)
}
/// Record the kind the user just confirmed. Callers must skip the special
/// enable-always-approve (YOLO) and allow-edits-session options — neither
/// represents a per-prompt choice that should steer a later prompt's cursor.
pub fn set_last_used_permission(kind: DefaultSelectedPermission) {
LAST_USED.with(|c| c.set(kind));
}
// ── Resolution ──────────────────────────────────────────────────────────────
/// Pick the initially-highlighted row for a freshly-queued permission prompt.
///
/// Precedence:
///
/// 1. the sticky last-used kind (once the user has confirmed any prompt),
/// 2. the configured `[ui].default_selected_permission`,
/// 3. the global "Always allow on all sessions" (enable-always-approve) row,
/// matched by identity via `is_enable_always_approve_option` — not by list
/// position, so the intent lives in the code rather than the option order,
/// 4. index 0 (clients that don't get the YOLO row prepended).
///
/// The YOLO row is skipped while a concrete target kind is in play, so a
/// configured / sticky preselection never lands on it.
pub fn resolve_initial_cursor(options: &[acp::PermissionOption]) -> usize {
let target = match last_used_permission() {
DefaultSelectedPermission::AlwaysAllowAllSessions => load_default_selected_permission(),
sticky => sticky,
};
options
.iter()
.position(|o| target.matches_kind(&o.kind) && !is_enable_always_approve_option(o))
.or_else(|| options.iter().position(is_enable_always_approve_option))
.unwrap_or(0)
}
/// Read a `[ui].<key>` string from the shell's layered effective config.
/// Returns `None` when the key is absent or not a string.
fn load_string_from_effective_config(key: &str) -> Option<String> {
let root = kigi_config::load_effective_config_disk_only().ok()?;
root.get("ui")?
.get(key)?
.as_str()
.map(std::string::ToString::to_string)
}
// -- Tests -------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use kigi_workspace::permission::ENABLE_ALWAYS_APPROVE_OPTION_ID;
fn opt(id: &str, kind: acp::PermissionOptionKind) -> acp::PermissionOption {
acp::PermissionOption::new(acp::PermissionOptionId::new(id), id.to_owned(), kind)
}
#[test]
fn from_config_value_accepts_one_canonical_per_variant() {
// Trimmed + case-insensitive, but still the exact canonical token.
assert_eq!(
DefaultSelectedPermission::from_config_value("allow_once"),
DefaultSelectedPermission::AllowOnce
);
assert_eq!(
DefaultSelectedPermission::from_config_value(" ALLOW_COMMAND_ALWAYS "),
DefaultSelectedPermission::AllowCommandAlways
);
assert_eq!(
DefaultSelectedPermission::from_config_value("reject"),
DefaultSelectedPermission::Reject
);
assert_eq!(
DefaultSelectedPermission::from_config_value("always_allow_all_sessions"),
DefaultSelectedPermission::AlwaysAllowAllSessions
);
// Empty and garbage collapse to the effective default.
assert_eq!(
DefaultSelectedPermission::from_config_value(""),
DefaultSelectedPermission::AlwaysAllowAllSessions
);
assert_eq!(
DefaultSelectedPermission::from_config_value("bogus"),
DefaultSelectedPermission::AlwaysAllowAllSessions
);
}
#[test]
fn canonical_round_trips() {
// `as_canonical` is the inverse of `from_config_value` for every
// variant — the enum is the single source of truth for the strings.
for variant in [
DefaultSelectedPermission::AlwaysAllowAllSessions,
DefaultSelectedPermission::AllowOnce,
DefaultSelectedPermission::AllowCommandAlways,
DefaultSelectedPermission::Reject,
] {
assert_eq!(
DefaultSelectedPermission::from_config_value(variant.as_canonical()),
variant,
);
assert!(!variant.display().is_empty());
}
}
#[test]
fn matches_kind_targets_the_right_rows() {
use DefaultSelectedPermission as P;
assert!(P::AllowOnce.matches_kind(&acp::PermissionOptionKind::AllowOnce));
assert!(P::AllowCommandAlways.matches_kind(&acp::PermissionOptionKind::AllowAlways));
assert!(!P::AllowCommandAlways.matches_kind(&acp::PermissionOptionKind::AllowOnce));
// A sticky reject must land on EITHER reject row, since some prompts
// only offer `RejectAlways`.
assert!(P::Reject.matches_kind(&acp::PermissionOptionKind::RejectOnce));
assert!(P::Reject.matches_kind(&acp::PermissionOptionKind::RejectAlways));
// The always-allow-on-all-sessions sentinel targets no specific kind
// (its row is matched by identity; the cursor falls to it).
for kind in [
acp::PermissionOptionKind::AllowOnce,
acp::PermissionOptionKind::AllowAlways,
acp::PermissionOptionKind::RejectOnce,
acp::PermissionOptionKind::RejectAlways,
] {
assert!(!P::AlwaysAllowAllSessions.matches_kind(&kind));
}
}
#[test]
fn from_kind_is_total_and_collapses_reject() {
assert_eq!(
DefaultSelectedPermission::from_kind(&acp::PermissionOptionKind::AllowOnce),
DefaultSelectedPermission::AllowOnce
);
assert_eq!(
DefaultSelectedPermission::from_kind(&acp::PermissionOptionKind::AllowAlways),
DefaultSelectedPermission::AllowCommandAlways
);
assert_eq!(
DefaultSelectedPermission::from_kind(&acp::PermissionOptionKind::RejectOnce),
DefaultSelectedPermission::Reject
);
// `RejectAlways` ("No, and don't run X") collapses to Reject so the
// next prompt still lands on its reject row.
assert_eq!(
DefaultSelectedPermission::from_kind(&acp::PermissionOptionKind::RejectAlways),
DefaultSelectedPermission::Reject
);
}
#[test]
fn last_used_round_trips() {
// Fresh thread = un-seeded thread-local (starts at the sentinel).
std::thread::spawn(|| {
assert_eq!(
last_used_permission(),
DefaultSelectedPermission::AlwaysAllowAllSessions
);
set_last_used_permission(DefaultSelectedPermission::AllowCommandAlways);
assert_eq!(
last_used_permission(),
DefaultSelectedPermission::AllowCommandAlways
);
set_last_used_permission(DefaultSelectedPermission::Reject);
assert_eq!(last_used_permission(), DefaultSelectedPermission::Reject);
})
.join()
.unwrap();
}
#[test]
fn resolve_unset_lands_on_yolo_row() {
std::thread::spawn(|| {
// Force the config cache to the default without touching disk/env.
set_default_selected_permission(DefaultSelectedPermission::AlwaysAllowAllSessions);
let options = [
opt("allow-once", acp::PermissionOptionKind::AllowOnce),
opt(
ENABLE_ALWAYS_APPROVE_OPTION_ID,
acp::PermissionOptionKind::AllowOnce,
),
opt("reject-once", acp::PermissionOptionKind::RejectOnce),
];
// No sticky + default config → the enable-always-approve row,
// matched by identity (index 1), not the first AllowOnce (index 0).
assert_eq!(resolve_initial_cursor(&options), 1);
})
.join()
.unwrap();
}
#[test]
fn resolve_sticky_skips_yolo_row() {
std::thread::spawn(|| {
set_default_selected_permission(DefaultSelectedPermission::AlwaysAllowAllSessions);
set_last_used_permission(DefaultSelectedPermission::AllowOnce);
let options = [
opt(
ENABLE_ALWAYS_APPROVE_OPTION_ID,
acp::PermissionOptionKind::AllowOnce,
),
opt("allow-once", acp::PermissionOptionKind::AllowOnce),
opt("reject-once", acp::PermissionOptionKind::RejectOnce),
];
// Sticky AllowOnce must skip the YOLO row (also AllowOnce kind) and
// land on the plain allow-once row (index 1).
assert_eq!(resolve_initial_cursor(&options), 1);
})
.join()
.unwrap();
}
#[test]
fn resolve_sticky_reject_lands_on_reject_always_only_prompt() {
std::thread::spawn(|| {
set_default_selected_permission(DefaultSelectedPermission::AlwaysAllowAllSessions);
set_last_used_permission(DefaultSelectedPermission::Reject);
let options = [
opt(
ENABLE_ALWAYS_APPROVE_OPTION_ID,
acp::PermissionOptionKind::AllowOnce,
),
opt("allow-once", acp::PermissionOptionKind::AllowOnce),
opt("reject-always", acp::PermissionOptionKind::RejectAlways),
];
// The prompt offers only `RejectAlways`; a sticky reject must still
// find it (index 2) rather than falling back to the YOLO row.
assert_eq!(resolve_initial_cursor(&options), 2);
})
.join()
.unwrap();
}
#[test]
fn resolve_without_yolo_row_falls_back_to_index_0() {
std::thread::spawn(|| {
set_default_selected_permission(DefaultSelectedPermission::AlwaysAllowAllSessions);
let options = [
opt("allow-once", acp::PermissionOptionKind::AllowOnce),
opt("reject-once", acp::PermissionOptionKind::RejectOnce),
];
// No sticky, default config, no YOLO row (non-TUI client) → index 0.
assert_eq!(resolve_initial_cursor(&options), 0);
})
.join()
.unwrap();
}
}
@@ -0,0 +1,72 @@
//! The `render_mermaid` user setting (`auto | on | off`).
//!
//! 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
//! (`◇ 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
//! is identical in every terminal.
/// User preference for rendering Mermaid diagrams.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
pub enum RenderMermaid {
/// Show the diagram's clickable affordance row. The default.
#[default]
Auto,
/// Explicit opt-in; behaves identically to [`Auto`](Self::Auto) (terminal
/// capability is never consulted — the affordance row is text + hit-rects).
On,
/// Show the inline diagram art alone, without the affordance row.
Off,
}
impl RenderMermaid {
/// Canonical persisted string (matches the settings-registry choices).
pub fn as_canonical(self) -> &'static str {
match self {
Self::Auto => "auto",
Self::On => "on",
Self::Off => "off",
}
}
/// Parse a canonical string, returning `None` for unrecognized input so
/// callers can fall back to the default rather than guess.
pub fn from_canonical(value: &str) -> Option<Self> {
match value {
"auto" => Some(Self::Auto),
"on" => Some(Self::On),
"off" => Some(Self::Off),
_ => None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn canonical_round_trips() {
for kind in [RenderMermaid::Auto, RenderMermaid::On, RenderMermaid::Off] {
assert_eq!(
RenderMermaid::from_canonical(kind.as_canonical()),
Some(kind)
);
}
}
#[test]
fn default_is_auto() {
assert_eq!(RenderMermaid::default(), RenderMermaid::Auto);
assert_eq!(RenderMermaid::default().as_canonical(), "auto");
}
#[test]
fn unknown_canonical_is_none() {
assert_eq!(RenderMermaid::from_canonical("yes"), None);
assert_eq!(RenderMermaid::from_canonical(""), None);
assert_eq!(RenderMermaid::from_canonical("Auto"), None);
}
}
@@ -0,0 +1,67 @@
//! The `scroll_mode` user setting (`auto` | `wheel` | `trackpad`).
//!
//! Wheel-vs-trackpad detection is heuristic (terminal scroll events carry no
//! magnitude), so this setting lets a user force one classification when the
//! heuristic is wrong for their setup. The pager's input layer maps it onto
//! `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.
#[default]
Auto,
/// Always treat scroll input as a mouse wheel (fixed lines per tick).
Wheel,
/// Always treat scroll input as a trackpad (fractional accumulation).
Trackpad,
}
impl ScrollMode {
/// Canonical persisted string (matches the settings-registry choices).
pub const fn as_canonical(self) -> &'static str {
match self {
Self::Auto => "auto",
Self::Wheel => "wheel",
Self::Trackpad => "trackpad",
}
}
/// Parse a canonical string, returning `None` for unrecognized input.
pub fn from_canonical(value: &str) -> Option<Self> {
match value {
"auto" => Some(Self::Auto),
"wheel" => Some(Self::Wheel),
"trackpad" => Some(Self::Trackpad),
_ => None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn canonical_round_trips() {
for mode in [ScrollMode::Auto, ScrollMode::Wheel, ScrollMode::Trackpad] {
assert_eq!(ScrollMode::from_canonical(mode.as_canonical()), Some(mode));
}
}
#[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.
for junk in ["", "Auto", "WHEEL", "track pad", "mouse", "1"] {
assert_eq!(ScrollMode::from_canonical(junk), None, "{junk:?}");
}
}
#[test]
fn default_is_auto() {
assert_eq!(ScrollMode::default(), ScrollMode::Auto);
assert_eq!(ScrollMode::default().as_canonical(), "auto");
}
}
@@ -0,0 +1,102 @@
//! 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`.
/// 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.
#[default]
Flash,
/// Stay visible until Esc/click/scroll; double-click toggles fold.
Hold,
/// Stay visible until dismissed; double/triple-click selects & copies a
/// word/line (terminal-like). Implies [`TextSelection::holds`].
WordSelect,
}
impl TextSelection {
/// Canonical persisted string (matches the settings-registry choices).
pub const fn as_canonical(self) -> &'static str {
match self {
Self::Flash => "flash",
Self::Hold => "hold",
Self::WordSelect => "word_select",
}
}
/// Parse a canonical string, returning `None` for unrecognized input.
pub fn from_canonical(value: &str) -> Option<Self> {
match value {
"flash" => Some(Self::Flash),
"hold" => Some(Self::Hold),
"word_select" => Some(Self::WordSelect),
_ => None,
}
}
/// Never timer-dismiss the highlight (`hold` or `word_select`).
pub const fn holds(self) -> bool {
matches!(self, Self::Hold | Self::WordSelect)
}
/// Whether double-click selects & copies a word (and triple-click a line),
/// terminal-style, instead of toggling a fold (`word_select` only).
pub const fn selects_word(self) -> bool {
matches!(self, Self::WordSelect)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn canonical_round_trips() {
for kind in [
TextSelection::Flash,
TextSelection::Hold,
TextSelection::WordSelect,
] {
assert_eq!(
TextSelection::from_canonical(kind.as_canonical()),
Some(kind)
);
}
}
#[test]
fn default_is_flash() {
assert_eq!(TextSelection::default(), TextSelection::Flash);
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.
#[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());
}
#[test]
fn unknown_canonical_is_none() {
assert_eq!(TextSelection::from_canonical("yes"), None);
assert_eq!(TextSelection::from_canonical(""), None);
assert_eq!(TextSelection::from_canonical("Flash"), None);
assert_eq!(TextSelection::from_canonical("true"), None);
}
}
@@ -0,0 +1,72 @@
//! File watcher for appearance configuration.
//!
//! In dev mode, watches ~/.kigi/pager.toml for changes and hot-reloads.
//! In prod mode, returns static defaults (no file operations).
use super::config::AppearanceConfig;
use std::io;
use std::path::PathBuf;
use tokio::sync::watch;
/// Watches for appearance config changes.
///
/// In dev mode: reads from ~/.kigi/pager.toml, watches for changes.
/// In prod mode: returns static defaults, `.changed()` never fires.
pub struct ConfigWatcher {
rx: watch::Receiver<AppearanceConfig>,
#[allow(dead_code)]
state: WatcherState,
}
enum WatcherState {
/// No background task (prod mode or dev without notify)
Static {
/// Keep sender alive so channel doesn't close
_tx: watch::Sender<AppearanceConfig>,
},
}
impl ConfigWatcher {
/// Start the config watcher.
///
/// - In dev mode: reads/creates ~/.kigi/pager.toml, watches for changes
/// - In prod mode: returns default config, no file operations
pub async fn start() -> io::Result<Self> {
Self::start_static()
}
/// Get current config.
pub fn current(&self) -> watch::Ref<'_, AppearanceConfig> {
self.rx.borrow()
}
/// Wait for config to change. Never completes in prod mode.
pub async fn changed(&mut self) -> Result<(), watch::error::RecvError> {
self.rx.changed().await
}
/// Path to `$KIGI_SHARE_DIR/pager.toml`.
fn pager_config_path() -> PathBuf {
crate::util::pager_toml_path()
}
/// Start with config loaded from disk (prod mode — no hot-reload).
fn start_static() -> io::Result<Self> {
let config = kigi_config::user_kigi_home()
.and_then(|_| std::fs::read_to_string(Self::pager_config_path()).ok())
.and_then(|content| {
toml::from_str::<super::config::RawAppearanceConfig>(&content)
.ok()
.map(AppearanceConfig::from)
})
.unwrap_or_default();
let (tx, rx) = watch::channel(config);
Ok(Self {
rx,
state: WatcherState::Static { _tx: tx },
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_watcher_start() {
let watcher = ConfigWatcher::start().await.unwrap();
let config = watcher.current();
let _ = config.scrollback.blocks.edit.indent;
let _ = config.scrollback.blocks.edit.vpad;
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,703 @@
//! Trusted success / toast policy for clipboard writes.
//!
//! Writes still multi-fire every backend; this module decides whether we tell
//! the user it worked based on legs that actually reach the pasteboard they use.
use crate::host::{DisplayServer, HostOs};
use crate::terminal::TerminalName;
use super::{ClipboardToastKind, ClipboardWriteLegs};
/// True when native legs wrote the **local** OS clipboard (not SSH/container).
pub(crate) fn trusted_native(
legs: &ClipboardWriteLegs,
host_os: HostOs,
display_server: DisplayServer,
remote: bool,
container: bool,
) -> bool {
if remote || container || !legs.route_native {
return false;
}
match host_os {
HostOs::Linux => match display_server {
// A verified wl-copy write, or an arboard write that went through
// the compositor's data-control protocol (focus-free, no XWayland
// bridge). Without data-control, arboard only reached the X11 side
// and the Wayland paste may never see it.
DisplayServer::Wayland => legs.wl_copy_ok || (legs.arboard_ok && legs.data_control),
_ => legs.cli_ok || legs.arboard_ok,
},
HostOs::Macos | HostOs::Windows | HostOs::Other => legs.cli_ok || legs.arboard_ok,
}
}
/// True when an OSC 52 write reaches the user's real clipboard.
///
/// Normally this requires the detected terminal brand to natively apply OSC 52
/// to the system pasteboard (fail closed). Two overrides widen the brand gate:
///
/// - `osc52_sink`: when `grok wrap` is capturing this process's output (see
/// [`super::osc52_sink_active`]) the escape sequence is intercepted upstream
/// and copied to the *local* clipboard regardless of the (often misdetected,
/// e.g. over SSH) inner terminal brand, so the copy is trusted.
/// - `container` + `Unknown` brand: inside a container without a display server
/// (docker/podman), native legs *cannot* reach the user's pasteboard and the
/// container runtime does not forward brand env vars (`WT_SESSION`,
/// `TERM_PROGRAM`, …), so the brand is `Unknown` even when the outer terminal
/// (Windows Terminal, iTerm2, Ghostty, …) applies OSC 52 fine. Failing closed
/// here would mis-report *every* container copy as failed (GB report:
/// "Copy failed" toast in docker from Windows PowerShell while the copy
/// landed). The `CopiedOscContainer` toast already hedges with a fallback
/// instruction, so trust the emitted escape. A *detected* non-supporting
/// brand (env explicitly forwarded) stays fail-closed.
pub(crate) fn trusted_osc(
legs: &ClipboardWriteLegs,
brand: TerminalName,
container: bool,
osc52_sink: bool,
) -> bool {
legs.osc52_ok
&& (brand.supports_osc52_clipboard()
|| osc52_sink
|| (container && brand == TerminalName::Unknown))
}
/// 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.
#[allow(clippy::too_many_arguments)]
pub(crate) fn resolve_copy_toast(
legs: &ClipboardWriteLegs,
text: &str,
brand: TerminalName,
host_os: HostOs,
display_server: DisplayServer,
remote: bool,
container: bool,
osc52_sink: bool,
) -> ClipboardToastKind {
if trusted_native(legs, host_os, display_server, remote, container) {
return ClipboardToastKind::Copied;
}
if trusted_osc(legs, brand, container, osc52_sink) {
if remote && brand.is_vscode_family() && !text.is_ascii() {
return ClipboardToastKind::VsCodeSshNonAscii;
}
// Container before remote (matches prior route-flag toast order).
if container {
return ClipboardToastKind::CopiedOscContainer;
}
if remote {
return ClipboardToastKind::CopiedOscRemote;
}
return ClipboardToastKind::Copied;
}
if legs.tmux_ok {
return ClipboardToastKind::CopiedTmux;
}
ClipboardToastKind::Failed
}
#[cfg(test)]
mod tests {
use super::*;
use crate::clipboard::ClipboardWriteLegs;
fn legs(
route_native: bool,
cli_ok: bool,
arboard_ok: bool,
tmux_ok: bool,
osc52_ok: bool,
cli_ok_tools: &str,
) -> ClipboardWriteLegs {
ClipboardWriteLegs {
route_native,
cli_tools_tried: String::new(),
cli_ok_tools: cli_ok_tools.into(),
wl_copy_ok: cli_ok_tools.split('+').any(|t| t == "wl-copy"),
cli_ok,
arboard_ok,
data_control: false,
tmux_ok,
osc52_ok,
}
}
/// Same as [`legs`] with the Wayland data-control flag set.
fn legs_data_control(
route_native: bool,
cli_ok: bool,
arboard_ok: bool,
tmux_ok: bool,
osc52_ok: bool,
cli_ok_tools: &str,
) -> ClipboardWriteLegs {
ClipboardWriteLegs {
data_control: true,
..legs(
route_native,
cli_ok,
arboard_ok,
tmux_ok,
osc52_ok,
cli_ok_tools,
)
}
}
fn resolve(
legs: &ClipboardWriteLegs,
brand: TerminalName,
host_os: HostOs,
display_server: DisplayServer,
remote: bool,
container: bool,
) -> ClipboardToastKind {
resolve_copy_toast(
legs,
"hello",
brand,
host_os,
display_server,
remote,
container,
false,
)
}
#[test]
fn macos_local_native_ok() {
let l = legs(true, true, false, false, false, "pbcopy");
assert_eq!(
resolve(
&l,
TerminalName::Ghostty,
HostOs::Macos,
DisplayServer::Quartz,
false,
false
),
ClipboardToastKind::Copied
);
}
#[test]
fn macos_apple_terminal_osc_only_fails() {
let l = legs(true, false, false, false, true, "");
assert_eq!(
resolve(
&l,
TerminalName::AppleTerminal,
HostOs::Macos,
DisplayServer::Quartz,
false,
false
),
ClipboardToastKind::Failed
);
assert!(!TerminalName::AppleTerminal.supports_osc52_clipboard());
}
#[test]
fn windows_local_native_ok() {
let l = legs(true, false, true, false, false, "");
assert_eq!(
resolve(
&l,
TerminalName::WindowsTerminal,
HostOs::Windows,
DisplayServer::Win32,
false,
false
),
ClipboardToastKind::Copied
);
}
#[test]
fn linux_x11_xclip_ok() {
let l = legs(true, true, false, false, true, "xclip");
assert_eq!(
resolve(
&l,
TerminalName::Vte,
HostOs::Linux,
DisplayServer::X11,
false,
false
),
ClipboardToastKind::Copied
);
}
#[test]
fn linux_wayland_arboard_only_fails() {
let l = legs(true, false, true, false, true, "");
assert_eq!(
resolve(
&l,
TerminalName::Vte,
HostOs::Linux,
DisplayServer::Wayland,
false,
false
),
ClipboardToastKind::Failed
);
}
// 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.
#[test]
fn linux_wayland_arboard_data_control_ok() {
let l = legs_data_control(true, false, true, false, true, "");
assert_eq!(
resolve(
&l,
TerminalName::Vte,
HostOs::Linux,
DisplayServer::Wayland,
false,
false
),
ClipboardToastKind::Copied
);
}
// Without data-control (GNOME <= 47 or kill-switch), an arboard-only write
// keeps the `linux_wayland_arboard_only_fails` semantics.
#[test]
fn linux_wayland_arboard_without_data_control_still_fails() {
let l = legs(true, false, true, false, false, "");
assert_eq!(
resolve(
&l,
TerminalName::Vte,
HostOs::Linux,
DisplayServer::Wayland,
false,
false
),
ClipboardToastKind::Failed
);
}
// 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, "");
assert_eq!(
resolve(
&l,
TerminalName::Vte,
HostOs::Linux,
DisplayServer::Wayland,
false,
false
),
ClipboardToastKind::Failed
);
}
#[test]
fn linux_wayland_wl_copy_ok() {
let l = legs(true, true, false, false, true, "wl-copy");
assert_eq!(
resolve(
&l,
TerminalName::Vte,
HostOs::Linux,
DisplayServer::Wayland,
false,
false
),
ClipboardToastKind::Copied
);
}
#[test]
fn linux_wayland_xclip_only_not_trusted_native() {
let l = legs(true, true, true, false, true, "xclip");
assert_eq!(
resolve(
&l,
TerminalName::Vte,
HostOs::Linux,
DisplayServer::Wayland,
false,
false
),
ClipboardToastKind::Failed
);
}
#[test]
fn linux_vte_osc_only_fails() {
let l = legs(true, false, false, false, true, "");
assert_eq!(
resolve(
&l,
TerminalName::Vte,
HostOs::Linux,
DisplayServer::X11,
false,
false
),
ClipboardToastKind::Failed
);
}
#[test]
fn ssh_vte_osc_only_fails() {
let l = legs(true, false, false, false, true, "");
assert_eq!(
resolve(
&l,
TerminalName::Vte,
HostOs::Linux,
DisplayServer::Unknown,
true,
false
),
ClipboardToastKind::Failed
);
}
#[test]
fn ssh_ghostty_osc_only_remote_toast() {
let l = legs(true, false, false, false, true, "");
assert_eq!(
resolve(
&l,
TerminalName::Ghostty,
HostOs::Linux,
DisplayServer::Unknown,
true,
false
),
ClipboardToastKind::CopiedOscRemote
);
}
#[test]
fn ssh_iterm2_osc_only_remote_toast() {
// Guards the OSC-52 membership invariant the fix depends on.
assert!(TerminalName::Iterm2.supports_osc52_clipboard());
let l = legs(true, false, false, false, true, "");
assert_eq!(
resolve(
&l,
TerminalName::Iterm2,
HostOs::Linux,
DisplayServer::Unknown,
true,
false
),
ClipboardToastKind::CopiedOscRemote
);
}
#[test]
fn local_ghostty_osc_only_copied() {
let l = legs(true, false, false, false, true, "");
assert_eq!(
resolve(
&l,
TerminalName::Ghostty,
HostOs::Linux,
DisplayServer::X11,
false,
false
),
ClipboardToastKind::Copied
);
}
#[test]
fn tmux_only_ok() {
let l = legs(true, false, false, true, false, "");
assert_eq!(
resolve(
&l,
TerminalName::Vte,
HostOs::Linux,
DisplayServer::X11,
false,
false
),
ClipboardToastKind::CopiedTmux
);
}
#[test]
fn vscode_ssh_ascii_trusted_osc_remote() {
let l = legs(true, false, false, false, true, "");
assert_eq!(
resolve(
&l,
TerminalName::VsCode,
HostOs::Linux,
DisplayServer::Unknown,
true,
false
),
ClipboardToastKind::CopiedOscRemote
);
}
#[test]
fn vscode_ssh_non_ascii_trusted_osc() {
let l = legs(true, false, false, false, true, "");
assert_eq!(
resolve_copy_toast(
&l,
"café",
TerminalName::VsCode,
HostOs::Linux,
DisplayServer::Unknown,
true,
false,
false,
),
ClipboardToastKind::VsCodeSshNonAscii
);
}
#[test]
fn vscode_ssh_non_ascii_untrusted_osc_fails() {
let l = legs(true, false, false, false, true, "");
assert_eq!(
resolve_copy_toast(
&l,
"café",
TerminalName::Vte,
HostOs::Linux,
DisplayServer::Unknown,
true,
false,
false,
),
ClipboardToastKind::Failed
);
}
#[test]
fn all_fail() {
let l = legs(true, false, false, false, false, "");
assert_eq!(
resolve(
&l,
TerminalName::Ghostty,
HostOs::Linux,
DisplayServer::X11,
false,
false
),
ClipboardToastKind::Failed
);
assert!(!ClipboardToastKind::Failed.reported_success());
}
#[test]
fn ssh_remote_native_not_trusted_without_osc() {
let l = legs(true, true, true, false, false, "xclip");
assert_eq!(
resolve(
&l,
TerminalName::Vte,
HostOs::Linux,
DisplayServer::X11,
true,
false
),
ClipboardToastKind::Failed
);
}
#[test]
fn container_ghostty_osc_container_toast() {
let l = legs(true, false, false, false, true, "");
assert_eq!(
resolve(
&l,
TerminalName::Ghostty,
HostOs::Linux,
DisplayServer::Unknown,
false,
true
),
ClipboardToastKind::CopiedOscContainer
);
}
#[test]
fn remote_and_container_prefers_container_toast() {
let l = legs(true, false, false, false, true, "");
assert_eq!(
resolve(
&l,
TerminalName::Ghostty,
HostOs::Linux,
DisplayServer::Unknown,
true,
true
),
ClipboardToastKind::CopiedOscContainer
);
}
// `grok 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.
#[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.
assert_eq!(
resolve_copy_toast(
&l,
"hello",
TerminalName::Vte,
HostOs::Linux,
DisplayServer::Unknown,
true,
false,
false,
),
ClipboardToastKind::Failed
);
// With the sink active: trusted → success toast.
assert_eq!(
resolve_copy_toast(
&l,
"hello",
TerminalName::Vte,
HostOs::Linux,
DisplayServer::Unknown,
true,
false,
true,
),
ClipboardToastKind::CopiedOscRemote
);
}
// 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, "");
assert!(!trusted_osc(&l, TerminalName::Vte, false, true));
assert_eq!(
resolve_copy_toast(
&l,
"hello",
TerminalName::Vte,
HostOs::Linux,
DisplayServer::Unknown,
true,
false,
true,
),
ClipboardToastKind::Failed
);
}
// 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).
#[test]
fn container_unknown_brand_osc_trusted() {
let l = legs(true, false, false, false, true, "");
assert_eq!(
resolve(
&l,
TerminalName::Unknown,
HostOs::Linux,
DisplayServer::Unknown,
false,
true
),
ClipboardToastKind::CopiedOscContainer
);
}
// 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, "");
assert!(!trusted_osc(&l, TerminalName::Unknown, true, false));
assert_eq!(
resolve(
&l,
TerminalName::Unknown,
HostOs::Linux,
DisplayServer::Unknown,
false,
true
),
ClipboardToastKind::Failed
);
}
// A *detected* non-supporting brand stays fail-closed even in a container
// (env was explicitly forwarded, so the detection is authoritative).
#[test]
fn container_detected_nonsupporting_brand_fails() {
let l = legs(true, false, false, false, true, "");
assert_eq!(
resolve(
&l,
TerminalName::AppleTerminal,
HostOs::Linux,
DisplayServer::Unknown,
false,
true
),
ClipboardToastKind::Failed
);
}
// Unknown brand over SSH (not container) keeps failing closed — the
// container override is deliberately narrow; `grok wrap` is the SSH path.
#[test]
fn ssh_unknown_brand_osc_only_still_fails() {
let l = legs(true, false, false, false, true, "");
assert_eq!(
resolve(
&l,
TerminalName::Unknown,
HostOs::Linux,
DisplayServer::Unknown,
true,
false
),
ClipboardToastKind::Failed
);
}
// 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, "");
assert_eq!(
resolve_copy_toast(
&l,
"hello",
TerminalName::Unknown,
HostOs::Linux,
DisplayServer::Unknown,
false,
true,
true,
),
ClipboardToastKind::CopiedOscContainer
);
}
}
@@ -0,0 +1,711 @@
//! Procedural art assets for the `/gboom` easter egg.
//!
//! Everything is generated in code — no binary assets, no copyrighted
//! material. Wall textures are synthesized from hash noise, sprites are
//! hand-drawn char-map pixel art, and text uses a tiny 5x7 pixel font.
/// RGB color.
pub(super) type Rgb = [u8; 3];
/// Wall texture side length (square).
pub(super) const TEX_SIZE: usize = 64;
/// The signature crimson red, shared by the title screen, end screens,
/// and the overlay chrome so they always match.
pub(crate) const GBOOM_RED: Rgb = [235, 40, 32];
/// Imp eye color. The renderer exempts exactly this color from distance
/// fog so eyes glow in the dark; keep the sprite art and renderer in sync
/// through this constant.
pub(super) const EYE_GLOW: Rgb = [255, 216, 0];
/// Minimal xorshift64* PRNG.
///
/// The game needs determinism-friendly, allocation-free randomness for
/// damage rolls and the fire effect — not `rand`-crate quality. Seeded
/// per consumer so simulation and visuals stay independent streams.
pub(super) struct XorShift64(u64);
impl XorShift64 {
pub fn new(seed: u64) -> Self {
Self(seed.max(1)) // xorshift state must be non-zero
}
pub fn next_u32(&mut self) -> u32 {
self.0 ^= self.0 >> 12;
self.0 ^= self.0 << 25;
self.0 ^= self.0 >> 27;
(self.0.wrapping_mul(0x2545F4914F6CDD1D) >> 33) as u32
}
/// Uniform float in `[0.0, 1.0)`.
pub fn next_f32(&mut self) -> f32 {
(self.next_u32() >> 8) as f32 / (1u32 << 24) as f32
}
}
/// Deterministic 2D integer hash → `[0.0, 1.0)`. Used for texture noise.
fn hash01(x: u32, y: u32, seed: u32) -> f32 {
let mut h = x
.wrapping_mul(0x9E37_79B9)
.wrapping_add(y.wrapping_mul(0x85EB_CA6B))
.wrapping_add(seed.wrapping_mul(0xC2B2_AE35));
h ^= h >> 16;
h = h.wrapping_mul(0x7FEB_352D);
h ^= h >> 15;
h = h.wrapping_mul(0x846C_A68B);
h ^= h >> 16;
(h & 0xFFFF) as f32 / 65536.0
}
fn scale(c: Rgb, f: f32) -> Rgb {
[
(c[0] as f32 * f).clamp(0.0, 255.0) as u8,
(c[1] as f32 * f).clamp(0.0, 255.0) as u8,
(c[2] as f32 * f).clamp(0.0, 255.0) as u8,
]
}
/// A generated wall texture: `TEX_SIZE * TEX_SIZE` RGB pixels, row-major.
pub(super) struct Texture {
pub pixels: Vec<Rgb>,
}
impl Texture {
#[inline]
pub fn sample(&self, x: usize, y: usize) -> Rgb {
self.pixels[(y & (TEX_SIZE - 1)) * TEX_SIZE + (x & (TEX_SIZE - 1))]
}
}
/// Generate the wall texture set, indexed by map cell value - 1.
pub(super) fn build_textures() -> Vec<Texture> {
vec![brick(), stone(), tech(), hellstone()]
}
/// Classic red-brown brick: 16x8 bricks, offset every other row, mortar gaps.
fn brick() -> Texture {
let base: Rgb = [148, 64, 44];
let mortar: Rgb = [78, 60, 54];
let mut pixels = Vec::with_capacity(TEX_SIZE * TEX_SIZE);
for y in 0..TEX_SIZE {
for x in 0..TEX_SIZE {
let row = y / 8;
let off = if row % 2 == 0 { 0 } else { 8 };
let in_mortar = y % 8 == 0 || (x + off) % 16 == 0;
let c = if in_mortar {
mortar
} else {
// Per-brick tone variation + per-pixel grain.
let brick_id = (row as u32) * 31 + (((x + off) / 16) as u32);
let tone = 0.82 + 0.30 * hash01(brick_id, 7, 1);
let grain = 0.92 + 0.16 * hash01(x as u32, y as u32, 2);
scale(base, tone * grain)
};
pixels.push(c);
}
}
Texture { pixels }
}
/// Large gray stone blocks with grout lines and noise.
fn stone() -> Texture {
let base: Rgb = [118, 118, 126];
let grout: Rgb = [58, 58, 64];
let mut pixels = Vec::with_capacity(TEX_SIZE * TEX_SIZE);
for y in 0..TEX_SIZE {
for x in 0..TEX_SIZE {
let row = y / 16;
let off = if row % 2 == 0 { 0 } else { 16 };
let in_grout = y % 16 == 0 || (x + off) % 32 == 0;
let c = if in_grout {
grout
} else {
let block_id = (row as u32) * 17 + (((x + off) / 32) as u32);
let tone = 0.80 + 0.28 * hash01(block_id, 3, 3);
let grain = 0.90 + 0.20 * hash01(x as u32, y as u32, 4);
scale(base, tone * grain)
};
pixels.push(c);
}
}
Texture { pixels }
}
/// Dark sci-fi metal panels with horizontal seams and green light strips.
fn tech() -> Texture {
let base: Rgb = [74, 82, 92];
let seam: Rgb = [38, 42, 48];
let light: Rgb = [110, 240, 130];
let mut pixels = Vec::with_capacity(TEX_SIZE * TEX_SIZE);
for y in 0..TEX_SIZE {
for x in 0..TEX_SIZE {
let in_seam = y % 16 == 0 || y % 16 == 15 || x % 32 == 0;
// Blinking-looking light dots along the middle of each panel.
let is_light =
y % 16 == 8 && x % 8 == 4 && hash01((x / 8) as u32, (y / 16) as u32, 5) > 0.35;
let c = if is_light {
light
} else if in_seam {
seam
} else {
let grain = 0.88 + 0.22 * hash01(x as u32, y as u32, 6);
scale(base, grain)
};
pixels.push(c);
}
}
Texture { pixels }
}
/// Floor: large worn stone tiles with grime patches, kept dark so the
/// fog gradient toward the horizon reads naturally.
pub(super) fn build_floor_texture() -> Texture {
let base: Rgb = [96, 86, 74];
let grout: Rgb = [44, 40, 36];
let mut pixels = Vec::with_capacity(TEX_SIZE * TEX_SIZE);
for y in 0..TEX_SIZE {
for x in 0..TEX_SIZE {
let in_grout = y % 32 == 0 || x % 32 == 0;
let c = if in_grout {
grout
} else {
let tile_id = (y / 32) as u32 * 5 + (x / 32) as u32;
let tone = 0.78 + 0.30 * hash01(tile_id, 11, 9);
// Grime: coarse blotches darken patches of the tile.
let grime = if hash01((x / 6) as u32, (y / 6) as u32, 10) > 0.72 {
0.78
} else {
1.0
};
let grain = 0.90 + 0.20 * hash01(x as u32, y as u32, 11);
scale(base, tone * grime * grain)
};
pixels.push(c);
}
}
Texture { pixels }
}
/// Ceiling: dark metal panels with sparse emissive light squares. The
/// lights stay bright through fog shading simply by starting near white.
pub(super) fn build_ceiling_texture() -> Texture {
let base: Rgb = [52, 56, 66];
let seam: Rgb = [30, 32, 38];
let lamp: Rgb = [232, 226, 198];
let mut pixels = Vec::with_capacity(TEX_SIZE * TEX_SIZE);
for y in 0..TEX_SIZE {
for x in 0..TEX_SIZE {
let in_seam = y % 16 == 0 || x % 16 == 0;
// One panel in ~6 carries a recessed lamp in its center.
let panel = ((x / 16) as u32, (y / 16) as u32);
let has_lamp = hash01(panel.0, panel.1, 12) > 0.84;
let in_lamp = has_lamp && (4..12).contains(&(x % 16)) && (4..12).contains(&(y % 16));
let c = if in_lamp {
lamp
} else if in_seam {
seam
} else {
let grain = 0.88 + 0.20 * hash01(x as u32, y as u32, 13);
scale(base, grain)
};
pixels.push(c);
}
}
Texture { pixels }
}
/// Dark red marbled "hell" stone for accent walls.
fn hellstone() -> Texture {
let base: Rgb = [120, 30, 28];
let vein: Rgb = [200, 80, 50];
let mut pixels = Vec::with_capacity(TEX_SIZE * TEX_SIZE);
for y in 0..TEX_SIZE {
for x in 0..TEX_SIZE {
let fx = x as f32 / TEX_SIZE as f32;
let fy = y as f32 / TEX_SIZE as f32;
// Cheap marble: layered sine waves distorted by noise.
let n = hash01((x / 4) as u32, (y / 4) as u32, 7);
let v = ((fx * 9.0 + fy * 4.0 + n * 3.0).sin() * 0.5 + 0.5).powi(3);
let grain = 0.85 + 0.25 * hash01(x as u32, y as u32, 8);
let c = [
(base[0] as f32 * (1.0 - v) + vein[0] as f32 * v) * grain,
(base[1] as f32 * (1.0 - v) + vein[1] as f32 * v) * grain,
(base[2] as f32 * (1.0 - v) + vein[2] as f32 * v) * grain,
];
pixels.push([c[0] as u8, c[1] as u8, c[2] as u8]);
}
}
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
_ => None,
}
}
/// A char-map sprite: rows of equal length, `.` = transparent.
pub(super) struct Sprite {
pub w: usize,
pub h: usize,
pixels: Vec<Option<Rgb>>,
}
impl Sprite {
fn from_art(art: &[&str]) -> Self {
let h = art.len();
let w = art.first().map_or(0, |r| r.len());
let mut pixels = Vec::with_capacity(w * h);
for row in art {
debug_assert_eq!(row.len(), w, "sprite rows must be equal length");
for &ch in row.as_bytes() {
pixels.push(sprite_color(ch));
}
}
Self { w, h, pixels }
}
/// Sample with normalized coordinates in `[0, 1)`.
#[inline]
pub fn sample(&self, u: f32, v: f32) -> Option<Rgb> {
let x = ((u * self.w as f32) as usize).min(self.w - 1);
let y = ((v * self.h as f32) as usize).min(self.h - 1);
self.pixels[y * self.w + x]
}
}
/// Imp frame set, indexed by [`super::game::ImpVisual`].
pub(super) struct ImpSprites {
pub walk_a: Sprite,
pub walk_b: Sprite,
pub attack: Sprite,
pub pain: Sprite,
pub die_a: Sprite,
pub die_b: Sprite,
pub corpse: Sprite,
}
pub(super) fn build_imp_sprites() -> ImpSprites {
// 16x20 horned demon. Two walk frames differ in leg/arm pose.
let walk_a = Sprite::from_art(&[
"..H..........H..",
"..HH........HH..",
"...bBBBBBBBBb...",
"...BBBBBBBBBB...",
"..BBEEBBBBEEBB..",
"..BBEEBBBBEEBB..",
"...BBBBBBBBBB...",
"...BbMTMTMTbB...",
"....bBBBBBBb....",
"..bBBBBBBBBBBb..",
".CBBb.BBBB.bBBC.",
".CBB..BBBB..BBC.",
".CC...BBBB...CC.",
"......bBBb......",
".....BB..BB.....",
"....BB....BB....",
"....BB.....BB...",
"...bB.......Bb..",
"...BB.......BB..",
"..CC.........CC.",
]);
let walk_b = Sprite::from_art(&[
"..H..........H..",
"..HH........HH..",
"...bBBBBBBBBb...",
"...BBBBBBBBBB...",
"..BBEEBBBBEEBB..",
"..BBEEBBBBEEBB..",
"...BBBBBBBBBB...",
"...BbMTMTMTbB...",
"....bBBBBBBb....",
"..bBBBBBBBBBBb..",
".CBBb.BBBB.bBBC.",
".CBB..BBBB..BBC.",
".CC...BBBB...CC.",
"......bBBb......",
".....BB..BB.....",
"....BB.....BB...",
"...BB.......BB..",
"...Bb.......bB..",
"...BB........BB.",
"..CC..........CC",
]);
// Arms raised overhead, mouth wide.
let attack = Sprite::from_art(&[
".CC.H......H.CC.",
".CBBHH....HHBBC.",
".CBBbBBBBBBbBBC.",
"..BBBBBBBBBBBB..",
"..BBEEBBBBEEBB..",
"..bBEEBBBBEEBb..",
"...BBBBBBBBBB...",
"...BbMMMMMMbB...",
"...BbMTMTMTbB...",
"....bBBBBBBb....",
"...BBBBBBBBBB...",
"...BBBBBBBBBB...",
"....BBBBBBBB....",
"......bBBb......",
".....BB..BB.....",
"....BB....BB....",
"....BB....BB....",
"...bB......Bb...",
"...BB......BB...",
"..CC........CC..",
]);
// Flinching: head tilted, eyes shut, blood.
let pain = Sprite::from_art(&[
"....H......H....",
"...HH.....HH....",
"..bBBBBBBBBb....",
".RBBBBBBBBBB....",
".RBBMMBBBMMBB...",
"..rBBBBBBBBBR...",
"...BBBBBBBBBB...",
"...BbMMMMMMbB...",
"....bBBBBBBbR...",
"..bBBBBBBBBBBb..",
".CBBb.BBBB.bBBC.",
".CBB..BBBB..BBC.",
".CC...BBBB...CC.",
"......bBBb......",
".....BB..BB.....",
"....BB....BB....",
"....BB....BB....",
"...bB......Bb...",
"...BB......BB...",
"..CC........CC..",
]);
// Collapsing forward.
let die_a = Sprite::from_art(&[
"................",
"................",
"................",
"...H........H...",
"...HHBBBBBBHH...",
"..bBBBBBBBBBBb..",
"..RBMMBBBBMMBR..",
"..rBBBBBBBBBBr..",
"...BbMMMMMMbB...",
"..RbBBBBBBBBbR..",
".CBBBBBBBBBBBBC.",
".CBb.BBBBBB.bBC.",
".CC..BBBBBB..CC.",
"....bBBBBBBb....",
"...RBB....BBR...",
"...BB......BB...",
"..rB........Br..",
"..BB........BB..",
".RCC........CCR.",
"................",
]);
let die_b = Sprite::from_art(&[
"................",
"................",
"................",
"................",
"................",
"................",
"................",
"....H......H....",
"...HHBBBBBHH....",
"..RbBBBBBBBbR...",
"..rBMMBBBMMBr...",
"..RBBBBBBBBBR...",
".RbBBBBBBBBBbR..",
".CBBBBBBBBBBBC..",
".CBbRBBBBBBRbC..",
"..RR.BBBBB.RR...",
"...RbBBBBBbR....",
"..RRBBBBBBRR....",
".rRRRbBBbRRRr...",
"................",
]);
// Flat smear on the floor.
let corpse = Sprite::from_art(&[
"................",
"................",
"................",
"................",
"................",
"................",
"................",
"................",
"................",
"................",
"................",
"................",
"................",
"....rr..r.......",
"..rRRrrRRr.r....",
".rRbBBBBbRRrr...",
"rRRBbHbBBbRRRr..",
".rrRRbBBbRRrr...",
"..r.rRRRRr.r....",
"................",
]);
ImpSprites {
walk_a,
walk_b,
attack,
pain,
die_a,
die_b,
corpse,
}
}
/// Gun frame set (drawn bottom-center, view-model style).
pub(super) struct GunSprites {
pub idle: Sprite,
pub fire: Sprite,
}
pub(super) fn build_gun_sprites() -> GunSprites {
// 24x18 pistol held in a gloved hand, slightly right of center.
let idle = Sprite::from_art(&[
"........................",
"..........WGG...........",
".........GGGGG..........",
".........gGGGGg.........",
".........gGGGGg.........",
".........gGGGGg.........",
"........gGGGGGGg........",
"........gGGGGGGg........",
".......gGGGGGGGGg.......",
".......sSGGGGGGSs.......",
"......sSSSGGGGSSSs......",
".....sSSSSSGGSSSSSs.....",
"....sSSSSSSSSSSSSSSs....",
"....sSSSSSSSSSSSSSs.....",
"...sSSSSSSSSSSSSSSs.....",
"...sSSSSSSSSSSSSSs......",
"..sSSSSSSSSSSSSSSs......",
"..sSSSSSSSSSSSSSs.......",
]);
let fire = Sprite::from_art(&[
".........fFFf...........",
"........fFFFFf..........",
".......fFFFFFFf.........",
"........fFFFFf..........",
".........FGGF...........",
".........gGGGGg.........",
"........gGGGGGGg........",
"........gGGGGGGg........",
".......gGGGGGGGGg.......",
".......sSGGGGGGSs.......",
"......sSSSGGGGSSSs......",
".....sSSSSSGGSSSSSs.....",
"....sSSSSSSSSSSSSSSs....",
"....sSSSSSSSSSSSSSs.....",
"...sSSSSSSSSSSSSSSs.....",
"...sSSSSSSSSSSSSSs......",
"..sSSSSSSSSSSSSSSs......",
"..sSSSSSSSSSSSSSs.......",
]);
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.
pub(super) fn glyph5x7(ch: char) -> [u8; 7] {
match ch.to_ascii_uppercase() {
'A' => [
0b01110, 0b10001, 0b10001, 0b11111, 0b10001, 0b10001, 0b10001,
],
'B' => [
0b11110, 0b10001, 0b10001, 0b11110, 0b10001, 0b10001, 0b11110,
],
'C' => [
0b01110, 0b10001, 0b10000, 0b10000, 0b10000, 0b10001, 0b01110,
],
'D' => [
0b11110, 0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b11110,
],
'E' => [
0b11111, 0b10000, 0b10000, 0b11110, 0b10000, 0b10000, 0b11111,
],
'G' => [
0b01110, 0b10001, 0b10000, 0b10111, 0b10001, 0b10001, 0b01111,
],
'H' => [
0b10001, 0b10001, 0b10001, 0b11111, 0b10001, 0b10001, 0b10001,
],
'I' => [
0b11111, 0b00100, 0b00100, 0b00100, 0b00100, 0b00100, 0b11111,
],
'K' => [
0b10001, 0b10010, 0b10100, 0b11000, 0b10100, 0b10010, 0b10001,
],
'L' => [
0b10000, 0b10000, 0b10000, 0b10000, 0b10000, 0b10000, 0b11111,
],
'M' => [
0b10001, 0b11011, 0b10101, 0b10101, 0b10001, 0b10001, 0b10001,
],
'N' => [
0b10001, 0b11001, 0b10101, 0b10011, 0b10001, 0b10001, 0b10001,
],
'O' => [
0b01110, 0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b01110,
],
'P' => [
0b11110, 0b10001, 0b10001, 0b11110, 0b10000, 0b10000, 0b10000,
],
'R' => [
0b11110, 0b10001, 0b10001, 0b11110, 0b10100, 0b10010, 0b10001,
],
'S' => [
0b01111, 0b10000, 0b10000, 0b01110, 0b00001, 0b00001, 0b11110,
],
'T' => [
0b11111, 0b00100, 0b00100, 0b00100, 0b00100, 0b00100, 0b00100,
],
'U' => [
0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b01110,
],
'V' => [
0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b01010, 0b00100,
],
'Y' => [
0b10001, 0b10001, 0b01010, 0b00100, 0b00100, 0b00100, 0b00100,
],
'!' => [
0b00100, 0b00100, 0b00100, 0b00100, 0b00100, 0b00000, 0b00100,
],
'-' => [
0b00000, 0b00000, 0b00000, 0b01110, 0b00000, 0b00000, 0b00000,
],
_ => [0; 7],
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn textures_have_expected_size() {
for tex in build_textures() {
assert_eq!(tex.pixels.len(), TEX_SIZE * TEX_SIZE);
}
assert_eq!(build_floor_texture().pixels.len(), TEX_SIZE * TEX_SIZE);
assert_eq!(build_ceiling_texture().pixels.len(), TEX_SIZE * TEX_SIZE);
}
#[test]
fn xorshift_is_nondegenerate() {
// Zero seed must not collapse to the all-zero fixed point.
let mut rng = XorShift64::new(0);
assert!((0..4).map(|_| rng.next_u32()).any(|v| v != 0));
// Floats stay in [0, 1).
let mut rng = XorShift64::new(42);
for _ in 0..1000 {
let f = rng.next_f32();
assert!((0.0..1.0).contains(&f));
}
}
#[test]
fn every_sprite_palette_char_is_used_in_art() {
// Keep the palette free of dead entries: every mapped char's color
// must appear in at least one sprite. Palette colors are distinct,
// so matching on color is equivalent to matching on char.
let mut used: std::collections::HashSet<Rgb> = std::collections::HashSet::new();
let imps = build_imp_sprites();
let guns = build_gun_sprites();
for sprite in [
&imps.walk_a,
&imps.walk_b,
&imps.attack,
&imps.pain,
&imps.die_a,
&imps.die_b,
&imps.corpse,
&guns.idle,
&guns.fire,
] {
used.extend(sprite.pixels.iter().flatten());
}
for ch in b"BbHEMTCRrGgWSsFf" {
let color = sprite_color(*ch).expect("palette char must map to a color");
assert!(
used.contains(&color),
"palette char {:?} is mapped but unused in art",
*ch as char
);
}
}
#[test]
fn sprites_have_consistent_rows() {
// Construction debug-asserts equal row lengths; touch every frame.
let imps = build_imp_sprites();
for s in [
&imps.walk_a,
&imps.walk_b,
&imps.attack,
&imps.pain,
&imps.die_a,
&imps.die_b,
&imps.corpse,
] {
assert_eq!(s.w, 16);
assert_eq!(s.h, 20);
// Sampling corners must not panic.
let _ = s.sample(0.0, 0.0);
let _ = s.sample(0.999, 0.999);
}
let guns = build_gun_sprites();
assert_eq!(guns.idle.w, 24);
assert_eq!(guns.fire.w, 24);
}
#[test]
fn font_covers_required_strings() {
// Every character used by in-game screens must have a glyph.
for text in [
"GBOOM",
"KNEE-DEEP IN THE TOKENS",
"PRESS ANY KEY",
"YOU DIED",
"VICTORY!",
] {
for ch in text.chars().filter(|c| *c != ' ') {
assert_ne!(
glyph5x7(ch),
[0; 7],
"missing glyph for {ch:?} used in {text:?}"
);
}
}
}
}
@@ -0,0 +1,728 @@
//! Software renderer for the `/gboom` easter egg.
//!
//! Grid raycaster (Lodev-style DDA): textured walls, floor, and ceiling
//! with distance fog, billboard sprites with a 1D depth buffer, a
//! view-model gun, and full-frame effects (muzzle light, damage flash,
//! vignette). Also renders the title/end screens (animated fire + text).
//!
//! Everything draws into a plain RGB8 framebuffer the caller PNG-encodes
//! for the kitty graphics protocol.
use super::assets::{self, GunSprites, ImpSprites, Rgb, TEX_SIZE, Texture, XorShift64};
use super::game::{Game, ImpVisual};
/// Distance fog factor: shade = 1 / (1 + dist * FOG).
const FOG: f32 = 0.16;
/// Sprite height in world units (walls are 1.0 tall).
const IMP_WORLD_HEIGHT: f32 = 0.72;
/// Camera half-FOV tangent (0.66 ≈ the classic 66° FOV).
const PLANE_LEN: f32 = 0.66;
/// Corner-vignette strength (0 = none); subtle, ~0.8 at the extreme corners.
const VIGNETTE: f32 = 0.11;
/// RGB framebuffer with reusable scratch buffers.
pub(super) struct FrameBuffer {
pub w: usize,
pub h: usize,
pub pixels: Vec<u8>, // RGB8, row-major
zbuf: Vec<f32>, // per-column wall depth
/// 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).
wall_top: Vec<i32>,
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.
vig_x: Vec<f32>,
vig_y: Vec<f32>,
}
impl FrameBuffer {
pub fn new() -> Self {
Self {
w: 0,
h: 0,
pixels: Vec::new(),
zbuf: Vec::new(),
wall_top: Vec::new(),
wall_bottom: Vec::new(),
sprite_order: Vec::new(),
vig_x: Vec::new(),
vig_y: Vec::new(),
}
}
pub fn resize(&mut self, w: usize, h: usize) {
if self.w == w && self.h == h {
return;
}
self.w = w;
self.h = h;
self.pixels.resize(w * h * 3, 0);
self.zbuf.resize(w, f32::MAX);
self.wall_top.resize(w, 0);
self.wall_bottom.resize(w, 0);
let axis = |i: usize, n: usize| {
let t = if n <= 1 {
0.0
} else {
2.0 * i as f32 / (n - 1) as f32 - 1.0
};
1.0 - VIGNETTE * t * t
};
self.vig_x = (0..w).map(|x| axis(x, w)).collect();
self.vig_y = (0..h).map(|y| axis(y, h)).collect();
}
#[inline]
fn put(&mut self, x: usize, y: usize, c: Rgb) {
let i = (y * self.w + x) * 3;
self.pixels[i] = c[0];
self.pixels[i + 1] = c[1];
self.pixels[i + 2] = c[2];
}
/// Multiply the pixel at `(x, y)` by `f` (used for sprite shadows).
#[inline]
fn darken(&mut self, x: usize, y: usize, f: f32) {
let i = (y * self.w + x) * 3;
self.pixels[i] = (self.pixels[i] as f32 * f) as u8;
self.pixels[i + 1] = (self.pixels[i + 1] as f32 * f) as u8;
self.pixels[i + 2] = (self.pixels[i + 2] as f32 * f) as u8;
}
/// Darken the frame toward the corners. Applied to the world (before
/// the view-model gun, which stays crisp).
fn apply_vignette(&mut self) {
for y in 0..self.h {
let vy = self.vig_y[y];
let row = &mut self.pixels[y * self.w * 3..(y + 1) * self.w * 3];
for (x, px) in row.chunks_exact_mut(3).enumerate() {
let f = self.vig_x[x] * vy;
px[0] = (px[0] as f32 * f) as u8;
px[1] = (px[1] as f32 * f) as u8;
px[2] = (px[2] as f32 * f) as u8;
}
}
}
}
#[inline]
fn shade(c: Rgb, f: f32) -> Rgb {
[
(c[0] as f32 * f).min(255.0) as u8,
(c[1] as f32 * f).min(255.0) as u8,
(c[2] as f32 * f).min(255.0) as u8,
]
}
#[inline]
fn lerp_color(a: Rgb, b: Rgb, t: f32) -> Rgb {
let t = t.clamp(0.0, 1.0);
[
(a[0] as f32 + (b[0] as f32 - a[0] as f32) * t) as u8,
(a[1] as f32 + (b[1] as f32 - a[1] as f32) * t) as u8,
(a[2] as f32 + (b[2] as f32 - a[2] as f32) * t) as u8,
]
}
/// All immutable render resources, built once per game.
pub(super) struct Renderer {
textures: Vec<Texture>,
floor: Texture,
ceiling: Texture,
imps: ImpSprites,
guns: GunSprites,
}
impl Renderer {
pub fn new() -> Self {
Self {
textures: assets::build_textures(),
floor: assets::build_floor_texture(),
ceiling: assets::build_ceiling_texture(),
imps: assets::build_imp_sprites(),
guns: assets::build_gun_sprites(),
}
}
/// Render one gameplay frame into `fb`.
pub fn render_game(&self, fb: &mut FrameBuffer, game: &Game) {
let (w, h) = (fb.w, fb.h);
if w == 0 || h == 0 {
return;
}
// Muzzle flash briefly lights the whole scene.
let light_boost = if game.player.muzzle > 0.0 { 1.35 } else { 1.0 };
// Walls first: they record per-column strip bounds + depth, letting
// the floor/ceiling pass skip the pixels they cover (no double-write).
self.draw_walls(fb, game, light_boost);
self.draw_floor_ceiling(fb, game, light_boost);
self.draw_imps(fb, game, light_boost);
// World-only vignette: the view-model gun stays crisp on top.
fb.apply_vignette();
self.draw_gun(fb, game);
// Damage flash: flat blend of the whole frame toward red, decaying
// with `damage_flash`. Reads clearly even at low resolutions.
if game.player.damage_flash > 0.0 {
let t = (game.player.damage_flash * 0.45).min(0.45);
for px in fb.pixels.chunks_exact_mut(3) {
px[0] = (px[0] as f32 + (220.0 - px[0] as f32) * t) as u8;
px[1] = (px[1] as f32 * (1.0 - t * 0.8)) as u8;
px[2] = (px[2] as f32 * (1.0 - t * 0.8)) as u8;
}
}
// Low-health vignette pulse.
if game.player.hp <= 25 && !game.dead() {
let pulse = 0.10 + 0.06 * (game.time * 5.0).sin();
for px in fb.pixels.chunks_exact_mut(3) {
px[0] = (px[0] as f32 + (160.0 - px[0] as f32) * pulse) as u8;
}
}
}
/// Perspective-correct textured floor and ceiling (Lodev scanline
/// casting): each screen row below/above the horizon maps to one
/// world-space distance, so texels are sampled by stepping world
/// coordinates across the row. Distance fog matches the wall pass,
/// making the whole scene recede uniformly into darkness.
///
/// Runs after `draw_walls` and skips pixels inside each column's wall
/// strip — the world coords still step every pixel (to stay aligned),
/// but the texture sample/shade/write are elided where a wall covers.
fn draw_floor_ceiling(&self, fb: &mut FrameBuffer, game: &Game, light: f32) {
let (w, h) = (fb.w, fb.h);
let p = &game.player;
let (dir_x, dir_y) = p.dir();
let (plane_x, plane_y) = (-dir_y * PLANE_LEN, dir_x * PLANE_LEN);
// Leftmost and rightmost camera rays of the view frustum.
let (ray0_x, ray0_y) = (dir_x - plane_x, dir_y - plane_y);
let (ray1_x, ray1_y) = (dir_x + plane_x, dir_y + plane_y);
let cam_z = 0.5 * h as f32;
for y in h / 2..h {
// Rows at the horizon map to (near-)infinite distance.
let row = (y - h / 2).max(1) as f32;
let row_dist = cam_z / row;
let fog = (1.0 / (1.0 + row_dist * FOG)) * light;
let step_x = row_dist * (ray1_x - ray0_x) / w as f32;
let step_y = row_dist * (ray1_y - ray0_y) / w as f32;
let mut world_x = p.x + row_dist * ray0_x;
let mut world_y = p.y + row_dist * ray0_y;
// The ceiling row at the same distance mirrors across the
// horizon (camera eye is at half wall height).
let ceil_y = h - 1 - y;
let (yi, ceil_yi) = (y as i32, ceil_y as i32);
for x in 0..w {
let (wx, wy) = (world_x, world_y);
world_x += step_x;
world_y += step_y;
// Skip pixels the wall strip already filled this column. The
// texel coords are computed lazily, so the central wall band
// (where both are covered) costs only the world-coord step.
let floor_vis = yi >= fb.wall_bottom[x];
let ceil_vis = ceil_yi < fb.wall_top[x];
if !(floor_vis || ceil_vis) {
continue;
}
let tx = (wx.rem_euclid(1.0) * TEX_SIZE as f32) as usize;
let ty = (wy.rem_euclid(1.0) * TEX_SIZE as f32) as usize;
if floor_vis {
fb.put(x, y, shade(self.floor.sample(tx, ty), fog));
}
if ceil_vis {
fb.put(x, ceil_y, shade(self.ceiling.sample(tx, ty), fog));
}
}
}
}
fn draw_walls(&self, fb: &mut FrameBuffer, game: &Game, light: f32) {
let (w, h) = (fb.w, fb.h);
let p = &game.player;
let (dir_x, dir_y) = p.dir();
let (plane_x, plane_y) = (-dir_y * PLANE_LEN, dir_x * PLANE_LEN);
for x in 0..w {
let camera_x = 2.0 * x as f32 / w as f32 - 1.0;
let rd_x = dir_x + plane_x * camera_x;
let rd_y = dir_y + plane_y * camera_x;
let mut map_x = p.x.floor() as i32;
let mut map_y = p.y.floor() as i32;
let delta_x = if rd_x == 0.0 {
f32::MAX
} else {
(1.0 / rd_x).abs()
};
let delta_y = if rd_y == 0.0 {
f32::MAX
} else {
(1.0 / rd_y).abs()
};
let (step_x, mut side_x) = if rd_x < 0.0 {
(-1, (p.x - map_x as f32) * delta_x)
} else {
(1, (map_x as f32 + 1.0 - p.x) * delta_x)
};
let (step_y, mut side_y) = if rd_y < 0.0 {
(-1, (p.y - map_y as f32) * delta_y)
} else {
(1, (map_y as f32 + 1.0 - p.y) * delta_y)
};
// DDA until a solid cell. The map border is fully solid, so
// bound the loop defensively rather than trusting it blindly.
let mut side = 0;
let mut tex_id = 1u8;
for _ in 0..256 {
if side_x < side_y {
side_x += delta_x;
map_x += step_x;
side = 0;
} else {
side_y += delta_y;
map_y += step_y;
side = 1;
}
let cell = game.map.cell(map_x, map_y);
if cell != 0 {
tex_id = cell;
break;
}
}
let perp = if side == 0 {
(map_x as f32 - p.x + (1 - step_x) as f32 / 2.0) / rd_x
} else {
(map_y as f32 - p.y + (1 - step_y) as f32 / 2.0) / rd_y
};
let perp = perp.max(1e-4);
fb.zbuf[x] = perp;
let line_h = (h as f32 / perp) as i32;
let draw_start = ((h as i32 - line_h) / 2).max(0);
let draw_end = ((h as i32 + line_h) / 2).min(h as i32);
// Record the strip so draw_floor_ceiling skips these rows.
fb.wall_top[x] = draw_start;
fb.wall_bottom[x] = draw_end;
// Texture column.
let wall_x = if side == 0 {
p.y + perp * rd_y
} else {
p.x + perp * rd_x
};
let wall_x = wall_x - wall_x.floor();
let mut tex_x = (wall_x * TEX_SIZE as f32) as usize;
if (side == 0 && rd_x > 0.0) || (side == 1 && rd_y < 0.0) {
tex_x = TEX_SIZE - 1 - tex_x.min(TEX_SIZE - 1);
}
let texture = &self.textures[(tex_id as usize - 1).min(self.textures.len() - 1)];
let side_shade = if side == 1 { 0.72 } else { 1.0 };
let fog_shade = (1.0 / (1.0 + perp * FOG)) * side_shade * light;
let tex_step = TEX_SIZE as f32 / line_h.max(1) as f32;
let mut tex_pos = (draw_start as f32 - h as f32 / 2.0 + line_h as f32 / 2.0) * tex_step;
for y in draw_start..draw_end {
let tex_y = (tex_pos as usize).min(TEX_SIZE - 1);
tex_pos += tex_step;
let c = shade(texture.sample(tex_x, tex_y), fog_shade);
fb.put(x, y as usize, c);
}
}
}
fn draw_imps(&self, fb: &mut FrameBuffer, game: &Game, light: f32) {
let (w, h) = (fb.w, fb.h);
let p = &game.player;
let (dir_x, dir_y) = p.dir();
let (plane_x, plane_y) = (-dir_y * PLANE_LEN, dir_x * PLANE_LEN);
let inv_det = 1.0 / (plane_x * dir_y - dir_x * plane_y);
// Painter's order: far → near. The order buffer lives on the
// framebuffer so the 30 fps render loop stays allocation-free;
// it is taken out for the duration of the draw because the loop
// body needs `fb` mutably.
let mut order = std::mem::take(&mut fb.sprite_order);
order.clear();
order.extend(game.imps.iter().enumerate().map(|(i, imp)| {
let d2 = (imp.x - p.x).powi(2) + (imp.y - p.y).powi(2);
(i, d2)
}));
order.sort_unstable_by(|a, b| b.1.total_cmp(&a.1));
for &(i, _) in &order {
let imp = &game.imps[i];
let rel_x = imp.x - p.x;
let rel_y = imp.y - p.y;
// Camera-space transform: ty = forward depth, tx = lateral.
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
}
let sprite = match imp.visual() {
ImpVisual::WalkA => &self.imps.walk_a,
ImpVisual::WalkB => &self.imps.walk_b,
ImpVisual::Attack => &self.imps.attack,
ImpVisual::Pain => &self.imps.pain,
ImpVisual::DieA => &self.imps.die_a,
ImpVisual::DieB => &self.imps.die_b,
ImpVisual::Corpse => &self.imps.corpse,
};
let screen_x = (w as f32 / 2.0) * (1.0 + tx / ty);
// Vertical span from world heights [0, IMP_WORLD_HEIGHT] with the
// camera eye at 0.5: y(world_z) = h/2 + (0.5 - z) * h / ty.
let y_feet = h as f32 / 2.0 + 0.5 * h as f32 / ty;
let y_head = h as f32 / 2.0 + (0.5 - IMP_WORLD_HEIGHT) * h as f32 / ty;
let sprite_h = (y_feet - y_head).max(1.0);
let sprite_w = sprite_h * sprite.w as f32 / sprite.h as f32;
// Small vertical bob while walking sells the gait.
let bob_px = imp.walk_bob().map_or(0.0, |phase| phase * sprite_h * 0.02);
let x0 = (screen_x - sprite_w / 2.0).floor() as i32;
let x1 = (screen_x + sprite_w / 2.0).ceil() as i32;
let y0 = (y_head + bob_px).floor() as i32;
let y1 = (y_feet + bob_px).ceil() as i32;
let fog_shade = (1.0 / (1.0 + ty * FOG)) * light;
// Soft elliptical contact shadow under standing demons. Drawn
// before the body, z-tested per column like the body.
if !matches!(imp.visual(), ImpVisual::Corpse) {
draw_contact_shadow(fb, screen_x, y_feet, sprite_w, sprite_h, ty);
}
// Pain frames flash toward white so hits register instantly.
let pain_flash = imp.visual() == ImpVisual::Pain;
for sx in x0.max(0)..x1.min(w as i32) {
if fb.zbuf[sx as usize] <= ty {
continue; // occluded by a wall
}
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) {
let v = (sy as f32 - y0 as f32) / (y1 - y0).max(1) as f32;
if let Some(c) = sprite.sample(u, v) {
// Glowing eyes ignore fog; everything else fades.
let mut lit = if c == assets::EYE_GLOW {
c
} else {
shade(c, fog_shade)
};
if pain_flash {
lit = lerp_color(lit, [255, 255, 255], 0.40);
}
fb.put(sx as usize, sy as usize, lit);
}
}
}
}
fb.sprite_order = order;
}
fn draw_gun(&self, fb: &mut FrameBuffer, game: &Game) {
let (w, h) = (fb.w, fb.h);
let sprite = if game.player.muzzle > 0.0 {
&self.guns.fire
} else {
&self.guns.idle
};
// Gun occupies ~42% of frame height, bottom-center, with walk bob.
let gun_h = (h as f32 * 0.42) as i32;
let gun_w = gun_h * sprite.w as i32 / sprite.h as i32;
let bob_x = (game.player.bob * 1.7).sin() * w as f32 * 0.012;
let bob_y = (game.player.bob * 3.4).cos().abs() * h as f32 * 0.018;
let x0 = w as i32 / 2 - gun_w / 2 + bob_x as i32;
let y0 = h as i32 - gun_h + bob_y as i32;
for sy in y0.max(0)..h as i32 {
let v = (sy - y0) as f32 / gun_h.max(1) as f32;
for sx in x0.max(0)..(x0 + gun_w).min(w as i32) {
let u = (sx - x0) as f32 / gun_w.max(1) as f32;
if let Some(c) = sprite.sample(u, v) {
fb.put(sx as usize, sy as usize, c);
}
}
}
// Crosshair.
let (cx, cy) = (w / 2, h / 2);
// Aim feedback: the crosshair turns red over a hittable demon and
// gains a center dot.
let on_target = game.target_in_crosshair().is_some();
let ch_c: Rgb = if on_target {
assets::GBOOM_RED
} else {
[210, 210, 210]
};
for d in 2..5usize {
if cx >= d && cx + d < w {
fb.put(cx - d, cy, ch_c);
fb.put(cx + d, cy, ch_c);
}
if cy >= d && cy + d < h {
fb.put(cx, cy - d, ch_c);
fb.put(cx, cy + d, ch_c);
}
}
if on_target {
fb.put(cx, cy, ch_c);
}
}
}
/// Soft elliptical contact shadow at a sprite's feet, z-tested per column
/// with the sprite's own depth so walls still occlude it.
fn draw_contact_shadow(
fb: &mut FrameBuffer,
center_x: f32,
y_feet: f32,
sprite_w: f32,
sprite_h: f32,
depth: f32,
) {
let (w, h) = (fb.w, fb.h);
let rx = (sprite_w * 0.38).max(1.0);
let ry = (sprite_h * 0.05).max(1.5);
let x0 = (center_x - rx).floor() as i32;
let x1 = (center_x + rx).ceil() as i32;
let y0 = (y_feet - ry).floor() as i32;
let y1 = (y_feet + ry).ceil() as i32;
for sx in x0.max(0)..x1.min(w as i32) {
if fb.zbuf[sx as usize] <= depth {
continue;
}
let nx = (sx as f32 - center_x) / rx;
for sy in y0.max(0)..y1.min(h as i32) {
let ny = (sy as f32 - y_feet) / ry;
let r2 = nx * nx + ny * ny;
if r2 < 1.0 {
// Darkest at the center, fading out toward the rim.
fb.darken(sx as usize, sy as usize, 0.55 + 0.45 * r2);
}
}
}
}
// -------------------------------------------------------------------------
// 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.
pub(super) struct FireSim {
w: usize,
h: usize,
heat: Vec<u8>,
rng: XorShift64,
}
const FIRE_MAX: u8 = 36;
impl FireSim {
pub fn new() -> Self {
let (w, h) = (160, 84);
let mut heat = vec![0u8; w * h];
// Bottom row is the white-hot source.
for x in 0..w {
heat[(h - 1) * w + x] = FIRE_MAX;
}
Self {
w,
h,
heat,
rng: XorShift64::new(0xDEAD_BEEF_CAFE_F00D),
}
}
/// One simulation step: heat propagates upward with random decay/drift.
pub fn step(&mut self) {
for y in 1..self.h {
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 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;
}
}
}
fn palette(heat: u8) -> Rgb {
// Black → deep red → orange → yellow → white.
let t = heat as f32 / FIRE_MAX as f32;
if t < 0.02 {
[7, 7, 9]
} else if t < 0.4 {
lerp_color([24, 8, 6], [180, 30, 10], t / 0.4)
} else if t < 0.75 {
lerp_color([180, 30, 10], [240, 150, 30], (t - 0.4) / 0.35)
} else {
lerp_color([240, 150, 30], [255, 250, 200], (t - 0.75) / 0.25)
}
}
/// Draw the fire across the bottom `frac` of the framebuffer.
pub fn draw(&self, fb: &mut FrameBuffer, frac: f32) {
let (w, h) = (fb.w, fb.h);
if w == 0 || h == 0 {
return;
}
let fire_h = (h as f32 * frac) as usize;
let y_start = h - fire_h.min(h);
for y in y_start..h {
let fy = (y - y_start) * self.h / fire_h.max(1);
for x in 0..w {
let fx = x * self.w / w;
let heat = self.heat[fy.min(self.h - 1) * self.w + fx.min(self.w - 1)];
if heat > 1 {
fb.put(x, y, Self::palette(heat));
}
}
}
}
}
/// Fill the framebuffer with a flat color.
pub(super) fn clear(fb: &mut FrameBuffer, c: Rgb) {
for px in fb.pixels.chunks_exact_mut(3) {
px.copy_from_slice(&c);
}
}
/// Measure the pixel width of `text` at `scale` (5x7 glyphs, 1px tracking).
pub(super) fn text_width(text: &str, scale: usize) -> usize {
text.chars().count() * 6 * scale
}
/// Draw 5x7 pixel text with its top-left at `(x0, y0)`.
pub(super) fn draw_text(fb: &mut FrameBuffer, text: &str, x0: i32, y0: i32, scale: usize, c: Rgb) {
let mut pen_x = x0;
for ch in text.chars() {
let glyph = assets::glyph5x7(ch);
for (row, bits) in glyph.iter().enumerate() {
for col in 0..5 {
if bits & (1 << (4 - col)) == 0 {
continue;
}
for dy in 0..scale {
for dx in 0..scale {
let px = pen_x + (col * scale + dx) as i32;
let py = y0 + (row * scale + dy) as i32;
if px >= 0 && py >= 0 && (px as usize) < fb.w && (py as usize) < fb.h {
fb.put(px as usize, py as usize, c);
}
}
}
}
}
pen_x += (6 * scale) as i32;
}
}
/// Draw centered text with an 8-direction outline, which keeps the chunky
/// font legible over the animated fire background.
pub(super) fn draw_text_centered_outlined(
fb: &mut FrameBuffer,
text: &str,
y0: i32,
scale: usize,
c: Rgb,
outline: Rgb,
) {
let x0 = (fb.w as i32 - text_width(text, scale) as i32) / 2;
let o = (scale as i32 / 2).max(1);
for (dx, dy) in [
(-o, -o),
(0, -o),
(o, -o),
(-o, 0),
(o, 0),
(-o, o),
(0, o),
(o, o),
] {
draw_text(fb, text, x0 + dx, y0 + dy, scale, outline);
}
draw_text(fb, text, x0, y0, scale, c);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn render_game_fills_framebuffer() {
let renderer = Renderer::new();
let mut fb = FrameBuffer::new();
fb.resize(320, 200);
renderer.render_game(&mut fb, &Game::new());
// The floor/ceiling pass paints the full frame, so it can't be all-zero.
assert!(fb.pixels.iter().any(|&b| b != 0));
assert_eq!(fb.pixels.len(), 320 * 200 * 3);
}
#[test]
fn render_survives_extreme_sizes() {
let renderer = Renderer::new();
let game = Game::new();
let mut fb = FrameBuffer::new();
for (w, h) in [(1usize, 1usize), (2, 2), (16, 8), (639, 401)] {
fb.resize(w, h);
renderer.render_game(&mut fb, &game);
}
}
#[test]
fn zbuffer_occludes_sprites_behind_walls() {
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.
for imp in &mut game.imps {
imp.x = game.player.x - 8.0;
imp.y = game.player.y;
}
let mut fb = FrameBuffer::new();
fb.resize(160, 100);
renderer.render_game(&mut fb, &game);
let before = fb.pixels.clone();
let (dx, dy) = game.player.dir();
game.imps[0].x = game.player.x + dx * 1.5;
game.imps[0].y = game.player.y + dy * 1.5;
renderer.render_game(&mut fb, &game);
assert_ne!(before, fb.pixels, "visible imp must change the frame");
}
#[test]
fn fire_sim_burns_upward() {
let mut fire = FireSim::new();
for _ in 0..60 {
fire.step();
}
// After enough steps some heat must exist above the source row.
let above: u32 = (0..fire.w)
.map(|x| fire.heat[(fire.h / 2) * fire.w + x] as u32)
.sum();
assert!(above > 0, "fire should propagate upward");
}
}
@@ -0,0 +1,916 @@
//! World simulation for the `/gboom` easter egg: map, momentum-based player
//! movement, imp AI, and hitscan combat.
//!
//! `step(dt)` advances by wall-clock time, so the game plays identically at
//! any frame rate.
/// One hand-authored level. Digits are walls and pick the texture:
/// `1` brick, `2` stone, `3` tech, `4` hellstone. `.` is floor, `P` the
/// player start, `I` an imp spawn. Spawn reachability is enforced by test.
const MAP_ART: &[&str] = &[
"1111111111111111111111",
"1P...1....2......2...1",
"1....1.22.2.3333.2.I.1",
"1....1.2..2.3..3.2...1",
"1.11.1.2.I2.3.I3.222.1",
"1.1..1.2..2.33.3...2.1",
"1.1..1.22.2....3.2.2.1",
"1.1......2..33.3.2...1",
"1.111111.2.I3..32222.1",
"1......1.2..3333.....1",
"144444.1.2........11.1",
"1....4.1.22222222..1.1",
"1.I..4.1........2.I1.1",
"1....4.11111111.2..1.1",
"1.4444.......41.2222.1",
"1.4..444444..41......1",
"1.4.I......I.41.111111",
"1.4..444444..4....I..1",
"1.444........4.11....1",
"1...44444444.4.1..1111",
"1............4.1.....1",
"1111111111111111111111",
];
/// Player collision radius, in tiles. A touch under a quarter-tile so the
/// 1-tile-wide corridors have comfortable clearance.
const PLAYER_RADIUS: f32 = 0.20;
/// Imp collision radius, in tiles.
const IMP_RADIUS: f32 = 0.30;
/// Movement tuning — continuous "held" model.
///
/// With no key-release events, each press/repeat refreshes a per-control
/// 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
/// 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;
const TURN_ACCEL_TAU: f32 = 0.07;
/// How long after each press/repeat a control stays "held". Must exceed
/// the slowest expected key-repeat interval (≈3060 ms) so motion never
/// stutters between repeats, while staying short enough that releasing
/// stops promptly.
const HOLD_WINDOW: f32 = 0.16;
const PLAYER_MAX_HP: i32 = 100;
const FIRE_COOLDOWN: f32 = 0.32;
const MUZZLE_TIME: f32 = 0.09;
/// Hitscan half-width: an imp is hit when its center is within this
/// perpendicular distance of the aim ray.
const HIT_WIDTH: f32 = 0.33;
const PISTOL_DAMAGE: i32 = 11;
const IMP_HP: i32 = 30;
const IMP_SPEED: f32 = 1.55;
const IMP_SIGHT_RANGE: f32 = 9.0;
const IMP_MELEE_RANGE: f32 = 0.95;
const IMP_WINDUP: f32 = 0.38;
const IMP_ATTACK_COOLDOWN: f32 = 0.95;
const IMP_PAIN_TIME: f32 = 0.28;
const IMP_DEATH_TIME: f32 = 0.55;
const IMP_BITE_DAMAGE: i32 = 7;
/// Grid map with texture-id cells.
pub(super) struct Map {
pub w: usize,
pub h: usize,
cells: Vec<u8>, // 0 = floor, 1..=4 = wall texture id
}
impl Map {
/// Wall texture id at a cell, or 0 for floor. Out of bounds is solid.
#[inline]
pub fn cell(&self, x: i32, y: i32) -> u8 {
if x < 0 || y < 0 || x >= self.w as i32 || y >= self.h as i32 {
return 1;
}
self.cells[y as usize * self.w + x as usize]
}
#[inline]
pub fn solid(&self, x: i32, y: i32) -> bool {
self.cell(x, y) != 0
}
/// Whether a circle of `radius` at `(x, y)` overlaps any solid cell.
fn blocked(&self, x: f32, y: f32, radius: f32) -> bool {
let min_x = (x - radius).floor() as i32;
let max_x = (x + radius).floor() as i32;
let min_y = (y - radius).floor() as i32;
let max_y = (y + radius).floor() as i32;
for cy in min_y..=max_y {
for cx in min_x..=max_x {
if self.solid(cx, cy) {
return true;
}
}
}
false
}
/// Line-of-sight check between two points (wall occlusion only).
/// Standard DDA over grid cells.
pub fn los(&self, x0: f32, y0: f32, x1: f32, y1: f32) -> bool {
let dx = x1 - x0;
let dy = y1 - y0;
let dist = (dx * dx + dy * dy).sqrt();
if dist < 1e-4 {
return true;
}
let (rdx, rdy) = (dx / dist, dy / dist);
let mut map_x = x0.floor() as i32;
let mut map_y = y0.floor() as i32;
let delta_x = if rdx == 0.0 {
f32::MAX
} else {
(1.0 / rdx).abs()
};
let delta_y = if rdy == 0.0 {
f32::MAX
} else {
(1.0 / rdy).abs()
};
let (step_x, mut side_x) = if rdx < 0.0 {
(-1, (x0 - map_x as f32) * delta_x)
} else {
(1, (map_x as f32 + 1.0 - x0) * delta_x)
};
let (step_y, mut side_y) = if rdy < 0.0 {
(-1, (y0 - map_y as f32) * delta_y)
} else {
(1, (map_y as f32 + 1.0 - y0) * delta_y)
};
loop {
let travelled = if side_x < side_y {
map_x += step_x;
let t = side_x;
side_x += delta_x;
t
} else {
map_y += step_y;
let t = side_y;
side_y += delta_y;
t
};
if travelled >= dist {
return true;
}
if self.solid(map_x, map_y) {
return false;
}
}
}
}
/// Player state.
pub(super) struct Player {
pub x: f32,
pub y: f32,
pub angle: f32,
/// Forward velocity (negative = backpedal), tiles/s.
pub vel_forward: f32,
/// Strafe velocity (positive = right), tiles/s.
pub vel_strafe: f32,
/// Angular velocity, rad/s (positive = turn right).
pub vel_rot: f32,
pub hp: i32,
/// Seconds until the pistol can fire again.
fire_cooldown: f32,
/// Remaining muzzle-flash display time.
pub muzzle: f32,
/// Damage flash intensity, decays to 0.
pub damage_flash: f32,
/// Accumulated distance for view/gun bobbing.
pub bob: f32,
}
impl Player {
#[inline]
pub fn dir(&self) -> (f32, f32) {
(self.angle.cos(), self.angle.sin())
}
}
/// Movement controls, fed by key press/repeat events. The discriminants
/// double as indices into [`Game::hold`], so keep them field-less.
#[derive(Clone, Copy, Debug)]
pub(super) enum Control {
Forward,
Back,
TurnLeft,
TurnRight,
StrafeLeft,
StrafeRight,
}
impl Control {
/// Number of controls — the size of the hold-timer array.
const COUNT: usize = 6;
}
/// What an imp is doing.
#[derive(Clone, Copy, Debug, PartialEq)]
pub(super) enum ImpState {
Idle,
Chasing,
/// Wind-up before a melee hit; `t` counts down.
Attacking {
t: f32,
},
Pain {
t: f32,
},
Dying {
t: f32,
},
Dead,
}
/// Which sprite frame to draw for an imp.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum ImpVisual {
WalkA,
WalkB,
Attack,
Pain,
DieA,
DieB,
Corpse,
}
pub(super) struct Imp {
pub x: f32,
pub y: f32,
pub hp: i32,
pub state: ImpState,
/// Walk-cycle clock.
anim: f32,
/// Seconds until the next melee attempt is allowed.
attack_cooldown: f32,
}
impl Imp {
pub fn alive(&self) -> bool {
!matches!(self.state, ImpState::Dying { .. } | ImpState::Dead)
}
/// Walk-cycle bob phase in `[-1, 1]`, or `None` when not walking.
/// Drives a small vertical offset in the renderer.
pub fn walk_bob(&self) -> Option<f32> {
matches!(self.state, ImpState::Chasing).then(|| (self.anim * 9.0).sin())
}
pub fn visual(&self) -> ImpVisual {
match self.state {
ImpState::Idle => ImpVisual::WalkA,
ImpState::Chasing => {
if ((self.anim * 3.0) as u32).is_multiple_of(2) {
ImpVisual::WalkA
} else {
ImpVisual::WalkB
}
}
ImpState::Attacking { .. } => ImpVisual::Attack,
ImpState::Pain { .. } => ImpVisual::Pain,
ImpState::Dying { t } => {
if t > IMP_DEATH_TIME * 0.5 {
ImpVisual::DieA
} else {
ImpVisual::DieB
}
}
ImpState::Dead => ImpVisual::Corpse,
}
}
}
/// The whole game world.
pub(super) struct Game {
pub map: Map,
pub player: Player,
pub imps: Vec<Imp>,
pub kills: u32,
pub time: f32,
/// Per-control "held" countdown in seconds, indexed by `Control as
/// usize`. Positive means held. In timer mode each press/repeat
/// refreshes it to [`HOLD_WINDOW`] and `step` decrements it; in
/// release-aware mode a press latches it (and only [`Game::release`]
/// clears it), so several keys can be held at once.
hold: [f32; Control::COUNT],
/// Whether the terminal delivers key-release events (Kitty keyboard
/// protocol). When true, controls are latched on press and cleared on
/// release — enabling true simultaneous move + turn. When false, the
/// timer bridges the gaps between OS key-repeats for a single key.
release_aware: bool,
/// Set by [`Game::queue_fire`], consumed by the next `step`.
fire_queued: bool,
rng: super::assets::XorShift64,
}
impl Game {
pub fn new() -> Self {
let h = MAP_ART.len();
let w = MAP_ART[0].len();
let mut cells = vec![0u8; w * h];
let mut player_start = (1.5f32, 1.5f32);
let mut imps = Vec::new();
for (y, row) in MAP_ART.iter().enumerate() {
debug_assert_eq!(row.len(), w, "map rows must be equal length");
for (x, ch) in row.bytes().enumerate() {
let center = (x as f32 + 0.5, y as f32 + 0.5);
match ch {
b'1'..=b'4' => cells[y * w + x] = ch - b'0',
b'P' => player_start = center,
b'I' => imps.push(Imp {
x: center.0,
y: center.1,
hp: IMP_HP,
state: ImpState::Idle,
anim: (x * 7 + y * 13) as f32 * 0.1, // desync walk cycles
attack_cooldown: 0.0,
}),
_ => {}
}
}
}
Self {
map: Map { w, h, cells },
player: Player {
x: player_start.0,
y: player_start.1,
angle: 0.0,
vel_forward: 0.0,
vel_strafe: 0.0,
vel_rot: 0.0,
hp: PLAYER_MAX_HP,
fire_cooldown: 0.0,
muzzle: 0.0,
damage_flash: 0.0,
bob: 0.0,
},
imps,
kills: 0,
time: 0.0,
hold: [0.0; Control::COUNT],
release_aware: false,
fire_queued: false,
rng: super::assets::XorShift64::new(0x9E3779B97F4A7C15),
}
}
/// Switch between the release-aware and timer movement models. Set once
/// when the game opens, from the terminal's keyboard capability.
pub fn set_release_aware(&mut self, release_aware: bool) {
self.release_aware = release_aware;
}
pub fn total_imps(&self) -> u32 {
self.imps.len() as u32
}
pub fn won(&self) -> bool {
self.kills == self.total_imps()
}
pub fn dead(&self) -> bool {
self.player.hp <= 0
}
/// Register a press/repeat event for `control`. In release-aware mode
/// the control latches until [`Game::release`]; otherwise it stays held
/// for [`HOLD_WINDOW`] seconds. Velocity is applied in `step`.
pub fn press(&mut self, control: Control) {
self.hold[control as usize] = if self.release_aware {
f32::INFINITY
} else {
HOLD_WINDOW
};
}
/// Register a key-release for `control` (release-aware mode only).
pub fn release(&mut self, control: Control) {
self.hold[control as usize] = 0.0;
}
/// Un-latch every control. Called on focus loss so a release event
/// dropped while the window was unfocused can't latch movement forever.
pub fn release_all(&mut self) {
self.hold = [0.0; Control::COUNT];
}
/// Whether any movement control is currently held (latched or within
/// the repeat-bridging window). Used to assert hold-clearing behavior.
#[cfg(any(test, feature = "test-support"))]
pub fn any_held(&self) -> bool {
self.hold.iter().any(|&h| h > 0.0)
}
/// Request a pistol shot; fires on the next `step` if off cooldown.
pub fn queue_fire(&mut self) {
self.fire_queued = true;
}
/// Advance the world by `dt` seconds.
pub fn step(&mut self, dt: f32) {
self.time += dt;
self.step_player(dt);
if self.fire_queued {
self.fire_queued = false;
self.try_fire();
}
self.step_imps(dt);
}
fn step_player(&mut self, dt: f32) {
// Age the hold timers, then read which controls are still held.
for h in &mut self.hold {
*h = (*h - dt).max(0.0);
}
let held = |c: Control| self.hold[c as usize] > 0.0;
let axis = |pos: Control, neg: Control| (held(pos) as i32 - held(neg) as i32) as f32;
// Steady target velocities from the held controls. The
// forward/strafe pair is clamped to unit length so moving
// diagonally isn't faster than moving straight.
let mut fwd = axis(Control::Forward, Control::Back);
let mut strafe = axis(Control::StrafeRight, Control::StrafeLeft);
let mag = (fwd * fwd + strafe * strafe).sqrt();
if mag > 1.0 {
fwd /= mag;
strafe /= mag;
}
let target_forward = fwd * MOVE_SPEED;
let target_strafe = strafe * MOVE_SPEED;
let target_rot = axis(Control::TurnRight, Control::TurnLeft) * TURN_SPEED;
// Frame-rate-independent exponential smoothing toward the targets:
// a constant target while held means no sawtooth, and a zero target
// on release glides to a stop.
let move_blend = 1.0 - (-dt / MOVE_ACCEL_TAU).exp();
let turn_blend = 1.0 - (-dt / TURN_ACCEL_TAU).exp();
let p = &mut self.player;
p.vel_forward += (target_forward - p.vel_forward) * move_blend;
p.vel_strafe += (target_strafe - p.vel_strafe) * move_blend;
p.vel_rot += (target_rot - p.vel_rot) * turn_blend;
// Integrate rotation, then position.
p.angle += p.vel_rot * dt;
let (dx, dy) = p.dir();
// Strafe axis is dir rotated +90°.
let (sx, sy) = (-dy, dx);
let step_x = (dx * p.vel_forward + sx * p.vel_strafe) * dt;
let step_y = (dy * p.vel_forward + sy * p.vel_strafe) * dt;
// Axis-separated movement → slide along walls.
if !self.map.blocked(p.x + step_x, p.y, PLAYER_RADIUS) {
p.x += step_x;
}
if !self.map.blocked(p.x, p.y + step_y, PLAYER_RADIUS) {
p.y += step_y;
}
p.bob += (p.vel_forward.abs() + p.vel_strafe.abs()) * dt;
p.fire_cooldown = (p.fire_cooldown - dt).max(0.0);
p.muzzle = (p.muzzle - dt).max(0.0);
p.damage_flash = (p.damage_flash - dt * 1.8).max(0.0);
}
/// The live imp the pistol would hit right now: nearest one within
/// [`HIT_WIDTH`] of the aim ray with clear line of sight. Shared by
/// [`Game::try_fire`] and the renderer's crosshair feedback.
pub fn target_in_crosshair(&self) -> Option<usize> {
let (px, py) = (self.player.x, self.player.y);
let (dx, dy) = self.player.dir();
let mut best: Option<(usize, f32)> = None;
for (i, imp) in self.imps.iter().enumerate() {
if !imp.alive() {
continue;
}
let (rx, ry) = (imp.x - px, imp.y - py);
let along = rx * dx + ry * dy;
if along <= 0.0 {
continue;
}
let perp = (rx * dy - ry * dx).abs();
if perp > HIT_WIDTH {
continue;
}
if !self.map.los(px, py, imp.x, imp.y) {
continue;
}
if best.is_none_or(|(_, d)| along < d) {
best = Some((i, along));
}
}
best.map(|(i, _)| i)
}
/// Hitscan pistol: damage the imp under the crosshair, if any.
fn try_fire(&mut self) {
if self.player.fire_cooldown > 0.0 {
return;
}
self.player.fire_cooldown = FIRE_COOLDOWN;
self.player.muzzle = MUZZLE_TIME;
if let Some(i) = self.target_in_crosshair() {
let damage = PISTOL_DAMAGE + (self.rng.next_f32() * 7.0) as i32;
let imp = &mut self.imps[i];
imp.hp -= damage;
if imp.hp <= 0 {
imp.state = ImpState::Dying { t: IMP_DEATH_TIME };
self.kills += 1;
} else {
imp.state = ImpState::Pain { t: IMP_PAIN_TIME };
}
}
}
fn step_imps(&mut self, dt: f32) {
let (px, py) = (self.player.x, self.player.y);
let mut player_damage = 0;
for i in 0..self.imps.len() {
let (ix, iy, state) = {
let imp = &self.imps[i];
(imp.x, imp.y, imp.state)
};
// Corpses never act — skip the distance sqrt for them (late game
// is mostly corpses).
if matches!(state, ImpState::Dead) {
continue;
}
let dist = ((px - ix).powi(2) + (py - iy).powi(2)).sqrt();
match state {
ImpState::Idle => {
if dist < IMP_SIGHT_RANGE && self.map.los(ix, iy, px, py) {
self.imps[i].state = ImpState::Chasing;
}
}
ImpState::Chasing => {
self.imps[i].attack_cooldown = (self.imps[i].attack_cooldown - dt).max(0.0);
if dist < IMP_MELEE_RANGE {
if self.imps[i].attack_cooldown <= 0.0 {
self.imps[i].state = ImpState::Attacking { t: IMP_WINDUP };
}
} else {
// The walk cycle only advances while actually
// moving, so an imp waiting out its attack
// cooldown doesn't march in place.
self.imps[i].anim += dt;
self.chase_step(i, px, py, dist, dt);
}
}
ImpState::Attacking { t } => {
let t = t - dt;
if t <= 0.0 {
// Bite lands if the player is still close.
let imp = &mut self.imps[i];
imp.state = ImpState::Chasing;
imp.attack_cooldown = IMP_ATTACK_COOLDOWN;
if dist < IMP_MELEE_RANGE * 1.25 {
player_damage += IMP_BITE_DAMAGE + (self.rng.next_f32() * 5.0) as i32;
}
} else {
self.imps[i].state = ImpState::Attacking { t };
}
}
ImpState::Pain { t } => {
let t = t - dt;
self.imps[i].state = if t <= 0.0 {
ImpState::Chasing
} else {
ImpState::Pain { t }
};
}
ImpState::Dying { t } => {
let t = t - dt;
self.imps[i].state = if t <= 0.0 {
ImpState::Dead
} else {
ImpState::Dying { t }
};
}
ImpState::Dead => {}
}
}
if player_damage > 0 {
self.player.hp = (self.player.hp - player_damage).max(0);
self.player.damage_flash = 1.0;
}
}
/// Move imp `i` toward the player with wall sliding and a small
/// separation force from other live imps.
fn chase_step(&mut self, i: usize, px: f32, py: f32, dist: f32, dt: f32) {
let (ix, iy) = (self.imps[i].x, self.imps[i].y);
let mut mx = (px - ix) / dist;
let mut my = (py - iy) / dist;
// Separation: push away from live imps closer than 0.7 tiles
// (0.49 = 0.7²) so the pack doesn't collapse into one sprite.
for (j, other) in self.imps.iter().enumerate() {
if i == j || !other.alive() {
continue;
}
let (ox, oy) = (ix - other.x, iy - other.y);
let d2 = ox * ox + oy * oy;
if d2 < 0.49 && d2 > 1e-6 {
let d = d2.sqrt();
mx += (ox / d) * 0.6;
my += (oy / d) * 0.6;
}
}
let mag = (mx * mx + my * my).sqrt().max(1e-4);
let step = IMP_SPEED * dt;
let (sx, sy) = (mx / mag * step, my / mag * step);
let imp = &mut self.imps[i];
if !self.map.blocked(imp.x + sx, imp.y, IMP_RADIUS) {
imp.x += sx;
}
if !self.map.blocked(imp.x, imp.y + sy, IMP_RADIUS) {
imp.y += sy;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn all_imp_spawns_reachable_from_player_start() {
// Flood-fill walkable cells from the player start; every imp spawn
// must be in the same connected component. This subsumes "spawns are
// on floor tiles" (reachable cells are non-solid) and guards future
// map edits against sealing a demon into an unreachable room.
let game = Game::new();
assert!(game.total_imps() >= 5, "want a meaningful demon count");
let (w, h) = (game.map.w, game.map.h);
let start = (game.player.x.floor() as i32, game.player.y.floor() as i32);
let mut reachable = vec![false; w * h];
let mut stack = vec![start];
while let Some((x, y)) = stack.pop() {
if game.map.solid(x, y) || reachable[y as usize * w + x as usize] {
continue;
}
reachable[y as usize * w + x as usize] = true;
stack.extend([(x + 1, y), (x - 1, y), (x, y + 1), (x, y - 1)]);
}
for imp in &game.imps {
let (ix, iy) = (imp.x.floor() as usize, imp.y.floor() as usize);
assert!(
reachable[iy * w + ix],
"imp spawn at ({ix}, {iy}) unreachable from player start"
);
}
}
#[test]
fn map_border_is_solid() {
let game = Game::new();
for x in 0..game.map.w as i32 {
assert!(game.map.solid(x, 0));
assert!(game.map.solid(x, game.map.h as i32 - 1));
}
for y in 0..game.map.h as i32 {
assert!(game.map.solid(0, y));
assert!(game.map.solid(game.map.w as i32 - 1, y));
}
}
#[test]
fn player_cannot_walk_through_walls() {
let mut game = Game::new();
// Face straight up (-y) into the top border and push hard.
game.player.angle = -std::f32::consts::FRAC_PI_2;
for _ in 0..600 {
game.press(Control::Forward);
game.step(1.0 / 30.0);
}
assert!(
game.player.y >= 1.0 + PLAYER_RADIUS - 1e-3,
"clipped into border wall"
);
}
#[test]
fn los_blocked_by_walls_and_open_in_room() {
let game = Game::new();
let (px, py) = (game.player.x, game.player.y);
// Opposite map corner is far behind many walls.
assert!(!game.map.los(px, py, 20.5, 20.5));
// A spot in the same starting room is visible.
assert!(game.map.los(px, py, px + 1.0, py + 1.0));
}
#[test]
fn shooting_an_imp_in_front_damages_and_eventually_kills() {
let mut game = Game::new();
// Plant a target two tiles in front of the player, clear LOS.
let (dx, dy) = game.player.dir();
let (tx, ty) = (game.player.x + dx * 2.0, game.player.y + dy * 2.0);
game.imps[0].x = tx;
game.imps[0].y = ty;
game.imps[0].state = ImpState::Idle;
let hp_before = game.imps[0].hp;
game.queue_fire();
game.step(0.016);
assert!(game.imps[0].hp < hp_before, "first shot must connect");
for _ in 0..20 {
game.step(FIRE_COOLDOWN + 0.01); // let cooldown lapse
game.queue_fire();
game.step(0.016);
if !game.imps[0].alive() {
break;
}
}
assert!(!game.imps[0].alive(), "imp should die after repeated hits");
assert_eq!(game.kills, 1);
}
#[test]
fn fire_respects_cooldown() {
let mut game = Game::new();
let (dx, dy) = game.player.dir();
game.imps[0].x = game.player.x + dx * 2.0;
game.imps[0].y = game.player.y + dy * 2.0;
game.queue_fire();
game.step(0.016);
let hp_after_first = game.imps[0].hp;
// Immediate second shot is swallowed by the cooldown.
game.queue_fire();
game.step(0.016);
assert_eq!(game.imps[0].hp, hp_after_first);
}
#[test]
fn imp_chases_and_bites_player() {
let mut game = Game::new();
// Keep only one imp and put it right next to the player.
game.imps.truncate(1);
game.imps[0].x = game.player.x + 1.5;
game.imps[0].y = game.player.y;
game.imps[0].state = ImpState::Idle;
let mut bitten = false;
for _ in 0..400 {
game.step(1.0 / 30.0);
if game.player.hp < PLAYER_MAX_HP {
bitten = true;
break;
}
}
assert!(bitten, "imp should reach and bite the player");
assert!(game.player.damage_flash > 0.0 || game.player.hp < PLAYER_MAX_HP);
}
#[test]
fn win_condition_when_all_imps_dead() {
let mut game = Game::new();
for imp in &mut game.imps {
imp.state = ImpState::Dead;
}
game.kills = game.total_imps();
assert!(game.won());
assert!(!game.dead());
}
#[test]
fn movement_decays_to_rest() {
// A single press (no further repeats) glides to a stop once the
// hold window lapses.
let mut game = Game::new();
game.press(Control::Forward);
game.press(Control::TurnRight);
for _ in 0..120 {
game.step(1.0 / 30.0);
}
assert!(game.player.vel_forward.abs() < 0.01);
assert!(game.player.vel_rot.abs() < 0.01);
}
#[test]
fn held_forward_reaches_and_sustains_target_speed() {
// Holding forward (a press every frame) must converge to MOVE_SPEED
// 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
let dt = 1.0 / 30.0;
for _ in 0..40 {
game.press(Control::Forward);
game.step(dt);
}
let speed = game.player.vel_forward;
assert!(
(speed - MOVE_SPEED).abs() < 0.05,
"expected ~{MOVE_SPEED}, got {speed}"
);
// Sustained: the spread over the next second stays tiny.
let (mut lo, mut hi) = (f32::MAX, f32::MIN);
for _ in 0..30 {
game.press(Control::Forward);
game.step(dt);
lo = lo.min(game.player.vel_forward);
hi = hi.max(game.player.vel_forward);
}
assert!(hi - lo < 0.02, "velocity sawtoothed: [{lo}, {hi}]");
}
#[test]
fn release_aware_supports_simultaneous_move_and_turn() {
// The bug: on a Kitty-keyboard terminal, holding W + an arrow must
// move AND turn. The terminal only auto-repeats the last key, so
// forward gets a single press then nothing until release — latching
// 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.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
}
assert!(
game.player.vel_forward > 1.0,
"forward must persist without repeats, got {}",
game.player.vel_forward
);
assert!(
game.player.angle < angle0,
"should have turned left while moving"
);
// Releasing forward stops forward; turning continues.
game.release(Control::Forward);
for _ in 0..30 {
game.step(1.0 / 30.0);
}
assert!(
game.player.vel_forward.abs() < 0.2,
"released forward should glide to rest, got {}",
game.player.vel_forward
);
assert!(game.player.vel_rot.abs() > 0.1, "turn should still be held");
}
#[test]
fn release_all_un_latches_movement() {
// Safety valve for a release dropped on focus loss: latched keys
// must all clear so the player doesn't walk forever.
let mut game = Game::new();
game.set_release_aware(true);
game.press(Control::Forward);
game.press(Control::TurnLeft);
game.release_all();
for _ in 0..30 {
game.step(1.0 / 30.0);
}
assert!(game.player.vel_forward.abs() < 0.1);
assert!(game.player.vel_rot.abs() < 0.1);
}
#[test]
fn sustained_speed_is_independent_of_repeat_cadence() {
// The whole point of the hold model: a slow key-repeat cadence must
// reach the same sustained speed as a fast one (no stutter), as long
// as repeats arrive within HOLD_WINDOW.
fn sustained_speed(repeat_interval: f32) -> f32 {
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
// Run 2s of simulation, pressing every `repeat_interval`.
for _ in 0..120 {
since_repeat += dt;
if since_repeat >= repeat_interval {
game.press(Control::Forward);
since_repeat = 0.0;
}
game.step(dt);
}
game.player.vel_forward
}
let fast = sustained_speed(0.03); // ~33 Hz
let slow = sustained_speed(0.12); // ~8 Hz, still under HOLD_WINDOW
assert!((fast - MOVE_SPEED).abs() < 0.1, "fast cadence: {fast}");
assert!(
(fast - slow).abs() < 0.25,
"cadence changed sustained speed: fast={fast}, slow={slow}"
);
}
}
@@ -0,0 +1,723 @@
//! `/gboom` easter egg: a tiny single-level raycaster shooter rendered in
//! the terminal via the kitty graphics protocol.
//!
//! Typing `/gboom` (and nothing else) opens a modal overlay — the same
//! surface the imagine-video player uses — and streams PNG frames via
//! per-frame kitty `a=T` retransmission at the ~30 fps animation tick. The
//! simulation steps with wall-clock `dt`, so gameplay speed is independent
//! of the achieved frame rate.
//!
//! Controls: `W`/`↑` forward, `S`/`↓` back, `A`/`D` strafe, `←`/`→` turn,
//! mouse move/drag aim (in-modal, Playing only), click or `Space`/`Enter`
//! fire, `Esc`/`q` quit. Movement uses the continuous held-key model in
//! [`game`] — terminals deliver no key-release events, so it eases toward a
//! steady target while a key is held, staying smooth regardless of the OS
//! key-repeat cadence.
mod assets;
mod engine;
mod game;
pub(crate) use assets::GBOOM_RED;
use std::time::Instant;
use crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind};
use engine::{FireSim, FrameBuffer, Renderer};
use game::{Control, Game};
/// Maximum rendered frame width in pixels. Each frame is PNG-encoded and
/// pushed through the PTY every tick (~100 KB / ~3 MB/s at 30 fps at this
/// cap), comfortably within what the video player already streams.
const MAX_FRAME_W: usize = 480;
/// Maximum rendered frame height in pixels.
const MAX_FRAME_H: usize = 320;
/// Assumed cell size in pixels for aspect mapping (cell aspect 0.5,
/// consistent with `terminal::image::fit_image_to_cells`).
const CELL_PX_W: usize = 8;
const CELL_PX_H: usize = 16;
/// Largest simulation step; longer gaps (lag, suspend) are clamped.
const MAX_DT: f32 = 0.1;
/// Delay before end screens accept a dismissal key.
const END_SCREEN_GRACE: f32 = 0.8;
/// Radians of yaw per terminal cell of horizontal mouse motion.
const MOUSE_AIM_SENSITIVITY: f32 = 0.06;
/// Column jumps larger than this re-seed the baseline without yawing.
const MAX_MOUSE_AIM_DX: i32 = 12;
/// Near-black outline that keeps screen text legible over the fire.
const TEXT_OUTLINE: [u8; 3] = [16, 6, 6];
/// Where the player is in the easter egg flow. Time spent in the current
/// phase is tracked by `GboomState::phase_time` (reset on every transition).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Phase {
Title,
Playing,
Won,
Dead,
}
/// Result of a key event while the game modal is open.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum GboomKeyOutcome {
/// Close the modal (caller clears the kitty placement).
Close,
/// Key consumed; state may have changed.
Changed,
}
/// HUD values for the overlay chrome.
pub struct GboomHud {
pub(crate) hp: i32,
pub(crate) kills: u32,
pub(crate) total: u32,
pub(crate) playing: bool,
}
/// Modal state for the `/gboom` easter egg. Owned by the agent view like
/// the video viewer; ticked from the animation tick; rendered in the draw
/// path via post-flush kitty escapes.
pub struct GboomState {
game: Game,
renderer: Renderer,
fire: FireSim,
phase: Phase,
last_tick: Instant,
/// Monotonic simulation generation; bumps invalidate the frame cache.
sim_gen: u64,
fb: FrameBuffer,
png: Vec<u8>,
/// `(sim_gen, w, h)` of the cached PNG in `png`.
cached: Option<(u64, usize, usize)>,
/// Wall-clock time inside the current phase.
phase_time: f32,
last_mouse_col: Option<u16>,
/// Popup cell rect `(x, y, w, h)` for mouse hit-testing; set from draw.
mouse_region: Option<(u16, u16, u16, u16)>,
}
impl GboomState {
pub fn new() -> Self {
let mut game = Game::new();
// On terminals that report key releases (Kitty keyboard protocol),
// latch keys on press/release so the player can move and turn at
// once; otherwise fall back to the repeat-bridging timer model.
game.set_release_aware(crate::terminal::kitty_flags_pushed());
Self {
game,
renderer: Renderer::new(),
fire: FireSim::new(),
phase: Phase::Title,
last_tick: Instant::now(),
sim_gen: 0,
fb: FrameBuffer::new(),
png: Vec::new(),
cached: None,
phase_time: 0.0,
last_mouse_col: None,
mouse_region: None,
}
}
pub fn set_mouse_region(&mut self, x: u16, y: u16, width: u16, height: u16) {
self.mouse_region = Some((x, y, width, height));
}
pub fn clear_mouse_region(&mut self) {
self.mouse_region = None;
self.last_mouse_col = None;
}
fn in_mouse_region(&self, col: u16, row: u16) -> bool {
let Some((x, y, w, h)) = self.mouse_region else {
return false;
};
col >= x && row >= y && col < x.saturating_add(w) && row < y.saturating_add(h)
}
/// Map a key to its movement control, if any.
fn control_for(code: KeyCode) -> Option<Control> {
match code {
KeyCode::Char('w' | 'W') | KeyCode::Up => Some(Control::Forward),
KeyCode::Char('s' | 'S') | KeyCode::Down => Some(Control::Back),
KeyCode::Char('a' | 'A') => Some(Control::StrafeLeft),
KeyCode::Char('d' | 'D') => Some(Control::StrafeRight),
KeyCode::Left => Some(Control::TurnLeft),
KeyCode::Right => Some(Control::TurnRight),
_ => None,
}
}
/// Advance the game by wall-clock time. Every tick produces a new
/// frame (the world, fire, and bob animations are continuous), so the
/// caller should always redraw after ticking.
pub fn tick(&mut self) {
let dt = self.last_tick.elapsed().as_secs_f32().min(MAX_DT);
self.last_tick = Instant::now();
self.phase_time += dt;
match self.phase {
Phase::Title | Phase::Won | Phase::Dead => self.fire.step(),
Phase::Playing => {
self.game.step(dt);
if self.game.dead() {
self.set_phase(Phase::Dead);
} else if self.game.won() && self.all_corpses_settled() {
// The corpse check makes the win screen wait for the
// final death animation to play out.
self.set_phase(Phase::Won);
}
}
}
self.sim_gen += 1;
}
fn set_phase(&mut self, phase: Phase) {
self.phase = phase;
self.phase_time = 0.0;
self.last_mouse_col = None;
}
fn all_corpses_settled(&self) -> bool {
self.game
.imps
.iter()
.all(|imp| matches!(imp.state, game::ImpState::Dead))
}
/// Handle a key press/repeat event.
pub fn handle_key(&mut self, key: &KeyEvent) -> GboomKeyOutcome {
// Esc / q always quit, in every phase.
if matches!(
key.code,
KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('Q')
) {
return GboomKeyOutcome::Close;
}
match self.phase {
Phase::Title => {
self.set_phase(Phase::Playing);
self.last_tick = Instant::now();
self.sim_gen += 1;
}
Phase::Playing => {
// No `sim_gen` bump: held keys and queued shots only take
// effect on the next tick (which bumps it), so the current
// frame's cache stays valid.
if let Some(control) = Self::control_for(key.code) {
self.game.press(control);
} else if matches!(key.code, KeyCode::Char(' ') | KeyCode::Enter) {
self.game.queue_fire();
}
}
Phase::Won | Phase::Dead => {
if self.phase_time > END_SCREEN_GRACE {
return GboomKeyOutcome::Close;
}
}
}
GboomKeyOutcome::Changed
}
/// Handle a key-release event (release-aware terminals only): un-latch
/// the corresponding movement control so the player stops that motion.
pub fn handle_release(&mut self, key: &KeyEvent) {
if matches!(self.phase, Phase::Playing)
&& let Some(control) = Self::control_for(key.code)
{
self.game.release(control);
}
}
/// In-region move/drag aims; left-click fires (Playing only).
pub fn handle_mouse(&mut self, mouse: &MouseEvent) {
if !matches!(self.phase, Phase::Playing) {
return;
}
let in_region = self.in_mouse_region(mouse.column, mouse.row);
match mouse.kind {
MouseEventKind::Down(MouseButton::Left) => {
if in_region {
self.game.queue_fire();
}
}
MouseEventKind::Moved | MouseEventKind::Drag(_) => {
if !in_region {
self.last_mouse_col = None;
return;
}
let col = mouse.column;
if let Some(prev) = self.last_mouse_col {
let dx = col as i32 - prev as i32;
if dx.abs() > MAX_MOUSE_AIM_DX {
self.last_mouse_col = Some(col);
return;
}
if dx != 0 {
self.game.player.angle += dx as f32 * MOUSE_AIM_SENSITIVITY;
self.sim_gen += 1;
}
}
self.last_mouse_col = Some(col);
}
_ => {}
}
}
/// Un-latch all movement (on focus loss), so a release dropped while
/// unfocused can't leave the player walking forever.
pub fn release_all(&mut self) {
self.game.release_all();
self.last_mouse_col = None;
}
/// Whether the game currently holds a latched movement control. Lets the
/// app layer assert that backgrounded games drop their holds.
#[cfg(any(test, feature = "test-support"))]
pub fn any_movement_held(&self) -> bool {
self.game.any_held()
}
/// HUD values for the chrome line.
pub fn hud(&self) -> GboomHud {
GboomHud {
hp: self.game.player.hp,
kills: self.game.kills,
total: self.game.total_imps(),
playing: matches!(self.phase, Phase::Playing),
}
}
/// Map a cell box to the internal render resolution: match the box's
/// pixel aspect (8x16 px cells), capped to bound PNG payload and CPU.
pub fn frame_size_for_cells(cols: u16, rows: u16) -> (usize, usize) {
let mut w = (cols as usize * CELL_PX_W).max(64);
let mut h = (rows as usize * CELL_PX_H).max(64);
let scale = (MAX_FRAME_W as f32 / w as f32)
.min(MAX_FRAME_H as f32 / h as f32)
.min(1.0);
w = ((w as f32 * scale) as usize).max(64);
h = ((h as f32 * scale) as usize).max(64);
(w, h)
}
/// Render the current frame at `(w, h)` and return it PNG-encoded.
/// Cached per `(sim_gen, w, h)` so extra draws between ticks are free.
pub fn frame_png(&mut self, w: usize, h: usize) -> Option<&[u8]> {
if w < 8 || h < 8 {
return None;
}
if self.cached == Some((self.sim_gen, w, h)) {
return Some(&self.png);
}
self.fb.resize(w, h);
match self.phase {
Phase::Title => self.render_title_screen(),
Phase::Playing => self.renderer.render_game(&mut self.fb, &self.game),
Phase::Won => self.render_end_screen("VICTORY!", [255, 214, 80]),
Phase::Dead => self.render_end_screen("YOU DIED", assets::GBOOM_RED),
}
self.png.clear();
{
use image::codecs::png::{CompressionType, FilterType, PngEncoder};
use image::{ExtendedColorType, ImageEncoder};
let encoder = PngEncoder::new_with_quality(
&mut self.png,
CompressionType::Fast,
FilterType::Adaptive,
);
if encoder
.write_image(&self.fb.pixels, w as u32, h as u32, ExtendedColorType::Rgb8)
.is_err()
{
self.cached = None;
return None;
}
}
self.cached = Some((self.sim_gen, w, h));
Some(&self.png)
}
fn render_title_screen(&mut self) {
engine::clear(&mut self.fb, [7, 7, 9]);
self.fire.draw(&mut self.fb, 0.62);
let h = self.fb.h as i32;
let title_scale = (self.fb.w / 36).clamp(2, 10);
let small_scale = (title_scale / 3).max(1);
engine::draw_text_centered_outlined(
&mut self.fb,
"GBOOM",
h / 6,
title_scale,
assets::GBOOM_RED,
TEXT_OUTLINE,
);
engine::draw_text_centered_outlined(
&mut self.fb,
"KNEE-DEEP IN THE TOKENS",
h / 6 + 8 * title_scale as i32,
small_scale,
[212, 168, 92],
TEXT_OUTLINE,
);
// Blink the prompt line.
if ((self.phase_time * 1.6) as u32).is_multiple_of(2) {
engine::draw_text_centered_outlined(
&mut self.fb,
"PRESS ANY KEY",
h / 6 + 8 * title_scale as i32 + 10 * small_scale as i32,
small_scale,
[220, 210, 190],
TEXT_OUTLINE,
);
}
}
fn render_end_screen(&mut self, text: &str, color: [u8; 3]) {
engine::clear(&mut self.fb, [7, 7, 9]);
self.fire.draw(&mut self.fb, 0.5);
let h = self.fb.h as i32;
let scale = (self.fb.w / 40).clamp(2, 8);
engine::draw_text_centered_outlined(&mut self.fb, text, h / 5, scale, color, TEXT_OUTLINE);
// Show the blinking dismissal hint once the grace period is over.
if self.phase_time > END_SCREEN_GRACE && ((self.phase_time * 1.6) as u32).is_multiple_of(2)
{
engine::draw_text_centered_outlined(
&mut self.fb,
"PRESS ANY KEY",
h / 5 + 10 * scale as i32,
(scale / 3).max(1),
[220, 210, 190],
TEXT_OUTLINE,
);
}
}
}
impl Default for GboomState {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crossterm::event::{KeyEvent, KeyModifiers};
fn key(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
#[test]
fn starts_on_title_and_any_key_starts_game() {
let mut state = GboomState::new();
assert_eq!(state.phase, Phase::Title);
assert_eq!(
state.handle_key(&key(KeyCode::Char('w'))),
GboomKeyOutcome::Changed
);
assert_eq!(state.phase, Phase::Playing);
}
#[test]
fn esc_and_q_close_in_every_phase() {
for phase in [Phase::Title, Phase::Playing, Phase::Won, Phase::Dead] {
for code in [KeyCode::Esc, KeyCode::Char('q')] {
let mut state = GboomState::new();
state.phase = phase;
assert_eq!(state.handle_key(&key(code)), GboomKeyOutcome::Close);
}
}
}
#[test]
fn end_screens_have_dismissal_grace_period() {
let mut state = GboomState::new();
state.phase = Phase::Dead;
state.phase_time = 0.1;
// Within the grace period, non-quit keys are swallowed.
assert_eq!(
state.handle_key(&key(KeyCode::Char(' '))),
GboomKeyOutcome::Changed
);
state.phase_time = END_SCREEN_GRACE + 0.1;
assert_eq!(
state.handle_key(&key(KeyCode::Char(' '))),
GboomKeyOutcome::Close
);
}
#[test]
fn frame_png_produces_valid_png_at_requested_size() {
let mut state = GboomState::new();
state.tick();
let png = state.frame_png(320, 200).expect("png frame").to_vec();
assert_eq!(&png[..8], b"\x89PNG\r\n\x1a\n");
let dims = crate::prompt_images::decode_image_dimensions(&png).expect("decodable");
assert_eq!(dims, (320, 200));
}
#[test]
fn frame_png_is_cached_until_state_changes() {
let mut state = GboomState::new();
state.tick();
let a = state.frame_png(160, 100).unwrap().to_vec();
let b = state.frame_png(160, 100).unwrap().to_vec();
assert_eq!(a, b, "same gen + dims must reuse the cached frame");
state.tick();
// After a tick the fire animates, so the title frame changes.
let c = state.frame_png(160, 100).unwrap().to_vec();
assert_ne!(a, c, "tick must invalidate the cached frame");
}
#[test]
fn frame_size_mapping_respects_caps_and_aspect() {
// A huge cell box gets capped.
let (w, h) = GboomState::frame_size_for_cells(300, 80);
assert!(w <= MAX_FRAME_W && h <= MAX_FRAME_H);
// A typical popup box keeps the cell-box pixel aspect.
let (w, h) = GboomState::frame_size_for_cells(60, 15);
assert_eq!(
(w as f32 / h as f32 * 10.0).round(),
((60.0 * CELL_PX_W as f32) / (15.0 * CELL_PX_H as f32) * 10.0).round()
);
// Tiny boxes stay above the floor.
let (w, h) = GboomState::frame_size_for_cells(1, 1);
assert!(w >= 64 && h >= 64);
}
#[test]
fn playing_to_dead_transition() {
let mut state = GboomState::new();
state.handle_key(&key(KeyCode::Char('w'))); // leave title
state.game.player.hp = 0;
state.tick();
assert_eq!(state.phase, Phase::Dead);
}
#[test]
fn playing_to_won_transition_after_corpses_settle() {
let mut state = GboomState::new();
state.handle_key(&key(KeyCode::Char('w')));
let total = state.game.total_imps();
for imp in &mut state.game.imps {
imp.state = game::ImpState::Dead;
}
state.game.kills = total;
state.tick();
assert_eq!(state.phase, Phase::Won);
}
fn playing_with_region() -> GboomState {
let mut state = GboomState::new();
state.handle_key(&key(KeyCode::Char('w')));
state.set_mouse_region(0, 0, 200, 50);
state
}
fn mouse_at(kind: MouseEventKind, column: u16, row: u16) -> MouseEvent {
MouseEvent {
kind,
column,
row,
modifiers: KeyModifiers::NONE,
}
}
fn mouse_moved(column: u16) -> MouseEvent {
mouse_at(MouseEventKind::Moved, column, 10)
}
fn mouse_click(column: u16, row: u16) -> MouseEvent {
mouse_at(MouseEventKind::Down(MouseButton::Left), column, row)
}
/// In-region yaw: Δangle = Δcol × sensitivity (first event seeds only).
#[test]
fn mouse_aim_delta_matches_column_delta() {
let mut state = playing_with_region();
state.game.player.angle = 0.0;
state.handle_mouse(&mouse_moved(40));
assert_eq!(state.game.player.angle, 0.0);
state.handle_mouse(&mouse_moved(45));
assert_eq!(state.game.player.angle, 5.0 * MOUSE_AIM_SENSITIVITY);
state.handle_mouse(&mouse_moved(42));
assert!((state.game.player.angle - 2.0 * MOUSE_AIM_SENSITIVITY).abs() < 1e-5);
}
/// Non-Playing phases ignore mouse aim.
#[test]
fn mouse_aim_only_while_playing() {
for phase in [Phase::Title, Phase::Won, Phase::Dead] {
let mut state = GboomState::new();
state.set_mouse_region(0, 0, 200, 50);
state.phase = phase;
state.game.player.angle = 0.5;
state.handle_mouse(&mouse_moved(10));
state.handle_mouse(&mouse_moved(50));
assert_eq!(state.game.player.angle, 0.5);
}
}
/// Continuity breaks (release_all / teleport dx) re-seed without applying the gap as yaw.
#[test]
fn mouse_aim_baseline_reseeds_on_discontinuity() {
let mut state = playing_with_region();
state.game.player.angle = 0.0;
state.handle_mouse(&mouse_moved(10));
state.handle_mouse(&mouse_moved(12));
let after_aim = state.game.player.angle;
assert_eq!(after_aim, 2.0 * MOUSE_AIM_SENSITIVITY);
state.release_all();
state.handle_mouse(&mouse_moved(50));
assert_eq!(state.game.player.angle, after_aim);
state.handle_mouse(&mouse_moved(52));
assert_eq!(
state.game.player.angle,
after_aim + 2.0 * MOUSE_AIM_SENSITIVITY
);
state.game.player.angle = 0.0;
state.last_mouse_col = None;
state.handle_mouse(&mouse_moved(10));
state.handle_mouse(&mouse_moved(10 + MAX_MOUSE_AIM_DX as u16 + 1));
assert_eq!(state.game.player.angle, 0.0);
state.handle_mouse(&mouse_moved(10 + MAX_MOUSE_AIM_DX as u16 + 1 + 3));
assert_eq!(state.game.player.angle, 3.0 * MOUSE_AIM_SENSITIVITY);
}
/// Out-of-region motion does not yaw; re-entry does not apply the OOB span.
#[test]
fn out_of_region_motion_does_not_aim() {
let mut state = playing_with_region();
state.set_mouse_region(20, 5, 20, 10);
state.game.player.angle = 0.0;
state.handle_mouse(&mouse_at(MouseEventKind::Moved, 25, 10));
state.handle_mouse(&mouse_at(MouseEventKind::Moved, 30, 10));
let angle = state.game.player.angle;
assert_eq!(angle, 5.0 * MOUSE_AIM_SENSITIVITY);
state.handle_mouse(&mouse_at(MouseEventKind::Moved, 100, 10));
assert_eq!(state.game.player.angle, angle);
state.handle_mouse(&mouse_at(MouseEventKind::Moved, 22, 10));
assert_eq!(state.game.player.angle, angle);
state.handle_mouse(&mouse_at(MouseEventKind::Moved, 24, 10));
assert_eq!(state.game.player.angle, angle + 2.0 * MOUSE_AIM_SENSITIVITY);
}
/// Left-click fires only when Playing and in-region (same queue as Space).
#[test]
fn click_fires_only_playing_in_region() {
let mut state = playing_with_region();
state.handle_mouse(&mouse_click(40, 10));
state.tick();
assert!(state.game.player.muzzle > 0.0);
let mut state = GboomState::new();
state.set_mouse_region(0, 0, 200, 50);
state.handle_mouse(&mouse_click(40, 10));
state.tick();
assert_eq!(state.game.player.muzzle, 0.0);
state.handle_key(&key(KeyCode::Char('w')));
state.handle_mouse(&mouse_click(250, 10));
state.tick();
assert_eq!(state.game.player.muzzle, 0.0);
for phase in [Phase::Won, Phase::Dead] {
let mut state = GboomState::new();
state.set_mouse_region(0, 0, 200, 50);
state.phase = phase;
state.handle_mouse(&mouse_click(40, 10));
state.tick();
assert_eq!(state.game.player.muzzle, 0.0);
}
}
/// No region ⇒ no aim or click-fire.
#[test]
fn clear_mouse_region_disables_aim_and_fire() {
let mut state = playing_with_region();
state.game.player.angle = 0.0;
state.handle_mouse(&mouse_moved(40));
state.handle_mouse(&mouse_moved(45));
let angle = state.game.player.angle;
state.clear_mouse_region();
state.handle_mouse(&mouse_moved(50));
state.handle_mouse(&mouse_moved(55));
assert_eq!(state.game.player.angle, angle);
state.handle_mouse(&mouse_click(40, 10));
state.tick();
assert_eq!(state.game.player.muzzle, 0.0);
}
/// Dumps representative frames to a temp dir for eyeballing.
/// Run manually: `cargo test -p kigi-tui gboom::tests::dump -- --ignored`
#[test]
#[ignore]
fn dump_frames_for_visual_inspection() {
let dir = std::env::temp_dir().join("grok-gboom-frames");
std::fs::create_dir_all(&dir).unwrap();
let dump = |name: &str, state: &mut GboomState| {
state.sim_gen += 1;
std::fs::write(dir.join(name), state.frame_png(480, 300).unwrap()).unwrap();
};
// Title screen with developed fire.
let mut state = GboomState::new();
for _ in 0..70 {
state.tick();
}
dump("title.png", &mut state);
// 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.phase = Phase::Playing;
state.game.player.angle = std::f32::consts::FRAC_PI_2; // +y, south
state.game.step(0.016);
dump("game.png", &mut state);
// Imp 3 tiles ahead in that corridor, walking at us.
state.game.imps[0].x = state.game.player.x;
state.game.imps[0].y = state.game.player.y + 3.0;
state.game.imps[0].state = game::ImpState::Chasing;
// A second one further away to check fog/scale falloff.
state.game.imps[1].x = state.game.player.x;
state.game.imps[1].y = state.game.player.y + 6.0;
state.game.imps[1].state = game::ImpState::Attacking { t: 0.2 };
dump("imp.png", &mut state);
// Muzzle flash over that scene.
state.game.queue_fire();
state.game.step(0.016);
dump("fire.png", &mut state);
// Damage flash + pain.
state.game.player.damage_flash = 0.8;
dump("hurt.png", &mut state);
// End screens (past the grace period so the hint line shows, with
// the fire developed as it would be after a few seconds of play).
for _ in 0..80 {
state.fire.step();
}
state.phase = Phase::Dead;
state.phase_time = 2.0;
dump("dead.png", &mut state);
state.phase = Phase::Won;
dump("won.png", &mut state);
eprintln!("frames dumped to {}", dir.display());
}
}
@@ -0,0 +1,819 @@
//! Legacy-console fallbacks for chrome glyphs that don't ship in the
//! legacy Windows ConHost default font (Consolas / Lucida Console).
//!
//! Fallbacks are ASCII where possible (`x`, `o`, `c`, `*`), or a CP437
//! glyph when one reads better and still renders on the raster font
//! (`✓` → `√` U+221A, `⇣` → `↓` U+2193).
//!
//! ConHost does no font fallback, so missing glyphs render as tofu.
//! Windows Terminal, VS Code, and modern emulators bundle fonts (or
//! fall back to one) that cover the Dingbats / symbol glyphs we use as
//! chrome — `` (U+276F), `❙` (U+2759), `✗` (U+2717), `✓` (U+2713),
//! `↗` (U+2197), `⧉` (U+29C9), `⇣` (U+21E3), the diamonds `◆`/`◇`/`◈`
//! (U+25C6 / U+25C7 / U+25C8), and the braille / dot progress spinners —
//! so the substitution only fires for legacy `cmd.exe` / `powershell.exe`.
use std::borrow::Cow;
use std::sync::OnceLock;
use crate::host::HostOs;
use crate::terminal::{TerminalName, terminal_context};
/// `" "` normally, `"> "` on legacy ConHost. Always 2 columns wide.
pub fn prompt_arrow() -> &'static str {
if is_legacy_windows_console() {
"> "
} else {
"\u{276F} "
}
}
/// Display width of [`prompt_arrow`] in columns.
pub const PROMPT_ARROW_WIDTH: u16 = 2;
/// Record indicator glyph shown above the prompt while voice capture is
/// active — a dot inside a ring. Two states swapped on the pulse cadence:
/// FISHEYE (`◉`, filled center) on the bright half and BULLSEYE (`◎`, open
/// center) on the dim half, which together with a smooth color fade reads
/// like a studio recording light. ASCII fallback (`*`/`o`) on legacy
/// ConHost. Always 1 column wide.
pub fn record_dot(filled: bool) -> &'static str {
if is_legacy_windows_console() {
if filled { "*" } else { "o" }
} else if filled {
"\u{25C9}"
} else {
"\u{25CE}"
}
}
/// `"❙"` normally, `"|"` on legacy ConHost. Always 1 column wide.
pub fn collapsed_accent() -> &'static str {
if is_legacy_windows_console() {
"|"
} else {
"\u{2759}"
}
}
/// `"✗"` (U+2717 BALLOT X) normally, `"x"` on legacy ConHost. Always 1
/// column wide.
///
/// Used for close / cancel / kill buttons and failure status markers. The
/// Dingbats `✗` is not covered by Consolas / Lucida Console, so it renders
/// as tofu on legacy `cmd.exe` / `powershell.exe` — same coverage gap as
/// the chrome glyphs above.
pub fn ballot_x() -> &'static str {
if is_legacy_windows_console() {
"x"
} else {
"\u{2717}"
}
}
/// `"✓"` (U+2713 CHECK MARK) normally, `"√"` (U+221A SQUARE ROOT) on legacy
/// ConHost. Always 1 column wide.
///
/// The success / done sibling of [`ballot_x`]; the Dingbats `✓` shares the
/// same coverage gap on legacy consoles. The fallback `√` is a CP437 glyph
/// (code 0xFB), so it renders even on the stripped-down raster font and
/// reads as a checkmark — pairing with the `x` failure mark.
pub fn check_mark() -> &'static str {
if is_legacy_windows_console() {
"\u{221A}"
} else {
"\u{2713}"
}
}
/// `"↗"` (U+2197 NORTH EAST ARROW) normally, `"o"` on legacy ConHost.
/// Always 1 column wide.
///
/// The enlarge / view / fullscreen button glyph. The previous glyph
/// (`⛶` U+26F6 SQUARE FOUR CORNERS) lives in the Miscellaneous Symbols
/// block and is missing from many modern monospace fonts too — not just
/// legacy Windows consoles — so it rendered as tofu (`□`) in common
/// macOS/Linux terminals. U+2197 lives in the well-covered core Arrows
/// block and reads as the standard "open / maximize" affordance.
pub fn enlarge() -> &'static str {
if is_legacy_windows_console() {
"o"
} else {
"\u{2197}"
}
}
/// `"⧉"` (U+29C9 TWO JOINED SQUARES) normally, `"c"` on legacy ConHost.
/// Always 1 column wide.
///
/// The copy button glyph on the scrollback selection box (pairs with
/// [`enlarge`]). U+29C9 lives in Miscellaneous Mathematical Symbols-B and
/// is absent from legacy console fonts.
pub fn copy_icon() -> &'static str {
if is_legacy_windows_console() {
"c"
} else {
"\u{29C9}"
}
}
/// `"⇣"` (U+21E3 DOWNWARDS DASHED ARROW) normally, `"↓"` (U+2193) on legacy
/// ConHost. Always 1 column wide.
///
/// Used for the context-token count in the turn-status line. The dashed
/// arrow is missing from legacy console fonts, but the plain down arrow
/// (present in CP437) is a faithful, always-renderable stand-in.
pub fn token_arrow() -> &'static str {
if is_legacy_windows_console() {
"\u{2193}"
} else {
"\u{21E3}"
}
}
/// Pulsing monitor-indicator frames (`○ ◎ ◉ ◎` — U+25CB WHITE CIRCLE,
/// U+25CE BULLSEYE, U+25C9 FISHEYE, U+25CE BULLSEYE) normally; a 1-column
/// dot pulse (`·`, `○`, `•`, `○`) on legacy ConHost.
///
/// Animates the "watching · N monitors" cue in the turn-status line: a
/// concentric circle that breathes open → shut like a scanning scope. Of
/// the fancy frames only the white circle `○` (U+25CB, CP437 `0x09`) is
/// part of CP437 — the bullseye `◎` and fisheye `◉` live in the Geometric
/// Shapes block and render as tofu on the legacy raster font. The legacy
/// fallback keeps the same fixed-size breath using only CP437 dots —
/// middle dot `·` (U+00B7, `0xFA`), white circle `○` (`0x09`), bullet `•`
/// (U+2022, `0x07`) — so it pulses by fill (faint → ring → solid → ring)
/// rather than by size. Every frame in both sets is exactly 1 column so the
/// trailing label never shifts as the icon animates.
pub fn monitor_icon_frames() -> &'static [&'static str] {
const FANCY: &[&str] = &["\u{25CB}", "\u{25CE}", "\u{25C9}", "\u{25CE}"];
const FALLBACK: &[&str] = &["\u{00B7}", "\u{25CB}", "\u{2022}", "\u{25CB}"];
if is_legacy_windows_console() {
FALLBACK
} else {
FANCY
}
}
/// `"◆"` (U+25C6 BLACK DIAMOND) normally, `"♦"` (U+2666 BLACK DIAMOND
/// SUIT) on legacy ConHost. Always 1 column wide.
///
/// The filled diamond used for the scrollback block bullet, the
/// "waiting on you" status cues (turn-status + plan-approval), the
/// `/context` usage bar (system / messages categories), picker leaf /
/// fold indicators, and the dashboard's non-idle row markers. U+25C6 is
/// absent from the legacy console raster font, but U+2666 is a CP437
/// glyph (code `0x04`) so it renders and still reads as a filled diamond.
pub fn diamond_filled() -> &'static str {
if is_legacy_windows_console() {
"\u{2666}"
} else {
"\u{25C6}"
}
}
/// `"◇"` (U+25C7 WHITE DIAMOND) normally, `"○"` (U+25CB WHITE CIRCLE) on
/// legacy ConHost. Always 1 column wide.
///
/// The hollow sibling of [`diamond_filled`] — the `/context` bar's free
/// (unused) cells and the dashboard's idle row marker. U+25C7 is absent
/// from the raster font; U+25CB is a CP437 glyph (code `0x09`) that
/// renders and reads as an empty / outline marker.
pub fn diamond_hollow() -> &'static str {
if is_legacy_windows_console() {
"\u{25CB}"
} else {
"\u{25C7}"
}
}
/// `"◈"` (U+25C8 WHITE DIAMOND CONTAINING BLACK SMALL DIAMOND) normally,
/// `"♦"` (U+2666) on legacy ConHost. Always 1 column wide.
///
/// Used for the `/context` bar's tool-definitions category and the
/// collapsed-group scrollback header. Falls back to the same CP437
/// filled diamond as [`diamond_filled`]; both call sites already
/// distinguish this category by color, so collapsing the glyph on
/// legacy consoles is lossless in practice.
pub fn diamond_dotted() -> &'static str {
if is_legacy_windows_console() {
"\u{2666}"
} else {
"\u{25C8}"
}
}
/// Filled-diamond glyph as a [`char`] (see [`diamond_filled`]), for the
/// tool-usage sequence bar which builds its row from single `char`s.
pub fn diamond_filled_char() -> char {
diamond_filled().chars().next().unwrap_or('\u{25C6}')
}
/// Hollow-diamond glyph as a [`char`] (see [`diamond_hollow`]).
pub fn diamond_hollow_char() -> char {
diamond_hollow().chars().next().unwrap_or('\u{25C7}')
}
/// Rotating braille progress-spinner frames (`⠋⠙⠹⠸⠼⠴⠦⠧`) normally; a
/// 1-column ASCII spinner (`|`, `/`, `-`, `\`) on legacy ConHost.
///
/// The U+2800 Braille Patterns block is not part of CP437 and renders as
/// tofu on the legacy console raster font, so the turn-status line, the
/// MCP-connecting chip, the image-viewer loader, and the `/btw` overlay
/// all fall back to the classic ASCII spinner there. Every frame in both
/// sets is exactly 1 column so the surrounding layout never shifts.
pub fn braille_spinner_frames() -> &'static [&'static str] {
const FANCY: &[&str] = &[
"\u{280b}", "\u{2819}", "\u{2839}", "\u{2838}", "\u{283c}", "\u{2834}", "\u{2826}",
"\u{2827}",
];
const FALLBACK: &[&str] = &["|", "/", "-", "\\"];
if is_legacy_windows_console() {
FALLBACK
} else {
FANCY
}
}
/// Pulsing dot progress-spinner frames (`⋅ : ⸬ ⁙`) normally; a quiet
/// 1-column dot cycle (`.`, `:`, `·`) on legacy ConHost.
///
/// U+22C5 / U+2E2C / U+2059 are absent from the CP437 raster font, so the
/// running-subagent / task rows (Tasks pane + Dashboard), the dashboard
/// status chips, and the active-goal indicators fall back to a quiet dot
/// cycle there — period, colon, and `·` (U+00B7, CP437 `0xFA`) all render
/// on the raster font. Every frame in both sets is exactly 1 column.
pub fn dot_spinner_frames() -> &'static [&'static str] {
const FANCY: &[&str] = &[
"\u{22c5}", ":", "\u{2e2c}", "\u{2059}", "\u{22c5}", ":", "\u{2e2c}", "\u{2059}",
];
const FALLBACK: &[&str] = &[".", ":", "\u{00b7}"];
if is_legacy_windows_console() {
FALLBACK
} else {
FANCY
}
}
/// `"┃"` (U+2503 HEAVY VERTICAL) normally, `"│"` (U+2502 LIGHT VERTICAL,
/// CP437 `0xB3`) on legacy ConHost. Always 1 column wide.
///
/// The left accent rail painted beside scrollback blocks and modal
/// panels. The heavy box-drawing vertical is absent from CP437 (which
/// ships only the light `│` and double `║` verticals), so it falls back
/// to the light vertical that the raster font does render.
pub fn accent_bar() -> &'static str {
if is_legacy_windows_console() {
"\u{2502}"
} else {
"\u{2503}"
}
}
/// `"▴"` (U+25B4 SMALL UP-POINTING TRIANGLE) normally, `"▲"` (U+25B2,
/// CP437 `0x1E`) on legacy ConHost. Always 1 column wide.
///
/// The timeline sidebar's previous-turn chevron. The small triangles are
/// absent from CP437; the full-size ones are control-picture glyphs the
/// raster font renders.
pub fn timeline_chevron_up() -> &'static str {
if is_legacy_windows_console() {
"\u{25B2}"
} else {
"\u{25B4}"
}
}
/// `"▾"` (U+25BE SMALL DOWN-POINTING TRIANGLE) normally, `"▼"` (U+25BC,
/// CP437 `0x1F`) on legacy ConHost. Always 1 column wide.
///
/// The timeline sidebar's next-turn chevron; see [`timeline_chevron_up`].
pub fn timeline_chevron_down() -> &'static str {
if is_legacy_windows_console() {
"\u{25BC}"
} else {
"\u{25BE}"
}
}
/// `"━"` (U+2501 HEAVY HORIZONTAL) normally, `"─"` (U+2500 LIGHT
/// HORIZONTAL, CP437 `0xC4`) on legacy ConHost. Always 1 column wide.
///
/// Prefer [`timeline_tick_active`] for the sidebar rail — on legacy ConHost
/// this falls back to the same light stroke used for hover.
pub fn heavy_horizontal() -> &'static str {
if is_legacy_windows_console() {
"\u{2500}"
} else {
"\u{2501}"
}
}
/// `"─"` (U+2500 LIGHT HORIZONTAL, CP437 `0xC4`). Always 1 column wide and
/// present on every target, but exposed here so the timeline sidebar's
/// inactive ticks share one glyph source with [`heavy_horizontal`] instead
/// of hardcoding the codepoint.
pub fn light_horizontal() -> &'static str {
"\u{2500}"
}
/// Precomposed 2-col active tick for the timeline rail: `"━━"` normally,
/// `"══"` (U+2550 BOX DRAWINGS DOUBLE HORIZONTAL, CP437 `0xCD`) on legacy
/// ConHost — distinct from the light hover/idle stroke there.
pub fn timeline_tick_active() -> &'static str {
if is_legacy_windows_console() {
"\u{2550}\u{2550}"
} else {
"\u{2501}\u{2501}"
}
}
/// Precomposed 2-col hover tick for the timeline rail: `"──"` (light
/// horizontal). Idle ticks reuse a single light cell; this is the wide
/// bright hover form.
pub fn timeline_tick_hover() -> &'static str {
"\u{2500}\u{2500}"
}
/// `"●"` (U+25CF BLACK CIRCLE) normally, `"•"` (U+2022 BULLET, CP437
/// `0x07`) on legacy ConHost. Always 1 column wide.
///
/// 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.
pub fn filled_dot() -> &'static str {
if is_legacy_windows_console() {
"\u{2022}"
} else {
"\u{25CF}"
}
}
/// `"▏"` (U+258F LEFT ONE EIGHTH BLOCK) normally, `"│"` (U+2502, CP437
/// `0xB3`) on legacy ConHost. Always 1 column wide.
///
/// The thin left bar marking the selected row in the dashboard and the
/// settings panes. The eighth-width block glyphs are absent from CP437,
/// so it falls back to the light vertical.
pub fn selection_bar() -> &'static str {
if is_legacy_windows_console() {
"\u{2502}"
} else {
"\u{258F}"
}
}
/// `""` (U+203A SINGLE RIGHT-POINTING ANGLE QUOTATION MARK) normally,
/// `">"` (ASCII) on legacy ConHost. Always 1 column wide.
///
/// The chevron used for collapsed fold indicators, settings breadcrumbs,
/// the integer-stepper increment affordance, and the dashboard "next"
/// button. U+203A is absent from CP437, so it falls back to the ASCII
/// greater-than sign.
pub fn chevron() -> &'static str {
if is_legacy_windows_console() {
">"
} else {
"\u{203A}"
}
}
/// `""` (U+2039 SINGLE LEFT-POINTING ANGLE QUOTATION MARK) normally,
/// `"<"` (ASCII) on legacy ConHost. Always 1 column wide.
///
/// The mirror of [`chevron`] — the integer-stepper decrement affordance
/// and the dashboard "prev" button. Kept in lockstep so a fixed ``/`>`
/// never sits next to a tofu ``.
pub fn chevron_left() -> &'static str {
if is_legacy_windows_console() {
"<"
} else {
"\u{2039}"
}
}
/// `"⌄"` (U+2304 DOWN ARROWHEAD) normally, `"v"` (ASCII) on legacy
/// ConHost. Always 1 column wide.
///
/// The downward member of the [`chevron`] family — matches ``'s light
/// visual weight (unlike the solid `▾` disclosure triangle). Used by the
/// scrollback expandable indicator when the selected row is an expanded
/// verb-group header. U+2304 is absent from CP437, so it falls back to a
/// lowercase `v`.
pub fn chevron_down() -> &'static str {
if is_legacy_windows_console() {
"v"
} else {
"\u{2304}"
}
}
/// `"▾"` (U+25BE BLACK DOWN-POINTING SMALL TRIANGLE) normally, `"v"`
/// (ASCII) on legacy ConHost. Always 1 column wide.
///
/// The "expanded" disclosure indicator for a collapsible dashboard
/// section header (the section's rows are visible below it). U+25BE is
/// absent from CP437, so it falls back to a lowercase `v`.
pub fn disclosure_open() -> &'static str {
if is_legacy_windows_console() {
"v"
} else {
"\u{25BE}"
}
}
/// `"▸"` (U+25B8 BLACK RIGHT-POINTING SMALL TRIANGLE) normally, `">"`
/// (ASCII) on legacy ConHost. Always 1 column wide.
///
/// The "collapsed" disclosure indicator for a collapsible dashboard
/// section header (the section's rows are hidden). Pairs with
/// [`disclosure_open`]; U+25B8 is absent from CP437, so it falls back to
/// the ASCII greater-than sign.
pub fn disclosure_closed() -> &'static str {
if is_legacy_windows_console() {
">"
} else {
"\u{25B8}"
}
}
/// `"[✗]"` normally, `"[x]"` on legacy ConHost. Always 3 columns wide.
///
/// Pre-composed bracketed form of [`ballot_x`] so per-frame render paths
/// (bg-task kill button, picker / dashboard close) reuse a `&'static str`
/// instead of allocating a `format!` each frame.
pub fn ballot_x_button() -> &'static str {
if is_legacy_windows_console() {
"[x]"
} else {
"[\u{2717}]"
}
}
/// `"[↗]"` normally, `"[o]"` on legacy ConHost. Always 3 columns wide.
///
/// Pre-composed bracketed sibling of [`ballot_x_button`] for the bg-task
/// view / enlarge button.
pub fn enlarge_button() -> &'static str {
if is_legacy_windows_console() {
"[o]"
} else {
"[\u{2197}]"
}
}
/// Substitute the chrome glyphs that legacy ConHost can't render with
/// legacy-console-safe equivalents (`✓` → `√`, `✗` → `x`, `⚠` → `!`) in
/// free-flowing status text such as toasts.
///
/// 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
/// platform, so toast strings stay byte-identical there.
pub fn legacy_glyph_fallback(s: &str) -> Cow<'_, str> {
if !is_legacy_windows_console() {
return Cow::Borrowed(s);
}
if !s.contains(['\u{2713}', '\u{2717}', '\u{26A0}']) {
return Cow::Borrowed(s);
}
Cow::Owned(to_legacy_glyphs(s))
}
/// Pure glyph → legacy-safe mapping behind [`legacy_glyph_fallback`], split
/// out so tests can exercise the substitution without faking the host probe.
/// `√` matches [`check_mark`]'s fallback; `x` matches [`ballot_x`]'s.
fn to_legacy_glyphs(s: &str) -> String {
s.chars()
.map(|c| match c {
'\u{2713}' => '\u{221A}',
'\u{2717}' => 'x',
'\u{26A0}' => '!',
other => other,
})
.collect()
}
/// True when running on native Windows in a console host whose default
/// font is known not to ship the Dingbats glyphs we use as chrome.
/// Cached for process lifetime.
///
/// `KIGI_FORCE_LEGACY_CONSOLE=1` (or `true`) forces this on regardless of
/// host/terminal, and `=0` (or `false`) forces it off — a QA aid for
/// eyeballing the ASCII fallbacks (or confirming the fancy glyphs) on any
/// platform without a real ConHost.
pub fn is_legacy_windows_console() -> bool {
static CACHE: OnceLock<bool> = OnceLock::new();
*CACHE.get_or_init(|| {
forced_legacy_console_override().unwrap_or_else(|| {
// `env_brand`, not `brand`: a bare ConHost is detected as
// `Unknown`, but `brand` optimistically becomes `WindowsTerminal`
// on native Windows. Font capability needs the raw detection so
// legacy consoles still get the ASCII glyph fallback.
decide_legacy_windows_console(HostOs::current(), terminal_context().env_brand)
})
})
}
/// Read the `KIGI_FORCE_LEGACY_CONSOLE` escape hatch from the environment.
fn forced_legacy_console_override() -> Option<bool> {
parse_forced_legacy_console(std::env::var("KIGI_FORCE_LEGACY_CONSOLE").ok().as_deref())
}
/// Pure parse of the override value so tests don't touch the environment.
/// `"1"` / `"true"` → force on, `"0"` / `"false"` → force off, anything
/// else (including unset) → `None` so normal host/brand detection runs.
fn parse_forced_legacy_console(value: Option<&str>) -> Option<bool> {
match value {
Some("1" | "true") => Some(true),
Some("0" | "false") => Some(false),
_ => None,
}
}
/// Pure decision function so tests can drive (host, brand) pairs
/// without touching ambient state. Default-deny on Windows: an unknown
/// brand is treated as legacy, since bare `cmd.exe` / `powershell.exe`
/// in ConHost sets no terminal env vars and the brand probe returns
/// `Unknown` in exactly the case we need to catch.
fn decide_legacy_windows_console(host: HostOs, brand: TerminalName) -> bool {
if host != HostOs::Windows {
return false;
}
!matches!(
brand,
TerminalName::WindowsTerminal
| TerminalName::VsCode
| TerminalName::Cursor
| TerminalName::Windsurf
| TerminalName::Zed
| TerminalName::WezTerm
| TerminalName::Kitty
| TerminalName::Alacritty
| TerminalName::Ghostty
| TerminalName::Rio
| TerminalName::GrokDesktop
)
}
#[cfg(test)]
mod tests {
use super::*;
use unicode_width::UnicodeWidthStr;
// Both variants must match `PROMPT_ARROW_WIDTH` so callers using the
// constant for layout math don't drift between platforms.
#[test]
fn prompt_arrow_variants_are_two_columns() {
assert_eq!("\u{276F} ".width(), PROMPT_ARROW_WIDTH as usize);
assert_eq!("> ".width(), PROMPT_ARROW_WIDTH as usize);
}
// Both record-dot states must be exactly 1 column so the "Recording"
// label position is stable as the indicator pulses.
#[test]
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
}
#[test]
fn collapsed_accent_variants_are_one_column() {
assert_eq!("\u{2759}".width(), 1);
assert_eq!("|".width(), 1);
}
// Every icon and its fallback must be exactly one column so fixed-width
// button layouts (`[✗]` / `[↗]`, the bg-task overlay, the status badges)
// don't shift between platforms.
#[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
] {
assert_eq!(fancy.width(), 1, "icon {fancy:?} must be 1 column");
assert_eq!(
fallback.width(),
1,
"fallback {fallback:?} must be 1 column"
);
}
}
// Every diamond glyph and its legacy fallback must be exactly one
// column so the `/context` usage bar, scrollback bullets, picker fold
// indicators, and dashboard row markers keep their layout on every
// platform.
#[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
] {
assert_eq!(fancy.width(), 1, "diamond {fancy:?} must be 1 column");
assert_eq!(
fallback.width(),
1,
"fallback {fallback:?} must be 1 column"
);
}
}
// Each chrome glyph and its legacy fallback must be exactly one column
// so the accent rail, status dots, selection bars, and chevrons keep
// their layout on every platform.
#[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
] {
assert_eq!(fancy.width(), 1, "glyph {fancy:?} must be 1 column");
assert_eq!(
fallback.width(),
1,
"fallback {fallback:?} must be 1 column"
);
}
}
// Both the fancy frames and the fallbacks of each spinner and the
// monitor pulse must be 1 column so animating them never shifts the
// label / timer that follows.
#[test]
fn spinner_frames_are_one_column() {
for frame in braille_spinner_frames()
.iter()
.chain(dot_spinner_frames().iter())
.chain(monitor_icon_frames().iter())
.chain(
[
"|", "/", "-", "\\", ".", ":", "\u{00b7}", "\u{25cb}", "\u{2022}",
]
.iter(),
)
{
assert_eq!(frame.width(), 1, "spinner frame {frame:?} must be 1 column");
}
}
// On the (non-Windows) test host the helpers must return the fancy
// glyphs, and the `char` helpers must agree with their `&str` siblings.
#[test]
fn glyph_helpers_return_fancy_on_non_legacy() {
assert!(!is_legacy_windows_console());
assert_eq!(diamond_filled(), "\u{25C6}");
assert_eq!(diamond_hollow(), "\u{25C7}");
assert_eq!(diamond_dotted(), "\u{25C8}");
assert_eq!(diamond_filled_char(), '\u{25C6}');
assert_eq!(diamond_hollow_char(), '\u{25C7}');
assert_eq!(braille_spinner_frames()[0], "\u{280b}");
assert_eq!(dot_spinner_frames()[2], "\u{2e2c}");
assert_eq!(
monitor_icon_frames(),
["\u{25CB}", "\u{25CE}", "\u{25C9}", "\u{25CE}"]
);
}
// Both variants of each pre-composed button must keep a fixed column
// width so the right-aligned chrome (cancel button, bg-task overlay,
// close affordances) lands in the same cells on every platform.
#[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
] {
assert_eq!(fancy.width(), cols, "button {fancy:?} must be {cols} cols");
assert_eq!(
fallback.width(),
cols,
"fallback {fallback:?} must be {cols} cols"
);
}
}
// The toast scrubber maps every legacy-tofu chrome glyph to a 1-column
// legacy-safe stand-in and leaves all other text untouched.
#[test]
fn to_legacy_glyphs_maps_known_glyphs() {
assert_eq!(to_legacy_glyphs("\u{2713}\u{2717}\u{26A0}"), "\u{221A}x!");
assert_eq!(
to_legacy_glyphs("\u{2713} Saved: on"),
"\u{221A} Saved: on",
"only the glyph is replaced; surrounding text is preserved"
);
// Glyphs this module doesn't own (em dash, CJK) pass through verbatim.
assert_eq!(
to_legacy_glyphs("a \u{2014} \u{4e2d}"),
"a \u{2014} \u{4e2d}"
);
}
// On the (non-Windows) test host the funnel must be a zero-copy borrow
// so non-legacy toasts are byte-identical to the input.
#[test]
fn legacy_glyph_fallback_is_borrow_on_non_legacy() {
assert!(!is_legacy_windows_console());
assert!(matches!(
legacy_glyph_fallback("\u{2713} Saved"),
Cow::Borrowed("\u{2713} Saved")
));
}
#[test]
fn forced_legacy_console_override_parses_known_values() {
assert_eq!(parse_forced_legacy_console(Some("1")), Some(true));
assert_eq!(parse_forced_legacy_console(Some("true")), Some(true));
assert_eq!(parse_forced_legacy_console(Some("0")), Some(false));
assert_eq!(parse_forced_legacy_console(Some("false")), Some(false));
// Unset or unrecognized → defer to normal host/brand detection.
assert_eq!(parse_forced_legacy_console(None), None);
assert_eq!(parse_forced_legacy_console(Some("")), None);
assert_eq!(parse_forced_legacy_console(Some("yes")), None);
}
#[test]
fn non_windows_is_never_legacy() {
for brand in [
TerminalName::Unknown,
TerminalName::AppleTerminal,
TerminalName::Vte,
TerminalName::WindowsTerminal,
] {
assert!(!decide_legacy_windows_console(HostOs::Macos, brand));
assert!(!decide_legacy_windows_console(HostOs::Linux, brand));
assert!(!decide_legacy_windows_console(HostOs::Other, brand));
}
}
#[test]
fn windows_unknown_is_legacy() {
// Realistic ConHost case: no terminal env vars set.
assert!(decide_legacy_windows_console(
HostOs::Windows,
TerminalName::Unknown
));
}
#[test]
fn windows_terminal_is_not_legacy() {
assert!(!decide_legacy_windows_console(
HostOs::Windows,
TerminalName::WindowsTerminal
));
}
#[test]
fn vscode_family_on_windows_is_not_legacy() {
for brand in [
TerminalName::VsCode,
TerminalName::Cursor,
TerminalName::Windsurf,
TerminalName::Zed,
] {
assert!(!decide_legacy_windows_console(HostOs::Windows, brand));
}
}
#[test]
fn modern_emulators_on_windows_are_not_legacy() {
for brand in [
TerminalName::WezTerm,
TerminalName::Kitty,
TerminalName::Alacritty,
TerminalName::Ghostty,
TerminalName::Rio,
TerminalName::GrokDesktop,
] {
assert!(!decide_legacy_windows_console(HostOs::Windows, brand));
}
}
// AppleTerminal/VTE can't actually be probed on Windows; the
// assertion is the default-deny safety net for unfamiliar brands.
#[test]
fn unfamiliar_brands_on_windows_default_to_legacy() {
for brand in [
TerminalName::AppleTerminal,
TerminalName::Vte,
TerminalName::Iterm2,
TerminalName::WarpTerminal,
] {
assert!(decide_legacy_windows_console(HostOs::Windows, brand));
}
}
}
@@ -0,0 +1,505 @@
//! One-shot primary-display refresh probe (OnceLock-cached).
//! Fail-closed: never panics into callers; no TTY IO; no display mode mutation.
use std::sync::OnceLock;
use std::time::Instant;
use super::{DisplayServer, HostOs, is_wsl};
/// Sane bounds; outside → fail closed.
const MIN_HZ: u32 = 30;
const MAX_HZ: u32 = 500;
#[derive(Debug, Clone, Copy, PartialEq, Eq, strum::Display, strum::AsRefStr)]
#[strum(serialize_all = "snake_case")]
pub enum DisplayRefreshSource {
None,
MacosCoreGraphics,
WindowsEnumDisplaySettings,
Linux,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct DisplayRefreshProbeResult {
pub hz: Option<u32>,
pub source: DisplayRefreshSource,
/// Empty when ok; else a stable skip/error token.
pub skip_reason: &'static str,
pub duration_ms: u64,
}
impl DisplayRefreshProbeResult {
/// `ok` | `skipped` | `error`
pub fn outcome(self) -> &'static str {
if self.hz.is_some() {
"ok"
} else if self.skip_reason == "error" {
"error"
} else {
"skipped"
}
}
}
/// Once per process. Infallible; never panics.
pub fn probe_display_refresh() -> DisplayRefreshProbeResult {
static CACHE: OnceLock<DisplayRefreshProbeResult> = OnceLock::new();
*CACHE.get_or_init(probe_uncached)
}
fn probe_uncached() -> DisplayRefreshProbeResult {
let start = Instant::now();
let (hz, source, skip_reason) = probe_inner();
DisplayRefreshProbeResult {
hz,
source,
skip_reason,
duration_ms: start.elapsed().as_millis() as u64,
}
}
fn probe_inner() -> (Option<u32>, DisplayRefreshSource, &'static str) {
let is_ssh = kigi_shared::clipboard::is_remote_session();
let wsl = is_wsl();
let os = HostOs::current();
let display = DisplayServer::current();
// Avoid FFI when env already forces a skip.
let platform_hz = if precheck_skip(is_ssh, wsl).is_some() {
None
} else {
match os {
HostOs::Macos => Some(probe_macos()),
HostOs::Windows => Some(probe_windows()),
HostOs::Linux | HostOs::Other => None,
}
};
decide(is_ssh, wsl, os, display, platform_hz)
}
/// Pure matrix used by production and tests; inject only the platform result.
fn decide(
is_ssh: bool,
is_wsl: bool,
os: HostOs,
display: DisplayServer,
platform_hz: Option<Result<u32, &'static str>>,
) -> (Option<u32>, DisplayRefreshSource, &'static str) {
if let Some(reason) = precheck_skip(is_ssh, is_wsl) {
return (None, DisplayRefreshSource::None, reason);
}
match os {
HostOs::Macos => {
let source = DisplayRefreshSource::MacosCoreGraphics;
match platform_hz.unwrap_or(Err("error")) {
Ok(hz) => accept_hz(hz, source),
Err(reason) => (None, source, reason),
}
}
HostOs::Windows => {
let source = DisplayRefreshSource::WindowsEnumDisplaySettings;
match platform_hz.unwrap_or(Err("error")) {
Ok(hz) => accept_hz(hz, source),
Err(reason) => (None, source, reason),
}
}
HostOs::Linux => {
let reason = linux_skip_reason(display);
(None, DisplayRefreshSource::Linux, reason)
}
HostOs::Other => (None, DisplayRefreshSource::None, "unsupported"),
}
}
fn precheck_skip(is_ssh: bool, is_wsl: bool) -> Option<&'static str> {
if is_ssh {
return Some("ssh");
}
if is_wsl {
return Some("wsl");
}
None
}
fn linux_skip_reason(display: DisplayServer) -> &'static str {
match display {
DisplayServer::Wayland => "wayland_unsupported",
DisplayServer::X11 => "x11_unsupported",
_ => "no_display",
}
}
fn accept_hz(
hz: u32,
source: DisplayRefreshSource,
) -> (Option<u32>, DisplayRefreshSource, &'static str) {
if !(MIN_HZ..=MAX_HZ).contains(&hz) {
return (None, source, "out_of_range");
}
(Some(hz), source, "")
}
#[cfg(target_os = "macos")]
fn probe_macos() -> Result<u32, &'static str> {
// Fail-closed if FFI panics (abort builds still abort).
match std::panic::catch_unwind(|| {
// SAFETY: read-only CoreGraphics display query; no mode mutation.
unsafe { macos_main_display_refresh_hz() }
}) {
Ok(inner) => inner,
Err(_) => Err("error"),
}
}
#[cfg(not(target_os = "macos"))]
fn probe_macos() -> Result<u32, &'static str> {
Err("unsupported")
}
#[cfg(target_os = "macos")]
unsafe fn macos_main_display_refresh_hz() -> Result<u32, &'static str> {
type CgDisplayModeRef = *mut core::ffi::c_void;
type CgDirectDisplayId = u32;
#[link(name = "CoreGraphics", kind = "framework")]
unsafe extern "C" {
fn CGMainDisplayID() -> CgDirectDisplayId;
fn CGDisplayCopyDisplayMode(display: CgDirectDisplayId) -> CgDisplayModeRef;
fn CGDisplayModeGetRefreshRate(mode: CgDisplayModeRef) -> f64;
fn CGDisplayModeRelease(mode: CgDisplayModeRef);
}
// SAFETY: stable public CG APIs; null mode handled; Release pairs with Copy.
let display = unsafe { CGMainDisplayID() };
let mode = unsafe { CGDisplayCopyDisplayMode(display) };
if mode.is_null() {
return Err("error");
}
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");
}
if rate == 0.0 {
return Err("indeterminate");
}
Ok(rate.round() as u32)
}
#[cfg(target_os = "windows")]
fn probe_windows() -> Result<u32, &'static str> {
match std::panic::catch_unwind(|| {
// SAFETY: read-only EnumDisplayDevices/Settings for primary only.
unsafe { windows_primary_display_refresh_hz() }
}) {
Ok(inner) => inner,
Err(_) => Err("error"),
}
}
#[cfg(not(target_os = "windows"))]
fn probe_windows() -> Result<u32, &'static str> {
Err("unsupported")
}
/// Primary monitor Hz (matches macOS `CGMainDisplayID`). Null device name to
/// `EnumDisplaySettingsW` is the *current* adapter, which can differ from the
/// primary on multi-monitor machines.
#[cfg(target_os = "windows")]
unsafe fn windows_primary_display_refresh_hz() -> Result<u32, &'static str> {
use windows_sys::Win32::Graphics::Gdi::{
DEVMODEW, DISPLAY_DEVICE_PRIMARY_DEVICE, DISPLAY_DEVICEW, ENUM_CURRENT_SETTINGS,
EnumDisplayDevicesW, EnumDisplaySettingsW,
};
// Bound device enumeration so a broken driver cannot spin forever.
for i in 0u32..32 {
// SAFETY: zeroed DISPLAY_DEVICEW with cb set is the documented pattern.
let mut device: DISPLAY_DEVICEW = unsafe { std::mem::zeroed() };
device.cb = std::mem::size_of::<DISPLAY_DEVICEW>() as u32;
// SAFETY: null parent = desktop adapters; i indexes adapters.
let ok = unsafe { EnumDisplayDevicesW(std::ptr::null(), i, &mut device, 0) };
if ok == 0 {
break;
}
if device.StateFlags & DISPLAY_DEVICE_PRIMARY_DEVICE == 0 {
continue;
}
// SAFETY: zeroed DEVMODEW with dmSize set; DeviceName is the primary.
let mut devmode: DEVMODEW = unsafe { std::mem::zeroed() };
devmode.dmSize = std::mem::size_of::<DEVMODEW>() as u16;
let ok = unsafe {
EnumDisplaySettingsW(
device.DeviceName.as_ptr(),
ENUM_CURRENT_SETTINGS,
&mut devmode,
)
};
if ok == 0 {
return Err("error");
}
let hz = devmode.dmDisplayFrequency;
// 0/1 often mean "default hardware rate" — fail closed.
if hz < 2 {
return Err("error");
}
return Ok(hz);
}
Err("error")
}
#[cfg(test)]
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.
#[test]
fn probe_display_refresh_never_panics() {
let r = probe_display_refresh();
assert!(
matches!(r.outcome(), "ok" | "skipped" | "error"),
"unexpected outcome {:?}",
r.outcome()
);
if let Some(hz) = r.hz {
assert!((MIN_HZ..=MAX_HZ).contains(&hz), "hz out of bounds: {hz}");
assert_eq!(r.outcome(), "ok");
assert!(r.skip_reason.is_empty());
} else {
assert!(!r.skip_reason.is_empty() || r.outcome() == "error");
}
// Second call hits OnceLock — still must not panic.
let r2 = probe_display_refresh();
assert_eq!(r.hz, r2.hz);
assert_eq!(r.outcome(), r2.outcome());
}
#[test]
fn ssh_skips_before_platform() {
let (hz, source, reason) = decide(
true,
false,
HostOs::Macos,
DisplayServer::Unknown,
Some(Ok(120)),
);
assert_eq!(hz, None);
assert_eq!(source, DisplayRefreshSource::None);
assert_eq!(reason, "ssh");
}
#[test]
fn wsl_skips() {
let (hz, source, reason) =
decide(false, true, HostOs::Linux, DisplayServer::X11, Some(Ok(60)));
assert_eq!(hz, None);
assert_eq!(source, DisplayRefreshSource::None);
assert_eq!(reason, "wsl");
}
#[test]
fn linux_wayland_unsupported() {
let (hz, source, reason) =
decide(false, false, HostOs::Linux, DisplayServer::Wayland, None);
assert_eq!(hz, None);
assert_eq!(source, DisplayRefreshSource::Linux);
assert_eq!(reason, "wayland_unsupported");
}
#[test]
fn linux_x11_unsupported() {
let (hz, source, reason) = decide(false, false, HostOs::Linux, DisplayServer::X11, None);
assert_eq!(hz, None);
assert_eq!(source, DisplayRefreshSource::Linux);
assert_eq!(reason, "x11_unsupported");
}
#[test]
fn linux_no_display() {
let (hz, source, reason) =
decide(false, false, HostOs::Linux, DisplayServer::Unknown, None);
assert_eq!(hz, None);
assert_eq!(source, DisplayRefreshSource::Linux);
assert_eq!(reason, "no_display");
}
#[test]
fn host_os_other_unsupported() {
let (hz, source, reason) =
decide(false, false, HostOs::Other, DisplayServer::Unknown, None);
assert_eq!(hz, None);
assert_eq!(source, DisplayRefreshSource::None);
assert_eq!(reason, "unsupported");
}
#[test]
fn out_of_range_low() {
let (hz, source, reason) = decide(
false,
false,
HostOs::Macos,
DisplayServer::Unknown,
Some(Ok(15)),
);
assert_eq!(hz, None);
assert_eq!(source, DisplayRefreshSource::MacosCoreGraphics);
assert_eq!(reason, "out_of_range");
}
#[test]
fn out_of_range_high() {
let (hz, source, reason) = decide(
false,
false,
HostOs::Windows,
DisplayServer::Unknown,
Some(Ok(1000)),
);
assert_eq!(hz, None);
assert_eq!(source, DisplayRefreshSource::WindowsEnumDisplaySettings);
assert_eq!(reason, "out_of_range");
}
#[test]
fn ok_120_macos() {
let (hz, source, reason) = decide(
false,
false,
HostOs::Macos,
DisplayServer::Unknown,
Some(Ok(120)),
);
assert_eq!(hz, Some(120));
assert_eq!(source, DisplayRefreshSource::MacosCoreGraphics);
assert_eq!(reason, "");
}
#[test]
fn ok_144_windows() {
let (hz, source, reason) = decide(
false,
false,
HostOs::Windows,
DisplayServer::Unknown,
Some(Ok(144)),
);
assert_eq!(hz, Some(144));
assert_eq!(source, DisplayRefreshSource::WindowsEnumDisplaySettings);
assert_eq!(reason, "");
}
#[test]
fn backend_error() {
let (hz, source, reason) = decide(
false,
false,
HostOs::Macos,
DisplayServer::Unknown,
Some(Err("error")),
);
assert_eq!(hz, None);
assert_eq!(source, DisplayRefreshSource::MacosCoreGraphics);
assert_eq!(reason, "error");
}
#[test]
fn indeterminate_is_skipped_not_error() {
let (hz, source, reason) = decide(
false,
false,
HostOs::Macos,
DisplayServer::Unknown,
Some(Err("indeterminate")),
);
assert_eq!(
(hz, source, reason),
(
None,
DisplayRefreshSource::MacosCoreGraphics,
"indeterminate"
)
);
assert_eq!(
DisplayRefreshProbeResult {
hz: None,
source,
skip_reason: reason,
duration_ms: 0,
}
.outcome(),
"skipped"
);
}
#[test]
fn missing_platform_hz_is_error() {
let (hz, source, reason) =
decide(false, false, HostOs::Macos, DisplayServer::Unknown, None);
assert_eq!(hz, None);
assert_eq!(source, DisplayRefreshSource::MacosCoreGraphics);
assert_eq!(reason, "error");
}
#[test]
fn boundary_hz_accepted() {
for hz_in in [30u32, 500] {
let (hz, _, reason) = decide(
false,
false,
HostOs::Macos,
DisplayServer::Unknown,
Some(Ok(hz_in)),
);
assert_eq!(hz, Some(hz_in));
assert_eq!(reason, "");
}
}
#[test]
fn source_strum_snake_case_stable() {
assert_eq!(DisplayRefreshSource::None.as_ref(), "none");
assert_eq!(
DisplayRefreshSource::MacosCoreGraphics.as_ref(),
"macos_core_graphics"
);
assert_eq!(
DisplayRefreshSource::WindowsEnumDisplaySettings.as_ref(),
"windows_enum_display_settings"
);
assert_eq!(DisplayRefreshSource::Linux.as_ref(), "linux");
assert_eq!(DisplayRefreshSource::Linux.to_string(), "linux");
}
#[test]
fn outcome_tokens() {
let ok = DisplayRefreshProbeResult {
hz: Some(120),
source: DisplayRefreshSource::MacosCoreGraphics,
skip_reason: "",
duration_ms: 1,
};
assert_eq!(ok.outcome(), "ok");
let skipped = DisplayRefreshProbeResult {
hz: None,
source: DisplayRefreshSource::None,
skip_reason: "ssh",
duration_ms: 0,
};
assert_eq!(skipped.outcome(), "skipped");
let err = DisplayRefreshProbeResult {
hz: None,
source: DisplayRefreshSource::MacosCoreGraphics,
skip_reason: "error",
duration_ms: 2,
};
assert_eq!(err.outcome(), "error");
}
}
@@ -0,0 +1,185 @@
//! Host platform and display server classification.
use std::collections::HashMap;
use std::ffi::OsString;
use std::sync::OnceLock;
mod display_refresh;
pub use display_refresh::{DisplayRefreshProbeResult, DisplayRefreshSource, probe_display_refresh};
/// Process env as UTF-8. Skips non-Unicode entries (`vars()` panics on those).
pub fn collect_unicode_env() -> HashMap<String, String> {
unicode_env_from_os(std::env::vars_os())
}
/// Pure helper: drop OsString pairs that are not valid Unicode.
pub fn unicode_env_from_os(
iter: impl IntoIterator<Item = (OsString, OsString)>,
) -> HashMap<String, String> {
iter.into_iter()
.filter_map(|(k, v)| Some((k.into_string().ok()?, v.into_string().ok()?)))
.collect()
}
#[derive(Debug, Clone, Copy, Default, Eq, PartialEq, strum::Display)]
#[strum(serialize_all = "snake_case")]
#[non_exhaustive]
pub enum HostOs {
Macos,
Linux,
Windows,
#[default]
Other,
}
impl HostOs {
/// Call on demand since cfg is compile-time constant.
pub fn current() -> Self {
if cfg!(target_os = "macos") {
Self::Macos
} else if cfg!(target_os = "linux") {
Self::Linux
} else if cfg!(target_os = "windows") {
Self::Windows
} else {
Self::Other
}
}
}
/// 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.
pub use kigi_tty_utils::is_wsl;
#[derive(Debug, Clone, Copy, Default, Eq, PartialEq, strum::Display)]
#[strum(serialize_all = "snake_case")]
#[non_exhaustive]
pub enum DisplayServer {
Quartz,
Wayland,
X11,
Win32,
#[default]
Unknown,
}
impl DisplayServer {
/// Detect the display server. Cached for process lifetime on Linux
/// (env vars don't change); compile-time constant on macOS/Windows.
pub fn current() -> Self {
static CACHE: OnceLock<DisplayServer> = OnceLock::new();
*CACHE.get_or_init(|| {
let env = collect_unicode_env();
Self::detect_from_env(&env)
})
}
/// Pure helper so tests can drive env directly.
fn detect_from_env(env: &HashMap<String, String>) -> Self {
match HostOs::current() {
HostOs::Macos => Self::Quartz,
HostOs::Windows => Self::Win32,
HostOs::Linux => {
if env.get("WAYLAND_DISPLAY").is_some_and(|v| !v.is_empty()) {
Self::Wayland
} else if env.get("DISPLAY").is_some_and(|v| !v.is_empty()) {
Self::X11
} else {
Self::Unknown
}
}
HostOs::Other => Self::Unknown,
}
}
}
#[cfg(test)]
mod unicode_env_tests {
use super::*;
use std::ffi::OsString;
#[test]
fn unicode_env_from_os_skips_non_unicode_key_or_value() {
#[cfg(unix)]
{
use std::os::unix::ffi::OsStringExt;
let bad = OsString::from_vec(vec![0xff, 0xfe]);
let map = unicode_env_from_os([
(bad.clone(), OsString::from("ok")),
(OsString::from("OK_KEY"), bad),
(OsString::from("GOOD"), OsString::from("yes")),
]);
assert_eq!(map, HashMap::from([("GOOD".into(), "yes".into())]));
}
#[cfg(windows)]
{
use std::os::windows::ffi::OsStringExt;
let bad = OsString::from_wide(&[0xD800]); // lone surrogate
let map = unicode_env_from_os([
(bad.clone(), OsString::from("ok")),
(OsString::from("OK_KEY"), bad),
(OsString::from("GOOD"), OsString::from("yes")),
]);
assert_eq!(map, HashMap::from([("GOOD".into(), "yes".into())]));
}
}
}
// All remaining tests here are Linux-only DisplayServer tests; WSL detection
// tests live with the implementation in `kigi-tty-utils`.
#[cfg(all(test, target_os = "linux"))]
mod tests {
use super::*;
fn env(pairs: &[(&str, &str)]) -> HashMap<String, String> {
pairs
.iter()
.map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
.collect()
}
#[test]
fn display_server_wayland() {
assert_eq!(
DisplayServer::detect_from_env(&env(&[("WAYLAND_DISPLAY", "wayland-0")])),
DisplayServer::Wayland,
);
}
#[test]
fn display_server_x11() {
assert_eq!(
DisplayServer::detect_from_env(&env(&[("DISPLAY", ":0")])),
DisplayServer::X11,
);
}
#[test]
fn display_server_wayland_wins_over_x11() {
assert_eq!(
DisplayServer::detect_from_env(&env(&[
("WAYLAND_DISPLAY", "wayland-0"),
("DISPLAY", ":0"),
])),
DisplayServer::Wayland,
);
}
#[test]
fn display_server_unknown_when_no_display() {
assert_eq!(
DisplayServer::detect_from_env(&env(&[])),
DisplayServer::Unknown,
);
}
#[test]
fn display_server_empty_wayland_display_ignored() {
assert_eq!(
DisplayServer::detect_from_env(&env(&[("WAYLAND_DISPLAY", ""), ("DISPLAY", ":1")])),
DisplayServer::X11,
);
}
}
@@ -0,0 +1,13 @@
pub mod appearance;
pub mod clipboard;
pub mod gboom;
pub mod glyphs;
pub mod host;
pub mod link_opener;
pub mod modal_window_state;
pub mod prompt_images;
pub mod render;
pub mod syntax;
pub mod terminal;
pub mod theme;
pub mod util;
@@ -0,0 +1,449 @@
//! Shared URL-opening and scheme validation utilities.
//!
//! Extracted from the `OpenSupergrokUrl` dispatch handler so that any
//! code path (keyboard navigation, mouse click, action dispatch) can
//! open a link safely without duplicating platform-specific logic.
use crate::terminal::hyperlinks::SchemeFilter;
/// Open a URL in the system's default browser/handler.
///
/// Spawns the platform-native opener (`open` on macOS, `xdg-open` on
/// Linux, `cmd /c start` on Windows) with fully detached stdio so it
/// cannot block the pager.
///
/// **Callers handling untrusted input** should call [`is_safe_to_open`]
/// first, or use [`open_url_if_safe`] which combines both steps.
pub fn open_url(url: &str) {
// Test seam: PTY e2e must observe the open without launching a real
// browser. When set, append the URL to the file and skip the OS opener.
if let Ok(path) = std::env::var("KIGI_TEST_OPEN_URL_FILE") {
use std::io::Write;
// Surface misconfiguration: a swallowed write leaves the PTY test
// failing with a generic timeout and no clue why.
if let Err(e) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.and_then(|mut f| writeln!(f, "{url}"))
{
tracing::warn!(error = %e, path, "KIGI_TEST_OPEN_URL_FILE write failed");
}
return;
}
#[cfg(target_os = "macos")]
let cmd = "open";
#[cfg(target_os = "windows")]
let cmd = "cmd";
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
let cmd = "xdg-open";
let mut command = std::process::Command::new(cmd);
#[cfg(target_os = "windows")]
command.args(["/c", "start", ""]);
command
.arg(url)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null());
kigi_tools::util::detach_std_command(&mut command);
if let Err(e) = command.spawn() {
// Redact URL to avoid leaking sensitive query params to logs.
let redacted = url::Url::parse(url)
.map(|mut u| {
u.set_query(None);
u.set_fragment(None);
u.to_string()
})
.unwrap_or_else(|_| "<unparseable>".to_string());
tracing::warn!(url = %redacted, error = %e, "failed to open URL");
}
}
/// Build the `open`/`xdg-open` opener command (macOS / Linux / BSD).
///
/// The returned command is TTY-guarded via [`kigi_tty_utils::detach_std_command`]
/// (`setsid`/`setpgid`) so the spawned GUI helper and its children can't grab
/// the TUI's `/dev/tty`, with stdio fully redirected to null. Split from
/// [`open_path`] so it can be unit-tested without spawning. The path is a single
/// argument, never interpolated into a shell string. Windows uses
/// [`reveal_in_explorer`] instead.
#[cfg(not(target_os = "windows"))]
fn build_open_path_command(path: &std::path::Path) -> std::process::Command {
#[cfg(target_os = "macos")]
let mut command = std::process::Command::new("open");
#[cfg(not(target_os = "macos"))]
let mut command = std::process::Command::new("xdg-open");
command
.arg(path)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null());
kigi_tty_utils::detach_std_command(&mut command);
command
}
/// Reveal/open a local file in the OS file manager or default application.
///
/// Returns `true` on success. Takes a trusted filesystem path (no scheme
/// validation, unlike [`open_url`]).
///
/// - **Windows**: `explorer.exe /select,<path>` reveals + highlights the file
/// in Explorer. We deliberately avoid `cmd /c start`, whose `%VAR%`
/// expansion corrupts the percent-encoded session-directory segment in
/// imagine media paths (e.g. `…\C%3A%5CUsers…`).
/// - **macOS / Linux**: `open` / `xdg-open` open the file in its default app.
pub fn open_path(path: &std::path::Path) -> bool {
// Never launch a real GUI app in tests.
#[cfg(test)]
{
!path.as_os_str().is_empty()
}
#[cfg(all(not(test), target_os = "windows"))]
{
reveal_in_explorer(path)
}
#[cfg(all(not(test), not(target_os = "windows")))]
{
match build_open_path_command(path).spawn() {
Ok(_) => true,
Err(e) => {
tracing::warn!(path = %path.display(), error = %e, "failed to open file natively");
false
}
}
}
}
/// Reveal `path` in a new Explorer window with the file selected.
///
/// Uses `raw_arg` so Explorer's required `/select,"<path>"` quoting is passed
/// verbatim — the default arg quoting wraps the whole token and breaks the
/// switch. Launched directly (not via `cmd`), so percent characters in the
/// path are not expanded by the shell. Session dirs embed a urlencoded cwd
/// segment (`C%3A%5CUsers…`); those `%` chars must reach Explorer intact.
///
/// Prefer the on-disk path as-is. When the file is missing, open the parent
/// folder (no `/select`) so the user lands near the media instead of Home.
#[cfg(all(not(test), target_os = "windows"))]
fn reveal_in_explorer(path: &std::path::Path) -> bool {
use std::os::windows::process::CommandExt;
// Prefer the real on-disk location (absolute). Fall back to parent when
// the file was deleted so Explorer does not dump the user in Home.
let target = if path.is_file() || path.is_dir() {
path.to_path_buf()
} else if let Some(parent) = path.parent().filter(|p| p.is_dir()) {
tracing::debug!(
path = %path.display(),
parent = %parent.display(),
"media path missing; opening parent folder in Explorer"
);
parent.to_path_buf()
} else {
path.to_path_buf()
};
let select_file = target.is_file();
let mut command = std::process::Command::new("explorer");
// Escape embedded double-quotes in the path so the `/select,"<path>"`
// quoting does not break. Windows file-system paths cannot legally contain
// `"`, but percent-decoded display paths or future user-chosen filenames
// could, so be defensive.
let escaped = target.display().to_string().replace('"', "\"\"");
if select_file {
command.raw_arg(format!("/select,\"{}\"", escaped));
} else {
// Open the folder itself (no /select) — works for dirs and as a
// fallback when we only have a parent path.
command.raw_arg(format!("\"{}\"", escaped));
}
command
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null());
kigi_tty_utils::detach_std_command(&mut command);
// explorer.exe returns exit code 1 even on success, so a successful spawn
// is the best signal we have.
match command.spawn() {
Ok(_) => true,
Err(e) => {
tracing::warn!(path = %target.display(), error = %e, "failed to reveal file in Explorer");
false
}
}
}
/// Check if a URL's scheme is safe to open.
///
/// Uses the `url` crate for robust scheme extraction. Falls back to
/// prefix matching for non-standard URLs that `url::Url::parse` rejects.
pub fn is_safe_to_open(url: &str, filter: SchemeFilter) -> bool {
let url = url.trim();
if let Ok(parsed) = url::Url::parse(url) {
return filter.allows(parsed.scheme());
}
// Fallback: check for scheme via "://" prefix, lowercasing for
// case-insensitive comparison (SchemeFilter matches lowercase literals).
if let Some((scheme, _)) = url.split_once("://") {
return filter.allows(&scheme.to_ascii_lowercase());
}
// Defensive: url::Url::parse handles well-formed mailto, but guard
// against edge cases where the parser rejects a mailto-like string.
if let Some((scheme, _)) = url.split_once(':')
&& scheme.eq_ignore_ascii_case("mailto")
{
return filter.allows(&scheme.to_ascii_lowercase());
}
false
}
/// Validate scheme and open a URL if permitted. Returns `true` if opened.
pub fn open_url_if_safe(url: &str, filter: SchemeFilter) -> bool {
if is_safe_to_open(url, filter) {
open_url(url);
true
} else {
tracing::debug!(url, "URL scheme not permitted");
false
}
}
/// Ensure `url` carries the given query parameter, returning the rewritten URL.
///
/// If the URL already contains a parameter with that name, its value is left
/// untouched (the caller upstream may have intentionally set one). On parse
/// failure, the original string is returned unchanged so this is safe to apply
/// to opener input from untrusted sources.
///
/// Used by the SuperGrok upsell flow to attribute clicks to `referrer=grok-build`,
/// matching the OAuth consent screen and x.ai/cli marketing links regardless of
/// what the remote settings `gate_url` value happens to be.
pub fn ensure_query_param(url: &str, key: &str, value: &str) -> String {
let Ok(mut parsed) = url::Url::parse(url) else {
return url.to_string();
};
let already_present = parsed.query_pairs().any(|(k, _)| k == key);
if already_present {
return parsed.to_string();
}
parsed.query_pairs_mut().append_pair(key, value);
parsed.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn open_path_command_passes_path_as_a_single_arg() {
// Path with spaces must be one argument, never shell-interpolated.
let path = std::path::Path::new("/tmp/grok session/image 1.jpg");
let command = build_open_path_command(path);
let args: Vec<_> = command.get_args().map(|a| a.to_os_string()).collect();
assert!(args.contains(&path.as_os_str().to_os_string()));
}
#[test]
fn standard_http_schemes_allowed() {
assert!(is_safe_to_open(
"http://example.com",
SchemeFilter::Standard
));
assert!(is_safe_to_open(
"https://example.com/path?q=1",
SchemeFilter::Standard
));
}
#[test]
fn mailto_allowed() {
assert!(is_safe_to_open(
"mailto:user@example.com",
SchemeFilter::Standard
));
}
#[test]
fn file_scheme_blocked_by_standard() {
// file:// removed from Standard to prevent local file / SSRF attacks.
assert!(!is_safe_to_open(
"file:///home/user/doc.pdf",
SchemeFilter::Standard
));
// But allowed under EditorExtended.
assert!(is_safe_to_open(
"file:///home/user/doc.pdf",
SchemeFilter::EditorExtended
));
}
#[test]
fn javascript_scheme_blocked() {
assert!(!is_safe_to_open(
"javascript:alert(1)",
SchemeFilter::Standard
));
}
#[test]
fn data_scheme_blocked() {
assert!(!is_safe_to_open(
"data:text/html,<h1>hi</h1>",
SchemeFilter::Standard
));
}
#[test]
fn empty_and_garbage_rejected() {
assert!(!is_safe_to_open("", SchemeFilter::Standard));
assert!(!is_safe_to_open("not-a-url", SchemeFilter::Standard));
assert!(!is_safe_to_open(
"://missing-scheme",
SchemeFilter::Standard
));
}
#[test]
fn editor_schemes_with_extended_filter() {
assert!(is_safe_to_open(
"vscode://file/path",
SchemeFilter::EditorExtended
));
assert!(is_safe_to_open(
"cursor://open",
SchemeFilter::EditorExtended
));
assert!(is_safe_to_open("idea://open", SchemeFilter::EditorExtended));
assert!(is_safe_to_open("zed://open", SchemeFilter::EditorExtended));
}
#[test]
fn editor_schemes_blocked_by_standard_filter() {
assert!(!is_safe_to_open(
"vscode://file/path",
SchemeFilter::Standard
));
assert!(!is_safe_to_open("cursor://open", SchemeFilter::Standard));
}
#[test]
fn scheme_case_sensitivity() {
// url::Url normalizes to lowercase
assert!(is_safe_to_open(
"HTTP://EXAMPLE.COM",
SchemeFilter::Standard
));
assert!(is_safe_to_open(
"HTTPS://EXAMPLE.COM",
SchemeFilter::Standard
));
}
#[test]
fn url_with_fragment_and_query() {
assert!(is_safe_to_open(
"https://example.com/page?key=val#section",
SchemeFilter::Standard
));
}
#[test]
fn ftp_scheme_blocked() {
assert!(!is_safe_to_open(
"ftp://files.example.com/pub",
SchemeFilter::Standard
));
}
#[test]
fn fallback_colon_slash_slash_path() {
// A custom scheme that url::Url may reject but has ://
assert!(!is_safe_to_open(
"custom://something",
SchemeFilter::Standard
));
}
#[test]
fn non_mailto_colon_without_slashes_rejected() {
assert!(!is_safe_to_open("tel:+1234567890", SchemeFilter::Standard));
}
#[test]
fn whitespace_trimmed_before_parse() {
assert!(is_safe_to_open(
" https://example.com ",
SchemeFilter::Standard
));
assert!(is_safe_to_open(
"\thttps://example.com\n",
SchemeFilter::Standard
));
}
#[test]
fn ensure_query_param_appends_when_missing() {
let out = ensure_query_param("https://grok.com/supergrok", "referrer", "grok-build");
assert_eq!(out, "https://grok.com/supergrok?referrer=grok-build");
}
#[test]
fn ensure_query_param_preserves_existing_value() {
let out = ensure_query_param(
"https://grok.com/supergrok?referrer=other",
"referrer",
"grok-build",
);
assert_eq!(out, "https://grok.com/supergrok?referrer=other");
}
#[test]
fn ensure_query_param_keeps_other_query_pairs() {
let out = ensure_query_param(
"https://grok.com/supergrok?heavy=1",
"referrer",
"grok-build",
);
assert_eq!(
out,
"https://grok.com/supergrok?heavy=1&referrer=grok-build"
);
}
#[test]
fn ensure_query_param_preserves_fragment() {
// The current remote settings value uses a hash fragment for client-side
// routing (`grok.com/#supergrok`); we still want the referrer attached.
let out = ensure_query_param("https://grok.com/#supergrok", "referrer", "grok-build");
assert_eq!(out, "https://grok.com/?referrer=grok-build#supergrok");
}
#[test]
fn ensure_query_param_returns_unchanged_on_parse_failure() {
let out = ensure_query_param("not a url", "referrer", "grok-build");
assert_eq!(out, "not a url");
}
#[test]
fn ensure_query_param_url_encodes_value() {
let out = ensure_query_param("https://grok.com/supergrok", "referrer", "grok build");
assert_eq!(out, "https://grok.com/supergrok?referrer=grok+build");
}
#[test]
fn fallback_scheme_case_insensitive() {
// Uppercase scheme that url::Url::parse rejects triggers fallback path;
// the fallback must lowercase before matching SchemeFilter.
assert!(!is_safe_to_open(
"CUSTOM://something",
SchemeFilter::Standard
));
// Ensure mailto fallback is case-insensitive too.
assert!(is_safe_to_open(
"MAILTO:user@example.com",
SchemeFilter::Standard
));
}
}
@@ -0,0 +1,84 @@
//! Pure data types for modal window chrome state.
//!
//! Extracted from `views::modal_window` so lower layers (e.g.
//! `prompt_images`) can reference these plain-data types without depending
//! on the `views` layer. The rendering/input logic stays in
//! `views::modal_window`, which re-exports these for existing call sites.
use ratatui::layout::Rect;
/// Persistent state for a modal window's chrome. Stored by the caller
/// alongside their domain-specific content state.
#[derive(Debug, Clone)]
pub struct ModalWindowState {
/// Whether the `[✗]` close button is currently hovered.
pub close_hovered: bool,
/// Hit-test rect for the top-right `[✗]` close button.
pub close_button_rect: Option<Rect>,
/// Full popup area (for click-outside-to-close detection).
pub popup_area: Option<Rect>,
// -- Tabs (optional) --
/// Currently active tab index.
pub active_tab: usize,
/// Number of tabs (0 = no tab bar).
pub tab_count: usize,
/// Hit-test rects for each tab label.
pub tab_rects: Vec<Option<Rect>>,
/// Whether the tab bar region has keyboard focus. When true, Left/Right
pub tabs_focused: bool,
// -- Footer shortcuts --
/// Hit-test areas for clickable footer shortcuts.
pub shortcut_hits: Vec<ShortcutHitArea>,
/// Which footer shortcut (by index) is currently hovered.
pub hovered_shortcut: Option<usize>,
}
impl ModalWindowState {
/// Create a new modal window state with no tabs.
pub fn new() -> Self {
Self {
close_hovered: false,
close_button_rect: None,
popup_area: None,
active_tab: 0,
tab_count: 0,
tab_rects: Vec::new(),
shortcut_hits: Vec::new(),
hovered_shortcut: None,
tabs_focused: false,
}
}
/// Create a new modal window state with a given number of tabs.
pub fn with_tabs(tab_count: usize) -> Self {
Self {
tab_count,
tab_rects: vec![None; tab_count],
..Self::new()
}
}
}
impl Default for ModalWindowState {
fn default() -> Self {
Self::new()
}
}
/// Hit-test area for a rendered footer shortcut.
#[derive(Debug, Clone)]
pub struct ShortcutHitArea {
/// Screen rect occupied by this shortcut label.
pub rect: Rect,
/// Caller-defined identifier matching [`Shortcut::id`].
pub id: usize,
/// Index within the full `shortcuts` slice passed to
/// [`render_modal_shortcuts`]. Used by [`handle_modal_mouse`] to
/// track hover state in the same index space that the renderer uses.
pub shortcuts_idx: usize,
/// Whether clicking this shortcut dispatches `ShortcutActivated`.
/// All shortcuts get hover highlights regardless of this flag.
pub clickable: bool,
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,652 @@
//! Color blending and fading utilities.
//!
//! These utilities support smooth fade transitions (e.g., for sticky headers
//! being pushed off screen) by blending colors toward a base color.
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Color;
use ratatui::text::{Line, Span};
/// The 6 channel values in the 256-color 6×6×6 cube.
const CUBE_VALUES: [u8; 6] = [0, 95, 135, 175, 215, 255];
/// Convert a 256-color indexed color to its (R, G, B) components.
///
/// Handles all three regions of the 256-color palette:
/// - 015: standard/bright ANSI colors (uses common xterm defaults)
/// - 16231: 6×6×6 color cube
/// - 232255: 24-step grayscale ramp
pub fn indexed_to_rgb(index: u8) -> (u8, u8, u8) {
match index {
// Standard colors (07) — common xterm defaults
0 => (0, 0, 0),
1 => (128, 0, 0),
2 => (0, 128, 0),
3 => (128, 128, 0),
4 => (0, 0, 128),
5 => (128, 0, 128),
6 => (0, 128, 128),
7 => (192, 192, 192),
// Bright colors (815)
8 => (128, 128, 128),
9 => (255, 0, 0),
10 => (0, 255, 0),
11 => (255, 255, 0),
12 => (0, 0, 255),
13 => (255, 0, 255),
14 => (0, 255, 255),
15 => (255, 255, 255),
// 6×6×6 color cube (16231)
16..=231 => {
let n = index - 16;
let r = CUBE_VALUES[(n / 36) as usize];
let g = CUBE_VALUES[((n % 36) / 6) as usize];
let b = CUBE_VALUES[(n % 6) as usize];
(r, g, b)
}
// Grayscale ramp (232255): value = 8 + (index 232) × 10
232..=255 => {
let v = 8 + (index - 232) * 10;
(v, v, v)
}
}
}
/// Map an RGB triplet to the nearest 256-color palette index (16255).
///
/// Searches both the 6×6×6 color cube (16231) and the 24-step grayscale
/// ramp (232255), 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 (16231) ---
let ri = nearest_cube_channel(r);
let gi = nearest_cube_channel(g);
let bi = nearest_cube_channel(b);
let cube_idx = 16 + 36 * ri as u16 + 6 * gi as u16 + bi as u16;
let cube_dist = sq_dist(
r,
g,
b,
CUBE_VALUES[ri as usize],
CUBE_VALUES[gi as usize],
CUBE_VALUES[bi as usize],
);
// --- nearest in the grayscale ramp (232255) ---
// 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 {
0u8
} else if lum >= 243 {
23
} else {
((lum as i16 - 8 + 5) / 10).clamp(0, 23) as u8
};
let gv = (8 + gray_step as u16 * 10) as u8;
let gray_dist = sq_dist(r, g, b, gv, gv, gv);
if gray_dist < cube_dist {
232 + gray_step
} else {
cube_idx as u8
}
}
/// Find the nearest index (05) into [`CUBE_VALUES`] for a single channel.
fn nearest_cube_channel(v: u8) -> u8 {
let mut best = 0u8;
let mut best_d = v.abs_diff(CUBE_VALUES[0]) as u16;
for i in 1..6u8 {
let d = v.abs_diff(CUBE_VALUES[i as usize]) as u16;
if d < best_d {
best = i;
best_d = d;
}
}
best
}
/// Squared Euclidean distance between two RGB colors.
fn sq_dist(r1: u8, g1: u8, b1: u8, r2: u8, g2: u8, b2: u8) -> u32 {
let dr = r1 as i32 - r2 as i32;
let dg = g1 as i32 - g2 as i32;
let db = b1 as i32 - b2 as i32;
(dr * dr + dg * dg + db * db) as u32
}
/// Extract (R, G, B) from a Color, supporting both Rgb and Indexed variants.
///
/// Returns `None` for named ANSI colors (Color::Red, etc.) and Color::Reset.
fn color_to_rgb(color: Color) -> Option<(u8, u8, u8)> {
match color {
Color::Rgb(r, g, b) => Some((r, g, b)),
Color::Indexed(n) => Some(indexed_to_rgb(n)),
_ => None,
}
}
/// Map every [`Color`] variant to an xterm-default RGB triple. `None`
/// only for `Color::Reset` (no defined RGB — caller chooses a fallback).
///
/// 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
/// of terminal color depth.
///
/// Named-color RGB matches the xterm 16-color palette used by
/// [`indexed_to_rgb`] for indices 015; the user's terminal may have
/// customised those entries, so the result is "approximate but
/// consistent with our other colorimetry".
pub fn resolve_to_rgb(color: Color) -> Option<(u8, u8, u8)> {
let idx: u8 = match color {
Color::Rgb(r, g, b) => return Some((r, g, b)),
Color::Indexed(n) => return Some(indexed_to_rgb(n)),
Color::Black => 0,
Color::Red => 1,
Color::Green => 2,
Color::Yellow => 3,
Color::Blue => 4,
Color::Magenta => 5,
Color::Cyan => 6,
Color::Gray => 7,
Color::DarkGray => 8,
Color::LightRed => 9,
Color::LightGreen => 10,
Color::LightYellow => 11,
Color::LightBlue => 12,
Color::LightMagenta => 13,
Color::LightCyan => 14,
Color::White => 15,
Color::Reset => return None,
};
Some(indexed_to_rgb(idx))
}
/// 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)
#[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
}
/// Blend a color toward a base color based on opacity.
///
/// - `opacity = 0.0`: returns `base` (fully faded)
/// - `opacity = 1.0`: returns `original` (no change)
///
/// Supports both `Color::Rgb` and `Color::Indexed` colors (indexed colors are
/// converted to their RGB equivalents for blending). When either input is
/// `Color::Indexed`, the blended result is quantized back to the nearest
/// 256-color index so the output stays terminal-compatible.
///
/// Returns `None` for named ANSI colors (Color::Red, etc.) since their RGB
/// values are terminal-dependent.
pub fn blend_color(base: Color, original: Color, opacity: f32) -> Option<Color> {
let (base_r, base_g, base_b) = color_to_rgb(base)?;
let (orig_r, orig_g, orig_b) = color_to_rgb(original)?;
let r = blend_channel(base_r, orig_r, opacity);
let g = blend_channel(base_g, orig_g, opacity);
let b = blend_channel(base_b, orig_b, opacity);
// When either input is indexed, quantize the blended result back to the
// nearest 256-color index so the output stays terminal-compatible.
// On 256-color terminals the theme quantizes all colors to Indexed at
// startup, so any Indexed input signals that the terminal cannot handle
// raw RGB — the output must stay in the indexed palette.
Some(match (base, original) {
(Color::Indexed(_), _) | (_, Color::Indexed(_)) => Color::Indexed(nearest_indexed(r, g, b)),
_ => Color::Rgb(r, g, b),
})
}
/// Blend all span colors in a line toward a base color.
///
/// This is useful for making content appear "faded" or "muted" by blending
/// its colors toward the background.
///
/// - `opacity = 0.0`: fully faded to base color
/// - `opacity = 1.0`: no change (original colors)
///
/// 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
.into_iter()
.map(|span| {
let mut style = span.style;
if let Some(fg) = style.fg
&& let Some(blended) = blend_color(base, fg, opacity)
{
style.fg = Some(blended);
}
Span::styled(span.content, style)
})
.collect();
Line::from(blended_spans).style(line.style)
}
/// Blend all span colors in a line toward a base color, with default foreground.
///
/// Like `blend_line`, but spans without an explicit fg color are assigned
/// `default_fg` before blending. This ensures all text gets blended, not just
/// explicitly colored text.
///
/// - `opacity = 0.0`: fully faded to base color
/// - `opacity = 1.0`: no change (original colors)
///
/// Named ANSI colors are left unchanged.
pub fn blend_line_with_default(
line: Line<'static>,
base: Color,
default_fg: Color,
opacity: f32,
) -> Line<'static> {
let blended_spans: Vec<Span<'static>> = line
.spans
.into_iter()
.map(|span| {
let mut style = span.style;
// Use default_fg if no explicit fg color
let fg = style.fg.unwrap_or(default_fg);
if let Some(blended) = blend_color(base, fg, opacity) {
style.fg = Some(blended);
}
Span::styled(span.content, style)
})
.collect();
Line::from(blended_spans).style(line.style)
}
/// Fade a region of the buffer toward a base color.
///
/// 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
///
/// Both RGB and Indexed colors are blended; named ANSI colors (Color::Red, etc.)
/// 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,
area,
Some((base_color, opacity)),
Some((base_color, opacity)),
);
}
/// 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
/// - `Some((target, opacity))`: blend toward `target` at `opacity`
/// - `opacity = 0.0`: fully target (original gone)
/// - `opacity = 1.0`: no change (original kept)
///
/// Both RGB and Indexed colors are blended; named ANSI color cells are skipped.
pub fn blend_area(
buf: &mut Buffer,
area: Rect,
fg: Option<(Color, f32)>,
bg: Option<(Color, f32)>,
) {
for y in area.y..area.y + area.height {
for x in area.x..area.x + area.width {
if let Some(cell) = buf.cell_mut((x, y)) {
if let Some((target, opacity)) = fg
&& let Some(blended) = blend_color(target, cell.fg, opacity)
{
cell.set_fg(blended);
}
if let Some((target, opacity)) = bg
&& let Some(blended) = blend_color(target, cell.bg, opacity)
{
cell.set_bg(blended);
}
}
}
}
}
/// Dim a screen area: reset all modifiers then blend toward a background color.
///
/// This ensures no bold/italic/underline bleeds through the dimmed overlay.
pub fn dim_area(buf: &mut Buffer, area: Rect, blend_bg: ratatui::style::Color, blend_factor: f32) {
use ratatui::style::Modifier;
for y in area.y..area.y + area.height {
for x in area.x..area.x + area.width {
if let Some(cell) = buf.cell_mut((x, y)) {
// Strip all modifiers (BOLD, ITALIC, UNDERLINE, etc.).
cell.modifier = Modifier::empty();
}
}
}
// Then blend colors.
crate::render::color::blend_area(buf, area, Some((blend_bg, blend_factor)), None);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_nearest_indexed_exact_cube_values() {
// Pure black in the cube → index 16
assert_eq!(nearest_indexed(0, 0, 0), 16);
// Pure white in the cube → index 231
assert_eq!(nearest_indexed(255, 255, 255), 231);
// Exact cube hit: rgb(95, 135, 215) → 16 + 36*1 + 6*2 + 4 = 68
assert_eq!(nearest_indexed(95, 135, 215), 68);
}
#[test]
fn test_nearest_indexed_grayscale() {
// Mid-gray should map to a grayscale index
let idx = nearest_indexed(128, 128, 128);
assert!((232..=255).contains(&idx));
}
#[test]
fn test_nearest_indexed_roundtrip() {
// A known indexed color should round-trip back to itself
for &idx in &[16u8, 141, 149, 210, 234, 243, 245, 255] {
let (r, g, b) = indexed_to_rgb(idx);
assert_eq!(
nearest_indexed(r, g, b),
idx,
"round-trip failed for index {idx}"
);
}
}
#[test]
fn test_blend_channel_extremes() {
// opacity = 0: fully base
assert_eq!(blend_channel(0, 255, 0.0), 0);
assert_eq!(blend_channel(100, 200, 0.0), 100);
// opacity = 1: fully original
assert_eq!(blend_channel(0, 255, 1.0), 255);
assert_eq!(blend_channel(100, 200, 1.0), 200);
}
#[test]
fn test_blend_channel_midpoint() {
// 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
}
#[test]
fn test_blend_channel_partial() {
// 25% opacity
assert_eq!(blend_channel(0, 100, 0.25), 25);
// 75% opacity
assert_eq!(blend_channel(0, 100, 0.75), 75);
}
#[test]
fn test_blend_color_rgb() {
let base = Color::Rgb(0, 0, 0);
let original = Color::Rgb(100, 150, 200);
// Fully faded
let faded = blend_color(base, original, 0.0);
assert_eq!(faded, Some(Color::Rgb(0, 0, 0)));
// No change
let unchanged = blend_color(base, original, 1.0);
assert_eq!(unchanged, Some(Color::Rgb(100, 150, 200)));
// Halfway
let half = blend_color(base, original, 0.5);
assert_eq!(half, Some(Color::Rgb(50, 75, 100)));
}
#[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)
let half = blend_color(base, original, 0.5).unwrap();
assert!(matches!(half, Color::Indexed(_)));
// Fully base
let faded = blend_color(base, original, 0.0).unwrap();
assert!(matches!(faded, Color::Indexed(_)));
// Fully original
let full = blend_color(base, original, 1.0).unwrap();
assert!(matches!(full, Color::Indexed(_)));
}
#[test]
fn test_blend_color_mixed_returns_indexed() {
let rgb = Color::Rgb(100, 100, 100);
let indexed = Color::Indexed(5); // magenta (128, 0, 128)
// Mixed: indexed base + rgb original → Indexed result (quantized)
let result = blend_color(indexed, rgb, 0.5);
assert!(
matches!(result, Some(Color::Indexed(_))),
"expected Indexed, got {result:?}"
);
// Mixed: rgb base + indexed original → Indexed result (quantized)
let result = blend_color(rgb, indexed, 0.5);
assert!(
matches!(result, Some(Color::Indexed(_))),
"expected Indexed, got {result:?}"
);
}
#[test]
fn test_blend_color_named_returns_none() {
let rgb = Color::Rgb(100, 100, 100);
let named = Color::Red;
// Named ANSI colors are not blendable
assert_eq!(blend_color(named, rgb, 0.5), None);
assert_eq!(blend_color(rgb, named, 0.5), None);
}
#[test]
fn test_fade_region() {
let mut buf = Buffer::empty(Rect::new(0, 0, 3, 2));
// Set up some RGB colors
let fg_color = Color::Rgb(200, 200, 200);
let bg_color = Color::Rgb(50, 50, 50);
let base = Color::Rgb(0, 0, 0);
for y in 0..2 {
for x in 0..3 {
if let Some(cell) = buf.cell_mut((x, y)) {
cell.set_fg(fg_color);
cell.set_bg(bg_color);
}
}
}
// Fade to 50%
fade_region(&mut buf, Rect::new(0, 0, 3, 2), base, 0.5);
// 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
}
}
#[test]
fn test_fade_region_partial_area() {
let mut buf = Buffer::empty(Rect::new(0, 0, 4, 4));
let fg_color = Color::Rgb(100, 100, 100);
let base = Color::Rgb(0, 0, 0);
// Set all cells
for y in 0..4 {
for x in 0..4 {
if let Some(cell) = buf.cell_mut((x, y)) {
cell.set_fg(fg_color);
}
}
}
// 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
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
assert_eq!(buf.cell((3, 3)).unwrap().fg, Color::Rgb(100, 100, 100));
}
#[test]
fn test_blend_area_fg_only() {
let mut buf = Buffer::empty(Rect::new(0, 0, 2, 1));
let fg = Color::Rgb(200, 100, 0);
let bg = Color::Rgb(10, 10, 10);
for x in 0..2 {
if let Some(cell) = buf.cell_mut((x, 0)) {
cell.set_fg(fg);
cell.set_bg(bg);
}
}
let target = Color::Rgb(0, 0, 0);
blend_area(&mut buf, Rect::new(0, 0, 2, 1), Some((target, 0.5)), None);
// fg blended to 50%
assert_eq!(buf.cell((0, 0)).unwrap().fg, Color::Rgb(100, 50, 0));
// bg unchanged
assert_eq!(buf.cell((0, 0)).unwrap().bg, Color::Rgb(10, 10, 10));
}
#[test]
fn test_blend_area_bg_only() {
let mut buf = Buffer::empty(Rect::new(0, 0, 2, 1));
let fg = Color::Rgb(200, 200, 200);
let bg = Color::Rgb(100, 100, 100);
for x in 0..2 {
if let Some(cell) = buf.cell_mut((x, 0)) {
cell.set_fg(fg);
cell.set_bg(bg);
}
}
let target = Color::Rgb(0, 0, 0);
blend_area(&mut buf, Rect::new(0, 0, 2, 1), None, Some((target, 0.5)));
// 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));
}
#[test]
fn test_blend_area_both() {
let mut buf = Buffer::empty(Rect::new(0, 0, 1, 1));
if let Some(cell) = buf.cell_mut((0, 0)) {
cell.set_fg(Color::Rgb(100, 200, 0));
cell.set_bg(Color::Rgb(50, 50, 50));
}
let fg_target = Color::Rgb(0, 0, 0);
let bg_target = Color::Rgb(20, 20, 20);
blend_area(
&mut buf,
Rect::new(0, 0, 1, 1),
Some((fg_target, 0.75)),
Some((bg_target, 0.75)),
);
// fg: 75% of (100,200,0) + 25% of (0,0,0) = (75,150,0)
assert_eq!(buf.cell((0, 0)).unwrap().fg, Color::Rgb(75, 150, 0));
// bg: 75% of (50,50,50) + 25% of (20,20,20) = (42.5, 42.5, 42.5) → (43,43,43)
assert_eq!(buf.cell((0, 0)).unwrap().bg, Color::Rgb(43, 43, 43));
}
#[test]
fn test_blend_area_none_none_is_noop() {
let mut buf = Buffer::empty(Rect::new(0, 0, 2, 2));
let fg = Color::Rgb(123, 45, 67);
let bg = Color::Rgb(89, 10, 11);
for y in 0..2 {
for x in 0..2 {
if let Some(cell) = buf.cell_mut((x, y)) {
cell.set_fg(fg);
cell.set_bg(bg);
}
}
}
blend_area(&mut buf, Rect::new(0, 0, 2, 2), None, None);
for y in 0..2 {
for x in 0..2 {
assert_eq!(buf.cell((x, y)).unwrap().fg, fg);
assert_eq!(buf.cell((x, y)).unwrap().bg, bg);
}
}
}
#[test]
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
cell.set_bg(Color::Red);
}
blend_area(
&mut buf,
Rect::new(0, 0, 1, 1),
Some((Color::Rgb(0, 0, 0), 0.5)),
Some((Color::Rgb(0, 0, 0), 0.5)),
);
// 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);
}
#[test]
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
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
blend_area(
&mut buf,
Rect::new(0, 0, 1, 1),
Some((target, 0.5)),
Some((target, 0.5)),
);
// Both should now be blended (and still indexed, not Rgb)
let cell = buf.cell((0, 0)).unwrap();
assert!(matches!(cell.fg, Color::Indexed(_)));
assert!(matches!(cell.bg, Color::Indexed(_)));
}
}
@@ -0,0 +1,573 @@
//! Frame drawing with cursor blink preservation.
//!
//! # Problem
//!
//! Ratatui's [`Terminal::draw()`] (internally `try_draw()`) unconditionally
//! sends cursor escape sequences on every frame:
//!
//! - If `frame.set_cursor_position()` was called: `Show` + `MoveTo` every frame
//! - If not called: `Hide` every frame
//!
//! Both reset the terminal's cursor blink timer (`Show` restarts the blink
//! cycle, `MoveTo` resets the blink phase). At 30fps, the 500ms blink interval
//! never completes, so the cursor appears solid.
//!
//! # Solution
//!
//! We bypass `try_draw()` and use ratatui's lower-level API directly:
//!
//! ```text
//! terminal.autoresize() — handle terminal size changes
//! terminal.get_frame() — get a fresh buffer to render into
//! terminal.flush() — diff old/new buffers, write only changed cells
//! terminal.swap_buffers() — prepare for next frame
//! ```
//!
//! Cursor is managed entirely by [`CursorState`] with de-duplication:
//!
//! - **No cell changes + same position**: zero cursor commands → blink preserved
//! - **Cells changed + same position**: `MoveTo` to fix cursor after cell writes
//! - **Position changed**: `MoveTo` (blink resets — expected, user just typed)
//! - **Visibility transition**: `Show`/`Hide` (only on actual transition)
//! - **Idle (no draw calls)**: nothing sent → blink runs undisturbed
//!
//! The "no cell changes" optimization is possible because we use
//! [`kigi_ratatui_inline::Terminal`] whose `flush()` returns `bool` indicating
//! whether any cells were written. When animated entries are off-screen, the
//! buffer diff is empty and we skip all cursor commands.
//!
//! # Synchronized output
//!
//! Each frame is wrapped in `BeginSynchronizedUpdate` / `EndSynchronizedUpdate`
//! so the terminal processes all escape sequences atomically. This prevents
//! flicker and is critical for multiplexers like zellij and tmux.
use crossterm::terminal::{BeginSynchronizedUpdate, EndSynchronizedUpdate};
use crossterm::{QueueableCommand, cursor};
use kigi_ratatui_inline::LinkSpan;
use ratatui::Frame;
use ratatui::backend::CrosstermBackend;
use std::io::Write;
use std::sync::mpsc;
use std::time::{Duration, Instant};
/// Terminal type for the pager. Defined here (beside [`TermWriter`]) so the
/// `render` module does not depend on `app`. Re-exported from `app` as
/// `crate::app::PagerTerminal` for existing call sites.
pub type PagerTerminal = kigi_ratatui_inline::Terminal<CrosstermBackend<TermWriter>>;
/// Shared queued/written frame counters linking [`TermWriter`] to the writer
/// thread, so callers can wait for the output pipeline to drain.
///
/// The channel between them is fire-and-forget by design (the event loop must
/// never block on pty I/O), but a few operations need a *happens-before* on
/// terminal bytes: suspending into a tty-taking child (`$EDITOR` / `$PAGER`)
/// while a frame is still queued lets that frame race the child's own output —
/// it can land on the child's alternate screen (so the main screen never
/// receives it) or tear mid-escape-sequence around the alt-screen switch,
/// leaving the restored screen out of sync with the renderer's diff buffer
/// (stale rows, one-line offsets, literal `[` fragments). [`wait_drained`]
/// closes that window.
///
/// `queued` is incremented *before* the frame is sent and `written` after the
/// writer thread has flushed it to the tty, so `written == queued` ⇒ every
/// frame handed to the channel has reached the terminal fd.
///
/// [`wait_drained`]: WriterSync::wait_drained
#[derive(Clone, Debug, Default)]
pub struct WriterSync {
queued: std::sync::Arc<std::sync::atomic::AtomicU64>,
written: std::sync::Arc<std::sync::atomic::AtomicU64>,
}
impl WriterSync {
pub fn new() -> Self {
Self::default()
}
/// Record a frame handed to the channel. Called by [`TermWriter::flush`]
/// *before* the send so `written` can never observably exceed `queued`.
fn mark_queued(&self) {
self.queued
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
}
/// Record a frame fully written + flushed to the tty (writer thread).
fn mark_written(&self) {
self.written
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
}
/// Whether every queued frame has been written to the tty.
pub fn is_drained(&self) -> bool {
self.written.load(std::sync::atomic::Ordering::SeqCst)
>= self.queued.load(std::sync::atomic::Ordering::SeqCst)
}
/// Block (bounded) until the writer thread has flushed every queued frame.
///
/// Returns `true` when drained, `false` on timeout (wedged pty / dead
/// writer thread — callers proceed anyway, matching the bounded
/// reader-park in the suspend path).
pub fn wait_drained(&self, timeout: Duration) -> bool {
let deadline = Instant::now() + timeout;
while !self.is_drained() {
if Instant::now() >= deadline {
return false;
}
std::thread::sleep(Duration::from_millis(1));
}
true
}
}
/// A writer that buffers frame output and sends it to a background thread
/// for non-blocking terminal I/O.
///
/// All escape sequences produced during a frame are collected in an internal
/// `Vec<u8>`. When [`flush()`](Write::flush) is called, the accumulated bytes
/// are sent through a channel to a dedicated writer thread that performs the
/// actual (potentially blocking) `write()` to stderr / the pty fd.
///
/// This decouples the tokio event loop from pty back-pressure: if the
/// terminal emulator is slow to read (e.g. Ghostty busy with another pane),
/// only the writer thread stalls — the event loop keeps processing timers,
/// events, and ACP messages.
pub struct TermWriter {
buf: Vec<u8>,
tx: mpsc::Sender<Vec<u8>>,
sync: WriterSync,
}
impl TermWriter {
pub fn new(tx: mpsc::Sender<Vec<u8>>, sync: WriterSync) -> Self {
Self {
buf: Vec::with_capacity(32 * 1024),
tx,
sync,
}
}
/// Drop the current frame's buffered bytes without sending them.
pub fn discard(&mut self) {
self.buf.clear();
}
/// The queued/written counters shared with the writer thread. Used by the
/// suspend path to [`WriterSync::wait_drained`] before a child takes the tty.
pub fn writer_sync(&self) -> &WriterSync {
&self.sync
}
}
impl Write for TermWriter {
fn write(&mut self, data: &[u8]) -> std::io::Result<usize> {
self.buf.extend_from_slice(data);
Ok(data.len())
}
fn flush(&mut self) -> std::io::Result<()> {
if !self.buf.is_empty() {
let data = std::mem::take(&mut self.buf);
self.sync.mark_queued();
let _ = self.tx.send(data);
}
Ok(())
}
}
impl Drop for TermWriter {
fn drop(&mut self) {
let _ = self.flush();
}
}
/// Handle for the background writer thread.
///
/// Joining ensures all queued frames have been written to the terminal
/// before proceeding with teardown (e.g. `LeaveAlternateScreen`).
pub struct WriterThread {
handle: Option<std::thread::JoinHandle<()>>,
}
impl WriterThread {
/// Block until the writer thread has processed all pending frames and
/// exited. The [`mpsc::Sender`] must be dropped *before* calling this,
/// otherwise the thread will never see the channel close.
pub fn join(mut self) {
if let Some(h) = self.handle.take() {
let _ = h.join();
}
}
}
impl Drop for WriterThread {
fn drop(&mut self) {
if let Some(h) = self.handle.take() {
let _ = h.join();
}
}
}
/// Spawn a background OS thread that writes frame data to stderr.
///
/// Returns `(Sender, WriterSync, WriterThread)`. Send `Vec<u8>` frame data
/// through the sender; the thread writes each frame to stderr via a 64 KiB
/// `BufWriter`. The [`WriterSync`] must be shared with every [`TermWriter`]
/// built on the sender so [`WriterSync::wait_drained`] tracks the queue.
/// Drop the sender to signal the thread to exit, then call
/// [`WriterThread::join`] to wait for it.
pub fn spawn_writer_thread() -> (mpsc::Sender<Vec<u8>>, WriterSync, WriterThread) {
let (tx, rx) = mpsc::channel::<Vec<u8>>();
let sync = WriterSync::new();
let thread_sync = sync.clone();
let test_delay = std::env::var("KIGI_TEST_FRAME_WRITE_DELAY_MS")
.ok()
.and_then(|v| v.parse::<u64>().ok())
.map(Duration::from_millis);
let handle = std::thread::Builder::new()
.name("term-writer".into())
.spawn(move || {
#[cfg(not(windows))]
let mut writer: Box<dyn std::io::Write> = {
let tui_out = kigi_tty_utils::dup_tui_stderr().unwrap_or_else(|_| {
use std::os::unix::io::{AsRawFd, FromRawFd};
let fd = unsafe { libc::dup(std::io::stderr().as_raw_fd()) };
unsafe { std::fs::File::from_raw_fd(fd) }
});
Box::new(std::io::BufWriter::with_capacity(64 * 1024, tui_out))
};
#[cfg(windows)]
let mut writer: Box<dyn std::io::Write> = Box::new(std::io::BufWriter::with_capacity(
64 * 1024,
std::io::stderr(),
));
while let Ok(data) = rx.recv() {
if let Some(delay) = test_delay {
std::thread::sleep(delay);
}
{
let _guard = kigi_shared::stderr::stderr_lock();
let _ = writer.write_all(&data);
let _ = writer.flush();
}
thread_sync.mark_written();
}
})
.expect("failed to spawn term-writer thread");
(
tx,
sync,
WriterThread {
handle: Some(handle),
},
)
}
/// Cursor state tracker for blink-preserving cursor management.
///
/// Tracks the last cursor position written to the terminal. By comparing
/// with the desired position each frame, we emit the minimum cursor escape
/// sequences necessary — avoiding redundant `Show`/`Hide`/`MoveTo` that
/// would reset the terminal's blink timer.
#[derive(Debug, Default)]
pub struct CursorState {
/// Last cursor position written to the terminal.
/// `None` = cursor is hidden; `Some((x, y))` = cursor visible at (x, y).
last_pos: Option<(u16, u16)>,
}
/// What cursor commands to emit after a frame render.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CursorAction {
/// No cursor commands needed — blink timer preserved.
None,
/// Cursor is visible and cells changed — reposition after cell writes
/// disturbed the terminal cursor. Resets blink (unavoidable when cells
/// change on screen).
Reposition(u16, u16),
/// Cursor becoming visible at (x, y) — needs `MoveTo` + `Show`.
Show(u16, u16),
/// Cursor becoming hidden — needs `Hide`.
Hide,
}
impl CursorState {
pub fn new() -> Self {
Self { last_pos: None }
}
/// Determine what cursor action to take for this frame.
///
/// Pure function — computes the action from current state without
/// side effects. Call [`apply`] to execute it.
pub fn action(&self, cursor_pos: Option<(u16, u16)>, has_changes: bool) -> CursorAction {
if cursor_pos == self.last_pos {
if has_changes && let Some((x, y)) = cursor_pos {
return CursorAction::Reposition(x, y);
}
CursorAction::None
} else {
match (cursor_pos, self.last_pos) {
(Some((x, y)), Some(_)) => CursorAction::Reposition(x, y),
(Some((x, y)), None) => CursorAction::Show(x, y),
(None, Some(_)) => CursorAction::Hide,
(None, None) => CursorAction::None,
}
}
}
/// Execute a cursor action by queuing escape sequences into `w`.
///
/// Uses `queue!` (buffered) instead of `execute!` (immediate flush) so
/// that cursor commands are batched with the rest of the frame data and
/// written to the terminal atomically by the writer thread.
pub fn apply<W: Write>(&mut self, action: CursorAction, w: &mut W) {
match action {
CursorAction::None => {}
CursorAction::Reposition(x, y) => {
let _ = w.queue(cursor::MoveTo(x, y));
self.last_pos = Some((x, y));
}
CursorAction::Show(x, y) => {
let _ = w.queue(cursor::MoveTo(x, y));
let _ = w.queue(cursor::Show);
self.last_pos = Some((x, y));
}
CursorAction::Hide => {
let _ = w.queue(cursor::Hide);
self.last_pos = None;
}
}
}
}
/// Render a frame to the terminal with cursor blink preservation.
///
/// Bypasses ratatui's `try_draw()` to avoid its unconditional cursor
/// management. See [module docs](self) for the full rationale.
///
/// The `render_fn` receives a [`Frame`] and a `&mut Vec<LinkSpan>` to populate
/// with the frame's OSC 8 hyperlink regions (absolute viewport coordinates).
/// Those spans are handed to the terminal before the diff so hyperlinks
/// participate in the cell diff (emitted/cleared in lockstep with content) —
/// no out-of-band post-flush repaint. It returns a tuple of:
/// - `Option<(u16, u16)>` — cursor position (or `None` to hide cursor)
/// - `Option<PostFlush>` — escape sequences to write after cell flush (e.g.
/// Kitty graphics protocol image data). Written inside the synchronized
/// update block so the image appears atomically with the cell diff.
pub fn draw_frame(
terminal: &mut PagerTerminal,
cursor: &mut CursorState,
render_fn: impl FnOnce(
&mut Frame,
&mut Vec<LinkSpan>,
) -> (
Option<(u16, u16)>,
Option<crate::terminal::overlay::PostFlush>,
),
) {
let _ = terminal.backend_mut().queue(BeginSynchronizedUpdate);
let _ = terminal.autoresize();
let mut link_spans: Vec<LinkSpan> = Vec::new();
let (cursor_pos, post_flush_escapes) = {
let mut frame = terminal.get_frame();
render_fn(&mut frame, &mut link_spans)
};
terminal.set_frame_links(&link_spans);
let has_changes = terminal.flush_with_links().unwrap_or(false);
terminal.swap_buffers();
let post_flush_wrote_cursor = post_flush_escapes.is_some();
let action = cursor.action(cursor_pos, has_changes || post_flush_wrote_cursor);
if !has_changes && !post_flush_wrote_cursor && action == CursorAction::None {
terminal.backend_mut().writer_mut().discard();
return;
}
if let Some(post_flush) = post_flush_escapes {
let _ = post_flush.write_to(terminal.backend_mut());
}
cursor.apply(action, terminal.backend_mut());
let _ = terminal.backend_mut().queue(EndSynchronizedUpdate);
let _ = terminal.backend_mut().flush();
}
#[cfg(test)]
mod tests {
use super::*;
/// An unchanged frame must emit zero bytes to the PTY.
#[test]
fn idle_frame_emits_zero_bytes() {
use ratatui::backend::CrosstermBackend;
use ratatui::layout::Rect;
use ratatui::widgets::Paragraph;
use ratatui::{TerminalOptions, Viewport};
use std::sync::mpsc;
fn render(
frame: &mut ratatui::Frame,
_links: &mut Vec<LinkSpan>,
) -> (
Option<(u16, u16)>,
Option<crate::terminal::overlay::PostFlush>,
) {
frame.render_widget(Paragraph::new("hello world"), frame.area());
(None, None)
}
let (tx, rx) = mpsc::channel::<Vec<u8>>();
let backend = CrosstermBackend::new(TermWriter::new(tx, WriterSync::new()));
let mut terminal = kigi_ratatui_inline::Terminal::with_options(
backend,
TerminalOptions {
viewport: Viewport::Fixed(Rect::new(0, 0, 80, 24)),
},
)
.expect("build terminal");
let mut cursor = CursorState::new();
draw_frame(&mut terminal, &mut cursor, render);
let first: Vec<u8> = rx.try_iter().flatten().collect();
assert!(!first.is_empty(), "first frame should emit bytes");
draw_frame(&mut terminal, &mut cursor, render);
let second: Vec<u8> = rx.try_iter().flatten().collect();
assert!(
second.is_empty(),
"idle (unchanged) frame must emit 0 bytes, got {}: {:?}",
second.len(),
String::from_utf8_lossy(&second),
);
}
/// `wait_drained` semantics: drained when `written` has caught up with
/// `queued` — immediately when nothing is pending, after the consumer
/// marks the frame written, and a bounded `false` when it never does.
/// This is the happens-before the suspend path relies on so no queued
/// frame can race a tty-taking `$EDITOR` / `$PAGER` child.
#[test]
fn writer_sync_drains_when_written_catches_queued() {
let sync = WriterSync::new();
assert!(sync.wait_drained(Duration::from_millis(1)));
sync.mark_queued();
assert!(!sync.is_drained());
assert!(!sync.wait_drained(Duration::from_millis(5)));
let consumer_sync = sync.clone();
let consumer = std::thread::spawn(move || {
std::thread::sleep(Duration::from_millis(10));
consumer_sync.mark_written();
});
assert!(sync.wait_drained(Duration::from_secs(5)));
consumer.join().expect("consumer thread");
}
/// A `TermWriter::flush` with buffered bytes marks the frame queued; the
/// writer-thread side marking it written restores the drained state.
#[test]
fn term_writer_flush_marks_queued() {
let (tx, rx) = mpsc::channel::<Vec<u8>>();
let sync = WriterSync::new();
let mut writer = TermWriter::new(tx, sync.clone());
writer.flush().expect("flush");
assert!(sync.is_drained());
writer.write_all(b"frame bytes").expect("write");
writer.flush().expect("flush");
assert!(!sync.is_drained(), "queued frame not yet written");
assert_eq!(rx.try_recv().expect("frame on channel"), b"frame bytes");
sync.mark_written();
assert!(sync.is_drained());
}
fn state_hidden() -> CursorState {
CursorState { last_pos: None }
}
fn state_at(x: u16, y: u16) -> CursorState {
CursorState {
last_pos: Some((x, y)),
}
}
#[test]
fn hidden_no_changes_stays_hidden() {
let s = state_hidden();
assert_eq!(s.action(None, false), CursorAction::None);
}
#[test]
fn visible_same_pos_no_changes_preserves_blink() {
let s = state_at(5, 10);
assert_eq!(s.action(Some((5, 10)), false), CursorAction::None);
}
#[test]
fn visible_new_pos_no_changes_repositions() {
let s = state_at(5, 10);
assert_eq!(
s.action(Some((6, 10)), false),
CursorAction::Reposition(6, 10)
);
}
#[test]
fn hidden_with_changes_stays_hidden() {
let s = state_hidden();
assert_eq!(s.action(None, true), CursorAction::None);
}
#[test]
fn visible_same_pos_with_changes_repositions() {
let s = state_at(5, 10);
assert_eq!(
s.action(Some((5, 10)), true),
CursorAction::Reposition(5, 10)
);
}
#[test]
fn visible_new_pos_with_changes_repositions() {
let s = state_at(5, 10);
assert_eq!(
s.action(Some((8, 10)), true),
CursorAction::Reposition(8, 10)
);
}
#[test]
fn hidden_to_visible_shows() {
let s = state_hidden();
assert_eq!(s.action(Some((5, 10)), false), CursorAction::Show(5, 10));
}
#[test]
fn hidden_to_visible_with_changes_shows() {
let s = state_hidden();
assert_eq!(s.action(Some((5, 10)), true), CursorAction::Show(5, 10));
}
#[test]
fn visible_to_hidden_hides() {
let s = state_at(5, 10);
assert_eq!(s.action(None, false), CursorAction::Hide);
}
#[test]
fn visible_to_hidden_with_changes_hides() {
let s = state_at(5, 10);
assert_eq!(s.action(None, true), CursorAction::Hide);
}
#[test]
fn apply_show_updates_last_pos() {
let mut s = state_hidden();
let mut sink = Vec::new();
s.apply(CursorAction::Show(3, 7), &mut sink);
assert_eq!(s.last_pos, Some((3, 7)));
}
#[test]
fn apply_hide_clears_last_pos() {
let mut s = state_at(3, 7);
let mut sink = Vec::new();
s.apply(CursorAction::Hide, &mut sink);
assert_eq!(s.last_pos, None);
}
#[test]
fn apply_reposition_updates_last_pos() {
let mut s = state_at(3, 7);
let mut sink = Vec::new();
s.apply(CursorAction::Reposition(5, 9), &mut sink);
assert_eq!(s.last_pos, Some((5, 9)));
}
#[test]
fn apply_none_preserves_state() {
let mut s = state_at(3, 7);
let mut sink = Vec::new();
s.apply(CursorAction::None, &mut sink);
assert_eq!(s.last_pos, Some((3, 7)));
}
/// Verify the writer thread correctly round-trips multi-byte UTF-8
/// through the channel. This catches encoding issues where the writer
/// silently corrupts Braille/emoji/CJK characters.
#[test]
fn writer_thread_preserves_multibyte_utf8() {
let test_payload = "⣀⣾⠿⠛\u{e0a0}\u{1F600}";
let expected_bytes = test_payload.as_bytes().to_vec();
let (tx, rx) = std::sync::mpsc::channel::<Vec<u8>>();
let capture = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
let capture2 = capture.clone();
let handle = std::thread::spawn(move || {
let mut buf = Vec::new();
while let Ok(data) = rx.recv() {
buf.extend_from_slice(&data);
}
*capture2.lock().unwrap() = buf;
});
tx.send(expected_bytes.clone()).unwrap();
drop(tx);
handle.join().unwrap();
let captured = capture.lock().unwrap();
assert_eq!(
*captured, expected_bytes,
"Writer thread corrupted multi-byte UTF-8 payload"
);
assert_eq!(
std::str::from_utf8(&captured).unwrap(),
test_payload,
"Round-tripped bytes do not decode to original UTF-8 string"
);
}
}
@@ -0,0 +1,169 @@
//! `/gboom` easter-egg overlay chrome (border, title, HUD bar).
//!
//! The game frame itself is rendered via post-flush kitty escape sequences
//! by the caller, matching the image/video viewer pattern.
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
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.
pub fn render_gboom_overlay(
buf: &mut Buffer,
area: Rect,
hud: &GboomHud,
bg: Color,
text_fg: Color,
border_fg: Color,
) -> Option<Rect> {
if area.height < 8 || area.width < 30 {
return None;
}
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;
let popup_height = ((area.height as u32 * 90) / 100)
.max(8)
.min(area.height as u32) as u16;
let popup_x = area.x + (area.width.saturating_sub(popup_width)) / 2;
let popup_y = area.y + (area.height.saturating_sub(popup_height)) / 2;
let popup_rect = Rect::new(popup_x, popup_y, popup_width, popup_height);
ratatui::widgets::Clear.render(popup_rect, buf);
buf.set_style(popup_rect, Style::default().fg(text_fg).bg(bg));
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(border_fg).bg(bg))
.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()
.fg(Color::Rgb(r, g, b))
.bg(bg)
.add_modifier(Modifier::BOLD);
let tw = title.len() as u16;
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.
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;
if inner_width <= 12 {
return;
}
// Health-bar semantics: 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 {
Color::Rgb(235, 198, 82)
} else {
let [r, g, b] = crate::gboom::GBOOM_RED;
Color::Rgb(r, g, b)
};
let stats = format!(
" HP {:<3} \u{00b7} KILLS {}/{} ",
hud.hp, hud.kills, hud.total
);
// `chars().count()` not `len()`: the separator is multi-byte UTF-8 but
// every char here is a single display cell.
let stats_w = (stats.chars().count() as u16).min(inner_width as u16);
let line = Line::from(vec![Span::styled(
stats,
Style::default()
.fg(hp_color)
.bg(bg)
.add_modifier(Modifier::BOLD),
)]);
buf.set_line_safe(popup_rect.x + 1, bar_y, &line, stats_w);
let hint = if hud.playing {
" WASD/\u{2190}\u{2192} move \u{00b7} SPACE fire \u{00b7} ESC quit "
} else {
" ESC quit "
};
let hint_w = hint.chars().count() as u16;
if (stats_w + hint_w) as usize <= inner_width {
let hx = popup_rect.x + 1 + inner_width as u16 - hint_w;
buf.set_span_safe(
hx,
bar_y,
&Span::styled(hint, Style::default().fg(dim_fg).bg(bg)),
hint_w,
);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn hud() -> GboomHud {
GboomHud {
hp: 100,
kills: 2,
total: 8,
playing: true,
}
}
#[test]
fn returns_none_when_area_too_small() {
let mut buf = Buffer::empty(Rect::new(0, 0, 20, 5));
assert!(
render_gboom_overlay(
&mut buf,
Rect::new(0, 0, 20, 5),
&hud(),
Color::Black,
Color::White,
Color::Gray,
)
.is_none()
);
}
#[test]
fn renders_popup_with_title_and_hud() {
let area = Rect::new(0, 0, 80, 24);
let mut buf = Buffer::empty(area);
let popup = render_gboom_overlay(
&mut buf,
area,
&hud(),
Color::Black,
Color::White,
Color::Gray,
)
.expect("popup should render");
assert!(popup.width >= 30);
let content: String = buf.content().iter().map(|c| c.symbol()).collect();
assert!(content.contains("GBOOM"), "title missing");
assert!(content.contains("KILLS"), "HUD missing");
}
}
@@ -0,0 +1,77 @@
//! Match-highlight overlay shared by the list pane and other search surfaces.
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use crate::render::wrapping::{
byte_offset_to_display_col, byte_range_to_row_cols, wrap_byte_ranges_matching,
};
/// Invert (REVERSED) the buffer cells covering every match of `re` in `text`.
///
/// Run as a post-pass after a line has been drawn, so matches are highlighted
/// regardless of the underlying colors.
///
/// - `area`: the pane area; `area.x` / `area.width` bound painting horizontally.
/// - `row_y`: buffer row of the line's first visible row.
/// - `viewport_bottom`: exclusive bottom row; wrapped rows at or below it stop.
/// - `skip`: leading wrapped rows of this line clipped above the viewport.
/// - `prefix_w`: display column where `text` begins (e.g. a line-number gutter).
/// - `text`: the plain text the regex runs against.
/// - `single_row`: the line occupies one buffer row (NoWrap, or any 1-row item).
#[allow(clippy::too_many_arguments)]
pub fn paint_match_highlights(
buf: &mut Buffer,
area: Rect,
row_y: u16,
viewport_bottom: u16,
skip: u16,
prefix_w: u16,
text: &str,
re: &regex::Regex,
single_row: bool,
) {
if text.is_empty() {
return;
}
if single_row {
for m in re.find_iter(text) {
let col_start = prefix_w as usize + byte_offset_to_display_col(text, m.start());
let col_end = prefix_w as usize + byte_offset_to_display_col(text, m.end());
for col in col_start..col_end {
let x = area.x + col as u16;
if x < area.x + area.width {
invert_cell(&mut buf[(x, row_y)]);
}
}
}
return;
}
let text_w = area.width.saturating_sub(prefix_w) as usize;
let ranges = wrap_byte_ranges_matching(text, text_w);
for m in re.find_iter(text) {
for seg in byte_range_to_row_cols(text, &ranges, m.start()..m.end()) {
if seg.row < skip as usize {
continue;
}
let y = row_y + (seg.row - skip as usize) as u16;
if y >= viewport_bottom {
break;
}
for col in seg.col_start..seg.col_end {
let x = area.x + prefix_w + col as u16;
if x < area.x + area.width {
invert_cell(&mut buf[(x, y)]);
}
}
}
}
}
/// Apply the terminal's REVERSED attribute so the fg/bg swap is native and
/// respects the user's theme.
fn invert_cell(cell: &mut ratatui::buffer::Cell) {
cell.modifier.insert(ratatui::style::Modifier::REVERSED);
}
@@ -0,0 +1,223 @@
//! Image preview overlay for prompt image chips.
//!
//! Renders a bordered popup when the cursor is on (or right after) an image
//! chip, or when the chip is hovered. Content follows a pure 2×2 matrix:
//!
//! | | Has filepath | No filepath |
//! |--------------------|---------------------------|----------------------------|
//! | **Pixels available** | Image + path footer | Image only |
//! | **Pixels unavailable** | Metadata + path | Metadata only |
//!
//! The prompt bar chip itself is always path-free (`[Image #N]`); paths
//! appear only here.
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::{Color, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, BorderType, Borders, Paragraph, Widget, Wrap};
use crate::prompt_images::PastedImage;
use crate::terminal::image as terminal_image;
use crate::terminal::overlay;
mod content;
mod geometry;
use content::{
build_meta_line, format_bytes, format_mime, paint_path_line, truncate_path_for_overlay,
};
#[cfg(test)]
use geometry::ImagePlacement;
use geometry::{
MIN_BOX_WIDTH, MIN_META_BOX_HEIGHT, MIN_PIXEL_BOX_HEIGHT, overlay_geometry, plan_image_preview,
};
#[derive(Debug)]
struct ImageOverlayRender {
#[cfg(test)]
image_placement: Option<ImagePlacement>,
escapes: Option<overlay::Escapes>,
}
/// Render an image preview overlay and return any post-flush pixel escapes.
pub fn render_image_overlay(
buf: &mut Buffer,
area: Rect,
image: &PastedImage,
bg: Color,
text_fg: Color,
border_fg: Color,
) -> Option<overlay::Escapes> {
render_image_overlay_inner(buf, area, image, bg, text_fg, border_fg)
.and_then(|render| render.escapes)
}
fn render_image_overlay_inner(
buf: &mut Buffer,
area: Rect,
image: &PastedImage,
bg: Color,
text_fg: Color,
border_fg: Color,
) -> Option<ImageOverlayRender> {
if area.width < MIN_BOX_WIDTH {
return None;
}
let theme = crate::theme::Theme::current();
let protocol = terminal_image::detect_graphics_protocol();
let plan = plan_image_preview(image, protocol);
let min_height = if plan.show_pixels {
MIN_PIXEL_BOX_HEIGHT
} else {
MIN_META_BOX_HEIGHT
};
if area.height < min_height {
return None;
}
let geometry = overlay_geometry(
area,
plan.show_pixels,
plan.display_path.is_some(),
image.preview_dimensions().unwrap_or((640, 480)),
)?;
let overlay_rect = geometry.overlay_rect;
crate::render::color::dim_area(buf, area, theme.bg_base, 0.5);
ratatui::widgets::Clear.render(overlay_rect, buf);
buf.set_style(overlay_rect, Style::default().fg(text_fg).bg(bg));
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(border_fg).bg(bg))
.style(Style::default().bg(bg));
let inner = block.inner(overlay_rect);
block.render(overlay_rect, buf);
let title_text = format!(" Image #{} ", image.display_number);
let meta = build_meta_line(image, plan.display_path);
let full_title = if meta.len() + title_text.len() + 6 < overlay_rect.width as usize {
format!("{}\u{2500} {} ", title_text, meta)
} else {
title_text.clone()
};
let title_style = Style::default()
.fg(text_fg)
.bg(bg)
.add_modifier(ratatui::style::Modifier::BOLD);
let title_width = full_title.len() as u16;
let title_x = overlay_rect.x + (overlay_rect.width.saturating_sub(title_width)) / 2;
buf.set_span(
title_x,
overlay_rect.y,
&Span::styled(&full_title, title_style),
title_width,
);
if inner.width == 0 || inner.height == 0 {
return Some(ImageOverlayRender {
#[cfg(test)]
image_placement: geometry.image_placement,
escapes: None,
});
}
// Reserve the footer so a pixel placement cannot cover the path.
let path_footer = plan.display_path.filter(|_| inner.height >= 2);
let image_inner = if let Some(path) = path_footer {
let footer_y = inner.y + inner.height - 1;
paint_path_line(buf, inner.x, footer_y, inner.width, path, text_fg, bg);
Rect {
x: inner.x,
y: inner.y,
width: inner.width,
height: inner.height.saturating_sub(1),
}
} else {
inner
};
if !plan.show_pixels {
let mut lines = Vec::new();
lines.push(Line::from(format!(
"Format: {}",
format_mime(&image.mime_type)
)));
if let Some((w, h)) = image.preview_dimensions() {
lines.push(Line::from(format!("Dimensions: {} x {}", w, h)));
}
let status = if image.preview.is_failed() {
Some("Preview unavailable")
} else if image.preview.is_pending() && protocol.supports_images() {
Some("Preview pending")
} else {
None
};
lines.push(Line::from(status.map(str::to_owned).unwrap_or_else(|| {
format!("Size: {}", format_bytes(image.byte_len))
})));
// Short boxes need the path in the body because no footer fits.
if path_footer.is_none()
&& let Some(path) = plan.display_path
{
lines.push(Line::from(format!(
"Path: {}",
truncate_path_for_overlay(&path.display().to_string(), inner.width as usize)
)));
}
let body = if path_footer.is_some() {
image_inner
} else {
inner
};
let paragraph = Paragraph::new(lines)
.style(Style::default().fg(text_fg).bg(bg))
.wrap(Wrap { trim: false });
paragraph.render(body, buf);
return Some(ImageOverlayRender {
#[cfg(test)]
image_placement: None,
escapes: None,
});
}
if image_inner.width > 0 && image_inner.height > 0 {
use crate::render::SafeBuf;
let loading = "Loading...";
let lw = loading.len() as u16;
let lx = image_inner.x + image_inner.width.saturating_sub(lw) / 2;
let ly = image_inner.y + image_inner.height / 2;
buf.set_span_safe(
lx,
ly,
&Span::styled(loading, Style::default().fg(text_fg).bg(bg)),
lw,
);
}
let escapes = geometry.image_placement.and_then(|placement| {
let (bytes, _) = image.preview.prepared()?;
overlay::static_image_for_protocol(
protocol,
bytes,
placement.cols,
placement.rows,
placement.x,
placement.y,
image.preview.identity(),
)
});
Some(ImageOverlayRender {
#[cfg(test)]
image_placement: geometry.image_placement,
escapes,
})
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,87 @@
use std::path::Path;
use ratatui::buffer::Buffer;
use ratatui::style::{Color, Style};
use ratatui::text::Span;
use crate::prompt_images::PastedImage;
use crate::render::SafeBuf;
pub(super) fn paint_path_line(
buf: &mut Buffer,
x: u16,
y: u16,
width: u16,
path: &Path,
text_fg: Color,
bg: Color,
) {
let raw = path.display().to_string();
let label = format!(
"Path: {}",
truncate_path_for_overlay(&raw, width.saturating_sub(6) as usize)
);
let clipped = crate::render::line_utils::truncate_str(&label, width as usize);
buf.set_span_safe(
x,
y,
&Span::styled(clipped, Style::default().fg(text_fg).bg(bg)),
width,
);
}
pub(super) fn build_meta_line(image: &PastedImage, display_path: Option<&Path>) -> String {
let mut parts = Vec::with_capacity(4);
parts.push(format_mime(&image.mime_type));
if let Some((width, height)) = image.preview_dimensions() {
parts.push(format!("{}x{}", width, height));
}
parts.push(format_bytes(image.byte_len));
if let Some(path) = display_path
&& let Some(name) = path.file_name()
{
parts.push(name.to_string_lossy().into_owned());
}
parts.join(" \u{00b7} ")
}
pub(super) fn format_mime(mime: &str) -> String {
match mime {
"image/png" => "PNG".into(),
"image/jpeg" => "JPEG".into(),
"image/tiff" => "TIFF".into(),
"image/gif" => "GIF".into(),
"image/webp" => "WebP".into(),
"image/bmp" => "BMP".into(),
other => other.into(),
}
}
pub(super) fn format_bytes(bytes: usize) -> String {
if bytes < 1024 {
format!("{} B", bytes)
} else if bytes < 1024 * 1024 {
format!("{:.1} KB", bytes as f64 / 1024.0)
} else {
format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
}
}
pub(super) fn truncate_path_for_overlay(path: &str, max_chars: usize) -> String {
if max_chars == 0 {
return String::new();
}
let char_count = path.chars().count();
if char_count <= max_chars {
return path.to_owned();
}
if max_chars <= 3 {
return path.chars().take(max_chars).collect();
}
let keep = max_chars.saturating_sub(3) / 2;
let end_keep = max_chars.saturating_sub(3) - keep;
let chars: Vec<char> = path.chars().collect();
let head: String = chars[..keep].iter().collect();
let tail: String = chars[chars.len() - end_keep..].iter().collect();
format!("{head}...{tail}")
}
@@ -0,0 +1,101 @@
use std::path::Path;
use ratatui::layout::Rect;
use crate::prompt_images::PastedImage;
use crate::terminal::image::{self as terminal_image, GraphicsProtocol};
pub(super) const MIN_BOX_WIDTH: u16 = 28;
pub(super) const MIN_PIXEL_BOX_HEIGHT: u16 = 8;
pub(super) const MIN_META_BOX_HEIGHT: u16 = 6;
const META_PREVIEW_WIDTH_RATIO: f32 = 0.75;
const META_CONTENT_LINES: u16 = 4;
const META_BOX_CHROME_ROWS: u16 = 2;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) struct ImagePreviewPlan<'a> {
pub(super) show_pixels: bool,
pub(super) display_path: Option<&'a Path>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) struct ImageOverlayGeometry {
pub(super) overlay_rect: Rect,
pub(super) image_placement: Option<ImagePlacement>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) struct ImagePlacement {
pub(super) cols: u16,
pub(super) rows: u16,
pub(super) x: u16,
pub(super) y: u16,
}
pub(super) fn plan_image_preview(
image: &PastedImage,
protocol: GraphicsProtocol,
) -> ImagePreviewPlan<'_> {
ImagePreviewPlan {
show_pixels: protocol.supports_images() && image.preview.prepared().is_some(),
display_path: image.source_path.as_deref(),
}
}
pub(super) fn overlay_geometry(
area: Rect,
show_pixels: bool,
has_path: bool,
dimensions: (u32, u32),
) -> Option<ImageOverlayGeometry> {
let min_height = if show_pixels {
MIN_PIXEL_BOX_HEIGHT
} else {
MIN_META_BOX_HEIGHT
};
if area.width < MIN_BOX_WIDTH || area.height < min_height {
return None;
}
if show_pixels {
let footer_rows = u16::from(has_path);
let max_cols = area.width.saturating_sub(2).max(4);
let max_rows = area
.height
.saturating_sub(2)
.saturating_sub(footer_rows)
.max(2);
let (cols, rows) =
terminal_image::fit_image_to_cells(dimensions.0, dimensions.1, max_cols, max_rows);
let width = (cols.saturating_add(2)).clamp(MIN_BOX_WIDTH, area.width);
let height = (rows.saturating_add(2).saturating_add(footer_rows))
.clamp(MIN_PIXEL_BOX_HEIGHT, area.height);
let x = area.x + area.width.saturating_sub(width) / 2;
let y = area.y + area.height.saturating_sub(height) / 2;
let inner_width = width.saturating_sub(2);
let inner_height = height.saturating_sub(2).saturating_sub(footer_rows);
return Some(ImageOverlayGeometry {
overlay_rect: Rect::new(x, y, width, height),
image_placement: Some(ImagePlacement {
cols,
rows,
x: x + 1 + inner_width.saturating_sub(cols) / 2,
y: y + 1 + inner_height.saturating_sub(rows) / 2,
}),
});
}
let width = ((area.width as f32) * META_PREVIEW_WIDTH_RATIO) as u16;
let width = width.clamp(MIN_BOX_WIDTH, area.width);
let height = (META_CONTENT_LINES + META_BOX_CHROME_ROWS)
.min(area.height)
.max(MIN_META_BOX_HEIGHT)
.min(area.height);
let x = area.x + area.width.saturating_sub(width) / 2;
let y = area.y + area.height.saturating_sub(height);
Some(ImageOverlayGeometry {
overlay_rect: Rect::new(x, y, width, height),
image_placement: None,
})
}
@@ -0,0 +1,201 @@
use std::path::{Path, PathBuf};
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Color;
use super::content::{format_bytes, format_mime};
use super::geometry::{overlay_geometry, plan_image_preview};
use super::*;
use crate::terminal::image::{GraphicsProtocol, set_protocol_for_test};
fn png_header() -> Vec<u8> {
vec![0x89, b'P', b'N', b'G', b'\r', b'\n', 0x1a, b'\n']
}
fn sample_image(path: Option<&str>, pixels: bool) -> PastedImage {
let encoded_bytes = pixels.then(png_header);
let preview = encoded_bytes
.as_ref()
.map(|bytes| {
crate::prompt_images::PromptImagePreview::ready_for_test(bytes.clone(), (640, 480))
})
.unwrap_or_default();
PastedImage {
element_id: kigi_ratatui_textarea::ElementId::from_raw(1),
display_number: 1,
mime_type: "image/png".into(),
dimensions: Some((640, 480)),
byte_len: 1536,
encoded_bytes: encoded_bytes.map(Into::into),
source_path: path.map(PathBuf::from),
staged_temp_path: None,
session_image_path: None,
preview,
}
}
fn render_to_string(image: &PastedImage, area: Rect) -> (Option<ImageOverlayRender>, String) {
let mut buf = Buffer::empty(area);
let render = render_image_overlay_inner(
&mut buf,
area,
image,
Color::Black,
Color::White,
Color::Gray,
);
let rendered = (area.y..area.y + area.height)
.map(|y| {
(area.x..area.x + area.width)
.filter_map(|x| buf.cell((x, y)).map(|cell| cell.symbol().to_owned()))
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n");
(render, rendered)
}
#[test]
fn plan_covers_pixels_by_path_matrix() {
for (protocol, path, pixels, expected_pixels, expected_path) in [
(
GraphicsProtocol::Kitty,
Some("/tmp/logo.png"),
true,
true,
Some(Path::new("/tmp/logo.png")),
),
(GraphicsProtocol::Kitty, None, true, true, None),
(
GraphicsProtocol::None,
Some("/tmp/logo.png"),
true,
false,
Some(Path::new("/tmp/logo.png")),
),
(GraphicsProtocol::None, None, true, false, None),
] {
let image = sample_image(path, pixels);
let plan = plan_image_preview(&image, protocol);
assert_eq!(plan.show_pixels, expected_pixels);
assert_eq!(plan.display_path, expected_path);
}
}
#[test]
fn plan_displays_only_user_visible_source_path() {
let mut image = sample_image(None, true);
image.source_path = Some(PathBuf::from("/Users/me/original.png"));
image.session_image_path = Some(PathBuf::from("/tmp/session/image-uuid.png"));
assert_eq!(
plan_image_preview(&image, GraphicsProtocol::None).display_path,
Some(Path::new("/Users/me/original.png"))
);
image.source_path = None;
assert!(
plan_image_preview(&image, GraphicsProtocol::None)
.display_path
.is_none()
);
}
#[test]
fn paint_pixels_with_path_returns_footer_and_exact_transmission() {
let _guard = set_protocol_for_test(GraphicsProtocol::Kitty);
crate::terminal::overlay::reset_owner();
let image = sample_image(Some("/tmp/logo.png"), true);
let (render, text) = render_to_string(&image, Rect::new(10, 5, 60, 20));
let render = render.unwrap();
let placement = render.image_placement.unwrap();
let escapes = render.escapes.unwrap();
assert!(text.contains("Image #1"));
assert!(
text.contains("Path: /tmp/logo.png"),
"rendered footer missing path: {text:?}",
);
assert!(escapes.as_str().starts_with(&format!(
"\x1b[{};{}H",
placement.y + 1,
placement.x + 1
)));
assert!(
escapes
.as_str()
.contains(&format!("c={},r={}", placement.cols, placement.rows))
);
}
#[test]
fn paint_pixels_without_path_has_no_footer() {
let _guard = set_protocol_for_test(GraphicsProtocol::Kitty);
let (render, text) = render_to_string(&sample_image(None, true), Rect::new(0, 0, 60, 20));
assert!(render.unwrap().image_placement.is_some());
assert!(!text.contains("Path:"));
}
#[test]
fn paint_metadata_with_path_shows_all_fields() {
let _guard = set_protocol_for_test(GraphicsProtocol::None);
let (render, text) = render_to_string(
&sample_image(Some("/tmp/logo.png"), true),
Rect::new(0, 0, 60, 20),
);
assert!(render.unwrap().image_placement.is_none());
assert!(text.contains("Format: PNG"));
assert!(text.contains("Dimensions: 640 x 480"));
assert!(text.contains("Path:"));
assert!(text.contains("logo.png"));
}
#[test]
fn failed_preview_uses_stable_metadata_fallback() {
let _guard = set_protocol_for_test(GraphicsProtocol::Kitty);
let mut image = sample_image(Some("/tmp/photo.jpg"), false);
image.mime_type = "image/jpeg".into();
image.preview.mark_failed();
let (render, text) = render_to_string(&image, Rect::new(0, 0, 80, 30));
assert!(render.unwrap().image_placement.is_none());
assert!(text.contains("Format: JPEG"));
assert!(text.contains("Preview unavailable"));
assert!(!text.contains("Loading..."));
}
#[test]
fn geometry_keeps_metadata_compact_and_pixels_larger() {
let area = Rect::new(0, 0, 100, 40);
let metadata = overlay_geometry(area, false, true, (640, 480)).unwrap();
let pixels = overlay_geometry(area, true, true, (640, 480)).unwrap();
assert_eq!(
metadata.overlay_rect.y + metadata.overlay_rect.height,
area.y + area.height
);
assert!(metadata.overlay_rect.height <= 8);
assert!(
pixels.overlay_rect.height > metadata.overlay_rect.height
|| pixels.overlay_rect.width > metadata.overlay_rect.width
);
}
#[test]
fn geometry_honors_plan_specific_minima() {
assert!(overlay_geometry(Rect::new(0, 0, 20, 20), false, true, (640, 480)).is_none());
assert!(overlay_geometry(Rect::new(0, 0, 60, 7), true, false, (640, 480)).is_none());
for height in [6, 7] {
let geometry =
overlay_geometry(Rect::new(0, 0, 60, height), false, true, (640, 480)).unwrap();
assert_eq!(geometry.overlay_rect.height, 6);
}
}
#[test]
fn formatting_helpers_cover_known_and_unknown_values() {
assert_eq!(format_mime("image/png"), "PNG");
assert_eq!(
format_mime("application/octet-stream"),
"application/octet-stream"
);
assert_eq!(format_bytes(512), "512 B");
assert_eq!(format_bytes(1536), "1.5 KB");
assert_eq!(format_bytes(2_500_000), "2.4 MB");
}
@@ -0,0 +1,605 @@
//! Line and string utility functions for ratatui text manipulation.
use ratatui::text::{Line, Span};
use unicode_segmentation::UnicodeSegmentation;
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
pub use super::tool_paths::{path_basename, path_for_tool_header, shorten_path};
/// Clone a borrowed ratatui `Line` into an owned `'static` line.
pub fn line_to_static(line: &Line<'_>) -> Line<'static> {
Line {
style: line.style,
alignment: line.alignment,
spans: line
.spans
.iter()
.map(|s| Span {
style: s.style,
content: std::borrow::Cow::Owned(s.content.to_string()),
})
.collect(),
}
}
/// Append owned copies of borrowed lines to `out`.
pub fn push_owned_lines(src: &[Line<'_>], out: &mut Vec<Line<'static>>) {
for l in src {
out.push(line_to_static(l));
}
}
/// True for a character unsafe to render from untrusted/server text:
/// C0/C1 controls (the terminal-escape-injection vector) plus the Unicode
/// bidi-control and zero-width/format set (Trojan-Source spoofing) — U+061C,
/// U+200B200F, U+202A202E, U+2060206F, U+FEFF.
///
/// Shared by every untrusted-text strip/scrub site (chip labels, toast error
/// scrub, settings editor input) so the set never drifts between them.
pub fn is_unsafe_display_char(c: char) -> bool {
c.is_control()
|| matches!(
c,
'\u{061C}'
| '\u{200B}'..='\u{200F}'
| '\u{202A}'..='\u{202E}'
| '\u{2060}'..='\u{206F}'
| '\u{FEFF}'
)
}
/// Polyfill for nightly-only [`str::floor_char_boundary`].
/// Snaps a byte index down to the nearest char boundary.
pub fn floor_char_boundary(s: &str, index: usize) -> usize {
let index = index.min(s.len());
let mut i = index;
while i > 0 && !s.is_char_boundary(i) {
i -= 1;
}
i
}
/// Byte offset at which cumulative display width exceeds `max_width`.
/// Returns `s.len()` when the entire string fits.
pub fn byte_offset_at_width(s: &str, max_width: usize) -> usize {
let mut width = 0;
for (i, ch) in s.char_indices() {
let cw = UnicodeWidthChar::width(ch).unwrap_or(0);
if width + cw > max_width {
return i;
}
width += cw;
}
s.len()
}
/// Truncate a string to fit within `max_width` display columns.
///
/// Uses Unicode-aware width measurement (handles CJK wide chars,
/// multi-byte UTF-8 like em-dash, etc.). If truncated, the last character
/// is replaced with `…` so the result fits within `max_width`.
///
/// Returns the original string (owned) if it already fits.
pub fn truncate_str(s: &str, max_width: usize) -> String {
if max_width == 0 {
return String::new();
}
let end = byte_offset_at_width(s, max_width);
let needs_ellipsis = end < s.len();
if needs_ellipsis && max_width > 1 {
// Back up one char to make room for '…' (1 display column).
let truncated_end = s[..end]
.char_indices()
.next_back()
.map(|(i, _)| i)
.unwrap_or(0);
format!("{}", &s[..truncated_end])
} else if needs_ellipsis {
"".to_string()
} else {
s[..end].to_string()
}
}
/// Truncate a styled `Line` (multiple spans) to fit within `max_width` display columns.
///
/// Walks spans left-to-right, consuming width budget. When the budget is
/// 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.
pub fn truncate_line(line: Line<'static>, max_width: usize) -> Line<'static> {
if max_width == 0 {
return Line::from(vec![]);
}
let total: usize = line.spans.iter().map(|s| s.content.width()).sum();
if total <= max_width {
return line;
}
// Need room for the ellipsis (1 column).
let budget = max_width.saturating_sub(1);
let mut used = 0usize;
let mut out: Vec<Span<'static>> = Vec::new();
for span in line.spans {
let sw = span.content.width();
if used + sw <= budget {
// Entire span fits.
used += sw;
out.push(span);
} else {
// Partial fit — truncate this span.
let remaining = budget - used;
if remaining > 0 {
let truncated = take_width(&span.content, remaining);
out.push(Span::styled(truncated, span.style));
}
// Append ellipsis with the same style as the last span.
let ellipsis_style = out.last().map(|s| s.style).unwrap_or_default();
out.push(Span::styled("\u{2026}", ellipsis_style));
return Line::from(out);
}
}
// Shouldn't reach here (total > max_width checked above), but be safe.
Line::from(out)
}
/// 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
/// 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
/// glyph bug). Width uses [`UnicodeWidthStr`], matching the table layout.
///
/// `width` must be a bounded display width: the pad branch allocates
/// `width - total` spaces.
pub fn fit_line_to_width<'a>(line: Line<'a>, width: usize) -> Line<'a> {
let total: usize = line.spans.iter().map(|s| s.content.width()).sum();
if total == width {
return line;
}
let Line {
style,
alignment,
mut spans,
} = line;
if total < width {
spans.push(Span::raw(" ".repeat(width - total)));
return Line {
style,
alignment,
spans,
};
}
// Wider than width: clip on grapheme boundaries, no ellipsis.
let mut out: Vec<Span<'a>> = Vec::new();
let mut used = 0usize;
for span in spans {
let sw = span.content.width();
if used + sw <= width {
used += sw;
out.push(span);
if used == width {
break;
}
continue;
}
// This span straddles the boundary — take whole graphemes that fit.
let remaining = width - used;
let mut taken = String::new();
let mut taken_width = 0usize;
for g in span.content.graphemes(true) {
let gw = g.width();
if taken_width + gw > remaining {
break;
}
taken_width += gw;
taken.push_str(g);
}
if !taken.is_empty() {
out.push(Span::styled(taken, span.style));
used += taken_width;
}
// A straddling wide grapheme leaves a 1-column gap; pad it.
if used < width {
out.push(Span::raw(" ".repeat(width - used)));
}
break;
}
Line {
style,
alignment,
spans: out,
}
}
/// Take the first `n` display columns from a string.
fn take_width(s: &str, n: usize) -> String {
let mut width = 0;
let mut end = s.len();
for (i, ch) in s.char_indices() {
let cw = ch.width().unwrap_or(0);
if width + cw > n {
end = i;
break;
}
width += cw;
}
s[..end].to_string()
}
/// Cascade-truncate multiple text elements to fit within `avail` display columns.
///
/// Returns `(type, description, activity, meta)` truncated to fit.
/// Priority (highest first): type, activity, meta. Description is truncated
/// first. If overhead (type + activity + meta) >= avail, description is dropped
/// and the remaining elements are cascaded: meta is dropped first, then
/// activity is truncated, then type.
pub fn cascade_truncate(
avail: usize,
type_text: &str,
description: &str,
activity_text: &str,
meta_text: &str,
) -> (String, String, String, String) {
let overhead = type_text.width() + activity_text.width() + meta_text.width();
if overhead <= avail {
let desc_max = avail - overhead;
(
type_text.to_string(),
truncate_str(description, desc_max),
activity_text.to_string(),
meta_text.to_string(),
)
} else {
let mut budget = avail;
let td = if type_text.width() <= budget {
budget -= type_text.width();
type_text.to_string()
} else {
let s = truncate_str(type_text, budget);
budget = 0;
s
};
let ad = if budget == 0 {
String::new()
} else if activity_text.width() <= budget {
budget -= activity_text.width();
activity_text.to_string()
} else {
let s = truncate_str(activity_text, budget);
budget = 0;
s
};
let md = if budget == 0 {
String::new()
} else if meta_text.width() <= budget {
meta_text.to_string()
} else {
truncate_str(meta_text, budget)
};
(td, String::new(), ad, md)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn is_unsafe_display_char_covers_controls_and_bidi_format() {
// Safe: ordinary printable text (incl. legitimate RTL letters).
for c in ['a', ' ', '/', '\u{00e9}', '\u{05d0}'] {
assert!(!is_unsafe_display_char(c), "{c:?} must be safe");
}
// Unsafe: C0/C1 controls + the full bidi-control / zero-width set.
for c in [
'\u{1b}', '\n', '\t', '\u{061C}', '\u{200B}', '\u{200F}', '\u{202E}', '\u{2066}',
'\u{2069}', '\u{206F}', '\u{FEFF}',
] {
assert!(
is_unsafe_display_char(c),
"{:#06x} must be unsafe",
c as u32
);
}
}
#[test]
fn truncate_str_fits() {
assert_eq!(truncate_str("hello", 10), "hello");
assert_eq!(truncate_str("hello", 5), "hello");
}
#[test]
fn truncate_str_truncates() {
assert_eq!(truncate_str("hello world!", 5), "hell…");
assert_eq!(truncate_str("abcdef", 4), "abc…");
}
#[test]
fn truncate_str_empty_and_zero() {
assert_eq!(truncate_str("hello", 0), "");
assert_eq!(truncate_str("", 5), "");
}
#[test]
fn truncate_str_width_1() {
assert_eq!(truncate_str("hello", 1), "");
assert_eq!(truncate_str("x", 1), "x");
}
#[test]
fn truncate_str_multibyte() {
// em-dash is 1 display column but 3 bytes
let s = "hello — world";
let result = truncate_str(s, 8);
assert!(result.ends_with('…'));
assert!(result.len() <= 12); // safe byte length
}
// ── truncate_line tests ─────────────────────────────────────────
#[test]
fn truncate_line_fits() {
let line = Line::from(vec![Span::raw("Hello "), Span::raw("world")]);
let result = truncate_line(line, 20);
assert_eq!(result.spans.len(), 2);
assert_eq!(result.spans[0].content.as_ref(), "Hello ");
assert_eq!(result.spans[1].content.as_ref(), "world");
}
#[test]
fn truncate_line_cuts_mid_span() {
let line = Line::from(vec![
Span::raw("Edit "),
Span::raw("very/long/path/to/file.rs"),
]);
// Total = 29, budget = 15 → "Edit very/long…"
let result = truncate_line(line, 15);
let text: String = result.spans.iter().map(|s| s.content.as_ref()).collect();
assert!(text.ends_with('\u{2026}'));
assert!(text.width() <= 15);
}
#[test]
fn truncate_line_drops_later_spans() {
let line = Line::from(vec![
Span::raw("Search "),
Span::raw("pattern"),
Span::raw(" in "),
Span::raw("path"),
Span::raw(" (5 matches)"),
]);
let result = truncate_line(line, 18);
let text: String = result.spans.iter().map(|s| s.content.as_ref()).collect();
assert!(text.ends_with('\u{2026}'));
assert!(text.width() <= 18);
}
#[test]
fn truncate_line_zero_width() {
let line = Line::from(vec![Span::raw("hello")]);
let result = truncate_line(line, 0);
assert!(result.spans.is_empty());
}
// ── fit_line_to_width tests ─────────────────────────────────────
fn line_text(line: &Line<'static>) -> String {
line.spans.iter().map(|s| s.content.as_ref()).collect()
}
#[test]
fn fit_line_pads_short_line() {
let line = Line::from(vec![Span::raw("│ a │")]);
let out = fit_line_to_width(line, 10);
assert_eq!(line_text(&out).width(), 10);
assert_eq!(line_text(&out), "│ a │ ");
}
#[test]
fn fit_line_exact_width_unchanged() {
let line = Line::from(vec![Span::raw("hello")]);
let out = fit_line_to_width(line, 5);
assert_eq!(out.spans.len(), 1);
assert_eq!(line_text(&out), "hello");
}
#[test]
fn fit_line_clips_long_line_no_ellipsis() {
let line = Line::from(vec![Span::raw("│ Column A │ Column B │")]);
let out = fit_line_to_width(line, 8);
assert_eq!(line_text(&out).width(), 8);
assert_eq!(line_text(&out), "│ Column");
}
#[test]
fn fit_line_does_not_split_emoji_grapheme() {
// a(1)+b(1)+⚠️(2) = 4. Clipping to 3 must drop the width-2 grapheme
// whole (never split it) and pad → "ab" + 1 space.
let line = Line::from(vec![Span::raw("ab\u{26A0}\u{FE0F}")]);
let out = fit_line_to_width(line, 3);
assert_eq!(line_text(&out).width(), 3);
assert_eq!(line_text(&out), "ab ");
}
#[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.
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);
assert_eq!(line_text(&out), "ab ");
}
#[test]
fn fit_line_drops_subsequent_spans_after_clip() {
let line = Line::from(vec![
Span::raw("hello"),
Span::raw(" world"),
Span::raw("!!!"),
]);
let out = fit_line_to_width(line, 5);
assert_eq!(line_text(&out), "hello");
// The straddling/later spans must be dropped entirely.
assert_eq!(out.spans.len(), 1);
}
#[test]
fn fit_line_takes_partial_of_later_span() {
let line = Line::from(vec![Span::raw("ab"), Span::raw("cdef")]);
let out = fit_line_to_width(line, 4);
assert_eq!(line_text(&out), "abcd");
assert_eq!(line_text(&out).width(), 4);
}
#[test]
fn fit_line_zero_width_returns_empty() {
let line = Line::from(vec![Span::raw("│ a │")]);
let out = fit_line_to_width(line, 0);
assert_eq!(line_text(&out), "");
assert_eq!(line_text(&out).width(), 0);
}
#[test]
fn fit_line_preserves_span_styles_when_padding() {
let bold = ratatui::style::Style::new().add_modifier(ratatui::style::Modifier::BOLD);
let line = Line::from(vec![Span::styled("hi", bold)]);
let out = fit_line_to_width(line, 5);
assert_eq!(line_text(&out).width(), 5);
assert!(
out.spans[0]
.style
.add_modifier
.contains(ratatui::style::Modifier::BOLD)
);
}
#[test]
fn legacy_tool_path_api_remains_available_at_line_utils_path() {
assert_eq!(shorten_path("verylongfilename.rs", 10), "verylongf…");
assert_eq!(path_basename("/repo/src/main.rs", 80), "main.rs");
assert_eq!(
path_for_tool_header("/repo/src/main.rs", Some(80), "Read ".len()),
"main.rs"
);
assert_eq!(
path_for_tool_header("/repo/src/main.rs", None, "Read ".len()),
"/repo/src/main.rs"
);
}
// ── cascade_truncate tests ────────────────────────────────────
#[test]
fn cascade_truncate_all_fit() {
let (t, d, a, m) =
cascade_truncate(50, "type ", "description", " \u{2014} running", " meta");
assert_eq!(t, "type ");
assert_eq!(d, "description");
assert_eq!(a, " \u{2014} running");
assert_eq!(m, " meta");
}
#[test]
fn cascade_truncate_desc_truncated() {
let (t, d, a, m) = cascade_truncate(
25,
"type ",
"long description here",
" \u{2014} running",
" meta",
);
assert_eq!(t, "type ");
assert_eq!(d, "lo\u{2026}");
assert_eq!(a, " \u{2014} running");
assert_eq!(m, " meta");
}
#[test]
fn cascade_truncate_desc_gone_meta_truncated() {
// overhead = 6+10+6 = 22 > avail 20 → desc gone, type 6 + activity 10 + meta truncated to 4
let (t, d, a, m) = cascade_truncate(20, "type ", "desc", " \u{2014} running", " meta");
assert_eq!(t, "type ");
assert_eq!(d, "");
assert_eq!(a, " \u{2014} running");
assert_eq!(m, " m\u{2026}");
}
#[test]
fn cascade_truncate_meta_and_activity_gone() {
// avail=8, type=6 fits (budget=2), activity truncated to 2, meta gone
let (t, d, a, m) = cascade_truncate(8, "type ", "desc", " \u{2014} running", " meta");
assert_eq!(t, "type ");
assert_eq!(d, "");
assert_eq!(a, " \u{2026}");
assert_eq!(m, "");
}
#[test]
fn cascade_truncate_type_truncated() {
let (t, d, a, m) = cascade_truncate(3, "type ", "desc", " \u{2014} running", " meta");
assert_eq!(t, "ty\u{2026}");
assert_eq!(d, "");
assert_eq!(a, "");
assert_eq!(m, "");
}
#[test]
fn cascade_truncate_zero_avail() {
let (t, d, a, m) = cascade_truncate(0, "type ", "desc", " \u{2014} running", " meta");
assert_eq!(t, "");
assert_eq!(d, "");
assert_eq!(a, "");
assert_eq!(m, "");
}
#[test]
fn cascade_truncate_unicode() {
// ✗ = 1 display column; — = 1 display column
let (t, d, a, m) = cascade_truncate(10, "\u{2717} ", "description", " \u{2014} run", "");
assert_eq!(t, "\u{2717} ");
assert_eq!(d, "\u{2026}");
assert_eq!(a, " \u{2014} run");
assert_eq!(m, "");
}
#[test]
fn cascade_truncate_overhead_equals_avail() {
// overhead exactly equals avail → desc empty, everything else fits
let (t, d, a, m) = cascade_truncate(22, "type ", "desc", " \u{2014} running", " meta");
assert_eq!(t, "type ");
assert_eq!(d, "");
assert_eq!(a, " \u{2014} running");
assert_eq!(m, " meta");
}
#[test]
fn cascade_truncate_avail_one() {
let (t, d, a, m) = cascade_truncate(1, "type", "desc", "act", "meta");
assert_eq!(t, "\u{2026}");
assert_eq!((d.as_str(), a.as_str(), m.as_str()), ("", "", ""));
}
#[test]
fn cascade_truncate_all_empty() {
let (t, d, a, m) = cascade_truncate(10, "", "", "", "");
assert_eq!(
(t.as_str(), d.as_str(), a.as_str(), m.as_str()),
("", "", "", "")
);
}
}
@@ -0,0 +1,22 @@
//! Low-level rendering utilities.
//!
//! Generic rendering primitives used by the scrollback and viewport.
pub mod color;
pub mod draw;
pub mod gboom_overlay;
pub mod highlight;
pub mod image_overlay;
pub mod line_utils;
pub mod osc8;
pub mod preview_overlay;
pub mod renderable;
pub mod scrollbar;
pub mod terminal_output;
pub mod tool_paths;
pub mod video_overlay;
pub mod wrapping;
pub use image_overlay::render_image_overlay;
pub use preview_overlay::{PreviewConfig, PreviewStyle, render_preview_overlay};
pub mod safe_buf;
pub use renderable::Renderable;
pub use safe_buf::SafeBuf;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,604 @@
//! Multiline preview overlay widget.
//!
//! Renders a bordered popup showing a preview of multiline content.
//! Shows first N and last N lines with a `⋮` separator when content
//! exceeds the preview limit.
//!
//! Used for:
//! - Paste element previews in the prompt widget
//! - Queue item previews in the queue pane
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::{Color, Style};
use ratatui::text::{Line, Span};
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)]
pub struct PreviewStyle {
/// Background color for the entire overlay box.
pub bg: Color,
/// Foreground color for content text.
pub text_fg: Color,
/// Foreground color for the border and dots separator.
pub border_fg: Color,
}
impl PreviewStyle {
/// Create a style with explicit colors.
pub fn new(bg: Color, text_fg: Color, border_fg: Color) -> Self {
Self {
bg,
text_fg,
border_fg,
}
}
}
// ---------------------------------------------------------------------------
// PreviewConfig — layout configuration
// ---------------------------------------------------------------------------
/// Layout configuration for the preview overlay.
#[derive(Debug, Clone)]
pub struct PreviewConfig {
/// Number of lines to show from the top and bottom when truncating.
/// If content has more than `preview_lines * 2` lines, shows first N,
/// dots separator, and last N lines.
pub preview_lines: usize,
/// Width of the overlay as a fraction of the available width (0.0 - 1.0).
/// Default: 0.75 (3/4 of available width).
pub width_ratio: f32,
/// Vertical gap between the overlay's bottom border and the anchor point.
/// 0 = overlay sits flush against the anchor.
pub bottom_gap: u16,
/// Minimum width for the overlay. Below this, the overlay won't render.
pub min_width: u16,
/// Minimum height for the overlay area. Below this, the overlay won't render.
pub min_height: u16,
/// Optional one-line hint painted into the bottom border row, e.g.
/// `╰─ enter to expand ────╯`. Costs no content row; skipped when the
/// box is too narrow to fit readable text. `None` (the default)
/// leaves the plain border.
pub hint: Option<Line<'static>>,
}
impl Default for PreviewConfig {
fn default() -> Self {
Self {
preview_lines: 3,
width_ratio: 0.75,
bottom_gap: 0,
min_width: 20,
min_height: 5,
hint: None,
}
}
}
// ---------------------------------------------------------------------------
// render_preview_overlay — main rendering function
// ---------------------------------------------------------------------------
/// Render a multiline preview overlay.
///
/// The overlay is anchored at the bottom of `area`, showing a bordered box
/// with the content preview. If content exceeds `config.preview_lines * 2`
/// lines, shows first N lines, a `⋮ (X more lines)` separator, and last N lines.
///
/// # Arguments
///
/// * `buf` - The buffer to render into
/// * `area` - The available area for the overlay (anchored at bottom)
/// * `content` - The multiline text content to preview
/// * `style` - Visual styling (colors)
/// * `config` - Layout configuration
///
/// # Returns
///
/// The actual `Rect` where the overlay was rendered, or `None` if the overlay
/// couldn't be rendered (area too small, content empty).
pub fn render_preview_overlay(
buf: &mut Buffer,
area: Rect,
content: &str,
style: PreviewStyle,
config: PreviewConfig,
) -> Option<Rect> {
let lines: Vec<&str> = content.lines().collect();
let total = lines.len();
// Don't render if content is empty or area is too small
if total == 0 || area.height < config.min_height || area.width < config.min_width {
return None;
}
// 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
} else {
total
};
// Box dimensions: border(1) + content + border(1)
let box_height = (content_lines as u16 + 2).min(area.height);
let box_width = ((area.width as f32) * config.width_ratio) as u16;
// Anchor at bottom of area
let anchor_bottom = area.y + area.height - config.bottom_gap;
let box_x = area.x + (area.width.saturating_sub(box_width)) / 2;
let box_y = anchor_bottom.saturating_sub(box_height);
let box_area = Rect {
x: box_x,
y: box_y,
width: box_width,
height: box_height,
};
// Build the bordered block
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(style.border_fg))
.style(Style::default().bg(style.bg));
let inner = block.inner(box_area);
// Clear background - fill every cell so underlying content doesn't bleed through
Clear.render(box_area, buf);
buf.set_style(box_area, Style::default().bg(style.bg));
// Render the border
block.render(box_area, buf);
// Render content
let text_style = Style::default().fg(style.text_fg).bg(style.bg);
let dots_style = Style::default().fg(style.border_fg).bg(style.bg);
render_content_lines(
buf,
inner,
&lines,
needs_dots,
config.preview_lines,
text_style,
dots_style,
);
// Hint lives in the bottom border row: costs no content row, and the
// border interruption reads as a label even when a theme aliases the
// hint palette to the border/content colors.
if let Some(hint) = &config.hint {
render_border_hint(buf, box_area, hint, style.bg);
}
Some(box_area)
}
/// Render the content lines into the inner area.
fn render_content_lines(
buf: &mut Buffer,
inner: Rect,
lines: &[&str],
needs_dots: bool,
preview_lines: usize,
text_style: Style,
dots_style: Style,
) {
let total = lines.len();
let mut row = 0u16;
let max_rows = inner.height;
if needs_dots {
// Top lines
for line in lines.iter().take(preview_lines) {
if row >= max_rows {
break;
}
render_line(buf, inner.x, inner.y + row, inner.width, line, text_style);
row += 1;
}
// Dots separator
if row < max_rows {
let omitted = total - preview_lines * 2;
let dots_text = format!("⋮ ({omitted} more lines)");
buf.set_span_safe(
inner.x,
inner.y + row,
&Span::styled(dots_text, dots_style),
inner.width,
);
row += 1;
}
// Bottom lines
let start = total.saturating_sub(preview_lines);
for line in lines.iter().skip(start) {
if row >= max_rows {
break;
}
render_line(buf, inner.x, inner.y + row, inner.width, line, text_style);
row += 1;
}
} else {
// Show all lines
for line in lines {
if row >= max_rows {
break;
}
render_line(buf, inner.x, inner.y + row, inner.width, line, text_style);
row += 1;
}
}
}
/// Render a single truncated line.
#[inline]
fn render_line(buf: &mut Buffer, x: u16, y: u16, width: u16, line: &str, style: Style) {
let truncated = truncate_str(line, width as usize);
buf.set_span_safe(x, y, &Span::styled(truncated, style), width);
}
/// 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
/// 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.
fn render_border_hint(buf: &mut Buffer, box_area: Rect, hint: &Line<'static>, bg: Color) {
// Chrome around the text: corners (2) + one dash each side (2) + pads (2).
const CHROME: u16 = 6;
// Below this the truncated text is noise — keep the plain border.
const MIN_TEXT_WIDTH: u16 = 8;
let text_width = box_area.width.saturating_sub(CHROME);
if text_width < MIN_TEXT_WIDTH {
return;
}
let mut line = truncate_line(hint.clone(), text_width as usize);
// The box bg wins so the hint sits on the border row fill.
for span in &mut line.spans {
span.style = span.style.bg(bg);
}
let pad = Span::styled(" ", Style::default().bg(bg));
let mut spans = vec![pad.clone()];
spans.append(&mut line.spans);
spans.push(pad);
let y = box_area.y + box_area.height - 1;
buf.set_line_safe(box_area.x + 2, y, &Line::from(spans), box_area.width - 4);
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
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
)
}
#[test]
fn test_empty_content_returns_none() {
let mut buf = Buffer::empty(Rect::new(0, 0, 80, 20));
let result = render_preview_overlay(
&mut buf,
Rect::new(0, 0, 80, 20),
"",
test_style(),
PreviewConfig::default(),
);
assert!(result.is_none());
}
#[test]
fn test_area_too_small_returns_none() {
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
"hello\nworld",
test_style(),
PreviewConfig::default(),
);
assert!(result.is_none());
}
#[test]
fn test_single_line_renders() {
let mut buf = Buffer::empty(Rect::new(0, 0, 40, 10));
let result = render_preview_overlay(
&mut buf,
Rect::new(0, 0, 40, 10),
"single line",
test_style(),
PreviewConfig::default(),
);
assert!(result.is_some());
let rect = result.unwrap();
// Box should be 3 rows: border + 1 content + border
assert_eq!(rect.height, 3);
}
#[test]
fn test_few_lines_no_dots() {
let mut buf = Buffer::empty(Rect::new(0, 0, 40, 15));
let content = "line1\nline2\nline3\nline4";
let result = render_preview_overlay(
&mut buf,
Rect::new(0, 0, 40, 15),
content,
test_style(),
PreviewConfig::default(),
);
assert!(result.is_some());
let rect = result.unwrap();
// 4 lines + 2 borders = 6 rows
assert_eq!(rect.height, 6);
// Should NOT contain dots separator (4 lines <= 6 = preview_lines * 2)
let buf_str = buffer_to_string(&buf);
assert!(!buf_str.contains(""), "Should not have dots: {}", buf_str);
}
#[test]
fn test_many_lines_shows_dots() {
let mut buf = Buffer::empty(Rect::new(0, 0, 40, 15));
let content = (1..=10)
.map(|i| format!("line{}", i))
.collect::<Vec<_>>()
.join("\n");
let result = render_preview_overlay(
&mut buf,
Rect::new(0, 0, 40, 15),
&content,
test_style(),
PreviewConfig::default(),
);
assert!(result.is_some());
// Should contain dots separator (10 lines > 6 = preview_lines * 2)
let buf_str = buffer_to_string(&buf);
assert!(buf_str.contains(""), "Should have dots: {}", buf_str);
assert!(
buf_str.contains("4 more lines"),
"Should show omitted count: {}",
buf_str
);
}
#[test]
fn test_custom_preview_lines() {
let mut buf = Buffer::empty(Rect::new(0, 0, 40, 20));
let content = (1..=20)
.map(|i| format!("line{}", i))
.collect::<Vec<_>>()
.join("\n");
let config = PreviewConfig {
preview_lines: 5,
..Default::default()
};
let result = render_preview_overlay(
&mut buf,
Rect::new(0, 0, 40, 20),
&content,
test_style(),
config,
);
assert!(result.is_some());
let rect = result.unwrap();
// 5 top + 1 dots + 5 bottom + 2 borders = 13 rows
assert_eq!(rect.height, 13);
let buf_str = buffer_to_string(&buf);
assert!(
buf_str.contains("10 more lines"),
"Should show 10 omitted: {}",
buf_str
);
}
#[test]
fn test_width_ratio() {
let mut buf = Buffer::empty(Rect::new(0, 0, 100, 10));
let config = PreviewConfig {
width_ratio: 0.5,
..Default::default()
};
let result = render_preview_overlay(
&mut buf,
Rect::new(0, 0, 100, 10),
"hello",
test_style(),
config,
);
assert!(result.is_some());
let rect = result.unwrap();
assert_eq!(rect.width, 50); // 100 * 0.5 = 50
}
#[test]
fn test_long_line_truncated() {
let mut buf = Buffer::empty(Rect::new(0, 0, 30, 10));
let long_line = "a".repeat(100);
let result = render_preview_overlay(
&mut buf,
Rect::new(0, 0, 30, 10),
&long_line,
test_style(),
PreviewConfig::default(),
);
assert!(result.is_some());
// Content should be truncated with ellipsis
let buf_str = buffer_to_string(&buf);
assert!(
buf_str.contains(""),
"Long line should be truncated: {}",
buf_str
);
}
fn test_hint() -> Line<'static> {
Line::from(vec![
Span::styled("enter", Style::default()),
Span::styled(" to expand", Style::default()),
])
}
/// Helper: one buffer row as a string.
fn row_to_string(buf: &Buffer, y: u16) -> String {
(0..buf.area.width).map(|x| buf[(x, y)].symbol()).collect()
}
/// Assert the box's bottom border row keeps both rounded corners.
fn assert_corners(buf: &Buffer, rect: Rect) {
let y = rect.y + rect.height - 1;
assert_eq!(buf[(rect.x, y)].symbol(), "");
assert_eq!(buf[(rect.x + rect.width - 1, y)].symbol(), "");
}
#[test]
fn test_hint_renders_in_bottom_border() {
let mut buf = Buffer::empty(Rect::new(0, 0, 40, 10));
let config = PreviewConfig {
hint: Some(test_hint()),
..Default::default()
};
let rect = render_preview_overlay(
&mut buf,
Rect::new(0, 0, 40, 10),
"hello\nworld",
test_style(),
config,
)
.unwrap();
// The hint costs no row: 2 content + 2 borders.
assert_eq!(rect.height, 4);
assert!(row_to_string(&buf, rect.y + 1).contains("hello"));
assert!(row_to_string(&buf, rect.y + 2).contains("world"));
let bottom = row_to_string(&buf, rect.y + rect.height - 1);
assert!(bottom.contains("enter to expand"), "{bottom}");
assert_corners(&buf, rect);
}
#[test]
fn test_hint_costs_no_height() {
let area = Rect::new(0, 0, 40, 10);
let content = "l1\nl2\nl3";
let mut buf_hint = Buffer::empty(area);
let config = PreviewConfig {
hint: Some(test_hint()),
..Default::default()
};
let with_hint = render_preview_overlay(&mut buf_hint, area, content, test_style(), config);
let mut buf_plain = Buffer::empty(area);
let without = render_preview_overlay(
&mut buf_plain,
area,
content,
test_style(),
PreviewConfig::default(),
);
assert!(with_hint.is_some());
assert_eq!(with_hint, without, "hint must not change the box geometry");
}
#[test]
fn test_hint_none_keeps_plain_border() {
let mut buf = Buffer::empty(Rect::new(0, 0, 40, 10));
let rect = render_preview_overlay(
&mut buf,
Rect::new(0, 0, 40, 10),
"hello\nworld",
test_style(),
PreviewConfig::default(),
)
.unwrap();
assert_corners(&buf, rect);
// Every cell between the corners is a border dash.
let y = rect.y + rect.height - 1;
for x in rect.x + 1..rect.x + rect.width - 1 {
assert_eq!(buf[(x, y)].symbol(), "", "col {x}");
}
}
#[test]
fn test_hint_truncated_at_narrow_width() {
let mut buf = Buffer::empty(Rect::new(0, 0, 30, 10));
let config = PreviewConfig {
hint: Some(Line::from("a very long hint that cannot possibly fit")),
..Default::default()
};
let rect = render_preview_overlay(
&mut buf,
Rect::new(0, 0, 30, 10),
"hi",
test_style(),
config,
)
.unwrap();
let bottom = row_to_string(&buf, rect.y + rect.height - 1);
assert!(bottom.contains(""), "hint should ellipsize: {bottom}");
assert!(!bottom.contains("possibly"), "{bottom}");
assert_corners(&buf, rect);
}
#[test]
fn test_hint_skipped_when_ultra_narrow() {
// Box of 12 cells leaves 6 for text — below the readability floor,
// so the border stays plain.
let mut buf = Buffer::empty(Rect::new(0, 0, 16, 10));
let config = PreviewConfig {
hint: Some(test_hint()),
min_width: 10,
..Default::default()
};
let rect = render_preview_overlay(
&mut buf,
Rect::new(0, 0, 16, 10),
"hi",
test_style(),
config,
)
.unwrap();
let y = rect.y + rect.height - 1;
for x in rect.x + 1..rect.x + rect.width - 1 {
assert_eq!(buf[(x, y)].symbol(), "", "col {x}");
}
assert_corners(&buf, rect);
}
/// Helper: convert buffer to string for assertions.
fn buffer_to_string(buf: &Buffer) -> String {
let mut s = String::new();
for y in 0..buf.area.height {
for x in 0..buf.area.width {
s.push_str(buf[(x, y)].symbol());
}
s.push('\n');
}
s
}
}
@@ -0,0 +1,209 @@
//! The [`Renderable`] trait for self-rendering content.
//!
//! This is the core rendering abstraction for virtualized scrolling.
//! Types implementing `Renderable` know:
//! - How tall they are at a given width (`desired_height`)
//! - How to render themselves into a buffer area (`render`)
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::text::{Line, Span};
use ratatui::widgets::WidgetRef;
use std::sync::Arc;
/// Trait for content that can render itself.
///
/// Implementors must be able to:
/// - Report their desired height at a given width
/// - Render into a provided rectangular area
///
/// The trait is object-safe to allow heterogeneous collections.
pub trait Renderable {
/// Render content into the given area.
fn render(&self, area: Rect, buf: &mut Buffer);
/// Height needed at this width in lines.
///
/// This should be efficient (ideally O(1)) as it may be called
/// frequently during scroll position calculations.
fn desired_height(&self, width: u16) -> u16;
}
/// Owned or borrowed renderable item for composition.
pub enum RenderableItem<'a> {
Owned(Box<dyn Renderable + 'a>),
Borrowed(&'a dyn Renderable),
}
impl<'a> Renderable for RenderableItem<'a> {
fn render(&self, area: Rect, buf: &mut Buffer) {
match self {
RenderableItem::Owned(child) => child.render(area, buf),
RenderableItem::Borrowed(child) => child.render(area, buf),
}
}
fn desired_height(&self, width: u16) -> u16 {
match self {
RenderableItem::Owned(child) => child.desired_height(width),
RenderableItem::Borrowed(child) => child.desired_height(width),
}
}
}
impl<'a> From<Box<dyn Renderable + 'a>> for RenderableItem<'a> {
fn from(value: Box<dyn Renderable + 'a>) -> Self {
RenderableItem::Owned(value)
}
}
// ============================================================================
// Standard Implementations
// ============================================================================
/// Unit type renders as nothing (0 height).
impl Renderable for () {
fn render(&self, _area: Rect, _buf: &mut Buffer) {}
fn desired_height(&self, _width: u16) -> u16 {
0
}
}
/// String slices render as a single line.
impl Renderable for &str {
fn render(&self, area: Rect, buf: &mut Buffer) {
self.render_ref(area, buf);
}
fn desired_height(&self, _width: u16) -> u16 {
1
}
}
/// Owned strings render as a single line.
impl Renderable for String {
fn render(&self, area: Rect, buf: &mut Buffer) {
self.as_str().render_ref(area, buf);
}
fn desired_height(&self, _width: u16) -> u16 {
1
}
}
/// Spans render as a single line.
impl<'a> Renderable for Span<'a> {
fn render(&self, area: Rect, buf: &mut Buffer) {
self.render_ref(area, buf);
}
fn desired_height(&self, _width: u16) -> u16 {
1
}
}
/// Lines render as a single line (no wrapping).
impl<'a> Renderable for Line<'a> {
fn render(&self, area: Rect, buf: &mut Buffer) {
WidgetRef::render_ref(self, area, buf);
}
fn desired_height(&self, _width: u16) -> u16 {
1
}
}
// Note: Paragraph::line_count is unstable in ratatui, so we don't implement
// Renderable for Paragraph directly. Users should wrap text in custom types
// that handle their own height calculation.
/// Option<R> renders the inner value or nothing.
impl<R: Renderable> Renderable for Option<R> {
fn render(&self, area: Rect, buf: &mut Buffer) {
if let Some(renderable) = self {
renderable.render(area, buf);
}
}
fn desired_height(&self, width: u16) -> u16 {
if let Some(renderable) = self {
renderable.desired_height(width)
} else {
0
}
}
}
/// Arc<R> delegates to inner.
impl<R: Renderable> Renderable for Arc<R> {
fn render(&self, area: Rect, buf: &mut Buffer) {
self.as_ref().render(area, buf);
}
fn desired_height(&self, width: u16) -> u16 {
self.as_ref().desired_height(width)
}
}
/// Box<R> delegates to inner.
impl<R: Renderable + ?Sized> Renderable for Box<R> {
fn render(&self, area: Rect, buf: &mut Buffer) {
self.as_ref().render(area, buf);
}
fn desired_height(&self, width: u16) -> u16 {
self.as_ref().desired_height(width)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn unit_has_zero_height() {
assert_eq!(().desired_height(80), 0);
}
#[test]
fn str_has_height_one() {
assert_eq!("hello".desired_height(80), 1);
}
#[test]
fn string_has_height_one() {
assert_eq!(String::from("hello").desired_height(80), 1);
}
#[test]
fn line_has_height_one() {
let line = Line::from("hello");
assert_eq!(line.desired_height(80), 1);
}
#[test]
fn span_has_height_one() {
let span = Span::raw("hello");
assert_eq!(span.desired_height(80), 1);
}
#[test]
fn option_none_has_zero_height() {
let opt: Option<&str> = None;
assert_eq!(opt.desired_height(80), 0);
}
#[test]
fn option_some_delegates_height() {
let opt: Option<&str> = Some("hello");
assert_eq!(opt.desired_height(80), 1);
}
#[test]
fn renderable_item_owned_delegates() {
let boxed: Box<dyn Renderable> = Box::new("hello");
let item = RenderableItem::Owned(boxed);
assert_eq!(item.desired_height(80), 1);
}
#[test]
fn renderable_item_borrowed_delegates() {
let s = "hello";
let item = RenderableItem::Borrowed(&s as &dyn Renderable);
assert_eq!(item.desired_height(80), 1);
}
}
@@ -0,0 +1,52 @@
//! Bounds-checked buffer helpers.
//!
//! Ratatui's `Buffer::set_line`, `set_span`, and `set_string` panic when
//! given out-of-bounds coordinates (via `index_of`). During terminal resize
//! races, computed widget areas can momentarily exceed the buffer, causing
//! 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.
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);
}
impl SafeBuf for Buffer {
#[inline]
fn set_line_safe(&mut self, x: u16, y: u16, line: &Line<'_>, width: u16) {
if y >= self.area.y && y < self.area.bottom() && x < self.area.right() {
self.set_line(x, y, line, width);
}
}
#[inline]
fn set_span_safe(&mut self, x: u16, y: u16, span: &Span<'_>, width: u16) {
if y >= self.area.y && y < self.area.bottom() && x < self.area.right() {
self.set_span(x, y, span, width);
}
}
#[inline]
fn set_string_safe<S: AsRef<str>>(&mut self, x: u16, y: u16, string: S, style: Style) {
if y >= self.area.y && y < self.area.bottom() && x < self.area.right() {
self.set_string(x, y, string, style);
}
}
}
@@ -0,0 +1,521 @@
//! Smooth scrollbar widget with follow-mode awareness.
//!
//! This module provides scrollbar rendering using `tui-scrollbar` for smooth
//! Unicode-based scrollbars with sub-character precision.
//!
//! # Visual Design
//!
//! The scrollbar visibility indicates follow mode state:
//! - **Following (at bottom):** Very dim scrollbar (subtle indicator of content above)
//! - **Not following:** Brighter scrollbar (draws attention to "scrolled up" state)
//!
//! This helps users understand when they're viewing live content vs. scrolled back.
//!
//! # Layout
//!
//! Callers should reserve space for the scrollbar:
//! - 1 column gap (visual separation from content)
//! - 1 column track (the scrollbar itself)
//!
//! Use [`split_area_for_scrollbar`] to compute content and scrollbar areas.
//!
//! # TODO: Mouse Support
//!
//! `tui-scrollbar` already provides mouse interaction support via:
//! - [`tui_scrollbar::ScrollBarInteraction`] for drag state
//! - [`tui_scrollbar::ScrollEvent`] / [`tui_scrollbar::PointerEvent`] for input
//! - [`tui_scrollbar::ScrollBar::handle_event`] for hit testing and drag math
//!
//! To wire this up:
//! 1. Store `ScrollBarInteraction` in pane state
//! 2. Translate crossterm `MouseEvent` to `tui_scrollbar::PointerEvent`
//! 3. Call `scrollbar.handle_event()` to get `ScrollCommand::SetOffset`
//! 4. Update scroll position accordingly
use std::sync::atomic::{AtomicBool, Ordering};
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Style;
use ratatui_core::buffer::Buffer as CoreBuffer;
use ratatui_core::layout::Rect as CoreRect;
use ratatui_core::widgets::Widget as _;
use tui_scrollbar::ScrollBar;
use tui_scrollbar::ScrollLengths;
use tui_scrollbar::{SUBCELL, ScrollMetrics};
/// When set, every scrollbar renders as a no-op. The pager toggles this on in
/// minimal (scrollback-native) mode, where lists/dropdowns show
/// no scrollbar bar at all — they scroll internally and the footer carries the
/// "↑/↓ navigate" hint. Off (default) everywhere else, so the full TUI is
/// unaffected.
static SCROLLBARS_HIDDEN: AtomicBool = AtomicBool::new(false);
/// Globally hide or show all scrollbars. See [`SCROLLBARS_HIDDEN`].
pub fn set_scrollbars_hidden(hidden: bool) {
SCROLLBARS_HIDDEN.store(hidden, Ordering::Relaxed);
}
/// Whether scrollbars are currently globally hidden.
pub fn scrollbars_hidden() -> bool {
SCROLLBARS_HIDDEN.load(Ordering::Relaxed)
}
/// Number of columns reserved between content and the scrollbar track.
/// This creates the "X" gap in the XSXBXX pattern (gap between selection_right and scrollbar).
const SCROLLBAR_GAP_COLS: u16 = 1;
/// Width of the scrollbar track itself (in terminal cells).
const SCROLLBAR_TRACK_COLS: u16 = 1;
/// Total columns reserved for scrollbar UI (gap + track).
pub const SCROLLBAR_TOTAL_COLS: u16 = SCROLLBAR_GAP_COLS + SCROLLBAR_TRACK_COLS;
/// Split an area into content + scrollbar regions.
///
/// 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)
///
/// Returns `(content_area, None)` when the terminal is too narrow.
///
/// **Note**: This always reserves space for scrollbar. Use [`maybe_split_for_scrollbar`]
/// to only reserve space when the scrollbar will actually be shown.
pub fn split_area_for_scrollbar(area: Rect) -> (Rect, Option<Rect>) {
if area.width <= SCROLLBAR_TOTAL_COLS {
return (area, None);
}
let content_width = area.width.saturating_sub(SCROLLBAR_TOTAL_COLS);
let content_area = Rect {
x: area.x,
y: area.y,
width: content_width,
height: area.height,
};
let scrollbar_area = Rect {
x: area.right().saturating_sub(1),
y: area.y,
width: SCROLLBAR_TRACK_COLS,
height: area.height,
};
(content_area, Some(scrollbar_area))
}
/// Split an area only if scrollbar is actually needed.
///
/// Unlike [`split_area_for_scrollbar`], this gives full width to content
/// when scrollbar won't be shown (`total_lines <= viewport_lines`).
///
/// Use this when you know the content height before splitting.
pub fn maybe_split_for_scrollbar(area: Rect, total_lines: u16) -> (Rect, Option<Rect>) {
// Only reserve space if scrollbar will actually be shown
if needs_scrollbar(total_lines, area.height) {
split_area_for_scrollbar(area)
} else {
// No scrollbar needed - give full width to content
(area, None)
}
}
/// Whether the scrollbar should be shown (content overflows viewport).
pub fn needs_scrollbar(total_lines: u16, viewport_lines: u16) -> bool {
total_lines > viewport_lines
}
/// Whether the view is at the bottom (following mode position).
#[allow(dead_code)] // Useful helper, kept for future use
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
}
/// Result of mapping a scrollbar click/drag position to a scroll offset.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ScrollbarClickResult {
/// Jump to the very top (click on first row of track).
Top,
/// Jump to the very bottom (click on last row of track).
Bottom,
/// Set scroll offset to this value (proportional position).
Offset(usize),
}
/// Map a click on the scrollbar gutter to a scroll offset.
///
/// Uses the same `tui_scrollbar::ScrollMetrics` that the renderer uses to
/// position the thumb, so the click is the exact inverse of the rendering.
/// Emulates `JumpToClick` behavior: centers the thumb on the click position.
///
/// # Arguments
///
/// * `cell_index` — 0-based row within the scrollbar area (screen_y - sb.y)
/// * `track_cells` — height of the scrollbar area (sb.height)
/// * `total_lines` — total content height (pre-scaled)
/// * `viewport_lines` — viewport height
///
/// Returns `Top`/`Bottom` for clicks on the first/last row, otherwise
/// an offset that places the thumb centered on the click.
pub fn scrollbar_click_to_offset(
cell_index: u16,
track_cells: u16,
total_lines: u16,
viewport_lines: u16,
) -> ScrollbarClickResult {
if track_cells == 0 {
return ScrollbarClickResult::Top;
}
// First row → go to top.
if cell_index == 0 {
return ScrollbarClickResult::Top;
}
// Last row → go to bottom.
if cell_index >= track_cells.saturating_sub(1) {
return ScrollbarClickResult::Bottom;
}
let lengths = ScrollLengths {
content_len: total_lines as usize,
viewport_len: viewport_lines as usize,
};
let metrics = ScrollMetrics::new(lengths, 0, track_cells);
// Center the thumb on the clicked cell (same as tui_scrollbar JumpToClick).
let position = (cell_index as usize)
.saturating_mul(SUBCELL)
.saturating_add(SUBCELL / 2);
let half_thumb = metrics.thumb_len() / 2;
let thumb_start = position.saturating_sub(half_thumb);
let offset = metrics.offset_for_thumb_start(thumb_start);
ScrollbarClickResult::Offset(offset)
}
/// Render a scrollbar with follow-mode aware styling.
///
/// # Arguments
///
/// * `buf` - The ratatui buffer to render into
/// * `scrollbar_area` - The 1-column area for the scrollbar track
/// * `total_lines` - Total content height in lines
/// * `viewport_lines` - Visible viewport height in lines
/// * `offset` - Current scroll offset (lines from top)
/// * `is_following` - Whether follow mode is active (dims the scrollbar)
///
/// The scrollbar is always rendered when content overflows, but styled differently
/// based on follow state:
/// - Following: very dim (subtle indicator)
/// - Not following: brighter (draws attention)
pub fn render_scrollbar(
buf: &mut Buffer,
scrollbar_area: Option<Rect>,
total_lines: u16,
viewport_lines: u16,
offset: u16,
is_following: bool,
) {
if SCROLLBARS_HIDDEN.load(Ordering::Relaxed) {
return;
}
let Some(scrollbar_area) = scrollbar_area else {
return;
};
if scrollbar_area.width == 0 || scrollbar_area.height == 0 {
return;
}
if !needs_scrollbar(total_lines, viewport_lines) {
return;
}
let lengths = ScrollLengths {
content_len: total_lines as usize,
viewport_len: viewport_lines as usize,
};
let scrollbar = ScrollBar::vertical(lengths).offset(offset as usize);
// Render into ratatui-core scratch buffer
let core_area = CoreRect {
x: scrollbar_area.x,
y: scrollbar_area.y,
width: scrollbar_area.width,
height: scrollbar_area.height,
};
let mut scratch = CoreBuffer::empty(core_area);
(&scrollbar).render(core_area, &mut scratch);
// Copy to ratatui buffer with follow-aware styling
let (track_style, thumb_style) = scrollbar_styles(is_following);
for row in 0..scrollbar_area.height {
let x = scrollbar_area.x;
let y = scrollbar_area.y + row;
let src = &scratch[(x, y)];
let dst = &mut buf[(x, y)];
if src.symbol() == " " {
dst.set_symbol(" ");
dst.set_style(track_style);
} else {
dst.set_symbol("\u{2588}");
dst.set_style(thumb_style);
}
}
}
/// Get track and thumb styles based on follow mode.
///
/// Following mode: very dim colors (scrollbar recedes into background)
/// Not following: brighter colors (scrollbar "pops out")
fn scrollbar_styles(is_following: bool) -> (Style, Style) {
let theme = crate::theme::Theme::current();
if is_following {
// Very dim - scrollbar is subtle when following
let track_style = Style::new().bg(theme.scrollbar_bg);
let thumb_style = Style::new().fg(theme.scrollbar_fg).bg(theme.scrollbar_bg);
(track_style, thumb_style)
} else {
// Brighter - scrollbar stands out when scrolled up
let track_style = Style::new().bg(theme.bg_highlight);
let thumb_style = Style::new().fg(theme.gray).bg(theme.bg_highlight);
(track_style, thumb_style)
}
}
/// Render a scrollbar with custom track and thumb styles.
///
/// Like [`render_scrollbar`] but allows custom styling for theme integration.
pub fn render_scrollbar_styled(
buf: &mut Buffer,
scrollbar_area: Option<Rect>,
total_lines: u16,
viewport_lines: u16,
offset: u16,
track_style: Style,
thumb_style: Style,
) {
if SCROLLBARS_HIDDEN.load(Ordering::Relaxed) {
return;
}
let Some(scrollbar_area) = scrollbar_area else {
return;
};
if scrollbar_area.width == 0 || scrollbar_area.height == 0 {
return;
}
if !needs_scrollbar(total_lines, viewport_lines) {
return;
}
let lengths = ScrollLengths {
content_len: total_lines as usize,
viewport_len: viewport_lines as usize,
};
let scrollbar = ScrollBar::vertical(lengths).offset(offset as usize);
// Render into ratatui-core scratch buffer
let core_area = CoreRect {
x: scrollbar_area.x,
y: scrollbar_area.y,
width: scrollbar_area.width,
height: scrollbar_area.height,
};
let mut scratch = CoreBuffer::empty(core_area);
(&scrollbar).render(core_area, &mut scratch);
// Copy to ratatui buffer with custom styling
for row in 0..scrollbar_area.height {
let x = scrollbar_area.x;
let y = scrollbar_area.y + row;
let src = &scratch[(x, y)];
let dst = &mut buf[(x, y)];
if src.symbol() == " " {
dst.set_symbol(" ");
dst.set_style(track_style);
} else {
dst.set_symbol("\u{2588}");
dst.set_style(thumb_style);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::style::Color;
#[test]
fn test_split_area_normal() {
let area = Rect::new(0, 0, 40, 10);
let (content, scrollbar) = split_area_for_scrollbar(area);
// Content should be 40 - 2 = 38 wide (gap + track)
assert_eq!(content.width, 38);
assert_eq!(content.height, 10);
// Scrollbar should be at x=39, 1 column wide
let sb = scrollbar.expect("scrollbar area");
assert_eq!(sb.x, 39);
assert_eq!(sb.width, 1);
assert_eq!(sb.height, 10);
}
#[test]
fn test_split_area_too_narrow() {
let area = Rect::new(0, 0, 2, 10);
let (content, scrollbar) = split_area_for_scrollbar(area);
// Too narrow - return original area, no scrollbar
assert_eq!(content, area);
assert!(scrollbar.is_none());
}
#[test]
fn test_maybe_split_reserves_when_needed() {
let area = Rect::new(0, 0, 40, 10);
// 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
assert!(scrollbar.is_some());
}
#[test]
fn test_maybe_split_full_width_when_not_needed() {
let area = Rect::new(0, 0, 40, 10);
// 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!(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
}
#[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
}
#[test]
fn test_render_scrollbar_no_area() {
let mut buf = Buffer::empty(Rect::new(0, 0, 10, 10));
// Should not panic with None area
render_scrollbar(&mut buf, None, 100, 10, 0, false);
}
#[test]
fn test_render_scrollbar_no_overflow() {
let area = Rect::new(0, 0, 10, 10);
let (_, scrollbar_area) = split_area_for_scrollbar(area);
let mut buf = Buffer::empty(area);
// Content fits - should not render anything
render_scrollbar(&mut buf, scrollbar_area, 5, 10, 0, false);
// Check scrollbar column is empty (spaces with no custom background)
let sb = scrollbar_area.unwrap();
for y in 0..sb.height {
let cell = &buf[(sb.x, sb.y + y)];
assert_eq!(cell.symbol(), " ");
// The cell should NOT have our scrollbar background colors
// (i.e., it should be reset/default, not Color::Rgb)
if let Some(Color::Rgb(_, _, _)) = cell.style().bg {
panic!("Should not have RGB background when no scrollbar rendered");
}
// Otherwise - Reset, None, or other default-like value
}
}
#[test]
fn test_render_scrollbar_following_vs_not() {
let area = Rect::new(0, 0, 10, 10);
let (_, scrollbar_area) = split_area_for_scrollbar(area);
// Render following
let mut buf_following = Buffer::empty(area);
render_scrollbar(&mut buf_following, scrollbar_area, 100, 10, 90, true);
// Render not following
let mut buf_not_following = Buffer::empty(area);
render_scrollbar(&mut buf_not_following, scrollbar_area, 100, 10, 50, false);
// The styles should differ - not following should be brighter
let sb = scrollbar_area.unwrap();
let following_style = buf_following[(sb.x, sb.y)].style();
let not_following_style = buf_not_following[(sb.x, sb.y)].style();
// Both should have backgrounds set (non-default)
assert!(following_style.bg.is_some());
assert!(not_following_style.bg.is_some());
// At 256-color or truecolor, the backgrounds should be distinguishable.
// At Basic (16-color) level, both dark grays map to Black — expected.
if crate::theme::color_support::get().has_256() {
assert_ne!(following_style.bg, not_following_style.bg);
}
}
#[test]
fn test_scrollbar_thumb_position() {
let area = Rect::new(0, 0, 10, 10);
let (_, scrollbar_area) = split_area_for_scrollbar(area);
let sb = scrollbar_area.unwrap();
// At top
let mut buf_top = Buffer::empty(area);
render_scrollbar(&mut buf_top, scrollbar_area, 100, 10, 0, false);
// At bottom
let mut buf_bottom = Buffer::empty(area);
render_scrollbar(&mut buf_bottom, scrollbar_area, 100, 10, 90, false);
// Count thumb cells (non-space)
let count_thumb = |buf: &Buffer| -> usize {
(0..sb.height)
.filter(|&y| buf[(sb.x, sb.y + y)].symbol() != " ")
.count()
};
// Both should have a thumb
let top_thumb = count_thumb(&buf_top);
let bottom_thumb = count_thumb(&buf_bottom);
assert!(top_thumb > 0, "Should have thumb at top");
assert!(bottom_thumb > 0, "Should have thumb at bottom");
// Thumb size should be consistent
assert_eq!(top_thumb, bottom_thumb, "Thumb size should be consistent");
// Thumb position should differ (visual inspection would show top vs bottom)
// We can check that the thumb cells are in different positions
let thumb_positions = |buf: &Buffer| -> Vec<u16> {
(0..sb.height)
.filter(|&y| buf[(sb.x, sb.y + y)].symbol() != " ")
.collect()
};
let top_pos = thumb_positions(&buf_top);
let bottom_pos = thumb_positions(&buf_bottom);
assert_ne!(
top_pos, bottom_pos,
"Thumb should be at different positions"
);
}
}
@@ -0,0 +1,540 @@
//! Native terminal rendering for command output.
//!
//! Bash/terminal tool output arrives as a raw PTY byte stream that can contain
//! ANSI SGR (colors/styles), cursor movement, line erases, and carriage returns
//! (progress bars rewriting a line). ratatui paints text verbatim and does not
//! interpret these, so without this module the scrollback shows literal escape
//! codes like `[1m[36m`.
//!
//! [`render_terminal_lines`] feeds the stream through a minimal, line-oriented
//! VTE emulator (built on the `vte` parser) and produces styled
//! [`Line`]s plus de-escaped plain text — what a terminal would actually
//! display. Unlike a screen/grid emulator it keeps an unbounded, fully-styled
//! transcript that maps onto the pager's line model.
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use vte::{Params, Parser, Perform};
use crate::theme::color_support::quantize;
/// Bound transcript growth against pathological cursor jumps. Tool output is
/// already truncated upstream; these only guard against escape-code abuse.
const MAX_ROWS: usize = 50_000;
const MAX_COLS: usize = 8_192;
/// A single rendered transcript line: styled spans plus de-escaped plain text.
pub struct RenderedLine {
pub line: Line<'static>,
pub plain: String,
}
/// Parse a raw terminal stream (ANSI SGR + cursor/erase + carriage return) into
/// styled lines. `base` is the default style for text without an SGR override.
///
/// Deterministic and idempotent: a fresh emulator per call, safe to invoke from
/// both the render path and the height-cache path.
pub fn render_terminal_lines(raw: &str, base: Style) -> Vec<RenderedLine> {
if raw.is_empty() {
return Vec::new();
}
let mut sink = TermSink::new(base);
let mut parser = Parser::new();
parser.advance(&mut sink, raw.as_bytes());
sink.finish()
}
/// De-escaped, cursor-resolved plain text of a terminal stream, for
/// clipboard/search. Lines are joined with `\n`.
pub fn render_terminal_plain(raw: &str) -> String {
render_terminal_lines(raw, Style::default())
.into_iter()
.map(|rl| rl.plain)
.collect::<Vec<_>>()
.join("\n")
}
#[derive(Clone, Copy)]
struct Cell {
ch: char,
style: Style,
}
struct TermSink {
base: Style,
cur: Style,
rows: Vec<Vec<Cell>>,
row: usize,
col: usize,
}
impl TermSink {
fn new(base: Style) -> Self {
Self {
base,
cur: base,
rows: vec![Vec::new()],
row: 0,
col: 0,
}
}
fn ensure_row(&mut self) {
if self.row >= MAX_ROWS {
self.row = MAX_ROWS - 1;
}
while self.rows.len() <= self.row {
self.rows.push(Vec::new());
}
}
fn put(&mut self, ch: char) {
if self.col >= MAX_COLS {
return;
}
self.ensure_row();
let blank = Cell {
ch: ' ',
style: self.base,
};
let line = &mut self.rows[self.row];
if self.col >= line.len() {
line.resize(self.col + 1, blank);
}
line[self.col] = Cell {
ch,
style: self.cur,
};
self.col += 1;
}
fn newline(&mut self) {
self.row += 1;
self.col = 0;
self.ensure_row();
}
fn erase_line(&mut self, mode: u16) {
self.ensure_row();
let blank = Cell {
ch: ' ',
style: self.base,
};
let line = &mut self.rows[self.row];
match mode {
0 => line.truncate(self.col.min(line.len())),
1 => {
let end = (self.col + 1).min(line.len());
line[..end].fill(blank);
}
2 => line.clear(),
_ => {}
}
}
fn erase_display(&mut self, mode: u16) {
match mode {
0 => {
self.ensure_row();
let len = self.rows[self.row].len();
self.rows[self.row].truncate(self.col.min(len));
self.rows.truncate(self.row + 1);
}
2 | 3 => {
self.rows.clear();
self.rows.push(Vec::new());
self.row = 0;
self.col = 0;
}
_ => {}
}
}
fn apply_sgr(&mut self, params: &Params) {
if params.is_empty() {
self.cur = self.base;
return;
}
let groups: Vec<&[u16]> = params.iter().collect();
let mut i = 0;
while i < groups.len() {
let code = groups[i].first().copied().unwrap_or(0);
match code {
0 => self.cur = self.base,
1 => self.cur = self.cur.add_modifier(Modifier::BOLD),
2 => self.cur = self.cur.add_modifier(Modifier::DIM),
3 => self.cur = self.cur.add_modifier(Modifier::ITALIC),
4 => self.cur = self.cur.add_modifier(Modifier::UNDERLINED),
7 => self.cur = self.cur.add_modifier(Modifier::REVERSED),
22 => self.cur = self.cur.remove_modifier(Modifier::BOLD | Modifier::DIM),
23 => self.cur = self.cur.remove_modifier(Modifier::ITALIC),
24 => self.cur = self.cur.remove_modifier(Modifier::UNDERLINED),
27 => self.cur = self.cur.remove_modifier(Modifier::REVERSED),
30..=37 => self.cur.fg = Some(quantize(ansi16(code - 30))),
39 => self.cur.fg = self.base.fg,
40..=47 => self.cur.bg = Some(quantize(ansi16(code - 40))),
49 => self.cur.bg = self.base.bg,
90..=97 => self.cur.fg = Some(quantize(ansi16_bright(code - 90))),
100..=107 => self.cur.bg = Some(quantize(ansi16_bright(code - 100))),
38 => {
if let Some(c) = ext_color(&groups, &mut i) {
self.cur.fg = Some(quantize(c));
}
}
48 => {
if let Some(c) = ext_color(&groups, &mut i) {
self.cur.bg = Some(quantize(c));
}
}
_ => {}
}
i += 1;
}
}
fn finish(mut self) -> Vec<RenderedLine> {
// `str::lines()` ignores a single trailing newline; mirror that so a
// command ending in `\n` does not gain a spurious blank line.
if self.rows.last().is_some_and(|r| r.is_empty()) {
self.rows.pop();
}
let base = self.base;
self.rows
.into_iter()
.map(|cells| row_to_line(cells, base))
.collect()
}
}
impl Perform for TermSink {
fn print(&mut self, c: char) {
self.put(c);
}
fn execute(&mut self, byte: u8) {
match byte {
b'\n' | 0x0b | 0x0c => self.newline(),
b'\r' => self.col = 0,
b'\t' => self.col = (self.col / 8 + 1) * 8,
0x08 => self.col = self.col.saturating_sub(1),
_ => {}
}
}
fn csi_dispatch(
&mut self,
params: &Params,
_intermediates: &[u8],
_ignore: bool,
action: char,
) {
match action {
'm' => self.apply_sgr(params),
'K' => self.erase_line(first_param(params, 0)),
'J' => self.erase_display(first_param(params, 0)),
'A' => self.row = self.row.saturating_sub(first_param(params, 1) as usize),
'B' => {
let n = first_param(params, 1) as usize;
self.row = (self.row + n).min(self.rows.len().saturating_sub(1));
}
'C' => self.col = (self.col + first_param(params, 1) as usize).min(MAX_COLS),
'D' => self.col = self.col.saturating_sub(first_param(params, 1) as usize),
'G' => {
self.col = (first_param(params, 1) as usize)
.saturating_sub(1)
.min(MAX_COLS)
}
_ => {}
}
}
}
/// First parameter value, substituting `default` for a missing or `0` value
/// (CSI cursor ops treat `0` as `1`; erase ops pass `0` as the default).
fn first_param(params: &Params, default: u16) -> u16 {
match params.iter().next().and_then(|p| p.first().copied()) {
Some(0) | None => default,
Some(v) => v,
}
}
/// Map a 0-7 ANSI color index to a named ratatui color.
fn ansi16(n: u16) -> Color {
match n {
0 => Color::Black,
1 => Color::Red,
2 => Color::Green,
3 => Color::Yellow,
4 => Color::Blue,
5 => Color::Magenta,
6 => Color::Cyan,
_ => Color::Gray,
}
}
/// Map a 0-7 bright ANSI color index to a named ratatui color.
fn ansi16_bright(n: u16) -> Color {
match n {
0 => Color::DarkGray,
1 => Color::LightRed,
2 => Color::LightGreen,
3 => Color::LightYellow,
4 => Color::LightBlue,
5 => Color::LightMagenta,
6 => Color::LightCyan,
_ => Color::White,
}
}
/// Resolve an extended color (`38`/`48`) in either `;` (advancing `i` over the
/// consumed groups) or `:` subparameter form. Returns an un-quantized color.
fn ext_color(groups: &[&[u16]], i: &mut usize) -> Option<Color> {
let g = groups[*i];
if g.len() >= 2 {
return parse_ext(&g[1..]);
}
match groups.get(*i + 1).and_then(|p| p.first().copied())? {
5 => {
let idx = groups.get(*i + 2).and_then(|p| p.first().copied())?;
*i += 2;
Some(Color::Indexed(idx as u8))
}
2 => {
let r = groups.get(*i + 2).and_then(|p| p.first().copied())?;
let g = groups.get(*i + 3).and_then(|p| p.first().copied())?;
let b = groups.get(*i + 4).and_then(|p| p.first().copied())?;
*i += 4;
Some(Color::Rgb(r as u8, g as u8, b as u8))
}
_ => None,
}
}
/// Parse the subparameter form of an extended color, e.g. `[5, n]` (256) or
/// `[2, r, g, b]` (with an optional leading colorspace id). Un-quantized.
fn parse_ext(sub: &[u16]) -> Option<Color> {
match sub.first().copied()? {
5 => sub.get(1).map(|n| Color::Indexed(*n as u8)),
2 => {
let vals = &sub[1..];
let (r, g, b) = match vals.len() {
3 => (vals[0], vals[1], vals[2]),
n if n >= 4 => (vals[n - 3], vals[n - 2], vals[n - 1]),
_ => return None,
};
Some(Color::Rgb(r as u8, g as u8, b as u8))
}
_ => None,
}
}
fn row_to_line(cells: Vec<Cell>, base: Style) -> RenderedLine {
let mut end = cells.len();
while end > 0 && cells[end - 1].ch == ' ' && cells[end - 1].style == base {
end -= 1;
}
let cells = &cells[..end];
if cells.is_empty() {
return RenderedLine {
line: Line::default(),
plain: String::new(),
};
}
let plain: String = cells.iter().map(|c| c.ch).collect();
let mut spans: Vec<Span<'static>> = Vec::new();
let mut buf = String::new();
let mut style = cells[0].style;
for c in cells {
if c.style != style {
spans.push(Span::styled(std::mem::take(&mut buf), style));
style = c.style;
}
buf.push(c.ch);
}
spans.push(Span::styled(buf, style));
RenderedLine {
line: Line::from(spans),
plain,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn plain(raw: &str) -> String {
render_terminal_plain(raw)
}
fn lines(raw: &str) -> Vec<String> {
render_terminal_lines(raw, Style::default())
.into_iter()
.map(|rl| rl.plain)
.collect()
}
#[test]
fn strips_sgr_to_plain_text() {
assert_eq!(plain("\x1b[1m\x1b[36mbazel\x1b[0m"), "bazel");
}
#[test]
fn carriage_return_overwrites_in_place() {
assert_eq!(plain("aaaa\rbb"), "bbaa");
}
#[test]
fn progress_bar_collapses_to_final_state() {
assert_eq!(plain("10%\r50%\r100%\n"), "100%");
}
#[test]
fn newline_splits_lines() {
assert_eq!(lines("a\nb"), vec!["a", "b"]);
}
#[test]
fn trailing_newline_adds_no_blank_line() {
assert_eq!(lines("a\n"), vec!["a"]);
assert_eq!(lines("a\n\n"), vec!["a", ""]);
}
#[test]
fn cursor_up_then_carriage_return_and_erase() {
// Write two lines, move up, overwrite the start, erase to end of line.
assert_eq!(lines("line1\nline2\x1b[A\rXX\x1b[K"), vec!["XX", "line2"]);
}
#[test]
fn tab_advances_to_next_stop() {
assert_eq!(plain("a\tb"), "a b");
}
#[test]
fn malformed_escape_does_not_panic() {
assert_eq!(plain("\x1b[38;5mhi"), "hi");
assert!(plain("\x1b[99999999999m\x1b[mok").contains("ok"));
}
#[test]
fn sgr_splits_into_styled_spans() {
let rendered = render_terminal_lines("plain \x1b[31mred\x1b[0m", Style::default());
assert_eq!(rendered.len(), 1);
let spans = &rendered[0].line.spans;
assert_eq!(spans.len(), 2);
assert_eq!(spans[0].content.as_ref(), "plain ");
assert_eq!(spans[1].content.as_ref(), "red");
assert!(spans[1].style.fg.is_some());
assert_eq!(rendered[0].plain, "plain red");
}
#[test]
fn idempotent_line_count() {
let raw = "a\nb\x1b[32mc\x1b[0m\rd\ne";
let first = render_terminal_lines(raw, Style::default()).len();
let second = render_terminal_lines(raw, Style::default()).len();
assert_eq!(first, second);
}
#[test]
fn empty_input_yields_no_lines() {
assert!(render_terminal_lines("", Style::default()).is_empty());
}
#[test]
fn ansi16_mapping() {
assert_eq!(ansi16(1), Color::Red);
assert_eq!(ansi16(6), Color::Cyan);
assert_eq!(ansi16_bright(2), Color::LightGreen);
assert_eq!(ansi16_bright(7), Color::White);
}
#[test]
fn ext_color_subparam_forms() {
assert_eq!(parse_ext(&[5, 42]), Some(Color::Indexed(42)));
assert_eq!(parse_ext(&[2, 10, 20, 30]), Some(Color::Rgb(10, 20, 30)));
assert_eq!(parse_ext(&[2, 0, 10, 20, 30]), Some(Color::Rgb(10, 20, 30)));
assert_eq!(parse_ext(&[2, 1]), None);
}
// Cross-platform robustness. Bash/terminal output is captured via pipes
// (non-TTY) on macOS, Linux, and Windows alike, so the input is plain text
// plus line endings plus optionally forced SGR — never a ConPTY screen
// stream. Windows uses CRLF, and unsupported control sequences (DEC private
// modes, OSC, cursor save/restore, absolute positioning) must be ignored
// without corrupting surrounding text.
#[test]
fn windows_crlf_line_endings() {
assert_eq!(lines("a\r\nb\r\nc\r\n"), vec!["a", "b", "c"]);
}
#[test]
fn ignores_dec_private_modes_and_osc() {
let raw = "\x1b[?25l\x1b]0;window title\x07hello\x1b[?1049h world\x1b[?25h";
assert_eq!(plain(raw), "hello world");
}
#[test]
fn ignores_cursor_save_restore_and_absolute_positioning() {
assert_eq!(plain("\x1b7\x1b[10;5Hkept\x1b8"), "kept");
}
#[test]
fn forced_sgr_over_crlf_renders_styled() {
let rendered =
render_terminal_lines("\x1b[01;31mmatch\x1b[0m\r\nplain\r\n", Style::default());
assert_eq!(rendered.len(), 2);
assert_eq!(rendered[0].plain, "match");
assert_eq!(rendered[1].plain, "plain");
assert!(rendered[0].line.spans.iter().any(|s| s.style.fg.is_some()));
}
// Real Windows shell output samples. Each pins a distinct parser behavior
// exercised by a sequence these shells actually emit on the wire.
// Git Bash / GNU `grep --color=always`: the match is wrapped in a bold-red
// SGR with an interleaved EL (`\x1b[K`) and closed by an empty-param reset
// (`\x1b[m`). The EL must not truncate already-printed text, and `\x1b[m`
// must restore the base style for the trailing run.
#[test]
fn git_bash_gnu_grep_color() {
let rendered =
render_terminal_lines("\x1b[01;31m\x1b[Kfoo\x1b[m\x1b[Kbar\n", Style::default());
assert_eq!(rendered.len(), 1);
assert_eq!(rendered[0].plain, "foobar");
let spans = &rendered[0].line.spans;
assert_eq!(spans.len(), 2);
assert_eq!(spans[0].content.as_ref(), "foo");
assert!(spans[0].style.fg.is_some());
assert!(spans[0].style.add_modifier.contains(Modifier::BOLD));
assert_eq!(spans[1].content.as_ref(), "bar");
assert_eq!(spans[1].style, Style::default());
}
// PowerShell 7 (`$PSStyle`): 24-bit color via the semicolon form
// `\x1b[38;2;R;G;Bm`, which drives the multi-group extended-color branch of
// `ext_color` (consume-following-groups + advance). If that advance were
// wrong the trailing `0` param would reset and drop the color.
#[test]
fn powershell_truecolor_psstyle() {
let rendered = render_terminal_lines(
"\x1b[38;2;255;128;0mWARNING\x1b[0m: low disk\n",
Style::default(),
);
assert_eq!(rendered.len(), 1);
assert_eq!(rendered[0].plain, "WARNING: low disk");
let spans = &rendered[0].line.spans;
assert_eq!(spans[0].content.as_ref(), "WARNING");
assert!(spans[0].style.fg.is_some());
assert_eq!(spans[1].style, Style::default());
}
// Progress output (cargo/npm/pip style under cmd/PowerShell): a status line
// is wiped with EL mode 2 (`\x1b[2K`) regardless of cursor column, then
// rewritten, so the transcript collapses to the final line.
#[test]
fn progress_erase_entire_line_collapses() {
assert_eq!(lines("loading 99%\x1b[2K\rdone\n"), vec!["done"]);
}
}
@@ -0,0 +1,449 @@
//! Read/Edit tool-path resolution and surface formatting.
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
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.
Collapsed,
/// Relative to session cwd when lexically contained; else normalized.
Expanded,
/// Normalized target spelling for the modal preamble.
Fullscreen,
}
#[derive(Debug, Clone)]
struct ResolvedToolPath {
display_path: PathBuf,
relative_to_cwd: Option<String>,
}
fn expand_tilde_with_home(path: &Path, home: Option<&Path>) -> Option<PathBuf> {
use std::path::Component;
let mut components = path.components();
let Some(Component::Normal(first)) = components.next() else {
return Some(path.to_path_buf());
};
if first != "~" {
return Some(path.to_path_buf());
}
let mut expanded = home?.to_path_buf();
for component in components {
match component {
Component::Prefix(_) | Component::RootDir => {}
_ => expanded.push(component.as_os_str()),
}
}
Some(expanded)
}
/// Resolve the path the OS should receive, preserving `.`/`..` and symlink semantics.
pub(crate) fn resolve_tool_path_target_with_home(
path: &Path,
cwd: Option<&Path>,
home: Option<&Path>,
) -> Option<PathBuf> {
use std::path::Component;
let target = expand_tilde_with_home(path, home)?;
if target.is_absolute() || matches!(target.components().next(), Some(Component::Prefix(_))) {
return Some(target);
}
Some(match cwd {
Some(cwd) => cwd.join(target),
None => target,
})
}
fn non_empty_rel(rel: &Path) -> Option<String> {
let value = rel.to_string_lossy();
if value.is_empty() {
None
} else {
Some(value.into_owned())
}
}
fn home_dir() -> Option<&'static Path> {
static HOME: OnceLock<Option<PathBuf>> = OnceLock::new();
HOME.get_or_init(dirs::home_dir).as_deref()
}
/// Resolve the path-native target for OSC8 or background filesystem work.
pub fn resolve_tool_path_target(path: &str, cwd: Option<&Path>) -> Option<PathBuf> {
resolve_tool_path_target_with_home(Path::new(path), cwd, home_dir())
}
fn resolve_tool_path_with_home(
path: &str,
cwd: Option<&Path>,
home: Option<&Path>,
) -> ResolvedToolPath {
let target = resolve_tool_path_target_with_home(Path::new(path), cwd, home);
let display_path = target
.as_deref()
.map(kigi_paths::normalize_lexically)
.unwrap_or_else(|| PathBuf::from(path));
let relative_to_cwd = target.as_ref().and_then(|_| {
let cwd = kigi_paths::normalize_lexically(cwd?);
display_path.strip_prefix(cwd).ok().and_then(non_empty_rel)
});
ResolvedToolPath {
display_path,
relative_to_cwd,
}
}
fn resolve_tool_path(path: &str, cwd: Option<&Path>) -> ResolvedToolPath {
resolve_tool_path_with_home(path, cwd, home_dir())
}
fn path_for_fullscreen_header(path: &str, cwd: Option<&Path>) -> String {
resolve_tool_path(path, cwd)
.display_path
.to_string_lossy()
.into_owned()
}
fn path_for_expanded_header(path: &str, cwd: Option<&Path>) -> String {
let resolved = resolve_tool_path(path, cwd);
resolved
.relative_to_cwd
.unwrap_or_else(|| resolved.display_path.to_string_lossy().into_owned())
}
/// Shorten a file path to fit within `budget` display columns using fish-style
/// component shortening.
pub fn shorten_path(path: &str, budget: usize) -> String {
if budget == 0 {
return String::new();
}
if path.width() <= budget {
return path.to_string();
}
let parts: Vec<&str> = path.split('/').collect();
if parts.len() <= 1 {
return truncate_str(path, budget);
}
let mut shortened: Vec<String> = parts.iter().map(|part| part.to_string()).collect();
let last_idx = shortened.len() - 1;
for i in 0..last_idx {
if shortened.iter().map(String::len).sum::<usize>() + shortened.len() - 1 <= budget {
break;
}
if let Some(first) = parts[i].chars().next() {
shortened[i] = first.to_string();
}
}
let joined = shortened.join("/");
if joined.width() <= budget {
return joined;
}
let mut tail_start = 0;
for (i, _) in path.char_indices() {
if i == 0 {
continue;
}
if path.as_bytes().get(i.wrapping_sub(1)) == Some(&b'/') {
let candidate = format!("\u{2026}{}", &path[i - 1..]);
if candidate.width() <= budget {
tail_start = i - 1;
break;
}
}
}
if tail_start > 0 {
let result = format!("\u{2026}{}", &path[tail_start..]);
if result.width() <= budget {
return result;
}
}
truncate_str(path, budget)
}
pub fn path_basename(path: &str, budget: usize) -> String {
let name = path
.trim_end_matches(['/', '\\'])
.rsplit(['/', '\\'])
.next()
.filter(|name| !name.is_empty())
.unwrap_or(path);
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)),
None => path.to_string(),
}
}
/// Path text for a Read/Edit tool-header surface.
pub fn path_for_tool_surface(
path: &str,
surface: ToolPathSurface,
cwd: Option<&Path>,
width: Option<usize>,
reserved: usize,
) -> String {
match surface {
ToolPathSurface::Collapsed => {
let budget = width.unwrap_or(usize::MAX).saturating_sub(reserved);
path_basename(path, budget)
}
ToolPathSurface::Expanded => path_for_expanded_header(path, cwd),
ToolPathSurface::Fullscreen => path_for_fullscreen_header(path, cwd),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn shorten_path_fits() {
assert_eq!(shorten_path("src/main.rs", 20), "src/main.rs");
}
#[test]
fn shorten_path_fish_style() {
let result = shorten_path("crates/codegen/kigi-tui/src/views/foo.rs", 25);
assert!(result.width() <= 25, "got: {result}");
assert!(result.ends_with("foo.rs"), "got: {result}");
}
#[test]
fn shorten_path_front_truncate() {
let result = shorten_path(
"crates/codegen/kigi-tui/src/views/very_long_filename.rs",
20,
);
assert!(result.width() <= 20, "got: {result}");
}
#[test]
fn shorten_path_no_separator() {
assert_eq!(shorten_path("verylongfilename.rs", 10), "verylongf\u{2026}");
}
#[test]
fn shorten_path_zero_budget() {
assert_eq!(shorten_path("src/main.rs", 0), "");
}
#[test]
fn path_basename_handles_native_and_mixed_separators() {
assert_eq!(
path_basename("/Users/me/project/src/main.rs", 80),
"main.rs"
);
assert_eq!(path_basename("src/main.rs", 80), "main.rs");
assert_eq!(
path_basename(r"C:\Users\me/project/src/main.rs", 80),
"main.rs"
);
assert_eq!(path_basename(r"C:\Users\me\project\src\", 80), "src");
assert_eq!(path_basename("/Users/me/project/src/", 80), "src");
}
#[test]
fn path_basename_truncates_to_budget() {
assert_eq!(
path_basename("/x/verylongfilename.rs", 10),
"verylongf\u{2026}"
);
assert_eq!(path_basename("src/main.rs", 0), "");
}
#[test]
fn collapsed_surface_is_basename() {
assert_eq!(
path_for_tool_surface(
"/Users/me/project/src/main.rs",
ToolPathSurface::Collapsed,
None,
Some(80),
"Read ".len()
),
"main.rs"
);
}
#[test]
fn expanded_surface_normalizes_and_classifies_against_cwd() {
let cwd = Path::new("/Users/me/project");
assert_eq!(
path_for_tool_surface(
"/Users/me/project/src/main.rs",
ToolPathSurface::Expanded,
Some(cwd),
None,
0
),
"src/main.rs"
);
assert_eq!(
path_for_tool_surface(
"src/./nested/../main.rs",
ToolPathSurface::Expanded,
Some(cwd),
None,
0
),
"src/main.rs"
);
assert_eq!(
path_for_tool_surface(
"../outside.rs",
ToolPathSurface::Expanded,
Some(cwd),
None,
0
),
"/Users/me/outside.rs"
);
}
#[test]
fn filesystem_target_preserves_symlink_sensitive_parent_segments() {
let raw = Path::new("/repo/link/../target.rs");
assert_eq!(
resolve_tool_path_target_with_home(raw, None, Some(Path::new("/home/me"))),
Some(raw.to_path_buf())
);
}
#[cfg(unix)]
#[test]
fn tilde_expansion_uses_native_components_and_fails_closed_without_home() {
let home = Path::new("/home/me");
let cwd = Path::new("/repo");
assert_eq!(
resolve_tool_path_target_with_home(Path::new("~//foo.rs"), Some(cwd), Some(home)),
Some(home.join("foo.rs"))
);
assert_eq!(
resolve_tool_path_target_with_home(Path::new("~/dir/../foo.rs"), Some(cwd), Some(home)),
Some(home.join("dir/../foo.rs"))
);
assert_eq!(
resolve_tool_path_target_with_home(Path::new("~/foo.rs"), Some(cwd), None),
None
);
let unresolved = resolve_tool_path_with_home("~/foo.rs", Some(cwd), None);
assert_eq!(unresolved.display_path, PathBuf::from("~/foo.rs"));
assert_eq!(unresolved.relative_to_cwd, None);
}
#[test]
fn expanded_outside_cwd_stays_normalized_target() {
let cwd = Path::new("/Users/me/project");
let got =
path_for_tool_surface("/etc/hosts", ToolPathSurface::Expanded, Some(cwd), None, 0);
assert!(Path::new(&got).is_absolute(), "got {got}");
assert!(got.ends_with("hosts"), "got {got}");
assert!(!got.starts_with("/Users/me/project"), "got {got}");
}
#[test]
fn expanded_surface_uses_worktree_cwd() {
let cwd = Path::new("/Users/me/.kigi/worktrees/foo");
let path = "/Users/me/.kigi/worktrees/foo/crates/x/a.rs";
assert_eq!(
path_for_tool_surface(path, ToolPathSurface::Expanded, Some(cwd), None, 0),
"crates/x/a.rs"
);
}
#[test]
fn fullscreen_surface_uses_anchored_or_honestly_relative_target() {
let cwd = Path::new("/Users/me/project");
assert_eq!(
path_for_tool_surface(
"src/main.rs",
ToolPathSurface::Fullscreen,
Some(cwd),
None,
0
),
"/Users/me/project/src/main.rs"
);
let relative = resolve_tool_path("src/../main.rs", None);
assert_eq!(relative.display_path, PathBuf::from("main.rs"));
assert!(!relative.display_path.is_absolute());
}
#[test]
fn home_relative_target_preserves_filesystem_spelling_for_io() {
let Some(home) = dirs::home_dir() else {
return;
};
assert_eq!(resolve_tool_path_target("~", None), Some(home.clone()));
assert_eq!(
resolve_tool_path_target("~/project/../notes.md", None),
Some(home.join("project/../notes.md"))
);
assert_eq!(
resolve_tool_path("~/project/../notes.md", None).display_path,
kigi_paths::normalize_lexically(&home.join("notes.md"))
);
}
#[cfg(windows)]
#[test]
fn windows_tilde_and_drive_relative_targets_keep_native_semantics() {
let home = Path::new(r"C:\Users\me");
let cwd = Path::new(r"C:\repo");
assert_eq!(
resolve_tool_path_target_with_home(Path::new(r"~\foo.rs"), Some(cwd), Some(home)),
Some(home.join("foo.rs"))
);
assert_eq!(
resolve_tool_path_target_with_home(Path::new(r"C:foo.rs"), Some(cwd), Some(home)),
Some(PathBuf::from(r"C:foo.rs"))
);
let resolved = resolve_tool_path(r"C:foo.rs", Some(cwd));
assert_eq!(resolved.display_path, PathBuf::from(r"C:foo.rs"));
assert!(!resolved.display_path.is_absolute());
assert_eq!(
path_for_tool_surface(r"C:foo.rs", ToolPathSurface::Fullscreen, Some(cwd), None, 0),
r"C:foo.rs"
);
}
#[cfg(unix)]
#[test]
fn expanded_surface_does_not_dereference_symlink_aliases() {
let dir = tempfile::tempdir().unwrap();
let real = dir.path().join("real_project");
std::fs::create_dir_all(real.join("src")).unwrap();
std::fs::write(real.join("src/main.rs"), b"fn main() {}").unwrap();
let link = dir.path().join("link_project");
std::os::unix::fs::symlink(&real, &link).unwrap();
let file_via_real = real.join("src/main.rs");
assert_eq!(
path_for_tool_surface(
file_via_real.to_str().unwrap(),
ToolPathSurface::Expanded,
Some(link.as_path()),
None,
0
),
file_via_real.to_string_lossy()
);
}
}
@@ -0,0 +1,148 @@
//! Video playback overlay chrome (border, title, progress bar).
//!
//! The video frame itself is rendered via post-flush escape sequences
//! by the caller, matching the image viewer pattern.
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
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,
viewer: &VideoViewerState,
bg: Color,
text_fg: Color,
border_fg: Color,
) -> Option<Rect> {
if area.height < 8 || area.width < 20 {
return None;
}
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;
let popup_height = ((area.height as u32 * 90) / 100)
.max(8)
.min(area.height as u32) as u16;
let popup_x = area.x + (area.width.saturating_sub(popup_width)) / 2;
let popup_y = area.y + (area.height.saturating_sub(popup_height)) / 2;
let popup_rect = Rect::new(popup_x, popup_y, popup_width, popup_height);
ratatui::widgets::Clear.render(popup_rect, buf);
buf.set_style(popup_rect, Style::default().fg(text_fg).bg(bg));
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(border_fg).bg(bg))
.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}{}) ",
name, viewer.video_width, viewer.video_height
),
None => format!(
" Video ({}\u{00d7}{}) ",
viewer.video_width, viewer.video_height
),
};
let title_style = Style::default()
.fg(text_fg)
.bg(bg)
.add_modifier(Modifier::BOLD);
let tw = title.len() as u16;
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,
viewer: &VideoViewerState,
text_fg: Color,
bar_dim: 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;
if inner_width <= 10 {
return;
}
let icon = if viewer.playing {
"\u{25b6}"
} else {
"\u{23f8}"
};
let time_label = format!(
"{icon} {}/{} ",
format_time(viewer.position_secs()),
format_time(viewer.duration_secs),
);
let bar_width = inner_width.saturating_sub(time_label.len());
if bar_width <= 4 {
return;
}
let filled = ((viewer.progress() * bar_width as f64).round() as usize).min(bar_width);
let empty = bar_width.saturating_sub(filled);
let line = Line::from(vec![
Span::styled(time_label, Style::default().fg(text_fg).bg(bg)),
Span::styled(
"\u{2501}".repeat(filled),
Style::default().fg(text_fg).bg(bg),
),
Span::styled(
"\u{2500}".repeat(empty),
Style::default().fg(bar_dim).bg(bg),
),
]);
buf.set_line_safe(popup_rect.x + 1, bar_y, &line, inner_width as u16);
}
fn format_time(secs: f64) -> String {
let total = secs.round() as u64;
format!("{}:{:02}", total / 60, total % 60)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn format_time_zero() {
assert_eq!(format_time(0.0), "0:00");
}
#[test]
fn format_time_short() {
assert_eq!(format_time(5.4), "0:05");
}
#[test]
fn format_time_minutes() {
assert_eq!(format_time(90.0), "1:30");
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,79 @@
//! Syntax highlighting initialization.
//!
//! Provides lazily-initialized `Syntect` instances for code highlighting.
//! Dark themes (GrokNight, TokyoNight) share `grok-night.tmTheme`;
//! GrokDay uses `grok-day.tmTheme` with deepened colors for light backgrounds.
use std::sync::OnceLock;
pub use kigi_markdown::Syntect;
use crate::theme::ThemeKind;
static SYNTECT_GROKNIGHT: OnceLock<Syntect> = OnceLock::new();
static SYNTECT_TOKYONIGHT: OnceLock<Syntect> = OnceLock::new();
static SYNTECT_GROKDAY: 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,
style.foreground.g,
style.foreground.b,
));
let mut out = ratatui::style::Style::default().fg(fg);
use syntect::highlighting::FontStyle;
if style.font_style.contains(FontStyle::BOLD) {
out = out.add_modifier(ratatui::style::Modifier::BOLD);
}
if style.font_style.contains(FontStyle::ITALIC) {
out = out.add_modifier(ratatui::style::Modifier::ITALIC);
}
if style.font_style.contains(FontStyle::UNDERLINE) {
out = out.add_modifier(ratatui::style::Modifier::UNDERLINED);
}
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<'_>>,
syntect: &Syntect,
fallback: ratatui::style::Style,
) -> Vec<ratatui::text::Span<'static>> {
if let Some(hl) = highlighter.as_mut()
&& let Ok(ranges) = hl.highlight_line(&format!("{text}\n"), &syntect.syntax_set)
{
let mut spans = Vec::new();
for (style, segment) in ranges {
let mut s = segment.to_owned();
while s.ends_with('\n') || s.ends_with('\r') {
s.pop();
}
if s.is_empty() {
continue;
}
spans.push(ratatui::text::Span::styled(s, syntect_to_ratatui_fg(style)));
}
if !spans.is_empty() {
return spans;
}
}
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::GrokNight
| ThemeKind::RosePineMoon
| ThemeKind::OscuraMidnight
| ThemeKind::Auto => SYNTECT_GROKNIGHT
.get_or_init(|| Syntect::new(include_bytes!("../assets/grok-night.tmTheme"))),
ThemeKind::TokyoNight => SYNTECT_TOKYONIGHT
.get_or_init(|| Syntect::new(include_bytes!("../assets/tokyo-night.tmTheme"))),
ThemeKind::GrokDay => SYNTECT_GROKDAY
.get_or_init(|| Syntect::new(include_bytes!("../assets/grok-day.tmTheme"))),
}
}
@@ -0,0 +1,108 @@
//! Detects whether grok is running inside an editor's embedded `:terminal`
//! (Neovim/Vim `:terminal`, Emacs `vterm`).
//!
//! WHY this matters: inside an editor `:terminal` the *immediate* terminal
//! emulator is the editor's own libvterm, not tmux — even though the `TMUX`
//! env var is inherited through the editor. A tmux DCS passthrough envelope
//! (`\x1bPtmux;…\x1b\\`) is only understood by tmux, so emitting it into the
//! editor's libvterm renders the wrapper as visible garbage text. Detection
//! records this on [`super::TerminalContext`] so clipboard routing emits a
//! plain OSC 52 sequence in that case instead.
use std::collections::HashMap;
use super::env_get;
/// Which embedded editor `:terminal` grok is running inside.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum EmbeddedEditor {
/// Neovim `:terminal` (sets `NVIM`, or legacy `NVIM_LISTEN_ADDRESS`).
Neovim,
/// Vim 8/9 `:terminal` (sets `VIM_TERMINAL`).
Vim,
/// Emacs (sets `INSIDE_EMACS`; `vterm` uses libvterm — same bug).
Emacs,
}
/// Detect the embedded editor terminal from an injected environment map.
///
/// Checks, in order: `NVIM` / `NVIM_LISTEN_ADDRESS` → [`EmbeddedEditor::Neovim`];
/// `VIM_TERMINAL` → [`EmbeddedEditor::Vim`]; `INSIDE_EMACS` →
/// [`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
/// `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> {
// Markers can't distinguish editor-inside-tmux (the 100%-repro bug; don't wrap)
// from the inverted tmux-inside-editor; we target the former and the latter still
// works since plain OSC 52 is forwarded.
// NVIM_LISTEN_ADDRESS is legacy (modern nvim unsets it at startup); a
// stray/user-exported marker only degrades to plain OSC 52, never garbage.
if env_get(env, "NVIM").is_some() || env_get(env, "NVIM_LISTEN_ADDRESS").is_some() {
return Some(EmbeddedEditor::Neovim);
}
if env_get(env, "VIM_TERMINAL").is_some() {
return Some(EmbeddedEditor::Vim);
}
if env_get(env, "INSIDE_EMACS").is_some() {
return Some(EmbeddedEditor::Emacs);
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use crate::terminal::env_from;
#[test]
fn nvim_detected_as_neovim() {
let env = env_from(&[("NVIM", "/tmp/nvim.12345.0")]);
assert_eq!(embedded_editor_from_env(&env), Some(EmbeddedEditor::Neovim));
}
#[test]
fn nvim_listen_address_detected_as_neovim() {
let env = env_from(&[("NVIM_LISTEN_ADDRESS", "/tmp/nvim.sock")]);
assert_eq!(embedded_editor_from_env(&env), Some(EmbeddedEditor::Neovim));
}
#[test]
fn vim_terminal_detected_as_vim() {
let env = env_from(&[("VIM_TERMINAL", "8.2")]);
assert_eq!(embedded_editor_from_env(&env), Some(EmbeddedEditor::Vim));
}
#[test]
fn inside_emacs_detected_as_emacs() {
let env = env_from(&[("INSIDE_EMACS", "30.1,vterm")]);
assert_eq!(embedded_editor_from_env(&env), Some(EmbeddedEditor::Emacs));
}
#[test]
fn no_editor_markers_is_none() {
let env = env_from(&[
("TERM", "xterm-256color"),
("TMUX", "/tmp/tmux-501/default,1,0"),
]);
assert_eq!(embedded_editor_from_env(&env), None);
}
#[test]
fn empty_value_treated_as_absent() {
let env = env_from(&[("NVIM", ""), ("VIM_TERMINAL", ""), ("INSIDE_EMACS", "")]);
assert_eq!(embedded_editor_from_env(&env), None);
}
#[test]
fn nvim_beats_vim_and_emacs() {
let env = env_from(&[
("NVIM", "/tmp/nvim.12345.0"),
("VIM_TERMINAL", "8.2"),
("INSIDE_EMACS", "30.1,vterm"),
]);
assert_eq!(embedded_editor_from_env(&env), Some(EmbeddedEditor::Neovim));
}
}
@@ -0,0 +1,306 @@
//! Per-terminal hyperlink (OSC 8) capabilities.
//!
//! Classifies caller semantics so input-handling code can consume one struct
//! instead of branching on brand.
use super::TerminalName;
/// Whether the terminal supports OSC 8 hyperlink sequences.
#[derive(Debug, Clone, Copy, Default, Eq, PartialEq, strum::Display)]
#[strum(serialize_all = "snake_case")]
#[non_exhaustive]
pub enum Osc8Support {
/// Terminal natively supports OSC 8 sequences.
Native,
/// Terminal actively garbles unknown OSC sequences (Apple Terminal).
HostileParser,
/// Terminal explicitly does not support OSC 8.
Unsupported,
/// Support status is unknown.
#[default]
Unknown,
}
/// Which URL schemes the terminal supports in OSC 8 links.
#[derive(Debug, Clone, Copy, Default, Eq, PartialEq, strum::Display)]
#[strum(serialize_all = "snake_case")]
#[non_exhaustive]
pub enum SchemeFilter {
/// Standard web schemes: http, https, mailto.
#[default]
Standard,
/// Extended editor schemes: vscode://, cursor://, idea://, zed://.
EditorExtended,
}
impl SchemeFilter {
/// Returns `true` if the given scheme is permitted by this filter.
pub fn allows(&self, scheme: &str) -> bool {
match self {
Self::Standard => matches!(scheme, "http" | "https" | "mailto"),
Self::EditorExtended => matches!(
scheme,
"http" | "https" | "mailto" | "file" | "vscode" | "cursor" | "idea" | "zed"
),
}
}
}
/// Per-terminal hyperlink capabilities.
#[derive(Debug, Clone, Copy, Default)]
#[non_exhaustive]
pub struct HyperlinkCapabilities {
/// OSC 8 support level.
pub osc8: Osc8Support,
/// Whether the terminal supports the `id=` parameter for hover-grouping.
pub id_param: bool,
/// Which URL schemes the terminal handles.
pub scheme_filter: SchemeFilter,
/// Whether the terminal supports OSC 22 cursor-shape changes
/// (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
/// app should skip its own Cmd/Ctrl+hover highlight logic).
pub native_link_hover: bool,
/// Terminal opens bare http(s)/mailto under mouse reporting (Warp).
pub native_plain_url_open: bool,
}
/// Classify hyperlink capabilities for a given `brand`.
pub fn hyperlink_capabilities(brand: TerminalName) -> HyperlinkCapabilities {
use Osc8Support::*;
match brand {
// Apple Terminal actively garbles unknown OSC sequences.
TerminalName::AppleTerminal => HyperlinkCapabilities {
osc8: HostileParser,
id_param: false,
scheme_filter: SchemeFilter::Standard,
osc22_cursor: false,
native_link_hover: false,
native_plain_url_open: false,
},
// Reference implementation. Excellent id= handling.
TerminalName::Iterm2 => HyperlinkCapabilities {
osc8: Native,
id_param: true,
scheme_filter: SchemeFilter::Standard,
osc22_cursor: true,
native_link_hover: false,
native_plain_url_open: false,
},
TerminalName::Ghostty => HyperlinkCapabilities {
osc8: Native,
id_param: true,
scheme_filter: SchemeFilter::Standard,
osc22_cursor: true,
native_link_hover: false,
native_plain_url_open: false,
},
// Since kitty v0.19.
TerminalName::Kitty => HyperlinkCapabilities {
osc8: Native,
id_param: true,
scheme_filter: SchemeFilter::Standard,
osc22_cursor: true,
native_link_hover: false,
native_plain_url_open: false,
},
// Since Alacritty v0.11. Rio and foot also support OSC 8.
TerminalName::Alacritty | TerminalName::Rio | TerminalName::Foot => HyperlinkCapabilities {
osc8: Native,
id_param: true,
scheme_filter: SchemeFilter::Standard,
osc22_cursor: false,
native_link_hover: false,
native_plain_url_open: false,
},
TerminalName::WezTerm => HyperlinkCapabilities {
osc8: Native,
id_param: true,
scheme_filter: SchemeFilter::Standard,
osc22_cursor: false,
native_link_hover: false,
native_plain_url_open: false,
},
// VS Code integrated terminal since v1.72. VS Code-family embeds
// inherit the same terminal renderer (xterm.js). Zed implements
// OSC 8 with similar capabilities. All of these handle link hover
// styling natively.
TerminalName::VsCode
| TerminalName::Cursor
| TerminalName::Windsurf
| TerminalName::Zed => HyperlinkCapabilities {
osc8: Native,
id_param: true,
scheme_filter: SchemeFilter::Standard,
osc22_cursor: false,
native_link_hover: true,
native_plain_url_open: false,
},
// Open issue warpdotdev/Warp#4194. UrlLocator opens bare URLs under
// mouse reporting; keep native_link_hover false for file:// fallback.
TerminalName::WarpTerminal => HyperlinkCapabilities {
osc8: Unsupported,
id_param: false,
scheme_filter: SchemeFilter::Standard,
osc22_cursor: false,
native_link_hover: false,
native_plain_url_open: true,
},
// VTE-based terminals (GNOME Terminal, Terminator, etc.).
// Conservative -- gated by version in the route resolver if
// vte_version is too old.
TerminalName::Vte | TerminalName::Terminator => HyperlinkCapabilities {
osc8: Native,
id_param: true,
scheme_filter: SchemeFilter::Standard,
osc22_cursor: false,
native_link_hover: false,
native_plain_url_open: false,
},
// Windows Terminal since v1.4 (OSC 8 support).
TerminalName::WindowsTerminal => HyperlinkCapabilities {
osc8: Native,
id_param: true,
scheme_filter: SchemeFilter::Standard,
osc22_cursor: false,
native_link_hover: false,
native_plain_url_open: false,
},
// JetBrains JediTerm: OSC 8 varies across IDE versions; no
// runtime probe available (no TERM_FEATURES). Conservative.
TerminalName::JetBrains => HyperlinkCapabilities {
osc8: Unknown,
id_param: false,
scheme_filter: SchemeFilter::Standard,
osc22_cursor: false,
native_link_hover: false,
native_plain_url_open: false,
},
// Electron app; behavior undocumented.
TerminalName::GrokDesktop => HyperlinkCapabilities {
osc8: Unknown,
id_param: false,
scheme_filter: SchemeFilter::Standard,
osc22_cursor: false,
native_link_hover: false,
native_plain_url_open: false,
},
TerminalName::Otty | TerminalName::Unknown => HyperlinkCapabilities {
osc8: Unknown,
id_param: false,
scheme_filter: SchemeFilter::Standard,
osc22_cursor: false,
native_link_hover: false,
native_plain_url_open: false,
},
}
}
// ── 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.
/// OSC 22: set the mouse pointer to the "pointer" (hand) shape.
///
/// Supported by iTerm2, Ghostty, and Kitty. Silently ignored by
/// terminals that don't understand OSC 22.
pub struct SetPointerCursor;
impl crossterm::Command for SetPointerCursor {
fn write_ansi(&self, f: &mut impl std::fmt::Write) -> std::fmt::Result {
f.write_str("\x1b]22;pointer\x1b\\")
}
#[cfg(windows)]
fn execute_winapi(&self) -> std::io::Result<()> {
Ok(())
}
}
/// OSC 22: reset the mouse pointer to the default (arrow) shape.
pub struct SetDefaultCursor;
impl crossterm::Command for SetDefaultCursor {
fn write_ansi(&self, f: &mut impl std::fmt::Write) -> std::fmt::Result {
f.write_str("\x1b]22;default\x1b\\")
}
#[cfg(windows)]
fn execute_winapi(&self) -> std::io::Result<()> {
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn apple_terminal_hostile_parser() {
let caps = hyperlink_capabilities(TerminalName::AppleTerminal);
assert_eq!(caps.osc8, Osc8Support::HostileParser);
assert!(!caps.id_param);
}
#[test]
fn iterm2_native_with_id() {
let caps = hyperlink_capabilities(TerminalName::Iterm2);
assert_eq!(caps.osc8, Osc8Support::Native);
assert!(caps.id_param);
}
#[test]
fn warp_unsupported() {
let caps = hyperlink_capabilities(TerminalName::WarpTerminal);
assert_eq!(caps.osc8, Osc8Support::Unsupported);
assert!(!caps.id_param);
assert!(caps.native_plain_url_open);
assert!(!caps.native_link_hover);
}
#[test]
fn native_plain_url_open_only_warp() {
assert!(hyperlink_capabilities(TerminalName::WarpTerminal).native_plain_url_open);
for brand in [
TerminalName::Iterm2,
TerminalName::VsCode,
TerminalName::AppleTerminal,
TerminalName::Ghostty,
TerminalName::Unknown,
] {
assert!(
!hyperlink_capabilities(brand).native_plain_url_open,
"{brand:?} must not set native_plain_url_open"
);
}
}
#[test]
fn unknown_terminal_unknown_support() {
let caps = hyperlink_capabilities(TerminalName::Unknown);
assert_eq!(caps.osc8, Osc8Support::Unknown);
assert!(!caps.id_param);
}
#[test]
fn scheme_filter_standard_allows_http() {
assert!(SchemeFilter::Standard.allows("http"));
assert!(SchemeFilter::Standard.allows("https"));
assert!(SchemeFilter::Standard.allows("mailto"));
assert!(!SchemeFilter::Standard.allows("file"));
assert!(!SchemeFilter::Standard.allows("vscode"));
assert!(!SchemeFilter::Standard.allows("javascript"));
}
#[test]
fn scheme_filter_extended_allows_editor_schemes() {
assert!(SchemeFilter::EditorExtended.allows("http"));
assert!(SchemeFilter::EditorExtended.allows("vscode"));
assert!(SchemeFilter::EditorExtended.allows("cursor"));
assert!(SchemeFilter::EditorExtended.allows("idea"));
assert!(SchemeFilter::EditorExtended.allows("zed"));
assert!(!SchemeFilter::EditorExtended.allows("javascript"));
}
}
@@ -0,0 +1,651 @@
//! Terminal inline image rendering (Kitty / iTerm2 protocols).
//!
//! 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.
//!
//! # Supported protocols
//!
//! - **Kitty graphics protocol**: used by Kitty, Ghostty, WezTerm, Warp
//! - **iTerm2 inline images**: helpers exist but are currently gated off in
//! [`protocol_for_brand()`] (see there for why); the text fallback is used
//! for iTerm2 instead.
//!
//! # Usage
//!
//! 1. Call [`detect_graphics_protocol()`] once (cached).
//! 2. During draw, if an image preview is active, call
//! [`render_kitty_image()`] or [`render_iterm2_image()`] to build the
//! escape sequence.
//! 3. Write the escape sequence to stderr **after** the ratatui cell
//! flush but inside the synchronized-output block.
//! 4. Coordinate shared ID-1 ownership through [`super::overlay`].
use std::sync::OnceLock;
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)]
pub enum GraphicsProtocol {
/// Kitty graphics protocol (also used by Ghostty, WezTerm).
Kitty,
/// iTerm2 inline images protocol.
ITerm2,
/// No graphics protocol available — text fallback only.
#[default]
None,
}
impl GraphicsProtocol {
/// Whether this protocol can render pixel images inline.
pub fn supports_images(self) -> bool {
!matches!(self, Self::None)
}
}
static GRAPHICS_PROTOCOL: OnceLock<GraphicsProtocol> = OnceLock::new();
/// When set, scrollback inline-media overlays are forced **off** process-wide,
/// regardless of the terminal's graphics capability. The scrollback-native
/// minimal mode (`grok --minimal`) sets this once at startup: it never runs the
/// interactive draw loop that paints inline images, so committed media blocks
/// must always fall back to the `[Open …]` text affordance — and must not
/// reserve blank image rows. See [`set_inline_overlay_force_off`].
static INLINE_OVERLAY_FORCE_OFF: AtomicBool = AtomicBool::new(false);
/// Force scrollback inline-media overlays off (`off = true`) or restore the
/// capability-based default (`off = false`) process-wide. Called once at
/// startup by the pager when minimal mode is active.
pub fn set_inline_overlay_force_off(off: bool) {
INLINE_OVERLAY_FORCE_OFF.store(off, Ordering::Relaxed);
}
/// Whether scrollback inline-media overlays are currently forced off — i.e. the
/// process is in minimal/scrollback-native mode, which commits static text and
/// never runs the interactive draw loop. Also used to suppress draw-loop-painted
/// affordances (e.g. the mermaid button row) that would otherwise commit blank.
pub fn scrollback_inline_overlay_forced_off() -> bool {
INLINE_OVERLAY_FORCE_OFF.load(Ordering::Relaxed)
}
#[cfg(any(test, feature = "test-support"))]
thread_local! {
/// Per-test override so tests don't depend on the host terminal or the
/// process-wide `GRAPHICS_PROTOCOL` cache.
static TEST_PROTOCOL_OVERRIDE: std::cell::Cell<Option<GraphicsProtocol>> =
const { std::cell::Cell::new(None) };
}
/// Detect and cache the graphics protocol for the current terminal.
pub fn detect_graphics_protocol() -> GraphicsProtocol {
#[cfg(any(test, feature = "test-support"))]
if let Some(p) = TEST_PROTOCOL_OVERRIDE.with(|c| c.get()) {
return p;
}
*GRAPHICS_PROTOCOL.get_or_init(|| {
let ctx = terminal_context();
if ctx.graphics_protocol_skip_reason().is_some() {
return GraphicsProtocol::None;
}
protocol_for_brand(ctx.brand, cfg!(target_os = "windows"))
})
}
/// Whether the current terminal can safely host scrollback inline-media
/// overlays.
///
/// This is narrower than "supports Kitty graphics": scrollback media uses
/// Kitty image ids, placement ids, z-index, clearing, and source cropping so
/// images scroll with the text grid. Warp accepts some Kitty image escapes but
/// does not reliably support that placement/scrollback model, which leaves
/// stale or corrupted pixels while scrolling.
pub fn scrollback_inline_overlay_active() -> bool {
// Minimal mode forces this off process-wide: it never paints inline images,
// so media must always use the text affordance.
if INLINE_OVERLAY_FORCE_OFF.load(Ordering::Relaxed) {
return false;
}
let protocol = detect_graphics_protocol();
if test_protocol_override_active() {
return protocol == GraphicsProtocol::Kitty;
}
scrollback_inline_overlay_active_for_brand(protocol, terminal_context().brand)
}
#[cfg(any(test, feature = "test-support"))]
fn test_protocol_override_active() -> bool {
TEST_PROTOCOL_OVERRIDE.with(|c| c.get().is_some())
}
#[cfg(not(any(test, feature = "test-support")))]
fn test_protocol_override_active() -> bool {
false
}
/// Pure capability helper for scrollback inline-media overlays.
fn scrollback_inline_overlay_active_for_brand(
protocol: GraphicsProtocol,
brand: TerminalName,
) -> bool {
matches!(
(protocol, brand),
(
GraphicsProtocol::Kitty,
TerminalName::Kitty | TerminalName::Ghostty | TerminalName::WezTerm
)
)
}
/// Set a per-thread protocol override for tests. Returns a guard that
/// clears it on drop.
#[cfg(any(test, feature = "test-support"))]
pub fn set_protocol_for_test(p: GraphicsProtocol) -> TestProtocolGuard {
TEST_PROTOCOL_OVERRIDE.with(|c| c.set(Some(p)));
TestProtocolGuard
}
/// RAII guard that clears the test protocol override on drop.
#[cfg(any(test, feature = "test-support"))]
pub struct TestProtocolGuard;
#[cfg(any(test, feature = "test-support"))]
impl Drop for TestProtocolGuard {
fn drop(&mut self) {
TEST_PROTOCOL_OVERRIDE.with(|c| c.set(None));
}
}
/// Map terminal brand to graphics protocol. Returns `None` on Windows
/// because ConPTY strips the Kitty/iTerm2 APC escape sequences before
/// they reach the host terminal.
///
/// Parameterised by `is_windows` so unit tests can exercise both paths
/// on any OS.
pub fn protocol_for_brand(brand: TerminalName, is_windows: bool) -> GraphicsProtocol {
if is_windows {
return GraphicsProtocol::None;
}
match brand {
TerminalName::Kitty => GraphicsProtocol::Kitty,
TerminalName::Ghostty => GraphicsProtocol::Kitty,
TerminalName::WezTerm => GraphicsProtocol::Kitty,
TerminalName::WarpTerminal => GraphicsProtocol::Kitty,
// iTerm2's OSC 1337 inline-image protocol lacks the image-id, z-index,
// source-crop, and clear primitives the Kitty protocol has, so overlay
// images don't track the text grid — they paint wrong or never appear
// (leaving a stuck "Loading…" hint). Disable it; the text/metadata
// fallback is used instead. Re-enable with `ITerm2` once verified.
TerminalName::Iterm2 => GraphicsProtocol::None,
_ => GraphicsProtocol::None,
}
}
// -------------------------------------------------------------------------
// Kitty graphics protocol
// -------------------------------------------------------------------------
/// Shared placement ID; every renderer must coordinate through [`super::overlay`].
pub(super) const KITTY_PLACEMENT_ID: u32 = 1;
/// Kitty graphics protocol image format identifier.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum KittyImageFormat {
/// PNG image data (`f=100`).
Png,
}
impl KittyImageFormat {
fn code(self) -> u16 {
match self {
Self::Png => 100,
}
}
}
/// Detect the Kitty graphics format code for encoded image bytes.
pub fn kitty_format_from_bytes(image_data: &[u8]) -> Option<KittyImageFormat> {
match kigi_shared::clipboard::mime_from_bytes(image_data) {
"image/png" => Some(KittyImageFormat::Png),
_ => None,
}
}
/// Whether Kitty can directly render this encoded MIME type in raw-byte mode.
pub fn kitty_mime_is_directly_supported(mime_type: &str) -> bool {
mime_type == "image/png"
}
/// Prepare encoded image bytes for Kitty's raw-byte overlay path.
///
/// Kitty accepts encoded PNG bytes via `f=100`, but not encoded JPEG/WebP/etc.
/// Convert other decodable images to PNG before handing them to the centered
/// overlay renderer. Callers must keep this out of draw paths.
///
/// On macOS, uses `sips` (Apple CoreGraphics) which handles ICC colour
/// profiles correctly. Falls back to the `image` crate on other platforms.
pub fn prepare_kitty_overlay_image_bytes(image_data: &[u8]) -> Option<Vec<u8>> {
if kitty_format_from_bytes(image_data).is_some() {
return Some(image_data.to_vec());
}
// On macOS, convert via `sips` through a temp file. CoreGraphics
// handles ICC colour profiles correctly, avoiding the artifacts
// that the `image` crate's JPEG→PNG path can produce.
if cfg!(target_os = "macos")
&& let Some(png) = convert_via_sips(image_data)
{
return Some(png);
}
let img = image::ImageReader::new(std::io::Cursor::new(image_data))
.with_guessed_format()
.ok()?
.decode()
.ok()?;
let mut png = Vec::new();
{
use image::ExtendedColorType;
use image::ImageEncoder;
use image::codecs::png::{CompressionType, FilterType, PngEncoder};
let rgba = img.to_rgba8();
let encoder =
PngEncoder::new_with_quality(&mut png, CompressionType::Fast, FilterType::Adaptive);
encoder
.write_image(
rgba.as_raw(),
rgba.width(),
rgba.height(),
ExtendedColorType::Rgba8,
)
.ok()?;
}
Some(png)
}
/// Convert image bytes to PNG via macOS `sips` using temp files.
fn convert_via_sips(image_data: &[u8]) -> Option<Vec<u8>> {
use std::io::Write;
let tmp_dir = std::env::temp_dir();
let id = std::process::id();
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
let src = tmp_dir.join(format!("grok-sips-{id}-{ts}.dat"));
let dst = tmp_dir.join(format!("grok-sips-{id}-{ts}.png"));
// Write source bytes to temp file.
let mut f = std::fs::File::create(&src).ok()?;
f.write_all(image_data).ok()?;
f.sync_all().ok()?;
drop(f);
let mut sips_cmd = std::process::Command::new("sips");
sips_cmd
.args(["-s", "format", "png"])
.arg(&src)
.arg("--out")
.arg(&dst)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null());
kigi_tty_utils::detach_std_command(&mut sips_cmd);
let status = sips_cmd.status().ok()?;
let _ = std::fs::remove_file(&src);
if !status.success() || !dst.is_file() {
let _ = std::fs::remove_file(&dst);
return None;
}
let png = std::fs::read(&dst).ok()?;
let _ = std::fs::remove_file(&dst);
Some(png)
}
/// Prepare encoded image bytes for the currently detected overlay protocol.
pub fn prepare_overlay_image_bytes(image_data: &[u8]) -> Option<Vec<u8>> {
match detect_graphics_protocol() {
GraphicsProtocol::Kitty => prepare_kitty_overlay_image_bytes(image_data),
GraphicsProtocol::ITerm2 => Some(image_data.to_vec()),
GraphicsProtocol::None => None,
}
}
/// Build a Kitty graphics protocol escape sequence to display encoded image data.
///
/// The image is transmitted inline as base64-encoded data and scaled by the
/// terminal to fit `cols` columns × `rows` rows. The terminal handles
/// HiDPI/Retina scaling correctly since it knows the actual cell pixel
/// dimensions.
///
/// Uses `a=T` (transmit + display), `f=<format>` (PNG format), `t=d`
/// (direct data transmission), `q=2` (suppress responses), `C=1` (preserve
/// cursor position), and `z=1` (draw above text cells), chunked into 4096-byte
/// pieces.
pub fn render_kitty_image(
image_data: &[u8],
format: KittyImageFormat,
cols: u16,
rows: u16,
) -> String {
render_kitty_image_z(image_data, format, cols, rows, 1)
}
/// Render a Kitty image with a specific z-index.
///
/// `z=1`: above text (modal overlays). `z=-1`: below text, above
/// background (inline scrollback media — dropdowns render on top).
pub fn render_kitty_image_z(
image_data: &[u8],
format: KittyImageFormat,
cols: u16,
rows: u16,
z: i32,
) -> String {
let header = format!(
"a=T,f={},t=d,q=2,C=1,z={},i={},p={},c={},r={}",
format.code(),
z,
KITTY_PLACEMENT_ID,
KITTY_PLACEMENT_ID,
cols,
rows,
);
kitty_chunked_escape(image_data, &header)
}
/// Transmit image data to the terminal without displaying it (`a=t`).
/// Use `place_kitty_image` to display it at a position.
pub fn transmit_kitty_image(image_data: &[u8], format: KittyImageFormat, image_id: u32) -> String {
let header = format!("a=t,f={},t=d,q=2,i={}", format.code(), image_id);
kitty_chunked_escape(image_data, &header)
}
/// Encode image data as chunked Kitty escape sequences.
/// `first_chunk_header` is the metadata for the first chunk (action, format, etc.).
fn kitty_chunked_escape(image_data: &[u8], first_chunk_header: &str) -> String {
use base64::Engine as _;
let b64 = base64::engine::general_purpose::STANDARD.encode(image_data);
let chunk_size = 4096;
let chunks: Vec<&str> = b64
.as_bytes()
.chunks(chunk_size)
.map(|c| std::str::from_utf8(c).unwrap_or(""))
.collect();
let mut out = String::new();
for (i, chunk) in chunks.iter().enumerate() {
let is_last = i == chunks.len() - 1;
let m = if is_last { 0 } else { 1 };
if i == 0 {
out.push_str(&format!("\x1b_G{first_chunk_header},m={m};{chunk}\x1b\\"));
} else {
out.push_str(&format!("\x1b_Gq=2,m={m};{chunk}\x1b\\"));
}
}
out
}
/// Place an already-transmitted image at the cursor position (`a=p`).
///
/// Tiny escape (~50 bytes) — no image data, just placement metadata.
pub fn place_kitty_image(image_id: u32, cols: u16, rows: u16, z: i32) -> String {
format!(
"\x1b_Ga=p,i={},p={},c={},r={},z={},C=1,q=2\x1b\\",
image_id, image_id, cols, rows, z,
)
}
/// Place an already-transmitted image with source cropping (`a=p`).
///
/// `src_x, src_y, src_w, src_h`: pixel region of the source image to display.
#[allow(clippy::too_many_arguments)]
pub fn place_kitty_image_cropped(
image_id: u32,
cols: u16,
rows: u16,
z: i32,
src_x: u32,
src_y: u32,
src_w: u32,
src_h: u32,
) -> String {
format!(
"\x1b_Ga=p,i={},p={},c={},r={},z={},x={},y={},w={},h={},C=1,q=2\x1b\\",
image_id, image_id, cols, rows, z, src_x, src_y, src_w, src_h,
)
}
/// Build a Kitty escape sequence to delete a specific image by ID.
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.
///
/// Uses `\x1b]1337;File=inline=1;width=Ncells;height=Ncells;preserveAspectRatio=1:BASE64\x07`.
pub fn render_iterm2_image(image_data: &[u8], cols: u16, rows: u16) -> String {
use base64::Engine as _;
let b64 = base64::engine::general_purpose::STANDARD.encode(image_data);
format!(
"\x1b]1337;File=inline=1;width={cols}cells;height={rows}cells;preserveAspectRatio=1:{b64}\x07",
cols = cols,
rows = rows,
b64 = b64,
)
}
// -------------------------------------------------------------------------
// Shared overlay helpers
// -------------------------------------------------------------------------
/// Build the full escape-sequence string to render image data at a cell
/// position using the provided graphics protocol.
///
/// For Kitty: transmits image data once (`a=t`) then places it (`a=p`). Pass
/// `retransmit = false` on subsequent frames to emit only the placement escape
/// (~50 bytes) instead of re-uploading the full image every redraw.
///
/// For iTerm2: always emits the full inline image escape (no separate transmit
/// primitive). Callers should pass `retransmit = false` after the first frame
/// to avoid re-decoding the same image every tick.
///
/// Returns `None` when no graphics protocol is available.
///
/// Pass `retransmit = false` on subsequent frames to skip the data upload
/// (Kitty: place-only; iTerm2: no-op).
#[allow(clippy::too_many_arguments)]
pub(super) fn build_overlay_image_escapes_for_protocol(
protocol: GraphicsProtocol,
image_data: &[u8],
cols: u16,
rows: u16,
cell_x: u16,
cell_y: u16,
retransmit: bool,
) -> Option<String> {
if protocol == GraphicsProtocol::None {
return None;
}
let mut esc = String::new();
// ANSI cursor positioning is 1-based.
esc.push_str(&format!("\x1b[{};{}H", cell_y + 1, cell_x + 1));
match protocol {
GraphicsProtocol::Kitty => {
if retransmit {
// Transmit once, then place — never use a=T (transmit+display)
// on every frame; that re-uploads the full image at ~10fps and
// balloons native GPU surface counts in long-lived sessions.
// Place-only frames do not need image bytes / format detection.
let format = kitty_format_from_bytes(image_data)?;
esc.push_str(&transmit_kitty_image(
image_data,
format,
KITTY_PLACEMENT_ID,
));
}
esc.push_str(&place_kitty_image(
KITTY_PLACEMENT_ID,
cols,
rows,
1, // above text (modal overlays)
));
}
GraphicsProtocol::ITerm2 => {
if retransmit {
esc.push_str(&render_iterm2_image(image_data, cols, rows));
}
}
GraphicsProtocol::None => unreachable!(),
}
Some(esc)
}
/// Transmit inline image data to the terminal GPU.
///
/// Kitty: uploads with the given `image_id`. iTerm2: no-op (data sent per-place).
pub fn transmit_inline_image(image_data: &[u8], image_id: u32) -> Option<String> {
match detect_graphics_protocol() {
GraphicsProtocol::Kitty => {
let format = kitty_format_from_bytes(image_data)?;
Some(transmit_kitty_image(image_data, format, image_id))
}
GraphicsProtocol::ITerm2 => Some(String::new()),
GraphicsProtocol::None => None,
}
}
/// Place an inline image at a position, optionally cropping.
///
/// For Kitty: ~80 bytes (no image data, just placement with crop).
/// For iTerm2: sends full image data only when `emit_iterm_data` is true
/// (no crop support). Pass `false` after the first placement to avoid
/// re-decoding the same image on every TUI frame.
#[allow(clippy::too_many_arguments)]
pub fn place_inline_image(
image_data: &[u8],
img_w: u32,
img_h: u32,
area: ratatui::layout::Rect,
full_rows: u16,
top_crop_rows: u16,
image_id: u32,
emit_iterm_data: bool,
) -> Option<String> {
let protocol = detect_graphics_protocol();
if protocol == GraphicsProtocol::None {
return None;
}
// Compute fit dimensions as if the full image were visible.
let (fit_cols, fit_rows) = fit_image_to_cells(img_w, img_h, area.width, full_rows);
let pad_x = area.width.saturating_sub(fit_cols) / 2;
let img_x = area.x + pad_x;
let img_y = area.y;
let mut esc = String::new();
esc.push_str(&format!("\x1b[{};{}H", img_y + 1, img_x + 1));
match protocol {
GraphicsProtocol::Kitty => {
let visible_rows = area.height.min(fit_rows);
if top_crop_rows > 0 || visible_rows < fit_rows {
let src_y = if fit_rows > 0 {
(top_crop_rows as u32 * img_h) / fit_rows as u32
} else {
0
};
let src_h = if fit_rows > 0 {
(visible_rows as u32 * img_h) / fit_rows as u32
} else {
img_h
};
esc.push_str(&place_kitty_image_cropped(
image_id,
fit_cols,
visible_rows,
-1,
0,
src_y,
img_w,
src_h.max(1),
));
} else {
esc.push_str(&place_kitty_image(image_id, fit_cols, fit_rows, -1));
}
}
GraphicsProtocol::ITerm2 => {
if emit_iterm_data {
esc.push_str(&render_iterm2_image(image_data, fit_cols, area.height));
}
}
GraphicsProtocol::None => unreachable!(),
}
Some(esc)
}
/// Compute the cell dimensions (`cols`, `rows`) to display an image at
/// its correct aspect ratio within a bounding box of `max_cols × max_rows`.
///
/// Terminal cells are not square — they're roughly twice as tall as wide
/// (typical monospace cell ~8px wide × ~16px tall, ratio ≈ 0.5). This
/// function accounts for that so a 1:1 image appears visually square
/// and a 16:9 screenshot looks like a 16:9 rectangle.
pub fn fit_image_to_cells(img_w: u32, img_h: u32, max_cols: u16, max_rows: u16) -> (u16, u16) {
if img_w == 0 || img_h == 0 || max_cols == 0 || max_rows == 0 {
return (max_cols.max(1), max_rows.max(1));
}
// Cell aspect ratio: width / height. Typical monospace cell is ~0.5
// (half as wide as tall). This converts between pixel-space and
// cell-space so the image doesn't appear stretched.
let cell_aspect: f64 = 0.5;
// Image aspect ratio in pixel space.
let img_aspect = img_w as f64 / img_h as f64;
// Convert image aspect to cell-space: how many columns per row the
// image needs to look correct. A cell is `cell_aspect` times as wide
// as it is tall, so we divide by cell_aspect.
// cols_per_row = img_aspect / cell_aspect
let cols_per_row = img_aspect / cell_aspect;
// Try fitting by width first.
let cols_by_width = max_cols;
let rows_by_width = (cols_by_width as f64 / cols_per_row).round() as u16;
// Try fitting by height.
let rows_by_height = max_rows;
let cols_by_height = (rows_by_height as f64 * cols_per_row).round() as u16;
// Pick whichever fit stays within bounds.
if rows_by_width <= max_rows {
(cols_by_width, rows_by_width.max(1))
} else {
(cols_by_height.min(max_cols).max(1), rows_by_height)
}
}
// =========================================================================
// Tests
// =========================================================================
#[cfg(test)]
mod tests;
@@ -0,0 +1,121 @@
use super::*;
#[test]
fn protocol_matrix_matches_supported_terminals() {
for (brand, expected) in [
(TerminalName::Kitty, GraphicsProtocol::Kitty),
(TerminalName::Ghostty, GraphicsProtocol::Kitty),
(TerminalName::WezTerm, GraphicsProtocol::Kitty),
(TerminalName::WarpTerminal, GraphicsProtocol::Kitty),
(TerminalName::Iterm2, GraphicsProtocol::None),
(TerminalName::Unknown, GraphicsProtocol::None),
] {
assert_eq!(protocol_for_brand(brand, false), expected);
assert_eq!(protocol_for_brand(brand, true), GraphicsProtocol::None);
}
}
#[test]
fn scrollback_overlay_excludes_warp() {
assert!(scrollback_inline_overlay_active_for_brand(
GraphicsProtocol::Kitty,
TerminalName::Kitty,
));
assert!(!scrollback_inline_overlay_active_for_brand(
GraphicsProtocol::Kitty,
TerminalName::WarpTerminal,
));
}
#[test]
#[serial_test::serial]
fn force_off_overrides_capability() {
let _guard = set_protocol_for_test(GraphicsProtocol::Kitty);
set_inline_overlay_force_off(false);
assert!(scrollback_inline_overlay_active());
set_inline_overlay_force_off(true);
assert!(!scrollback_inline_overlay_active());
set_inline_overlay_force_off(false);
}
#[test]
fn kitty_escape_chunks_and_preserves_cursor() {
let small = render_kitty_image(&[0u8; 10], KittyImageFormat::Png, 40, 20);
assert!(small.contains("a=T"));
assert!(small.contains("f=100"));
assert!(small.contains("q=2"));
assert!(small.contains("C=1"));
assert!(small.contains("c=40"));
assert!(small.contains("r=20"));
assert!(small.contains("m=0"));
let large = render_kitty_image(&vec![0u8; 5000], KittyImageFormat::Png, 40, 20);
assert!(large.matches("\x1b_G").count() > 1);
}
#[test]
fn kitty_format_and_conversion_produce_png() {
use image::{ImageBuffer, Rgb};
let png = [0x89, b'P', b'N', b'G', b'\r', b'\n', 0x1a, b'\n'];
assert_eq!(kitty_format_from_bytes(&png), Some(KittyImageFormat::Png));
let buffer: ImageBuffer<Rgb<u8>, Vec<u8>> = ImageBuffer::from_pixel(4, 3, Rgb([128, 64, 32]));
let mut jpeg = Vec::new();
buffer
.write_to(
&mut std::io::Cursor::new(&mut jpeg),
image::ImageFormat::Jpeg,
)
.unwrap();
assert_eq!(kitty_format_from_bytes(&jpeg), None);
let converted = prepare_kitty_overlay_image_bytes(&jpeg).unwrap();
assert_eq!(
kitty_format_from_bytes(&converted),
Some(KittyImageFormat::Png)
);
}
#[test]
fn iterm_escape_preserves_requested_geometry() {
let escape = render_iterm2_image(&[0u8; 10], 30, 15);
assert!(escape.starts_with("\x1b]1337;File="));
assert!(escape.contains("width=30cells"));
assert!(escape.contains("height=15cells"));
assert!(escape.contains("preserveAspectRatio=1"));
}
#[test]
fn low_level_overlay_separates_transmit_from_placement() {
let png = [0x89, b'P', b'N', b'G', b'\r', b'\n', 0x1a, b'\n'];
let protocol = GraphicsProtocol::Kitty;
let first =
build_overlay_image_escapes_for_protocol(protocol, &png, 20, 10, 0, 0, true).unwrap();
let subsequent =
build_overlay_image_escapes_for_protocol(protocol, &png, 20, 10, 0, 0, false).unwrap();
assert!(first.contains("a=t") && first.contains("a=p"));
assert!(!first.contains("a=T"));
assert!(subsequent.contains("a=p"));
assert!(!subsequent.contains("a=t"));
}
#[test]
fn iterm_place_can_skip_inline_data() {
let _guard = set_protocol_for_test(GraphicsProtocol::ITerm2);
let area = ratatui::layout::Rect::new(0, 0, 40, 20);
let escape = place_inline_image(&[0u8; 10], 100, 50, area, 20, 0, 2, false).unwrap();
assert!(escape.starts_with("\x1b["));
assert!(!escape.contains("1337"));
}
#[test]
fn placement_only_steady_state_removes_payload_cost() {
let mut png = vec![0x89, b'P', b'N', b'G', b'\r', b'\n', 0x1a, b'\n'];
png.extend(std::iter::repeat_n(0u8, 200_000));
let protocol = GraphicsProtocol::Kitty;
let first =
build_overlay_image_escapes_for_protocol(protocol, &png, 40, 20, 0, 0, true).unwrap();
let subsequent =
build_overlay_image_escapes_for_protocol(protocol, &png, 40, 20, 0, 0, false).unwrap();
assert!(first.len() > 200_000);
assert!(subsequent.len() < 200);
assert!(!subsequent.contains("a=t"));
}
@@ -0,0 +1,215 @@
//! Per-terminal keyboard input capabilities.
//!
//! Classifies keyboard delivery semantics so input-handling code can consume one struct
//! 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
//! `match self.brand` sites scattered through the pager.
use super::TerminalName;
use crate::host::HostOs;
/// What happens to a single modifier (Cmd, Opt, etc.) on its way from
/// the keyboard to the program.
#[derive(Debug, Clone, Copy, Default, Eq, PartialEq, strum::Display)]
#[strum(serialize_all = "snake_case")]
#[non_exhaustive]
pub enum ModifierFate {
/// Terminal delivers the modifier in the `KeyEvent` (KKP) or as a
/// readline-equivalent byte sequence the textarea already handles
/// (`^U`, `ESC ^?`).
Native,
/// Terminal drops the modifier; the OS-level rescue can recover it
/// (CoreGraphics on macOS).
Dropped,
/// Chord captured before reaching the PTY (Apple Terminal Cmd+Bsp).
/// No event arrives; not even an OS rescue helps.
Unrecoverable,
/// Behavior unclassified — treated as no-rescue to avoid false
/// positives on unknown brands.
#[default]
Unknown,
}
impl ModifierFate {
pub fn benefits_from_rescue(self) -> bool {
matches!(self, Self::Dropped)
}
}
/// How a terminal delivers Cmd/Opt-modified Backspace/Delete.
#[derive(Debug, Clone, Copy, Default, Eq, PartialEq)]
#[non_exhaustive]
pub struct ModifierDelivery {
pub cmd: ModifierFate,
pub opt: ModifierFate,
}
impl ModifierDelivery {
/// Construct a delivery from explicit fates. `#[non_exhaustive]` blocks
/// struct-literal construction from other crates, so downstream test
/// builds use this constructor.
#[cfg(any(test, feature = "test-support"))]
pub fn new_for_test(cmd: ModifierFate, opt: ModifierFate) -> Self {
Self { cmd, opt }
}
pub fn benefits_from_rescue(self) -> bool {
self.cmd.benefits_from_rescue() || self.opt.benefits_from_rescue()
}
pub fn label(self) -> String {
format!("cmd={}, opt={}", self.cmd, self.opt)
}
}
/// Per-terminal keyboard capabilities. Extend with new fields as more
/// per-terminal input behaviors get classified.
#[derive(Debug, Clone, Copy, Default)]
#[non_exhaustive]
pub struct KeyboardCapabilities {
pub modifier_delivery: ModifierDelivery,
/// Fate of Shift/Opt/Cmd when modifying `Enter`. Apple Terminal
/// drops these and we recover them via the same OS poll used for
/// Backspace/Delete.
pub enter_modifier: ModifierFate,
}
impl KeyboardCapabilities {
pub fn enter_needs_rescue(&self) -> bool {
matches!(self.enter_modifier, ModifierFate::Dropped)
}
}
/// 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
/// per-OS arm here rather than forking the function.
pub fn keyboard_capabilities(brand: TerminalName) -> KeyboardCapabilities {
match HostOs::current() {
HostOs::Macos => macos_capabilities(brand),
HostOs::Linux | HostOs::Windows | HostOs::Other => KeyboardCapabilities::default(),
}
}
fn macos_capabilities(brand: TerminalName) -> KeyboardCapabilities {
use ModifierFate::*;
let (cmd, opt, enter) = match brand {
TerminalName::Ghostty | TerminalName::Kitty | TerminalName::Foot => {
(Native, Native, Native)
}
// iTerm2/VS Code translate Cmd+Bsp → ^U and Opt+Bsp → ESC ^?,
// both of which the textarea already handles natively. VS Code-family
// embeds and Zed inherit the same keymap behavior as VS Code
// capabilities at runtime (no TERM_FEATURES, XTVERSION leaks).
// (including the Cmd+Bsp → ^U translation).
TerminalName::Iterm2
| TerminalName::VsCode
| TerminalName::Cursor
| TerminalName::Windsurf
| TerminalName::Zed => (Native, Native, Native),
TerminalName::WezTerm => (Dropped, Native, Native),
// Alacritty's macOS keymap binds Cmd+Bsp → ^U (native readline);
// Opt+Bsp is a bare ^? without `option_as_alt` set.
TerminalName::Alacritty | TerminalName::Rio => (Native, Dropped, Native),
TerminalName::WarpTerminal => (Dropped, Dropped, Native),
// Apple Terminal: Cmd+Bsp captured by the window manager.
// Opt+Bsp and modified Enter are dropped; CG can rescue both.
TerminalName::AppleTerminal => (Unrecoverable, Dropped, Dropped),
TerminalName::GrokDesktop => (Unknown, Unknown, Unknown),
// VTE-based terminals (incl. Terminator) on macOS are unusual;
// classify when we have evidence rather than guessing.
TerminalName::Vte | TerminalName::Terminator => (Unknown, Unknown, Unknown),
// JetBrains JediTerm: no KKP, no CG rescue. No way to probe
// Mouse reporting has known SGR bugs in Classic engine (IJPL-232482);
// Mouse reporting has known SGR bugs in Classic engine (IJPL-232482);
// Reworked 2025 engine is better but indistinguishable via env vars.
TerminalName::JetBrains => (Unknown, Unknown, Unknown),
TerminalName::WindowsTerminal | TerminalName::Otty | TerminalName::Unknown => {
(Unknown, Unknown, Unknown)
}
};
KeyboardCapabilities {
modifier_delivery: ModifierDelivery { cmd, opt },
enter_modifier: enter,
}
}
// Tests verify the macOS classification table; they only mean
// something on a macOS host. Cross-OS verification will need a test
// harness that overrides `HostOs::current()`; see the module doc.
#[cfg(all(test, target_os = "macos"))]
mod tests {
use super::*;
#[test]
fn ghostty_and_kitty_native_no_rescue() {
for brand in [TerminalName::Ghostty, TerminalName::Kitty] {
let c = keyboard_capabilities(brand);
assert_eq!(c.modifier_delivery.cmd, ModifierFate::Native);
assert_eq!(c.modifier_delivery.opt, ModifierFate::Native);
assert!(!c.modifier_delivery.benefits_from_rescue());
}
}
#[test]
fn iterm_and_vscode_readline_no_rescue() {
for brand in [TerminalName::Iterm2, TerminalName::VsCode] {
assert!(
!keyboard_capabilities(brand)
.modifier_delivery
.benefits_from_rescue()
);
}
}
#[test]
fn wezterm_drops_cmd_keeps_opt() {
let c = keyboard_capabilities(TerminalName::WezTerm);
assert_eq!(c.modifier_delivery.cmd, ModifierFate::Dropped);
assert_eq!(c.modifier_delivery.opt, ModifierFate::Native);
assert!(c.modifier_delivery.benefits_from_rescue());
}
#[test]
fn alacritty_keeps_cmd_drops_opt() {
let c = keyboard_capabilities(TerminalName::Alacritty);
assert_eq!(c.modifier_delivery.cmd, ModifierFate::Native);
assert_eq!(c.modifier_delivery.opt, ModifierFate::Dropped);
assert!(c.modifier_delivery.benefits_from_rescue());
}
#[test]
fn rio_keeps_cmd_drops_opt() {
let c = keyboard_capabilities(TerminalName::Rio);
assert_eq!(c.modifier_delivery.cmd, ModifierFate::Native);
assert_eq!(c.modifier_delivery.opt, ModifierFate::Dropped);
assert!(c.modifier_delivery.benefits_from_rescue());
}
#[test]
fn apple_terminal_cmd_unrecoverable_opt_dropped() {
let c = keyboard_capabilities(TerminalName::AppleTerminal);
assert_eq!(c.modifier_delivery.cmd, ModifierFate::Unrecoverable);
assert_eq!(c.modifier_delivery.opt, ModifierFate::Dropped);
assert!(c.modifier_delivery.benefits_from_rescue());
assert!(c.enter_needs_rescue());
}
#[test]
fn unknown_brands_skip_rescue() {
for brand in [
TerminalName::Unknown,
TerminalName::GrokDesktop,
TerminalName::Vte,
TerminalName::JetBrains,
] {
assert!(
!keyboard_capabilities(brand)
.modifier_delivery
.benefits_from_rescue()
);
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,301 @@
use std::io::{self, Write};
use std::sync::atomic::{AtomicU64, Ordering};
use ratatui::layout::Rect;
use super::image::{
GraphicsProtocol, KITTY_PLACEMENT_ID, build_overlay_image_escapes_for_protocol,
clear_kitty_image, detect_graphics_protocol, fit_image_to_cells,
};
static NEXT_OWNER_ID: AtomicU64 = AtomicU64::new(1);
thread_local! {
static OWNER: std::cell::Cell<Option<u64>> = const { std::cell::Cell::new(None) };
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum Ownership {
Static(u64),
Clear,
}
#[derive(Debug)]
pub struct Escapes {
bytes: String,
ownership: Ownership,
}
impl Escapes {
pub fn as_str(&self) -> &str {
&self.bytes
}
pub fn into_string(self) -> String {
self.bytes
}
pub fn commit(self) -> String {
commit(self.ownership);
self.bytes
}
}
#[derive(Debug, Default)]
pub struct PostFlush {
bytes: String,
ownership: Option<Ownership>,
}
impl PostFlush {
pub fn plain(bytes: String) -> Self {
Self {
bytes,
ownership: None,
}
}
pub fn append(&mut self, other: Self) {
self.bytes.push_str(&other.bytes);
if let Some(ownership) = other.ownership {
self.ownership = Some(ownership);
}
}
pub fn append_plain(&mut self, bytes: &str) {
self.bytes.push_str(bytes);
}
pub fn as_str(&self) -> &str {
&self.bytes
}
pub fn write_to(self, writer: &mut impl Write) -> io::Result<()> {
writer.write_all(self.bytes.as_bytes())?;
if let Some(ownership) = self.ownership {
commit(ownership);
}
Ok(())
}
}
impl From<Escapes> for PostFlush {
fn from(escapes: Escapes) -> Self {
Self {
bytes: escapes.bytes,
ownership: Some(escapes.ownership),
}
}
}
pub(crate) fn next_owner_id() -> u64 {
NEXT_OWNER_ID.fetch_add(1, Ordering::Relaxed)
}
pub fn reset_owner() {
OWNER.with(|owner| owner.set(None));
}
fn commit(ownership: Ownership) {
OWNER.with(|owner| {
owner.set(match ownership {
Ownership::Static(id) => Some(id),
Ownership::Clear => None,
});
});
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn static_image_for_protocol(
protocol: GraphicsProtocol,
image_data: &[u8],
cols: u16,
rows: u16,
cell_x: u16,
cell_y: u16,
owner_id: u64,
) -> Option<Escapes> {
let retransmit = OWNER.with(|owner| owner.get() != Some(owner_id));
let bytes = build_overlay_image_escapes_for_protocol(
protocol, image_data, cols, rows, cell_x, cell_y, retransmit,
)?;
Some(Escapes {
bytes,
ownership: Ownership::Static(owner_id),
})
}
#[allow(clippy::too_many_arguments)]
pub fn static_image(
image_data: &[u8],
cols: u16,
rows: u16,
cell_x: u16,
cell_y: u16,
owner_id: u64,
) -> Option<Escapes> {
static_image_for_protocol(
detect_graphics_protocol(),
image_data,
cols,
rows,
cell_x,
cell_y,
owner_id,
)
}
pub fn volatile_image(
image_data: &[u8],
cols: u16,
rows: u16,
cell_x: u16,
cell_y: u16,
) -> Option<Escapes> {
let bytes = build_overlay_image_escapes_for_protocol(
detect_graphics_protocol(),
image_data,
cols,
rows,
cell_x,
cell_y,
true,
)?;
Some(Escapes {
bytes,
ownership: Ownership::Clear,
})
}
pub fn static_centered(
image_data: &[u8],
img_w: u32,
img_h: u32,
overlay_rect: Rect,
owner_id: u64,
) -> Option<Escapes> {
let (cols, rows, x, y) = centered_placement(img_w, img_h, overlay_rect)?;
static_image(image_data, cols, rows, x, y, owner_id)
}
pub fn volatile_centered(
image_data: &[u8],
img_w: u32,
img_h: u32,
overlay_rect: Rect,
) -> Option<Escapes> {
let (cols, rows, x, y) = centered_placement(img_w, img_h, overlay_rect)?;
volatile_image(image_data, cols, rows, x, y)
}
pub fn clear() -> Option<Escapes> {
match detect_graphics_protocol() {
GraphicsProtocol::Kitty => Some(clear_kitty()),
GraphicsProtocol::ITerm2 | GraphicsProtocol::None => None,
}
}
pub fn clear_kitty() -> Escapes {
Escapes {
bytes: clear_kitty_image(KITTY_PLACEMENT_ID),
ownership: Ownership::Clear,
}
}
fn centered_placement(img_w: u32, img_h: u32, overlay_rect: Rect) -> Option<(u16, u16, u16, u16)> {
let max_cols = overlay_rect.width.saturating_sub(2);
let max_rows = overlay_rect.height.saturating_sub(2);
if max_cols < 4 || max_rows < 2 {
return None;
}
let (cols, rows) = fit_image_to_cells(img_w, img_h, max_cols, max_rows);
let x = overlay_rect.x + 1 + max_cols.saturating_sub(cols) / 2;
let y = overlay_rect.y + 1 + max_rows.saturating_sub(rows) / 2;
Some((cols, rows, x, y))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::terminal::image::set_protocol_for_test;
fn png() -> [u8; 8] {
[0x89, b'P', b'N', b'G', b'\r', b'\n', 0x1a, b'\n']
}
#[test]
fn static_owner_reuses_consecutive_frames_after_commit() {
let _guard = set_protocol_for_test(GraphicsProtocol::Kitty);
reset_owner();
let first = static_image(&png(), 20, 10, 0, 0, 11).unwrap();
assert!(first.as_str().contains("a=t"));
let _ = first.commit();
let second = static_image(&png(), 20, 10, 0, 0, 11).unwrap();
assert!(!second.as_str().contains("a=t"));
}
#[test]
fn discarded_clear_does_not_invalidate_static_owner() {
let _guard = set_protocol_for_test(GraphicsProtocol::Kitty);
reset_owner();
let _ = static_image(&png(), 20, 10, 0, 0, 11).unwrap().commit();
let _discarded = clear().unwrap();
let next = static_image(&png(), 20, 10, 0, 0, 11).unwrap();
assert!(!next.as_str().contains("a=t"));
}
#[test]
fn discarded_static_escape_does_not_replace_owner() {
let _guard = set_protocol_for_test(GraphicsProtocol::Kitty);
reset_owner();
let _ = static_image(&png(), 20, 10, 0, 0, 11).unwrap().commit();
let _discarded = static_image(&png(), 20, 10, 0, 0, 12).unwrap();
let next = static_image(&png(), 20, 10, 0, 0, 11).unwrap();
assert!(!next.as_str().contains("a=t"));
}
#[test]
fn failed_post_flush_write_does_not_commit_transition() {
struct FailingWriter;
impl std::io::Write for FailingWriter {
fn write(&mut self, _buf: &[u8]) -> std::io::Result<usize> {
Err(std::io::Error::other("injected write failure"))
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
let _guard = set_protocol_for_test(GraphicsProtocol::Kitty);
reset_owner();
let _ = static_image(&png(), 20, 10, 0, 0, 11).unwrap().commit();
let clear = PostFlush::from(clear().unwrap());
assert!(clear.write_to(&mut FailingWriter).is_err());
let next = static_image(&png(), 20, 10, 0, 0, 11).unwrap();
assert!(!next.as_str().contains("a=t"));
}
#[test]
fn committed_clear_and_volatile_frame_invalidate_owner() {
let _guard = set_protocol_for_test(GraphicsProtocol::Kitty);
reset_owner();
let _ = static_image(&png(), 20, 10, 0, 0, 11).unwrap().commit();
let _ = clear().unwrap().commit();
assert!(
static_image(&png(), 20, 10, 0, 0, 11)
.unwrap()
.as_str()
.contains("a=t")
);
let _ = static_image(&png(), 20, 10, 0, 0, 11).unwrap().commit();
let _ = volatile_image(&png(), 20, 10, 0, 0).unwrap().commit();
assert!(
static_image(&png(), 20, 10, 0, 0, 11)
.unwrap()
.as_str()
.contains("a=t")
);
}
}
@@ -0,0 +1,159 @@
//! Shared startup terminal-probe primitive: write a query, and (OSC 11
//! only) raw-fd poll/read stdin until a terminator or deadline.
//! XTVERSION uses only `write_query`;
//! its reply is handled by the event loop's response filter.
//!
//! Safety invariants (timed-read path):
//! - Startup-only: must run before crossterm's `EventStream` exists (both
//! compete for stdin).
//! - Keystrokes typed inside the read window are consumed and dropped — no
//! portable re-injection exists (TIOCSTI is blocked); accepted loss.
use std::io::Write;
use std::time::Duration;
/// Bounds the reply buffer against terminals that stream without a terminator.
#[cfg(unix)]
pub(crate) const MAX_PROBE_RESPONSE: usize = 256;
/// Hard cap on post-deadline consumption of an in-flight reply.
#[cfg(unix)]
const LATE_REPLY_GRACE: Duration = Duration::from_millis(100);
/// Per-byte quiet window during the grace period.
#[cfg(unix)]
const LATE_REPLY_QUIET_MS: i32 = 25;
/// Write a probe query via the shared stderr lock; `false` if the TUI fd is
/// not a TTY or the write fails.
pub(crate) fn write_query(query: &[u8]) -> bool {
use std::io::IsTerminal;
let write_result: std::io::Result<()> = kigi_shared::stderr::with_locked_stderr(|stderr| {
// fd 2 is /dev/null-redirected; the TTY check must run on the
// dup'd render fd inside the lock, not on std::io::stderr().
if !stderr.is_terminal() {
return Err(std::io::Error::other("TUI output is not a TTY"));
}
stderr.write_all(query)?;
stderr.flush()
});
write_result.is_ok()
}
/// Read stdin until `is_terminated`, the size cap, or the deadline.
///
/// Returns `Some(buf)` whenever bytes were consumed (even partial, so a
/// half-read reply is never left for the EventStream); `None` when nothing
/// arrived or stdin errored before any byte.
#[cfg(unix)]
pub(crate) fn read_tty_reply(
timeout: Duration,
mut is_terminated: impl FnMut(&[u8], u8) -> bool,
) -> Option<Vec<u8>> {
use std::os::unix::io::AsRawFd;
let fd = std::io::stdin().as_raw_fd();
let start = std::time::Instant::now();
let mut buf: Vec<u8> = Vec::with_capacity(64);
loop {
let Some(remaining) = timeout.checked_sub(start.elapsed()) else {
return finish_after_deadline(fd, buf, is_terminated);
};
let remaining_ms = remaining.as_millis().min(i32::MAX as u128) as i32;
match poll_read_byte(fd, remaining_ms) {
PollRead::Byte(byte) => {
buf.push(byte);
if buf.len() >= MAX_PROBE_RESPONSE || is_terminated(&buf, byte) {
return Some(buf);
}
}
// Re-entry recomputes the deadline, so EINTR cannot extend it.
PollRead::Interrupted => continue,
PollRead::Timeout => return finish_after_deadline(fd, buf, is_terminated),
PollRead::Error => return if buf.is_empty() { None } else { Some(buf) },
}
}
}
/// Deadline expiry: an in-flight reply (ESC byte seen — replies are
/// DCS/CSI/OSC, plain keystrokes aren't) is consumed until quiet so its
/// tail can't reach the EventStream as typed garbage; otherwise return
/// immediately to avoid eating keystrokes at a silent terminal.
#[cfg(unix)]
fn finish_after_deadline(
fd: i32,
mut buf: Vec<u8>,
mut is_terminated: impl FnMut(&[u8], u8) -> bool,
) -> Option<Vec<u8>> {
if buf.is_empty() {
return None;
}
if !buf.contains(&0x1b) {
return Some(buf);
}
let grace_start = std::time::Instant::now();
while grace_start.elapsed() < LATE_REPLY_GRACE {
match poll_read_byte(fd, LATE_REPLY_QUIET_MS) {
PollRead::Byte(byte) => {
buf.push(byte);
if buf.len() >= MAX_PROBE_RESPONSE || is_terminated(&buf, byte) {
break;
}
}
PollRead::Interrupted => continue,
PollRead::Timeout | PollRead::Error => break,
}
}
Some(buf)
}
#[cfg(unix)]
enum PollRead {
Byte(u8),
Interrupted,
Timeout,
Error,
}
/// One EINTR-retrying poll-then-read step for a single byte.
#[cfg(unix)]
fn poll_read_byte(fd: i32, timeout_ms: i32) -> PollRead {
let mut pfd = libc::pollfd {
fd,
events: libc::POLLIN,
revents: 0,
};
// SAFETY: pfd is a valid pollfd struct with a valid fd.
let ret = unsafe { libc::poll(&mut pfd, 1, timeout_ms) };
if ret == 0 {
return PollRead::Timeout;
}
if ret < 0 {
return if last_errno_is_eintr() {
PollRead::Interrupted
} else {
PollRead::Error
};
}
loop {
let mut byte = [0u8; 1];
// SAFETY: byte is a valid buffer of length 1.
let n = unsafe { libc::read(fd, byte.as_mut_ptr().cast(), 1) };
if n == 1 {
return PollRead::Byte(byte[0]);
}
if n < 0 && last_errno_is_eintr() {
continue;
}
return PollRead::Error;
}
}
#[cfg(unix)]
fn last_errno_is_eintr() -> bool {
std::io::Error::last_os_error().raw_os_error() == Some(libc::EINTR)
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,212 @@
//! Runtime XTVERSION probe (`CSI > 0 q` → `DCS > | text ST`), run when
//! env-based brand detection yields Unknown (SSH, plain xterm) or a
//! headfully-validated allowlisted brand (see [`gate_allows_probe`]).
//!
//! Fire-and-forget, parser-integrated model (as in helix and similar TUIs):
//! the query is written once at startup with no timed read; the reply is
//! recognized and swallowed by the event loop's `XtversionFilter` whenever
//! it arrives.
//!
//! Safety invariants:
//! - Query write must happen after `enable_raw_mode()` and before the
//! `EventStream` filter is constructed.
//! - Accepted residuals: SSH *from* JediTerm still probes (its env marker
//! doesn't cross SSH) and leaks the query there; a reply whose first
//! event arrives only after the filter's 5s arm window types as
//! Alt+Shift+P + literal text; on a silent terminal with a fully idle
//! session the `OnceLock` stays unset (`record_no_reply` only runs from
//! the filter, which only runs on input) — `detected()` is None either
//! way, so both consumers are unaffected.
use std::sync::OnceLock;
use std::sync::atomic::{AtomicBool, Ordering};
/// Startup probe outcome.
#[derive(Debug)]
enum ProbeResult {
Skipped,
NoReply,
Identified(String),
}
/// 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
/// ordering here, and a stale unsolicited DA1 reply could mis-answer a
/// future crossterm DA1-waiting probe.
#[cfg(unix)]
const QUERY: &[u8] = b"\x1b[>0q";
/// Returns the terminal's self-reported name/version, if the terminal
/// answered (e.g. `"kitty 0.35.2"`, `"foot(1.22.0)"`).
pub fn detected() -> Option<&'static str> {
match XTVERSION.get() {
Some(ProbeResult::Identified(v)) => Some(v),
_ => None,
}
}
/// True when the query was sent and no reply has been recorded yet — the
/// event loop arms its response filter on this.
pub fn reply_pending() -> bool {
QUERY_SENT.load(Ordering::Relaxed) && XTVERSION.get().is_none()
}
/// Record the DCS payload recognized by the event-loop filter.
pub fn record_reply(payload: &str) {
let result = match sanitize_payload(payload) {
Some(v) => ProbeResult::Identified(v),
None => ProbeResult::NoReply,
};
tracing::info!(?result, "XTVERSION probe");
let _ = XTVERSION.set(result);
}
/// Record that the filter disarmed without seeing a reply. Only invoked
/// from the filter on input, so a fully idle session can leave the
/// `OnceLock` unset (benign — see module doc).
pub fn record_no_reply() {
if XTVERSION.set(ProbeResult::NoReply).is_ok() {
tracing::info!("XTVERSION probe: no reply");
}
}
/// Send the XTVERSION query once at startup (fire-and-forget); no-ops when
/// the gate rejects the brand/multiplexer or stdin is not a TTY.
pub fn probe_at_startup() {
use std::io::IsTerminal;
if XTVERSION.get().is_some() || QUERY_SENT.load(Ordering::Relaxed) {
return;
}
let ctx = super::terminal_context();
if !gate_allows_probe(ctx) || !std::io::stdin().is_terminal() {
let _ = XTVERSION.set(ProbeResult::Skipped);
return;
}
send_query();
}
/// Crush-style brand allowlist: Unknown plus brands headfully validated as
/// clean XTVERSION responders (version fidelity is the payoff there).
/// CSI-intercepting multiplexers skip — the innermost layer answers as
/// itself, which the `multiplexer` field already records. Transparent muxes
/// (e.g. cmux) need no special case.
fn gate_allows_probe(ctx: &super::TerminalContext) -> bool {
use super::TerminalName::*;
matches!(
ctx.brand,
Unknown | Kitty | WezTerm | Ghostty | Iterm2 | Rio
) && !ctx.multiplexer.intercepts_csi_queries()
}
#[cfg(unix)]
fn send_query() {
if super::probe::write_query(QUERY) {
QUERY_SENT.store(true, Ordering::Relaxed);
} else {
// Brand-Unknown TTY whose query can't reach the terminal is a
// feedback-triage signal worth tracing.
tracing::debug!("XTVERSION probe skipped: query write failed or output is not a TTY");
let _ = XTVERSION.set(ProbeResult::Skipped);
}
}
#[cfg(not(unix))]
fn send_query() {
// ConPTY does not implement XTVERSION.
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();
if cleaned.is_empty() {
None
} else {
Some(cleaned)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sanitize_plain_payload() {
assert_eq!(
sanitize_payload("kitty 0.35.2").as_deref(),
Some("kitty 0.35.2")
);
assert_eq!(
sanitize_payload("XTerm(388)").as_deref(),
Some("XTerm(388)")
);
}
#[test]
fn sanitize_strips_controls_and_whitespace() {
assert_eq!(
sanitize_payload(" We\x01zTerm 2.0 ").as_deref(),
Some("WezTerm 2.0")
);
}
#[test]
fn sanitize_empty_is_none() {
assert_eq!(sanitize_payload(""), None);
assert_eq!(sanitize_payload(" \x07 "), None);
}
#[test]
fn gate_allows_unknown_and_allowlisted_brands() {
use crate::terminal::{MultiplexerKind, TerminalContext, TerminalName};
let ctx = |brand, multiplexer| TerminalContext {
brand,
multiplexer,
..Default::default()
};
for brand in [
TerminalName::Unknown,
TerminalName::Kitty,
TerminalName::WezTerm,
TerminalName::Ghostty,
TerminalName::Iterm2,
TerminalName::Rio,
] {
assert!(
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"
);
}
// JediTerm renders the query as garbage and must never be probed.
assert!(!gate_allows_probe(&ctx(
TerminalName::JetBrains,
MultiplexerKind::Undetected
)));
}
// Sets the process-global OnceLock — safe under nextest's
// process-per-test isolation.
#[test]
fn diagnostics_snapshot_includes_recorded_reply() {
record_reply("PtyHarnessTerm 9.9");
let t = crate::terminal::terminal_context().diagnostics_snapshot();
assert_eq!(t.xtversion, "PtyHarnessTerm 9.9");
}
}
@@ -0,0 +1,655 @@
//! In-memory theme cache + resolution.
//!
//! The pager reads the active `ThemeKind` on every render frame, so the
//! lookup must be cheaper than re-loading from `~/.kigi/config.toml`.
//! [`current_kind`] returns the in-memory value, lazily seeding from the
//! shell's layered effective config on first call.
//!
//! Disk writes are NOT performed here — they live in
//! `kigi_shell::util::config::set_theme()` (and friends), invoked
//! via `Effect::PersistSetting` from the dispatcher. This module is a
//! pager-side in-memory cache + resolution layer only.
use std::sync::Mutex;
use std::sync::atomic::{AtomicBool, AtomicU8, Ordering};
use super::ThemeKind;
use super::system_appearance;
/// In-memory theme kind, encoded as a `u8` matching the
/// `ThemeKind` discriminants. Loaded from disk once at startup via
/// `load_from_disk()`, then kept in sync by `set()`.
static CURRENT: AtomicU8 = AtomicU8::new(ThemeKind::GrokNight as u8);
static LOADED: AtomicBool = AtomicBool::new(false);
#[cfg(any(test, feature = "test-support"))]
static TEST_LOCK: Mutex<()> = Mutex::new(());
/// Whether auto-switching mode is active. Set when the config file
/// contains `theme = "auto"`. Checked by the event loop to decide
/// whether the `SystemAppearanceWatcher` should run.
///
/// Uses `AtomicBool` for thread-safe access from the watcher task.
static AUTO_MODE: AtomicBool = AtomicBool::new(false);
/// Whether the theme is locked to `Theme::terminal_default` for the whole
/// session (minimal mode — no theming).
static TERMINAL_NATIVE_LOCK: AtomicBool = AtomicBool::new(false);
/// Decode the u8 stored in `CURRENT` back to a `ThemeKind`. Falls
/// back to `GrokNight` if the byte is somehow out of range (which
/// can't happen via `set` — the discriminant is always a valid
/// variant — but defends against a future variant addition that
/// forgot to extend this match).
fn theme_kind_from_u8(byte: u8) -> ThemeKind {
match byte {
x if x == ThemeKind::GrokNight as u8 => ThemeKind::GrokNight,
x if x == ThemeKind::GrokDay as u8 => ThemeKind::GrokDay,
x if x == ThemeKind::TokyoNight as u8 => ThemeKind::TokyoNight,
x if x == ThemeKind::RosePineMoon as u8 => ThemeKind::RosePineMoon,
x if x == ThemeKind::OscuraMidnight as u8 => ThemeKind::OscuraMidnight,
x if x == ThemeKind::Auto as u8 => ThemeKind::Auto,
_ => ThemeKind::GrokNight,
}
}
/// Cached auto-theme configuration (which themes map to dark/light).
///
/// Uses `Mutex<Option<_>>` rather than `OnceLock` so the cache can be
/// invalidated when the user changes mappings via the settings modal
/// or the `/theme auto` slash command.
static AUTO_THEME_CONFIG: Mutex<Option<AutoThemeConfig>> = Mutex::new(None);
/// Auto-theme config: which themes map to dark/light system appearance.
///
/// `dark_theme` and `light_theme` are the user-configured overrides read
/// from `[ui].auto_dark_theme` and `[ui].auto_light_theme` in `config.toml`.
/// When `None`, `to_theme_kind()` defaults to `GrokNight` / `GrokDay`.
#[derive(Debug, Clone, Copy, Default)]
pub struct AutoThemeConfig {
pub dark_theme: Option<ThemeKind>,
pub light_theme: Option<ThemeKind>,
}
/// Get the current theme kind.
///
/// On the first call, reads from `~/.kigi/config.toml` (via the shell's
/// `load_effective_config`). After that, returns the in-memory value
/// (updated by [`set`]).
pub fn current_kind() -> ThemeKind {
// Locked: return a constant nominal kind without seeding from disk.
if terminal_native_locked() {
return ThemeKind::GrokNight;
}
if !LOADED.load(Ordering::Acquire) {
// Two threads racing into the seed path is harmless — the
// disk read is idempotent and `store` is atomic. Worst case
// both threads call `load_from_disk` once.
if let Some(kind) = load_from_disk() {
CURRENT.store(kind as u8, Ordering::Relaxed);
}
LOADED.store(true, Ordering::Release);
}
theme_kind_from_u8(CURRENT.load(Ordering::Relaxed))
}
/// Set the in-memory theme kind without writing to disk.
///
/// Used by the dispatcher (after `Action::SetTheme` is processed) and
/// by the live-preview path during the picker. Disk-write happens via
/// `Effect::PersistSetting`, NOT here.
pub fn set(kind: ThemeKind) {
CURRENT.store(kind as u8, Ordering::Relaxed);
LOADED.store(true, Ordering::Release);
}
// -- Terminal-native lock (minimal mode) --------------------------------------
/// Whether the theme is locked to the terminal-native palette.
#[must_use]
pub fn terminal_native_locked() -> bool {
TERMINAL_NATIVE_LOCK.load(Ordering::Relaxed)
}
/// Engage or clear the terminal-native theme lock.
pub fn set_terminal_native_lock(locked: bool) {
TERMINAL_NATIVE_LOCK.store(locked, Ordering::Relaxed);
kigi_markdown::set_color_level_cap(if locked {
kigi_markdown::ColorLevel::Basic
} else {
kigi_markdown::ColorLevel::TrueColor
});
}
// -- Auto-mode ---------------------------------------------------------------
/// Whether auto-switching mode is active.
#[must_use]
pub fn is_auto_mode() -> bool {
AUTO_MODE.load(Ordering::Relaxed)
}
/// Set or clear auto-switching mode.
pub fn set_auto_mode(enabled: bool) {
AUTO_MODE.store(enabled, Ordering::Relaxed);
}
/// Get the cached auto-theme configuration, loading from config on first access.
///
/// The cache can be invalidated via [`invalidate_auto_theme_config`] so
/// subsequent lookups re-read from disk.
#[must_use]
pub fn auto_theme_config() -> AutoThemeConfig {
let mut guard = AUTO_THEME_CONFIG.lock().unwrap_or_else(|e| e.into_inner());
*guard.get_or_insert_with(load_auto_theme_config)
}
/// Invalidate the cached auto-theme configuration.
///
/// Call after updating `auto_dark_theme` or `auto_light_theme` in config
/// so subsequent lookups see the new values. Used by the settings modal
/// and the `/theme auto` slash command.
pub fn invalidate_auto_theme_config() {
*AUTO_THEME_CONFIG.lock().unwrap_or_else(|e| e.into_inner()) = None;
}
// -- Theme resolution --------------------------------------------------------
/// Resolve the effective theme, respecting the full precedence chain.
///
/// Called once at startup. Returns the concrete `ThemeKind` (never `Auto`).
///
/// Precedence:
/// 1. Environment variable (`KIGI_THEME`)
/// 2. Config file (`[ui].theme`)
/// 3. Default: `GrokNight`
#[must_use]
pub fn resolve_initial_theme() -> ThemeKind {
// 1. Environment variable (for desktop app integration)
// 2. Config file + 3. Default
resolve_from_config(load_from_disk(), true)
}
/// Inner resolution logic, factored out for testability.
fn resolve_from_config(config_theme: Option<ThemeKind>, osc11_fallback: bool) -> ThemeKind {
if let Some(kind) = config_theme {
if kind.is_auto() {
set_auto_mode(true);
let appearance = if osc11_fallback {
system_appearance::detect_with_osc11_fallback()
} else {
system_appearance::detect()
};
return resolve_from_appearance(appearance);
}
return kind;
}
// Default: GrokNight
ThemeKind::GrokNight
}
/// Map an optional appearance detection result to a concrete `ThemeKind`.
fn resolve_from_appearance(appearance: Option<system_appearance::SystemAppearance>) -> ThemeKind {
let config = auto_theme_config();
appearance
.map(|a| system_appearance::to_theme_kind(a, config.dark_theme, config.light_theme))
.unwrap_or(ThemeKind::GrokNight)
}
/// Resolve "auto" by detecting system appearance and mapping via config.
///
/// Returns the concrete `ThemeKind` based on the current system appearance
/// and the user's dark/light theme mapping. Falls back to `GrokNight`
/// when detection fails.
///
/// Uses desktop APIs only (no OSC 11) — safe to call at runtime while
/// crossterm's `EventStream` is active. Called from the settings modal
/// and the `/theme auto` slash command.
#[must_use]
pub fn resolve_auto() -> ThemeKind {
resolve_from_appearance(system_appearance::detect())
}
/// Variant of [`resolve_initial_theme`] without the OSC 11 startup
/// fallback, for resolution after the terminal is initialized.
#[must_use]
pub fn resolve_initial_theme_no_osc11() -> ThemeKind {
resolve_from_config(load_from_disk(), false)
}
// -- Disk reads --------------------------------------------------------------
//
// All writes go through `kigi_shell::util::config::set_theme()` (and
// friends) via `Effect::PersistSetting`. This module only READS from the
// shell's layered effective config.
/// Read the theme from the effective config (managed_config.toml merged
/// under config.toml — user wins).
///
/// Checks `[ui].theme` first (the canonical location), then falls back
/// to a top-level `theme` key for backwards compatibility.
fn load_from_disk() -> Option<ThemeKind> {
let root = kigi_config::load_effective_config_disk_only().ok()?;
let table = root.as_table()?;
// Canonical: [ui] section
let value = table
.get("ui")
.and_then(|ui| ui.get("theme"))
.and_then(|v| v.as_str())
// Fallback: top-level `theme` key (legacy)
.or_else(|| table.get("theme").and_then(|v| v.as_str()));
value.and_then(ThemeKind::from_name)
}
/// Load auto-theme configuration from the effective config.
///
/// Reads `[ui].auto_dark_theme` and `[ui].auto_light_theme`, parsing them
/// as theme names. Filters out `Auto` to prevent circular reference.
fn load_auto_theme_config() -> AutoThemeConfig {
let Ok(root) = kigi_config::load_effective_config_disk_only() else {
return AutoThemeConfig::default();
};
let Some(table) = root.as_table() else {
return AutoThemeConfig::default();
};
let ui = table.get("ui");
AutoThemeConfig {
dark_theme: ui
.and_then(|u| u.get("auto_dark_theme"))
.and_then(|v| v.as_str())
.and_then(ThemeKind::from_name)
.filter(|k| !k.is_auto()),
light_theme: ui
.and_then(|u| u.get("auto_light_theme"))
.and_then(|v| v.as_str())
.and_then(ThemeKind::from_name)
.filter(|k| !k.is_auto()),
}
}
// -- Test support ------------------------------------------------------------
#[cfg(any(test, feature = "test-support"))]
pub fn reset_for_test() {
// Tests are serialized via TEST_LOCK so the AtomicU8/AtomicBool
// pair is safe to reset without any cross-thread coordination.
CURRENT.store(ThemeKind::GrokNight as u8, Ordering::Relaxed);
LOADED.store(false, Ordering::Release);
AUTO_MODE.store(false, Ordering::Relaxed);
set_terminal_native_lock(false);
*AUTO_THEME_CONFIG.lock().unwrap_or_else(|e| e.into_inner()) = None;
}
/// Seed `AUTO_THEME_CONFIG` with explicit defaults so `auto_theme_config()`
/// never falls through to `load_auto_theme_config()` (which reads the
/// user's real `config.toml`). Call from test setup after `reset_for_test()`.
#[cfg(any(test, feature = "test-support"))]
pub fn seed_auto_theme_defaults_for_test() {
*AUTO_THEME_CONFIG.lock().unwrap_or_else(|e| e.into_inner()) = Some(AutoThemeConfig::default());
}
#[cfg(any(test, feature = "test-support"))]
pub fn test_lock() -> &'static Mutex<()> {
&TEST_LOCK
}
/// Pin a deterministic theme + color level for a test's duration so exact
/// height / screen-position assertions are hermetic. Rendered heights are
/// computed under the process-global `Theme::current()` (which concurrent
/// `set_theme` tests mutate) and `Theme::current()` reads the global color
/// level; holding the shared test lock blocks a mid-test theme change. Hold the
/// returned guard for the whole test.
#[cfg(any(test, feature = "test-support"))]
pub fn pin_theme() -> std::sync::MutexGuard<'static, ()> {
let guard = test_lock().lock().unwrap_or_else(|e| e.into_inner());
set(ThemeKind::GrokNight);
// Color level is a write-once `OnceLock`; tests run without a TTY so it
// resolves to `TrueColor` anyway. Pin it explicitly (best-effort: ignore the
// already-initialized `Err`) so the measure path that reads it stays fixed.
let _ = super::color_support::set(super::color_support::ColorLevel::TrueColor);
guard
}
#[cfg(test)]
mod tests {
use super::*;
/// Helper: run a test body while holding the global test lock and
/// with a clean initial state.
fn with_test_env(f: impl FnOnce()) {
let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
reset_for_test();
seed_auto_theme_defaults_for_test();
// Set LOADED=true so current_kind() doesn't read from disk.
set(ThemeKind::GrokNight);
system_appearance::clear_mock();
f();
system_appearance::clear_mock();
reset_for_test();
}
/// Pre-populate the auto-theme config cache for testing.
fn set_test_auto_config(config: AutoThemeConfig) {
*AUTO_THEME_CONFIG.lock().unwrap_or_else(|e| e.into_inner()) = Some(config);
}
// -- Terminal-native lock (minimal mode) ----------------------------------
#[test]
fn terminal_native_lock_pins_kind_and_blocks_apply_kind() {
with_test_env(|| {
set(ThemeKind::GrokDay);
set_terminal_native_lock(true);
assert!(terminal_native_locked());
assert_eq!(current_kind(), ThemeKind::GrokNight, "nominal kind");
let applied = super::super::Theme::apply_kind(ThemeKind::GrokDay);
assert_eq!(applied, ThemeKind::GrokNight, "apply_kind must no-op");
assert_eq!(current_kind(), ThemeKind::GrokNight);
set_terminal_native_lock(false);
assert_eq!(
current_kind(),
ThemeKind::GrokDay,
"unlocking restores the cached kind"
);
});
}
#[test]
fn terminal_native_lock_serves_terminal_default_palette() {
with_test_env(|| {
set(ThemeKind::GrokDay);
set_terminal_native_lock(true);
let theme = super::super::Theme::current();
let native = super::super::Theme::terminal_default();
assert_eq!(theme.bg_base, native.bg_base);
assert_eq!(theme.text_primary, native.text_primary);
assert_eq!(theme.accent_user, native.accent_user);
assert_ne!(
theme.text_primary,
super::super::Theme::grokday().text_primary,
"must not serve the cached (GrokDay) theme"
);
});
}
#[test]
fn reset_for_test_clears_terminal_native_lock() {
with_test_env(|| {
set_terminal_native_lock(true);
reset_for_test();
assert!(!terminal_native_locked());
});
}
#[test]
fn terminal_native_lock_caps_quantize_at_ansi16() {
use ratatui::style::Color;
use crate::theme::color_support;
with_test_env(|| {
set_terminal_native_lock(true);
assert!(color_support::detect() <= color_support::ColorLevel::Basic);
for input in [
Color::Rgb(0x26, 0x26, 0x26), // grokday text_primary
Color::Rgb(122, 162, 247),
Color::Indexed(141),
] {
let q = color_support::quantize(input);
assert!(
!matches!(q, Color::Rgb(..) | Color::Indexed(_)),
"quantize({input:?}) must collapse to Reset/named ANSI under \
the lock, got {q:?}"
);
}
});
}
#[test]
fn resolve_no_osc11_explicit_auto_and_default() {
with_test_env(|| {
assert_eq!(
resolve_from_config(Some(ThemeKind::GrokDay), false),
ThemeKind::GrokDay
);
assert!(!is_auto_mode());
system_appearance::set_mock(Some(system_appearance::SystemAppearance::Light));
assert_eq!(
resolve_from_config(Some(ThemeKind::Auto), false),
ThemeKind::GrokDay
);
assert!(is_auto_mode(), "auto must arm the appearance watcher");
assert_eq!(resolve_from_config(None, false), ThemeKind::GrokNight);
});
}
// -- AUTO_MODE -----------------------------------------------------------
#[test]
fn auto_mode_default_is_false() {
with_test_env(|| {
assert!(!is_auto_mode());
});
}
#[test]
fn set_auto_mode_toggles() {
with_test_env(|| {
set_auto_mode(true);
assert!(is_auto_mode());
set_auto_mode(false);
assert!(!is_auto_mode());
});
}
// -- AutoThemeConfig -----------------------------------------------------
#[test]
fn auto_theme_config_defaults_to_none() {
let config = AutoThemeConfig::default();
assert!(config.dark_theme.is_none());
assert!(config.light_theme.is_none());
}
// -- resolve_auto --------------------------------------------------------
#[test]
fn resolve_auto_dark_system_returns_groknight() {
with_test_env(|| {
system_appearance::set_mock(Some(system_appearance::SystemAppearance::Dark));
let result = resolve_auto();
assert_eq!(result, ThemeKind::GrokNight);
});
}
#[test]
fn resolve_auto_light_system_returns_grokday() {
with_test_env(|| {
system_appearance::set_mock(Some(system_appearance::SystemAppearance::Light));
let result = resolve_auto();
assert_eq!(result, ThemeKind::GrokDay);
});
}
#[test]
fn resolve_auto_detection_failure_returns_groknight() {
with_test_env(|| {
system_appearance::set_mock(None);
let result = resolve_auto();
assert_eq!(result, ThemeKind::GrokNight);
});
}
// -- invalidate_auto_theme_config ----------------------------------------
#[test]
fn invalidate_clears_cached_config() {
with_test_env(|| {
// Pre-populate the cache with a known config.
set_test_auto_config(AutoThemeConfig {
dark_theme: Some(ThemeKind::TokyoNight),
light_theme: None,
});
let config1 = auto_theme_config();
assert_eq!(config1.dark_theme, Some(ThemeKind::TokyoNight));
// Invalidate — next read re-loads (defaults in test env).
invalidate_auto_theme_config();
// Pre-populate again with defaults to avoid disk dependency.
set_test_auto_config(AutoThemeConfig::default());
let config2 = auto_theme_config();
assert!(config2.dark_theme.is_none());
});
}
// -- resolve_from_config (resolve_initial_theme inner logic) ---------------
#[test]
fn resolve_from_config_no_config_returns_groknight() {
with_test_env(|| {
let result = resolve_from_config(None, true);
assert_eq!(result, ThemeKind::GrokNight);
assert!(!is_auto_mode());
});
}
#[test]
fn resolve_from_config_explicit_theme_returns_it() {
with_test_env(|| {
let result = resolve_from_config(Some(ThemeKind::GrokDay), true);
assert_eq!(result, ThemeKind::GrokDay);
assert!(
!is_auto_mode(),
"explicit theme should not enable auto mode"
);
});
}
#[test]
fn resolve_from_config_auto_sets_auto_mode_dark() {
with_test_env(|| {
system_appearance::set_mock(Some(system_appearance::SystemAppearance::Dark));
let result = resolve_from_config(Some(ThemeKind::Auto), true);
assert_eq!(result, ThemeKind::GrokNight);
assert!(is_auto_mode(), "auto config must enable auto mode");
});
}
#[test]
fn resolve_from_config_auto_with_light_system() {
with_test_env(|| {
system_appearance::set_mock(Some(system_appearance::SystemAppearance::Light));
let result = resolve_from_config(Some(ThemeKind::Auto), true);
assert_eq!(result, ThemeKind::GrokDay);
assert!(is_auto_mode());
});
}
#[test]
fn resolve_from_config_auto_detection_failure() {
with_test_env(|| {
system_appearance::set_mock(None);
let result = resolve_from_config(Some(ThemeKind::Auto), true);
assert_eq!(result, ThemeKind::GrokNight);
assert!(is_auto_mode(), "auto mode is set before detection");
});
}
// -- resolve_auto with custom config -------------------------------------
#[test]
fn resolve_auto_with_custom_dark_config() {
with_test_env(|| {
set_test_auto_config(AutoThemeConfig {
dark_theme: Some(ThemeKind::TokyoNight),
light_theme: None,
});
system_appearance::set_mock(Some(system_appearance::SystemAppearance::Dark));
assert_eq!(resolve_auto(), ThemeKind::TokyoNight);
});
}
#[test]
fn resolve_auto_with_custom_light_config() {
with_test_env(|| {
set_test_auto_config(AutoThemeConfig {
dark_theme: None,
light_theme: Some(ThemeKind::RosePineMoon),
});
system_appearance::set_mock(Some(system_appearance::SystemAppearance::Light));
assert_eq!(resolve_auto(), ThemeKind::RosePineMoon);
});
}
// -- auto_theme_config filter --------------------------------------------
#[test]
fn auto_theme_config_filter_rejects_auto_value() {
// Simulates the .filter(|k| !k.is_auto()) guard in load_auto_theme_config().
// When config contains auto_dark_theme = "auto", from_name returns Some(Auto),
// but the filter discards it to prevent circular reference.
let parsed = ThemeKind::from_name("auto").filter(|k| !k.is_auto());
assert!(parsed.is_none(), "Auto must be filtered out");
}
#[test]
fn auto_theme_config_filter_accepts_concrete_theme() {
let parsed = ThemeKind::from_name("tokyonight").filter(|k| !k.is_auto());
assert_eq!(parsed, Some(ThemeKind::TokyoNight));
}
// -- 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.
/// The optimistic-update invariant the dispatcher relies on.
///
/// Explicitly observe the `LOADED` flag
/// side-effect by calling `reset_for_test()` between sets — if
/// `set` didn't flip `LOADED = true`, the second `current_kind`
/// read would re-seed from disk and the assertion would fail.
#[test]
fn set_then_current_kind_round_trips() {
with_test_env(|| {
set(ThemeKind::TokyoNight);
assert_eq!(current_kind(), ThemeKind::TokyoNight);
set(ThemeKind::GrokDay);
assert_eq!(current_kind(), ThemeKind::GrokDay);
});
}
/// `set` flips `LOADED` so a subsequent `current_kind` read does
/// NOT re-seed from disk. Mirror of the
/// `set_then_current_kind_round_trips` test that the docstring
/// claims to enforce — exercises the `LOADED` flag invariant
/// directly via the atomic statics.
#[test]
fn set_flips_loaded_flag_so_current_kind_skips_disk_reseed() {
with_test_env(|| {
// with_test_env seeds LOADED=true to prevent disk reads;
// this test specifically needs LOADED=false to verify that
// set() flips it.
LOADED.store(false, Ordering::Release);
assert!(
!LOADED.load(Ordering::Acquire),
"LOADED must be false for this test"
);
set(ThemeKind::GrokDay);
assert!(
LOADED.load(Ordering::Acquire),
"set must flip LOADED to true"
);
// Subsequent current_kind read returns the set value (no
// disk re-seed).
assert_eq!(current_kind(), ThemeKind::GrokDay);
assert!(
LOADED.load(Ordering::Acquire),
"current_kind must NOT flip LOADED back to false"
);
});
}
}
@@ -0,0 +1,357 @@
//! Terminal color support detection and quantization.
//!
//! Detects the terminal's color capabilities (truecolor / 256 / 16 / none) and
//! provides a [`quantize_color`] function that downgrades a [`ratatui::style::Color`]
//! to the highest level the terminal supports.
//!
//! The detected level is cached in a global [`OnceLock`] — call [`detect`] once
//! at startup, then use [`get`] everywhere else.
use std::sync::OnceLock;
use ratatui::style::Color;
use crate::render::color::{indexed_to_rgb, nearest_indexed};
use crate::terminal::{TerminalName, terminal_context};
/// Terminal color support level (ordered low → high).
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum ColorLevel {
/// No color support (monochrome).
None,
/// Basic 16-color ANSI (SGR 3037 / 9097).
Basic,
/// 256-color indexed palette (SGR 38;5;N).
Ansi256,
/// 24-bit truecolor RGB (SGR 38;2;R;G;B).
TrueColor,
}
impl ColorLevel {
pub fn has_color(self) -> bool {
self >= Self::Basic
}
pub fn has_256(self) -> bool {
self >= Self::Ansi256
}
pub fn has_truecolor(self) -> bool {
self >= Self::TrueColor
}
/// Canonical lowercase spelling that round-trips through the
/// `KIGI_FORCE_COLOR_LEVEL` parser. Use this in user-facing
/// diagnostics (not `{:?}` Debug, which yields `Basic` / `Ansi256`
/// / `TrueColor` / `None`).
pub fn as_str(self) -> &'static str {
match self {
Self::None => "none",
Self::Basic => "basic",
Self::Ansi256 => "256",
Self::TrueColor => "truecolor",
}
}
}
impl std::fmt::Display for ColorLevel {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
// ── Global singleton ─────────────────────────────────────────────────────
static COLOR_LEVEL: OnceLock<ColorLevel> = OnceLock::new();
/// Detect the terminal's color support and cache the result.
///
/// Uses the `supports-color` crate which checks `COLORTERM`, `TERM`,
/// terminal-specific env vars (`ITERM_SESSION_ID`, etc.) and whether
/// stdout is a TTY.
///
/// If `NO_COLOR` is set the result is [`ColorLevel::None`].
/// If stdout is not a TTY (test runner, piped output) and `NO_COLOR` is
/// absent, defaults to [`ColorLevel::TrueColor`] — the safe assumption
/// for a TUI app that always runs inside a terminal.
///
/// Capped at [`ColorLevel::Basic`] while the terminal-native lock is
/// engaged.
pub fn detect() -> ColorLevel {
let raw = detect_raw();
if crate::theme::cache::terminal_native_locked() {
return raw.min(ColorLevel::Basic);
}
raw
}
/// The raw cached detection, without the terminal-native lock cap.
fn detect_raw() -> ColorLevel {
*COLOR_LEVEL.get_or_init(|| {
// Explicit opt-out via NO_COLOR takes priority.
if std::env::var_os("NO_COLOR").is_some() {
return ColorLevel::None;
}
let level = match supports_color::on(supports_color::Stream::Stdout) {
Some(level) => {
if level.has_16m {
ColorLevel::TrueColor
} else if level.has_256 {
ColorLevel::Ansi256
} else if level.has_basic {
ColorLevel::Basic
} else {
ColorLevel::None
}
}
// Not a TTY (tests, piped) — default to TrueColor.
None => ColorLevel::TrueColor,
};
// The `supports-color` crate relies on COLORTERM=truecolor, but
// tmux/SSH/mosh often strip that variable. When the crate reports
// only 256-color support, upgrade to TrueColor if we can identify
// the terminal emulator and know it handles 24-bit RGB.
if level < ColorLevel::TrueColor && terminal_supports_truecolor() {
return ColorLevel::TrueColor;
}
level
})
}
/// Return the cached color level (calls [`detect`] if not yet initialized).
pub fn get() -> ColorLevel {
detect()
}
/// Override the color level (useful for tests or `--color` flags).
///
/// Returns `Err` if already set.
pub fn set(level: ColorLevel) -> Result<(), ColorLevel> {
COLOR_LEVEL.set(level)
}
// ── Color quantization ──────────────────────────────────────────────────
/// Downgrade a [`Color`] to the highest representation the terminal supports.
///
/// | Terminal level | `Rgb` | `Indexed` | Named (`Red`…) |
/// |----------------|------------------|--------------------|----------------|
/// | TrueColor | pass-through | pass-through | pass-through |
/// | Ansi256 | → nearest idx | pass-through | pass-through |
/// | Basic | → nearest ANSI16 | → nearest ANSI16 | pass-through |
/// | None | → `Reset` | → `Reset` | → `Reset` |
pub fn quantize_color(color: Color, level: ColorLevel) -> Color {
match level {
ColorLevel::TrueColor => color,
ColorLevel::Ansi256 => match color {
Color::Rgb(r, g, b) => Color::Indexed(nearest_indexed(r, g, b)),
other => other,
},
ColorLevel::Basic => match color {
Color::Rgb(r, g, b) => indexed_to_ansi16(nearest_indexed(r, g, b)),
Color::Indexed(n) => indexed_to_ansi16(n),
other => other,
},
ColorLevel::None => Color::Reset,
}
}
/// Quantize a color using the globally-detected level.
pub fn quantize(color: Color) -> Color {
quantize_color(color, get())
}
// ── Terminal-based truecolor inference ──────────────────────────────────
/// Check whether the detected terminal emulator is known to support truecolor.
///
/// Used as a fallback when `COLORTERM` is missing (e.g. inside tmux, SSH, or
/// — most importantly — under a bare `cmd.exe` / `powershell.exe` ConHost
/// window, which has supported VT-encoded 24-bit color since Windows 10
/// 1709 (Fall Creators Update) but doesn't advertise it via COLORTERM. Without
/// this fallback our themes get quantized to the 16-color ANSI palette there
/// and the subtle bg/border/muted gradations collapse onto each other.
fn terminal_supports_truecolor() -> bool {
if matches!(
terminal_context().brand,
TerminalName::Iterm2
| TerminalName::Ghostty
| TerminalName::Kitty
| TerminalName::WezTerm
| TerminalName::Alacritty
| TerminalName::Rio
| TerminalName::WarpTerminal
| TerminalName::VsCode
| TerminalName::WindowsTerminal
| TerminalName::Foot
) {
return true;
}
// Native Windows: assume ConHost has VT processing enabled. Pre-1709
// hosts are effectively extinct and would gracefully degrade by
// ignoring the SGR 38;2;... sequences.
cfg!(target_os = "windows")
}
// ── 256 → 16 mapping ────────────────────────────────────────────────────
/// Map a 256-color index to the nearest basic ANSI 16 color.
fn indexed_to_ansi16(n: u8) -> Color {
match n {
// First 16 indices already *are* the ANSI 16 colors.
0 => Color::Black,
1 => Color::Red,
2 => Color::Green,
3 => Color::Yellow,
4 => Color::Blue,
5 => Color::Magenta,
6 => Color::Cyan,
7 => Color::White, // actually "silver" in most terminals
8 => Color::DarkGray,
9 => Color::LightRed,
10 => Color::LightGreen,
11 => Color::LightYellow,
12 => Color::LightBlue,
13 => Color::LightMagenta,
14 => Color::LightCyan,
15 => Color::White,
// For 16255, convert to RGB and find nearest ANSI 16 color.
_ => {
let (r, g, b) = indexed_to_rgb(n);
rgb_to_ansi16(r, g, b)
}
}
}
/// Find the nearest ANSI 16 color for an RGB triplet.
///
/// Uses a simple squared-Euclidean distance over the standard xterm ANSI 16
/// palette. Good enough for a fallback — 16-color terminals are very rare.
fn rgb_to_ansi16(r: u8, g: u8, b: u8) -> Color {
// Standard xterm ANSI 16 palette (same values used by indexed_to_rgb for 015).
const PALETTE: [(u8, u8, u8, Color); 16] = [
(0, 0, 0, Color::Black),
(128, 0, 0, Color::Red),
(0, 128, 0, Color::Green),
(128, 128, 0, Color::Yellow),
(0, 0, 128, Color::Blue),
(128, 0, 128, Color::Magenta),
(0, 128, 128, Color::Cyan),
(192, 192, 192, Color::White),
(128, 128, 128, Color::DarkGray),
(255, 0, 0, Color::LightRed),
(0, 255, 0, Color::LightGreen),
(255, 255, 0, Color::LightYellow),
(0, 0, 255, Color::LightBlue),
(255, 0, 255, Color::LightMagenta),
(0, 255, 255, Color::LightCyan),
(255, 255, 255, Color::White), // index 15 = bright white
];
let mut best = Color::White;
let mut best_dist = u32::MAX;
for &(pr, pg, pb, color) in &PALETTE {
let dr = r as i32 - pr as i32;
let dg = g as i32 - pg as i32;
let db = b as i32 - pb as i32;
let dist = (dr * dr + dg * dg + db * db) as u32;
if dist < best_dist {
best_dist = dist;
best = color;
}
}
best
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn truecolor_passes_through() {
let rgb = Color::Rgb(122, 162, 247);
assert_eq!(quantize_color(rgb, ColorLevel::TrueColor), rgb);
let idx = Color::Indexed(141);
assert_eq!(quantize_color(idx, ColorLevel::TrueColor), idx);
}
#[test]
fn ansi256_quantizes_rgb_to_indexed() {
let rgb = Color::Rgb(122, 162, 247);
let q = quantize_color(rgb, ColorLevel::Ansi256);
assert!(matches!(q, Color::Indexed(_)));
}
#[test]
fn ansi256_passes_indexed_through() {
let idx = Color::Indexed(141);
assert_eq!(quantize_color(idx, ColorLevel::Ansi256), idx);
}
#[test]
fn basic_quantizes_to_named() {
let rgb = Color::Rgb(255, 0, 0);
let q = quantize_color(rgb, ColorLevel::Basic);
// Should map to a red variant
assert!(
matches!(q, Color::Red | Color::LightRed),
"expected Red/LightRed, got {q:?}"
);
}
#[test]
fn basic_quantizes_indexed_to_named() {
// Indexed(196) = (255,0,0) — pure bright red in the cube
let idx = Color::Indexed(196);
let q = quantize_color(idx, ColorLevel::Basic);
assert!(
matches!(q, Color::Red | Color::LightRed),
"expected Red/LightRed, got {q:?}"
);
}
#[test]
fn none_resets_everything() {
assert_eq!(
quantize_color(Color::Rgb(100, 200, 50), ColorLevel::None),
Color::Reset
);
assert_eq!(
quantize_color(Color::Indexed(111), ColorLevel::None),
Color::Reset
);
}
#[test]
fn named_colors_pass_through_all_levels() {
for level in [
ColorLevel::TrueColor,
ColorLevel::Ansi256,
ColorLevel::Basic,
] {
assert_eq!(quantize_color(Color::Red, level), Color::Red);
assert_eq!(quantize_color(Color::Blue, level), Color::Blue);
}
}
#[test]
fn level_ordering() {
assert!(ColorLevel::None < ColorLevel::Basic);
assert!(ColorLevel::Basic < ColorLevel::Ansi256);
assert!(ColorLevel::Ansi256 < ColorLevel::TrueColor);
}
#[test]
fn ansi16_roundtrip_first_16() {
// Indices 015 should map to their corresponding named colors
assert_eq!(indexed_to_ansi16(0), Color::Black);
assert_eq!(indexed_to_ansi16(1), Color::Red);
assert_eq!(indexed_to_ansi16(4), Color::Blue);
assert_eq!(indexed_to_ansi16(9), Color::LightRed);
assert_eq!(indexed_to_ansi16(14), Color::LightCyan);
}
}
@@ -0,0 +1,140 @@
//! GrokDay theme — neutral gray base (light) with deepened accent colors.
//!
//! Light counterpart to GrokNight. Backgrounds and text use a neutral
//! grayscale ramp (no blue/warm tint). Accent colors are the same hue
//! family as GrokNight but deepened for contrast on light backgrounds.
use ratatui::style::{Color, Modifier};
use super::tokyonight::Theme;
const fn rgb(r: u8, g: u8, b: u8) -> Color {
Color::Rgb(r, g, b)
}
#[allow(dead_code)]
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
// ── 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
// ── 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
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
}
use palette::*;
impl Theme {
pub const fn grokday() -> Self {
Self {
bg_base: BG_STORM,
bg_light: BG_HIGHLIGHT,
bg_dark: rgb(228, 228, 228),
bg_highlight: BG_HIGHLIGHT,
bg_hover: rgb(208, 208, 208),
bg_terminal: BG,
accent_user: FG_DARK,
accent_assistant: MAGENTA,
accent_thinking: MAGENTA,
accent_tool: DARK5,
accent_system: BLUE,
accent_error: RED,
accent_success: GREEN,
accent_running: MAGENTA,
accent_skill: BLUE,
text_primary: FG,
text_secondary: FG_DARK,
gray_dim: rgb(165, 165, 165), // #a5a5a5 — slightly darker than FG_GUTTER
gray: COMMENT,
gray_bright: DARK5,
command: YELLOW,
path: ORANGE,
running: CYAN,
warning: YELLOW,
fuzzy_accent: BLUE,
accent_plan: rgb(168, 120, 10), // #A8780A — deep golden
accent_verify: rgb(120, 80, 160), // deep violet (readable on light bg)
accent_feedback: GREEN1,
accent_remember: rgb(76, 175, 80), // #4CAF50 — Material Design green (readable on light bg)
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
hover_border: rgb(212, 212, 216),
accent_model: TEAL,
scrollbar_bg: BG_STORM_DARK,
scrollbar_fg: BG_HIGHLIGHT,
diff_delete_bg: RED_LIGHT,
diff_delete_fg: RED,
diff_insert_bg: GREEN_LIGHT,
diff_insert_fg: GREEN,
diff_equal_fg: COMMENT,
diff_gutter_fg: COMMENT,
bg_visual: rgb(198, 198, 198),
paste_bg: BG_HIGHLIGHT,
paste_fg: FG_DARK,
paste_dim: FG_GUTTER,
md_heading_h1: TEAL,
md_heading_h1_mod: Modifier::BOLD,
md_heading_h2: BLUE,
md_heading_h2_mod: Modifier::BOLD,
md_heading_h3: PURPLE,
md_heading_h3_mod: Modifier::BOLD,
md_heading_h4: DARK5,
md_heading_h4_mod: Modifier::BOLD,
md_heading_h5: COMMENT,
md_heading_h5_mod: Modifier::BOLD,
md_heading_h6: DARK3,
md_heading_h6_mod: Modifier::empty(),
md_code: BLUE1,
md_task_checked: GREEN,
md_task_unchecked: FG_DARK,
md_muted: COMMENT,
md_code_bg: rgb(228, 228, 228),
md_text: FG_DARK,
link_fg: BLUE, // #2F64D2 -- deep blue for light bg
}
}
}
@@ -0,0 +1,166 @@
//! GrokNight theme — neutral gray base with TokyoNight accent colors.
//!
//! The canonical palette is defined in RGB (`Color::Rgb`). At startup the
//! theme is run through [`Theme::quantized`] which downgrades every color
//! to the terminal's detected capability level (256-color, 16-color, etc.).
use ratatui::style::{Color, Modifier};
use super::tokyonight::Theme;
/// Helper for concise const `Color::Rgb` definitions.
const fn rgb(r: u8, g: u8, b: u8) -> Color {
Color::Rgb(r, g, b)
}
// GrokNight palette — neutral gray base + TokyoNight accent colors.
//
// Backgrounds and text use a custom grayscale ramp anchored at:
// • bg = #141414 (20)
// • fg = #f3f3f3 (243)
//
// Accent colors are the original TokyoNight Night hex values.
#[allow(dead_code)]
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
// ── 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
// ── 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
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
}
use palette::*;
impl Theme {
/// GrokNight theme — neutral gray base with TokyoNight accents.
///
/// Colors are defined in RGB. Call [`Theme::quantized`] to downgrade
/// them to the terminal's supported color level before rendering.
pub const fn groknight() -> Self {
Self {
bg_base: BG_STORM,
bg_light: BG_HIGHLIGHT,
bg_dark: rgb(28, 28, 28), // lighter than bg_base for visible code blocks
bg_highlight: BG_HIGHLIGHT,
bg_hover: rgb(44, 44, 44),
bg_terminal: BG,
accent_user: FG_DARK,
accent_assistant: MAGENTA,
accent_thinking: MAGENTA,
accent_tool: DARK5,
accent_system: BLUE,
accent_error: RED,
accent_success: GREEN,
accent_running: MAGENTA,
accent_skill: BLUE,
text_primary: FG,
text_secondary: FG_DARK,
gray_dim: rgb(88, 88, 88), // #585858 — slightly brighter than FG_GUTTER
gray: COMMENT,
gray_bright: DARK5,
command: YELLOW,
path: ORANGE,
running: CYAN,
warning: YELLOW,
fuzzy_accent: BLUE,
accent_plan: rgb(255, 219, 141), // #FFDB8D — golden
accent_verify: rgb(187, 154, 247), // #bb9af7 — violet
accent_feedback: GREEN1, // #73daca
accent_remember: Color::Rgb(139, 195, 74), // #8BC34A — Material Design light green
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
hover_border: rgb(30, 30, 34),
accent_model: TEAL,
scrollbar_bg: BG_STORM_DARK,
scrollbar_fg: BG_HIGHLIGHT,
diff_delete_bg: RED_DARK,
diff_delete_fg: RED,
diff_insert_bg: GREEN_DARK,
diff_insert_fg: GREEN,
diff_equal_fg: COMMENT,
diff_gutter_fg: COMMENT,
bg_visual: rgb(54, 54, 54),
paste_bg: BG_STORM_DARK,
paste_fg: FG_DARK,
paste_dim: FG_GUTTER,
md_heading_h1: TEAL,
md_heading_h1_mod: Modifier::BOLD,
md_heading_h2: BLUE,
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_mod: Modifier::BOLD,
md_heading_h5: COMMENT, // medium gray
md_heading_h5_mod: Modifier::BOLD,
md_heading_h6: DARK3, // medium gray, unbold
md_heading_h6_mod: Modifier::empty(),
md_code: BLUE1,
md_task_checked: GREEN,
md_task_unchecked: FG_DARK, // text_secondary
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
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[ignore = "known broken: expected accent values drift from runtime theme"]
fn test_groknight_theme() {
let theme = Theme::groknight();
assert!(matches!(theme.bg_base, Color::Rgb(20, 20, 20)));
assert!(matches!(theme.accent_user, Color::Rgb(225, 225, 225)));
assert!(matches!(theme.text_primary, Color::Rgb(225, 225, 225)));
}
}
@@ -0,0 +1,198 @@
//! Theme-aware markdown rendering style.
//!
//! Defines the `MarkdownStyle` used by agent message and thinking blocks.
//! Colors come from the `md_*` fields on the current [`Theme`], which are
//! already quantized to the terminal's color capability level.
use anstyle::{Ansi256Color, AnsiColor, Color, Style};
use kigi_markdown::MarkdownStyle;
/// Convert `ratatui::style::Color` → `anstyle::Color` (type conversion only).
///
/// Quantization is already handled by [`Theme::current()`], so this just
/// bridges the two color types.
///
/// Returns `None` for `Reset`: `anstyle::Color` has no "terminal default"
/// variant, and downstream an unset color renders as the terminal default.
fn to_anstyle(c: ratatui::style::Color) -> Option<Color> {
Some(match c {
ratatui::style::Color::Reset => return None,
ratatui::style::Color::Rgb(r, g, b) => Color::Rgb(anstyle::RgbColor(r, g, b)),
ratatui::style::Color::Indexed(n) => Color::Ansi256(Ansi256Color(n)),
// Named ANSI colors (from 16-color quantization).
ratatui::style::Color::Black => Color::Ansi(AnsiColor::Black),
ratatui::style::Color::Red => Color::Ansi(AnsiColor::Red),
ratatui::style::Color::Green => Color::Ansi(AnsiColor::Green),
ratatui::style::Color::Yellow => Color::Ansi(AnsiColor::Yellow),
ratatui::style::Color::Blue => Color::Ansi(AnsiColor::Blue),
ratatui::style::Color::Magenta => Color::Ansi(AnsiColor::Magenta),
ratatui::style::Color::Cyan => Color::Ansi(AnsiColor::Cyan),
ratatui::style::Color::Gray => Color::Ansi(AnsiColor::White),
ratatui::style::Color::DarkGray => Color::Ansi(AnsiColor::BrightBlack),
ratatui::style::Color::LightRed => Color::Ansi(AnsiColor::BrightRed),
ratatui::style::Color::LightGreen => Color::Ansi(AnsiColor::BrightGreen),
ratatui::style::Color::LightYellow => Color::Ansi(AnsiColor::BrightYellow),
ratatui::style::Color::LightBlue => Color::Ansi(AnsiColor::BrightBlue),
ratatui::style::Color::LightMagenta => Color::Ansi(AnsiColor::BrightMagenta),
ratatui::style::Color::LightCyan => Color::Ansi(AnsiColor::BrightCyan),
ratatui::style::Color::White => Color::Ansi(AnsiColor::BrightWhite),
})
}
/// `anstyle::Style` with the given foreground color (converted from ratatui).
fn fg(c: ratatui::style::Color) -> Style {
Style::new().fg_color(to_anstyle(c))
}
/// `anstyle::Style` with the given background color (converted from ratatui).
fn bg(c: ratatui::style::Color) -> Style {
Style::new().bg_color(to_anstyle(c))
}
/// Convert `ratatui::style::Modifier` flags to `anstyle::Style` effects.
fn modifier_to_anstyle(m: ratatui::style::Modifier) -> Style {
let mut s = Style::new();
if m.contains(ratatui::style::Modifier::BOLD) {
s = s.bold();
}
if m.contains(ratatui::style::Modifier::ITALIC) {
s = s.italic();
}
if m.contains(ratatui::style::Modifier::UNDERLINED) {
s = s.underline();
}
if m.contains(ratatui::style::Modifier::DIM) {
s = s.dimmed();
}
if m.contains(ratatui::style::Modifier::HIDDEN) {
s = s.hidden();
}
if m.contains(ratatui::style::Modifier::CROSSED_OUT) {
s = s.strikethrough();
}
s
}
/// Build heading inner styles from theme colors and per-level modifiers.
fn heading_inner_styles(
colors: [ratatui::style::Color; 6],
mods: [ratatui::style::Modifier; 6],
) -> [Style; 6] {
std::array::from_fn(|i| {
let color_style = fg(colors[i]);
let mod_style = modifier_to_anstyle(mods[i]);
// Combine fg color with modifier effects.
let mut s = color_style;
let effects = mod_style.get_effects();
if !effects.is_plain() {
s = s.effects(s.get_effects() | effects);
}
s
})
}
/// Build heading outer styles (dimmed + hidden, for syntax markers).
fn heading_outer_styles(colors: [ratatui::style::Color; 6]) -> [Style; 6] {
colors.map(|c| fg(c).dimmed().hidden())
}
/// Get the theme-aware markdown style.
///
/// Built fresh from [`Theme::current()`] on each call. Both the theme
/// construction and style mapping are trivial struct copies.
pub fn style() -> MarkdownStyle {
build_style()
}
fn build_style() -> MarkdownStyle {
let theme = super::Theme::current();
let heading_colors = [
theme.md_heading_h1,
theme.md_heading_h2,
theme.md_heading_h3,
theme.md_heading_h4,
theme.md_heading_h5,
theme.md_heading_h6,
];
let heading_mods = [
theme.md_heading_h1_mod,
theme.md_heading_h2_mod,
theme.md_heading_h3_mod,
theme.md_heading_h4_mod,
theme.md_heading_h5_mod,
theme.md_heading_h6_mod,
];
MarkdownStyle {
heading_inner: heading_inner_styles(heading_colors, heading_mods),
heading_outer: heading_outer_styles(heading_colors),
strong_inner: fg(theme.md_text).bold(),
strong_outer: Style::new().dimmed().hidden(),
emphasis_inner: fg(theme.md_text).italic(),
emphasis_outer: Style::new().dimmed().hidden(),
strikethrough_inner: fg(theme.md_text).strikethrough(),
strikethrough_outer: Style::new().dimmed().hidden(),
inline_code_inner: fg(theme.md_code).bold(),
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.
blockquote_outer: fg(theme.md_muted).dimmed(),
task_checked: fg(theme.md_task_checked),
task_unchecked: fg(theme.md_task_unchecked).dimmed(),
list_item: fg(theme.md_muted),
rule: fg(theme.md_muted),
link_outer: fg(theme.md_muted),
link_text: fg(theme.link_fg).underline(),
link_url: fg(theme.md_muted),
link_title: fg(theme.md_heading_h5),
code_outer: fg(theme.md_code).dimmed().hidden(),
code_language: fg(theme.md_heading_h3).hidden(),
code_untagged: fg(theme.md_text),
code_background: bg(theme.md_code_bg),
table_outer: fg(theme.md_heading_h2).hidden(),
text: fg(theme.md_text),
math: fg(theme.md_text).italic(),
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Regression: `Reset` used to fall back to a concrete `AnsiColor::White`
/// (ANSI-7 silver), which rendered Reset-themed markdown washed-out gray
/// on light terminals and broke the `NO_COLOR` opt-out.
#[test]
fn reset_maps_to_no_color() {
assert_eq!(to_anstyle(ratatui::style::Color::Reset), None);
assert_eq!(fg(ratatui::style::Color::Reset).get_fg_color(), None);
assert_eq!(bg(ratatui::style::Color::Reset).get_bg_color(), None);
}
/// Spot checks around the Gray/DarkGray naming mismatch between ratatui
/// and anstyle.
#[test]
fn named_colors_map_concretely() {
assert_eq!(
to_anstyle(ratatui::style::Color::DarkGray),
Some(Color::Ansi(AnsiColor::BrightBlack))
);
assert_eq!(
to_anstyle(ratatui::style::Color::Gray),
Some(Color::Ansi(AnsiColor::White))
);
assert_eq!(
to_anstyle(ratatui::style::Color::Red),
Some(Color::Ansi(AnsiColor::Red))
);
}
#[test]
fn terminal_default_body_text_has_no_fg() {
let theme = super::super::Theme::terminal_default();
assert_eq!(fg(theme.md_text).get_fg_color(), None);
assert_eq!(bg(theme.md_code_bg).get_bg_color(), None);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,466 @@
//! OSC 11 terminal background detection.
//!
//! Queries the terminal's background color via the OSC 11 escape sequence:
//! Query: `\x1b]11;?\x07`
//! Reply: `\x1b]11;rgb:RRRR/GGGG/BBBB\x07` (or ST terminator `\x1b\\`)
//!
//! The response contains hex color values (2-digit or 4-digit per channel).
//! For 4-digit values we extract the high byte; for 2-digit we use the value
//! directly. Relative luminance (ITU-R BT.709) classifies the background as
//! dark or light.
//!
//! This is a **startup-only** fallback — it must NOT be called once
//! crossterm's `EventStream` is active, as both compete for stdin in raw
//! mode. The live `SystemAppearanceWatcher` uses only
//! `dark-light::detect()`.
use super::system_appearance::SystemAppearance;
use std::time::Duration;
#[cfg(unix)]
use std::os::unix::io::RawFd;
/// Luminance threshold: backgrounds with Y < 0.5 are considered dark.
const LUMINANCE_THRESHOLD: f64 = 0.5;
/// Timeout for reading the OSC 11 response from the terminal.
const OSC11_TIMEOUT: Duration = Duration::from_millis(500);
/// Detect system appearance by querying the terminal's background color.
///
/// Returns `None` if stdin is not a TTY, the terminal does not respond
/// within `OSC11_TIMEOUT`, or the response cannot be parsed.
///
/// MUST be called before crossterm's event stream is initialized.
/// Manages stdin termios locally (no `crossterm::enable_raw_mode`) and
/// routes the query write through the shared stderr lock to avoid
/// interleaving with the render writer thread.
pub fn detect_via_osc11() -> Option<SystemAppearance> {
use std::io::IsTerminal;
if !std::io::stdin().is_terminal() {
return None;
}
if !crate::terminal::probe::write_query(b"\x1b]11;?\x07") {
return None;
}
let response = read_osc_response(OSC11_TIMEOUT)?;
let (r, g, b) = parse_osc11_rgb(&response)?;
Some(classify_luminance(r, g, b))
}
/// Classify an sRGB color as dark or light based on relative luminance.
///
/// Uses ITU-R BT.709 luminance coefficients with sRGB gamma correction.
/// Threshold at 0.5 — below is dark, at or above is light.
pub(crate) fn classify_luminance(r: u8, g: u8, b: u8) -> SystemAppearance {
let luminance =
0.2126 * srgb_to_linear(r) + 0.7152 * srgb_to_linear(g) + 0.0722 * srgb_to_linear(b);
if luminance < LUMINANCE_THRESHOLD {
SystemAppearance::Dark
} else {
SystemAppearance::Light
}
}
/// Parse the RGB components from an OSC 11 response string.
///
/// Handles both 4-digit (`rgb:RRRR/GGGG/BBBB`) and 2-digit (`rgb:RR/GG/BB`)
/// hex formats. For 4-digit values the high byte is extracted (>> 8).
pub(crate) fn parse_osc11_rgb(response: &str) -> Option<(u8, u8, u8)> {
let rgb_start = response.find("rgb:")? + 4;
let rgb_part = &response[rgb_start..];
// Split on channel separator `/` and terminators (BEL, ESC).
let parts: Vec<&str> = rgb_part.split(['/', '\x07', '\x1b']).take(3).collect();
if parts.len() < 3 {
return None;
}
Some((
parse_channel(parts[0])?,
parse_channel(parts[1])?,
parse_channel(parts[2])?,
))
}
/// Parse a single hex color channel.
///
/// For 34 digit values, extracts the high byte (`>> 8`) to map to 0255.
/// For 12 digit values, uses the value directly as 0255.
fn parse_channel(s: &str) -> Option<u8> {
let trimmed = s.trim();
let val = u16::from_str_radix(trimmed, 16).ok()?;
Some(if trimmed.len() > 2 {
(val >> 8) as u8
} else {
val as u8
})
}
/// Convert an sRGB channel value (0255) to linear light.
///
/// Applies the sRGB transfer function inverse (IEC 61966-2-1).
fn srgb_to_linear(c: u8) -> f64 {
let s = c as f64 / 255.0;
if s <= 0.04045 {
s / 12.92
} else {
((s + 0.055) / 1.055).powf(2.4)
}
}
/// Restores the original termios on drop without touching crossterm's
/// process-wide `TERMINAL_MODE_PRIOR_RAW_MODE`. Calling
/// `crossterm::disable_raw_mode` here would restore the shell's
/// pre-pager cooked termios, breaking the pager's own raw mode.
#[cfg(unix)]
struct TermiosGuard {
fd: RawFd,
original: libc::termios,
}
#[cfg(unix)]
impl Drop for TermiosGuard {
fn drop(&mut self) {
// SAFETY: fd was valid at construction; original was populated
// by a successful tcgetattr.
unsafe {
libc::tcsetattr(self.fd, libc::TCSANOW, &self.original);
}
}
}
/// POSIX-portable subset of `cfmakeraw(3)`: clear the lflags that would
/// block a single-byte read (canonical mode, echo, signal interpretation,
/// extended processing).
#[cfg(unix)]
fn make_raw_termios(snapshot: &libc::termios) -> libc::termios {
let mut raw = *snapshot;
raw.c_lflag &= !(libc::ICANON | libc::ECHO | libc::ISIG | libc::IEXTEN);
raw
}
#[cfg(unix)]
fn read_osc_response(timeout: Duration) -> Option<String> {
use std::os::unix::io::AsRawFd;
read_osc_response_with_fd(std::io::stdin().as_raw_fd(), timeout)
}
#[cfg(not(unix))]
fn read_osc_response(_timeout: Duration) -> Option<String> {
None
}
/// `fd`-parameterized for tests (pass `/dev/null` to exercise the
/// non-TTY path). Guard is constructed before `tcsetattr` to keep the
/// restore atomic with the switch -- POSIX guarantees `tcsetattr` is
/// atomic on failure, so a redundant restore on the early-return path
/// is harmless.
#[cfg(unix)]
fn read_osc_response_with_fd(fd: RawFd, timeout: Duration) -> Option<String> {
let mut original: libc::termios = unsafe { std::mem::zeroed() };
// SAFETY: caller passes a valid fd; original is a valid owned buffer.
if unsafe { libc::tcgetattr(fd, &mut original) } != 0 {
return None;
}
let raw = make_raw_termios(&original);
let _guard = TermiosGuard { fd, original };
// SAFETY: raw is a valid owned buffer.
if unsafe { libc::tcsetattr(fd, libc::TCSANOW, &raw) } != 0 {
return None;
}
read_with_timeout(timeout)
}
/// Read bytes from stdin until a terminator is found or timeout expires.
///
/// Recognizes two terminators:
/// - BEL (`\x07`)
/// - ST (`\x1b\x5c`, i.e. ESC + backslash)
///
/// Uses `libc::poll` + `libc::read` for non-blocking reads with a timeout
/// on Unix. Returns `None` on non-Unix platforms.
// Only invoked from `read_osc_response_with_fd`, which is Unix-only.
#[cfg(unix)]
fn read_with_timeout(timeout: Duration) -> Option<String> {
unix_read_with_timeout(timeout)
}
/// Unix implementation: shared probe read loop with the OSC terminators
/// (BEL, or ST as `ESC \`) as the stop predicate.
#[cfg(unix)]
fn unix_read_with_timeout(timeout: Duration) -> Option<String> {
let buf = crate::terminal::probe::read_tty_reply(timeout, |buf, byte| {
byte == 0x07 || (buf.len() >= 2 && buf[buf.len() - 2] == 0x1b && byte == 0x5c)
})?;
// Reject partial buffers: a reply truncated mid-channel would
// mis-parse, since channel width is inferred from digit count.
if !ends_with_osc_terminator(&buf) {
return None;
}
String::from_utf8(buf).ok()
}
/// True when the buffer ends with BEL or ST (`ESC \`).
#[cfg(any(unix, test))]
fn ends_with_osc_terminator(buf: &[u8]) -> bool {
buf.last() == Some(&0x07) || buf.ends_with(b"\x1b\\")
}
#[cfg(test)]
mod tests {
use super::*;
// -- ends_with_osc_terminator ---------------------------------------------
#[test]
fn unterminated_reply_is_rejected() {
// A truncated channel would mis-parse and could flip dark/light.
assert!(!ends_with_osc_terminator(b"\x1b]11;rgb:ffff/ffff/00"));
assert!(ends_with_osc_terminator(b"\x1b]11;rgb:ffff/ffff/ffff\x07"));
assert!(ends_with_osc_terminator(
b"\x1b]11;rgb:ffff/ffff/ffff\x1b\\"
));
assert!(!ends_with_osc_terminator(b""));
}
// -- parse_osc11_rgb -----------------------------------------------------
#[test]
fn parse_4digit_white() {
// xterm-style: rgb:ffff/ffff/ffff
let response = "\x1b]11;rgb:ffff/ffff/ffff\x07";
assert_eq!(parse_osc11_rgb(response), Some((255, 255, 255)));
}
#[test]
fn parse_4digit_black() {
let response = "\x1b]11;rgb:0000/0000/0000\x07";
assert_eq!(parse_osc11_rgb(response), Some((0, 0, 0)));
}
#[test]
fn parse_2digit_dark() {
// Some terminals use 2-digit hex: rgb:1a/1b/26
let response = "\x1b]11;rgb:1a/1b/26\x07";
assert_eq!(parse_osc11_rgb(response), Some((0x1a, 0x1b, 0x26)));
}
#[test]
fn parse_2digit_light() {
let response = "\x1b]11;rgb:f0/f0/f0\x07";
assert_eq!(parse_osc11_rgb(response), Some((0xf0, 0xf0, 0xf0)));
}
#[test]
fn parse_4digit_midrange() {
// rgb:8080/8080/8080 → high byte is 0x80 = 128
let response = "\x1b]11;rgb:8080/8080/8080\x07";
assert_eq!(parse_osc11_rgb(response), Some((128, 128, 128)));
}
#[test]
fn parse_st_terminator() {
// Some terminals use ESC \ (ST) instead of BEL as terminator.
let response = "\x1b]11;rgb:ffff/ffff/ffff\x1b\\";
assert_eq!(parse_osc11_rgb(response), Some((255, 255, 255)));
}
#[test]
fn parse_missing_rgb_prefix() {
let response = "\x1b]11;color:ffff/ffff/ffff\x07";
assert!(parse_osc11_rgb(response).is_none());
}
#[test]
fn parse_too_few_channels() {
let response = "\x1b]11;rgb:ffff/ffff\x07";
assert!(parse_osc11_rgb(response).is_none());
}
#[test]
fn parse_empty_response() {
assert!(parse_osc11_rgb("").is_none());
}
#[test]
fn parse_invalid_hex() {
let response = "\x1b]11;rgb:gggg/hhhh/iiii\x07";
assert!(parse_osc11_rgb(response).is_none());
}
#[test]
fn parse_1digit_channel() {
// Edge case: single digit per channel (treated as 2-digit path).
let response = "\x1b]11;rgb:f/f/f\x07";
assert_eq!(parse_osc11_rgb(response), Some((15, 15, 15)));
}
#[test]
fn parse_3digit_channel() {
// 3-digit hex (uncommon but possible) — >2 digits, so high byte extracted.
// 0xfff = 4095, >> 8 = 15
let response = "\x1b]11;rgb:fff/fff/fff\x07";
assert_eq!(parse_osc11_rgb(response), Some((15, 15, 15)));
}
// -- parse_channel -------------------------------------------------------
#[test]
fn channel_4digit_max() {
assert_eq!(parse_channel("ffff"), Some(255));
}
#[test]
fn channel_4digit_zero() {
assert_eq!(parse_channel("0000"), Some(0));
}
#[test]
fn channel_2digit_max() {
assert_eq!(parse_channel("ff"), Some(255));
}
#[test]
fn channel_2digit_zero() {
assert_eq!(parse_channel("00"), Some(0));
}
#[test]
fn channel_with_whitespace() {
assert_eq!(parse_channel(" ff "), Some(255));
}
// -- classify_luminance --------------------------------------------------
#[test]
fn classify_pure_black_is_dark() {
assert_eq!(classify_luminance(0, 0, 0), SystemAppearance::Dark);
}
#[test]
fn classify_pure_white_is_light() {
assert_eq!(classify_luminance(255, 255, 255), SystemAppearance::Light);
}
#[test]
fn classify_dark_gray_is_dark() {
// Typical dark terminal background: #1a1b26 (TokyoNight)
assert_eq!(classify_luminance(0x1a, 0x1b, 0x26), SystemAppearance::Dark);
}
#[test]
fn classify_light_gray_is_light() {
// Typical light terminal background: #f0f0f0
assert_eq!(
classify_luminance(0xf0, 0xf0, 0xf0),
SystemAppearance::Light
);
}
#[test]
fn classify_mid_gray_boundary() {
// sRGB (186, 186, 186) has luminance ≈ 0.497 → just below 0.5 → Dark
// sRGB (188, 188, 188) has luminance ≈ 0.508 → just above 0.5 → Light
assert_eq!(classify_luminance(186, 186, 186), SystemAppearance::Dark);
assert_eq!(classify_luminance(188, 188, 188), SystemAppearance::Light);
}
#[test]
fn classify_solarized_dark_is_dark() {
// Solarized Dark base03: #002b36
assert_eq!(classify_luminance(0x00, 0x2b, 0x36), SystemAppearance::Dark);
}
#[test]
fn classify_solarized_light_is_light() {
// Solarized Light base3: #fdf6e3
assert_eq!(
classify_luminance(0xfd, 0xf6, 0xe3),
SystemAppearance::Light
);
}
// -- srgb_to_linear ------------------------------------------------------
#[test]
fn srgb_to_linear_zero() {
assert!((srgb_to_linear(0) - 0.0).abs() < f64::EPSILON);
}
#[test]
fn srgb_to_linear_max() {
assert!((srgb_to_linear(255) - 1.0).abs() < 1e-10);
}
#[test]
fn srgb_to_linear_low_value() {
// 10/255 ≈ 0.0392 < 0.04045 → linear branch
let result = srgb_to_linear(10);
let expected = (10.0 / 255.0) / 12.92;
assert!((result - expected).abs() < 1e-10);
}
#[test]
fn srgb_to_linear_high_value() {
// 128/255 ≈ 0.502 > 0.04045 → gamma branch
let result = srgb_to_linear(128);
let s: f64 = 128.0 / 255.0;
let expected = ((s + 0.055) / 1.055).powf(2.4);
assert!((result - expected).abs() < 1e-10);
}
// -- detect_via_osc11 (graceful degradation) -----------------------------
#[test]
fn detect_returns_none_when_not_tty() {
// In CI / test runners stdin is captured; the early `is_terminal`
// check must return None without writing anything to stderr.
assert_eq!(detect_via_osc11(), None);
}
#[cfg(unix)]
#[test]
fn read_osc_response_with_fd_returns_none_for_non_tty_fd() {
// tcgetattr on /dev/null returns ENOTTY; we must bail without
// panicking and without touching crossterm's process-wide state.
use std::os::unix::io::AsRawFd;
let f = std::fs::File::open("/dev/null").unwrap();
let result = read_osc_response_with_fd(f.as_raw_fd(), Duration::from_millis(10));
assert_eq!(result, None);
}
#[cfg(unix)]
#[test]
fn make_raw_termios_clears_only_canonical_echo_signal_extended() {
// Pre-populate with cleared bits AND preserved bits, then assert
// the result is exactly the preserved set. Catches regressions
// that widen the mask.
let mut snapshot: libc::termios = unsafe { std::mem::zeroed() };
snapshot.c_lflag =
libc::ICANON | libc::ECHO | libc::ISIG | libc::IEXTEN | libc::TOSTOP | libc::NOFLSH;
let raw = make_raw_termios(&snapshot);
assert_eq!(raw.c_lflag, libc::TOSTOP | libc::NOFLSH);
}
#[cfg(unix)]
#[test]
fn make_raw_termios_preserves_other_flag_words() {
let mut snapshot: libc::termios = unsafe { std::mem::zeroed() };
snapshot.c_lflag = libc::TOSTOP | libc::ICANON;
snapshot.c_iflag = libc::ICRNL;
snapshot.c_oflag = libc::OPOST;
snapshot.c_cflag = libc::CS8;
let raw = make_raw_termios(&snapshot);
assert_eq!(raw.c_lflag & libc::TOSTOP, libc::TOSTOP);
assert_eq!(raw.c_iflag, snapshot.c_iflag);
assert_eq!(raw.c_oflag, snapshot.c_oflag);
assert_eq!(raw.c_cflag, snapshot.c_cflag);
}
}
@@ -0,0 +1,149 @@
use ratatui::style::{Color, Modifier};
use super::tokyonight::Theme;
const fn rgb(r: u8, g: u8, b: u8) -> Color {
Color::Rgb(r, g, b)
}
/// Oscura Midnight palette.
///
/// Deep, dark backgrounds with a subtle purple/blue tint (OKLCH hue 265),
/// inspired by the Oscura Midnight palette (narative/oscura). Accent colors
/// lean purple to give the theme its distinctive identity.
///
/// Base colors were converted from OKLCH to sRGB programmatically via
/// the `coloraide` Python library. Purple accent colors are hand-picked
/// to complement the hue-265 background tint.
#[allow(dead_code)]
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)
// -- 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)
// -- 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)
// -- 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)
// -- 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
// -- cyan (for running indicators, links) ---------------------------------
pub const CYAN: Color = rgb(125, 207, 223); // #7DCFDF
// -- 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
}
use palette::*;
impl Theme {
pub const fn oscura_midnight() -> Self {
Self {
bg_base: BASE,
bg_light: ELEVATED,
bg_dark: SURFACE,
bg_highlight: ELEVATED,
bg_hover: HIGHLIGHT_MED,
bg_terminal: BASE,
accent_user: PURPLE_BRIGHT,
accent_assistant: PURPLE,
accent_thinking: MUTED,
accent_tool: SUBTLE,
accent_system: CYAN,
accent_error: RED,
accent_success: TEAL,
accent_running: PURPLE_DIM,
accent_skill: PURPLE,
text_primary: TEXT,
text_secondary: TEXT_DIM,
gray_dim: SUBTLE,
gray: MUTED,
gray_bright: TEXT_DIM,
command: GOLD,
path: AMBER,
running: CYAN,
warning: GOLD,
fuzzy_accent: PURPLE_BRIGHT,
accent_plan: GOLD,
accent_verify: PURPLE,
accent_feedback: TEAL,
accent_remember: rgb(139, 195, 74), // #8BC34A — Material Design light green
selection_border: HIGHLIGHT_HIGH,
hover_border: HIGHLIGHT_MED,
prompt_border: HIGHLIGHT_MED,
prompt_border_active: HIGHLIGHT_HIGH,
accent_model: CYAN,
// Thumb must sit clearly above the track: `ELEVATED` (Σrgb 55)
// was *darker* than the `HIGHLIGHT_LOW` track (Σrgb 62), which
// made the scrollbar invisible — and follow mode blends the
// thumb 40% toward the track, shrinking the delta further.
// `HIGHLIGHT_HIGH` matches the weight of the theme's visible
// chrome (selection border) and Rose Pine's thumb brightness.
scrollbar_bg: HIGHLIGHT_LOW,
scrollbar_fg: HIGHLIGHT_HIGH,
diff_delete_bg: rgb(45, 15, 25),
diff_delete_fg: RED,
diff_insert_bg: rgb(10, 35, 30),
diff_insert_fg: TEAL,
diff_equal_fg: MUTED,
diff_gutter_fg: MUTED,
bg_visual: HIGHLIGHT_MED,
paste_bg: SURFACE,
paste_fg: TEXT_DIM,
paste_dim: MUTED,
md_heading_h1: TEXT,
md_heading_h1_mod: Modifier::BOLD,
md_heading_h2: PURPLE_BRIGHT,
md_heading_h2_mod: Modifier::BOLD,
md_heading_h3: PURPLE,
md_heading_h3_mod: Modifier::BOLD,
md_heading_h4: TEAL,
md_heading_h4_mod: Modifier::BOLD.union(Modifier::ITALIC),
md_heading_h5: GOLD,
md_heading_h5_mod: Modifier::BOLD,
md_heading_h6: CYAN,
md_heading_h6_mod: Modifier::BOLD,
md_code: CYAN,
md_task_checked: TEAL,
md_task_unchecked: TEXT_DIM,
md_muted: MUTED,
md_code_bg: SURFACE,
md_text: TEXT,
link_fg: CYAN,
}
}
}
@@ -0,0 +1,117 @@
use ratatui::style::{Color, Modifier};
use super::tokyonight::Theme;
const fn rgb(r: u8, g: u8, b: u8) -> Color {
Color::Rgb(r, g, b)
}
#[allow(dead_code)]
mod palette {
use super::*;
pub const BASE: Color = rgb(35, 33, 54);
pub const SURFACE: Color = rgb(42, 39, 63);
pub const OVERLAY: Color = rgb(57, 53, 82);
pub const MUTED: Color = rgb(110, 106, 134);
pub const SUBTLE: Color = rgb(144, 140, 170);
pub const TEXT: Color = rgb(224, 222, 244);
pub const LOVE: Color = rgb(235, 111, 146);
pub const GOLD: Color = rgb(246, 193, 119);
pub const ROSE: Color = rgb(234, 154, 151);
pub const PINE: Color = rgb(62, 143, 176);
pub const FOAM: Color = rgb(156, 207, 216);
pub const IRIS: Color = rgb(196, 167, 231);
pub const HIGHLIGHT_LOW: Color = rgb(42, 40, 62);
pub const HIGHLIGHT_MED: Color = rgb(68, 65, 90);
pub const HIGHLIGHT_HIGH: Color = rgb(86, 82, 110);
}
use palette::*;
impl Theme {
pub const fn rosepine_moon() -> Self {
Self {
bg_base: BASE,
bg_light: OVERLAY,
bg_dark: SURFACE,
bg_highlight: OVERLAY,
bg_hover: HIGHLIGHT_MED,
bg_terminal: BASE,
accent_user: TEXT,
accent_assistant: IRIS,
accent_thinking: MUTED,
accent_tool: SUBTLE,
accent_system: PINE,
accent_error: LOVE,
accent_success: FOAM,
accent_running: MUTED,
accent_skill: SUBTLE,
text_primary: TEXT,
text_secondary: SUBTLE,
gray_dim: HIGHLIGHT_MED,
gray: MUTED,
gray_bright: SUBTLE,
command: GOLD,
path: ROSE,
running: FOAM,
warning: GOLD,
fuzzy_accent: PINE,
accent_plan: GOLD,
accent_verify: PINE,
accent_feedback: FOAM,
accent_remember: PINE,
selection_border: HIGHLIGHT_HIGH,
hover_border: HIGHLIGHT_MED,
prompt_border: HIGHLIGHT_MED,
prompt_border_active: HIGHLIGHT_HIGH,
accent_model: PINE,
scrollbar_bg: HIGHLIGHT_LOW,
scrollbar_fg: OVERLAY,
diff_delete_bg: rgb(55, 30, 40),
diff_delete_fg: LOVE,
diff_insert_bg: rgb(25, 45, 55),
diff_insert_fg: FOAM,
diff_equal_fg: MUTED,
diff_gutter_fg: MUTED,
bg_visual: HIGHLIGHT_MED,
paste_bg: SURFACE,
paste_fg: SUBTLE,
paste_dim: MUTED,
md_heading_h1: TEXT,
md_heading_h1_mod: Modifier::BOLD,
md_heading_h2: FOAM,
md_heading_h2_mod: Modifier::BOLD.union(Modifier::UNDERLINED),
md_heading_h3: IRIS,
md_heading_h3_mod: Modifier::BOLD,
md_heading_h4: ROSE,
md_heading_h4_mod: Modifier::BOLD.union(Modifier::ITALIC),
md_heading_h5: GOLD,
md_heading_h5_mod: Modifier::BOLD,
md_heading_h6: PINE,
md_heading_h6_mod: Modifier::BOLD,
md_code: FOAM,
md_task_checked: FOAM,
md_task_unchecked: SUBTLE,
md_muted: MUTED,
md_code_bg: SURFACE,
md_text: TEXT,
link_fg: FOAM, // #9ccfd8 -- teal/cyan for dark bg
}
}
}
@@ -0,0 +1,415 @@
//! System appearance detection for automatic day/night theming.
//!
//! Uses the `dark-light` crate for cross-platform detection:
//! - macOS: reads `AppleInterfaceStyle` preference
//! - Linux: queries XDG Desktop Portal (`org.freedesktop.appearance.color-scheme`)
//! - Windows: reads system personalization registry
//!
//! Falls back to OSC 11 terminal background query when `dark-light` returns
//! `Unspecified` (e.g., over SSH where no desktop session is available).
//! The OSC 11 fallback is **startup-only** — see [`detect_with_osc11_fallback`].
//!
//! Falls back to `None` on total detection failure.
use super::ThemeKind;
use std::time::Duration;
use tokio::sync::watch;
/// Detected system appearance.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SystemAppearance {
Light,
Dark,
}
/// Detect the current system appearance (desktop APIs only).
///
/// Detection chain:
/// 1. `dark-light::detect()` — desktop session APIs (macOS/Linux/Windows)
/// 2. `None` — if detection fails
///
/// For the extended chain that includes OSC 11 as a startup-only fallback,
/// see [`detect_with_osc11_fallback`].
///
/// In `#[cfg(test)]` builds, checks the mock override first so that
/// `SystemAppearanceWatcher`'s polling loop (which calls `detect()`
/// directly) is also controllable from tests.
#[must_use]
pub fn detect() -> Option<SystemAppearance> {
#[cfg(any(test, feature = "test-support"))]
if let Some(v) = mock_override() {
return v;
}
detect_without_mock()
}
/// Detect system appearance with OSC 11 terminal background fallback.
///
/// Extended detection chain:
/// 1. `dark-light::detect()` — desktop session APIs
/// 2. OSC 11 terminal background query — fallback for SSH/headless
/// 3. `None` — if both fail
///
/// **Startup-only**: the OSC 11 step requires raw-mode stdin access and
/// must NOT be called once crossterm's `EventStream` is active. The
/// live [`SystemAppearanceWatcher`] uses [`detect`] (without OSC 11).
#[must_use]
pub fn detect_with_osc11_fallback() -> Option<SystemAppearance> {
#[cfg(any(test, feature = "test-support"))]
if let Some(v) = mock_override() {
return v;
}
detect_without_mock().or_else(super::osc11::detect_via_osc11)
}
/// Inner detection via desktop APIs only (no mock, no OSC 11).
fn detect_without_mock() -> Option<SystemAppearance> {
match dark_light::detect() {
Ok(dark_light::Mode::Dark) => Some(SystemAppearance::Dark),
Ok(dark_light::Mode::Light) => Some(SystemAppearance::Light),
// Mode::Unspecified or Err — no system preference detected
_ => None,
}
}
/// Return the mock value if one has been set (test builds only).
///
/// Returns `Some(value)` when a mock is active, `None` when real
/// detection should proceed.
#[cfg(any(test, feature = "test-support"))]
fn mock_override() -> Option<Option<SystemAppearance>> {
*MOCK_APPEARANCE.lock().unwrap_or_else(|e| e.into_inner())
}
/// Map system appearance to a theme kind using config-driven overrides.
///
/// `dark_theme` and `light_theme` are the user-configured themes for each
/// appearance mode, read from `[ui].auto_dark_theme` and `[ui].auto_light_theme`
/// in `config.toml`. When `None`, defaults to `GrokNight` / `GrokDay`.
///
/// This function is the single mapping point for appearance -> theme.
/// All callers go through it, making the mapping trivially extensible.
#[must_use]
pub fn to_theme_kind(
appearance: SystemAppearance,
dark_theme: Option<ThemeKind>,
light_theme: Option<ThemeKind>,
) -> ThemeKind {
match appearance {
SystemAppearance::Light => light_theme.unwrap_or(ThemeKind::GrokDay),
SystemAppearance::Dark => dark_theme.unwrap_or(ThemeKind::GrokNight),
}
}
/// Polling interval for system appearance detection.
///
/// In test builds, a shorter interval (50ms) is used so polling tests
/// complete quickly.
#[cfg(not(test))]
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.
///
/// The spawned polling task only reads system state and sends via
/// `watch::channel` — it never mutates `theme_cache::CURRENT` or `AUTO_MODE`.
/// The watcher does NOT use OSC 11 for polling — only [`detect()`].
pub struct SystemAppearanceWatcher {
rx: watch::Receiver<Option<SystemAppearance>>,
_handle: tokio::task::JoinHandle<()>,
}
impl SystemAppearanceWatcher {
/// Start the watcher if auto mode is active.
///
/// Returns `None` when `is_auto` is false — the event loop uses
/// `std::future::pending()` in that case so the `select!` branch
/// never fires.
pub fn start_if_auto(is_auto: bool) -> Option<Self> {
if !is_auto {
return None;
}
let initial = detect();
let (tx, rx) = watch::channel(initial);
let interval = POLL_INTERVAL;
let handle = tokio::spawn(async move {
let mut current = initial;
loop {
tokio::time::sleep(interval).await;
let detected = detect();
if detected != current {
current = detected;
let _ = tx.send(current);
}
}
});
Some(Self {
rx,
_handle: handle,
})
}
/// Wait for the next appearance change.
pub async fn changed(&mut self) -> Result<(), watch::error::RecvError> {
self.rx.changed().await
}
/// Return the current detected appearance.
#[must_use]
pub fn current(&self) -> Option<SystemAppearance> {
*self.rx.borrow()
}
}
impl Drop for SystemAppearanceWatcher {
fn drop(&mut self) {
self._handle.abort();
}
}
// -- Test support ----------------------------------------------------------
#[cfg(any(test, feature = "test-support"))]
use std::sync::Mutex;
/// Mock override for `detect()`. When set to `Some(value)`, `detect()`
/// returns the mock value instead of calling `dark_light::detect()`.
/// This ensures the `SystemAppearanceWatcher` polling loop (which calls
/// `detect()` directly) is also controllable from tests.
#[cfg(any(test, feature = "test-support"))]
static MOCK_APPEARANCE: Mutex<Option<Option<SystemAppearance>>> = Mutex::new(None);
/// Override `detect()` for tests. Set to `Some(value)` to mock a specific
/// appearance, or `None` to mock detection failure.
#[cfg(any(test, feature = "test-support"))]
pub fn set_mock(value: Option<SystemAppearance>) {
*MOCK_APPEARANCE.lock().unwrap_or_else(|e| e.into_inner()) = Some(value);
}
/// Clear the mock override, restoring real detection behavior.
#[cfg(any(test, feature = "test-support"))]
pub fn clear_mock() {
*MOCK_APPEARANCE.lock().unwrap_or_else(|e| e.into_inner()) = None;
}
#[cfg(test)]
mod tests {
use super::super::cache as theme_cache;
use super::*;
/// Helper: set mock, assert `detect()` returns the expected value, clear mock.
/// Caller must hold `theme_cache::test_lock()` to prevent races with parallel
/// tests in `cache::tests` and `slash::commands::theme::tests` that also
/// mutate the shared `MOCK_APPEARANCE` static via `set_mock`/`clear_mock`.
fn assert_mock_roundtrip(value: Option<SystemAppearance>) {
set_mock(value);
assert_eq!(detect(), value);
clear_mock();
}
#[test]
fn to_theme_kind_dark_defaults_to_groknight() {
let result = to_theme_kind(SystemAppearance::Dark, None, None);
assert_eq!(result, ThemeKind::GrokNight);
}
#[test]
fn to_theme_kind_light_defaults_to_grokday() {
let result = to_theme_kind(SystemAppearance::Light, None, None);
assert_eq!(result, ThemeKind::GrokDay);
}
#[test]
fn to_theme_kind_custom_dark_theme() {
let result = to_theme_kind(SystemAppearance::Dark, Some(ThemeKind::TokyoNight), None);
assert_eq!(result, ThemeKind::TokyoNight);
}
#[test]
fn to_theme_kind_custom_light_theme() {
let result = to_theme_kind(SystemAppearance::Light, None, Some(ThemeKind::RosePineMoon));
assert_eq!(result, ThemeKind::RosePineMoon);
}
#[test]
fn to_theme_kind_custom_both() {
let result = to_theme_kind(
SystemAppearance::Dark,
Some(ThemeKind::RosePineMoon),
Some(ThemeKind::GrokNight),
);
assert_eq!(result, ThemeKind::RosePineMoon);
let result = to_theme_kind(
SystemAppearance::Light,
Some(ThemeKind::RosePineMoon),
Some(ThemeKind::GrokNight),
);
assert_eq!(result, ThemeKind::GrokNight);
}
#[test]
fn to_theme_kind_dark_ignores_light_override() {
let result = to_theme_kind(SystemAppearance::Dark, None, Some(ThemeKind::TokyoNight));
// Dark appearance should use the dark default, not the light override.
assert_eq!(result, ThemeKind::GrokNight);
}
#[test]
fn to_theme_kind_light_ignores_dark_override() {
let result = to_theme_kind(SystemAppearance::Light, Some(ThemeKind::TokyoNight), None);
// Light appearance should use the light default, not the dark override.
assert_eq!(result, ThemeKind::GrokDay);
}
#[test]
fn mock_dark_appearance() {
let _guard = theme_cache::test_lock()
.lock()
.unwrap_or_else(|e| e.into_inner());
assert_mock_roundtrip(Some(SystemAppearance::Dark));
}
#[test]
fn mock_light_appearance() {
let _guard = theme_cache::test_lock()
.lock()
.unwrap_or_else(|e| e.into_inner());
assert_mock_roundtrip(Some(SystemAppearance::Light));
}
#[test]
fn mock_detection_failure() {
let _guard = theme_cache::test_lock()
.lock()
.unwrap_or_else(|e| e.into_inner());
assert_mock_roundtrip(None);
}
#[test]
fn clear_mock_restores_real_detection() {
let _guard = theme_cache::test_lock()
.lock()
.unwrap_or_else(|e| e.into_inner());
set_mock(Some(SystemAppearance::Dark));
assert_eq!(detect(), Some(SystemAppearance::Dark));
clear_mock();
// After clearing, detect() calls dark_light::detect() for real.
// We can't assert a specific value since it depends on the system,
// but we can verify it doesn't panic.
let _ = detect();
}
// -- SystemAppearanceWatcher -----------------------------------------
#[tokio::test]
async fn start_if_auto_returns_none_when_not_auto() {
assert!(SystemAppearanceWatcher::start_if_auto(false).is_none());
}
#[tokio::test]
async fn start_if_auto_returns_some_when_auto() {
let _guard = theme_cache::test_lock()
.lock()
.unwrap_or_else(|e| e.into_inner());
set_mock(Some(SystemAppearance::Dark));
let watcher = SystemAppearanceWatcher::start_if_auto(true);
assert!(watcher.is_some());
clear_mock();
}
#[tokio::test]
async fn watcher_reports_initial_appearance() {
let _guard = theme_cache::test_lock()
.lock()
.unwrap_or_else(|e| e.into_inner());
set_mock(Some(SystemAppearance::Light));
let watcher = SystemAppearanceWatcher::start_if_auto(true).unwrap();
assert_eq!(watcher.current(), Some(SystemAppearance::Light));
clear_mock();
}
#[tokio::test]
async fn watcher_reports_none_on_detection_failure() {
let _guard = theme_cache::test_lock()
.lock()
.unwrap_or_else(|e| e.into_inner());
set_mock(None);
let watcher = SystemAppearanceWatcher::start_if_auto(true).unwrap();
assert_eq!(watcher.current(), None);
clear_mock();
}
#[tokio::test]
#[allow(clippy::await_holding_lock)] // Deliberate: theme_cache::test_lock() serializes mock access.
async fn watcher_detects_appearance_change() {
let _guard = theme_cache::test_lock()
.lock()
.unwrap_or_else(|e| e.into_inner());
set_mock(Some(SystemAppearance::Dark));
let mut watcher = SystemAppearanceWatcher::start_if_auto(true).unwrap();
assert_eq!(watcher.current(), Some(SystemAppearance::Dark));
// Change the mock appearance.
set_mock(Some(SystemAppearance::Light));
// Wait for the watcher to detect the change (polls every 50ms in tests).
tokio::time::timeout(std::time::Duration::from_secs(2), watcher.changed())
.await
.expect("timed out waiting for change")
.expect("watcher channel closed");
assert_eq!(watcher.current(), Some(SystemAppearance::Light));
clear_mock();
}
#[tokio::test]
#[allow(clippy::await_holding_lock)] // Deliberate: theme_cache::test_lock() serializes mock access.
async fn watcher_does_not_send_when_unchanged() {
let _guard = theme_cache::test_lock()
.lock()
.unwrap_or_else(|e| e.into_inner());
set_mock(Some(SystemAppearance::Dark));
let mut watcher = SystemAppearanceWatcher::start_if_auto(true).unwrap();
// Wait longer than the poll interval — no change should occur.
let result =
tokio::time::timeout(std::time::Duration::from_millis(200), watcher.changed()).await;
// Should timeout because appearance didn't change.
assert!(
result.is_err(),
"expected timeout — no change should be emitted"
);
assert_eq!(watcher.current(), Some(SystemAppearance::Dark));
clear_mock();
}
#[tokio::test]
#[allow(clippy::await_holding_lock)] // Deliberate: theme_cache::test_lock() serializes mock access.
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
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())
.await
.expect("timed out waiting for recovery")
.expect("watcher channel closed");
assert_eq!(watcher.current(), Some(SystemAppearance::Dark));
clear_mock();
}
}
@@ -0,0 +1,298 @@
//! Terminal-native palette for minimal mode.
//!
//! Any RGB theme is designed for one background polarity, so composited on
//! 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
//! 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
//! accents is polarity-safe without detection.
//!
//! ## Grays / secondary text
//!
//! Do **not** paint body or status text as `DarkGray` (ANSI bright black).
//! Many dark profiles deliberately set that slot very dark for subtle
//! chrome, which washes out tool stdout and the prompt info bar. Instead:
//!
//! - **Primary content** (`text_primary`, `gray_bright`, …) → `Color::Reset`
//! (terminal default foreground).
//! - **Secondary chrome** (`gray`, `gray_dim`, `text_secondary`) → also
//! `Color::Reset`; [`Theme::muted`] / [`Theme::dim`] apply `Modifier::DIM`
//! so de-emphasis tracks the terminal's own fg (polarity-safe), unlike
//! hard-coding bright black.
use ratatui::style::{Color, Modifier};
use super::Theme;
impl Theme {
/// The fixed terminal-native palette used by minimal mode: every field
/// is `Color::Reset` or a named ANSI-16 color (see the module docs).
pub const fn terminal_default() -> Self {
// Secondary roles store Reset; Theme::muted / Theme::dim apply SGR dim.
const MUTED: Color = Color::Reset;
Self {
bg_base: Color::Reset,
bg_light: Color::Reset,
bg_dark: Color::Reset,
bg_highlight: Color::Reset,
bg_hover: Color::Reset,
bg_terminal: Color::Reset,
accent_user: Color::Reset,
accent_assistant: Color::Magenta,
accent_thinking: MUTED,
accent_tool: MUTED,
accent_system: Color::Blue,
accent_error: Color::Red,
accent_success: Color::Green,
accent_running: Color::Magenta,
accent_skill: Color::Blue,
text_primary: Color::Reset,
text_secondary: MUTED,
gray_dim: MUTED,
gray: MUTED,
gray_bright: Color::Reset,
command: Color::Yellow,
path: Color::Cyan,
running: Color::Cyan,
warning: Color::Yellow,
fuzzy_accent: Color::Cyan,
accent_plan: Color::Yellow,
accent_verify: Color::Magenta,
accent_feedback: Color::Cyan,
accent_remember: Color::Green,
selection_border: MUTED,
hover_border: MUTED,
prompt_border: MUTED,
prompt_border_active: Color::Reset,
accent_model: Color::Cyan,
scrollbar_bg: Color::Reset,
scrollbar_fg: MUTED,
diff_delete_bg: Color::Reset,
diff_delete_fg: Color::Red,
diff_insert_bg: Color::Reset,
diff_insert_fg: Color::Green,
diff_equal_fg: MUTED,
diff_gutter_fg: MUTED,
bg_visual: Color::Reset,
paste_bg: Color::Reset,
paste_fg: MUTED,
paste_dim: MUTED,
md_heading_h1: Color::Reset,
md_heading_h1_mod: Modifier::BOLD.union(Modifier::UNDERLINED),
md_heading_h2: Color::Reset,
md_heading_h2_mod: Modifier::BOLD,
md_heading_h3: Color::Reset,
md_heading_h3_mod: Modifier::BOLD,
md_heading_h4: Color::Reset,
md_heading_h4_mod: Modifier::BOLD,
md_heading_h5: Color::Reset,
md_heading_h5_mod: Modifier::BOLD,
md_heading_h6: MUTED,
md_heading_h6_mod: Modifier::BOLD,
md_code: Color::Cyan,
md_task_checked: Color::Green,
md_task_unchecked: MUTED,
md_muted: MUTED,
md_code_bg: Color::Reset,
md_text: Color::Reset,
link_fg: Color::Blue,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn all_colors(theme: &Theme) -> Vec<(&'static str, Color)> {
vec![
("bg_base", theme.bg_base),
("bg_light", theme.bg_light),
("bg_dark", theme.bg_dark),
("bg_highlight", theme.bg_highlight),
("bg_hover", theme.bg_hover),
("bg_terminal", theme.bg_terminal),
("accent_user", theme.accent_user),
("accent_assistant", theme.accent_assistant),
("accent_thinking", theme.accent_thinking),
("accent_tool", theme.accent_tool),
("accent_system", theme.accent_system),
("accent_error", theme.accent_error),
("accent_success", theme.accent_success),
("accent_running", theme.accent_running),
("accent_skill", theme.accent_skill),
("text_primary", theme.text_primary),
("text_secondary", theme.text_secondary),
("gray_dim", theme.gray_dim),
("gray", theme.gray),
("gray_bright", theme.gray_bright),
("command", theme.command),
("path", theme.path),
("running", theme.running),
("warning", theme.warning),
("fuzzy_accent", theme.fuzzy_accent),
("accent_plan", theme.accent_plan),
("accent_verify", theme.accent_verify),
("accent_feedback", theme.accent_feedback),
("accent_remember", theme.accent_remember),
("selection_border", theme.selection_border),
("hover_border", theme.hover_border),
("prompt_border", theme.prompt_border),
("prompt_border_active", theme.prompt_border_active),
("accent_model", theme.accent_model),
("scrollbar_bg", theme.scrollbar_bg),
("scrollbar_fg", theme.scrollbar_fg),
("diff_delete_bg", theme.diff_delete_bg),
("diff_delete_fg", theme.diff_delete_fg),
("diff_insert_bg", theme.diff_insert_bg),
("diff_insert_fg", theme.diff_insert_fg),
("diff_equal_fg", theme.diff_equal_fg),
("diff_gutter_fg", theme.diff_gutter_fg),
("bg_visual", theme.bg_visual),
("paste_bg", theme.paste_bg),
("paste_fg", theme.paste_fg),
("paste_dim", theme.paste_dim),
("md_heading_h1", theme.md_heading_h1),
("md_heading_h2", theme.md_heading_h2),
("md_heading_h3", theme.md_heading_h3),
("md_heading_h4", theme.md_heading_h4),
("md_heading_h5", theme.md_heading_h5),
("md_heading_h6", theme.md_heading_h6),
("md_code", theme.md_code),
("md_task_checked", theme.md_task_checked),
("md_task_unchecked", theme.md_task_unchecked),
("md_muted", theme.md_muted),
("md_code_bg", theme.md_code_bg),
("md_text", theme.md_text),
("link_fg", theme.link_fg),
]
}
#[test]
fn terminal_default_uses_only_reset_and_named_ansi() {
let theme = Theme::terminal_default();
for (name, color) in all_colors(&theme) {
assert!(
!matches!(color, Color::Rgb(..) | Color::Indexed(_)),
"{name} must be Reset or a named ANSI color, got {color:?}"
);
}
}
#[test]
fn terminal_default_backgrounds_are_transparent() {
let theme = Theme::terminal_default();
for (name, color) in [
("bg_base", theme.bg_base),
("bg_light", theme.bg_light),
("bg_dark", theme.bg_dark),
("bg_terminal", theme.bg_terminal),
("md_code_bg", theme.md_code_bg),
("diff_delete_bg", theme.diff_delete_bg),
("diff_insert_bg", theme.diff_insert_bg),
("paste_bg", theme.paste_bg),
] {
assert_eq!(color, Color::Reset, "{name} must defer to the canvas");
}
}
#[test]
fn terminal_default_leaves_cursor_color_alone() {
let theme = Theme::terminal_default();
assert_eq!(theme.accent_user, Color::Reset);
assert_eq!(
crate::render::color::resolve_to_rgb(theme.accent_user),
None
);
}
#[test]
fn terminal_default_survives_quantization() {
use crate::theme::color_support::ColorLevel;
let theme = Theme::terminal_default();
for level in [
ColorLevel::Basic,
ColorLevel::Ansi256,
ColorLevel::TrueColor,
] {
let quantized = theme.quantized(level);
for ((name, before), (_, after)) in
all_colors(&theme).into_iter().zip(all_colors(&quantized))
{
assert_eq!(before, after, "{name} must survive {level:?}");
}
}
let stripped = theme.quantized(ColorLevel::None);
for (name, color) in all_colors(&stripped) {
assert_eq!(color, Color::Reset, "{name} must strip under NO_COLOR");
}
}
#[test]
fn terminal_default_primary_is_reset_not_dark_gray() {
let theme = Theme::terminal_default();
assert_eq!(theme.text_primary, Color::Reset);
assert_eq!(theme.gray, Color::Reset);
assert_eq!(theme.gray_dim, Color::Reset);
// Must not hard-code bright black for body/secondary roles.
assert_ne!(theme.text_primary, Color::DarkGray);
assert_ne!(theme.gray, Color::DarkGray);
assert_ne!(theme.gray_dim, Color::DarkGray);
}
#[test]
fn terminal_default_muted_and_dim_use_sgr_dim_not_bright_black() {
use ratatui::style::Modifier;
let theme = Theme::terminal_default();
let muted = theme.muted();
let dim = theme.dim();
assert!(
muted.add_modifier.contains(Modifier::DIM),
"muted should DIM the terminal default fg: {muted:?}"
);
assert!(
dim.add_modifier.contains(Modifier::DIM),
"dim should DIM the terminal default fg: {dim:?}"
);
// No explicit DarkGray paint — dim tracks the host palette.
assert!(
muted.fg.is_none() || muted.fg == Some(Color::Reset),
"muted must not set a hard gray: {muted:?}"
);
assert!(
dim.fg.is_none() || dim.fg == Some(Color::Reset),
"dim must not set a hard gray: {dim:?}"
);
}
#[test]
fn rgb_theme_muted_keeps_explicit_gray_without_forced_dim() {
use ratatui::style::Modifier;
// GrokNight paints real RGB grays; muted/dim must not invent DIM.
let theme = Theme::groknight();
assert!(!matches!(theme.gray, Color::Reset));
let muted = theme.muted();
assert_eq!(muted.fg, Some(theme.gray));
assert!(
!muted.add_modifier.contains(Modifier::DIM),
"RGB muted should not force SGR dim: {muted:?}"
);
}
}
@@ -0,0 +1,350 @@
//! TokyoNight theme for the pager.
//!
//! All colors come from the `Theme` struct. NO hardcoded colors elsewhere.
//!
//! The named constants below match the TokyoNight Night/Storm palette from
//! `kigi-tui/src/ui/style.rs` for consistency. The `Theme` struct maps
//! these constants to semantic roles.
use ratatui::style::{Color, Modifier, Style};
/// Helper for concise const Color::Rgb definitions.
const fn rgb(r: u8, g: u8, b: u8) -> Color {
Color::Rgb(r, g, b)
}
// TokyoNight palette constants (Night/Storm variant).
// Keep in sync with kigi-tui TokyoNightNight.
#[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
}
use palette::*;
/// Theme for v3 pager rendering.
#[derive(Debug, Clone, Copy)]
pub struct Theme {
// Backgrounds
pub bg_base: Color,
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)
// Accent colors (for vertical lines)
pub accent_user: Color,
pub accent_assistant: Color,
pub accent_thinking: Color,
pub accent_tool: Color,
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)
// Text colors
pub text_primary: Color,
pub text_secondary: Color,
// 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
// 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
// Search
pub fuzzy_accent: Color, // Highlight color for fuzzy search matches
// Plan mode
pub accent_plan: Color, // Golden accent for plan mode indicator
// Context-window overhead category (context info block)
pub accent_verify: Color, // Violet accent — distinct from plan gold and feedback teal
// Feedback mode
pub accent_feedback: Color, // Teal/green accent for feedback mode
// Remember mode
pub accent_remember: Color, // Green accent for # remember mode
// Selection
pub selection_border: Color,
pub hover_border: Color,
pub prompt_border: Color,
pub prompt_border_active: Color,
// Prompt info
pub accent_model: Color, // Model name in prompt info line
// Scrollbar
pub scrollbar_bg: Color,
pub scrollbar_fg: Color,
// Diff colors
pub diff_delete_bg: Color,
pub diff_delete_fg: Color,
pub diff_insert_bg: Color,
pub diff_insert_fg: Color,
pub diff_equal_fg: Color,
pub diff_gutter_fg: Color,
// Visual selection / dropdown selection background
pub bg_visual: Color,
// Paste elements (chip + preview overlay)
pub paste_bg: Color,
pub paste_fg: Color,
pub paste_dim: Color,
// Markdown rendering colors — used by md_style.rs for headings, code
// 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
}
impl Theme {
/// TokyoNight Storm theme.
pub const fn tokyonight() -> Self {
Self {
bg_base: BG_STORM,
bg_light: BG_HIGHLIGHT,
bg_dark: BG_HIGHLIGHT,
bg_highlight: BG_HIGHLIGHT,
bg_hover: rgb(40, 49, 76),
bg_terminal: BG,
accent_user: BLUE,
accent_assistant: MAGENTA,
accent_thinking: FG_GUTTER,
accent_tool: DARK5,
accent_system: BLUE,
accent_error: RED,
accent_success: GREEN,
accent_running: MAGENTA,
accent_skill: rgb(100, 180, 170), // Muted teal
text_primary: FG,
text_secondary: FG_DARK,
gray_dim: FG_GUTTER,
gray: COMMENT,
gray_bright: DARK5,
command: YELLOW,
path: ORANGE,
running: CYAN,
warning: YELLOW,
fuzzy_accent: BLUE,
accent_plan: rgb(230, 180, 50), // #E6B432 — golden
accent_verify: MAGENTA, // #bb9af7 — violet (distinct from plan / feedback)
accent_feedback: GREEN1, // #73daca — warm teal/green
accent_remember: Color::Rgb(139, 195, 74), // #8BC34A — Material Design light green
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
hover_border: rgb(55, 58, 80),
accent_model: TEAL,
scrollbar_bg: BG_STORM_DARK,
scrollbar_fg: BG_HIGHLIGHT,
diff_delete_bg: rgb(85, 15, 20),
diff_delete_fg: RED,
diff_insert_bg: rgb(15, 65, 20),
diff_insert_fg: GREEN,
diff_equal_fg: COMMENT,
diff_gutter_fg: COMMENT,
bg_visual: rgb(40, 52, 87), // #283457 — blue-tinted selection bg
paste_bg: BG_STORM_DARK,
paste_fg: FG_DARK,
paste_dim: FG_GUTTER,
// paste_bg: BG_HIGHLIGHT,
// paste_fg: DARK5,
// paste_dim: COMMENT,
md_heading_h1: TEAL,
md_heading_h1_mod: Modifier::BOLD,
md_heading_h2: BLUE,
md_heading_h2_mod: Modifier::BOLD,
md_heading_h3: ORANGE,
md_heading_h3_mod: Modifier::BOLD,
md_heading_h4: RED,
md_heading_h4_mod: Modifier::BOLD,
md_heading_h5: GREEN,
md_heading_h5_mod: Modifier::BOLD,
md_heading_h6: MAGENTA,
md_heading_h6_mod: Modifier::BOLD,
md_code: GREEN1,
md_task_checked: CYAN,
md_task_unchecked: BLUE,
md_muted: COMMENT,
md_code_bg: BG_HIGHLIGHT,
md_text: FG,
link_fg: BLUE, // #7aa2f7
}
}
/// Get a style with the given foreground color.
pub const fn fg(&self, color: Color) -> Style {
Style::new().fg(color)
}
/// Get a style with muted text (gray — medium).
///
/// When `gray` is [`Color::Reset`] (terminal-native / minimal palette),
/// de-emphasize with [`Modifier::DIM`] instead of painting ANSI bright
/// black — dim scales the terminal's own default fg, so contrast stays
/// polarity-safe. RGB themes keep an explicit gray foreground.
pub const fn muted(&self) -> Style {
match self.gray {
Color::Reset => Style::new().add_modifier(Modifier::DIM),
c => Style::new().fg(c),
}
}
/// Style for OSC 8 hyperlink overlay text.
pub fn link_style(&self) -> Style {
Style::new()
.fg(self.link_fg)
.add_modifier(ratatui::style::Modifier::UNDERLINED)
}
/// Get a style with dim text (gray_dim — dimmest).
///
/// Same Reset→DIM rule as [`Self::muted`] for the terminal-native palette.
pub const fn dim(&self) -> Style {
match self.gray_dim {
Color::Reset => Style::new().add_modifier(Modifier::DIM),
c => Style::new().fg(c),
}
}
/// Get a style for primary text.
pub const fn primary(&self) -> Style {
Style::new().fg(self.text_primary)
}
/// Get a bold style.
pub const fn bold(&self) -> Style {
Style::new().add_modifier(Modifier::BOLD)
}
}
/// Compute animated brightness for a traveling wave effect.
///
/// Creates a wave that travels along the accent line. Each row has a fixed phase
/// offset so the wave appears to move smoothly regardless of block height.
///
/// # Arguments
/// - `tick`: Frame counter (increments each render tick)
/// - `row`: Current row within the block (0 = top)
/// - `wave_rows`: Rows per full wave cycle (e.g., 32)
/// - `speed`: Wave speed (radians per tick, e.g., 0.15)
///
/// # Returns
/// Brightness value in [0.0, 1.0] for this row at this tick.
pub fn wave_brightness(tick: u64, row: u16, wave_rows: u16, speed: f32) -> f32 {
use std::f32::consts::PI;
let rows_per_wave = wave_rows.max(1) as f32;
let phase = (row as f32 / rows_per_wave) * 2.0 * PI;
// Time-based oscillation
let t = tick as f32 * speed;
// sin²(t + phase) gives smooth 0-1 oscillation
let sin_val = (t + phase).sin();
sin_val * sin_val
}
/// Compute a smooth pulsing brightness for a single element (icon, indicator).
///
/// Unlike [`wave_brightness`] which creates a spatial wave across rows,
/// this is a simple temporal pulse: all elements sharing the same tick
/// pulse in unison.
///
/// # Arguments
/// - `tick`: Frame counter (increments each render tick, ~30fps)
/// - `speed`: Pulse speed (radians per tick). The returned value uses
/// `sin²`, which has period π, so the visible bright→dim→bright cycle
/// is `π / (speed * fps)`. At 30fps, `speed = 0.08` ≈ 1.3s per cycle;
/// for a 2.5s cycle pass `speed ≈ 0.042`.
///
/// # Returns
/// Brightness value in [0.0, 1.0].
pub fn pulse_brightness(tick: u64, speed: f32) -> f32 {
let t = tick as f32 * speed;
let sin_val = t.sin();
sin_val * sin_val
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_tokyonight_theme() {
let theme = Theme::tokyonight();
assert!(matches!(theme.bg_base, Color::Rgb(36, 40, 59)));
assert!(matches!(theme.accent_user, Color::Rgb(122, 162, 247)));
}
}
@@ -0,0 +1,442 @@
//! Shared utility functions.
use std::borrow::Cow;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
pub use kigi_config::kigi_home;
/// Path to `$KIGI_SHARE_DIR/pager.toml`.
pub fn pager_toml_path() -> PathBuf {
kigi_home().join("pager.toml")
}
/// User-facing label for the user grok directory (``~/.kigi`` or ``$KIGI_SHARE_DIR``).
///
/// Derived from resolved [`kigi_home()`] vs `kigi_config::default_kigi_home()`,
/// not from whether `KIGI_SHARE_DIR` is set in the environment.
pub fn display_kigi_home_prefix() -> String {
if kigi_home() == kigi_config::default_kigi_home() {
"~/.kigi".to_string()
} else {
"$KIGI_SHARE_DIR".to_string()
}
}
/// User-facing path under [`kigi_home()`], e.g. ``~/.kigi/config.toml``.
pub fn display_user_grok_path(relative: impl AsRef<Path>) -> String {
let rel = relative.as_ref();
let prefix = display_kigi_home_prefix();
if rel.as_os_str().is_empty() {
return prefix;
}
format!("{prefix}/{}", rel.display())
}
/// Abbreviate an absolute path for display: prefer [`kigi_home()`], then `$HOME`.
pub fn abbreviate_path(path: &str) -> Cow<'_, str> {
let path_buf = Path::new(path);
let grok = kigi_home();
if let Ok(rest) = path_buf.strip_prefix(&grok) {
let prefix = display_kigi_home_prefix();
if rest.as_os_str().is_empty() {
return Cow::Owned(prefix);
}
return Cow::Owned(format!("{prefix}/{}", rest.display()));
}
if let Ok(home) = std::env::var("HOME")
&& !home.is_empty()
&& let Some(rest) = path.strip_prefix(&home)
{
if rest.is_empty() {
return Cow::Borrowed("~");
}
if rest.starts_with('/') {
return Cow::Owned(format!("~{rest}"));
}
}
Cow::Borrowed(path)
}
/// True when `path` is under user [`kigi_home()`] (not project `{cwd}/.kigi`).
pub fn is_under_user_kigi_home(path: &Path) -> bool {
path.starts_with(kigi_home())
}
/// Format a duration as a compact human-friendly string.
///
/// Uses consistent rounding for visual stability:
/// - Under 10s: `"5.2s"` (one decimal for granularity)
/// - 10-59s: `"32s"` (no decimal)
/// - 1m-59m: `"2m5s"`
/// - 1h+: `"1h2m"`
pub fn format_duration(d: Duration) -> String {
let total_secs = d.as_secs();
if total_secs < 10 {
return format!("{:.1}s", d.as_secs_f64());
}
if total_secs < 60 {
return format!("{total_secs}s");
}
let mins = total_secs / 60;
let secs = total_secs % 60;
if mins < 60 {
return format!("{mins}m{secs}s");
}
let hours = mins / 60;
let remaining_mins = mins % 60;
format!("{hours}h{remaining_mins}m")
}
/// Format a duration as a coarse recency string for "time ago" / age
/// displays (e.g. dashboard row age column and peek panel prefix).
///
/// Buckets chosen for the agent dashboard so the column stays compact
/// and doesn't distract with second-level churn:
/// - < 1 minute: `"just now"`
/// - minutes: `"1m"` … `"59m"`
/// - hours: `"1h"` … `"23h"`
/// - days: `"1d"` … `"29d"`
/// - months (≈30d+): `"1mo"` … `"11mo"`
/// - years (≈365d+): `"1y"` …
pub fn format_time_ago(d: Duration) -> String {
let secs = d.as_secs();
if secs < 60 {
return "just now".to_string();
}
if secs < 3600 {
let mins = secs / 60;
return format!("{mins}m");
}
if secs < 86400 {
let hours = secs / 3600;
return format!("{hours}h");
}
let days = secs / 86400;
if days < 30 {
return format!("{days}d");
}
if days < 365 {
let months = days / 30;
return format!("{months}mo");
}
let years = days / 365;
format!("{years}y")
}
/// Convert unix-epoch millis into a wall-clock [`SystemTime`].
///
/// Used for dashboard recency that originates as a wall-clock timestamp (the
/// leader roster's `last_change_unix_ms`). A non-positive value — the
/// `#[serde(default)]` `0` sentinel for a missing roster timestamp — falls
/// back to "now".
pub fn system_time_from_unix_ms(unix_ms: i64) -> SystemTime {
if unix_ms <= 0 {
return SystemTime::now();
}
UNIX_EPOCH
.checked_add(Duration::from_millis(unix_ms as u64))
.unwrap_or_else(SystemTime::now)
}
/// Project a monotonic [`Instant`] onto the wall clock as the [`SystemTime`]
/// it corresponds to (`SystemTime::now() - instant.elapsed()`).
///
/// The dashboard stores row recency as a wall-clock `SystemTime` so on-disk
/// roster timestamps (which can predate this process — even the machine's
/// boot — and so are unrepresentable as a monotonic `Instant`) sit in the same
/// comparable space as local rows. Local rows hold live `Instant` anchors;
/// this maps them across. A fixed anchor ages correctly because only `now`
/// advances, and the sub-millisecond skew between the two `now()` samples is
/// invisible to the minute-granularity [`format_time_ago`] buckets.
pub fn system_time_from_instant(instant: Instant) -> SystemTime {
SystemTime::now()
.checked_sub(instant.elapsed())
.unwrap_or_else(SystemTime::now)
}
/// Decode common HTML entities (`&amp;`, `&lt;`, `&gt;`, `&quot;`, `&#39;`)
/// that may appear in LLM-generated session summaries.
pub fn decode_html_entities(s: &str) -> std::borrow::Cow<'_, str> {
if !s.contains('&') {
return std::borrow::Cow::Borrowed(s);
}
let mut out = s.to_string();
out = out.replace("&amp;", "&");
out = out.replace("&lt;", "<");
out = out.replace("&gt;", ">");
out = out.replace("&quot;", "\"");
out = out.replace("&#39;", "'");
out = out.replace("&#x27;", "'");
out = out.replace("&apos;", "'");
std::borrow::Cow::Owned(out)
}
pub fn parse_schedule_interval_secs(human: &str) -> Option<u64> {
let s = human.trim_start();
if !s.starts_with("every ") {
return None;
}
let rest = s[6..].trim_start();
let (num_str, unit) = if let Some(sp) = rest.find(char::is_whitespace) {
(&rest[..sp], &rest[sp + 1..])
} else if rest.len() >= 2 {
let (d, u) = rest.split_at(rest.len() - 1);
(d, u)
} else {
return None;
};
let n: u64 = num_str.parse().ok()?;
let unit = unit.trim();
let secs_per = match unit {
"s" | "second" | "seconds" => 1,
"m" | "minute" | "minutes" => 60,
"h" | "hour" | "hours" => 3600,
"d" | "day" | "days" => 86400,
_ => return None,
};
Some(n * secs_per)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn subsecond() {
assert_eq!(format_duration(Duration::from_millis(500)), "0.5s");
}
#[test]
fn under_ten_seconds() {
assert_eq!(format_duration(Duration::from_secs_f64(5.23)), "5.2s");
}
#[test]
fn ten_seconds_no_decimal() {
assert_eq!(format_duration(Duration::from_secs(10)), "10s");
}
#[test]
fn seconds_no_decimal() {
assert_eq!(format_duration(Duration::from_secs_f64(12.3)), "12s");
}
#[test]
fn thirty_seconds() {
assert_eq!(format_duration(Duration::from_secs(30)), "30s");
}
#[test]
fn minutes() {
assert_eq!(format_duration(Duration::from_secs(125)), "2m5s");
}
#[test]
fn hours() {
assert_eq!(format_duration(Duration::from_secs(3725)), "1h2m");
}
#[test]
fn time_ago_just_now() {
assert_eq!(format_time_ago(Duration::from_secs(0)), "just now");
assert_eq!(format_time_ago(Duration::from_secs(30)), "just now");
assert_eq!(format_time_ago(Duration::from_secs(59)), "just now");
}
#[test]
fn time_ago_minutes() {
assert_eq!(format_time_ago(Duration::from_secs(60)), "1m");
assert_eq!(format_time_ago(Duration::from_secs(125)), "2m");
assert_eq!(format_time_ago(Duration::from_secs(3599)), "59m");
}
#[test]
fn time_ago_hours() {
assert_eq!(format_time_ago(Duration::from_secs(3600)), "1h");
assert_eq!(format_time_ago(Duration::from_secs(7200)), "2h");
assert_eq!(format_time_ago(Duration::from_secs(86399)), "23h");
}
#[test]
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
}
#[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(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");
}
#[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(63_072_000)), "2y");
}
fn now_unix_ms() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_millis() as i64
}
/// A real past timestamp survives the round-trip and renders its true age —
/// including ages beyond the machine's uptime, which a monotonic `Instant`
/// could not represent (its floor is system boot).
#[test]
fn system_time_from_unix_ms_renders_real_age() {
let two_hours_ago = now_unix_ms() - 2 * 3_600_000;
let elapsed = system_time_from_unix_ms(two_hours_ago)
.elapsed()
.unwrap_or_default();
assert_eq!(format_time_ago(elapsed), "2h");
let forty_five_days_ago = now_unix_ms() - 45 * 86_400_000;
let elapsed = system_time_from_unix_ms(forty_five_days_ago)
.elapsed()
.unwrap_or_default();
assert_eq!(format_time_ago(elapsed), "1mo");
}
/// A zero / missing timestamp (the `#[serde(default)]` sentinel) falls back
/// to "now" rather than the unix epoch (1970).
#[test]
fn system_time_from_unix_ms_zero_falls_back_to_now() {
let elapsed = system_time_from_unix_ms(0).elapsed().unwrap_or_default();
assert!(elapsed.as_secs() < 5, "zero sentinel must fall back to now");
}
/// A future timestamp (clock skew) renders as "just now": `elapsed()` errors
/// on a future `SystemTime`, and callers default that to a zero duration.
#[test]
fn system_time_from_unix_ms_future_renders_just_now() {
let future = now_unix_ms() + 10_000_000;
let elapsed = system_time_from_unix_ms(future)
.elapsed()
.unwrap_or_default();
assert_eq!(format_time_ago(elapsed), "just now");
}
/// A fixed `Instant` projects to a stable wall-clock moment, so its age
/// reflects time-since-anchor (here ~10m) rather than re-anchoring to now.
#[test]
fn system_time_from_instant_reflects_elapsed() {
let ten_min_ago = Instant::now() - Duration::from_secs(600);
let elapsed = system_time_from_instant(ten_min_ago)
.elapsed()
.unwrap_or_default();
assert_eq!(format_time_ago(elapsed), "10m");
}
#[test]
fn parses_every_5_minutes() {
assert_eq!(parse_schedule_interval_secs("every 5 minutes"), Some(300));
}
#[test]
fn parses_every_5m_short() {
assert_eq!(parse_schedule_interval_secs("every 5m"), Some(300));
}
#[test]
fn parses_every_10s() {
assert_eq!(parse_schedule_interval_secs("every 10s"), Some(10));
}
#[test]
fn parses_every_1_hour() {
assert_eq!(parse_schedule_interval_secs("every 1 hour"), Some(3600));
}
#[test]
fn parses_every_1_day() {
assert_eq!(parse_schedule_interval_secs("every 1 day"), Some(86400));
}
#[test]
fn decode_html_entities_no_entities() {
let s = "hello world";
let out = decode_html_entities(s);
assert!(matches!(out, std::borrow::Cow::Borrowed(_)));
assert_eq!(out.as_ref(), s);
}
#[test]
fn decode_html_entities_amp() {
assert_eq!(decode_html_entities("foo &amp; bar").as_ref(), "foo & bar");
}
#[test]
fn decode_html_entities_multiple() {
assert_eq!(
decode_html_entities("1 &lt; 2 &amp;&amp; 3 &gt; 2").as_ref(),
"1 < 2 && 3 > 2"
);
}
#[test]
fn decode_html_entities_quotes() {
assert_eq!(
decode_html_entities("&quot;hello&quot; &amp; &#39;world&#39;").as_ref(),
"\"hello\" & 'world'"
);
}
#[test]
fn unknown_schedule_returns_none() {
assert_eq!(parse_schedule_interval_secs("foo bar"), None);
assert_eq!(parse_schedule_interval_secs("every foo"), None);
assert_eq!(parse_schedule_interval_secs("every 5x"), None);
}
#[test]
fn display_kigi_home_prefix_default_install() {
if std::env::var("KIGI_SHARE_DIR").is_ok() {
return;
}
assert_eq!(display_kigi_home_prefix(), "~/.kigi");
}
#[test]
fn display_user_grok_path_joins_relative() {
let path = display_user_grok_path("config.toml");
assert!(path.ends_with("/config.toml") || path.ends_with("\\config.toml"));
assert!(path.contains(".kigi") || path.contains("$KIGI_SHARE_DIR"));
}
#[test]
fn abbreviate_path_uses_home_when_under_default_grok() {
if let Ok(home) = std::env::var("HOME") {
if home.is_empty() {
return;
}
let full = format!("{home}/.kigi/memory/MEMORY.md");
let abbreviated = abbreviate_path(&full);
assert!(
abbreviated.contains("memory/MEMORY.md"),
"got {abbreviated}"
);
}
}
#[test]
#[serial_test::serial]
fn abbreviate_path_empty_home_does_not_fake_tilde() {
let prev = std::env::var("HOME").ok();
unsafe {
std::env::set_var("HOME", "");
}
assert_eq!(abbreviate_path("/foo").as_ref(), "/foo");
match prev {
Some(home) => unsafe { std::env::set_var("HOME", home) },
None => unsafe { std::env::remove_var("HOME") },
}
}
}