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:
@@ -0,0 +1,298 @@
|
||||
//! Layout cache for the list pane.
|
||||
//!
|
||||
//! Tracks per-item heights and prefix sums so that scroll-position ↔ item-index
|
||||
//! conversions are fast. Two variants:
|
||||
//!
|
||||
//! - [`FixedHeight`] — all items have height 1 (NoWrap mode). Everything is O(1).
|
||||
//! - [`Variable`] — items have different heights (Wrap mode). Uses a prefix-sum
|
||||
//! vec for O(log n) position lookups.
|
||||
|
||||
/// Wrap mode for the list pane.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum WrapMode {
|
||||
/// Soft-wrap lines at viewport width. Variable height per item.
|
||||
/// Requires full layout cache.
|
||||
Wrap,
|
||||
/// No wrapping — each item is exactly 1 visual line, truncated with `…`.
|
||||
/// Layout is trivial O(1).
|
||||
NoWrap,
|
||||
}
|
||||
|
||||
/// Layout cache — an enum to support the fixed-height fast path.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ListLayoutCache {
|
||||
/// All items are height 1 (NoWrap mode). No allocation needed.
|
||||
FixedHeight {
|
||||
/// Number of items (= total height in visual lines).
|
||||
count: usize,
|
||||
},
|
||||
/// Variable-height items (Wrap mode).
|
||||
Variable {
|
||||
/// Width at which heights were computed.
|
||||
width: u16,
|
||||
/// Per-item heights (indexed by *visible* index when filtered).
|
||||
heights: Vec<u16>,
|
||||
/// Prefix sums: `prefix_sums[i]` = sum of `heights[0..i]`.
|
||||
///
|
||||
/// Length = `heights.len() + 1`. `prefix_sums[0] = 0`.
|
||||
/// `prefix_sums[n] = total_height`.
|
||||
prefix_sums: Vec<usize>,
|
||||
},
|
||||
}
|
||||
|
||||
impl ListLayoutCache {
|
||||
// -----------------------------------------------------------------------
|
||||
// Constructors
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Create a fixed-height cache for `count` items (all height 1).
|
||||
pub fn fixed(count: usize) -> Self {
|
||||
Self::FixedHeight { count }
|
||||
}
|
||||
|
||||
/// Build a variable-height cache from an iterator of per-item heights.
|
||||
pub fn from_heights(width: u16, heights: impl IntoIterator<Item = u16>) -> Self {
|
||||
let heights: Vec<u16> = heights.into_iter().collect();
|
||||
let mut prefix_sums = Vec::with_capacity(heights.len() + 1);
|
||||
prefix_sums.push(0);
|
||||
for &h in &heights {
|
||||
let prev = *prefix_sums.last().unwrap();
|
||||
prefix_sums.push(prev + h as usize);
|
||||
}
|
||||
Self::Variable {
|
||||
width,
|
||||
heights,
|
||||
prefix_sums,
|
||||
}
|
||||
}
|
||||
|
||||
/// Extend an existing `Variable` cache with additional item heights.
|
||||
///
|
||||
/// Used for **incremental append**: when new items arrive, we compute
|
||||
/// heights only for the new items and extend the prefix-sum array.
|
||||
///
|
||||
/// Panics if `self` is `FixedHeight` — caller must ensure the mode matches.
|
||||
pub fn extend_heights(&mut self, new_heights: impl IntoIterator<Item = u16>) {
|
||||
match self {
|
||||
Self::Variable {
|
||||
heights,
|
||||
prefix_sums,
|
||||
..
|
||||
} => {
|
||||
for h in new_heights {
|
||||
let prev = *prefix_sums.last().unwrap();
|
||||
prefix_sums.push(prev + h as usize);
|
||||
heights.push(h);
|
||||
}
|
||||
}
|
||||
Self::FixedHeight { .. } => {
|
||||
panic!("extend_heights called on FixedHeight cache");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Queries
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Total height in visual lines.
|
||||
pub fn total_height(&self) -> usize {
|
||||
match self {
|
||||
Self::FixedHeight { count } => *count,
|
||||
Self::Variable { prefix_sums, .. } => *prefix_sums.last().unwrap_or(&0),
|
||||
}
|
||||
}
|
||||
|
||||
/// Number of items in the cache.
|
||||
pub fn item_count(&self) -> usize {
|
||||
match self {
|
||||
Self::FixedHeight { count } => *count,
|
||||
Self::Variable { heights, .. } => heights.len(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Virtual-y position (in visual lines from top) of item at `idx`.
|
||||
///
|
||||
/// For `FixedHeight`, this is just `idx`.
|
||||
pub fn virtual_y(&self, idx: usize) -> usize {
|
||||
match self {
|
||||
Self::FixedHeight { .. } => idx,
|
||||
Self::Variable { prefix_sums, .. } => prefix_sums.get(idx).copied().unwrap_or(0),
|
||||
}
|
||||
}
|
||||
|
||||
/// Height of item at `idx` in visual lines.
|
||||
pub fn item_height(&self, idx: usize) -> u16 {
|
||||
match self {
|
||||
Self::FixedHeight { .. } => 1,
|
||||
Self::Variable { heights, .. } => heights.get(idx).copied().unwrap_or(1),
|
||||
}
|
||||
}
|
||||
|
||||
/// Find the item index whose virtual-y range contains `y`.
|
||||
///
|
||||
/// For `FixedHeight`, this is just `y` (clamped to `count - 1`).
|
||||
/// For `Variable`, binary search on prefix sums — O(log n).
|
||||
///
|
||||
/// Returns `None` if the cache is empty.
|
||||
pub fn item_at_y(&self, y: usize) -> Option<usize> {
|
||||
match self {
|
||||
Self::FixedHeight { count } => {
|
||||
if *count == 0 {
|
||||
None
|
||||
} else {
|
||||
Some(y.min(*count - 1))
|
||||
}
|
||||
}
|
||||
Self::Variable { prefix_sums, .. } => {
|
||||
if prefix_sums.len() <= 1 {
|
||||
return None; // empty
|
||||
}
|
||||
// Binary search: find the largest i such that prefix_sums[i] <= y.
|
||||
// partition_point returns the first index where prefix_sums[i] > y,
|
||||
// so we subtract 1.
|
||||
let pos = prefix_sums.partition_point(|&s| s <= y);
|
||||
let idx = pos.saturating_sub(1);
|
||||
// Clamp to valid item range
|
||||
let max_idx = prefix_sums.len() - 2; // last valid item index
|
||||
Some(idx.min(max_idx))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Width at which this cache was computed (only meaningful for `Variable`).
|
||||
pub fn cached_width(&self) -> Option<u16> {
|
||||
match self {
|
||||
Self::FixedHeight { .. } => None,
|
||||
Self::Variable { width, .. } => Some(*width),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Tests
|
||||
// ===========================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn fixed_height_basics() {
|
||||
let cache = ListLayoutCache::fixed(5);
|
||||
assert_eq!(cache.total_height(), 5);
|
||||
assert_eq!(cache.item_count(), 5);
|
||||
assert_eq!(cache.virtual_y(0), 0);
|
||||
assert_eq!(cache.virtual_y(3), 3);
|
||||
assert_eq!(cache.item_height(0), 1);
|
||||
assert_eq!(cache.item_height(4), 1);
|
||||
assert_eq!(cache.item_at_y(0), Some(0));
|
||||
assert_eq!(cache.item_at_y(4), Some(4));
|
||||
// Clamped
|
||||
assert_eq!(cache.item_at_y(100), Some(4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fixed_height_empty() {
|
||||
let cache = ListLayoutCache::fixed(0);
|
||||
assert_eq!(cache.total_height(), 0);
|
||||
assert_eq!(cache.item_count(), 0);
|
||||
assert_eq!(cache.item_at_y(0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn variable_height_basics() {
|
||||
// Items with heights: 3, 1, 2, 4
|
||||
let cache = ListLayoutCache::from_heights(80, vec![3, 1, 2, 4]);
|
||||
assert_eq!(cache.total_height(), 10);
|
||||
assert_eq!(cache.item_count(), 4);
|
||||
|
||||
// virtual_y positions: 0, 3, 4, 6
|
||||
assert_eq!(cache.virtual_y(0), 0);
|
||||
assert_eq!(cache.virtual_y(1), 3);
|
||||
assert_eq!(cache.virtual_y(2), 4);
|
||||
assert_eq!(cache.virtual_y(3), 6);
|
||||
|
||||
assert_eq!(cache.item_height(0), 3);
|
||||
assert_eq!(cache.item_height(1), 1);
|
||||
assert_eq!(cache.item_height(2), 2);
|
||||
assert_eq!(cache.item_height(3), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn variable_height_item_at_y() {
|
||||
// Items with heights: 3, 1, 2, 4 → prefix_sums: [0, 3, 4, 6, 10]
|
||||
let cache = ListLayoutCache::from_heights(80, vec![3, 1, 2, 4]);
|
||||
|
||||
// y=0,1,2 → item 0
|
||||
assert_eq!(cache.item_at_y(0), Some(0));
|
||||
assert_eq!(cache.item_at_y(1), Some(0));
|
||||
assert_eq!(cache.item_at_y(2), Some(0));
|
||||
// y=3 → item 1
|
||||
assert_eq!(cache.item_at_y(3), Some(1));
|
||||
// y=4,5 → item 2
|
||||
assert_eq!(cache.item_at_y(4), Some(2));
|
||||
assert_eq!(cache.item_at_y(5), Some(2));
|
||||
// y=6,7,8,9 → item 3
|
||||
assert_eq!(cache.item_at_y(6), Some(3));
|
||||
assert_eq!(cache.item_at_y(9), Some(3));
|
||||
// y=10+ → clamped to item 3
|
||||
assert_eq!(cache.item_at_y(10), Some(3));
|
||||
assert_eq!(cache.item_at_y(100), Some(3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn variable_height_empty() {
|
||||
let cache = ListLayoutCache::from_heights(80, Vec::<u16>::new());
|
||||
assert_eq!(cache.total_height(), 0);
|
||||
assert_eq!(cache.item_count(), 0);
|
||||
assert_eq!(cache.item_at_y(0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn variable_height_single_item() {
|
||||
let cache = ListLayoutCache::from_heights(80, vec![5]);
|
||||
assert_eq!(cache.total_height(), 5);
|
||||
assert_eq!(cache.item_count(), 1);
|
||||
assert_eq!(cache.virtual_y(0), 0);
|
||||
assert_eq!(cache.item_at_y(0), Some(0));
|
||||
assert_eq!(cache.item_at_y(4), Some(0));
|
||||
assert_eq!(cache.item_at_y(5), Some(0)); // clamped
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cached_width() {
|
||||
let fixed = ListLayoutCache::fixed(5);
|
||||
assert_eq!(fixed.cached_width(), None);
|
||||
|
||||
let var = ListLayoutCache::from_heights(120, vec![1, 2]);
|
||||
assert_eq!(var.cached_width(), Some(120));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extend_heights_appends() {
|
||||
let mut cache = ListLayoutCache::from_heights(80, vec![3, 1]);
|
||||
assert_eq!(cache.item_count(), 2);
|
||||
assert_eq!(cache.total_height(), 4);
|
||||
|
||||
cache.extend_heights(vec![2, 4]);
|
||||
assert_eq!(cache.item_count(), 4);
|
||||
assert_eq!(cache.total_height(), 10);
|
||||
|
||||
// Prefix sums: [0, 3, 4, 6, 10]
|
||||
assert_eq!(cache.virtual_y(0), 0);
|
||||
assert_eq!(cache.virtual_y(1), 3);
|
||||
assert_eq!(cache.virtual_y(2), 4);
|
||||
assert_eq!(cache.virtual_y(3), 6);
|
||||
assert_eq!(cache.item_height(2), 2);
|
||||
assert_eq!(cache.item_height(3), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extend_heights_empty_iter_is_noop() {
|
||||
let mut cache = ListLayoutCache::from_heights(80, vec![3, 1]);
|
||||
cache.extend_heights(Vec::<u16>::new());
|
||||
assert_eq!(cache.item_count(), 2);
|
||||
assert_eq!(cache.total_height(), 4);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
//! Generic scrollable list pane widget.
|
||||
//!
|
||||
//! `ListPaneState` + `ListPane<T>` provide a reusable, scrollable, selectable
|
||||
//! list component. The state is non-generic and owns only scroll/selection/layout
|
||||
//! data; item data lives in an external model and is borrowed via
|
||||
//! [`ListPaneState::prepare_layout`].
|
||||
//!
|
||||
//! Designed for three concrete use cases:
|
||||
//! - **Tracing pane** (100K+ entries, append-only, NoWrap, follow mode)
|
||||
//! - **Todo pane** (<10 items, random mutations, Wrap)
|
||||
//! - **Background task pane** (<10 items, random mutations, NoWrap)
|
||||
|
||||
mod layout;
|
||||
mod render;
|
||||
mod state;
|
||||
|
||||
pub use crate::search::QueryKind;
|
||||
pub use layout::{ListLayoutCache, WrapMode};
|
||||
pub use render::ListPane;
|
||||
pub use state::{
|
||||
FilterMatcher, InputBarMode, ListFilter, ListMatcher, ListPaneConfig, ListPaneState, MatchMode,
|
||||
};
|
||||
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::Color;
|
||||
use ratatui::text::Line;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ListPaneStyle — configurable colors for the framework's post-pass overlays
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Visual style configuration for a `ListPane`.
|
||||
///
|
||||
/// Controls colors for selection highlighting, input bar, and other
|
||||
/// framework-level overlays. Match highlights use style inversion
|
||||
/// (REVERSED modifier) and don't need configurable colors.
|
||||
///
|
||||
/// Items do **not** need to know about these — the framework applies them
|
||||
/// as post-passes after each item renders.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct ListPaneStyle {
|
||||
/// Background color for the selected item row (cursor line).
|
||||
pub selection_bg: Color,
|
||||
|
||||
/// Background color for the visual selection range (not the cursor line).
|
||||
/// Slightly distinct from cursor bg, distinguishing range from cursor.
|
||||
pub visual_select_bg: Color,
|
||||
|
||||
/// Background color for the input bar (search/filter).
|
||||
pub input_bar_bg: Color,
|
||||
|
||||
/// Foreground color for the prompt prefix (`/`, `f>`).
|
||||
pub input_bar_prompt_fg: Color,
|
||||
|
||||
/// Foreground color for the typed query text.
|
||||
pub input_bar_text_fg: Color,
|
||||
|
||||
/// Scrollbar track background color.
|
||||
pub scrollbar_bg: Color,
|
||||
|
||||
/// Scrollbar thumb foreground color.
|
||||
pub scrollbar_fg: Color,
|
||||
|
||||
/// Corner indicator color (▲ ▼ for scroll position hints).
|
||||
pub indicator_fg: Color,
|
||||
|
||||
/// Follow mode indicator color (▶ in bottom-right when following).
|
||||
/// Distinct from `indicator_fg` so it's visible against content.
|
||||
pub follow_indicator_fg: Color,
|
||||
|
||||
/// "Copied!" toast foreground color.
|
||||
pub toast_fg: Color,
|
||||
|
||||
/// When true, the cursor line uses `visual_select_bg` when inside a
|
||||
/// visual selection (uniform range appearance). The cursor is then
|
||||
/// distinguished only by the `prefix_cursor` style, not by background.
|
||||
///
|
||||
/// When false (default), the cursor line always uses `selection_bg`,
|
||||
/// even within a visual selection.
|
||||
pub uniform_visual_bg: bool,
|
||||
|
||||
/// When false, the right-corner scroll indicators (▲/▼) are suppressed.
|
||||
/// Used by panes that draw their own scroll affordance (e.g. the tasks
|
||||
/// pane draws the same ▲/▼ centered on dedicated rows). Defaults to `true`.
|
||||
pub show_corner_indicators: bool,
|
||||
}
|
||||
|
||||
impl Default for ListPaneStyle {
|
||||
fn default() -> Self {
|
||||
let theme = crate::theme::Theme::current();
|
||||
Self {
|
||||
// Palette defaults — sourced from theme to ensure quantization.
|
||||
selection_bg: theme.bg_highlight,
|
||||
visual_select_bg: theme.bg_visual,
|
||||
input_bar_bg: theme.bg_base,
|
||||
input_bar_prompt_fg: theme.command,
|
||||
input_bar_text_fg: theme.text_secondary,
|
||||
scrollbar_bg: theme.bg_base,
|
||||
scrollbar_fg: theme.scrollbar_fg,
|
||||
indicator_fg: theme.gray,
|
||||
follow_indicator_fg: theme.command,
|
||||
toast_fg: theme.accent_user,
|
||||
uniform_visual_bg: false,
|
||||
show_corner_indicators: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ListItem trait
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Trait that items in a `ListPane` must implement.
|
||||
///
|
||||
/// Items are owned by the **model** (not the view). The view borrows them
|
||||
/// through `&[T]` in [`ListPaneState::prepare_layout`] and
|
||||
/// [`ListPane::new`].
|
||||
///
|
||||
/// ## Rendering: two modes
|
||||
///
|
||||
/// **Content-based (preferred):** implement [`content()`] and optionally
|
||||
/// [`prefix()`]. The framework handles wrapping, truncation, highlighting,
|
||||
/// and selection overlays automatically. This is the right choice for most
|
||||
/// items.
|
||||
///
|
||||
/// **Custom rendering (escape hatch):** override [`render()`] to paint
|
||||
/// directly into a buffer. Use this only when the content/prefix model
|
||||
/// doesn't fit (e.g. diff hunks with side-by-side layout). You must also
|
||||
/// override [`desired_height()`] when using custom rendering.
|
||||
///
|
||||
/// Items that implement [`content()`] (non-empty Line) get framework
|
||||
/// rendering; the default [`render()`] and [`desired_height()`] are derived
|
||||
/// automatically. Items that override [`render()`] bypass the framework.
|
||||
pub trait ListItem {
|
||||
// =======================================================================
|
||||
// Content-based API (preferred)
|
||||
// =======================================================================
|
||||
|
||||
/// The styled content to display — one logical line of text.
|
||||
///
|
||||
/// The framework handles wrapping (Wrap mode) and truncation (NoWrap mode)
|
||||
/// based on this content. Return a reference to a stored `Line`.
|
||||
///
|
||||
/// Default returns an empty `Line` (signals "use custom `render()`").
|
||||
fn content(&self) -> &Line<'_> {
|
||||
static EMPTY: std::sync::LazyLock<Line<'static>> = std::sync::LazyLock::new(Line::default);
|
||||
&EMPTY
|
||||
}
|
||||
|
||||
/// Optional prefix column (checkbox, spinner, timestamp, etc.).
|
||||
///
|
||||
/// Rendered in a fixed-width column at the left edge of the item.
|
||||
/// In Wrap mode, continuation lines are indented by the prefix width.
|
||||
///
|
||||
/// Returned by value since prefixes are small and often constructed
|
||||
/// dynamically (spinner frame, elapsed timer, checkbox toggle).
|
||||
fn prefix(&self) -> Option<Line<'_>> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Optional prefix for items in the visual selection range (not the cursor line).
|
||||
///
|
||||
/// Called instead of `prefix()` when the item is in the visual selection
|
||||
/// range but NOT the cursor line. Default falls back to `prefix()`.
|
||||
fn prefix_in_selection(&self) -> Option<Line<'_>> {
|
||||
self.prefix()
|
||||
}
|
||||
|
||||
/// Optional prefix for the cursor line (the focused/active item).
|
||||
///
|
||||
/// Called instead of `prefix()` when the item is the cursor line.
|
||||
/// Default falls back to `prefix()`.
|
||||
fn prefix_cursor(&self) -> Option<Line<'_>> {
|
||||
self.prefix()
|
||||
}
|
||||
|
||||
/// Optional full-width background color for this item.
|
||||
///
|
||||
/// When `Some(color)`, the framework fills the entire item row(s) with
|
||||
/// this background color before rendering content. Used for code blocks
|
||||
/// in markdown viewers.
|
||||
fn background(&self) -> Option<Color> {
|
||||
None
|
||||
}
|
||||
|
||||
// =======================================================================
|
||||
// Custom rendering API (escape hatch)
|
||||
// =======================================================================
|
||||
|
||||
/// Render this item into the given area.
|
||||
///
|
||||
/// Override this **only** when the content/prefix model doesn't fit.
|
||||
/// When using the content-based API, leave this as the default (no-op).
|
||||
///
|
||||
/// The framework calls this only when `content()` returns an empty Line.
|
||||
fn render(&self, _area: Rect, _buf: &mut Buffer, _selected: bool, _focused: bool) {}
|
||||
|
||||
/// Height in visual lines at the given `width` when soft-wrapping.
|
||||
///
|
||||
/// In `NoWrap` mode the pane ignores this and uses height = 1.
|
||||
/// Must be ≥ 1.
|
||||
///
|
||||
/// Default implementation computes from [`content()`] and [`prefix()`].
|
||||
/// Override only when using custom [`render()`].
|
||||
fn desired_height(&self, width: u16) -> u16 {
|
||||
if width == 0 {
|
||||
return 1;
|
||||
}
|
||||
let prefix_w = self.prefix().map(|p| line_display_width(&p)).unwrap_or(0);
|
||||
let content_w = line_display_width(self.content());
|
||||
if content_w == 0 {
|
||||
return 1;
|
||||
}
|
||||
let text_area = (width as usize).saturating_sub(prefix_w);
|
||||
if text_area == 0 {
|
||||
return 1;
|
||||
}
|
||||
// Use actual word-wrap line count via textwrap (not character-count
|
||||
// division). The cheap ceil(chars/width) estimate underestimates
|
||||
// because word-aware wrapping produces more lines when words can't
|
||||
// fit at line boundaries.
|
||||
//
|
||||
// We use textwrap::wrap directly (cheap — just computes break
|
||||
// positions) rather than word_wrap_line (expensive — builds styled
|
||||
// Lines). Uses the same FirstFit options as the rendering pipeline.
|
||||
let flat: String = self
|
||||
.content()
|
||||
.spans
|
||||
.iter()
|
||||
.map(|s| s.content.as_ref())
|
||||
.collect();
|
||||
let opts = textwrap::Options::new(text_area)
|
||||
.wrap_algorithm(textwrap::WrapAlgorithm::FirstFit)
|
||||
.break_words(true);
|
||||
(textwrap::wrap(&flat, opts).len() as u16).max(1)
|
||||
}
|
||||
|
||||
// =======================================================================
|
||||
// Identity & behavior
|
||||
// =======================================================================
|
||||
|
||||
/// Stable identity that survives insertions, removals, and reordering.
|
||||
///
|
||||
/// Must be unique within the list. Used so that selection state persists
|
||||
/// across mutations without index arithmetic.
|
||||
fn stable_id(&self) -> u64;
|
||||
|
||||
/// Whether this item can be selected. Return `false` for separator rows.
|
||||
fn is_selectable(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// Source line number for goto-line (`:N`) navigation.
|
||||
///
|
||||
/// When items have a meaningful source line number (e.g., file viewer
|
||||
/// lines), return `Some(n)` so goto-line targets the correct item even
|
||||
/// when the visual index differs (e.g., interleaved comment lines).
|
||||
/// Return `None` (default) to use the visual index.
|
||||
fn goto_line_number(&self) -> Option<usize> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Whether this item needs periodic tick updates (e.g. elapsed timer).
|
||||
fn needs_tick(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
// =======================================================================
|
||||
// Search / filter
|
||||
// =======================================================================
|
||||
|
||||
/// Plain text for search/filter matching.
|
||||
///
|
||||
/// The framework calls `regex.is_match(item.search_text())` during
|
||||
/// filtering and `regex.find_iter(item.search_text())` for highlight
|
||||
/// rendering. Byte offsets in this string correspond to the text
|
||||
/// content rendered starting at column [`search_text_col_offset`].
|
||||
///
|
||||
/// Default returns `""` (item not searchable/filterable).
|
||||
fn search_text(&self) -> &str {
|
||||
""
|
||||
}
|
||||
|
||||
/// Column offset where `search_text()` content begins in the rendered output.
|
||||
///
|
||||
/// The framework uses this to position match highlights correctly.
|
||||
///
|
||||
/// Default derives from [`prefix()`] display width. Override only
|
||||
/// when using custom [`render()`] with a non-standard layout.
|
||||
fn search_text_col_offset(&self) -> u16 {
|
||||
self.prefix()
|
||||
.map(|p| line_display_width(&p) as u16)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Text to copy when `y` is pressed.
|
||||
///
|
||||
/// Default extracts plain text from `content()`. Override for items
|
||||
/// that use custom `render()` with empty `content()`.
|
||||
fn copy_text(&self) -> String {
|
||||
self.content()
|
||||
.spans
|
||||
.iter()
|
||||
.map(|s| s.content.as_ref())
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute the display width of a ratatui `Line` (sum of span display widths).
|
||||
pub(crate) fn line_display_width(line: &Line<'_>) -> usize {
|
||||
line.spans
|
||||
.iter()
|
||||
.map(|s| unicode_width::UnicodeWidthStr::width(s.content.as_ref()))
|
||||
.sum()
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user