diff --git a/codex-rs/core/src/config/edit.rs b/codex-rs/core/src/config/edit.rs index 0dfb4efd8e..fceb965998 100644 --- a/codex-rs/core/src/config/edit.rs +++ b/codex-rs/core/src/config/edit.rs @@ -55,6 +55,14 @@ pub enum ConfigEdit { ClearPath { segments: Vec }, } +/// Produces a config edit that sets `[tui] theme = ""`. +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 { diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index ebbf757523..95f965b1aa 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -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) } diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 159cda88f4..08c5bf672c 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -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. diff --git a/codex-rs/tui/src/bottom_pane/list_selection_view.rs b/codex-rs/tui/src/bottom_pane/list_selection_view.rs index 14f94ca6f4..21491033ac 100644 --- a/codex-rs/tui/src/bottom_pane/list_selection_view.rs +++ b/codex-rs/tui/src/bottom_pane/list_selection_view.rs @@ -36,6 +36,12 @@ use unicode_width::UnicodeWidthStr; /// One selectable item in the generic selection list. pub(crate) type SelectionAction = Box; +/// Callback type for when the selection changes (navigation, filter, number-key). +pub type OnSelectionChangedCallback = Option>; + +/// Callback type for when the selection popup is dismissed without accepting (e.g. via Esc or Ctrl+C). +pub type OnCancelCallback = Option>; + /// 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, pub initial_selected_idx: Option, + + /// 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, + + /// 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, header: Box, initial_selected_idx: Option, + footer_content: Box, + + /// 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), diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 9bfddd18e4..6dbed2d12b 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -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. diff --git a/codex-rs/tui/src/diff_render.rs b/codex-rs/tui/src/diff_render.rs index 8d9ab7d575..21142b26d2 100644 --- a/codex-rs/tui/src/diff_render.rs +++ b/codex-rs/tui/src/diff_render.rs @@ -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, wrap_cols: usize, cwd: &Path) -> Vec (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 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 = 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" + ); + } } diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 14fdb57431..e33d333d3c 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -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; diff --git a/codex-rs/tui/src/markdown_render.rs b/codex-rs/tui/src/markdown_render.rs index d55504be3b..c5ec4e3481 100644 --- a/codex-rs/tui/src/markdown_render.rs +++ b/codex-rs/tui/src/markdown_render.rs @@ -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:?}" + ); + } } diff --git a/codex-rs/tui/src/render/highlight.rs b/codex-rs/tui/src/render/highlight.rs index b7c6bcdede..d42972f2c5 100644 --- a/codex-rs/tui/src/render/highlight.rs +++ b/codex-rs/tui/src/render/highlight.rs @@ -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 = OnceLock::new(); -static THEME: OnceLock = OnceLock::new(); +static THEME: OnceLock> = OnceLock::new(); static THEME_OVERRIDE: OnceLock> = OnceLock::new(); static CODEX_HOME: OnceLock> = OnceLock::new(); @@ -113,34 +114,166 @@ fn load_custom_theme(name: &str, codex_home: &Path) -> Option { 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.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 { + 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 { + let mut entries: Vec = 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 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::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. diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index 16e767f802..55fc01e90f 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -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, } } diff --git a/codex-rs/tui/src/theme_picker.rs b/codex-rs/tui/src/theme_picker.rs new file mode 100644 index 0000000000..5414096d16 --- /dev/null +++ b/codex-rs/tui/src/theme_picker.rs @@ -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> = 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 = 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); + + // Restore original theme on cancel. + let on_cancel = Some(Box::new(move |_tx: &_| { + highlight::set_syntax_theme(original_theme.clone()); + }) + as Box); + + 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() + } +}