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:
@ -373,17 +373,9 @@ impl Cmd for EnterMode {
|
|||||||
fn execute(&self, _ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
fn execute(&self, _ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||||
let mut effects: Vec<Box<dyn Effect>> = Vec::new();
|
let mut effects: Vec<Box<dyn Effect>> = Vec::new();
|
||||||
// Clear the corresponding buffer when entering a text-entry mode
|
// Clear the corresponding buffer when entering a text-entry mode
|
||||||
let buffer_name = match &self.0 {
|
if let Some(mb) = self.0.minibuffer() {
|
||||||
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 {
|
|
||||||
effects.push(Box::new(effect::SetBuffer {
|
effects.push(Box::new(effect::SetBuffer {
|
||||||
name: name.to_string(),
|
name: mb.buffer_key.to_string(),
|
||||||
value: String::new(),
|
value: String::new(),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@ -623,9 +615,7 @@ impl Cmd for EnterEditMode {
|
|||||||
name: "edit".to_string(),
|
name: "edit".to_string(),
|
||||||
value: self.initial_value.clone(),
|
value: self.initial_value.clone(),
|
||||||
}),
|
}),
|
||||||
effect::change_mode(AppMode::Editing {
|
effect::change_mode(AppMode::editing()),
|
||||||
buffer: String::new(),
|
|
||||||
}),
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -764,9 +754,7 @@ impl Cmd for EnterExportPrompt {
|
|||||||
"enter-export-prompt"
|
"enter-export-prompt"
|
||||||
}
|
}
|
||||||
fn execute(&self, _ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
fn execute(&self, _ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||||
vec![effect::change_mode(AppMode::ExportPrompt {
|
vec![effect::change_mode(AppMode::export_prompt())]
|
||||||
buffer: String::new(),
|
|
||||||
})]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -869,9 +857,7 @@ impl Cmd for SearchOrCategoryAdd {
|
|||||||
panel: Panel::Category,
|
panel: Panel::Category,
|
||||||
open: true,
|
open: true,
|
||||||
}),
|
}),
|
||||||
effect::change_mode(AppMode::CategoryAdd {
|
effect::change_mode(AppMode::category_add()),
|
||||||
buffer: String::new(),
|
|
||||||
}),
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1237,9 +1223,7 @@ impl Cmd for EnterFormulaEdit {
|
|||||||
"enter-formula-edit"
|
"enter-formula-edit"
|
||||||
}
|
}
|
||||||
fn execute(&self, _ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
fn execute(&self, _ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||||
vec![effect::change_mode(AppMode::FormulaEdit {
|
vec![effect::change_mode(AppMode::formula_edit())]
|
||||||
buffer: String::new(),
|
|
||||||
})]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1302,10 +1286,7 @@ impl Cmd for OpenItemAddAtCursor {
|
|||||||
}
|
}
|
||||||
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>> {
|
||||||
if let Some(cat_name) = ctx.cat_at_cursor() {
|
if let Some(cat_name) = ctx.cat_at_cursor() {
|
||||||
vec![effect::change_mode(AppMode::ItemAdd {
|
vec![effect::change_mode(AppMode::item_add(cat_name))]
|
||||||
category: cat_name,
|
|
||||||
buffer: String::new(),
|
|
||||||
})]
|
|
||||||
} else {
|
} else {
|
||||||
vec![effect::set_status(
|
vec![effect::set_status(
|
||||||
"No category selected. Press n to add a category first.",
|
"No category selected. Press n to add a category first.",
|
||||||
@ -2543,21 +2524,11 @@ pub fn default_registry() -> CmdRegistry {
|
|||||||
"category-panel" => AppMode::CategoryPanel,
|
"category-panel" => AppMode::CategoryPanel,
|
||||||
"view-panel" => AppMode::ViewPanel,
|
"view-panel" => AppMode::ViewPanel,
|
||||||
"tile-select" => AppMode::TileSelect,
|
"tile-select" => AppMode::TileSelect,
|
||||||
"command" => AppMode::CommandMode {
|
"command" => AppMode::command_mode(),
|
||||||
buffer: String::new(),
|
"category-add" => AppMode::category_add(),
|
||||||
},
|
"editing" => AppMode::editing(),
|
||||||
"category-add" => AppMode::CategoryAdd {
|
"formula-edit" => AppMode::formula_edit(),
|
||||||
buffer: String::new(),
|
"export-prompt" => AppMode::export_prompt(),
|
||||||
},
|
|
||||||
"editing" => AppMode::Editing {
|
|
||||||
buffer: String::new(),
|
|
||||||
},
|
|
||||||
"formula-edit" => AppMode::FormulaEdit {
|
|
||||||
buffer: String::new(),
|
|
||||||
},
|
|
||||||
"export-prompt" => AppMode::ExportPrompt {
|
|
||||||
buffer: String::new(),
|
|
||||||
},
|
|
||||||
other => return Err(format!("Unknown mode: {other}")),
|
other => return Err(format!("Unknown mode: {other}")),
|
||||||
};
|
};
|
||||||
Ok(Box::new(EnterMode(mode)))
|
Ok(Box::new(EnterMode(mode)))
|
||||||
|
|||||||
73
src/draw.rs
73
src/draw.rs
@ -18,13 +18,14 @@ use ratatui::{
|
|||||||
|
|
||||||
use crate::model::Model;
|
use crate::model::Model;
|
||||||
use crate::ui::app::{App, AppMode};
|
use crate::ui::app::{App, AppMode};
|
||||||
use crate::ui::category_panel::CategoryPanel;
|
use crate::ui::category_panel::CategoryContent;
|
||||||
use crate::ui::formula_panel::FormulaPanel;
|
use crate::ui::formula_panel::FormulaContent;
|
||||||
use crate::ui::grid::GridWidget;
|
use crate::ui::grid::GridWidget;
|
||||||
use crate::ui::help::HelpWidget;
|
use crate::ui::help::HelpWidget;
|
||||||
use crate::ui::import_wizard_ui::ImportWizardWidget;
|
use crate::ui::import_wizard_ui::ImportWizardWidget;
|
||||||
|
use crate::ui::panel::Panel;
|
||||||
use crate::ui::tile_bar::TileBar;
|
use crate::ui::tile_bar::TileBar;
|
||||||
use crate::ui::view_panel::ViewPanel;
|
use crate::ui::view_panel::ViewContent;
|
||||||
|
|
||||||
struct TuiContext<'a> {
|
struct TuiContext<'a> {
|
||||||
terminal: Terminal<CrosstermBackend<&'a mut Stdout>>,
|
terminal: Terminal<CrosstermBackend<&'a mut Stdout>>,
|
||||||
@ -224,31 +225,20 @@ fn draw_content(f: &mut Frame, area: Rect, app: &App) {
|
|||||||
|
|
||||||
if app.formula_panel_open {
|
if app.formula_panel_open {
|
||||||
let a = Rect::new(side.x, y, side.width, ph);
|
let a = Rect::new(side.x, y, side.width, ph);
|
||||||
f.render_widget(
|
let content = FormulaContent::new(&app.model, &app.mode);
|
||||||
FormulaPanel::new(&app.model, &app.mode, app.formula_cursor),
|
f.render_widget(Panel::new(content, &app.mode, app.formula_cursor), a);
|
||||||
a,
|
|
||||||
);
|
|
||||||
y += ph;
|
y += ph;
|
||||||
}
|
}
|
||||||
if app.category_panel_open {
|
if app.category_panel_open {
|
||||||
let a = Rect::new(side.x, y, side.width, ph);
|
let a = Rect::new(side.x, y, side.width, ph);
|
||||||
f.render_widget(
|
let content = CategoryContent::new(&app.model, &app.expanded_cats);
|
||||||
CategoryPanel::new(
|
f.render_widget(Panel::new(content, &app.mode, app.cat_panel_cursor), a);
|
||||||
&app.model,
|
|
||||||
&app.mode,
|
|
||||||
app.cat_panel_cursor,
|
|
||||||
&app.expanded_cats,
|
|
||||||
),
|
|
||||||
a,
|
|
||||||
);
|
|
||||||
y += ph;
|
y += ph;
|
||||||
}
|
}
|
||||||
if app.view_panel_open {
|
if app.view_panel_open {
|
||||||
let a = Rect::new(side.x, y, side.width, ph);
|
let a = Rect::new(side.x, y, side.width, ph);
|
||||||
f.render_widget(
|
let content = ViewContent::new(&app.model);
|
||||||
ViewPanel::new(&app.model, &app.mode, app.view_panel_cursor),
|
f.render_widget(Panel::new(content, &app.mode, app.view_panel_cursor), a);
|
||||||
a,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
grid_area = area;
|
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) {
|
fn draw_bottom_bar(f: &mut Frame, area: Rect, app: &App) {
|
||||||
// All text-entry modes use the bottom bar as a minibuffer.
|
if let Some(mb) = app.mode.minibuffer() {
|
||||||
let minibuf = match &app.mode {
|
let buf = app
|
||||||
AppMode::CommandMode { .. } => {
|
.buffers
|
||||||
let buf = app.buffers.get("command").map(|s| s.as_str()).unwrap_or("");
|
.get(mb.buffer_key)
|
||||||
Some((format!(":{buf}▌"), Color::Yellow))
|
.map(|s| s.as_str())
|
||||||
}
|
.unwrap_or("");
|
||||||
AppMode::Editing { .. } => {
|
let text = format!("{}{}▌", mb.prompt, buf);
|
||||||
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 {
|
|
||||||
f.render_widget(
|
f.render_widget(
|
||||||
Paragraph::new(text).style(
|
Paragraph::new(text).style(
|
||||||
Style::default()
|
Style::default()
|
||||||
.fg(color)
|
.fg(mb.color)
|
||||||
.bg(Color::Indexed(235))
|
.bg(Color::Indexed(235))
|
||||||
.add_modifier(Modifier::BOLD),
|
.add_modifier(Modifier::BOLD),
|
||||||
),
|
),
|
||||||
|
|||||||
100
src/ui/app.rs
100
src/ui/app.rs
@ -7,6 +7,8 @@ use std::time::{Duration, Instant};
|
|||||||
|
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
use ratatui::style::Color;
|
||||||
|
|
||||||
use crate::command::cmd::CmdContext;
|
use crate::command::cmd::CmdContext;
|
||||||
use crate::command::keymap::{Keymap, KeymapSet};
|
use crate::command::keymap::{Keymap, KeymapSet};
|
||||||
use crate::import::wizard::ImportWizard;
|
use crate::import::wizard::ImportWizard;
|
||||||
@ -29,40 +31,126 @@ pub struct DrillState {
|
|||||||
pub pending_edits: std::collections::HashMap<(usize, String), String>,
|
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)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
pub enum AppMode {
|
pub enum AppMode {
|
||||||
Normal,
|
Normal,
|
||||||
Editing {
|
Editing {
|
||||||
buffer: String,
|
minibuf: MinibufferConfig,
|
||||||
},
|
},
|
||||||
FormulaEdit {
|
FormulaEdit {
|
||||||
buffer: String,
|
minibuf: MinibufferConfig,
|
||||||
},
|
},
|
||||||
FormulaPanel,
|
FormulaPanel,
|
||||||
CategoryPanel,
|
CategoryPanel,
|
||||||
/// Quick-add a new category: Enter adds and stays open, Esc closes.
|
/// Quick-add a new category: Enter adds and stays open, Esc closes.
|
||||||
CategoryAdd {
|
CategoryAdd {
|
||||||
buffer: String,
|
minibuf: MinibufferConfig,
|
||||||
},
|
},
|
||||||
/// Quick-add items to `category`: Enter adds and stays open, Esc closes.
|
/// Quick-add items to `category`: Enter adds and stays open, Esc closes.
|
||||||
ItemAdd {
|
ItemAdd {
|
||||||
category: String,
|
category: String,
|
||||||
buffer: String,
|
minibuf: MinibufferConfig,
|
||||||
},
|
},
|
||||||
ViewPanel,
|
ViewPanel,
|
||||||
TileSelect,
|
TileSelect,
|
||||||
ImportWizard,
|
ImportWizard,
|
||||||
ExportPrompt {
|
ExportPrompt {
|
||||||
buffer: String,
|
minibuf: MinibufferConfig,
|
||||||
},
|
},
|
||||||
/// Vim-style `:` command line
|
/// Vim-style `:` command line
|
||||||
CommandMode {
|
CommandMode {
|
||||||
buffer: String,
|
minibuf: MinibufferConfig,
|
||||||
},
|
},
|
||||||
Help,
|
Help,
|
||||||
Quit,
|
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 struct App {
|
||||||
pub model: Model,
|
pub model: Model,
|
||||||
pub file_path: Option<PathBuf>,
|
pub file_path: Option<PathBuf>,
|
||||||
|
|||||||
@ -2,12 +2,12 @@ use ratatui::{
|
|||||||
buffer::Buffer,
|
buffer::Buffer,
|
||||||
layout::Rect,
|
layout::Rect,
|
||||||
style::{Color, Modifier, Style},
|
style::{Color, Modifier, Style},
|
||||||
widgets::{Block, Borders, Widget},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::model::Model;
|
use crate::model::Model;
|
||||||
use crate::ui::app::AppMode;
|
use crate::ui::app::AppMode;
|
||||||
use crate::ui::cat_tree::{build_cat_tree, CatTreeEntry};
|
use crate::ui::cat_tree::{build_cat_tree, CatTreeEntry};
|
||||||
|
use crate::ui::panel::PanelContent;
|
||||||
use crate::view::Axis;
|
use crate::view::Axis;
|
||||||
|
|
||||||
fn axis_display(axis: Axis) -> (&'static str, Color) {
|
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 struct CategoryContent<'a> {
|
||||||
pub model: &'a Model,
|
model: &'a Model,
|
||||||
pub mode: &'a AppMode,
|
tree: Vec<CatTreeEntry>,
|
||||||
pub cursor: usize,
|
|
||||||
pub expanded: &'a std::collections::HashSet<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> CategoryPanel<'a> {
|
impl<'a> CategoryContent<'a> {
|
||||||
pub fn new(
|
pub fn new(model: &'a Model, expanded: &'a std::collections::HashSet<String>) -> Self {
|
||||||
model: &'a Model,
|
let tree = build_cat_tree(model, expanded);
|
||||||
mode: &'a AppMode,
|
Self { model, tree }
|
||||||
cursor: usize,
|
|
||||||
expanded: &'a std::collections::HashSet<String>,
|
|
||||||
) -> Self {
|
|
||||||
Self {
|
|
||||||
model,
|
|
||||||
mode,
|
|
||||||
cursor,
|
|
||||||
expanded,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> Widget for CategoryPanel<'a> {
|
impl PanelContent for CategoryContent<'_> {
|
||||||
fn render(self, area: Rect, buf: &mut Buffer) {
|
fn is_active(&self, mode: &AppMode) -> bool {
|
||||||
let is_item_add = matches!(self.mode, AppMode::ItemAdd { .. });
|
matches!(
|
||||||
let is_cat_add = matches!(self.mode, AppMode::CategoryAdd { .. });
|
mode,
|
||||||
let is_active = matches!(self.mode, AppMode::CategoryPanel) || is_item_add || is_cat_add;
|
AppMode::CategoryPanel | AppMode::ItemAdd { .. } | AppMode::CategoryAdd { .. }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
let (border_color, title) = if is_active {
|
fn active_color(&self) -> Color {
|
||||||
(Color::Cyan, " Categories ")
|
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 {
|
} else {
|
||||||
(Color::DarkGray, " Categories ")
|
Style::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
let block = Block::default()
|
if is_selected {
|
||||||
.borders(Borders::ALL)
|
let fill = " ".repeat(inner.width as usize);
|
||||||
.border_style(Style::default().fg(border_color))
|
buf.set_string(inner.x, y, &fill, base_style);
|
||||||
.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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for (i, entry) in tree.iter().enumerate() {
|
match &self.tree[index] {
|
||||||
if i as u16 >= inner.height {
|
CatTreeEntry::Category {
|
||||||
break;
|
name,
|
||||||
}
|
item_count,
|
||||||
let y = inner.y + i as u16;
|
expanded,
|
||||||
let is_selected = i == self.cursor && is_active;
|
} => {
|
||||||
|
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 {
|
buf.set_string(inner.x, y, &name_part, base_style);
|
||||||
Style::default()
|
if name_part.len() + axis_part.len() < inner.width as usize {
|
||||||
.fg(Color::Black)
|
buf.set_string(
|
||||||
.bg(Color::Cyan)
|
inner.x + name_part.len() as u16,
|
||||||
.add_modifier(Modifier::BOLD)
|
y,
|
||||||
} else {
|
&axis_part,
|
||||||
Style::default()
|
if is_selected {
|
||||||
};
|
base_style
|
||||||
|
} else {
|
||||||
if is_selected {
|
Style::default().fg(axis_color)
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
CatTreeEntry::Item { item_name, .. } => {
|
||||||
|
let label = format!(" · {item_name}");
|
||||||
|
buf.set_string(inner.x, y, &label, base_style);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -108,9 +108,7 @@ impl Effect for EnterEditAtCursor {
|
|||||||
let value = ctx.display_value.clone();
|
let value = ctx.display_value.clone();
|
||||||
drop(ctx);
|
drop(ctx);
|
||||||
app.buffers.insert("edit".to_string(), value);
|
app.buffers.insert("edit".to_string(), value);
|
||||||
app.mode = AppMode::Editing {
|
app.mode = AppMode::editing();
|
||||||
buffer: String::new(),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -2,93 +2,72 @@ use ratatui::{
|
|||||||
buffer::Buffer,
|
buffer::Buffer,
|
||||||
layout::Rect,
|
layout::Rect,
|
||||||
style::{Color, Modifier, Style},
|
style::{Color, Modifier, Style},
|
||||||
widgets::{Block, Borders, Widget},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::model::Model;
|
use crate::model::Model;
|
||||||
use crate::ui::app::AppMode;
|
use crate::ui::app::AppMode;
|
||||||
|
use crate::ui::panel::PanelContent;
|
||||||
|
|
||||||
pub struct FormulaPanel<'a> {
|
pub struct FormulaContent<'a> {
|
||||||
pub model: &'a Model,
|
pub model: &'a Model,
|
||||||
pub mode: &'a AppMode,
|
pub mode: &'a AppMode,
|
||||||
pub cursor: usize,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> FormulaPanel<'a> {
|
impl<'a> FormulaContent<'a> {
|
||||||
pub fn new(model: &'a Model, mode: &'a AppMode, cursor: usize) -> Self {
|
pub fn new(model: &'a Model, mode: &'a AppMode) -> Self {
|
||||||
Self {
|
Self { model, mode }
|
||||||
model,
|
|
||||||
mode,
|
|
||||||
cursor,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> Widget for FormulaPanel<'a> {
|
impl PanelContent for FormulaContent<'_> {
|
||||||
fn render(self, area: Rect, buf: &mut Buffer) {
|
fn is_active(&self, mode: &AppMode) -> bool {
|
||||||
let is_active = matches!(
|
matches!(mode, AppMode::FormulaPanel | AppMode::FormulaEdit { .. })
|
||||||
self.mode,
|
}
|
||||||
AppMode::FormulaPanel | AppMode::FormulaEdit { .. }
|
|
||||||
);
|
fn active_color(&self) -> Color {
|
||||||
let border_style = if is_active {
|
Color::Yellow
|
||||||
Style::default().fg(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 {
|
} 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()
|
fn render_footer(&self, inner: Rect, buf: &mut Buffer) {
|
||||||
.borders(Borders::ALL)
|
if matches!(self.mode, AppMode::FormulaEdit { .. }) {
|
||||||
.border_style(border_style)
|
let y = inner.y + inner.height.saturating_sub(1);
|
||||||
.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);
|
|
||||||
buf.set_string(
|
buf.set_string(
|
||||||
inner.x,
|
inner.x,
|
||||||
y,
|
y,
|
||||||
"┄ Enter formula (Name = expr): ",
|
"┄ Enter formula (Name = expr)",
|
||||||
Style::default().fg(Color::Yellow),
|
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));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,5 +6,6 @@ pub mod formula_panel;
|
|||||||
pub mod grid;
|
pub mod grid;
|
||||||
pub mod help;
|
pub mod help;
|
||||||
pub mod import_wizard_ui;
|
pub mod import_wizard_ui;
|
||||||
|
pub mod panel;
|
||||||
pub mod tile_bar;
|
pub mod tile_bar;
|
||||||
pub mod view_panel;
|
pub mod view_panel;
|
||||||
|
|||||||
82
src/ui/panel.rs
Normal file
82
src/ui/panel.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,75 +2,72 @@ use ratatui::{
|
|||||||
buffer::Buffer,
|
buffer::Buffer,
|
||||||
layout::Rect,
|
layout::Rect,
|
||||||
style::{Color, Modifier, Style},
|
style::{Color, Modifier, Style},
|
||||||
widgets::{Block, Borders, Widget},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::model::Model;
|
use crate::model::Model;
|
||||||
use crate::ui::app::AppMode;
|
use crate::ui::app::AppMode;
|
||||||
|
use crate::ui::panel::PanelContent;
|
||||||
|
|
||||||
pub struct ViewPanel<'a> {
|
pub struct ViewContent {
|
||||||
pub model: &'a Model,
|
view_names: Vec<String>,
|
||||||
pub mode: &'a AppMode,
|
active_view: String,
|
||||||
pub cursor: usize,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> ViewPanel<'a> {
|
impl ViewContent {
|
||||||
pub fn new(model: &'a Model, mode: &'a AppMode, cursor: usize) -> Self {
|
pub fn new(model: &Model) -> Self {
|
||||||
|
let view_names: Vec<String> = model.views.keys().cloned().collect();
|
||||||
|
let active_view = model.active_view.clone();
|
||||||
Self {
|
Self {
|
||||||
model,
|
view_names,
|
||||||
mode,
|
active_view,
|
||||||
cursor,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> Widget for ViewPanel<'a> {
|
impl PanelContent for ViewContent {
|
||||||
fn render(self, area: Rect, buf: &mut Buffer) {
|
fn is_active(&self, mode: &AppMode) -> bool {
|
||||||
let is_active = matches!(self.mode, AppMode::ViewPanel);
|
matches!(mode, AppMode::ViewPanel)
|
||||||
let border_style = if is_active {
|
}
|
||||||
Style::default().fg(Color::Blue)
|
|
||||||
|
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 {
|
} else {
|
||||||
Style::default().fg(Color::DarkGray)
|
Style::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
let block = Block::default()
|
let prefix = if is_active_view { "▶ " } else { " " };
|
||||||
.borders(Borders::ALL)
|
buf.set_string(
|
||||||
.border_style(border_style)
|
inner.x,
|
||||||
.title(" Views ");
|
inner.y + index as u16,
|
||||||
let inner = block.inner(area);
|
format!("{prefix}{view_name}"),
|
||||||
block.render(area, buf);
|
style,
|
||||||
|
);
|
||||||
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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user