docs(tui): theme picker, highlight lifecycle, diff rendering contracts

This commit is contained in:
Felipe Coury
2026-02-21 19:21:19 -03:00
parent 5986719f96
commit 75c819e54a
4 changed files with 127 additions and 12 deletions
@@ -42,12 +42,16 @@ const MIN_LIST_WIDTH_FOR_SIDE: u16 = 40;
/// panel when side-by-side layout is active.
const SIDE_CONTENT_GAP: u16 = 2;
/// Width mode for side content when side-by-side layout is active.
/// Controls how the side content panel is sized relative to the popup width.
///
/// When the computed side width falls below `side_content_min_width` or the
/// remaining list area would be narrower than [`MIN_LIST_WIDTH_FOR_SIDE`], the
/// side-by-side layout is abandoned and the stacked fallback is used instead.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum SideContentWidth {
/// Fixed number of columns.
/// Fixed number of columns. `Fixed(0)` disables side content entirely.
Fixed(u16),
/// Exact 50/50 split (minus inter-column gap).
/// Exact 50/50 split of the content area (minus the inter-column gap).
Half,
}
@@ -60,10 +64,13 @@ impl Default for SideContentWidth {
/// One selectable item in the generic selection list.
pub(crate) type SelectionAction = Box<dyn Fn(&AppEventSender) + Send + Sync>;
/// Callback type for when the selection changes (navigation, filter, number-key).
/// Callback invoked whenever the highlighted item changes (arrow keys, search
/// filter, number-key jump). Receives the *actual* index into the unfiltered
/// `items` list and the event sender. Used by the theme picker for live preview.
pub type OnSelectionChangedCallback = Option<Box<dyn Fn(usize, &AppEventSender) + Send + Sync>>;
/// Callback type for when the selection popup is dismissed without accepting (e.g. via Esc or Ctrl+C).
/// Callback invoked when the picker is dismissed without accepting (Esc or
/// Ctrl+C). Used by the theme picker to restore the pre-open theme.
pub type OnCancelCallback = Option<Box<dyn Fn(&AppEventSender) + Send + Sync>>;
/// One row in a [`ListSelectionView`] selection list.
+39 -1
View File
@@ -1,3 +1,23 @@
//! Renders unified diffs with line numbers, gutter signs, and optional syntax
//! highlighting.
//!
//! Each `FileChange` variant (Add / Delete / Update) is rendered as a block of
//! diff lines, each prefixed by a right-aligned line number, a gutter sign
//! (`+` / `-` / ` `), and the content text. When a recognized file extension
//! is present, the content text is syntax-highlighted using
//! [`crate::render::highlight`].
//!
//! **Highlighting strategy for `Update` diffs:** the renderer highlights each
//! hunk as a single concatenated block rather than line-by-line. This
//! preserves syntect's parser state across consecutive lines within a hunk
//! (important for multi-line strings, block comments, etc.). Cross-hunk state
//! is intentionally *not* preserved because hunks are visually separated and
//! re-synchronize at context boundaries anyway.
//!
//! **Wrapping:** long lines are hard-wrapped at the available column width.
//! Syntax-highlighted spans are split at character boundaries with styles
//! preserved across the split so that no color information is lost.
use diffy::Hunk;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
@@ -28,7 +48,11 @@ use crate::render::renderable::Renderable;
use codex_core::git_info::get_git_repo_root;
use codex_protocol::protocol::FileChange;
// Diff line type used for gutter sign + style selection.
/// Classifies a diff line for gutter sign rendering and style selection.
///
/// `Insert` renders with a `+` sign and green text, `Delete` with `-` and red
/// text (plus dim overlay when syntax-highlighted), and `Context` with a space
/// and default styling.
#[derive(Clone, Copy)]
pub(crate) enum DiffLineType {
Insert,
@@ -465,6 +489,9 @@ pub(crate) fn calculate_add_remove_from_diff(diff: &str) -> (usize, usize) {
}
}
/// Render a single diff line (no syntax highlighting) as one or more wrapped
/// ratatui `Line`s. The first output line carries the gutter sign; continuation
/// lines are indented under the line number column.
pub(crate) fn push_wrapped_diff_line(
line_number: usize,
kind: DiffLineType,
@@ -475,6 +502,9 @@ pub(crate) fn push_wrapped_diff_line(
push_wrapped_diff_line_inner(line_number, kind, text, width, line_number_width, None)
}
/// Render a single diff line with pre-computed syntax spans. The sign character
/// uses the diff color; content gets syntax colors with a dim overlay for delete
/// lines.
pub(crate) fn push_wrapped_diff_line_with_syntax(
line_number: usize,
kind: DiffLineType,
@@ -598,8 +628,16 @@ fn push_wrapped_diff_line_inner(
}
/// Split styled spans into chunks that fit within `max_cols` display columns.
///
/// Returns one `Vec<RtSpan>` per output line. Styles are preserved across
/// split boundaries so that wrapping never loses syntax coloring.
///
/// The algorithm walks characters using their Unicode display width (with tabs
/// expanded to [`TAB_WIDTH`] columns). When a character would overflow the
/// current line, the accumulated text is flushed and a new line begins. A
/// single character wider than the remaining space forces a line break *before*
/// the character so that progress is always made (avoiding infinite loops on
/// CJK characters or tabs at the end of a line).
fn wrap_styled_spans(spans: &[RtSpan<'static>], max_cols: usize) -> Vec<Vec<RtSpan<'static>>> {
let mut result: Vec<Vec<RtSpan<'static>>> = Vec::new();
let mut current_line: Vec<RtSpan<'static>> = Vec::new();
+44 -4
View File
@@ -1,3 +1,26 @@
//! Syntax highlighting engine for the TUI.
//!
//! Wraps [syntect] with the [two_face] grammar and theme bundles to provide
//! ~250-language syntax highlighting and 32 bundled color themes. The module
//! owns four process-global singletons:
//!
//! | Singleton | Type | Purpose |
//! |---|---|---|
//! | `SYNTAX_SET` | `OnceLock<SyntaxSet>` | Grammar database, immutable after init |
//! | `THEME` | `OnceLock<RwLock<Theme>>` | Active color theme, swappable at runtime |
//! | `THEME_OVERRIDE` | `OnceLock<Option<String>>` | Persisted user preference (write-once) |
//! | `CODEX_HOME` | `OnceLock<Option<PathBuf>>` | Root for custom `.tmTheme` discovery |
//!
//! **Lifecycle:** call [`set_theme_override`] once at startup (after the final
//! config is resolved) to persist the user preference and seed the `THEME`
//! lock. After that, [`set_syntax_theme`] and [`current_syntax_theme`] can
//! swap/snapshot the theme for live preview. All highlighting functions read
//! the theme via `theme_lock()`.
//!
//! **Guardrails:** inputs exceeding 512 KB or 10 000 lines are rejected early
//! (returns `None`) to prevent pathological CPU/memory usage. Callers must
//! fall back to plain unstyled text.
use ratatui::style::Color as RtColor;
use ratatui::style::Modifier;
use ratatui::style::Style;
@@ -235,9 +258,13 @@ pub(crate) fn resolve_theme_by_name(name: &str, codex_home: Option<&Path>) -> Op
None
}
/// A theme available in the picker.
/// A theme available in the picker, either bundled or loaded from a custom
/// `.tmTheme` file under `{CODEX_HOME}/themes/`.
pub(crate) struct ThemeEntry {
/// Kebab-case identifier used for config persistence and theme resolution.
pub name: String,
/// `true` when this entry was discovered from a `.tmTheme` file on disk
/// rather than the embedded two-face bundle.
pub is_custom: bool,
}
@@ -450,8 +477,14 @@ fn highlight_to_line_spans(code: &str, lang: &str) -> Option<Vec<Vec<Span<'stati
// -- Public API ---------------------------------------------------------------
/// Highlight code in any supported language, returning styled ratatui Lines.
/// Falls back to plain unstyled text when the language is not recognized.
/// Highlight code in any supported language, returning styled ratatui `Line`s.
///
/// Falls back to plain unstyled text when the language is not recognized or the
/// input exceeds safety guardrails. Callers can always render the result
/// directly -- the fallback path produces equivalent plain-text lines.
///
/// Used by `markdown_render` for fenced code blocks and by `exec_cell` for bash
/// command highlighting.
pub(crate) fn highlight_code_to_lines(code: &str, lang: &str) -> Vec<Line<'static>> {
if let Some(line_spans) = highlight_to_line_spans(code, lang) {
line_spans.into_iter().map(Line::from).collect()
@@ -474,7 +507,14 @@ pub(crate) fn highlight_bash_to_lines(script: &str) -> Vec<Line<'static>> {
}
/// Highlight code and return per-line styled spans for diff integration.
/// Returns None if the language is unsupported.
///
/// Returns `None` when the language is unrecognized or the input exceeds
/// guardrails. The caller (`diff_render`) uses this signal to fall back to
/// plain diff coloring.
///
/// Each inner `Vec<Span>` corresponds to one source line. Styles are derived
/// from the active theme but backgrounds are intentionally omitted so the
/// terminal's own background shows through.
pub(crate) fn highlight_code_to_styled_spans(
code: &str,
lang: &str,
+32 -2
View File
@@ -1,3 +1,24 @@
//! Builds the `/theme` picker dialog for the TUI.
//!
//! The picker lists all bundled themes plus any custom `.tmTheme` files found
//! under `{CODEX_HOME}/themes/`. It provides:
//!
//! - **Live preview:** the `on_selection_changed` callback swaps the runtime
//! syntax theme as the user navigates, giving instant visual feedback in both
//! the preview panel and any visible code blocks.
//! - **Cancel-restore:** on dismiss (Esc / Ctrl+C) the `on_cancel` callback
//! restores the theme snapshot taken when the picker opened.
//! - **Persist on confirm:** the `AppEvent::SyntaxThemeSelected` action persists
//! `[tui] theme = "..."` to `config.toml` via `ConfigEditsBuilder`.
//!
//! Two preview renderables adapt to terminal width:
//!
//! - `ThemePreviewWideRenderable` -- vertically centered, inset by 2 columns,
//! shown in the side panel when the terminal is wide enough for side-by-side
//! layout (>= 44-column side panel and >= 40-column list).
//! - `ThemePreviewNarrowRenderable` -- compact 4-line snippet stacked below the
//! list when side-by-side does not fit.
use std::path::Path;
use crate::app_event::AppEvent;
@@ -122,9 +143,12 @@ const SIDE_CONTENT_GAP: u16 = 2;
/// Minimum list width required for side-by-side mode in the selection popup.
const MIN_LIST_WIDTH_FOR_SIDE: u16 = 40;
/// Renders a syntax-highlighted code snippet below the theme list so users can
/// preview what each theme looks like on real code.
/// Side-by-side preview: syntax-highlighted Rust diff snippet, vertically
/// centered with a 2-column left inset. Fills the entire side panel height.
struct ThemePreviewWideRenderable;
/// Stacked preview: compact 4-line Rust diff snippet shown below the list
/// when the terminal is too narrow for side-by-side layout.
struct ThemePreviewNarrowRenderable;
fn preview_diff_line_type(kind: PreviewDiffKind) -> DiffLineType {
@@ -272,6 +296,12 @@ fn theme_picker_subtitle(codex_home: Option<&Path>, terminal_width: Option<u16>)
///
/// Lists all bundled themes plus custom `.tmTheme` files, with live preview
/// on cursor movement and cancel-restore.
///
/// `current_name` should be the value of `Config::tui_theme` (the persisted
/// preference). When it names a theme that is currently available the picker
/// pre-selects it; otherwise the picker falls back to the active runtime theme
/// (from adaptive auto-detection) so opening the picker without a persisted
/// preference still highlights the correct entry.
pub(crate) fn build_theme_picker_params(
current_name: Option<&str>,
codex_home: Option<&Path>,