envelope_cli/tui/dialogs/
category.rs1use ratatui::{
7 layout::{Constraint, Direction, Layout, Rect},
8 style::{Color, Modifier, Style},
9 text::{Line, Span},
10 widgets::{Block, Borders, Clear, Paragraph},
11 Frame,
12};
13
14use crate::models::{Category, CategoryGroupId};
15use crate::services::CategoryService;
16use crate::tui::app::App;
17use crate::tui::layout::centered_rect;
18use crate::tui::widgets::input::TextInput;
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
22pub enum CategoryField {
23 #[default]
24 Name,
25 Group,
26}
27
28#[derive(Debug, Clone)]
30pub struct CategoryFormState {
31 pub name_input: TextInput,
33
34 pub selected_group_index: usize,
36
37 pub groups: Vec<(CategoryGroupId, String)>,
39
40 pub focused_field: CategoryField,
42
43 pub error_message: Option<String>,
45
46 pub editing_id: Option<crate::models::CategoryId>,
48}
49
50impl Default for CategoryFormState {
51 fn default() -> Self {
52 Self::new()
53 }
54}
55
56impl CategoryFormState {
57 pub fn new() -> Self {
59 Self {
60 name_input: TextInput::new()
61 .label("Name")
62 .placeholder("Category name (e.g., Groceries, Rent)"),
63 selected_group_index: 0,
64 groups: Vec::new(),
65 focused_field: CategoryField::Name,
66 error_message: None,
67 editing_id: None,
68 }
69 }
70
71 pub fn init_with_groups(&mut self, groups: Vec<(CategoryGroupId, String)>) {
73 self.groups = groups;
74 self.selected_group_index = 0;
75 self.name_input = TextInput::new()
76 .label("Name")
77 .placeholder("Category name (e.g., Groceries, Rent)");
78 self.focused_field = CategoryField::Name;
79 self.error_message = None;
80 self.editing_id = None;
81 }
82
83 pub fn init_for_edit(&mut self, category: &Category, groups: Vec<(CategoryGroupId, String)>) {
85 self.groups = groups;
86 self.editing_id = Some(category.id);
87
88 self.name_input = TextInput::new()
90 .label("Name")
91 .placeholder("Category name (e.g., Groceries, Rent)")
92 .content(&category.name);
93
94 self.selected_group_index = self
96 .groups
97 .iter()
98 .position(|(id, _)| *id == category.group_id)
99 .unwrap_or(0);
100
101 self.focused_field = CategoryField::Name;
102 self.error_message = None;
103 }
104
105 pub fn set_focus(&mut self, field: CategoryField) {
107 self.focused_field = field;
108 }
109
110 pub fn next_field(&mut self) {
112 self.focused_field = match self.focused_field {
113 CategoryField::Name => CategoryField::Group,
114 CategoryField::Group => CategoryField::Name,
115 };
116 }
117
118 pub fn prev_field(&mut self) {
120 self.focused_field = match self.focused_field {
121 CategoryField::Name => CategoryField::Group,
122 CategoryField::Group => CategoryField::Name,
123 };
124 }
125
126 pub fn next_group(&mut self) {
128 if !self.groups.is_empty() {
129 self.selected_group_index = (self.selected_group_index + 1) % self.groups.len();
130 }
131 }
132
133 pub fn prev_group(&mut self) {
135 if !self.groups.is_empty() {
136 self.selected_group_index = if self.selected_group_index == 0 {
137 self.groups.len() - 1
138 } else {
139 self.selected_group_index - 1
140 };
141 }
142 }
143
144 pub fn selected_group_id(&self) -> Option<CategoryGroupId> {
146 self.groups
147 .get(self.selected_group_index)
148 .map(|(id, _)| *id)
149 }
150
151 pub fn selected_group_name(&self) -> Option<&str> {
153 self.groups
154 .get(self.selected_group_index)
155 .map(|(_, name)| name.as_str())
156 }
157
158 pub fn validate(&self) -> Result<(), String> {
160 let name = self.name_input.value().trim();
161 if name.is_empty() {
162 return Err("Category name is required".to_string());
163 }
164 if name.len() > 50 {
165 return Err("Category name too long (max 50 chars)".to_string());
166 }
167 if self.groups.is_empty() {
168 return Err("No category groups available. Create a group first (Shift+A)".to_string());
169 }
170 Ok(())
171 }
172
173 pub fn build_category(&self) -> Result<Category, String> {
175 self.validate()?;
176 let name = self.name_input.value().trim().to_string();
177 let group_id = self
178 .selected_group_id()
179 .ok_or_else(|| "No group selected".to_string())?;
180 Ok(Category::new(&name, group_id))
181 }
182
183 pub fn clear_error(&mut self) {
185 self.error_message = None;
186 }
187
188 pub fn set_error(&mut self, msg: impl Into<String>) {
190 self.error_message = Some(msg.into());
191 }
192}
193
194pub fn render(frame: &mut Frame, app: &mut App) {
196 let area = centered_rect(50, 30, frame.area());
197
198 frame.render_widget(Clear, area);
200
201 let title = if app.category_form.editing_id.is_some() {
203 " Edit Category "
204 } else {
205 " Add Category "
206 };
207
208 let block = Block::default()
209 .title(title)
210 .title_style(
211 Style::default()
212 .fg(Color::Cyan)
213 .add_modifier(Modifier::BOLD),
214 )
215 .borders(Borders::ALL)
216 .border_style(Style::default().fg(Color::Cyan));
217
218 frame.render_widget(block, area);
219
220 let inner = Rect {
222 x: area.x + 2,
223 y: area.y + 1,
224 width: area.width.saturating_sub(4),
225 height: area.height.saturating_sub(2),
226 };
227
228 let chunks = Layout::default()
230 .direction(Direction::Vertical)
231 .constraints([
232 Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Min(0), ])
240 .split(inner);
241
242 let name_value = app.category_form.name_input.value().to_string();
244 let name_cursor = app.category_form.name_input.cursor;
245 let name_placeholder = app.category_form.name_input.placeholder.clone();
246 let focused_field = app.category_form.focused_field;
247 let error_message = app.category_form.error_message.clone();
248 let group_name = app
249 .category_form
250 .selected_group_name()
251 .unwrap_or("(no groups)")
252 .to_string();
253
254 render_text_field(
256 frame,
257 chunks[0],
258 "Name",
259 &name_value,
260 focused_field == CategoryField::Name,
261 name_cursor,
262 &name_placeholder,
263 );
264
265 render_selector_field(
267 frame,
268 chunks[2],
269 "Group",
270 &group_name,
271 focused_field == CategoryField::Group,
272 );
273
274 if let Some(ref error) = error_message {
276 let error_line = Line::from(Span::styled(
277 error.as_str(),
278 Style::default().fg(Color::Red),
279 ));
280 frame.render_widget(Paragraph::new(error_line), chunks[4]);
281 }
282
283 let hints = Line::from(vec![
285 Span::styled("[Tab]", Style::default().fg(Color::Yellow)),
286 Span::raw(" Next "),
287 Span::styled("[Enter]", Style::default().fg(Color::Green)),
288 Span::raw(" Save "),
289 Span::styled("[Esc]", Style::default().fg(Color::Red)),
290 Span::raw(" Cancel"),
291 ]);
292 frame.render_widget(Paragraph::new(hints), chunks[5]);
293}
294
295fn render_text_field(
297 frame: &mut Frame,
298 area: Rect,
299 label: &str,
300 value: &str,
301 focused: bool,
302 cursor: usize,
303 placeholder: &str,
304) {
305 let label_style = if focused {
306 Style::default()
307 .fg(Color::Cyan)
308 .add_modifier(Modifier::BOLD)
309 } else {
310 Style::default().fg(Color::Yellow)
311 };
312
313 let label_span = Span::styled(format!("{}: ", label), label_style);
314 let value_style = Style::default().fg(Color::White);
315
316 let display_value = if value.is_empty() && !focused {
317 placeholder.to_string()
318 } else {
319 value.to_string()
320 };
321
322 let mut spans = vec![label_span];
323
324 if focused {
325 let cursor_pos = cursor.min(display_value.len());
326 let (before, after) = display_value.split_at(cursor_pos);
327
328 spans.push(Span::styled(before.to_string(), value_style));
329
330 let cursor_char = after.chars().next().unwrap_or(' ');
331 spans.push(Span::styled(
332 cursor_char.to_string(),
333 Style::default().fg(Color::Black).bg(Color::Cyan),
334 ));
335
336 if after.len() > 1 {
337 spans.push(Span::styled(after[1..].to_string(), value_style));
338 }
339 } else {
340 spans.push(Span::styled(display_value, value_style));
341 }
342
343 frame.render_widget(Paragraph::new(Line::from(spans)), area);
344}
345
346fn render_selector_field(frame: &mut Frame, area: Rect, label: &str, value: &str, focused: bool) {
348 let label_style = if focused {
349 Style::default()
350 .fg(Color::Cyan)
351 .add_modifier(Modifier::BOLD)
352 } else {
353 Style::default().fg(Color::Yellow)
354 };
355
356 let value_style = if focused {
357 Style::default().fg(Color::White).bg(Color::DarkGray)
358 } else {
359 Style::default().fg(Color::White)
360 };
361
362 let hint = if focused { " ◀ j/k ▶" } else { "" };
363
364 let line = Line::from(vec![
365 Span::styled(format!("{}: ", label), label_style),
366 Span::styled(format!(" {} ", value), value_style),
367 Span::styled(hint, Style::default().fg(Color::Yellow)),
368 ]);
369
370 frame.render_widget(Paragraph::new(line), area);
371}
372
373pub fn handle_key(app: &mut App, key: crossterm::event::KeyEvent) -> bool {
375 use crossterm::event::KeyCode;
376
377 match key.code {
378 KeyCode::Esc => {
379 app.close_dialog();
380 return true;
381 }
382
383 KeyCode::Tab | KeyCode::Down if app.category_form.focused_field == CategoryField::Name => {
384 app.category_form.next_field();
385 return true;
386 }
387
388 KeyCode::BackTab | KeyCode::Up
389 if app.category_form.focused_field == CategoryField::Group =>
390 {
391 app.category_form.prev_field();
392 return true;
393 }
394
395 KeyCode::Enter => {
396 if let Err(e) = save_category(app) {
398 app.category_form.set_error(e);
399 }
400 return true;
401 }
402
403 _ => {}
404 }
405
406 match app.category_form.focused_field {
408 CategoryField::Name => handle_name_input(app, key),
409 CategoryField::Group => handle_group_selector(app, key),
410 }
411}
412
413fn handle_name_input(app: &mut App, key: crossterm::event::KeyEvent) -> bool {
415 use crossterm::event::KeyCode;
416
417 let form = &mut app.category_form;
418
419 match key.code {
420 KeyCode::Backspace => {
421 form.clear_error();
422 form.name_input.backspace();
423 true
424 }
425
426 KeyCode::Delete => {
427 form.clear_error();
428 form.name_input.delete();
429 true
430 }
431
432 KeyCode::Left => {
433 form.name_input.move_left();
434 true
435 }
436
437 KeyCode::Right => {
438 form.name_input.move_right();
439 true
440 }
441
442 KeyCode::Home => {
443 form.name_input.move_start();
444 true
445 }
446
447 KeyCode::End => {
448 form.name_input.move_end();
449 true
450 }
451
452 KeyCode::Char(c) => {
453 form.clear_error();
454 form.name_input.insert(c);
455 true
456 }
457
458 _ => false,
459 }
460}
461
462fn handle_group_selector(app: &mut App, key: crossterm::event::KeyEvent) -> bool {
464 use crossterm::event::KeyCode;
465
466 let form = &mut app.category_form;
467
468 match key.code {
469 KeyCode::Char('j') | KeyCode::Right => {
470 form.next_group();
471 true
472 }
473
474 KeyCode::Char('k') | KeyCode::Left => {
475 form.prev_group();
476 true
477 }
478
479 _ => false,
480 }
481}
482
483fn save_category(app: &mut App) -> Result<(), String> {
485 app.category_form.validate()?;
487
488 let name = app.category_form.name_input.value().trim().to_string();
489 let group_id = app
490 .category_form
491 .selected_group_id()
492 .ok_or_else(|| "No group selected".to_string())?;
493
494 let category_service = CategoryService::new(app.storage);
495
496 if let Some(category_id) = app.category_form.editing_id {
497 category_service
499 .update_category(category_id, Some(&name), None, false)
500 .map_err(|e| e.to_string())?;
501
502 if let Ok(Some(cat)) = app.storage.categories.get_category(category_id) {
504 if cat.group_id != group_id {
505 category_service
506 .move_category(category_id, group_id)
507 .map_err(|e| e.to_string())?;
508 }
509 }
510
511 app.close_dialog();
513 app.set_status(format!("Category '{}' updated", name));
514 } else {
515 category_service
517 .create_category(&name, group_id)
518 .map_err(|e| e.to_string())?;
519
520 app.close_dialog();
522 app.set_status(format!("Category '{}' created", name));
523 }
524
525 Ok(())
526}