mirror of
https://github.com/openai/codex.git
synced 2026-05-05 22:01:37 +03:00
feat(tui): syntax highlighting via syntect with theme picker (#11447)
## Summary Adds syntax highlighting to the TUI for fenced code blocks in markdown responses and file diffs, plus a `/theme` command with live preview and persistent theme selection. Uses syntect (~250 grammars, 32 bundled themes, ~1 MB binary cost) — the same engine behind `bat`, `delta`, and `xi-editor`. Includes guardrails for large inputs, graceful fallback to plain text, and SSH-aware clipboard integration for the `/copy` command. <img width="1554" height="1014" alt="image" src="https://github.com/user-attachments/assets/38737a79-8717-4715-b857-94cf1ba59b85" /> <img width="2354" height="1374" alt="image" src="https://github.com/user-attachments/assets/25d30a00-c487-4af8-9cb6-63b0695a4be7" /> ## Problem Code blocks in the TUI (markdown responses and file diffs) render without syntax highlighting, making it hard to scan code at a glance. Users also have no way to pick a color theme that matches their terminal aesthetic. ## Mental model The highlighting system has three layers: 1. **Syntax engine** (`render::highlight`) -- a thin wrapper around syntect + two-face. It owns a process-global `SyntaxSet` (~250 grammars) and a `RwLock<Theme>` that can be swapped at runtime. All public entry points accept `(code, lang)` and return ratatui `Span`/`Line` vectors or `None` when the language is unrecognized or the input exceeds safety guardrails. 2. **Rendering consumers** -- `markdown_render` feeds fenced code blocks through the engine; `diff_render` highlights Add/Delete content as a whole file and Update hunks per-hunk (preserving parser state across hunk lines). Both callers fall back to plain unstyled text when the engine returns `None`. 3. **Theme lifecycle** -- at startup the config's `tui.theme` is resolved to a syntect `Theme` via `set_theme_override`. At runtime the `/theme` picker calls `set_syntax_theme` to swap themes live; on cancel it restores the snapshot taken at open. On confirm it persists `[tui] theme = "..."` to config.toml. ## Non-goals - Inline diff highlighting (word-level change detection within a line). - Semantic / LSP-backed highlighting. - Theme authoring tooling; users supply standard `.tmTheme` files. ## Tradeoffs | Decision | Upside | Downside | | ------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | | syntect over tree-sitter / arborium | ~1 MB binary increase for ~250 grammars + 32 themes; battle-tested crate powering widely-used tools (`bat`, `delta`, `xi-editor`). tree-sitter would add ~12 MB for 20-30 languages or ~35 MB for full coverage. | Regex-based; less structurally accurate than tree-sitter for some languages (e.g. language injections like JS-in-HTML). | | Global `RwLock<Theme>` | Enables live `/theme` preview without threading Theme through every call site | Lock contention risk (mitigated: reads vastly outnumber writes, single UI thread) | | Skip background / italic / underline from themes | Terminal BG preserved, avoids ugly rendering on some themes | Themes that rely on these properties lose fidelity | | Guardrails: 512 KB / 10k lines | Prevents pathological stalls on huge diffs or pastes | Very large files render without color | ## Architecture ``` config.toml ─[tui.theme]─> set_theme_override() ─> THEME (RwLock) │ ┌───────────────────────────────────────────┘ │ markdown_render ─── highlight_code_to_lines(code, lang) ─> Vec<Line> diff_render ─── highlight_code_to_styled_spans(code, lang) ─> Option<Vec<Vec<Span>>> │ │ (None ⇒ plain text fallback) │ /theme picker ─── set_syntax_theme(theme) // live preview swap ─── current_syntax_theme() // snapshot for cancel ─── resolve_theme_by_name(name) // lookup by kebab-case ``` Key files: - `tui/src/render/highlight.rs` -- engine, theme management, guardrails - `tui/src/diff_render.rs` -- syntax-aware diff line wrapping - `tui/src/theme_picker.rs` -- `/theme` command builder - `tui/src/bottom_pane/list_selection_view.rs` -- side content panel, callbacks - `core/src/config/types.rs` -- `Tui::theme` field - `core/src/config/edit.rs` -- `syntax_theme_edit()` helper ## Observability - `tracing::warn` when a configured theme name cannot be resolved. - `Config::startup_warnings` surfaces the same message as a TUI banner. - `tracing::error` when persisting theme selection fails. ## Tests - Unit tests in `highlight.rs`: language coverage, fallback behavior, CRLF stripping, style conversion, guardrail enforcement, theme name mapping exhaustiveness. - Unit tests in `diff_render.rs`: snapshot gallery at multiple terminal sizes (80x24, 94x35, 120x40), syntax-highlighted wrapping, large-diff guardrail, rename-to-different-extension highlighting, parser state preservation across hunk lines. - Unit tests in `theme_picker.rs`: preview rendering (wide + narrow), dim overlay on deletions, subtitle truncation, cancel-restore, fallback for unavailable configured theme. - Unit tests in `list_selection_view.rs`: side layout geometry, stacked fallback, buffer clearing, cancel/selection-changed callbacks. - Integration test in `lib.rs`: theme warning uses the final (post-resume) config. ## Cargo Deny: Unmaintained Dependency Exceptions This PR adds two `cargo deny` advisory exceptions for transitive dependencies pulled in by `syntect v5.3.0`: | Advisory | Crate | Status | |----------|-------|--------| | RUSTSEC-2024-0320 | `yaml-rust` | Unmaintained (maintainer unreachable) | | RUSTSEC-2025-0141 | `bincode` | Unmaintained (development ceased; v1.3.3 considered complete) | **Why this is safe in our usage:** - Neither advisory describes a known security vulnerability. Both are "unmaintained" notices only. - `bincode` is used by syntect to deserialize pre-compiled syntax sets. Again, these are **static vendored artifacts** baked into the binary at build time. No user-supplied bincode data is ever deserialized. - Attack surface is zero for both crates; exploitation would require a supply-chain compromise of our own build artifacts. - These exceptions can be removed when syntect migrates to `yaml-rust2` and drops `bincode`, or when alternative crates are available upstream.
This commit is contained in:
@@ -33,9 +33,76 @@ use super::selection_popup_common::render_rows_stable_col_widths;
|
||||
use super::selection_popup_common::render_rows_with_col_width_mode;
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
/// Minimum list width (in content columns) required before the side-by-side
|
||||
/// layout is activated. Keeps the list usable even when sharing horizontal
|
||||
/// space with the side content panel.
|
||||
const MIN_LIST_WIDTH_FOR_SIDE: u16 = 40;
|
||||
|
||||
/// Horizontal gap (in columns) between the list area and the side content
|
||||
/// panel when side-by-side layout is active.
|
||||
const SIDE_CONTENT_GAP: u16 = 2;
|
||||
|
||||
/// Shared menu-surface horizontal inset (2 cells per side) used by selection popups.
|
||||
const MENU_SURFACE_HORIZONTAL_INSET: u16 = 4;
|
||||
|
||||
/// 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(0)` disables side content entirely.
|
||||
Fixed(u16),
|
||||
/// Exact 50/50 split of the content area (minus the inter-column gap).
|
||||
Half,
|
||||
}
|
||||
|
||||
impl Default for SideContentWidth {
|
||||
fn default() -> Self {
|
||||
Self::Fixed(0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the popup content width after subtracting the shared menu-surface
|
||||
/// horizontal inset (2 columns on each side).
|
||||
pub(crate) fn popup_content_width(total_width: u16) -> u16 {
|
||||
total_width.saturating_sub(MENU_SURFACE_HORIZONTAL_INSET)
|
||||
}
|
||||
|
||||
/// Returns side-by-side layout widths as `(list_width, side_width)` when the
|
||||
/// layout can fit. Returns `None` when the side panel is disabled/too narrow or
|
||||
/// when the remaining list width would become unusably small.
|
||||
pub(crate) fn side_by_side_layout_widths(
|
||||
content_width: u16,
|
||||
side_content_width: SideContentWidth,
|
||||
side_content_min_width: u16,
|
||||
) -> Option<(u16, u16)> {
|
||||
let side_width = match side_content_width {
|
||||
SideContentWidth::Fixed(0) => return None,
|
||||
SideContentWidth::Fixed(width) => width,
|
||||
SideContentWidth::Half => content_width.saturating_sub(SIDE_CONTENT_GAP) / 2,
|
||||
};
|
||||
if side_width < side_content_min_width {
|
||||
return None;
|
||||
}
|
||||
let list_width = content_width.saturating_sub(SIDE_CONTENT_GAP + side_width);
|
||||
(list_width >= MIN_LIST_WIDTH_FOR_SIDE).then_some((list_width, side_width))
|
||||
}
|
||||
|
||||
/// One selectable item in the generic selection list.
|
||||
pub(crate) type SelectionAction = Box<dyn Fn(&AppEventSender) + Send + Sync>;
|
||||
|
||||
/// 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(crate) type OnSelectionChangedCallback =
|
||||
Option<Box<dyn Fn(usize, &AppEventSender) + Send + Sync>>;
|
||||
|
||||
/// 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(crate) type OnCancelCallback = Option<Box<dyn Fn(&AppEventSender) + Send + Sync>>;
|
||||
|
||||
/// One row in a [`ListSelectionView`] selection list.
|
||||
///
|
||||
/// This is the source-of-truth model for row state before filtering and
|
||||
@@ -79,6 +146,28 @@ pub(crate) struct SelectionViewParams {
|
||||
pub col_width_mode: ColumnWidthMode,
|
||||
pub header: Box<dyn Renderable>,
|
||||
pub initial_selected_idx: Option<usize>,
|
||||
|
||||
/// Rich content rendered beside (wide terminals) or below (narrow terminals)
|
||||
/// the list items, inside the bordered menu surface. Used by the theme picker
|
||||
/// to show a syntax-highlighted preview.
|
||||
pub side_content: Box<dyn Renderable>,
|
||||
|
||||
/// Width mode for side content when side-by-side layout is active.
|
||||
pub side_content_width: SideContentWidth,
|
||||
|
||||
/// Minimum side panel width required before side-by-side layout activates.
|
||||
pub side_content_min_width: u16,
|
||||
|
||||
/// Optional fallback content rendered when side-by-side does not fit.
|
||||
/// When absent, `side_content` is reused.
|
||||
pub stacked_side_content: Option<Box<dyn Renderable>>,
|
||||
|
||||
/// Called when the highlighted item changes (navigation, filter, number-key).
|
||||
/// Receives the *actual* item index, not the filtered/visible index.
|
||||
pub on_selection_changed: OnSelectionChangedCallback,
|
||||
|
||||
/// Called when the picker is dismissed via Esc/Ctrl+C without selecting.
|
||||
pub on_cancel: OnCancelCallback,
|
||||
}
|
||||
|
||||
impl Default for SelectionViewParams {
|
||||
@@ -95,6 +184,12 @@ impl Default for SelectionViewParams {
|
||||
col_width_mode: ColumnWidthMode::AutoVisible,
|
||||
header: Box::new(()),
|
||||
initial_selected_idx: None,
|
||||
side_content: Box::new(()),
|
||||
side_content_width: SideContentWidth::default(),
|
||||
side_content_min_width: 0,
|
||||
stacked_side_content: None,
|
||||
on_selection_changed: None,
|
||||
on_cancel: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -120,6 +215,16 @@ pub(crate) struct ListSelectionView {
|
||||
last_selected_actual_idx: Option<usize>,
|
||||
header: Box<dyn Renderable>,
|
||||
initial_selected_idx: Option<usize>,
|
||||
side_content: Box<dyn Renderable>,
|
||||
side_content_width: SideContentWidth,
|
||||
side_content_min_width: u16,
|
||||
stacked_side_content: Option<Box<dyn Renderable>>,
|
||||
|
||||
/// Called when the highlighted item changes (navigation, filter, number-key).
|
||||
on_selection_changed: OnSelectionChangedCallback,
|
||||
|
||||
/// Called when the picker is dismissed via Esc/Ctrl+C without selecting.
|
||||
on_cancel: OnCancelCallback,
|
||||
}
|
||||
|
||||
impl ListSelectionView {
|
||||
@@ -161,6 +266,12 @@ impl ListSelectionView {
|
||||
last_selected_actual_idx: None,
|
||||
header,
|
||||
initial_selected_idx: params.initial_selected_idx,
|
||||
side_content: params.side_content,
|
||||
side_content_width: params.side_content_width,
|
||||
side_content_min_width: params.side_content_min_width,
|
||||
stacked_side_content: params.stacked_side_content,
|
||||
on_selection_changed: params.on_selection_changed,
|
||||
on_cancel: params.on_cancel,
|
||||
};
|
||||
s.apply_filter();
|
||||
s
|
||||
@@ -174,11 +285,15 @@ impl ListSelectionView {
|
||||
MAX_POPUP_ROWS.min(len.max(1))
|
||||
}
|
||||
|
||||
fn apply_filter(&mut self) {
|
||||
let previously_selected = self
|
||||
.state
|
||||
fn selected_actual_idx(&self) -> Option<usize> {
|
||||
self.state
|
||||
.selected_idx
|
||||
.and_then(|visible_idx| self.filtered_indices.get(visible_idx).copied())
|
||||
}
|
||||
|
||||
fn apply_filter(&mut self) {
|
||||
let previously_selected = self
|
||||
.selected_actual_idx()
|
||||
.or_else(|| {
|
||||
(!self.is_searchable)
|
||||
.then(|| self.items.iter().position(|item| item.is_current))
|
||||
@@ -222,6 +337,13 @@ impl ListSelectionView {
|
||||
let visible = Self::max_visible_rows(len);
|
||||
self.state.clamp_selection(len);
|
||||
self.state.ensure_visible(len, visible);
|
||||
|
||||
// Notify the callback when filtering changes the selected actual item
|
||||
// so live preview stays in sync (e.g. typing in the theme picker).
|
||||
let new_actual = self.selected_actual_idx();
|
||||
if new_actual != previously_selected {
|
||||
self.fire_selection_changed();
|
||||
}
|
||||
}
|
||||
|
||||
fn build_rows(&self) -> Vec<GenericDisplayRow> {
|
||||
@@ -273,19 +395,35 @@ impl ListSelectionView {
|
||||
}
|
||||
|
||||
fn move_up(&mut self) {
|
||||
let before = self.selected_actual_idx();
|
||||
let len = self.visible_len();
|
||||
self.state.move_up_wrap(len);
|
||||
let visible = Self::max_visible_rows(len);
|
||||
self.state.ensure_visible(len, visible);
|
||||
self.skip_disabled_up();
|
||||
if self.selected_actual_idx() != before {
|
||||
self.fire_selection_changed();
|
||||
}
|
||||
}
|
||||
|
||||
fn move_down(&mut self) {
|
||||
let before = self.selected_actual_idx();
|
||||
let len = self.visible_len();
|
||||
self.state.move_down_wrap(len);
|
||||
let visible = Self::max_visible_rows(len);
|
||||
self.state.ensure_visible(len, visible);
|
||||
self.skip_disabled_down();
|
||||
if self.selected_actual_idx() != before {
|
||||
self.fire_selection_changed();
|
||||
}
|
||||
}
|
||||
|
||||
fn fire_selection_changed(&self) {
|
||||
if let Some(cb) = &self.on_selection_changed
|
||||
&& let Some(actual) = self.selected_actual_idx()
|
||||
{
|
||||
cb(actual, &self.app_event_tx);
|
||||
}
|
||||
}
|
||||
|
||||
fn accept(&mut self) {
|
||||
@@ -310,6 +448,9 @@ impl ListSelectionView {
|
||||
self.complete = true;
|
||||
}
|
||||
} else if selected_item.is_none() {
|
||||
if let Some(cb) = &self.on_cancel {
|
||||
cb(&self.app_event_tx);
|
||||
}
|
||||
self.complete = true;
|
||||
}
|
||||
}
|
||||
@@ -328,6 +469,63 @@ impl ListSelectionView {
|
||||
total_width.saturating_sub(2)
|
||||
}
|
||||
|
||||
fn clear_to_terminal_bg(buf: &mut Buffer, area: Rect) {
|
||||
let buf_area = buf.area();
|
||||
let min_x = area.x.max(buf_area.x);
|
||||
let min_y = area.y.max(buf_area.y);
|
||||
let max_x = area
|
||||
.x
|
||||
.saturating_add(area.width)
|
||||
.min(buf_area.x.saturating_add(buf_area.width));
|
||||
let max_y = area
|
||||
.y
|
||||
.saturating_add(area.height)
|
||||
.min(buf_area.y.saturating_add(buf_area.height));
|
||||
for y in min_y..max_y {
|
||||
for x in min_x..max_x {
|
||||
buf[(x, y)]
|
||||
.set_symbol(" ")
|
||||
.set_style(ratatui::style::Style::reset());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn force_bg_to_terminal_bg(buf: &mut Buffer, area: Rect) {
|
||||
let buf_area = buf.area();
|
||||
let min_x = area.x.max(buf_area.x);
|
||||
let min_y = area.y.max(buf_area.y);
|
||||
let max_x = area
|
||||
.x
|
||||
.saturating_add(area.width)
|
||||
.min(buf_area.x.saturating_add(buf_area.width));
|
||||
let max_y = area
|
||||
.y
|
||||
.saturating_add(area.height)
|
||||
.min(buf_area.y.saturating_add(buf_area.height));
|
||||
for y in min_y..max_y {
|
||||
for x in min_x..max_x {
|
||||
buf[(x, y)].set_bg(ratatui::style::Color::Reset);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn stacked_side_content(&self) -> &dyn Renderable {
|
||||
self.stacked_side_content
|
||||
.as_deref()
|
||||
.unwrap_or_else(|| self.side_content.as_ref())
|
||||
}
|
||||
|
||||
/// Returns `Some(side_width)` when the content area is wide enough for a
|
||||
/// side-by-side layout (list + gap + side panel), `None` otherwise.
|
||||
fn side_layout_width(&self, content_width: u16) -> Option<u16> {
|
||||
side_by_side_layout_widths(
|
||||
content_width,
|
||||
self.side_content_width,
|
||||
self.side_content_min_width,
|
||||
)
|
||||
.map(|(_, side_width)| side_width)
|
||||
}
|
||||
|
||||
fn skip_disabled_down(&mut self) {
|
||||
let len = self.visible_len();
|
||||
for _ in 0..len {
|
||||
@@ -469,6 +667,9 @@ impl BottomPaneView for ListSelectionView {
|
||||
}
|
||||
|
||||
fn on_ctrl_c(&mut self) -> CancellationEvent {
|
||||
if let Some(cb) = &self.on_cancel {
|
||||
cb(&self.app_event_tx);
|
||||
}
|
||||
self.complete = true;
|
||||
CancellationEvent::Handled
|
||||
}
|
||||
@@ -476,38 +677,59 @@ impl BottomPaneView for ListSelectionView {
|
||||
|
||||
impl Renderable for ListSelectionView {
|
||||
fn desired_height(&self, width: u16) -> u16 {
|
||||
// Measure wrapped height for up to MAX_POPUP_ROWS items at the given width.
|
||||
// Build the same display rows used by the renderer so wrapping math matches.
|
||||
// Inner content width after menu surface horizontal insets (2 per side).
|
||||
let inner_width = popup_content_width(width);
|
||||
|
||||
// When side-by-side is active, measure the list at the reduced width
|
||||
// that accounts for the gap and side panel.
|
||||
let effective_rows_width = if let Some(side_w) = self.side_layout_width(inner_width) {
|
||||
Self::rows_width(width).saturating_sub(SIDE_CONTENT_GAP + side_w)
|
||||
} else {
|
||||
Self::rows_width(width)
|
||||
};
|
||||
|
||||
// Measure wrapped height for up to MAX_POPUP_ROWS items.
|
||||
let rows = self.build_rows();
|
||||
let rows_width = Self::rows_width(width);
|
||||
let rows_height = match self.col_width_mode {
|
||||
ColumnWidthMode::AutoVisible => measure_rows_height(
|
||||
&rows,
|
||||
&self.state,
|
||||
MAX_POPUP_ROWS,
|
||||
rows_width.saturating_add(1),
|
||||
effective_rows_width.saturating_add(1),
|
||||
),
|
||||
ColumnWidthMode::AutoAllRows => measure_rows_height_stable_col_widths(
|
||||
&rows,
|
||||
&self.state,
|
||||
MAX_POPUP_ROWS,
|
||||
rows_width.saturating_add(1),
|
||||
effective_rows_width.saturating_add(1),
|
||||
),
|
||||
ColumnWidthMode::Fixed => measure_rows_height_with_col_width_mode(
|
||||
&rows,
|
||||
&self.state,
|
||||
MAX_POPUP_ROWS,
|
||||
rows_width.saturating_add(1),
|
||||
effective_rows_width.saturating_add(1),
|
||||
ColumnWidthMode::Fixed,
|
||||
),
|
||||
};
|
||||
|
||||
// Subtract 4 for the padding on the left and right of the header.
|
||||
let mut height = self.header.desired_height(width.saturating_sub(4));
|
||||
let mut height = self.header.desired_height(inner_width);
|
||||
height = height.saturating_add(rows_height + 3);
|
||||
if self.is_searchable {
|
||||
height = height.saturating_add(1);
|
||||
}
|
||||
|
||||
// Side content: when the terminal is wide enough the panel sits beside
|
||||
// the list and shares vertical space; otherwise it stacks below.
|
||||
if self.side_layout_width(inner_width).is_some() {
|
||||
// Side-by-side — side content shares list rows vertically so it
|
||||
// doesn't add to total height.
|
||||
} else {
|
||||
let side_h = self.stacked_side_content().desired_height(inner_width);
|
||||
if side_h > 0 {
|
||||
height = height.saturating_add(1 + side_h);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(note) = &self.footer_note {
|
||||
let note_width = width.saturating_sub(2);
|
||||
let note_lines = wrap_styled_line(note, note_width);
|
||||
@@ -538,41 +760,60 @@ impl Renderable for ListSelectionView {
|
||||
// Paint the shared menu surface and then layout inside the returned inset.
|
||||
let content_area = render_menu_surface(outer_content_area, buf);
|
||||
|
||||
let header_height = self
|
||||
.header
|
||||
// Subtract 4 for the padding on the left and right of the header.
|
||||
.desired_height(outer_content_area.width.saturating_sub(4));
|
||||
let inner_width = popup_content_width(outer_content_area.width);
|
||||
let side_w = self.side_layout_width(inner_width);
|
||||
|
||||
// When side-by-side is active, shrink the list to make room.
|
||||
let full_rows_width = Self::rows_width(outer_content_area.width);
|
||||
let effective_rows_width = if let Some(sw) = side_w {
|
||||
full_rows_width.saturating_sub(SIDE_CONTENT_GAP + sw)
|
||||
} else {
|
||||
full_rows_width
|
||||
};
|
||||
|
||||
let header_height = self.header.desired_height(inner_width);
|
||||
let rows = self.build_rows();
|
||||
let rows_width = Self::rows_width(outer_content_area.width);
|
||||
let rows_height = match self.col_width_mode {
|
||||
ColumnWidthMode::AutoVisible => measure_rows_height(
|
||||
&rows,
|
||||
&self.state,
|
||||
MAX_POPUP_ROWS,
|
||||
rows_width.saturating_add(1),
|
||||
effective_rows_width.saturating_add(1),
|
||||
),
|
||||
ColumnWidthMode::AutoAllRows => measure_rows_height_stable_col_widths(
|
||||
&rows,
|
||||
&self.state,
|
||||
MAX_POPUP_ROWS,
|
||||
rows_width.saturating_add(1),
|
||||
effective_rows_width.saturating_add(1),
|
||||
),
|
||||
ColumnWidthMode::Fixed => measure_rows_height_with_col_width_mode(
|
||||
&rows,
|
||||
&self.state,
|
||||
MAX_POPUP_ROWS,
|
||||
rows_width.saturating_add(1),
|
||||
effective_rows_width.saturating_add(1),
|
||||
ColumnWidthMode::Fixed,
|
||||
),
|
||||
};
|
||||
let [header_area, _, search_area, list_area] = Layout::vertical([
|
||||
|
||||
// Stacked (fallback) side content height — only used when not side-by-side.
|
||||
let stacked_side_h = if side_w.is_none() {
|
||||
self.stacked_side_content().desired_height(inner_width)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let stacked_gap = if stacked_side_h > 0 { 1 } else { 0 };
|
||||
|
||||
let [header_area, _, search_area, list_area, _, stacked_side_area] = Layout::vertical([
|
||||
Constraint::Max(header_height),
|
||||
Constraint::Max(1),
|
||||
Constraint::Length(if self.is_searchable { 1 } else { 0 }),
|
||||
Constraint::Length(rows_height),
|
||||
Constraint::Length(stacked_gap),
|
||||
Constraint::Length(stacked_side_h),
|
||||
])
|
||||
.areas(content_area);
|
||||
|
||||
// -- Header --
|
||||
if header_area.height < header_height {
|
||||
let [header_area, elision_area] =
|
||||
Layout::vertical([Constraint::Fill(1), Constraint::Length(1)]).areas(header_area);
|
||||
@@ -585,6 +826,7 @@ impl Renderable for ListSelectionView {
|
||||
self.header.render(header_area, buf);
|
||||
}
|
||||
|
||||
// -- Search bar --
|
||||
if self.is_searchable {
|
||||
Line::from(self.search_query.clone()).render(search_area, buf);
|
||||
let query_span: Span<'static> = if self.search_query.is_empty() {
|
||||
@@ -598,11 +840,12 @@ impl Renderable for ListSelectionView {
|
||||
Line::from(query_span).render(search_area, buf);
|
||||
}
|
||||
|
||||
// -- List rows --
|
||||
if list_area.height > 0 {
|
||||
let render_area = Rect {
|
||||
x: list_area.x.saturating_sub(2),
|
||||
y: list_area.y,
|
||||
width: rows_width.max(1),
|
||||
width: effective_rows_width.max(1),
|
||||
height: list_area.height,
|
||||
};
|
||||
match self.col_width_mode {
|
||||
@@ -634,6 +877,53 @@ impl Renderable for ListSelectionView {
|
||||
};
|
||||
}
|
||||
|
||||
// -- Side content (preview panel) --
|
||||
if let Some(sw) = side_w {
|
||||
// Side-by-side: render to the right half of the popup content
|
||||
// area so preview content can center vertically in that panel.
|
||||
let side_x = content_area.x + content_area.width - sw;
|
||||
let side_area = Rect::new(side_x, content_area.y, sw, content_area.height);
|
||||
|
||||
// Clear the menu-surface background behind the side panel so the
|
||||
// preview appears on the terminal's own background.
|
||||
let clear_x = side_x.saturating_sub(SIDE_CONTENT_GAP);
|
||||
let clear_w = outer_content_area
|
||||
.x
|
||||
.saturating_add(outer_content_area.width)
|
||||
.saturating_sub(clear_x);
|
||||
Self::clear_to_terminal_bg(
|
||||
buf,
|
||||
Rect::new(
|
||||
clear_x,
|
||||
outer_content_area.y,
|
||||
clear_w,
|
||||
outer_content_area.height,
|
||||
),
|
||||
);
|
||||
self.side_content.render(side_area, buf);
|
||||
Self::force_bg_to_terminal_bg(
|
||||
buf,
|
||||
Rect::new(
|
||||
clear_x,
|
||||
outer_content_area.y,
|
||||
clear_w,
|
||||
outer_content_area.height,
|
||||
),
|
||||
);
|
||||
} else if stacked_side_area.height > 0 {
|
||||
// Stacked fallback: render below the list (same as old footer_content).
|
||||
let clear_height = (outer_content_area.y + outer_content_area.height)
|
||||
.saturating_sub(stacked_side_area.y);
|
||||
let clear_area = Rect::new(
|
||||
outer_content_area.x,
|
||||
stacked_side_area.y,
|
||||
outer_content_area.width,
|
||||
clear_height,
|
||||
);
|
||||
Self::clear_to_terminal_bg(buf, clear_area);
|
||||
self.stacked_side_content().render(stacked_side_area, buf);
|
||||
}
|
||||
|
||||
if footer_area.height > 0 {
|
||||
let [note_area, hint_area] = Layout::vertical([
|
||||
Constraint::Length(note_height),
|
||||
@@ -683,9 +973,33 @@ mod tests {
|
||||
use crossterm::event::KeyCode;
|
||||
use insta::assert_snapshot;
|
||||
use pretty_assertions::assert_eq;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::Color;
|
||||
use ratatui::style::Style;
|
||||
use tokio::sync::mpsc::unbounded_channel;
|
||||
|
||||
struct MarkerRenderable {
|
||||
marker: &'static str,
|
||||
height: u16,
|
||||
}
|
||||
|
||||
impl Renderable for MarkerRenderable {
|
||||
fn render(&self, area: Rect, buf: &mut Buffer) {
|
||||
for y in area.y..area.y.saturating_add(area.height) {
|
||||
for x in area.x..area.x.saturating_add(area.width) {
|
||||
if x < buf.area().width && y < buf.area().height {
|
||||
buf[(x, y)].set_symbol(self.marker);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn desired_height(&self, _width: u16) -> u16 {
|
||||
self.height
|
||||
}
|
||||
}
|
||||
|
||||
fn make_selection_view(subtitle: Option<&str>) -> ListSelectionView {
|
||||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
@@ -722,7 +1036,10 @@ mod tests {
|
||||
}
|
||||
|
||||
fn render_lines_with_width(view: &ListSelectionView, width: u16) -> String {
|
||||
let height = view.desired_height(width);
|
||||
render_lines_in_area(view, width, view.desired_height(width))
|
||||
}
|
||||
|
||||
fn render_lines_in_area(view: &ListSelectionView, width: u16, height: u16) -> String {
|
||||
let area = Rect::new(0, 0, width, height);
|
||||
let mut buf = Buffer::empty(area);
|
||||
view.render(area, &mut buf);
|
||||
@@ -808,6 +1125,20 @@ mod tests {
|
||||
assert_snapshot!("list_selection_spacing_with_subtitle", render_lines(&view));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn theme_picker_subtitle_uses_fallback_text_in_94x35_terminal() {
|
||||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let home = dirs::home_dir().expect("home directory should be available");
|
||||
let codex_home = home.join(".codex");
|
||||
let params =
|
||||
crate::theme_picker::build_theme_picker_params(None, Some(&codex_home), Some(94));
|
||||
let view = ListSelectionView::new(params, tx);
|
||||
|
||||
let rendered = render_lines_in_area(&view, 94, 35);
|
||||
assert!(rendered.contains("Move up/down to live preview themes"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_footer_note_wraps() {
|
||||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||||
@@ -871,6 +1202,66 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enter_with_no_matches_triggers_cancel_callback() {
|
||||
let (tx_raw, mut rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let mut view = ListSelectionView::new(
|
||||
SelectionViewParams {
|
||||
items: vec![SelectionItem {
|
||||
name: "Read Only".to_string(),
|
||||
dismiss_on_select: true,
|
||||
..Default::default()
|
||||
}],
|
||||
is_searchable: true,
|
||||
on_cancel: Some(Box::new(|tx: &_| {
|
||||
tx.send(AppEvent::OpenApprovalsPopup);
|
||||
})),
|
||||
..Default::default()
|
||||
},
|
||||
tx,
|
||||
);
|
||||
view.set_search_query("no-matches".to_string());
|
||||
|
||||
view.handle_key_event(KeyEvent::from(KeyCode::Enter));
|
||||
|
||||
assert!(view.is_complete());
|
||||
match rx.try_recv() {
|
||||
Ok(AppEvent::OpenApprovalsPopup) => {}
|
||||
Ok(other) => panic!("expected OpenApprovalsPopup cancel event, got {other:?}"),
|
||||
Err(err) => panic!("expected cancel callback event, got {err}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_down_without_selection_change_does_not_fire_callback() {
|
||||
let (tx_raw, mut rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let mut view = ListSelectionView::new(
|
||||
SelectionViewParams {
|
||||
items: vec![SelectionItem {
|
||||
name: "Only choice".to_string(),
|
||||
dismiss_on_select: true,
|
||||
..Default::default()
|
||||
}],
|
||||
on_selection_changed: Some(Box::new(|_idx, tx: &_| {
|
||||
tx.send(AppEvent::OpenApprovalsPopup);
|
||||
})),
|
||||
..Default::default()
|
||||
},
|
||||
tx,
|
||||
);
|
||||
|
||||
while rx.try_recv().is_ok() {}
|
||||
|
||||
view.handle_key_event(KeyEvent::from(KeyCode::Down));
|
||||
|
||||
assert!(
|
||||
rx.try_recv().is_err(),
|
||||
"moving down in a single-item list should not fire on_selection_changed",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wraps_long_option_without_overflowing_columns() {
|
||||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||||
@@ -1159,4 +1550,194 @@ mod tests {
|
||||
"fixed description column changed across scroll:\nbefore:\n{before_scroll}\nafter:\n{after_scroll}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn side_layout_width_half_uses_exact_split() {
|
||||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let view = ListSelectionView::new(
|
||||
SelectionViewParams {
|
||||
items: vec![SelectionItem {
|
||||
name: "Item 1".to_string(),
|
||||
dismiss_on_select: true,
|
||||
..Default::default()
|
||||
}],
|
||||
side_content: Box::new(MarkerRenderable {
|
||||
marker: "W",
|
||||
height: 1,
|
||||
}),
|
||||
side_content_width: SideContentWidth::Half,
|
||||
side_content_min_width: 10,
|
||||
..Default::default()
|
||||
},
|
||||
tx,
|
||||
);
|
||||
|
||||
let content_width: u16 = 120;
|
||||
let expected = content_width.saturating_sub(SIDE_CONTENT_GAP) / 2;
|
||||
assert_eq!(view.side_layout_width(content_width), Some(expected));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn side_layout_width_half_falls_back_when_list_would_be_too_narrow() {
|
||||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let view = ListSelectionView::new(
|
||||
SelectionViewParams {
|
||||
items: vec![SelectionItem {
|
||||
name: "Item 1".to_string(),
|
||||
dismiss_on_select: true,
|
||||
..Default::default()
|
||||
}],
|
||||
side_content: Box::new(MarkerRenderable {
|
||||
marker: "W",
|
||||
height: 1,
|
||||
}),
|
||||
side_content_width: SideContentWidth::Half,
|
||||
side_content_min_width: 50,
|
||||
..Default::default()
|
||||
},
|
||||
tx,
|
||||
);
|
||||
|
||||
assert_eq!(view.side_layout_width(80), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stacked_side_content_is_used_when_side_by_side_does_not_fit() {
|
||||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let view = ListSelectionView::new(
|
||||
SelectionViewParams {
|
||||
title: Some("Debug".to_string()),
|
||||
items: vec![SelectionItem {
|
||||
name: "Item 1".to_string(),
|
||||
dismiss_on_select: true,
|
||||
..Default::default()
|
||||
}],
|
||||
side_content: Box::new(MarkerRenderable {
|
||||
marker: "W",
|
||||
height: 1,
|
||||
}),
|
||||
stacked_side_content: Some(Box::new(MarkerRenderable {
|
||||
marker: "N",
|
||||
height: 1,
|
||||
})),
|
||||
side_content_width: SideContentWidth::Half,
|
||||
side_content_min_width: 60,
|
||||
..Default::default()
|
||||
},
|
||||
tx,
|
||||
);
|
||||
|
||||
let rendered = render_lines_with_width(&view, 70);
|
||||
assert!(
|
||||
rendered.contains('N'),
|
||||
"expected stacked marker to be rendered:\n{rendered}"
|
||||
);
|
||||
assert!(
|
||||
!rendered.contains('W'),
|
||||
"wide marker should not render in stacked mode:\n{rendered}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn side_content_clearing_resets_symbols_and_style() {
|
||||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let view = ListSelectionView::new(
|
||||
SelectionViewParams {
|
||||
title: Some("Debug".to_string()),
|
||||
items: vec![SelectionItem {
|
||||
name: "Item 1".to_string(),
|
||||
dismiss_on_select: true,
|
||||
..Default::default()
|
||||
}],
|
||||
side_content: Box::new(MarkerRenderable {
|
||||
marker: "W",
|
||||
height: 1,
|
||||
}),
|
||||
side_content_width: SideContentWidth::Half,
|
||||
side_content_min_width: 10,
|
||||
..Default::default()
|
||||
},
|
||||
tx,
|
||||
);
|
||||
|
||||
let width = 120;
|
||||
let height = view.desired_height(width);
|
||||
let area = Rect::new(0, 0, width, height);
|
||||
let mut buf = Buffer::empty(area);
|
||||
for y in 0..height {
|
||||
for x in 0..width {
|
||||
buf[(x, y)]
|
||||
.set_symbol("X")
|
||||
.set_style(Style::default().bg(Color::Red));
|
||||
}
|
||||
}
|
||||
view.render(area, &mut buf);
|
||||
|
||||
let cell = &buf[(width - 1, 0)];
|
||||
assert_eq!(cell.symbol(), " ");
|
||||
let style = cell.style();
|
||||
assert_eq!(style.fg, Some(Color::Reset));
|
||||
assert_eq!(style.bg, Some(Color::Reset));
|
||||
assert_eq!(style.underline_color, Some(Color::Reset));
|
||||
|
||||
let mut saw_marker = false;
|
||||
for y in 0..height {
|
||||
for x in 0..width {
|
||||
let cell = &buf[(x, y)];
|
||||
if cell.symbol() == "W" {
|
||||
saw_marker = true;
|
||||
assert_eq!(cell.style().bg, Some(Color::Reset));
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
saw_marker,
|
||||
"expected side marker renderable to draw into buffer"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn side_content_clearing_handles_non_zero_buffer_origin() {
|
||||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let view = ListSelectionView::new(
|
||||
SelectionViewParams {
|
||||
title: Some("Debug".to_string()),
|
||||
items: vec![SelectionItem {
|
||||
name: "Item 1".to_string(),
|
||||
dismiss_on_select: true,
|
||||
..Default::default()
|
||||
}],
|
||||
side_content: Box::new(MarkerRenderable {
|
||||
marker: "W",
|
||||
height: 1,
|
||||
}),
|
||||
side_content_width: SideContentWidth::Half,
|
||||
side_content_min_width: 10,
|
||||
..Default::default()
|
||||
},
|
||||
tx,
|
||||
);
|
||||
|
||||
let width = 120;
|
||||
let height = view.desired_height(width);
|
||||
let area = Rect::new(0, 20, width, height);
|
||||
let mut buf = Buffer::empty(area);
|
||||
for y in area.y..area.y + height {
|
||||
for x in area.x..area.x + width {
|
||||
buf[(x, y)]
|
||||
.set_symbol("X")
|
||||
.set_style(Style::default().bg(Color::Red));
|
||||
}
|
||||
}
|
||||
view.render(area, &mut buf);
|
||||
|
||||
let cell = &buf[(area.x + width - 1, area.y)];
|
||||
assert_eq!(cell.symbol(), " ");
|
||||
assert_eq!(cell.style().bg, Some(Color::Reset));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,6 +78,9 @@ mod slash_commands;
|
||||
pub(crate) use footer::CollaborationModeIndicator;
|
||||
pub(crate) use list_selection_view::ColumnWidthMode;
|
||||
pub(crate) use list_selection_view::SelectionViewParams;
|
||||
pub(crate) use list_selection_view::SideContentWidth;
|
||||
pub(crate) use list_selection_view::popup_content_width;
|
||||
pub(crate) use list_selection_view::side_by_side_layout_widths;
|
||||
mod feedback_view;
|
||||
pub(crate) use feedback_view::FeedbackAudience;
|
||||
pub(crate) use feedback_view::feedback_disabled_params;
|
||||
|
||||
Reference in New Issue
Block a user