feat(tui): add /theme picker with live syntax-highlighted preview

Add a `/theme` slash command that opens an interactive picker listing
all bundled and custom (.tmTheme) syntax themes. The picker shows a
live-preview code snippet below the list that re-highlights as the
user navigates, and restores the original theme on cancel.

Key changes:
- ListSelectionView gains `footer_content`, `on_selection_changed`,
  and `on_cancel` to support rich preview and lifecycle callbacks
- highlight.rs: swap global Theme from OnceLock to RwLock for live
  swapping; add resolve/list/set/current helpers for theme management
- New theme_picker module with ThemePreviewRenderable and picker builder
- diff_render: make DiffLineType and helpers pub(crate); fix rename
  highlighting to use destination extension
- markdown_render: fix CRLF double-newlines and info-string metadata
  parsing for fenced code blocks
This commit is contained in:
Felipe Coury
2026-02-09 12:35:18 -03:00
parent 976dd6831e
commit ea57beb959
11 changed files with 540 additions and 37 deletions

View File

@@ -55,6 +55,14 @@ pub enum ConfigEdit {
ClearPath { segments: Vec<String> },
}
/// Produces a config edit that sets `[tui] theme = "<name>"`.
pub fn syntax_theme_edit(name: &str) -> ConfigEdit {
ConfigEdit::SetPath {
segments: vec!["tui".to_string(), "theme".to_string()],
value: value(name.to_string()),
}
}
pub fn status_line_items_edit(items: &[String]) -> ConfigEdit {
let mut array = toml_edit::Array::new();
for item in items {

View File

@@ -2605,6 +2605,23 @@ impl App {
AppEvent::StatusLineSetupCancelled => {
self.chat_widget.cancel_status_line_setup();
}
AppEvent::SyntaxThemeSelected { name } => {
let edit = codex_core::config::edit::syntax_theme_edit(&name);
let apply_result = ConfigEditsBuilder::new(&self.config.codex_home)
.with_edits([edit])
.apply()
.await;
match apply_result {
Ok(()) => {
self.config.tui_theme = Some(name);
}
Err(err) => {
tracing::error!(error = %err, "failed to persist theme selection");
self.chat_widget
.add_error_message(format!("Failed to save theme: {err}"));
}
}
}
}
Ok(AppRunControl::Continue)
}

View File

@@ -360,6 +360,9 @@ pub(crate) enum AppEvent {
},
/// Dismiss the status-line setup UI without changing config.
StatusLineSetupCancelled,
/// Apply a user-confirmed syntax theme selection.
SyntaxThemeSelected { name: String },
}
/// The exit strategy requested by the UI layer.

View File

