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:
commit
d6c20fc13f
2612 files changed
+1353757
No files matched your search
@@ -0,0 +1,115 @@
|
||||
[package]
|
||||
name = "kigi-pager-render"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license = "Apache-2.0"
|
||||
|
||||
[dependencies]
|
||||
# TTY safety
|
||||
dunce = { workspace = true }
|
||||
kigi-tty-utils = { workspace = true }
|
||||
|
||||
# Rendering
|
||||
ratatui = { workspace = true, features = [
|
||||
"crossterm",
|
||||
"unstable-widget-ref",
|
||||
"unstable-backend-writer",
|
||||
] }
|
||||
ratatui-core = { workspace = true }
|
||||
crossterm = { workspace = true, features = ["event-stream", "bracketed-paste"] }
|
||||
textwrap = { workspace = true }
|
||||
regex = { workspace = true }
|
||||
unicode-width = { workspace = true }
|
||||
unicode-segmentation = { workspace = true }
|
||||
linkify = { workspace = true }
|
||||
tui-scrollbar = { workspace = true }
|
||||
|
||||
# Markdown / syntax highlighting
|
||||
kigi-markdown = { workspace = true }
|
||||
syntect = { workspace = true }
|
||||
anstyle = { workspace = true }
|
||||
supports-color = { workspace = true }
|
||||
|
||||
# Data
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
toml = { workspace = true }
|
||||
toml_edit = { workspace = true, features = ["serde"] }
|
||||
documented = { workspace = true }
|
||||
|
||||
# VTE terminal emulation for native rendering of command output (SGR, cursor, \r)
|
||||
vte = { workspace = true }
|
||||
|
||||
# System appearance detection (dark/light mode)
|
||||
dark-light = "2"
|
||||
|
||||
# Utilities
|
||||
strum = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
tokio = { workspace = true, features = [
|
||||
"sync",
|
||||
"rt",
|
||||
"macros",
|
||||
"rt-multi-thread",
|
||||
"signal",
|
||||
"io-util",
|
||||
"io-std",
|
||||
] }
|
||||
tracing = { workspace = true }
|
||||
|
||||
# Prompt text input
|
||||
kigi-ratatui-textarea = { workspace = true }
|
||||
kigi-ratatui-inline = { workspace = true }
|
||||
|
||||
# ACP
|
||||
agent-client-protocol = { workspace = true }
|
||||
kigi-config = { workspace = true }
|
||||
kigi-shared = { workspace = true }
|
||||
kigi-workspace = { workspace = true }
|
||||
kigi-log = { workspace = true }
|
||||
kigi-tools = { workspace = true }
|
||||
kigi-paths = { path = "../kigi-paths" }
|
||||
|
||||
# Base64 encoding for image data
|
||||
base64 = { workspace = true }
|
||||
|
||||
# Image decoding/resizing for clipboard image validation before send
|
||||
image = { workspace = true, features = ["png", "jpeg", "tiff", "gif", "webp"] }
|
||||
|
||||
# ID generation
|
||||
uuid = { workspace = true, features = ["v4"] }
|
||||
|
||||
# Path computation
|
||||
dirs = { workspace = true }
|
||||
shellexpand = "3.1"
|
||||
|
||||
# Parsing
|
||||
url = { workspace = true }
|
||||
|
||||
# Hot-reload config (dev mode)
|
||||
notify = { workspace = true, optional = true }
|
||||
|
||||
# libc: poll/read for non-blocking terminal reads in theme/osc11.rs.
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = { workspace = true }
|
||||
|
||||
# EnumDisplaySettingsW for primary-display refresh probe (host/display_refresh).
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows-sys = { version = "0.59", features = ["Win32_Graphics_Gdi"] }
|
||||
|
||||
[dev-dependencies]
|
||||
pretty_assertions = { workspace = true }
|
||||
serial_test = { workspace = true }
|
||||
tempfile = { workspace = true }
|
||||
|
||||
[features]
|
||||
# CI builds a single shared rlib variant per crate, so the pager's CI
|
||||
# test target links the same render lib as production. Enabling the test-only
|
||||
# helpers in the CI default set keeps them present for the pager test while
|
||||
# leaving cargo's `default` (and thus cargo production builds) without them.
|
||||
# The helpers are inert unless their mock setters are called.
|
||||
default-bazel = []
|
||||
# Exposes the `#[cfg(test)]` seams (clipboard probe hooks, theme cache/mock
|
||||
# helpers, graphics-protocol override, …) to dependent crates' test builds.
|
||||
# Enable from a dependent's [dev-dependencies] entry only — never in
|
||||
# production. The seams are inert unless their setters are called.
|
||||
test-support = []
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
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 (≈30–60 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:
|
||||
/// - 0–15: standard/bright ANSI colors (uses common xterm defaults)
|
||||
/// - 16–231: 6×6×6 color cube
|
||||
/// - 232–255: 24-step grayscale ramp
|
||||
pub fn indexed_to_rgb(index: u8) -> (u8, u8, u8) {
|
||||
match index {
|
||||
// Standard colors (0–7) — 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 (8–15)
|
||||
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 (16–231)
|
||||
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 (232–255): 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 (16–255).
|
||||
///
|
||||
/// Searches both the 6×6×6 color cube (16–231) and the 24-step grayscale
|
||||
/// ramp (232–255), returning whichever has the smallest squared Euclidean
|
||||
/// distance.
|
||||
pub fn nearest_indexed(r: u8, g: u8, b: u8) -> u8 {
|
||||
// --- nearest in the 6×6×6 color cube (16–231) ---
|
||||
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 (232–255) ---
|
||||
// Ramp values: 8, 18, 28, …, 238 (24 entries)
|
||||
let lum = (r as u16 + g as u16 + b as u16) / 3;
|
||||
let gray_step = if lum <= 3 {
|
||||
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 (0–5) 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 0–15; 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: ®ex::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+200B–200F, U+202A–202E, U+2060–206F, 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 30–37 / 90–97).
|
||||
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 16–255, 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 0–15).
|
||||
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 0–15 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 3–4 digit values, extracts the high byte (`>> 8`) to map to 0–255.
|
||||
/// For 1–2 digit values, uses the value directly as 0–255.
|
||||
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 (0–255) 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 (`&`, `<`, `>`, `"`, `'`)
|
||||
/// 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("&", "&");
|
||||
out = out.replace("<", "<");
|
||||
out = out.replace(">", ">");
|
||||
out = out.replace(""", "\"");
|
||||
out = out.replace("'", "'");
|
||||
out = out.replace("'", "'");
|
||||
out = out.replace("'", "'");
|
||||
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 & bar").as_ref(), "foo & bar");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_html_entities_multiple() {
|
||||
assert_eq!(
|
||||
decode_html_entities("1 < 2 && 3 > 2").as_ref(),
|
||||
"1 < 2 && 3 > 2"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_html_entities_quotes() {
|
||||
assert_eq!(
|
||||
decode_html_entities(""hello" & 'world'").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") },
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user