use crate::commands::StyleCatalogRow;
use crossterm::{
event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers},
execute,
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
};
use ratatui::{
Frame, Terminal,
backend::{Backend, CrosstermBackend},
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style as TuiStyle},
text::{Line, Span},
widgets::{
Block, Borders, Cell, Clear, List, ListItem, ListState, Paragraph, Row, Table, TableState,
Wrap,
},
};
use std::error::Error;
use std::io;
use std::time::Duration;
pub(crate) struct StyleBrowserConfig {
pub(crate) rows: Vec<StyleCatalogRow>,
pub(crate) initial_query: String,
pub(crate) source_label: String,
}
pub(crate) trait StyleBrowserActions {
fn install_style(&mut self, row: &StyleCatalogRow) -> Result<(), Box<dyn Error>>;
fn remove_style(&mut self, row: &StyleCatalogRow) -> Result<(), Box<dyn Error>>;
}
pub(crate) fn run_style_browser<A: StyleBrowserActions>(
config: StyleBrowserConfig,
actions: &mut A,
) -> Result<(), Box<dyn Error>> {
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let mut app = StyleBrowserApp::new(config);
let run_result = run_browser_loop(&mut terminal, &mut app, actions);
let restore_result = restore_terminal(&mut terminal);
match (run_result, restore_result) {
(Ok(()), Ok(())) => Ok(()),
(Err(error), Ok(())) => Err(error),
(Ok(()), Err(error)) => Err(error),
(Err(error), Err(restore_error)) => {
Err(format!("{error}; additionally failed to restore terminal: {restore_error}").into())
}
}
}
fn restore_terminal(
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
) -> Result<(), Box<dyn Error>> {
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
terminal.show_cursor()?;
Ok(())
}
fn run_browser_loop<B: Backend, A: StyleBrowserActions>(
terminal: &mut Terminal<B>,
app: &mut StyleBrowserApp,
actions: &mut A,
) -> Result<(), Box<dyn Error>>
where
B::Error: 'static,
{
loop {
terminal.draw(|frame| app.render(frame))?;
if event::poll(Duration::from_millis(200))?
&& let Event::Key(key) = event::read()?
&& key.kind == KeyEventKind::Press
&& app.handle_key(key, actions)?
{
return Ok(());
}
}
}
#[derive(Clone, Debug)]
struct BrowserRow {
catalog: StyleCatalogRow,
installed: bool,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum BrowserFocus {
List,
Search,
Detail,
}
#[derive(Clone, Debug, Eq, PartialEq)]
enum PendingAction {
Remove(String),
}
#[derive(Clone, Debug)]
struct StatusMessage {
text: String,
kind: StatusKind,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum StatusKind {
Info,
Success,
Error,
}
struct StyleBrowserApp {
source_label: String,
query: String,
rows: Vec<BrowserRow>,
filtered: Vec<usize>,
selected: usize,
focus: BrowserFocus,
status: StatusMessage,
pending: Option<PendingAction>,
}
impl StyleBrowserApp {
fn new(config: StyleBrowserConfig) -> Self {
let rows = merge_catalog_rows(config.rows);
let installed_count = rows.iter().filter(|row| row.installed).count();
let mut app = Self {
source_label: config.source_label,
query: config.initial_query,
rows,
filtered: Vec::new(),
selected: 0,
focus: BrowserFocus::List,
status: StatusMessage {
text: format!(
"Use / to filter, arrows to move, i to install, r to remove, q to quit. {installed_count} installed."
),
kind: StatusKind::Info,
},
pending: None,
};
app.refresh_filter();
app
}
fn refresh_filter(&mut self) {
let query = self.query.to_lowercase();
self.filtered = self
.rows
.iter()
.enumerate()
.filter_map(|(idx, row)| {
if query.is_empty() || browser_row_matches_query(row, &query) {
Some(idx)
} else {
None
}
})
.collect();
if self.filtered.is_empty() {
self.selected = 0;
} else if self.selected >= self.filtered.len() {
self.selected = self.filtered.len().saturating_sub(1);
}
}
fn current_row(&self) -> Option<&BrowserRow> {
self.filtered
.get(self.selected)
.and_then(|row_idx| self.rows.get(*row_idx))
}
fn handle_key<A: StyleBrowserActions>(
&mut self,
key: KeyEvent,
actions: &mut A,
) -> Result<bool, Box<dyn Error>> {
if is_quit_key(key) {
return Ok(true);
}
if self.handle_pending_key(key, actions)? {
return Ok(false);
}
if self.focus == BrowserFocus::Search {
self.handle_search_key(key);
return Ok(false);
}
match key.code {
KeyCode::Char('/') => {
self.focus = BrowserFocus::Search;
self.set_info("Search mode. Type to filter, Enter to return to the list.");
}
KeyCode::Esc => {
self.focus = BrowserFocus::List;
self.set_info("List focus.");
}
KeyCode::Down | KeyCode::Char('j') => self.move_selection(1),
KeyCode::Up | KeyCode::Char('k') => self.move_selection(-1),
KeyCode::PageDown => self.move_selection(10),
KeyCode::PageUp => self.move_selection(-10),
KeyCode::Home => self.selected = 0,
KeyCode::End => {
self.selected = self.filtered.len().saturating_sub(1);
}
KeyCode::Enter | KeyCode::Char('d') => {
self.focus = if self.focus == BrowserFocus::Detail {
BrowserFocus::List
} else {
BrowserFocus::Detail
};
}
KeyCode::Char('i') => self.install_selected(actions)?,
KeyCode::Char('r') => self.request_remove_selected(),
_ => {}
}
Ok(false)
}
fn handle_pending_key<A: StyleBrowserActions>(
&mut self,
key: KeyEvent,
actions: &mut A,
) -> Result<bool, Box<dyn Error>> {
if self.pending.is_none() {
return Ok(false);
}
match key.code {
KeyCode::Char('y' | 'Y') => {
if matches!(self.pending, Some(PendingAction::Remove(_))) {
self.remove_selected(actions)?;
}
self.pending = None;
Ok(true)
}
KeyCode::Char('n' | 'N') | KeyCode::Esc => {
self.pending = None;
self.set_info("Remove canceled.");
Ok(true)
}
_ => Ok(true),
}
}
fn handle_search_key(&mut self, key: KeyEvent) {
match key.code {
KeyCode::Esc => {
if self.query.is_empty() {
self.focus = BrowserFocus::List;
self.set_info("List focus.");
} else {
self.query.clear();
self.refresh_filter();
self.set_info("Search cleared.");
}
}
KeyCode::Enter => {
self.focus = BrowserFocus::List;
self.set_info("List focus.");
}
KeyCode::Backspace => {
self.query.pop();
self.refresh_filter();
}
KeyCode::Char(ch) => {
self.query.push(ch);
self.refresh_filter();
}
_ => {}
}
}
fn move_selection(&mut self, delta: isize) {
if self.filtered.is_empty() {
self.selected = 0;
return;
}
let last = self.filtered.len().saturating_sub(1);
if delta < 0 {
self.selected = self.selected.saturating_sub(delta.unsigned_abs());
} else {
self.selected = self.selected.saturating_add(delta.unsigned_abs()).min(last);
}
}
fn install_selected<A: StyleBrowserActions>(
&mut self,
actions: &mut A,
) -> Result<(), Box<dyn Error>> {
let Some(row) = self.current_row().cloned() else {
self.set_error("No style selected.");
return Ok(());
};
if row.installed {
self.set_info(format!("{} is already installed.", row.catalog.id));
return Ok(());
}
match actions.install_style(&row.catalog) {
Ok(()) => {
self.mark_installed(&row.catalog.id, true);
self.set_success(format!("Installed {}.", row.catalog.id));
}
Err(error) => self.set_error(format!("Install failed: {error}")),
}
Ok(())
}
fn request_remove_selected(&mut self) {
let Some(row) = self.current_row() else {
self.set_error("No style selected.");
return;
};
if !row.installed {
self.set_info(format!("{} is not installed.", row.catalog.id));
return;
}
let id = row.catalog.id.clone();
self.pending = Some(PendingAction::Remove(id.clone()));
self.set_info(format!("Remove installed style {id}? y/n"));
}
fn remove_selected<A: StyleBrowserActions>(
&mut self,
actions: &mut A,
) -> Result<(), Box<dyn Error>> {
let Some(row) = self.current_row().cloned() else {
self.set_error("No style selected.");
return Ok(());
};
match actions.remove_style(&row.catalog) {
Ok(()) => {
self.mark_installed(&row.catalog.id, false);
self.set_success(format!("Removed {}.", row.catalog.id));
}
Err(error) => self.set_error(format!("Remove failed: {error}")),
}
Ok(())
}
fn mark_installed(&mut self, id: &str, installed: bool) {
for row in &mut self.rows {
if row.catalog.id == id {
row.installed = installed;
}
}
self.rows
.retain(|row| row.installed || row.catalog.source != "installed");
self.refresh_filter();
}
fn render(&mut self, frame: &mut Frame<'_>) {
let area = frame.area();
let root = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Min(8),
Constraint::Length(3),
])
.split(area);
let &[header, body, footer] = root.as_ref() else {
return;
};
self.render_header(frame, header);
self.render_body(frame, body);
self.render_footer(frame, footer);
}
fn render_header(&self, frame: &mut Frame<'_>, area: Rect) {
let installed_count = self.rows.iter().filter(|row| row.installed).count();
let query = if self.query.is_empty() {
"-".to_string()
} else {
self.query.clone()
};
let line = Line::from(vec![
Span::styled(
"Citum Style Browser",
TuiStyle::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Span::raw(" source "),
Span::styled(
self.source_label.as_str(),
TuiStyle::default().fg(Color::Yellow),
),
Span::raw(" query "),
Span::styled(query, TuiStyle::default().fg(Color::LightBlue)),
Span::raw(" results "),
Span::styled(
self.filtered.len().to_string(),
TuiStyle::default().fg(Color::Green),
),
Span::raw(" installed "),
Span::styled(
installed_count.to_string(),
TuiStyle::default().fg(Color::Green),
),
]);
frame.render_widget(
Paragraph::new(line)
.block(Block::default().borders(Borders::ALL))
.wrap(Wrap { trim: true }),
area,
);
}
fn render_body(&mut self, frame: &mut Frame<'_>, area: Rect) {
if area.width < 96 {
match self.focus {
BrowserFocus::Detail => self.render_detail(frame, area),
_ => self.render_list(frame, area),
}
return;
}
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(58), Constraint::Percentage(42)])
.split(area);
let &[list, detail] = chunks.as_ref() else {
return;
};
self.render_list(frame, list);
self.render_detail(frame, detail);
}
fn render_list(&mut self, frame: &mut Frame<'_>, area: Rect) {
let title = if self.focus == BrowserFocus::Search {
"Styles - searching"
} else {
"Styles"
};
if area.width < 96 {
self.render_compact_list(frame, area, title);
} else {
self.render_table(frame, area, title);
}
}
fn render_table(&mut self, frame: &mut Frame<'_>, area: Rect, title: &str) {
let rows: Vec<Row<'_>> = self
.filtered
.iter()
.filter_map(|row_idx| self.rows.get(*row_idx))
.map(style_table_row)
.collect();
let header = Row::new([
Cell::from("Status"),
Cell::from("Source"),
Cell::from("ID"),
Cell::from("Title"),
])
.style(
TuiStyle::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
);
let widths = [
Constraint::Length(10),
Constraint::Length(18),
Constraint::Length(36),
Constraint::Min(20),
];
let mut state = TableState::default();
if !rows.is_empty() {
state.select(Some(self.selected));
}
let table = Table::new(rows, widths)
.header(header)
.block(
Block::default()
.title(title)
.borders(Borders::ALL)
.border_style(focus_border_style(self.focus == BrowserFocus::List)),
)
.column_spacing(2)
.highlight_symbol("> ")
.row_highlight_style(
TuiStyle::default()
.bg(Color::DarkGray)
.fg(Color::White)
.add_modifier(Modifier::BOLD),
);
frame.render_stateful_widget(table, area, &mut state);
}
fn render_compact_list(&mut self, frame: &mut Frame<'_>, area: Rect, title: &str) {
let items: Vec<ListItem<'_>> = self
.filtered
.iter()
.filter_map(|row_idx| self.rows.get(*row_idx))
.map(compact_list_item)
.collect();
let mut state = ListState::default();
if !items.is_empty() {
state.select(Some(self.selected));
}
let list = List::new(items)
.block(
Block::default()
.title(title)
.borders(Borders::ALL)
.border_style(focus_border_style(self.focus == BrowserFocus::List)),
)
.highlight_style(
TuiStyle::default()
.bg(Color::DarkGray)
.fg(Color::White)
.add_modifier(Modifier::BOLD),
)
.highlight_symbol("> ");
frame.render_stateful_widget(list, area, &mut state);
}
fn render_detail(&self, frame: &mut Frame<'_>, area: Rect) {
let lines = if let Some(row) = self.current_row() {
detail_lines(row)
} else {
vec![Line::from("No styles match the current query.")]
};
let paragraph = Paragraph::new(lines)
.block(
Block::default()
.title("Details")
.borders(Borders::ALL)
.border_style(focus_border_style(self.focus == BrowserFocus::Detail)),
)
.wrap(Wrap { trim: true });
frame.render_widget(paragraph, area);
}
fn render_footer(&self, frame: &mut Frame<'_>, area: Rect) {
let keys = self.footer_keys(area.width);
let status = Line::from(Span::styled(
self.status.text.as_str(),
status_style(self.status.kind),
));
let paragraph = Paragraph::new(vec![Line::from(keys), status])
.block(Block::default().borders(Borders::ALL))
.wrap(Wrap { trim: true });
frame.render_widget(paragraph, area);
if self.pending.is_some() {
render_confirmation(frame, area, self.pending_remove_id());
}
}
fn footer_keys(&self, width: u16) -> &'static str {
if self.pending.is_some() {
return "Confirm remove: y Remove n Cancel q Quit";
}
if self.focus == BrowserFocus::Search {
return "Search mode: Enter apply Esc clear/back q Quit";
}
let Some(row) = self.current_row() else {
return "q Quit / Search";
};
match (width < 96, row.installed) {
(true, true) => "q Quit / Search arrows/jk Move d Details r Remove",
(true, false) => "q Quit / Search arrows/jk Move d Details i Install",
(false, true) => {
"q Quit / Search esc Back arrows/jk Move pg/home/end Jump d Details r Remove"
}
(false, false) => {
"q Quit / Search esc Back arrows/jk Move pg/home/end Jump d Details i Install"
}
}
}
fn pending_remove_id(&self) -> Option<&str> {
match self.pending.as_ref() {
Some(PendingAction::Remove(id)) => Some(id.as_str()),
None => None,
}
}
fn set_info(&mut self, text: impl Into<String>) {
self.status = StatusMessage {
text: text.into(),
kind: StatusKind::Info,
};
}
fn set_success(&mut self, text: impl Into<String>) {
self.status = StatusMessage {
text: text.into(),
kind: StatusKind::Success,
};
}
fn set_error(&mut self, text: impl Into<String>) {
self.status = StatusMessage {
text: text.into(),
kind: StatusKind::Error,
};
}
}
fn is_quit_key(key: KeyEvent) -> bool {
matches!(key.code, KeyCode::Char('q' | 'Q'))
|| (key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL))
}
fn merge_catalog_rows(rows: Vec<StyleCatalogRow>) -> Vec<BrowserRow> {
let mut merged: Vec<BrowserRow> = Vec::new();
for row in rows {
let is_installed = row.source == "installed";
if let Some(existing) = merged
.iter_mut()
.find(|candidate| candidate.catalog.id == row.id)
{
existing.installed |= is_installed;
if existing.catalog.source == "installed" && !is_installed {
existing.catalog = row;
existing.installed = true;
}
} else {
merged.push(BrowserRow {
catalog: row,
installed: is_installed,
});
}
}
merged
}
fn browser_row_matches_query(row: &BrowserRow, query: &str) -> bool {
row.catalog.id.to_lowercase().contains(query)
|| row
.catalog
.aliases
.iter()
.any(|alias| alias.to_lowercase().contains(query))
|| row
.catalog
.title
.as_ref()
.is_some_and(|title| title.to_lowercase().contains(query))
|| row
.catalog
.description
.as_ref()
.is_some_and(|description| description.to_lowercase().contains(query))
|| row
.catalog
.fields
.iter()
.any(|field| field.to_lowercase().contains(query))
|| row.catalog.source.to_lowercase().contains(query)
}
fn style_table_row(row: &BrowserRow) -> Row<'_> {
Row::new([
Cell::from(status_label(row.installed)).style(status_cell_style(row.installed)),
Cell::from(row.catalog.source.as_str()).style(source_style(&row.catalog.source)),
Cell::from(row.catalog.id.as_str()).style(
TuiStyle::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD),
),
Cell::from(row.catalog.title.as_deref().unwrap_or("-"))
.style(TuiStyle::default().fg(Color::Gray)),
])
}
fn compact_list_item(row: &BrowserRow) -> ListItem<'_> {
ListItem::new(vec![
Line::from(vec![
Span::styled(
status_label(row.installed),
status_cell_style(row.installed),
),
Span::raw(" "),
Span::styled(
row.catalog.source.as_str(),
source_style(&row.catalog.source),
),
]),
Line::from(vec![
Span::styled(
row.catalog.id.as_str(),
TuiStyle::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::styled(
row.catalog.title.as_deref().unwrap_or("-"),
TuiStyle::default().fg(Color::Gray),
),
]),
])
}
fn status_label(installed: bool) -> &'static str {
if installed { "INSTALLED" } else { "available" }
}
fn status_cell_style(installed: bool) -> TuiStyle {
if installed {
TuiStyle::default()
.fg(Color::Black)
.bg(Color::Green)
.add_modifier(Modifier::BOLD)
} else {
TuiStyle::default().fg(Color::DarkGray)
}
}
fn source_style(source: &str) -> TuiStyle {
let color = if source == "embedded" {
Color::LightBlue
} else if source == "installed" {
Color::Green
} else if source.starts_with("registry:") {
Color::Yellow
} else {
Color::Magenta
};
TuiStyle::default().fg(color)
}
fn detail_lines(row: &BrowserRow) -> Vec<Line<'static>> {
let aliases = if row.catalog.aliases.is_empty() {
"-".to_string()
} else {
row.catalog.aliases.join(", ")
};
let fields = if row.catalog.fields.is_empty() {
"-".to_string()
} else {
row.catalog.fields.join(", ")
};
let mut lines = vec![
label_value_line("ID", row.catalog.id.as_str()),
label_value_line("Title", row.catalog.title.as_deref().unwrap_or("-")),
label_value_line("Source", row.catalog.source.as_str()),
label_value_line("Status", status_label(row.installed)),
label_value_line("Aliases", aliases.as_str()),
label_value_line("Fields", fields.as_str()),
];
if let Some(description) = &row.catalog.description {
lines.push(Line::from(""));
lines.push(label_value_line("Summary", description));
}
if let Some(url) = &row.catalog.url {
lines.push(Line::from(""));
lines.push(label_value_line("URL", url));
}
lines
}
fn label_value_line(label: &str, value: impl Into<String>) -> Line<'static> {
Line::from(vec![
Span::styled(
format!("{label:<8}"),
TuiStyle::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Span::raw(value.into()),
])
}
fn focus_border_style(active: bool) -> TuiStyle {
if active {
TuiStyle::default().fg(Color::Cyan)
} else {
TuiStyle::default().fg(Color::DarkGray)
}
}
fn status_style(kind: StatusKind) -> TuiStyle {
match kind {
StatusKind::Info => TuiStyle::default().fg(Color::Gray),
StatusKind::Success => TuiStyle::default().fg(Color::Green),
StatusKind::Error => TuiStyle::default().fg(Color::Red),
}
}
fn render_confirmation(frame: &mut Frame<'_>, area: Rect, style_id: Option<&str>) {
let popup_area = centered_rect(64, 5, area);
frame.render_widget(Clear, popup_area);
let prompt = if let Some(style_id) = style_id {
format!("Remove {style_id}?\ny Remove n Cancel")
} else {
"Remove installed style?\ny Remove n Cancel".to_string()
};
frame.render_widget(
Paragraph::new(prompt)
.block(Block::default().title("Confirm").borders(Borders::ALL))
.wrap(Wrap { trim: true }),
popup_area,
);
}
fn centered_rect(width: u16, height: u16, area: Rect) -> Rect {
let popup_width = width.min(area.width);
let popup_height = height.min(area.height);
Rect {
x: area.x + area.width.saturating_sub(popup_width) / 2,
y: area.y + area.height.saturating_sub(popup_height) / 2,
width: popup_width,
height: popup_height,
}
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
reason = "Panicking is acceptable and desired in focused CLI TUI tests."
)]
mod tests {
use super::*;
use ratatui::backend::TestBackend;
#[derive(Default)]
struct FakeActions {
installed: Vec<String>,
removed: Vec<String>,
}
impl StyleBrowserActions for FakeActions {
fn install_style(&mut self, row: &StyleCatalogRow) -> Result<(), Box<dyn Error>> {
self.installed.push(row.id.clone());
Ok(())
}
fn remove_style(&mut self, row: &StyleCatalogRow) -> Result<(), Box<dyn Error>> {
self.removed.push(row.id.clone());
Ok(())
}
}
fn catalog_row(id: &str, source: &str, title: &str) -> StyleCatalogRow {
StyleCatalogRow {
source: source.to_string(),
id: id.to_string(),
aliases: Vec::new(),
title: Some(title.to_string()),
description: None,
fields: Vec::new(),
url: None,
}
}
#[test]
fn merge_catalog_rows_marks_embedded_style_as_installed_once() {
let rows = merge_catalog_rows(vec![
catalog_row("apa-7th", "embedded", "APA"),
catalog_row("apa-7th", "installed", ""),
]);
assert_eq!(rows.len(), 1);
let row = rows.first().expect("merged row should exist");
assert_eq!(row.catalog.source, "embedded");
assert!(row.installed);
}
#[test]
fn merge_catalog_rows_keeps_installed_only_style() {
let rows = merge_catalog_rows(vec![catalog_row("custom-style", "installed", "")]);
assert_eq!(rows.len(), 1);
let row = rows.first().expect("merged row should exist");
assert_eq!(row.catalog.source, "installed");
assert!(row.installed);
}
#[test]
fn browser_filter_matches_source_and_title() {
let row = BrowserRow {
catalog: catalog_row("apa-7th", "embedded", "American Psychological Association"),
installed: false,
};
assert!(browser_row_matches_query(&row, "psychological association"));
assert!(browser_row_matches_query(&row, "embedded"));
assert!(!browser_row_matches_query(&row, "vancouver numeric"));
}
#[test]
fn quit_key_is_global() {
let key = KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE);
let ctrl_c = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
assert!(is_quit_key(key));
assert!(is_quit_key(ctrl_c));
}
#[test]
fn remove_confirmation_marks_registry_row_available() {
let mut app = StyleBrowserApp::new(StyleBrowserConfig {
rows: vec![
catalog_row("apa-7th", "embedded", "American Psychological Association"),
catalog_row("apa-7th", "installed", ""),
],
initial_query: String::new(),
source_label: "all".to_string(),
});
let mut actions = FakeActions::default();
app.handle_key(
KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE),
&mut actions,
)
.expect("remove request should be handled");
app.handle_key(
KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE),
&mut actions,
)
.expect("remove confirmation should be handled");
assert_eq!(actions.removed, vec!["apa-7th"]);
let row = app
.current_row()
.expect("registry row should remain visible");
assert_eq!(row.catalog.source, "embedded");
assert!(!row.installed);
}
#[test]
fn remove_confirmation_drops_installed_only_row() {
let mut app = StyleBrowserApp::new(StyleBrowserConfig {
rows: vec![catalog_row("custom-style", "installed", "")],
initial_query: String::new(),
source_label: "all".to_string(),
});
let mut actions = FakeActions::default();
app.handle_key(
KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE),
&mut actions,
)
.expect("remove request should be handled");
app.handle_key(
KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE),
&mut actions,
)
.expect("remove confirmation should be handled");
assert_eq!(actions.removed, vec!["custom-style"]);
assert!(app.current_row().is_none());
}
#[test]
fn wide_render_includes_source_and_installed_state() {
let mut app = StyleBrowserApp::new(StyleBrowserConfig {
rows: vec![
catalog_row("apa-7th", "embedded", "American Psychological Association"),
catalog_row("apa-7th", "installed", ""),
],
initial_query: String::new(),
source_label: "all".to_string(),
});
let backend = TestBackend::new(120, 24);
let mut terminal = Terminal::new(backend).expect("test backend should initialize");
terminal
.draw(|frame| app.render(frame))
.expect("test render should succeed");
let rendered =
terminal
.backend()
.buffer()
.content()
.iter()
.fold(String::new(), |mut output, cell| {
output.push_str(cell.symbol());
output
});
assert!(rendered.contains("INSTALLED"));
assert!(rendered.contains("embedded"));
assert!(rendered.contains("apa-7th"));
assert!(rendered.contains("American Psychological Association"));
}
}