core-agent-ide/codex-rs/tui/src/theme_picker.rs
Felipe Coury 061d1d3b5e
feat(tui): add theme-aware diff backgrounds with capability-graded palettes (#12581)
## Problem

Diff lines used only foreground colors (green/red) with no background
tinting, making them hard to scan. The gutter (line numbers) also had no
theme awareness — dimmed text was fine on dark terminals but unreadable
on light ones.

## Mental model

Each diff line now has four styled layers: **gutter** (line number),
**sign** (`+`/`-`), **content** (text), and **line background** (full
terminal width). A `DiffTheme` enum (`Dark` / `Light`) is selected once
per render by probing the terminal's queried background via
`default_bg()`. A companion `DiffColorLevel` enum (`TrueColor` /
`Ansi256` / `Ansi16`) is derived from `stdout_color_level()` and gates
which palette is used. All style helpers dispatch on `(theme,
DiffLineType, color_level)` to pick the right colors.

| Theme Picker Wide | Theme Picker Narrow |
|---|---|
| <img width="1552" height="1012" alt="image"
src="https://github.com/user-attachments/assets/231b21b7-32d4-4727-80ed-7d01924954be"
/> | <img width="795" height="1012" alt="image"
src="https://github.com/user-attachments/assets/549cacdf-daec-43c9-ad64-2a28d16d140e"
/> |

| Dark BG - 16 colors | Dark BG - 256 colors | Dark BG - True Colors |
|---|---|---|
| <img width="1552" height="1012" alt="dark-16colors"
src="https://github.com/user-attachments/assets/fba36de3-c101-47d4-9e63-88cdd00410d0"
/> | <img width="1552" height="1012" alt="dark-256colors"
src="https://github.com/user-attachments/assets/f39e4307-c6b0-49c4-b4fe-bd26d3d8e41c"
/> | <img width="1552" height="1012" alt="dark-truecolor"
src="https://github.com/user-attachments/assets/1af4ec57-04bf-4dfb-8a44-0ab5e5aaaf18"
/> |

| Light BG - 16 colors | Light BG - 256 colors | Light BG - True Colors
|
|---|---|---|
| <img width="1552" height="1012" alt="light-16colors"
src="https://github.com/user-attachments/assets/2b5423d1-74b4-4b1e-8123-7c2488ff436b"
/> | <img width="1552" height="1012" alt="light-256colors"
src="https://github.com/user-attachments/assets/c94cff9a-8d3e-42c9-bbe7-079da39953a8"
/> | <img width="1552" height="1012" alt="light-truecolor"
src="https://github.com/user-attachments/assets/f73da626-725f-4452-99ee-69ef706df2c6"
/> |

## Non-goals

- No runtime theme switching beyond what `default_bg()` already
provides.
- No change to syntax highlighting theme selection or the highlight
module.

## Tradeoffs

- Three fixed palettes (truecolor RGB, 256-color indexed, 16-color
named) are maintained rather than using `best_color` nearest-match. This
is deliberate: `supports_color::on_cached(Stream::Stdout)` can misreport
capabilities once crossterm enters the alternate screen, so hand-picked
palette entries give better visual results than automatic quantization.
- Delete lines in the syntax-highlighted path get `Modifier::DIM` to
visually recede compared to insert lines. This trades some readability
of deleted code for scan-ability of additions.
- The theme picker's diff preview sets `preserve_side_content_bg: true`
on `ListSelectionView` so diff background tints survive into the side
panel. Other popups keep the default (`false`) to preserve their
reset-background look.

## Architecture

- **Color constants** are module-level `const` items grouped by palette
tier: `DARK_TC_*` / `LIGHT_TC_*` (truecolor RGB tuples), `DARK_256_*` /
`LIGHT_256_*` (xterm indexed), with named `Color` variants used for the
16-color tier.
- **`DiffTheme`** is a private enum; `diff_theme()` probes the terminal
and `diff_theme_for_bg()` is the testable pure-function version.
- **`DiffColorLevel`** is a private enum derived from `StdoutColorLevel`
via `diff_color_level()`.
- **Palette helpers** (`add_line_bg`, `del_line_bg`, `light_gutter_fg`,
`light_add_num_bg`, `light_del_num_bg`) each take `(DiffTheme,
DiffColorLevel)` or just `DiffColorLevel` and return a `Color`.
- **Style helpers** (`style_line_bg_for`, `style_gutter_for`,
`style_sign_add`, `style_sign_del`, `style_add`, `style_del`) each take
`(DiffLineType, DiffTheme, DiffColorLevel)` or `(DiffTheme,
DiffColorLevel)` and return a `Style`.
- **`push_wrapped_diff_line_inner_with_theme_and_color_level`** is the
innermost renderer, accepting both theme and color level so tests can
exercise any combination without depending on the terminal.
- **Line-level background** is applied via
`RtLine::from(...).style(line_bg)` so the tint extends across the full
terminal width, not just the text content.
- **Theme picker integration**: `ListSelectionView` gained a
`preserve_side_content_bg` flag. When `true`, the side panel skips
`force_bg_to_terminal_bg`, letting diff preview backgrounds render
faithfully.

## Observability

No new logging. Theme selection is deterministic from `default_bg()`,
which is already queried and cached at TUI startup.

## Tests

1. **`DiffTheme` is determined per `render_change` call** — if
`default_bg()` changes mid-render (e.g. `requery_default_colors()`
fires), different file chunks could render with different themes. Low
risk in practice since re-query only happens on explicit user action.
2. **16-color tier uses named `Color` variants** (`Color::Green`,
`Color::Red`, etc.) which the terminal maps to its own palette. On
unusual terminal themes these could clash with the background.
Acceptable since 16-color terminals already have unpredictable color
rendering.
3. **Light-theme `style_add` / `style_del` set bg but no fg** — on light
terminals, non-syntax-highlighted content uses the terminal's default
foreground against a pastel background. If the terminal's default fg
happens to be very light, contrast could suffer. This is an edge case
since light-terminal users typically have dark default fg.
4. **`preserve_side_content_bg` is a general-purpose flag but only used
by the theme picker** — if other popups start using side content with
intentional backgrounds they'll need to opt in explicitly. Not a real
risk today, just a note for future callers.
2026-02-24 11:55:01 -08:00

621 lines
21 KiB
Rust

//! 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;
use crate::bottom_pane::SelectionItem;
use crate::bottom_pane::SelectionViewParams;
use crate::bottom_pane::SideContentWidth;
use crate::bottom_pane::popup_consts::standard_popup_hint_line;
use crate::bottom_pane::popup_content_width;
use crate::bottom_pane::side_by_side_layout_widths;
use crate::diff_render::DiffLineType;
use crate::diff_render::line_number_width;
use crate::diff_render::push_wrapped_diff_line;
use crate::diff_render::push_wrapped_diff_line_with_syntax;
use crate::render::highlight;
use crate::render::renderable::Renderable;
use crate::status::format_directory_display;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::text::Line;
use ratatui::widgets::Widget;
use unicode_width::UnicodeWidthStr;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum PreviewDiffKind {
Context,
Added,
Removed,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
struct PreviewRow {
line_no: usize,
kind: PreviewDiffKind,
code: &'static str,
}
/// Compact fallback preview used in stacked (narrow) mode.
/// Keep exactly one removed and one added line visible at all times.
const NARROW_PREVIEW_ROWS: [PreviewRow; 4] = [
PreviewRow {
line_no: 12,
kind: PreviewDiffKind::Context,
code: "fn greet(name: &str) -> String {",
},
PreviewRow {
line_no: 13,
kind: PreviewDiffKind::Removed,
code: " format!(\"Hello, {}!\", name)",
},
PreviewRow {
line_no: 13,
kind: PreviewDiffKind::Added,
code: " format!(\"Hello, {name}!\")",
},
PreviewRow {
line_no: 14,
kind: PreviewDiffKind::Context,
code: "}",
},
];
/// Wider diff preview used in side-by-side mode.
/// This sample intentionally mixes context, additions, and removals.
const WIDE_PREVIEW_ROWS: [PreviewRow; 8] = [
PreviewRow {
line_no: 31,
kind: PreviewDiffKind::Context,
code: "fn summarize(users: &[User]) -> String {",
},
PreviewRow {
line_no: 32,
kind: PreviewDiffKind::Removed,
code: " let active = users.iter().filter(|u| u.is_active).count();",
},
PreviewRow {
line_no: 32,
kind: PreviewDiffKind::Added,
code: " let active = users.iter().filter(|u| u.is_active()).count();",
},
PreviewRow {
line_no: 33,
kind: PreviewDiffKind::Context,
code: " let names: Vec<&str> = users.iter().map(User::name).take(3).collect();",
},
PreviewRow {
line_no: 34,
kind: PreviewDiffKind::Removed,
code: " format!(\"{} active: {}\", active, names.join(\", \"))",
},
PreviewRow {
line_no: 34,
kind: PreviewDiffKind::Added,
code: " format!(\"{active} active users: {}\", names.join(\", \"))",
},
PreviewRow {
line_no: 35,
kind: PreviewDiffKind::Added,
code: " .trim()",
},
PreviewRow {
line_no: 36,
kind: PreviewDiffKind::Context,
code: "}",
},
];
/// Minimum side-panel width for side-by-side theme preview.
const WIDE_PREVIEW_MIN_WIDTH: u16 = 44;
/// Left inset used for wide preview content.
const WIDE_PREVIEW_LEFT_INSET: u16 = 2;
/// Minimum frame padding used for vertically centered wide preview.
const PREVIEW_FRAME_PADDING: u16 = 1;
const PREVIEW_FALLBACK_SUBTITLE: &str = "Move up/down to live preview themes";
/// 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 {
match kind {
PreviewDiffKind::Context => DiffLineType::Context,
PreviewDiffKind::Added => DiffLineType::Insert,
PreviewDiffKind::Removed => DiffLineType::Delete,
}
}
fn centered_offset(available: u16, content: u16, min_frame: u16) -> u16 {
let free = available.saturating_sub(content);
let frame = if free >= min_frame.saturating_mul(2) {
min_frame
} else {
0
};
frame + free.saturating_sub(frame.saturating_mul(2)) / 2
}
fn render_preview(
area: Rect,
buf: &mut Buffer,
preview_rows: &[PreviewRow],
center_vertically: bool,
left_inset: u16,
) {
if area.height == 0 || area.width == 0 {
return;
}
if preview_rows.is_empty() {
return;
}
let preview_code = preview_rows
.iter()
.map(|row| row.code)
.collect::<Vec<_>>()
.join("\n");
let syntax_lines = highlight::highlight_code_to_styled_spans(&preview_code, "rust");
let max_line_no = preview_rows
.iter()
.map(|row| row.line_no)
.max()
.unwrap_or(1);
let ln_width = line_number_width(max_line_no);
let content_height = (preview_rows.len() as u16).min(area.height);
let left_pad = left_inset.min(area.width.saturating_sub(1));
let top_pad = if center_vertically {
centered_offset(area.height, content_height, PREVIEW_FRAME_PADDING)
} else {
0
};
let mut y = area.y.saturating_add(top_pad);
let render_width = area.width.saturating_sub(left_pad);
for (idx, row) in preview_rows.iter().enumerate() {
if y >= area.y + area.height {
break;
}
let diff_type = preview_diff_line_type(row.kind);
let wrapped = if let Some(syn) = syntax_lines.as_ref().and_then(|sl| sl.get(idx)) {
push_wrapped_diff_line_with_syntax(
row.line_no,
diff_type,
row.code,
render_width as usize,
ln_width,
syn,
)
} else {
push_wrapped_diff_line(
row.line_no,
diff_type,
row.code,
render_width as usize,
ln_width,
)
};
let first_line = wrapped.into_iter().next().unwrap_or_else(|| Line::from(""));
first_line.render(
Rect::new(area.x.saturating_add(left_pad), y, render_width, 1),
buf,
);
y += 1;
}
}
impl Renderable for ThemePreviewWideRenderable {
fn desired_height(&self, _width: u16) -> u16 {
u16::MAX
}
fn render(&self, area: Rect, buf: &mut Buffer) {
render_preview(area, buf, &WIDE_PREVIEW_ROWS, true, WIDE_PREVIEW_LEFT_INSET);
}
}
impl Renderable for ThemePreviewNarrowRenderable {
fn desired_height(&self, _width: u16) -> u16 {
NARROW_PREVIEW_ROWS.len() as u16
}
fn render(&self, area: Rect, buf: &mut Buffer) {
render_preview(area, buf, &NARROW_PREVIEW_ROWS, false, 0);
}
}
fn subtitle_available_width(terminal_width: Option<u16>) -> usize {
let width = terminal_width.unwrap_or(80);
let content_width = popup_content_width(width);
if let Some((list_width, _side_width)) = side_by_side_layout_widths(
content_width,
SideContentWidth::Half,
WIDE_PREVIEW_MIN_WIDTH,
) {
list_width as usize
} else {
content_width as usize
}
}
fn theme_picker_subtitle(codex_home: Option<&Path>, terminal_width: Option<u16>) -> String {
let themes_dir = codex_home.map(|home| home.join("themes"));
let themes_dir_display = themes_dir
.as_deref()
.map(|path| format_directory_display(path, None));
let available_width = subtitle_available_width(terminal_width);
if let Some(path) = themes_dir_display
&& path.starts_with('~')
{
let subtitle = format!("Custom .tmTheme files can be added to the {path} directory.");
if UnicodeWidthStr::width(subtitle.as_str()) <= available_width {
return subtitle;
}
}
PREVIEW_FALLBACK_SUBTITLE.to_string()
}
/// Builds [`SelectionViewParams`] for the `/theme` picker dialog.
///
/// 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 configured name (or
/// adaptive default) so opening the picker without a persisted preference still
/// highlights the most likely intended entry.
pub(crate) fn build_theme_picker_params(
current_name: Option<&str>,
codex_home: Option<&Path>,
terminal_width: Option<u16>,
) -> SelectionViewParams {
// Snapshot the current theme so we can restore on cancel.
let original_theme = highlight::current_syntax_theme();
let entries = highlight::list_available_themes(codex_home);
let codex_home_owned = codex_home.map(Path::to_path_buf);
// Resolve the effective theme name: honor explicit config only when it is
// currently available; otherwise fall back to configured/default selection
// so opening `/theme` does not auto-preview an unrelated first entry.
let effective_name = if let Some(name) = current_name
&& entries.iter().any(|entry| entry.name == name)
{
name.to_string()
} else {
highlight::configured_theme_name()
};
// Track the index of the current theme so we can pre-select it.
let mut initial_idx = None;
let items: Vec<SelectionItem> = entries
.iter()
.enumerate()
.map(|(idx, entry)| {
let display_name = if entry.is_custom {
format!("{} (custom)", entry.name)
} else {
entry.name.clone()
};
let is_current = entry.name == effective_name;
if is_current {
initial_idx = Some(idx);
}
let name_for_action = entry.name.clone();
SelectionItem {
name: display_name,
is_current,
dismiss_on_select: true,
search_value: Some(entry.name.clone()),
actions: vec![Box::new(move |tx| {
tx.send(AppEvent::SyntaxThemeSelected {
name: name_for_action.clone(),
});
})],
..Default::default()
}
})
.collect();
// Derive preview targets from the final `items` list (not from `entries`)
// so preview ordering stays aligned if item construction/sorting changes.
let preview_theme_names: Vec<Option<String>> =
items.iter().map(|item| item.search_value.clone()).collect();
let preview_home = codex_home_owned.clone();
let on_selection_changed = Some(Box::new(move |idx: usize, _tx: &_| {
if let Some(Some(name)) = preview_theme_names.get(idx)
&& let Some(theme) = highlight::resolve_theme_by_name(name, preview_home.as_deref())
{
highlight::set_syntax_theme(theme);
}
})
as Box<dyn Fn(usize, &crate::app_event_sender::AppEventSender) + Send + Sync>);
// Restore original theme on cancel.
let on_cancel = Some(Box::new(move |_tx: &_| {
highlight::set_syntax_theme(original_theme.clone());
})
as Box<dyn Fn(&crate::app_event_sender::AppEventSender) + Send + Sync>);
SelectionViewParams {
title: Some("Select Syntax Theme".to_string()),
subtitle: Some(theme_picker_subtitle(
codex_home_owned.as_deref(),
terminal_width,
)),
footer_hint: Some(standard_popup_hint_line()),
items,
is_searchable: true,
search_placeholder: Some("Type to filter themes...".to_string()),
initial_selected_idx: initial_idx,
side_content: Box::new(ThemePreviewWideRenderable),
side_content_width: SideContentWidth::Half,
side_content_min_width: WIDE_PREVIEW_MIN_WIDTH,
stacked_side_content: Some(Box::new(ThemePreviewNarrowRenderable)),
preserve_side_content_bg: true,
on_selection_changed,
on_cancel,
..Default::default()
}
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
use ratatui::style::Modifier;
fn render_buffer(renderable: &dyn Renderable, width: u16, height: u16) -> Buffer {
let area = Rect::new(0, 0, width, height);
let mut buf = Buffer::empty(area);
renderable.render(area, &mut buf);
buf
}
fn render_lines(renderable: &dyn Renderable, width: u16, height: u16) -> Vec<String> {
let buf = render_buffer(renderable, width, height);
(0..height)
.map(|row| {
let mut line = String::new();
for col in 0..width {
let symbol = buf[(col, row)].symbol();
if symbol.is_empty() {
line.push(' ');
} else {
line.push_str(symbol);
}
}
line
})
.collect()
}
fn first_non_space_style_after_marker(buf: &Buffer, row: u16, width: u16) -> Option<Modifier> {
let marker_col = (0..width)
.find(|&col| buf[(col, row)].symbol() == "-" || buf[(col, row)].symbol() == "+")?;
for col in marker_col + 1..width {
if buf[(col, row)].symbol() != " " {
return Some(buf[(col, row)].style().add_modifier);
}
}
None
}
fn preview_line_number(line: &str) -> Option<usize> {
let trimmed = line.trim_start();
let digits_len = trimmed.chars().take_while(char::is_ascii_digit).count();
if digits_len == 0 {
return None;
}
let digits = &trimmed[..digits_len];
if !trimmed[digits_len..].starts_with(' ') {
return None;
}
digits.parse::<usize>().ok()
}
fn preview_line_marker(line: &str) -> Option<char> {
let trimmed = line.trim_start();
let digits_len = trimmed.chars().take_while(char::is_ascii_digit).count();
if digits_len == 0 {
return None;
}
let mut chars = trimmed[digits_len..].chars();
if chars.next()? != ' ' {
return None;
}
chars.next()
}
#[test]
fn theme_picker_uses_half_width_with_stacked_fallback_preview() {
let params = build_theme_picker_params(None, None, None);
assert_eq!(params.side_content_width, SideContentWidth::Half);
assert_eq!(params.side_content_min_width, WIDE_PREVIEW_MIN_WIDTH);
assert!(params.stacked_side_content.is_some());
}
#[test]
fn theme_picker_items_include_search_values_for_preview_mapping() {
let params = build_theme_picker_params(None, None, None);
assert!(
params.items.iter().all(|item| item.search_value.is_some()),
"theme picker preview mapping relies on item search_value to stay aligned with final item order"
);
}
#[test]
fn wide_preview_renders_all_lines_with_vertical_center_and_left_inset() {
let lines = render_lines(&ThemePreviewWideRenderable, 80, 20);
let numbered_rows: Vec<usize> = lines
.iter()
.enumerate()
.filter_map(|(idx, line)| preview_line_number(line).map(|_| idx))
.collect();
let total_preview_lines = WIDE_PREVIEW_ROWS.len();
assert_eq!(numbered_rows.len(), total_preview_lines);
let first_row = *numbered_rows
.first()
.expect("expected at least one preview row");
let last_row = *numbered_rows
.last()
.expect("expected at least one preview row");
assert!(
first_row > 0,
"expected top padding before centered preview"
);
assert!(
last_row < 19,
"expected bottom padding after centered preview"
);
let first_line = &lines[first_row];
assert!(
first_line.starts_with(" 31 fn summarize"),
"expected wide preview to start after a 2-char inset"
);
let markers: Vec<char> = lines
.iter()
.filter_map(|line| preview_line_marker(line))
.collect();
assert!(
markers.contains(&'+'),
"expected wide preview to include at least one addition line"
);
assert!(
markers.contains(&'-'),
"expected wide preview to include at least one removal line"
);
}
#[test]
fn narrow_preview_renders_single_add_and_single_remove_in_four_lines() {
let lines = render_lines(&ThemePreviewNarrowRenderable, 80, 6);
let numbered_lines: Vec<usize> = lines
.iter()
.filter_map(|line| preview_line_number(line))
.collect();
let markers: Vec<char> = lines
.iter()
.filter_map(|line| preview_line_marker(line))
.collect();
assert_eq!(numbered_lines, vec![12, 13, 13, 14]);
assert_eq!(markers.len(), 4);
assert_eq!(markers.iter().filter(|&&m| m == '+').count(), 1);
assert_eq!(markers.iter().filter(|&&m| m == '-').count(), 1);
let first_numbered = lines
.iter()
.find(|line| preview_line_number(line).is_some())
.expect("expected at least one rendered preview row");
assert!(
first_numbered.starts_with("12 fn greet"),
"expected narrow preview line numbers to start at the left edge"
);
}
#[test]
fn deleted_preview_code_uses_dim_overlay_like_real_diff_renderer() {
let width = 80;
let height = 6;
let buf = render_buffer(&ThemePreviewNarrowRenderable, width, height);
let lines = render_lines(&ThemePreviewNarrowRenderable, width, height);
let deleted_row = lines
.iter()
.enumerate()
.find_map(|(row, line)| (preview_line_marker(line) == Some('-')).then_some(row as u16))
.expect("expected a deleted preview row");
let modifiers = first_non_space_style_after_marker(&buf, deleted_row, width)
.expect("expected code text after diff marker");
assert!(
modifiers.contains(Modifier::DIM),
"expected deleted preview code to be dimmed"
);
}
#[test]
fn subtitle_uses_tilde_path_when_codex_home_under_home_directory() {
let home = dirs::home_dir().expect("home directory should be available");
let codex_home = home.join(".codex");
let subtitle = theme_picker_subtitle(Some(&codex_home), Some(200));
assert!(subtitle.contains("~"));
assert!(subtitle.contains("directory"));
}
#[test]
fn subtitle_falls_back_when_tilde_path_subtitle_is_too_wide() {
let home = dirs::home_dir().expect("home directory should be available");
let long_segment = "a".repeat(120);
let codex_home = home.join(long_segment).join(".codex");
let subtitle = theme_picker_subtitle(Some(&codex_home), Some(140));
assert_eq!(subtitle, PREVIEW_FALLBACK_SUBTITLE);
}
#[test]
fn subtitle_falls_back_to_preview_instructions_without_tilde_path() {
let subtitle = theme_picker_subtitle(None, None);
assert_eq!(subtitle, PREVIEW_FALLBACK_SUBTITLE);
}
#[test]
fn subtitle_falls_back_for_94_column_terminal_side_by_side_layout() {
let home = dirs::home_dir().expect("home directory should be available");
let codex_home = home.join(".codex");
let subtitle = theme_picker_subtitle(Some(&codex_home), Some(94));
assert_eq!(subtitle, PREVIEW_FALLBACK_SUBTITLE);
}
#[test]
fn unavailable_configured_theme_falls_back_to_configured_or_default_selection() {
let configured_or_default_theme = highlight::configured_theme_name();
let params = build_theme_picker_params(Some("not-a-real-theme"), None, Some(120));
let selected_idx = params
.initial_selected_idx
.expect("expected selected index for active fallback theme");
let selected_name = params.items[selected_idx]
.search_value
.as_deref()
.expect("expected search value to contain canonical theme name");
assert_eq!(selected_name, configured_or_default_theme);
}
}