@@ -36,6 +36,12 @@ use unicode_width::UnicodeWidthStr;
/// 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).
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).
pub 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 +85,17 @@ pub(crate) struct SelectionViewParams {
pub col_width_mode: ColumnWidthMode,
pub header: Box<dyn Renderable>,
pub initial_selected_idx: Option<usize>,
/// Rich content rendered below the list items, inside the bordered menu
/// surface. Used by the theme picker to show a syntax-highlighted preview.
pub footer_content: 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 +112,9 @@ impl Default for SelectionViewParams {
col_width_mode: ColumnWidthMode::AutoVisible,
header: Box::new(()),
initial_selected_idx: None,
footer_content: Box::new(()),
on_selection_changed: None,
on_cancel: None,
}
}
}
@@ -120,6 +140,13 @@ pub(crate) struct ListSelectionView {
last_selected_actual_idx: Option<usize>,
header: Box<dyn Renderable>,
initial_selected_idx: Option<usize>,
footer_content: 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 +188,9 @@ impl ListSelectionView {
last_selected_actual_idx: None,
header,
initial_selected_idx: params.initial_selected_idx,
footer_content: params.footer_content,
on_selection_changed: params.on_selection_changed,
on_cancel: params.on_cancel,
};
s.apply_filter();
s
@@ -278,6 +308,7 @@ impl ListSelectionView {
let visible = Self::max_visible_rows(len);
self.state.ensure_visible(len, visible);
self.skip_disabled_up();
self.fire_selection_changed();
}
fn move_down(&mut self) {
@@ -286,6 +317,18 @@ impl ListSelectionView {
let visible = Self::max_visible_rows(len);
self.state.ensure_visible(len, visible);
self.skip_disabled_down();
self.fire_selection_changed();
}
fn fire_selection_changed(&self) {
if let Some(cb) = &self.on_selection_changed
&& let Some(actual) = self
.state
.selected_idx
.and_then(|vis| self.filtered_indices.get(vis).copied())
{
cb(actual, &self.app_event_tx);
}
}
fn accept(&mut self) {
@@ -469,6 +512,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
}
@@ -508,6 +554,14 @@ impl Renderable for ListSelectionView {
if self.is_searchable {
height = height.saturating_add(1);
}
// Footer content (e.g. theme preview) rendered below the list.
// A 1-row gap keeps the picker bg as a visual separator.
let footer_content_height = self
.footer_content
.desired_height(width.saturating_sub(4));
if footer_content_height > 0 {
height = height.saturating_add(1 + footer_content_height);
}
if let Some(note) = &self.footer_note {
let note_width = width.saturating_sub(2);
let note_lines = wrap_styled_line(note, note_width);
@@ -565,11 +619,17 @@ impl Renderable for ListSelectionView {
ColumnWidthMode::Fixed,
),
};
let [header_area, _, search_area, list_area] = Layout::vertical([
let footer_content_height = self
.footer_content
.desired_height(outer_content_area.width.saturating_sub(4));
let footer_gap = if footer_content_height > 0 { 1 } else { 0 };
let [header_area, _, search_area, list_area, _, footer_content_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(footer_gap),
Constraint::Length(footer_content_height),
])
.areas(content_area);
@@ -634,6 +694,27 @@ impl Renderable for ListSelectionView {
};
}
// Render footer content (e.g. theme preview). Clear the menu surface
// background from the footer content area through the bottom inset so
// the preview appears on the terminal's own background without a
// trailing picker-bg row.
if footer_content_area.height > 0 {
let clear_height = (outer_content_area.y + outer_content_area.height)
.saturating_sub(footer_content_area.y);
let clear_area = Rect::new(
outer_content_area.x,
footer_content_area.y,
outer_content_area.width,
clear_height,
);
for y in clear_area.y..clear_area.y + clear_area.height {
for x in clear_area.x..clear_area.x + clear_area.width {
buf[(x, y)].set_style(ratatui::style::Style::reset());
}
}
self.footer_content.render(footer_content_area, buf);
}
if footer_area.height > 0 {
let [note_area, hint_area] = Layout::vertical([
Constraint::Length(note_height),

View File

@@ -3482,6 +3482,9 @@ impl ChatWidget {
SlashCommand::Statusline => {
self.open_status_line_setup();
}
SlashCommand::Theme => {
self.open_theme_picker();
}
SlashCommand::Ps => {
self.add_ps_output();
}
@@ -4431,6 +4434,15 @@ impl ChatWidget {
self.bottom_pane.show_view(Box::new(view));
}
fn open_theme_picker(&mut self) {
let codex_home = codex_core::config::find_codex_home().ok();
let params = crate::theme_picker::build_theme_picker_params(
self.config.tui_theme.as_deref(),
codex_home.as_deref(),
);
self.bottom_pane.show_selection_view(params);
}
/// Parses configured status-line ids into known items and collects unknown ids.
///
/// Unknown ids are deduplicated in insertion order for warning messages.

View File

@@ -28,8 +28,9 @@ use crate::render::renderable::Renderable;
use codex_core::git_info::get_git_repo_root;
use codex_protocol::protocol::FileChange;
// Internal representation for diff line rendering
enum DiffLineType {
// Diff line type used for gutter sign + style selection.
#[derive(Clone, Copy)]
pub(crate) enum DiffLineType {
Insert,
Delete,
Context,
@@ -192,7 +193,10 @@ fn render_changes_block(rows: Vec<Row>, wrap_cols: usize, cwd: &Path) -> Vec<RtL
out.push(RtLine::from(header));
}
let lang = detect_lang_for_path(&r.path);
// For renames, use the destination extension for highlighting — the
// diff content reflects the new file, not the old one.
let lang_path = r.move_path.as_deref().unwrap_or(&r.path);
let lang = detect_lang_for_path(lang_path);
let mut lines = vec![];
render_change(&r.change, &mut lines, wrap_cols - 4, lang.as_deref());
out.extend(prefix_lines(lines, " ".into(), " ".into()));
@@ -448,7 +452,7 @@ pub(crate) fn calculate_add_remove_from_diff(diff: &str) -> (usize, usize) {
}
}
fn push_wrapped_diff_line(
pub(crate) fn push_wrapped_diff_line(
line_number: usize,
kind: DiffLineType,
text: &str,
@@ -458,7 +462,7 @@ fn push_wrapped_diff_line(
push_wrapped_diff_line_inner(line_number, kind, text, width, line_number_width, None)
}
fn push_wrapped_diff_line_with_syntax(
pub(crate) fn push_wrapped_diff_line_with_syntax(
line_number: usize,
kind: DiffLineType,
text: &str,
@@ -650,7 +654,7 @@ fn wrap_styled_spans(spans: &[RtSpan<'static>], max_cols: usize) -> Vec<Vec<RtSp
result
}
fn line_number_width(max_line_number: usize) -> usize {
pub(crate) fn line_number_width(max_line_number: usize) -> usize {
if max_line_number == 0 {
1
} else {
@@ -1128,4 +1132,34 @@ mod tests {
}
}
}
#[test]
fn rename_diff_uses_destination_extension_for_highlighting() {
// A rename from an unknown extension to .rs should highlight as Rust.
// Without the fix, detect_lang_for_path uses the source path (.xyzzy),
// which has no syntax definition, so highlighting is skipped.
let original = "fn main() {}\n";
let modified = "fn main() { println!(\"hi\"); }\n";
let patch = diffy::create_patch(original, modified).to_string();
let mut changes: HashMap<PathBuf, FileChange> = HashMap::new();
changes.insert(
PathBuf::from("foo.xyzzy"),
FileChange::Update {
unified_diff: patch,
move_path: Some(PathBuf::from("foo.rs")),
},
);
let lines = create_diff_summary(&changes, &PathBuf::from("/"), 80);
let has_rgb = lines.iter().any(|line| {
line.spans
.iter()
.any(|s| matches!(s.style.fg, Some(ratatui::style::Color::Rgb(..))))
});
assert!(
has_rgb,
"rename from .xyzzy to .rs should produce syntax-highlighted (RGB) spans"
);
}
}

View File

@@ -104,6 +104,7 @@ mod status_indicator_widget;
mod streaming;
mod style;
mod terminal_palette;
mod theme_picker;
mod text_formatting;
mod tooltips;
mod tui;

View File

@@ -286,10 +286,9 @@ where
// When inside a fenced code block with a known language, accumulate
// text into the buffer for batch highlighting in end_codeblock().
// Append verbatim — pulldown-cmark text events already contain the
// original line breaks, so inserting separators would double them.
if self.in_code_block && self.code_block_lang.is_some() {
if !self.code_block_buffer.is_empty() {
self.code_block_buffer.push('\n');
}
self.code_block_buffer.push_str(&text);
return;
}
@@ -417,8 +416,15 @@ where
}
self.in_code_block = true;
// Store the language for syntax highlighting; clear the buffer.
let lang = lang.filter(|l| !l.is_empty());
// Extract the language token from the info string. CommonMark info
// strings can contain metadata after the language, separated by commas,
// spaces, or other delimiters (e.g. "rust,no_run", "rust title=demo").
// Take only the first token so the syntax lookup succeeds.
let lang = lang
.as_deref()
.and_then(|s| s.split([',', ' ', '\t']).next())
.filter(|s| !s.is_empty())
.map(|s| s.to_string());
self.code_block_lang = lang;
self.code_block_buffer.clear();
@@ -711,4 +717,39 @@ mod tests {
vec!["fn main() { println!(\"hi from a long line\"); }".to_string(),]
);
}
#[test]
fn fenced_code_info_string_with_metadata_highlights() {
// CommonMark info strings like "rust,no_run" or "rust title=demo"
// contain metadata after the language token. The language must be
// extracted (first word / comma-separated token) so highlighting works.
for info in &["rust,no_run", "rust no_run", "rust title=\"demo\""] {
let markdown = format!("```{info}\nfn main() {{}}\n```\n");
let rendered = render_markdown_text(&markdown);
let has_rgb = rendered.lines.iter().any(|line| {
line.spans
.iter()
.any(|s| matches!(s.style.fg, Some(ratatui::style::Color::Rgb(..))))
});
assert!(
has_rgb,
"info string \"{info}\" should still produce syntax highlighting"
);
}
}
#[test]
fn crlf_code_block_no_extra_blank_lines() {
// pulldown-cmark can split CRLF code blocks into multiple Text events.
// The buffer must concatenate them verbatim — no inserted separators.
let markdown = "```rust\r\nfn main() {}\r\n line2\r\n```\r\n";
let rendered = render_markdown_text(markdown);
let lines = lines_to_strings(&rendered);
// Should be exactly two code lines; no spurious blank line between them.
assert_eq!(
lines,
vec!["fn main() {}".to_string(), " line2".to_string()],
"CRLF code block should not produce extra blank lines: {lines:?}"
);
}
}

View File

@@ -6,6 +6,7 @@ use ratatui::text::Span;
use std::path::Path;
use std::path::PathBuf;
use std::sync::OnceLock;
use std::sync::RwLock;
use syntect::easy::HighlightLines;
use syntect::highlighting::FontStyle;
use syntect::highlighting::Style as SyntectStyle;
@@ -19,7 +20,7 @@ use two_face::theme::EmbeddedThemeName;
// -- Global singletons -------------------------------------------------------
static SYNTAX_SET: OnceLock<SyntaxSet> = OnceLock::new();
static THEME: OnceLock<Theme> = OnceLock::new();
static THEME: OnceLock<RwLock<Theme>> = OnceLock::new();
static THEME_OVERRIDE: OnceLock<Option<String>> = OnceLock::new();
static CODEX_HOME: OnceLock<Option<PathBuf>> = OnceLock::new();
@@ -113,34 +114,166 @@ fn load_custom_theme(name: &str, codex_home: &Path) -> Option<Theme> {
ThemeSet::get_theme(custom_theme_path(name, codex_home)).ok()
}
fn theme() -> &'static Theme {
THEME.get_or_init(|| {
let ts = two_face::theme::extra();
/// Build the theme from current override/auto-detection settings.
/// Extracted from the old `theme()` init closure so it can be reused.
fn build_default_theme() -> Theme {
let ts = two_face::theme::extra();
// Honor user-configured theme if valid.
if let Some(Some(name)) = THEME_OVERRIDE.get() {
// 1. Try bundled theme by kebab-case name.
if let Some(theme_name) = parse_theme_name(name) {
return ts.get(theme_name).clone();
// Honor user-configured theme if valid.
if let Some(Some(name)) = THEME_OVERRIDE.get() {
// 1. Try bundled theme by kebab-case name.
if let Some(theme_name) = parse_theme_name(name) {
return ts.get(theme_name).clone();
}
// 2. Try loading ~/.codex/themes/{name}.tmTheme from disk.
if let Some(Some(home)) = CODEX_HOME.get() {
if let Some(theme) = load_custom_theme(name, home) {
return theme;
}
// 2. Try loading ~/.codex/themes/{name}.tmTheme from disk.
if let Some(Some(home)) = CODEX_HOME.get() {
if let Some(theme) = load_custom_theme(name, home) {
return theme;
}
tracing::warn!("unknown syntax theme \"{name}\", falling back to auto-detection");
}
// Adaptive default: light or dark based on terminal background.
let name = match crate::terminal_palette::default_bg() {
Some(bg) if crate::color::is_light(bg) => EmbeddedThemeName::CatppuccinLatte,
_ => EmbeddedThemeName::CatppuccinMocha,
};
ts.get(name).clone()
}
fn theme_lock() -> &'static RwLock<Theme> {
THEME.get_or_init(|| RwLock::new(build_default_theme()))
}
/// Swap the active syntax theme at runtime (for live preview).
pub(crate) fn set_syntax_theme(theme: Theme) {
if let Ok(mut guard) = theme_lock().write() {
*guard = theme;
}
}
/// Clone the current syntax theme (e.g. to save for cancel-restore).
pub(crate) fn current_syntax_theme() -> Theme {
theme_lock().read().unwrap().clone()
}
/// Return the kebab-case name of the currently active theme.
/// This accounts for the user override, custom .tmTheme files, and the
/// adaptive auto-detection fallback.
pub(crate) fn current_theme_name() -> String {
// Explicit user override?
if let Some(Some(name)) = THEME_OVERRIDE.get() {
if parse_theme_name(name).is_some() {
return name.clone();
}
if let Some(Some(home)) = CODEX_HOME.get() {
if custom_theme_path(name, home).is_file() {
return name.clone();
}
}
}
// Adaptive default: light or dark based on terminal background.
match crate::terminal_palette::default_bg() {
Some(bg) if crate::color::is_light(bg) => "catppuccin-latte".to_string(),
_ => "catppuccin-mocha".to_string(),
}
}
/// Resolve a theme name to a `Theme` (bundled or custom). Returns `None`
/// when the name is unknown and no matching `.tmTheme` file exists.
pub(crate) fn resolve_theme_by_name(name: &str, codex_home: Option<&Path>) -> Option<Theme> {
let ts = two_face::theme::extra();
// Bundled theme?
if let Some(embedded) = parse_theme_name(name) {
return Some(ts.get(embedded).clone());
}
// Custom .tmTheme file?
if let Some(home) = codex_home {
if let Some(theme) = load_custom_theme(name, home) {
return Some(theme);
}
}
None
}
/// A theme available in the picker.
pub(crate) struct ThemeEntry {
pub name: String,
pub is_custom: bool,
}
/// List all available theme names: bundled themes + custom `.tmTheme` files
/// found in `{codex_home}/themes/`.
pub(crate) fn list_available_themes(codex_home: Option<&Path>) -> Vec<ThemeEntry> {
let mut entries: Vec<ThemeEntry> = BUILTIN_THEME_NAMES
.iter()
.map(|name| ThemeEntry {
name: name.to_string(),
is_custom: false,
})
.collect();
// Discover custom themes on disk, deduplicating against builtins.
if let Some(home) = codex_home {
let themes_dir = home.join("themes");
if let Ok(read_dir) = std::fs::read_dir(&themes_dir) {
for entry in read_dir.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) == Some("tmTheme") {
if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
let name = stem.to_string();
if !entries.iter().any(|e| e.name == name) {
entries.push(ThemeEntry {
name,
is_custom: true,
});
}
}
}
}
tracing::warn!("unknown syntax theme \"{name}\", falling back to auto-detection");
}
}
// Adaptive default: light or dark based on terminal background.
let name = match crate::terminal_palette::default_bg() {
Some(bg) if crate::color::is_light(bg) => EmbeddedThemeName::CatppuccinLatte,
_ => EmbeddedThemeName::CatppuccinMocha,
};
ts.get(name).clone()
})
entries
}
/// All 32 bundled theme names in kebab-case, ordered alphabetically.
const BUILTIN_THEME_NAMES: &[&str] = &[
"1337",
"ansi",
"base16",
"base16-256",
"base16-eighties-dark",
"base16-mocha-dark",
"base16-ocean-dark",
"base16-ocean-light",
"catppuccin-frappe",
"catppuccin-latte",
"catppuccin-macchiato",
"catppuccin-mocha",
"coldark-cold",
"coldark-dark",
"dark-neon",
"dracula",
"github",
"gruvbox-dark",
"gruvbox-light",
"inspired-github",
"monokai-extended",
"monokai-extended-bright",
"monokai-extended-light",
"monokai-extended-origin",
"nord",
"one-half-dark",
"one-half-light",
"solarized-dark",
"solarized-light",
"sublime-snazzy",
"two-dark",
"zenburn",
];
// -- Style conversion (syntect -> ratatui) ------------------------------------
/// Convert a syntect `Style` to a ratatui `Style`.
@@ -241,14 +374,15 @@ fn highlight_to_line_spans(code: &str, lang: &str) -> Option<Vec<Vec<Span<'stati
}
// Bail out early for oversized inputs to avoid excessive resource usage.
if code.len() > MAX_HIGHLIGHT_BYTES
|| code.as_bytes().iter().filter(|&&b| b == b'\n').count() > MAX_HIGHLIGHT_LINES
{
// Count actual lines (not newline bytes) to avoid an off-by-one when
// the input does not end with a newline.
if code.len() > MAX_HIGHLIGHT_BYTES || code.lines().count() > MAX_HIGHLIGHT_LINES {
return None;
}
let syntax = find_syntax(lang)?;
let mut h = HighlightLines::new(syntax, theme());
let theme_guard = theme_lock().read().unwrap();
let mut h = HighlightLines::new(syntax, &*theme_guard);
let mut lines: Vec<Vec<Span<'static>>> = Vec::new();
for line in LinesWithEndings::from(code) {
@@ -511,6 +645,21 @@ mod tests {
assert!(result.is_none(), "too many lines should fall back to None");
}
#[test]
fn highlight_many_lines_no_trailing_newline_falls_back() {
// A snippet with exactly MAX_HIGHLIGHT_LINES+1 lines but no trailing
// newline has only MAX_HIGHLIGHT_LINES newline bytes. The guard must
// count actual lines, not newline bytes, to catch this.
let mut code = "let x = 1;\n".repeat(MAX_HIGHLIGHT_LINES);
code.push_str("let x = 1;"); // line MAX_HIGHLIGHT_LINES+1, no trailing \n
assert_eq!(code.lines().count(), MAX_HIGHLIGHT_LINES + 1);
let result = highlight_code_to_styled_spans(&code, "rust");
assert!(
result.is_none(),
"MAX_HIGHLIGHT_LINES+1 lines without trailing newline should fall back"
);
}
#[test]
fn find_syntax_resolves_languages_and_aliases() {
// Languages resolved directly by two-face's extended syntax set.

View File

@@ -37,6 +37,7 @@ pub enum SlashCommand {
Status,
DebugConfig,
Statusline,
Theme,
Mcp,
Apps,
Logout,
@@ -75,6 +76,7 @@ impl SlashCommand {
SlashCommand::Status => "show current session configuration and token usage",
SlashCommand::DebugConfig => "show config layers and requirement sources for debugging",
SlashCommand::Statusline => "configure which items appear in the status line",
SlashCommand::Theme => "choose a syntax highlighting theme",
SlashCommand::Ps => "list background terminals",
SlashCommand::Clean => "stop all background terminals",
SlashCommand::MemoryDrop => "DO NOT USE",
@@ -155,6 +157,7 @@ impl SlashCommand {
SlashCommand::Collab => true,
SlashCommand::Agent => true,
SlashCommand::Statusline => false,
SlashCommand::Theme => false,
}
}

View File

@@ -0,0 +1,154 @@
use std::path::Path;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::text::Span;
use ratatui::widgets::Widget;
use crate::app_event::AppEvent;
use crate::bottom_pane::SelectionItem;
use crate::bottom_pane::SelectionViewParams;
use crate::render::highlight;
use crate::render::renderable::Renderable;
/// Rust snippet for the theme preview — compact enough to fit in the picker,
/// varied enough to exercise keywords, types, strings, and macros.
const PREVIEW_CODE: &str = "\
fn greet(name: &str) -> String {
let msg = format!(\"Hello, {name}!\");
println!(\"{msg}\");
msg
}";
/// Fixed height: 5 code lines.
const PREVIEW_HEIGHT: u16 = 5;
/// Renders a syntax-highlighted code snippet below the theme list so users can
/// preview what each theme looks like on real code.
struct ThemePreviewRenderable;
impl Renderable for ThemePreviewRenderable {
fn desired_height(&self, _width: u16) -> u16 {
PREVIEW_HEIGHT
}
fn render(&self, area: Rect, buf: &mut Buffer) {
if area.height == 0 || area.width == 0 {
return;
}
let mut y = area.y;
// Syntax-highlight the full snippet as one block.
let syntax_lines = highlight::highlight_code_to_styled_spans(PREVIEW_CODE, "rust");
let line_count = PREVIEW_CODE.lines().count();
let ln_width = if line_count == 0 { 1 } else { line_count.to_string().len() };
// Render each line with a dim gutter (line number).
for (i, raw_line) in PREVIEW_CODE.lines().enumerate() {
if y >= area.y + area.height {
break;
}
let gutter = format!("{:>ln_width$} ", i + 1);
let mut spans: Vec<Span<'static>> = vec![Span::from(gutter).dim()];
if let Some(syn) = syntax_lines.as_ref().and_then(|sl| sl.get(i)) {
spans.extend(syn.iter().cloned());
} else {
spans.push(Span::raw(raw_line.to_string()));
}
Line::from(spans).render(Rect::new(area.x, y, area.width, 1), buf);
y += 1;
}
}
}
/// Builds [`SelectionViewParams`] for the `/theme` picker dialog.
///
/// Lists all bundled themes plus custom `.tmTheme` files, with live preview
/// on cursor movement and cancel-restore.
pub(crate) fn build_theme_picker_params(
current_name: Option<&str>,
codex_home: Option<&Path>,
) -> 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(|p| p.to_path_buf());
// Resolve the effective theme name: honor explicit config, fall back to
// the auto-detected default so the picker pre-selects even when no theme
// is configured.
let effective_name = current_name
.map(str::to_string)
.unwrap_or_else(highlight::current_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();
// Live preview: resolve and apply theme on each cursor movement,
// and update the preview label name.
let preview_home = codex_home_owned.clone();
let on_selection_changed = Some(Box::new(move |idx: usize, _tx: &_| {
let all = highlight::list_available_themes(preview_home.as_deref());
if let Some(entry) = all.get(idx) {
if let Some(theme) =
highlight::resolve_theme_by_name(&entry.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("Arrow keys to preview, Enter to select, Esc to cancel.".to_string()),
items,
is_searchable: true,
search_placeholder: Some("Type to filter themes...".to_string()),
initial_selected_idx: initial_idx,
footer_content: Box::new(ThemePreviewRenderable),
on_selection_changed,
on_cancel,
..Default::default()
}
}