mirror of
https://github.com/hanzoai/dev.git
synced 2026-10-25 03:32:32 +00:00
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:
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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()))
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user