feat(ui): simplify AppMode minibuffer handling and panel rendering

Refactor AppMode to use MinibufferConfig for all text-entry modes. Update
command implementations to use new mode constructors. Introduce
PanelContent trait and replace panel structs with content types. Adjust
rendering to use Panel::new and minibuffer configuration. Update imports
and add MinibufferConfig struct. No functional changes; all behavior
preserved.

Co-Authored-By: fiddlerwoaroof/git-smart-commit (bartowski/nvidia_Nemotron-Cascade-2-30B-A3B-GGUF)
This commit is contained in:
Edward Langley
2026-04-07 02:04:46 -07:00
parent de047ddf1a
commit 4b11b6e321
9 changed files with 376 additions and 320 deletions

View File

@ -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<PathBuf>,

View File

@ -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<String>,
pub struct CategoryContent<'a> {
model: &'a Model,
tree: Vec<CatTreeEntry>,
}
impl<'a> CategoryPanel<'a> {
pub fn new(
model: &'a Model,
mode: &'a AppMode,
cursor: usize,
expanded: &'a std::collections::HashSet<String>,
) -> Self {
Self {
model,
mode,
cursor,
expanded,
}
impl<'a> CategoryContent<'a> {
pub fn new(model: &'a Model, expanded: &'a std::collections::HashSet<String>) -> 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 <name>)"
}
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 <name>)",
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);
}
}
}
}

View File

@ -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();
}
}

View File

@ -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));
}
}
}

View File

@ -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;

82
src/ui/panel.rs Normal file
View File

@ -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<C: PanelContent> 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);
}
}

View File

@ -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<String>,
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<String> = 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,
);
}
}