envelope_cli/tui/dialogs/
group.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::CategoryGroup;
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)]
22pub struct GroupFormState {
23 pub name_input: TextInput,
25
26 pub error_message: Option<String>,
28}
29
30impl Default for GroupFormState {
31 fn default() -> Self {
32 Self::new()
33 }
34}
35
36impl GroupFormState {
37 pub fn new() -> Self {
39 Self {
40 name_input: TextInput::new()
41 .label("Name")
42 .placeholder("Group name (e.g., Bills, Savings)"),
43 error_message: None,
44 }
45 }
46
47 pub fn validate(&self) -> Result<(), String> {
49 let name = self.name_input.value().trim();
50 if name.is_empty() {
51 return Err("Group name is required".to_string());
52 }
53 if name.len() > 50 {
54 return Err("Group name too long (max 50 chars)".to_string());
55 }
56 Ok(())
57 }
58
59 pub fn build_group(&self) -> Result<CategoryGroup, String> {
61 self.validate()?;
62 let name = self.name_input.value().trim().to_string();
63 Ok(CategoryGroup::new(name))
64 }
65
66 pub fn clear_error(&mut self) {
68 self.error_message = None;
69 }
70
71 pub fn set_error(&mut self, msg: impl Into<String>) {
73 self.error_message = Some(msg.into());
74 }
75}
76
77pub fn render(frame: &mut Frame, app: &mut App) {
79 let area = centered_rect(50, 25, frame.area());
80
81 frame.render_widget(Clear, area);
83
84 let block = Block::default()
85 .title(" Add Category Group ")
86 .title_style(
87 Style::default()
88 .fg(Color::Cyan)
89 .add_modifier(Modifier::BOLD),
90 )
91 .borders(Borders::ALL)
92 .border_style(Style::default().fg(Color::Cyan));
93
94 frame.render_widget(block, area);
95
96 let inner = Rect {
98 x: area.x + 2,
99 y: area.y + 1,
100 width: area.width.saturating_sub(4),
101 height: area.height.saturating_sub(2),
102 };
103
104 let chunks = Layout::default()
106 .direction(Direction::Vertical)
107 .constraints([
108 Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Min(0), ])
114 .split(inner);
115
116 let name_value = app.group_form.name_input.value().to_string();
118 let name_cursor = app.group_form.name_input.cursor;
119 let name_placeholder = app.group_form.name_input.placeholder.clone();
120 let error_message = app.group_form.error_message.clone();
121
122 render_text_field(
124 frame,
125 chunks[0],
126 "Name",
127 &name_value,
128 true, name_cursor,
130 &name_placeholder,
131 );
132
133 if let Some(ref error) = error_message {
135 let error_line = Line::from(Span::styled(
136 error.as_str(),
137 Style::default().fg(Color::Red),
138 ));
139 frame.render_widget(Paragraph::new(error_line), chunks[2]);
140 }
141
142 let hints = Line::from(vec![
144 Span::styled("[Enter]", Style::default().fg(Color::Green)),
145 Span::raw(" Save "),
146 Span::styled("[Esc]", Style::default().fg(Color::Red)),
147 Span::raw(" Cancel"),
148 ]);
149 frame.render_widget(Paragraph::new(hints), chunks[3]);
150}
151
152fn render_text_field(
154 frame: &mut Frame,
155 area: Rect,
156 label: &str,
157 value: &str,
158 focused: bool,
159 cursor: usize,
160 placeholder: &str,
161) {
162 let label_style = if focused {
163 Style::default()
164 .fg(Color::Cyan)
165 .add_modifier(Modifier::BOLD)
166 } else {
167 Style::default().fg(Color::Yellow)
168 };
169
170 let label_span = Span::styled(format!("{}: ", label), label_style);
171 let value_style = Style::default().fg(Color::White);
172
173 let display_value = if value.is_empty() && !focused {
174 placeholder.to_string()
175 } else {
176 value.to_string()
177 };
178
179 let mut spans = vec![label_span];
180
181 if focused {
182 let cursor_pos = cursor.min(display_value.len());
183 let (before, after) = display_value.split_at(cursor_pos);
184
185 spans.push(Span::styled(before.to_string(), value_style));
186
187 let cursor_char = after.chars().next().unwrap_or(' ');
188 spans.push(Span::styled(
189 cursor_char.to_string(),
190 Style::default().fg(Color::Black).bg(Color::Cyan),
191 ));
192
193 if after.len() > 1 {
194 spans.push(Span::styled(after[1..].to_string(), value_style));
195 }
196 } else {
197 spans.push(Span::styled(display_value, value_style));
198 }
199
200 frame.render_widget(Paragraph::new(Line::from(spans)), area);
201}
202
203pub fn handle_key(app: &mut App, key: crossterm::event::KeyEvent) -> bool {
205 use crossterm::event::KeyCode;
206
207 let form = &mut app.group_form;
208
209 match key.code {
210 KeyCode::Esc => {
211 app.close_dialog();
212 return true;
213 }
214
215 KeyCode::Enter => {
216 if let Err(e) = save_group(app) {
218 app.group_form.set_error(e);
219 }
220 return true;
221 }
222
223 KeyCode::Backspace => {
224 form.clear_error();
225 form.name_input.backspace();
226 return true;
227 }
228
229 KeyCode::Delete => {
230 form.clear_error();
231 form.name_input.delete();
232 return true;
233 }
234
235 KeyCode::Left => {
236 form.name_input.move_left();
237 return true;
238 }
239
240 KeyCode::Right => {
241 form.name_input.move_right();
242 return true;
243 }
244
245 KeyCode::Home => {
246 form.name_input.move_start();
247 return true;
248 }
249
250 KeyCode::End => {
251 form.name_input.move_end();
252 return true;
253 }
254
255 KeyCode::Char(c) => {
256 form.clear_error();
257 form.name_input.insert(c);
258 return true;
259 }
260
261 _ => {}
262 }
263
264 false
265}
266
267fn save_group(app: &mut App) -> Result<(), String> {
269 app.group_form.validate()?;
271
272 let name = app.group_form.name_input.value().trim().to_string();
273
274 let category_service = CategoryService::new(app.storage);
276 category_service
277 .create_group(&name)
278 .map_err(|e| e.to_string())?;
279
280 app.storage.categories.save().map_err(|e| e.to_string())?;
282
283 app.close_dialog();
285 app.set_status(format!("Category group '{}' created", name));
286
287 Ok(())
288}