1use ratatui::{
2 buffer::Buffer,
3 layout::{Constraint, Layout, Rect},
4 style::{Modifier, Style},
5 text::{Line, Span},
6 widgets::{Block, Clear, List, ListItem, Paragraph, Widget, Wrap},
7};
8
9use crate::config::constants::ui;
10use crate::ui::tui::session::{
11 modal::{
12 ModalListLayout, ModalRenderStyles, ModalSearchState, ModalSection, WizardModalState,
13 compute_modal_area,
14 },
15 terminal_capabilities,
16};
17
18pub struct ModalWidget<'a> {
35 title: String,
36 viewport: Rect,
37 modal_type: ModalType<'a>,
38 styles: ModalRenderStyles,
39 input_content: Option<&'a str>,
40 cursor_position: Option<usize>,
41}
42
43pub enum ModalType<'a> {
45 Text { lines: &'a [String] },
47 List {
49 lines: &'a [String],
50 list_state: &'a mut crate::ui::tui::session::modal::ModalListState,
51 },
52 Wizard { wizard_state: &'a WizardModalState },
54 Search {
56 lines: &'a [String],
57 search_state: &'a ModalSearchState,
58 list_state: Option<&'a mut crate::ui::tui::session::modal::ModalListState>,
59 },
60 SecurePrompt {
62 lines: &'a [String],
63 prompt_config: &'a crate::ui::tui::types::SecurePromptConfig,
64 },
65}
66
67impl<'a> ModalWidget<'a> {
68 pub fn new(title: String, viewport: Rect) -> Self {
70 Self {
71 title,
72 viewport,
73 modal_type: ModalType::Text { lines: &[] },
74 styles: ModalRenderStyles {
75 border: Style::default(),
76 highlight: Style::default(),
77 badge: Style::default(),
78 header: Style::default(),
79 selectable: Style::default(),
80 detail: Style::default(),
81 search_match: Style::default(),
82 title: Style::default().add_modifier(Modifier::BOLD),
83 divider: Style::default(),
84 instruction_border: Style::default(),
85 instruction_title: Style::default(),
86 instruction_bullet: Style::default(),
87 instruction_body: Style::default(),
88 hint: Style::default(),
89 },
90 input_content: None,
91 cursor_position: None,
92 }
93 }
94
95 #[must_use]
97 pub fn modal_type(mut self, modal_type: ModalType<'a>) -> Self {
98 self.modal_type = modal_type;
99 self
100 }
101
102 #[must_use]
104 pub fn styles(mut self, styles: ModalRenderStyles) -> Self {
105 self.styles = styles;
106 self
107 }
108
109 #[must_use]
111 pub fn input_content(mut self, content: &'a str) -> Self {
112 self.input_content = Some(content);
113 self
114 }
115
116 #[must_use]
118 pub fn cursor_position(mut self, position: usize) -> Self {
119 self.cursor_position = Some(position);
120 self
121 }
122
123 fn calculate_modal_area(&self) -> Rect {
125 let (text_lines, prompt_lines, search_lines, has_list) = match &self.modal_type {
126 ModalType::Text { lines } => (lines.len(), 0, 0, false),
127 ModalType::List { lines, .. } => (lines.len(), 0, 0, true),
128 ModalType::Wizard { wizard_state: _ } => {
129 let height = 10; (height, 0, 0, true)
131 }
132 ModalType::Search {
133 lines, list_state, ..
134 } => (lines.len(), 0, 3, list_state.is_some()),
135 ModalType::SecurePrompt { lines, .. } => (lines.len(), 2, 0, false),
136 };
137
138 compute_modal_area(
139 self.viewport,
140 text_lines,
141 prompt_lines,
142 search_lines,
143 has_list,
144 )
145 }
146}
147
148impl<'a> Widget for ModalWidget<'a> {
149 fn render(self, _area: Rect, buf: &mut Buffer) {
150 if self.viewport.height == 0 || self.viewport.width == 0 {
151 return;
152 }
153
154 let area = self.calculate_modal_area();
155
156 Clear.render(area, buf);
158
159 let block = Block::bordered()
161 .title(Line::styled(self.title.clone(), self.styles.title))
162 .border_type(terminal_capabilities::get_border_type())
163 .border_style(self.styles.border);
164 let inner = block.inner(area);
165 block.render(area, buf);
166
167 if inner.width == 0 || inner.height == 0 {
168 return;
169 }
170
171 match self.modal_type {
173 ModalType::Text { lines } => {
174 self.render_text_modal(inner, buf, lines);
175 }
176 ModalType::List {
177 lines,
178 ref list_state,
179 } => {
180 self.render_list_modal(inner, buf, lines, list_state);
181 }
182 ModalType::Wizard { wizard_state } => {
183 self.render_wizard_modal(inner, buf, wizard_state);
184 }
185 ModalType::Search {
186 lines,
187 search_state,
188 list_state: ref list_state_opt,
189 } => {
190 let list_state_ref = list_state_opt.as_ref().map(|s| &**s);
192 self.render_search_modal(inner, buf, lines, search_state, list_state_ref);
193 }
194 ModalType::SecurePrompt {
195 lines,
196 prompt_config,
197 } => {
198 self.render_secure_prompt_modal(inner, buf, lines, prompt_config);
199 }
200 }
201 }
202}
203
204impl<'a> ModalWidget<'a> {
205 fn render_text_modal(&self, area: Rect, buf: &mut Buffer, lines: &[String]) {
206 if lines.is_empty() {
207 return;
208 }
209
210 let paragraph = Paragraph::new(
211 lines
212 .iter()
213 .map(|line| Line::from(line.as_str()))
214 .collect::<Vec<_>>(),
215 )
216 .wrap(Wrap { trim: true });
217 paragraph.render(area, buf);
218 }
219
220 fn render_list_modal(
221 &self,
222 area: Rect,
223 buf: &mut Buffer,
224 lines: &[String],
225 list_state: &crate::ui::tui::session::modal::ModalListState,
226 ) {
227 let layout = ModalListLayout::new(area, lines.len());
228
229 if let Some(text_area) = layout.text_area
231 && !lines.is_empty()
232 {
233 self.render_instructions(text_area, buf, lines);
234 }
235
236 self.render_modal_list(layout.list_area, buf, list_state);
238 }
239
240 fn render_wizard_modal(&self, area: Rect, buf: &mut Buffer, wizard_state: &WizardModalState) {
241 let chunks = Layout::vertical([
243 Constraint::Length(1), Constraint::Length(2), Constraint::Min(3), ])
247 .split(area);
248
249 self.render_wizard_tabs(
251 chunks[0],
252 buf,
253 &wizard_state.steps,
254 wizard_state.current_step,
255 );
256
257 if let Some(step) = wizard_state.steps.get(wizard_state.current_step) {
259 let question = Paragraph::new(Line::from(Span::styled(
260 step.question.clone(),
261 self.styles.header,
262 )));
263 question.render(chunks[1], buf);
264
265 }
269 }
270
271 fn render_search_modal(
272 &self,
273 area: Rect,
274 buf: &mut Buffer,
275 lines: &[String],
276 search_state: &ModalSearchState,
277 list_state: Option<&crate::ui::tui::session::modal::ModalListState>,
278 ) {
279 let mut sections = Vec::new();
280 let has_instructions = lines.iter().any(|line| !line.trim().is_empty());
281
282 if has_instructions {
283 sections.push(ModalSection::Instructions);
284 }
285 sections.push(ModalSection::Search);
286 if list_state.is_some() {
287 sections.push(ModalSection::List);
288 }
289
290 let mut constraints = Vec::new();
291 for section in §ions {
292 match section {
293 ModalSection::Instructions => {
294 let visible_rows = lines.len().max(1) as u16;
295 let height = visible_rows.saturating_add(2);
296 constraints.push(Constraint::Length(height.min(area.height)));
297 }
298 ModalSection::Search => {
299 constraints.push(Constraint::Length(3.min(area.height)));
300 }
301 ModalSection::List => {
302 constraints.push(Constraint::Min(3));
303 }
304 _ => {}
305 }
306 }
307
308 let chunks = Layout::vertical(constraints).split(area);
309 let mut chunk_iter = chunks.iter();
310
311 for section in §ions {
312 if let Some(chunk) = chunk_iter.next() {
313 match section {
314 ModalSection::Instructions => {
315 if chunk.height > 0 && has_instructions {
316 self.render_instructions(*chunk, buf, lines);
317 }
318 }
319 ModalSection::Search => {
320 self.render_modal_search(*chunk, buf, search_state);
321 }
322 ModalSection::List => {
323 if let Some(list_state) = list_state {
324 self.render_modal_list(*chunk, buf, list_state);
325 }
326 }
327 _ => {}
328 }
329 }
330 }
331 }
332
333 fn render_secure_prompt_modal(
334 &self,
335 area: Rect,
336 buf: &mut Buffer,
337 lines: &[String],
338 prompt_config: &crate::ui::tui::types::SecurePromptConfig,
339 ) {
340 let mut sections = Vec::new();
341 let has_instructions = lines.iter().any(|line| !line.trim().is_empty());
342
343 if has_instructions {
344 sections.push(ModalSection::Instructions);
345 }
346 sections.push(ModalSection::Prompt);
347
348 let mut constraints = Vec::new();
349 for section in §ions {
350 match section {
351 ModalSection::Instructions => {
352 let visible_rows = lines.len().max(1) as u16;
353 let height = visible_rows.saturating_add(2);
354 constraints.push(Constraint::Length(height.min(area.height)));
355 }
356 ModalSection::Prompt => {
357 constraints.push(Constraint::Length(3.min(area.height)));
358 }
359 _ => {}
360 }
361 }
362
363 let chunks = Layout::vertical(constraints).split(area);
364 let mut chunk_iter = chunks.iter();
365
366 for section in §ions {
367 if let Some(chunk) = chunk_iter.next() {
368 match section {
369 ModalSection::Instructions => {
370 if chunk.height > 0 && has_instructions {
371 self.render_instructions(*chunk, buf, lines);
372 }
373 }
374 ModalSection::Prompt => {
375 self.render_secure_prompt(
376 *chunk,
377 buf,
378 prompt_config,
379 self.input_content.unwrap_or(""),
380 self.cursor_position.unwrap_or(0),
381 );
382 }
383 _ => {}
384 }
385 }
386 }
387 }
388
389 fn render_instructions(&self, area: Rect, buf: &mut Buffer, instructions: &[String]) {
390 let items: Vec<ListItem> = instructions
391 .iter()
392 .enumerate()
393 .map(|(i, line)| {
394 let trimmed = line.trim();
395 if trimmed.is_empty() {
396 ListItem::new(Line::default())
397 } else if i == 0 {
398 ListItem::new(Line::from(Span::styled(
400 trimmed.to_owned(),
401 self.styles.header,
402 )))
403 } else {
404 let bullet_prefix = format!("{} ", ui::MODAL_INSTRUCTIONS_BULLET);
406 ListItem::new(Line::from(vec![
407 Span::styled(bullet_prefix, self.styles.instruction_bullet),
408 Span::styled(trimmed.to_owned(), self.styles.instruction_body),
409 ]))
410 }
411 })
412 .collect();
413
414 let block = Block::bordered()
415 .title(Span::styled(
416 ui::MODAL_INSTRUCTIONS_TITLE.to_owned(),
417 self.styles.instruction_title,
418 ))
419 .border_type(terminal_capabilities::get_border_type())
420 .border_style(self.styles.instruction_border);
421
422 let widget = List::new(items)
423 .block(block)
424 .style(self.styles.instruction_body)
425 .highlight_symbol("")
426 .repeat_highlight_symbol(false);
427 widget.render(area, buf);
428 }
429
430 fn render_modal_list(
431 &self,
432 area: Rect,
433 buf: &mut Buffer,
434 list_state: &crate::ui::tui::session::modal::ModalListState,
435 ) {
436 use crate::ui::tui::session::modal::modal_list_items;
437
438 if list_state.visible_indices.is_empty() {
441 let message = Paragraph::new(Line::from(Span::styled(
442 ui::MODAL_LIST_NO_RESULTS_MESSAGE.to_owned(),
443 self.styles.detail,
444 )))
445 .wrap(Wrap { trim: true });
446 message.render(area, buf);
447 return;
448 }
449
450 let content_width = area.width.saturating_sub(4) as usize;
451 let items = modal_list_items(list_state, &self.styles, content_width);
452 let widget = List::new(items)
453 .highlight_style(self.styles.highlight)
454 .highlight_symbol(ui::MODAL_LIST_HIGHLIGHT_FULL)
455 .repeat_highlight_symbol(true);
456
457 widget.render(area, buf);
458 }
459
460 fn render_wizard_tabs(
461 &self,
462 area: Rect,
463 buf: &mut Buffer,
464 steps: &[crate::ui::tui::session::modal::WizardStepState],
465 current_step: usize,
466 ) {
467 if let Some(step) = steps.get(current_step) {
469 let icon = if step.completed { "✔" } else { "☐" };
470 let text = format!("{} {}", icon, step.title);
471 let tabs = Paragraph::new(Line::from(text).style(self.styles.highlight));
472 tabs.render(area, buf);
473 }
474 }
475
476 fn render_modal_search(&self, area: Rect, buf: &mut Buffer, search_state: &ModalSearchState) {
477 let mut spans = Vec::new();
478 if search_state.query.is_empty() {
479 if let Some(placeholder) = &search_state.placeholder {
480 spans.push(Span::styled(placeholder.clone(), self.styles.detail));
481 }
482 } else {
483 spans.push(Span::styled(
484 search_state.query.clone(),
485 self.styles.selectable,
486 ));
487 }
488 spans.push(Span::styled("▌".to_owned(), self.styles.highlight));
489
490 let block = Block::bordered()
491 .title(Span::styled(search_state.label.clone(), self.styles.header))
492 .border_type(terminal_capabilities::get_border_type())
493 .border_style(self.styles.border);
494
495 let paragraph = Paragraph::new(Line::from(spans))
496 .block(block)
497 .wrap(Wrap { trim: true });
498 paragraph.render(area, buf);
499 }
500
501 fn render_secure_prompt(
502 &self,
503 area: Rect,
504 buf: &mut Buffer,
505 config: &crate::ui::tui::types::SecurePromptConfig,
506 input: &str,
507 _cursor: usize,
508 ) {
509 let grapheme_count = input.chars().count();
512 let sanitized: String = std::iter::repeat_n('•', grapheme_count).collect();
513
514 let mut spans = vec![Span::styled(config.label.clone(), self.styles.header)];
515 spans.push(Span::raw(" "));
516 spans.push(Span::styled(sanitized, self.styles.selectable));
517 spans.push(Span::styled("▌".to_owned(), self.styles.highlight));
518
519 let block = Block::bordered()
520 .border_type(terminal_capabilities::get_border_type())
521 .border_style(self.styles.border);
522
523 let paragraph = Paragraph::new(Line::from(spans))
524 .block(block)
525 .wrap(Wrap { trim: true });
526 paragraph.render(area, buf);
527 }
528}