use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph};
use ratatui::Frame;
use crate::app::{App, InputMode, AVAILABLE_TAGS};
use crate::ui::preview::ThemePreview;
const ACCENT: Color = Color::Rgb(187, 154, 247); const DIM: Color = Color::Rgb(100, 100, 120);
const TAG_BG: Color = Color::Rgb(50, 50, 70);
pub fn render_browser(f: &mut Frame, app: &App) {
let size = f.area();
let outer = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), Constraint::Min(5), Constraint::Length(1), ])
.split(size);
render_top_bar(f, app, outer[0]);
render_main(f, app, outer[1]);
render_bottom_bar(f, app, outer[2]);
if app.input_mode == InputMode::TagSelect {
render_tag_popup(f, app, size);
}
}
fn render_top_bar(f: &mut Frame, app: &App, area: Rect) {
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Length(22), Constraint::Min(20), Constraint::Length(30), ])
.split(area);
let title = Paragraph::new(Line::from(vec![
Span::styled(" ghostty", Style::default().fg(ACCENT).add_modifier(Modifier::BOLD)),
Span::styled(".styles", Style::default().fg(Color::White).add_modifier(Modifier::BOLD)),
]))
.block(Block::default().borders(Borders::BOTTOM));
f.render_widget(title, chunks[0]);
let search_style = if app.input_mode == InputMode::Search {
Style::default().fg(Color::White)
} else {
Style::default().fg(DIM)
};
let search_text = if app.input_mode == InputMode::Search {
format!(" / {}_", app.search_input)
} else if let Some(ref q) = app.active_query {
format!(" / {} ", q)
} else {
" / search...".to_string()
};
let search = Paragraph::new(Span::styled(search_text, search_style))
.block(Block::default().borders(Borders::BOTTOM));
f.render_widget(search, chunks[1]);
let mut filter_spans = Vec::new();
filter_spans.push(Span::styled(
format!(" {} ", app.sort.label()),
Style::default().fg(ACCENT),
));
if let Some(ref tag) = app.active_tag {
filter_spans.push(Span::styled(
format!("[{}] ", tag),
Style::default().fg(Color::Rgb(130, 200, 130)),
));
}
match app.dark_filter {
Some(true) => filter_spans.push(Span::styled("dark ", Style::default().fg(DIM))),
Some(false) => filter_spans.push(Span::styled("light ", Style::default().fg(DIM))),
None => {}
}
filter_spans.push(Span::styled(
format!("p{}/{} ", app.page, app.total_pages.max(1)),
Style::default().fg(DIM),
));
let filters = Paragraph::new(Line::from(filter_spans))
.block(Block::default().borders(Borders::BOTTOM));
f.render_widget(filters, chunks[2]);
}
fn render_main(f: &mut Frame, app: &App, area: Rect) {
if app.loading {
let loading = Paragraph::new(Span::styled(
" Loading themes...",
Style::default().fg(ACCENT),
));
f.render_widget(loading, area);
return;
}
if let Some(ref err) = app.error {
let error = Paragraph::new(vec![
Line::from(Span::styled(
" Error loading themes",
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
)),
Line::from(Span::styled(
format!(" {}", err),
Style::default().fg(Color::Red),
)),
Line::from(""),
Line::from(Span::styled(
" Press 'r' to retry",
Style::default().fg(DIM),
)),
]);
f.render_widget(error, area);
return;
}
if app.themes.is_empty() {
let empty = Paragraph::new(Span::styled(
" No themes found. Try a different search or filter.",
Style::default().fg(DIM),
));
f.render_widget(empty, area);
return;
}
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(45),
Constraint::Percentage(55),
])
.split(area);
render_theme_list(f, app, chunks[0]);
render_preview_panel(f, app, chunks[1]);
}
fn render_theme_list(f: &mut Frame, app: &App, area: Rect) {
let items: Vec<ListItem> = app
.themes
.iter()
.enumerate()
.map(|(i, theme)| {
let is_selected = i == app.selected;
let indicator = if is_selected { ">" } else { " " };
let mut spans = vec![
Span::styled(
format!("{} ", indicator),
Style::default().fg(if is_selected { ACCENT } else { DIM }),
),
Span::styled(
truncate(&theme.title, 28),
Style::default()
.fg(if is_selected { Color::White } else { Color::Gray })
.add_modifier(if is_selected { Modifier::BOLD } else { Modifier::empty() }),
),
];
spans.push(Span::styled(
format!(" {} ", vote_icon(theme.vote_count)),
Style::default().fg(DIM),
));
for tag in theme.tags.iter().take(2) {
spans.push(Span::styled(
format!(" {} ", tag),
Style::default().fg(Color::Rgb(140, 140, 160)).bg(TAG_BG),
));
spans.push(Span::raw(" "));
}
ListItem::new(Line::from(spans))
})
.collect();
let list = List::new(items)
.highlight_style(Style::default())
.block(
Block::default()
.borders(Borders::RIGHT)
.border_style(Style::default().fg(Color::Rgb(60, 60, 80)))
.title(Span::styled(
format!(" Themes ({}) ", app.total_results),
Style::default().fg(ACCENT),
)),
);
let mut state = ListState::default().with_selected(Some(app.selected));
f.render_stateful_widget(list, area, &mut state);
}
fn render_preview_panel(f: &mut Frame, app: &App, area: Rect) {
if let Some(theme) = app.selected_theme() {
let block = Block::default()
.title(Span::styled(" Preview ", Style::default().fg(ACCENT)))
.borders(Borders::NONE);
let inner = block.inner(area);
f.render_widget(block, area);
f.render_widget(ThemePreview { theme }, inner);
} else {
let placeholder = Paragraph::new(Span::styled(
"Select a theme to preview",
Style::default().fg(DIM),
));
f.render_widget(placeholder, area);
}
}
fn render_bottom_bar(f: &mut Frame, app: &App, area: Rect) {
let mut spans = vec![];
if let Some(ref msg) = app.status_message {
spans.push(Span::styled(
format!(" {} ", msg),
Style::default().fg(Color::Rgb(130, 200, 130)),
));
} else {
let osc_indicator = if app.osc_preview_active {
Span::styled(" [LIVE] ", Style::default().fg(Color::Rgb(255, 150, 50)).add_modifier(Modifier::BOLD))
} else {
Span::raw("")
};
spans.push(osc_indicator);
let hints = vec![
("j/k", "nav"),
("Enter", "detail"),
("/", "search"),
("t", "tags"),
("s", "sort"),
("d", "dark/light"),
("p", "preview"),
("a", "apply"),
("n/N", "page"),
("q", "quit"),
];
for (key, desc) in hints {
spans.push(Span::styled(
format!(" {} ", key),
Style::default().fg(ACCENT),
));
spans.push(Span::styled(
format!("{} ", desc),
Style::default().fg(DIM),
));
}
}
let bar = Paragraph::new(Line::from(spans));
f.render_widget(bar, area);
}
fn render_tag_popup(f: &mut Frame, app: &App, area: Rect) {
let popup_width = 30u16;
let popup_height = (AVAILABLE_TAGS.len() as u16 + 2).min(area.height);
let x = area.width.saturating_sub(popup_width) / 2;
let y = area.height.saturating_sub(popup_height) / 2;
let popup_area = Rect::new(x, y, popup_width, popup_height);
f.render_widget(Clear, popup_area);
let items: Vec<ListItem> = AVAILABLE_TAGS
.iter()
.enumerate()
.map(|(i, tag)| {
let is_cursor = i == app.tag_cursor;
let is_active = app.active_tag.as_deref() == Some(tag);
let marker = if is_active { "[x]" } else { "[ ]" };
let style = if is_cursor {
Style::default().fg(ACCENT).add_modifier(Modifier::BOLD)
} else if is_active {
Style::default().fg(Color::Rgb(130, 200, 130))
} else {
Style::default().fg(Color::Gray)
};
ListItem::new(Span::styled(format!(" {} {} ", marker, tag), style))
})
.collect();
let list = List::new(items).block(
Block::default()
.title(Span::styled(" Filter by Tag ", Style::default().fg(ACCENT)))
.borders(Borders::ALL)
.border_style(Style::default().fg(ACCENT)),
);
f.render_widget(list, popup_area);
}
fn truncate(s: &str, max: usize) -> String {
if s.len() <= max {
format!("{:<width$}", s, width = max)
} else {
format!("{}...", &s[..max.saturating_sub(3)])
}
}
fn vote_icon(count: i32) -> String {
if count > 0 {
format!("{}{}", '\u{2665}', count) } else {
String::new()
}
}