diff --git a/src/command/cmd.rs b/src/command/cmd.rs index c09ff38..4283473 100644 --- a/src/command/cmd.rs +++ b/src/command/cmd.rs @@ -373,17 +373,9 @@ impl Cmd for EnterMode { fn execute(&self, _ctx: &CmdContext) -> Vec> { let mut effects: Vec> = Vec::new(); // Clear the corresponding buffer when entering a text-entry mode - let buffer_name = match &self.0 { - AppMode::CommandMode { .. } => Some("command"), - AppMode::Editing { .. } => Some("edit"), - AppMode::FormulaEdit { .. } => Some("formula"), - AppMode::CategoryAdd { .. } => Some("category"), - AppMode::ExportPrompt { .. } => Some("export"), - _ => None, - }; - if let Some(name) = buffer_name { + if let Some(mb) = self.0.minibuffer() { effects.push(Box::new(effect::SetBuffer { - name: name.to_string(), + name: mb.buffer_key.to_string(), value: String::new(), })); } @@ -623,9 +615,7 @@ impl Cmd for EnterEditMode { name: "edit".to_string(), value: self.initial_value.clone(), }), - effect::change_mode(AppMode::Editing { - buffer: String::new(), - }), + effect::change_mode(AppMode::editing()), ] } } @@ -764,9 +754,7 @@ impl Cmd for EnterExportPrompt { "enter-export-prompt" } fn execute(&self, _ctx: &CmdContext) -> Vec> { - vec![effect::change_mode(AppMode::ExportPrompt { - buffer: String::new(), - })] + vec![effect::change_mode(AppMode::export_prompt())] } } @@ -869,9 +857,7 @@ impl Cmd for SearchOrCategoryAdd { panel: Panel::Category, open: true, }), - effect::change_mode(AppMode::CategoryAdd { - buffer: String::new(), - }), + effect::change_mode(AppMode::category_add()), ] } } @@ -1237,9 +1223,7 @@ impl Cmd for EnterFormulaEdit { "enter-formula-edit" } fn execute(&self, _ctx: &CmdContext) -> Vec> { - vec![effect::change_mode(AppMode::FormulaEdit { - buffer: String::new(), - })] + vec![effect::change_mode(AppMode::formula_edit())] } } @@ -1302,10 +1286,7 @@ impl Cmd for OpenItemAddAtCursor { } fn execute(&self, ctx: &CmdContext) -> Vec> { if let Some(cat_name) = ctx.cat_at_cursor() { - vec![effect::change_mode(AppMode::ItemAdd { - category: cat_name, - buffer: String::new(), - })] + vec![effect::change_mode(AppMode::item_add(cat_name))] } else { vec![effect::set_status( "No category selected. Press n to add a category first.", @@ -2543,21 +2524,11 @@ pub fn default_registry() -> CmdRegistry { "category-panel" => AppMode::CategoryPanel, "view-panel" => AppMode::ViewPanel, "tile-select" => AppMode::TileSelect, - "command" => AppMode::CommandMode { - buffer: String::new(), - }, - "category-add" => AppMode::CategoryAdd { - buffer: String::new(), - }, - "editing" => AppMode::Editing { - buffer: String::new(), - }, - "formula-edit" => AppMode::FormulaEdit { - buffer: String::new(), - }, - "export-prompt" => AppMode::ExportPrompt { - buffer: String::new(), - }, + "command" => AppMode::command_mode(), + "category-add" => AppMode::category_add(), + "editing" => AppMode::editing(), + "formula-edit" => AppMode::formula_edit(), + "export-prompt" => AppMode::export_prompt(), other => return Err(format!("Unknown mode: {other}")), }; Ok(Box::new(EnterMode(mode))) diff --git a/src/draw.rs b/src/draw.rs index ef55836..c958398 100644 --- a/src/draw.rs +++ b/src/draw.rs @@ -18,13 +18,14 @@ use ratatui::{ use crate::model::Model; use crate::ui::app::{App, AppMode}; -use crate::ui::category_panel::CategoryPanel; -use crate::ui::formula_panel::FormulaPanel; +use crate::ui::category_panel::CategoryContent; +use crate::ui::formula_panel::FormulaContent; use crate::ui::grid::GridWidget; use crate::ui::help::HelpWidget; use crate::ui::import_wizard_ui::ImportWizardWidget; +use crate::ui::panel::Panel; use crate::ui::tile_bar::TileBar; -use crate::ui::view_panel::ViewPanel; +use crate::ui::view_panel::ViewContent; struct TuiContext<'a> { terminal: Terminal>, @@ -224,31 +225,20 @@ fn draw_content(f: &mut Frame, area: Rect, app: &App) { if app.formula_panel_open { let a = Rect::new(side.x, y, side.width, ph); - f.render_widget( - FormulaPanel::new(&app.model, &app.mode, app.formula_cursor), - a, - ); + let content = FormulaContent::new(&app.model, &app.mode); + f.render_widget(Panel::new(content, &app.mode, app.formula_cursor), a); y += ph; } if app.category_panel_open { let a = Rect::new(side.x, y, side.width, ph); - f.render_widget( - CategoryPanel::new( - &app.model, - &app.mode, - app.cat_panel_cursor, - &app.expanded_cats, - ), - a, - ); + let content = CategoryContent::new(&app.model, &app.expanded_cats); + f.render_widget(Panel::new(content, &app.mode, app.cat_panel_cursor), a); y += ph; } if app.view_panel_open { let a = Rect::new(side.x, y, side.width, ph); - f.render_widget( - ViewPanel::new(&app.model, &app.mode, app.view_panel_cursor), - a, - ); + let content = ViewContent::new(&app.model); + f.render_widget(Panel::new(content, &app.mode, app.view_panel_cursor), a); } } else { grid_area = area; @@ -272,44 +262,17 @@ fn draw_tile_bar(f: &mut Frame, area: Rect, app: &App) { } fn draw_bottom_bar(f: &mut Frame, area: Rect, app: &App) { - // All text-entry modes use the bottom bar as a minibuffer. - let minibuf = match &app.mode { - AppMode::CommandMode { .. } => { - let buf = app.buffers.get("command").map(|s| s.as_str()).unwrap_or(""); - Some((format!(":{buf}▌"), Color::Yellow)) - } - AppMode::Editing { .. } => { - let buf = app.buffers.get("edit").map(|s| s.as_str()).unwrap_or(""); - Some((format!("edit: {buf}▌"), Color::Green)) - } - AppMode::FormulaEdit { .. } => { - let buf = app.buffers.get("formula").map(|s| s.as_str()).unwrap_or(""); - Some((format!("formula: {buf}▌"), Color::Cyan)) - } - AppMode::CategoryAdd { .. } => { - let buf = app - .buffers - .get("category") - .map(|s| s.as_str()) - .unwrap_or(""); - Some((format!("new category: {buf}▌"), Color::Yellow)) - } - AppMode::ItemAdd { category, .. } => { - let buf = app.buffers.get("item").map(|s| s.as_str()).unwrap_or(""); - Some((format!("add item to {category}: {buf}▌"), Color::Green)) - } - AppMode::ExportPrompt { .. } => { - let buf = app.buffers.get("export").map(|s| s.as_str()).unwrap_or(""); - Some((format!("export path: {buf}▌"), Color::Yellow)) - } - _ => None, - }; - - if let Some((text, color)) = minibuf { + if let Some(mb) = app.mode.minibuffer() { + let buf = app + .buffers + .get(mb.buffer_key) + .map(|s| s.as_str()) + .unwrap_or(""); + let text = format!("{}{}▌", mb.prompt, buf); f.render_widget( Paragraph::new(text).style( Style::default() - .fg(color) + .fg(mb.color) .bg(Color::Indexed(235)) .add_modifier(Modifier::BOLD), ), diff --git a/src/ui/app.rs b/src/ui/app.rs index 1a8b8c5..31217ff 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -7,6 +7,8 @@ use std::time::{Duration, Instant}; use std::rc::Rc; +use ratatui::style::Color; + use crate::command::cmd::CmdContext; use crate::command::keymap::{Keymap, KeymapSet}; use crate::import::wizard::ImportWizard; @@ -29,40 +31,126 @@ pub struct DrillState { pub pending_edits: std::collections::HashMap<(usize, String), String>, } +/// Display configuration for the bottom-bar minibuffer. +/// Carried structurally by text-entry `AppMode` variants. +#[derive(Debug, Clone, PartialEq)] +pub struct MinibufferConfig { + pub buffer_key: &'static str, + pub prompt: String, + pub color: Color, +} + #[derive(Debug, Clone, PartialEq)] pub enum AppMode { Normal, Editing { - buffer: String, + minibuf: MinibufferConfig, }, FormulaEdit { - buffer: String, + minibuf: MinibufferConfig, }, FormulaPanel, CategoryPanel, /// Quick-add a new category: Enter adds and stays open, Esc closes. CategoryAdd { - buffer: String, + minibuf: MinibufferConfig, }, /// Quick-add items to `category`: Enter adds and stays open, Esc closes. ItemAdd { category: String, - buffer: String, + minibuf: MinibufferConfig, }, ViewPanel, TileSelect, ImportWizard, ExportPrompt { - buffer: String, + minibuf: MinibufferConfig, }, /// Vim-style `:` command line CommandMode { - buffer: String, + minibuf: MinibufferConfig, }, Help, Quit, } +impl AppMode { + /// Extract the minibuffer config from text-entry modes, if present. + pub fn minibuffer(&self) -> Option<&MinibufferConfig> { + match self { + Self::Editing { minibuf, .. } + | Self::FormulaEdit { minibuf, .. } + | Self::CommandMode { minibuf, .. } + | Self::CategoryAdd { minibuf, .. } + | Self::ItemAdd { minibuf, .. } + | Self::ExportPrompt { minibuf, .. } => Some(minibuf), + _ => None, + } + } + + pub fn editing() -> Self { + Self::Editing { + minibuf: MinibufferConfig { + buffer_key: "edit", + prompt: "edit: ".into(), + color: Color::Green, + }, + } + } + + pub fn formula_edit() -> Self { + Self::FormulaEdit { + minibuf: MinibufferConfig { + buffer_key: "formula", + prompt: "formula: ".into(), + color: Color::Cyan, + }, + } + } + + pub fn command_mode() -> Self { + Self::CommandMode { + minibuf: MinibufferConfig { + buffer_key: "command", + prompt: ":".into(), + color: Color::Yellow, + }, + } + } + + pub fn category_add() -> Self { + Self::CategoryAdd { + minibuf: MinibufferConfig { + buffer_key: "category", + prompt: "new category: ".into(), + color: Color::Yellow, + }, + } + } + + pub fn item_add(category: String) -> Self { + let prompt = format!("add item to {category}: "); + Self::ItemAdd { + category, + minibuf: MinibufferConfig { + buffer_key: "item", + prompt, + color: Color::Green, + }, + } + } + + pub fn export_prompt() -> Self { + Self::ExportPrompt { + minibuf: MinibufferConfig { + buffer_key: "export", + prompt: "export path: ".into(), + color: Color::Yellow, + }, + } + } +} + pub struct App { pub model: Model, pub file_path: Option, diff --git a/src/ui/category_panel.rs b/src/ui/category_panel.rs index be75eb8..1999a4a 100644 --- a/src/ui/category_panel.rs +++ b/src/ui/category_panel.rs @@ -2,12 +2,12 @@ use ratatui::{ buffer::Buffer, layout::Rect, style::{Color, Modifier, Style}, - widgets::{Block, Borders, Widget}, }; use crate::model::Model; use crate::ui::app::AppMode; use crate::ui::cat_tree::{build_cat_tree, CatTreeEntry}; +use crate::ui::panel::PanelContent; use crate::view::Axis; fn axis_display(axis: Axis) -> (&'static str, Color) { @@ -19,112 +19,89 @@ fn axis_display(axis: Axis) -> (&'static str, Color) { } } -pub struct CategoryPanel<'a> { - pub model: &'a Model, - pub mode: &'a AppMode, - pub cursor: usize, - pub expanded: &'a std::collections::HashSet, +pub struct CategoryContent<'a> { + model: &'a Model, + tree: Vec, } -impl<'a> CategoryPanel<'a> { - pub fn new( - model: &'a Model, - mode: &'a AppMode, - cursor: usize, - expanded: &'a std::collections::HashSet, - ) -> Self { - Self { - model, - mode, - cursor, - expanded, - } +impl<'a> CategoryContent<'a> { + pub fn new(model: &'a Model, expanded: &'a std::collections::HashSet) -> Self { + let tree = build_cat_tree(model, expanded); + Self { model, tree } } } -impl<'a> Widget for CategoryPanel<'a> { - fn render(self, area: Rect, buf: &mut Buffer) { - let is_item_add = matches!(self.mode, AppMode::ItemAdd { .. }); - let is_cat_add = matches!(self.mode, AppMode::CategoryAdd { .. }); - let is_active = matches!(self.mode, AppMode::CategoryPanel) || is_item_add || is_cat_add; +impl PanelContent for CategoryContent<'_> { + fn is_active(&self, mode: &AppMode) -> bool { + matches!( + mode, + AppMode::CategoryPanel | AppMode::ItemAdd { .. } | AppMode::CategoryAdd { .. } + ) + } - let (border_color, title) = if is_active { - (Color::Cyan, " Categories ") + fn active_color(&self) -> Color { + Color::Cyan + } + + fn title(&self) -> &str { + " Categories " + } + + fn item_count(&self) -> usize { + self.tree.len() + } + + fn empty_message(&self) -> &str { + "(no categories — use :add-cat )" + } + + fn render_item(&self, index: usize, is_selected: bool, inner: Rect, buf: &mut Buffer) { + let y = inner.y + index as u16; + let view = self.model.active_view(); + + let base_style = if is_selected { + Style::default() + .fg(Color::Black) + .bg(Color::Cyan) + .add_modifier(Modifier::BOLD) } else { - (Color::DarkGray, " Categories ") + Style::default() }; - let block = Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(border_color)) - .title(title); - let inner = block.inner(area); - block.render(area, buf); - - let view = self.model.active_view(); - let tree = build_cat_tree(self.model, self.expanded); - - if tree.is_empty() { - buf.set_string( - inner.x, - inner.y, - "(no categories — use :add-cat )", - Style::default().fg(Color::DarkGray), - ); - return; + if is_selected { + let fill = " ".repeat(inner.width as usize); + buf.set_string(inner.x, y, &fill, base_style); } - for (i, entry) in tree.iter().enumerate() { - if i as u16 >= inner.height { - break; - } - let y = inner.y + i as u16; - let is_selected = i == self.cursor && is_active; + match &self.tree[index] { + CatTreeEntry::Category { + name, + item_count, + expanded, + } => { + let indicator = if *expanded { "▼" } else { "▶" }; + let (axis_str, axis_color) = axis_display(view.axis_of(name)); + let name_part = format!("{indicator} {name} ({item_count})"); + let axis_part = format!(" [{axis_str}]"); - let base_style = if is_selected { - Style::default() - .fg(Color::Black) - .bg(Color::Cyan) - .add_modifier(Modifier::BOLD) - } else { - Style::default() - }; - - if is_selected { - let fill = " ".repeat(inner.width as usize); - buf.set_string(inner.x, y, &fill, base_style); - } - - match entry { - CatTreeEntry::Category { - name, - item_count, - expanded, - } => { - let indicator = if *expanded { "▼" } else { "▶" }; - let (axis_str, axis_color) = axis_display(view.axis_of(name)); - let name_part = format!("{indicator} {name} ({item_count})"); - let axis_part = format!(" [{axis_str}]"); - - buf.set_string(inner.x, y, &name_part, base_style); - if name_part.len() + axis_part.len() < inner.width as usize { - buf.set_string( - inner.x + name_part.len() as u16, - y, - &axis_part, - if is_selected { - base_style - } else { - Style::default().fg(axis_color) - }, - ); - } - } - CatTreeEntry::Item { item_name, .. } => { - let label = format!(" · {item_name}"); - buf.set_string(inner.x, y, &label, base_style); + buf.set_string(inner.x, y, &name_part, base_style); + if name_part.len() + axis_part.len() < inner.width as usize { + buf.set_string( + inner.x + name_part.len() as u16, + y, + &axis_part, + if is_selected { + base_style + } else { + Style::default().fg(axis_color) + }, + ); } } + CatTreeEntry::Item { item_name, .. } => { + let label = format!(" · {item_name}"); + buf.set_string(inner.x, y, &label, base_style); + } } } } diff --git a/src/ui/effect.rs b/src/ui/effect.rs index 7186095..514cb85 100644 --- a/src/ui/effect.rs +++ b/src/ui/effect.rs @@ -108,9 +108,7 @@ impl Effect for EnterEditAtCursor { let value = ctx.display_value.clone(); drop(ctx); app.buffers.insert("edit".to_string(), value); - app.mode = AppMode::Editing { - buffer: String::new(), - }; + app.mode = AppMode::editing(); } } diff --git a/src/ui/formula_panel.rs b/src/ui/formula_panel.rs index 2ec78b7..eb66931 100644 --- a/src/ui/formula_panel.rs +++ b/src/ui/formula_panel.rs @@ -2,93 +2,72 @@ use ratatui::{ buffer::Buffer, layout::Rect, style::{Color, Modifier, Style}, - widgets::{Block, Borders, Widget}, }; use crate::model::Model; use crate::ui::app::AppMode; +use crate::ui::panel::PanelContent; -pub struct FormulaPanel<'a> { +pub struct FormulaContent<'a> { pub model: &'a Model, pub mode: &'a AppMode, - pub cursor: usize, } -impl<'a> FormulaPanel<'a> { - pub fn new(model: &'a Model, mode: &'a AppMode, cursor: usize) -> Self { - Self { - model, - mode, - cursor, - } +impl<'a> FormulaContent<'a> { + pub fn new(model: &'a Model, mode: &'a AppMode) -> Self { + Self { model, mode } } } -impl<'a> Widget for FormulaPanel<'a> { - fn render(self, area: Rect, buf: &mut Buffer) { - let is_active = matches!( - self.mode, - AppMode::FormulaPanel | AppMode::FormulaEdit { .. } - ); - let border_style = if is_active { - Style::default().fg(Color::Yellow) +impl PanelContent for FormulaContent<'_> { + fn is_active(&self, mode: &AppMode) -> bool { + matches!(mode, AppMode::FormulaPanel | AppMode::FormulaEdit { .. }) + } + + fn active_color(&self) -> Color { + Color::Yellow + } + + fn title(&self) -> &str { + " Formulas [n]ew [d]elete " + } + + fn item_count(&self) -> usize { + self.model.formulas().len() + } + + fn empty_message(&self) -> &str { + "(no formulas — press 'n' to add)" + } + + fn render_item(&self, index: usize, is_selected: bool, inner: Rect, buf: &mut Buffer) { + let formula = &self.model.formulas()[index]; + let style = if is_selected { + Style::default() + .fg(Color::Black) + .bg(Color::Yellow) + .add_modifier(Modifier::BOLD) } else { - Style::default().fg(Color::DarkGray) + Style::default().fg(Color::Green) }; + let text = format!(" {} = {:?}", formula.target, formula.raw); + let truncated = if text.len() > inner.width as usize { + format!("{}…", &text[..inner.width as usize - 1]) + } else { + text + }; + buf.set_string(inner.x, inner.y + index as u16, &truncated, style); + } - let block = Block::default() - .borders(Borders::ALL) - .border_style(border_style) - .title(" Formulas [n]ew [d]elete "); - let inner = block.inner(area); - block.render(area, buf); - - let formulas = self.model.formulas(); - - if formulas.is_empty() { - buf.set_string( - inner.x, - inner.y, - "(no formulas — press 'n' to add)", - Style::default().fg(Color::DarkGray), - ); - return; - } - - for (i, formula) in formulas.iter().enumerate() { - if inner.y + i as u16 >= inner.y + inner.height { - break; - } - let is_selected = i == self.cursor && is_active; - let style = if is_selected { - Style::default() - .fg(Color::Black) - .bg(Color::Yellow) - .add_modifier(Modifier::BOLD) - } else { - Style::default().fg(Color::Green) - }; - let text = format!(" {} = {:?}", formula.target, formula.raw); - let truncated = if text.len() > inner.width as usize { - format!("{}…", &text[..inner.width as usize - 1]) - } else { - text - }; - buf.set_string(inner.x, inner.y + i as u16, &truncated, style); - } - - // Formula edit mode - if let AppMode::FormulaEdit { buffer } = self.mode { - let y = inner.y + inner.height.saturating_sub(2); + fn render_footer(&self, inner: Rect, buf: &mut Buffer) { + if matches!(self.mode, AppMode::FormulaEdit { .. }) { + let y = inner.y + inner.height.saturating_sub(1); buf.set_string( inner.x, y, - "┄ Enter formula (Name = expr): ", + "┄ Enter formula (Name = expr)", Style::default().fg(Color::Yellow), ); - let y = y + 1; - let prompt = format!("> {buffer}█"); - buf.set_string(inner.x, y, &prompt, Style::default().fg(Color::Green)); } } } diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 4bc76ec..de3919c 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -6,5 +6,6 @@ pub mod formula_panel; pub mod grid; pub mod help; pub mod import_wizard_ui; +pub mod panel; pub mod tile_bar; pub mod view_panel; diff --git a/src/ui/panel.rs b/src/ui/panel.rs new file mode 100644 index 0000000..29ee7e6 --- /dev/null +++ b/src/ui/panel.rs @@ -0,0 +1,82 @@ +use ratatui::{ + buffer::Buffer, + layout::Rect, + style::{Color, Style}, + widgets::{Block, Borders, Widget}, +}; + +use crate::ui::app::AppMode; + +/// Trait for panel-specific content. Implement this to create a new side panel. +pub trait PanelContent { + /// Whether the panel should appear active given the current mode. + fn is_active(&self, mode: &AppMode) -> bool; + /// Color used for the active border AND the selection highlight background. + fn active_color(&self) -> Color; + /// Block title string (include surrounding spaces for padding). + fn title(&self) -> &str; + /// Number of renderable rows. + fn item_count(&self) -> usize; + /// Message shown when `item_count()` returns 0. + fn empty_message(&self) -> &str; + /// Render a single item at the given row index. + /// `inner` is the full inner area of the panel; the item occupies row `index`. + fn render_item(&self, index: usize, is_selected: bool, inner: Rect, buf: &mut Buffer); + /// Optional footer rendered below the items (e.g. inline hints). + fn render_footer(&self, _inner: Rect, _buf: &mut Buffer) {} +} + +/// Generic side-panel widget that delegates content rendering to a `PanelContent` impl. +pub struct Panel<'a, C: PanelContent> { + content: C, + mode: &'a AppMode, + cursor: usize, +} + +impl<'a, C: PanelContent> Panel<'a, C> { + pub fn new(content: C, mode: &'a AppMode, cursor: usize) -> Self { + Self { + content, + mode, + cursor, + } + } +} + +impl Widget for Panel<'_, C> { + fn render(self, area: Rect, buf: &mut Buffer) { + let is_active = self.content.is_active(self.mode); + let border_style = if is_active { + Style::default().fg(self.content.active_color()) + } else { + Style::default().fg(Color::DarkGray) + }; + + let block = Block::default() + .borders(Borders::ALL) + .border_style(border_style) + .title(self.content.title()); + let inner = block.inner(area); + block.render(area, buf); + + if self.content.item_count() == 0 { + buf.set_string( + inner.x, + inner.y, + self.content.empty_message(), + Style::default().fg(Color::DarkGray), + ); + return; + } + + for i in 0..self.content.item_count() { + if i as u16 >= inner.height { + break; + } + let is_selected = i == self.cursor && is_active; + self.content.render_item(i, is_selected, inner, buf); + } + + self.content.render_footer(inner, buf); + } +} diff --git a/src/ui/view_panel.rs b/src/ui/view_panel.rs index 123eb97..a185c9d 100644 --- a/src/ui/view_panel.rs +++ b/src/ui/view_panel.rs @@ -2,75 +2,72 @@ use ratatui::{ buffer::Buffer, layout::Rect, style::{Color, Modifier, Style}, - widgets::{Block, Borders, Widget}, }; use crate::model::Model; use crate::ui::app::AppMode; +use crate::ui::panel::PanelContent; -pub struct ViewPanel<'a> { - pub model: &'a Model, - pub mode: &'a AppMode, - pub cursor: usize, +pub struct ViewContent { + view_names: Vec, + active_view: String, } -impl<'a> ViewPanel<'a> { - pub fn new(model: &'a Model, mode: &'a AppMode, cursor: usize) -> Self { +impl ViewContent { + pub fn new(model: &Model) -> Self { + let view_names: Vec = model.views.keys().cloned().collect(); + let active_view = model.active_view.clone(); Self { - model, - mode, - cursor, + view_names, + active_view, } } } -impl<'a> Widget for ViewPanel<'a> { - fn render(self, area: Rect, buf: &mut Buffer) { - let is_active = matches!(self.mode, AppMode::ViewPanel); - let border_style = if is_active { - Style::default().fg(Color::Blue) +impl PanelContent for ViewContent { + fn is_active(&self, mode: &AppMode) -> bool { + matches!(mode, AppMode::ViewPanel) + } + + fn active_color(&self) -> Color { + Color::Blue + } + + fn title(&self) -> &str { + " Views " + } + + fn item_count(&self) -> usize { + self.view_names.len() + } + + fn empty_message(&self) -> &str { + "(no views)" + } + + fn render_item(&self, index: usize, is_selected: bool, inner: Rect, buf: &mut Buffer) { + let view_name = &self.view_names[index]; + let is_active_view = view_name == &self.active_view; + + let style = if is_selected { + Style::default() + .fg(Color::Black) + .bg(Color::Blue) + .add_modifier(Modifier::BOLD) + } else if is_active_view { + Style::default() + .fg(Color::Blue) + .add_modifier(Modifier::BOLD) } else { - Style::default().fg(Color::DarkGray) + Style::default() }; - let block = Block::default() - .borders(Borders::ALL) - .border_style(border_style) - .title(" Views "); - let inner = block.inner(area); - block.render(area, buf); - - let view_names: Vec<&str> = self.model.views.keys().map(|s| s.as_str()).collect(); - let active = &self.model.active_view; - - for (i, view_name) in view_names.iter().enumerate() { - if inner.y + i as u16 >= inner.y + inner.height { - break; - } - - let is_selected = i == self.cursor && is_active; - let is_active_view = *view_name == active.as_str(); - - let style = if is_selected { - Style::default() - .fg(Color::Black) - .bg(Color::Blue) - .add_modifier(Modifier::BOLD) - } else if is_active_view { - Style::default() - .fg(Color::Blue) - .add_modifier(Modifier::BOLD) - } else { - Style::default() - }; - - let prefix = if is_active_view { "▶ " } else { " " }; - buf.set_string( - inner.x, - inner.y + i as u16, - format!("{prefix}{view_name}"), - style, - ); - } + let prefix = if is_active_view { "▶ " } else { " " }; + buf.set_string( + inner.x, + inner.y + index as u16, + format!("{prefix}{view_name}"), + style, + ); } }