feat: enhance theme selection UI with live preview and fix text wrapping

- Add live theme preview when navigating with arrow keys (PreviewTheme event)
- Separate preview from confirmation to prevent spamming chat history
- Create scrollable theme selection UI with position indicators
- Fix hard word-wrapping in LLM responses with proper tokenization
- Add basic markdown support for **bold** text rendering
- Move Paper Light Pro to correct light themes section
- Implement text_processing module for word wrapping and markdown
- Theme selection UI now uses current theme colors throughout

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <[email protected]>
This commit is contained in:
James Peter
2025-08-12 11:06:49 +10:00
co-authored by Claude
parent 3778243b76
commit 96a5922ba4
6 changed files with 394 additions and 61 deletions
+20
View File
@@ -504,6 +504,26 @@ impl App<'_> {
// Request a redraw to apply the new theme
self.schedule_redraw();
}
AppEvent::PreviewTheme(new_theme) => {
// Switch the theme immediately for preview (no history event)
crate::theme::switch_theme(new_theme);
// Clear terminal with new theme colors
let theme_bg = crate::colors::background();
let theme_fg = crate::colors::text();
let _ = crossterm::execute!(
std::io::stdout(),
crossterm::style::SetColors(crossterm::style::Colors::new(theme_fg.into(), theme_bg.into())),
crossterm::terminal::Clear(crossterm::terminal::ClearType::All),
crossterm::cursor::MoveTo(0, 0),
crossterm::terminal::SetTitle("Coder"),
crossterm::terminal::EnableLineWrap
);
// Don't update config or add to history for previews
// Request a redraw to apply the new theme
self.schedule_redraw();
}
AppEvent::OnboardingAuthComplete(result) => {
if let AppState::Onboarding { screen } = &mut self.app_state {
screen.on_auth_complete(result);
+4 -1
View File
@@ -41,8 +41,11 @@ pub(crate) enum AppEvent {
/// Update the reasoning effort level
UpdateReasoningEffort(ReasoningEffort),
/// Update the theme
/// Update the theme (with history event)
UpdateTheme(ThemeName),
/// Preview theme (no history event)
PreviewTheme(ThemeName),
/// Kick off an asynchronous file search for the given query (text after
/// the `@`). Previous searches may be cancelled by the app layer so there
@@ -2,9 +2,9 @@ use codex_core::config_types::ThemeName;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::buffer::Buffer;
use ratatui::layout::{Alignment, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Clear, Paragraph, Widget};
use ratatui::widgets::{Block, Borders, Paragraph, Widget};
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
@@ -14,8 +14,8 @@ use super::BottomPane;
/// Interactive UI for selecting theme
pub(crate) struct ThemeSelectionView {
current_theme: ThemeName,
selected_theme: ThemeName,
original_theme: ThemeName, // Theme to restore on cancel
current_theme: ThemeName, // Currently displayed theme
selected_index: usize,
app_event_tx: AppEventSender,
is_complete: bool,
@@ -30,8 +30,8 @@ impl ThemeSelectionView {
.unwrap_or(0);
Self {
original_theme: current_theme,
current_theme,
selected_theme: current_theme,
selected_index,
app_event_tx,
is_complete: false,
@@ -47,6 +47,7 @@ impl ThemeSelectionView {
(ThemeName::LightPorcelain, "Light - Porcelain", "Refined porcelain tones"),
(ThemeName::LightSandbar, "Light - Sandbar", "Warm sandy beach colors"),
(ThemeName::LightGlacier, "Light - Glacier", "Cool glacier blues"),
(ThemeName::DarkPaperLightPro, "Light - Paper Light Pro", "Premium paper-like"),
// Dark themes (below)
(ThemeName::DarkCarbonNight, "Dark - Carbon Night", "Sleek modern dark theme"),
(ThemeName::DarkShinobiDusk, "Dark - Shinobi Dusk", "Japanese-inspired twilight"),
@@ -55,7 +56,6 @@ impl ThemeSelectionView {
(ThemeName::DarkAuroraFlux, "Dark - Aurora Flux", "Northern lights inspired"),
(ThemeName::DarkCharcoalRainbow, "Dark - Charcoal Rainbow", "High-contrast accessible"),
(ThemeName::DarkZenGarden, "Dark - Zen Garden", "Calm and peaceful"),
(ThemeName::DarkPaperLightPro, "Dark - Paper Light Pro", "Premium paper-like"),
]
}
@@ -66,25 +66,38 @@ impl ThemeSelectionView {
} else {
self.selected_index -= 1;
}
self.selected_theme = options[self.selected_index].0;
self.current_theme = options[self.selected_index].0;
// Live preview - update theme immediately (no history event)
self.app_event_tx.send(AppEvent::PreviewTheme(self.current_theme));
}
fn move_selection_down(&mut self) {
let options = Self::get_theme_options();
self.selected_index = (self.selected_index + 1) % options.len();
self.selected_theme = options[self.selected_index].0;
self.current_theme = options[self.selected_index].0;
// Live preview - update theme immediately (no history event)
self.app_event_tx.send(AppEvent::PreviewTheme(self.current_theme));
}
fn confirm_selection(&self) {
// Send event to update theme
self.app_event_tx.send(AppEvent::UpdateTheme(self.selected_theme));
// Confirm the selection - this will add it to history
self.app_event_tx.send(AppEvent::UpdateTheme(self.current_theme));
}
fn cancel_selection(&mut self) {
// Restore original theme on cancel (no history event)
if self.current_theme != self.original_theme {
self.app_event_tx.send(AppEvent::PreviewTheme(self.original_theme));
}
}
}
impl<'a> BottomPaneView<'a> for ThemeSelectionView {
fn desired_height(&self, _width: u16) -> u16 {
// Return height needed for the popup
15
// Use most of the available screen for better scrolling
// But cap it at the number of themes + header/footer
let theme_count = Self::get_theme_options().len() as u16;
(theme_count + 4).min(20)
}
fn handle_key_event(&mut self, _pane: &mut BottomPane<'a>, key_event: KeyEvent) {
@@ -116,6 +129,7 @@ impl<'a> BottomPaneView<'a> for ThemeSelectionView {
modifiers: KeyModifiers::NONE,
..
} => {
self.cancel_selection();
self.is_complete = true;
}
_ => {}
@@ -128,84 +142,142 @@ impl<'a> BottomPaneView<'a> for ThemeSelectionView {
fn render(&self, area: Rect, buf: &mut Buffer) {
let options = Self::get_theme_options();
let theme = crate::theme::current_theme();
// Calculate available height for the list (excluding header and footer)
let available_height = area.height.saturating_sub(4) as usize;
// Calculate scroll offset to keep selected item visible
let scroll_offset = if available_height >= options.len() {
// All items fit, no scrolling needed
0
} else if self.selected_index < available_height / 2 {
// Near the top
0
} else if self.selected_index >= options.len() - available_height / 2 {
// Near the bottom
options.len().saturating_sub(available_height)
} else {
// Center the selected item
self.selected_index.saturating_sub(available_height / 2)
};
// Create content
let mut lines = vec![
Line::from(vec![
Span::raw("Select Theme"),
]).style(Style::default().add_modifier(Modifier::BOLD)),
Span::styled(
"Theme Selection",
Style::default()
.fg(theme.text_bright)
.add_modifier(Modifier::BOLD),
),
]),
Line::from(""),
];
for (i, (theme, name, description)) in options.iter().enumerate() {
// Add visible themes based on scroll offset
let visible_end = (scroll_offset + available_height).min(options.len());
for i in scroll_offset..visible_end {
let (theme_enum, name, description) = &options[i];
let is_selected = i == self.selected_index;
let is_current = *theme == self.current_theme;
let is_original = *theme_enum == self.original_theme;
let prefix = if is_selected { "> " } else { " " };
let suffix = if is_current { " (current)" } else { "" };
let prefix = if is_selected { "▶ " } else { " " };
let suffix = if is_original { " (original)" } else { "" };
let spans = vec![
let mut spans = vec![
Span::raw(prefix),
Span::raw(*name),
Span::raw(suffix),
Span::raw(" - "),
Span::styled(
*description,
Style::default().fg(crate::colors::dim()),
),
];
let line = Line::from(spans);
let styled_line = if is_selected {
line.style(
if is_selected {
spans.push(Span::styled(
*name,
Style::default()
.fg(crate::colors::light_blue())
.fg(theme.primary)
.add_modifier(Modifier::BOLD),
)
));
} else {
line
};
spans.push(Span::styled(
*name,
Style::default().fg(theme.text),
));
}
lines.push(styled_line);
spans.push(Span::styled(
suffix,
Style::default().fg(theme.text_dim),
));
if !suffix.is_empty() {
spans.push(Span::raw(" "));
} else {
spans.push(Span::raw(" "));
}
spans.push(Span::styled(
*description,
Style::default().fg(theme.text_dim),
));
lines.push(Line::from(spans));
}
// Add scroll indicators if needed
if scroll_offset > 0 || visible_end < options.len() {
lines.push(Line::from(""));
let scroll_info = format!(
"[{}/{}]",
self.selected_index + 1,
options.len()
);
lines.push(Line::from(vec![
Span::styled(
scroll_info,
Style::default().fg(theme.text_dim),
),
]));
} else {
lines.push(Line::from(""));
}
lines.push(Line::from(""));
lines.push(Line::from(vec![
Span::raw("Use "),
Span::styled("↑↓", Style::default().add_modifier(Modifier::BOLD)),
Span::raw(" to navigate, "),
Span::styled("Enter", Style::default().add_modifier(Modifier::BOLD)),
Span::raw(" to select, "),
Span::styled("Esc", Style::default().add_modifier(Modifier::BOLD)),
Span::raw(" to cancel"),
]).style(Style::default().fg(Color::DarkGray)));
Span::styled("↑↓", Style::default().fg(theme.keyword).add_modifier(Modifier::BOLD)),
Span::styled(" preview • ", Style::default().fg(theme.text_dim)),
Span::styled("Enter", Style::default().fg(theme.keyword).add_modifier(Modifier::BOLD)),
Span::styled(" confirm • ", Style::default().fg(theme.text_dim)),
Span::styled("Esc", Style::default().fg(theme.keyword).add_modifier(Modifier::BOLD)),
Span::styled(" cancel", Style::default().fg(theme.text_dim)),
]));
// Create the popup
let popup_width = 60;
let popup_height = (lines.len() + 2) as u16;
let popup_x = area.width.saturating_sub(popup_width) / 2;
let popup_y = area.height.saturating_sub(popup_height) / 2;
let popup_area = Rect {
x: area.x + popup_x,
y: area.y + popup_y,
width: popup_width.min(area.width),
height: popup_height.min(area.height),
// Use full width for better integration
let render_area = Rect {
x: area.x,
y: area.y,
width: area.width,
height: area.height.min(lines.len() as u16 + 2),
};
// Clear the area
Clear.render(popup_area, buf);
// Clear the area with theme background
for y in render_area.y..render_area.y + render_area.height {
for x in render_area.x..render_area.x + render_area.width {
if let Some(cell) = buf.cell_mut((x, y)) {
cell.set_style(Style::default().bg(theme.background));
}
}
}
// Render the popup
// Render with themed border
let block = Block::default()
.borders(Borders::ALL)
.style(Style::default().fg(crate::colors::text()));
.border_style(Style::default().fg(theme.border_focused))
.style(Style::default()
.bg(theme.background)
.fg(theme.text));
let paragraph = Paragraph::new(lines)
.block(block)
.alignment(Alignment::Left);
paragraph.render(popup_area, buf);
paragraph.render(render_area, buf);
}
}
+35
View File
@@ -300,6 +300,11 @@ impl HistoryCell {
// Fixed height for animation area
18u16 // 16 for animation + 2 for borders
}
HistoryCell::BackgroundEvent { view: _ } => {
// For background events (LLM responses), use proper word wrapping
let processed_lines = self.get_processed_lines(width);
processed_lines.len() as u16
}
_ => {
Paragraph::new(Text::from(self.plain_lines()))
.wrap(Wrap { trim: false })
@@ -960,6 +965,27 @@ impl HistoryCell {
view: TextBlock::new(vec![line]),
}
}
/// Get processed lines with proper word wrapping and markdown support
pub(crate) fn get_processed_lines(&self, width: u16) -> Vec<Line<'static>> {
match self {
HistoryCell::BackgroundEvent { view } => {
// Convert the TextBlock lines back to text for processing
let text = view.lines.iter()
.map(|line| {
line.spans.iter()
.map(|span| span.content.as_ref())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n");
// Process with markdown support and word wrapping
crate::text_processing::process_markdown_text(&text, width)
}
_ => self.plain_lines(),
}
}
}
impl WidgetRef for &HistoryCell {
@@ -995,6 +1021,15 @@ impl WidgetRef for &HistoryCell {
);
}
}
HistoryCell::BackgroundEvent { .. } => {
// Use processed lines with markdown support and proper word wrapping
let processed_lines = self.get_processed_lines(area.width);
Paragraph::new(Text::from(processed_lines))
.style(Style::default()
.fg(crate::colors::text())
.bg(crate::colors::background()))
.render(area, buf);
}
_ => {
// Apply theme background and text color to the paragraph
Paragraph::new(Text::from(self.plain_lines()))
+1
View File
@@ -47,6 +47,7 @@ mod status_indicator_widget;
mod terminal_info;
mod text_block;
mod text_formatting;
mod text_processing;
mod theme;
mod tui;
mod user_approval_widget;
+202
View File
@@ -0,0 +1,202 @@
use ratatui::prelude::*;
use ratatui::style::{Modifier, Style};
/// Process text with basic markdown support and proper word wrapping
pub(crate) fn process_markdown_text(text: &str, width: u16) -> Vec<Line<'static>> {
let mut lines = Vec::new();
for paragraph in text.split("\n\n") {
if paragraph.trim().is_empty() {
lines.push(Line::from(""));
continue;
}
// Handle each paragraph with word wrapping
let paragraph_lines = wrap_markdown_paragraph(paragraph.trim(), width);
lines.extend(paragraph_lines);
lines.push(Line::from(""));
}
// Remove trailing empty line if present
if lines.last().map_or(false, |l| l.spans.is_empty()) {
lines.pop();
}
lines
}
/// Parse a paragraph with basic markdown and wrap it properly
fn wrap_markdown_paragraph(paragraph: &str, width: u16) -> Vec<Line<'static>> {
let mut lines = Vec::new();
let mut current_line = Vec::new();
let mut current_length = 0u16;
let max_width = width.saturating_sub(4); // Account for padding
// Split paragraph into tokens (words and markdown elements)
let tokens = parse_markdown_tokens(paragraph);
for token in tokens {
let token_width = token.display_width();
// Check if we need to wrap to next line
if current_length > 0 && current_length + token_width + 1 > max_width {
// Finish current line
if !current_line.is_empty() {
lines.push(Line::from(current_line));
current_line = Vec::new();
current_length = 0;
}
}
// Add space if not at start of line
if current_length > 0 && !token.content().is_empty() {
current_line.push(Span::raw(" "));
current_length += 1;
}
// Add the token
current_line.push(token.to_span());
current_length += token_width;
}
// Add the last line if not empty
if !current_line.is_empty() {
lines.push(Line::from(current_line));
}
lines
}
#[derive(Debug, Clone)]
enum MarkdownToken {
Text(String),
Bold(String),
}
impl MarkdownToken {
fn display_width(&self) -> u16 {
match self {
MarkdownToken::Text(s) | MarkdownToken::Bold(s) => s.chars().count() as u16,
}
}
fn content(&self) -> &str {
match self {
MarkdownToken::Text(s) | MarkdownToken::Bold(s) => s,
}
}
fn to_span(&self) -> Span<'static> {
match self {
MarkdownToken::Text(s) => Span::styled(
s.clone(),
Style::default().fg(crate::colors::text())
),
MarkdownToken::Bold(s) => Span::styled(
s.clone(),
Style::default()
.fg(crate::colors::text_bright())
.add_modifier(Modifier::BOLD),
),
}
}
}
/// Parse a paragraph into markdown tokens
fn parse_markdown_tokens(text: &str) -> Vec<MarkdownToken> {
let mut tokens = Vec::new();
let mut current_text = String::new();
let mut chars = text.chars().peekable();
while let Some(ch) = chars.next() {
if ch == '*' && chars.peek() == Some(&'*') {
// Found **bold** marker
chars.next(); // consume second *
// Save any accumulated text
if !current_text.is_empty() {
// Split by whitespace to handle word boundaries properly
let words = current_text.split_whitespace();
for (i, word) in words.enumerate() {
if i > 0 {
tokens.push(MarkdownToken::Text(" ".to_string()));
}
tokens.push(MarkdownToken::Text(word.to_string()));
}
current_text.clear();
}
// Find the closing **
let mut bold_text = String::new();
let mut found_closing = false;
while let Some(ch) = chars.next() {
if ch == '*' && chars.peek() == Some(&'*') {
chars.next(); // consume second *
found_closing = true;
break;
}
bold_text.push(ch);
}
if found_closing && !bold_text.is_empty() {
// Split bold text by whitespace too
let words = bold_text.split_whitespace();
for (i, word) in words.enumerate() {
if i > 0 {
tokens.push(MarkdownToken::Bold(" ".to_string()));
}
tokens.push(MarkdownToken::Bold(word.to_string()));
}
} else {
// No closing **, treat as regular text
current_text.push_str("**");
current_text.push_str(&bold_text);
}
} else {
current_text.push(ch);
}
}
// Handle remaining text
if !current_text.is_empty() {
let words = current_text.split_whitespace();
for (i, word) in words.enumerate() {
if i > 0 {
tokens.push(MarkdownToken::Text(" ".to_string()));
}
tokens.push(MarkdownToken::Text(word.to_string()));
}
}
tokens
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_bold_parsing() {
let tokens = parse_markdown_tokens("Hello **world** and **bold text** here");
assert_eq!(tokens.len(), 7); // "Hello", " ", "world", " ", "and", " ", "bold", " ", "text", " ", "here"
match &tokens[2] {
MarkdownToken::Bold(text) => assert_eq!(text, "world"),
_ => panic!("Expected bold token"),
}
}
#[test]
fn test_word_wrapping() {
let lines = process_markdown_text("This is a very long line that should be wrapped properly when it exceeds the maximum width", 20);
assert!(lines.len() > 1);
}
#[test]
fn test_paragraph_separation() {
let lines = process_markdown_text("First paragraph.\n\nSecond paragraph.", 80);
// Should have: first para line, empty line, second para line
assert!(lines.len() >= 3);
}
}