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:
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