1use super::button::Button;
6use super::dialog::Dialog;
7use super::input_line::InputLine;
8use super::label::Label;
9use super::static_text::StaticText;
10use crate::app::Application;
11use crate::core::command::{CM_CANCEL, CM_NO, CM_OK, CM_YES, CommandId};
12use crate::core::geometry::Rect;
13use crate::core::state::{deprecated_aliases, flags};
14use crate::views::group::GroupLike;
15use std::time::Duration;
16
17flags! {
18 pub struct MsgBox: u16 {
21 const WARNING = 0x0000;
22 const ERROR = 0x0001;
23 const INFORMATION = 0x0002;
24 const CONFIRMATION = 0x0003;
25 const ABOUT = 0x0004;
26 const YES_BUTTON = 0x0100;
27 const NO_BUTTON = 0x0200;
28 const OK_BUTTON = 0x0400;
29 const CANCEL_BUTTON = 0x0800;
30 const AUTO_DISMISS = 0x1000;
35 const YES_NO_CANCEL = 0x0E00;
36 const OK_CANCEL = 0x0C00;
37 }
38}
39
40const MSGBOX_TYPE_MASK: MsgBox = MsgBox::from_bits(0x000F);
42
43deprecated_aliases! { MsgBox:
44 MF_WARNING => WARNING, MF_ERROR => ERROR, MF_INFORMATION => INFORMATION,
45 MF_CONFIRMATION => CONFIRMATION, MF_ABOUT => ABOUT, MF_YES_BUTTON => YES_BUTTON,
46 MF_NO_BUTTON => NO_BUTTON, MF_OK_BUTTON => OK_BUTTON, MF_CANCEL_BUTTON => CANCEL_BUTTON,
47 MF_AUTO_DISMISS => AUTO_DISMISS, MF_YES_NO_CANCEL => YES_NO_CANCEL, MF_OK_CANCEL => OK_CANCEL,
48}
49
50pub const MESSAGE_BOX_AUTO_DISMISS_TIMEOUT: Duration = Duration::from_secs(3);
52
53pub fn message_box(app: &mut Application, message: &str, options: MsgBox) -> CommandId {
61 let (screen_w, screen_h) = app.terminal.size();
62
63 let target_w = 60usize;
65 let inner_w = target_w.saturating_sub(6).max(10);
69
70 let wrapped = wrap_message(message, inner_w);
71
72 let msg_width = wrapped
73 .lines()
74 .map(|l| l.chars().count())
75 .max()
76 .unwrap_or(20);
77 let msg_height = wrapped.lines().count().max(1);
78
79 let width = (msg_width + 6).min(target_w).max(30);
80 let chrome_rows = if has_buttons(options) { 6 } else { 4 };
83 let min_height = chrome_rows + 1;
84 let max_height = (screen_h as usize).saturating_sub(2).max(min_height);
85 let height = (msg_height + chrome_rows).min(max_height).max(min_height);
86
87 let x = (screen_w - width as i16) / 2;
88 let y = (screen_h - height as i16) / 2;
89
90 let bounds = Rect::new(x, y, x + width as i16, y + height as i16);
91
92 message_box_rect(app, bounds, &wrapped, options)
93}
94
95#[allow(
100 unused_assignments,
101 reason = "line_len / first_word_in_line are dead on the last iteration of the inner loop but used on every other"
102)]
103fn wrap_message(message: &str, max_width: usize) -> String {
104 let max_width = max_width.max(1);
105 let mut out = String::new();
106
107 for (i, paragraph) in message.split('\n').enumerate() {
108 if i > 0 {
109 out.push('\n');
110 }
111 if paragraph.chars().count() <= max_width {
112 out.push_str(paragraph);
113 continue;
114 }
115
116 let mut line_len = 0usize;
117 let mut first_word_in_line = true;
118
119 for word in paragraph.split_whitespace() {
120 let word_chars: Vec<char> = word.chars().collect();
121 let word_len = word_chars.len();
122
123 if word_len > max_width {
126 if !first_word_in_line {
127 out.push('\n');
128 line_len = 0;
129 first_word_in_line = true;
130 }
131 let mut start = 0;
132 while start < word_len {
133 let end = (start + max_width).min(word_len);
134 if start > 0 {
135 out.push('\n');
136 }
137 for ch in &word_chars[start..end] {
138 out.push(*ch);
139 }
140 start = end;
141 }
142 line_len = (word_len % max_width).max(if word_len.is_multiple_of(max_width) {
143 max_width
144 } else {
145 0
146 });
147 first_word_in_line = false;
148 continue;
149 }
150
151 let needed = if first_word_in_line {
152 word_len
153 } else {
154 line_len + 1 + word_len
155 };
156 if needed > max_width {
157 out.push('\n');
158 out.push_str(word);
159 line_len = word_len;
160 } else {
161 if !first_word_in_line {
162 out.push(' ');
163 line_len += 1;
164 }
165 out.push_str(word);
166 line_len += word_len;
167 }
168 first_word_in_line = false;
169 }
170 }
171 out
172}
173
174pub fn message_box_rect(
176 app: &mut Application,
177 bounds: Rect,
178 message: &str,
179 options: MsgBox,
180) -> CommandId {
181 let title = match options & MSGBOX_TYPE_MASK {
183 MsgBox::WARNING => "\u{26A0} Warning",
184 MsgBox::ERROR => "\u{274C} Error",
185 MsgBox::INFORMATION => "\u{2139}\u{FE0F} Information",
186 MsgBox::CONFIRMATION => "\u{2753} Confirm",
187 MsgBox::ABOUT => "\u{2139}\u{FE0F} About",
188 _ => "Message",
189 };
190
191 let mut dialog = Dialog::new(bounds, title);
192
193 let text_bottom = if has_buttons(options) {
199 bounds.height() - 4
200 } else {
201 bounds.height() - 2
202 };
203 let text_bounds = Rect::new(3, 1, bounds.width() - 2, text_bottom);
204 dialog.add(StaticText::new(text_bounds, message));
205
206 let button_configs = [
208 (MsgBox::YES_BUTTON, " ~Y~es", CM_YES),
209 (MsgBox::NO_BUTTON, " ~N~o", CM_NO),
210 (MsgBox::OK_BUTTON, " ~O~K", CM_OK),
211 (MsgBox::CANCEL_BUTTON, " ~C~ancel", CM_CANCEL),
212 ];
213
214 let mut buttons = Vec::new();
215 for (flag, label, cmd) in &button_configs {
216 if options.contains(*flag) {
217 buttons.push((*label, *cmd));
218 }
219 }
220
221 let button_y = bounds.height() - 4;
223 let total_width: usize = buttons.iter().map(|(label, _)| label.len() + 2).sum();
224 let mut x = (bounds.width_clamped() as usize - total_width) / 2;
225
226 let is_default = buttons.len() == 1 || options.contains(MsgBox::OK_BUTTON);
228 for (i, (label, cmd)) in buttons.iter().enumerate() {
229 let button_width = label.len() as i16;
230 let button_bounds = Rect::new(x as i16, button_y, x as i16 + button_width, button_y + 2);
231 let is_this_default = is_default && (i == 0 || *cmd == CM_OK);
232 dialog.add(Button::new(button_bounds, label, *cmd, is_this_default));
233 x += button_width as usize + 2;
234 }
235
236 if options.contains(MsgBox::AUTO_DISMISS) {
237 dialog.set_auto_dismiss(
238 MESSAGE_BOX_AUTO_DISMISS_TIMEOUT,
239 auto_dismiss_command(&buttons.iter().map(|(_, cmd)| *cmd).collect::<Vec<_>>()),
240 );
241 }
242
243 dialog.set_initial_focus();
244 dialog.execute(app)
245}
246
247fn has_buttons(options: MsgBox) -> bool {
249 options.intersects(MsgBox::YES_NO_CANCEL | MsgBox::OK_BUTTON)
250}
251
252fn auto_dismiss_command(commands: &[CommandId]) -> CommandId {
255 if commands.contains(&CM_OK) {
256 CM_OK
257 } else {
258 commands.first().copied().unwrap_or(CM_CANCEL)
259 }
260}
261
262pub fn message_box_ok(app: &mut Application, message: &str) -> CommandId {
273 message_box(app, message, MsgBox::INFORMATION | MsgBox::OK_BUTTON)
274}
275
276pub fn message_box_error(app: &mut Application, message: &str) -> CommandId {
287 message_box(app, message, MsgBox::ERROR | MsgBox::OK_BUTTON)
288}
289
290pub fn message_box_warning(app: &mut Application, message: &str) -> CommandId {
294 message_box(app, message, MsgBox::WARNING | MsgBox::OK_BUTTON)
295}
296
297pub fn confirmation_box(app: &mut Application, message: &str) -> CommandId {
312 message_box(app, message, MsgBox::CONFIRMATION | MsgBox::YES_NO_CANCEL)
313}
314
315pub fn confirmation_box_yes_no(app: &mut Application, message: &str) -> CommandId {
319 message_box(
320 app,
321 message,
322 MsgBox::CONFIRMATION | MsgBox::YES_BUTTON | MsgBox::NO_BUTTON,
323 )
324}
325
326pub fn confirmation_box_ok_cancel(app: &mut Application, message: &str) -> CommandId {
330 message_box(app, message, MsgBox::CONFIRMATION | MsgBox::OK_CANCEL)
331}
332
333pub fn input_box(
335 app: &mut Application,
336 title: &str,
337 label: &str,
338 initial: &str,
339 max_length: usize,
340) -> Option<String> {
341 let label_len = label.len();
343 let width = (label_len + max_length + 12).clamp(30, 60);
344 let height = 8;
345
346 let (screen_w, screen_h) = app.terminal.size();
348 let x = (screen_w - width as i16) / 2;
349 let y = (screen_h - height as i16) / 2;
350
351 let bounds = Rect::new(x, y, x + width as i16, y + height as i16);
352
353 input_box_rect(app, bounds, title, label, initial, max_length)
354}
355
356pub fn input_box_rect(
358 app: &mut Application,
359 bounds: Rect,
360 title: &str,
361 label: &str,
362 initial: &str,
363 max_length: usize,
364) -> Option<String> {
365 let mut dialog = Dialog::new(bounds, title);
366
367 let label_x = 2;
369 let label_width = label.len() as i16;
370 let label_bounds = Rect::new(label_x, 2, label_x + label_width, 3);
371 dialog.add(Label::new(label_bounds, label));
372
373 let input_x = label_x + label_width + 1;
375 let input_width = (bounds.width() - input_x - 3).min(max_length as i16 + 2);
376 let input_bounds = Rect::new(input_x, 2, input_x + input_width, 3);
377 let mut input = InputLine::new(input_bounds, max_length);
378 input.set_text(initial);
379 let input = dialog.add_typed(input);
380
381 let button_y = bounds.height() - 4;
383 let ok_x = bounds.width() / 2 - 11;
384 let ok_bounds = Rect::new(ok_x, button_y, ok_x + 10, button_y + 2);
385 dialog.add(Button::new(ok_bounds, " ~O~K", CM_OK, true));
386
387 let cancel_x = ok_x + 12;
389 let cancel_bounds = Rect::new(cancel_x, button_y, cancel_x + 10, button_y + 2);
390 dialog.add(Button::new(cancel_bounds, " ~C~ancel", CM_CANCEL, false));
391
392 dialog.set_initial_focus();
393
394 let result = dialog.execute(app);
395
396 if result == CM_OK {
397 dialog.get(input).map(|f| f.text().to_string())
398 } else {
399 None
400 }
401}
402
403pub fn search_box(app: &mut Application, title: &str) -> Option<String> {
416 let width = 50;
418 let height = 9;
419
420 let (screen_w, screen_h) = app.terminal.size();
422 let x = (screen_w - width) / 2;
423 let y = (screen_h - height) / 2;
424
425 let bounds = Rect::new(x, y, x + width, y + height);
426
427 let mut dialog = Dialog::new(bounds, title);
428
429 let label_bounds = Rect::new(2, 2, 20, 3);
431 dialog.add(Label::new(label_bounds, "~F~ind:"));
432
433 let input_bounds = Rect::new(2, 3, width - 4, 4);
435 let input = dialog.add_typed(InputLine::new(input_bounds, 100));
436
437 let ok_bounds = Rect::new(15, 5, 25, 7);
439 dialog.add(Button::new(ok_bounds, " ~O~K", CM_OK, true));
440
441 let cancel_bounds = Rect::new(27, 5, 37, 7);
443 dialog.add(Button::new(cancel_bounds, " ~C~ancel", CM_CANCEL, false));
444
445 dialog.set_initial_focus();
446
447 let result = dialog.execute(app);
448
449 if result == CM_OK {
450 let text = dialog
451 .get(input)
452 .map(|f| f.text().to_string())
453 .unwrap_or_default();
454 (!text.is_empty()).then_some(text)
455 } else {
456 None
457 }
458}
459
460pub fn search_replace_box(app: &mut Application, title: &str) -> Option<(String, String)> {
473 let width = 50;
475 let height = 13;
476
477 let (screen_w, screen_h) = app.terminal.size();
479 let x = (screen_w - width) / 2;
480 let y = (screen_h - height) / 2;
481
482 let bounds = Rect::new(x, y, x + width, y + height);
483
484 let mut dialog = Dialog::new(bounds, title);
485
486 let label1_bounds = Rect::new(2, 2, 20, 3);
488 dialog.add(Label::new(label1_bounds, "~F~ind:"));
489
490 let input1_bounds = Rect::new(2, 3, width - 4, 4);
492 let find_input = dialog.add_typed(InputLine::new(input1_bounds, 100));
493
494 let label2_bounds = Rect::new(2, 5, 20, 6);
496 dialog.add(Label::new(label2_bounds, "~R~eplace with:"));
497
498 let input2_bounds = Rect::new(2, 6, width - 4, 7);
500 let replace_input = dialog.add_typed(InputLine::new(input2_bounds, 100));
501
502 let ok_bounds = Rect::new(15, 9, 25, 11);
504 dialog.add(Button::new(ok_bounds, " ~O~K", CM_OK, true));
505
506 let cancel_bounds = Rect::new(27, 9, 37, 11);
508 dialog.add(Button::new(cancel_bounds, " ~C~ancel", CM_CANCEL, false));
509
510 dialog.set_initial_focus();
511
512 let result = dialog.execute(app);
513
514 if result == CM_OK {
515 let text_of = |h| {
516 dialog
517 .get(h)
518 .map(|f: &InputLine| f.text().to_string())
519 .unwrap_or_default()
520 };
521 let find_text = text_of(find_input);
522 (!find_text.is_empty()).then(|| {
523 let replace_text = text_of(replace_input);
524 (find_text, replace_text)
525 })
526 } else {
527 None
528 }
529}
530
531pub fn goto_line_box(app: &mut Application, title: &str) -> Option<usize> {
544 let width = 40;
546 let height = 8;
547
548 let (screen_w, screen_h) = app.terminal.size();
550 let x = (screen_w - width) / 2;
551 let y = (screen_h - height) / 2;
552
553 let bounds = Rect::new(x, y, x + width, y + height);
554
555 let mut dialog = Dialog::new(bounds, title);
556
557 let label_bounds = Rect::new(2, 2, 20, 3);
559 dialog.add(Label::new(label_bounds, " ~L~ine number:"));
560
561 let input_bounds = Rect::new(2, 3, width - 4, 4);
563 let input = dialog.add_typed(InputLine::new(input_bounds, 10));
564
565 let ok_bounds = Rect::new(10, 5, 20, 7);
567 dialog.add(Button::new(ok_bounds, " ~O~K", CM_OK, true));
568
569 let cancel_bounds = Rect::new(22, 5, 32, 7);
571 dialog.add(Button::new(cancel_bounds, " ~C~ancel", CM_CANCEL, false));
572
573 dialog.set_initial_focus();
574
575 let result = dialog.execute(app);
576
577 if result == CM_OK {
578 dialog
579 .get(input)
580 .and_then(|f| f.text().parse::<usize>().ok())
581 } else {
582 None
583 }
584}
585
586#[cfg(test)]
587mod tests {
588 use super::*;
589
590 #[test]
591 fn auto_dismiss_prefers_ok_then_first_button() {
592 assert_eq!(auto_dismiss_command(&[CM_YES, CM_NO, CM_OK]), CM_OK);
593 assert_eq!(auto_dismiss_command(&[CM_YES, CM_NO]), CM_YES);
594 assert_eq!(auto_dismiss_command(&[]), CM_CANCEL);
595 }
596
597 #[test]
598 fn has_buttons_reflects_button_flags_only() {
599 assert!(has_buttons(MsgBox::INFORMATION | MsgBox::OK_BUTTON));
600 assert!(has_buttons(MsgBox::CONFIRMATION | MsgBox::YES_NO_CANCEL));
601 assert!(!has_buttons(MsgBox::INFORMATION | MsgBox::AUTO_DISMISS));
602 }
603
604 #[test]
605 fn auto_dismiss_flag_does_not_overlap_type_or_button_bits() {
606 assert!((MsgBox::AUTO_DISMISS & MSGBOX_TYPE_MASK).is_empty());
607 assert!(!MsgBox::AUTO_DISMISS.intersects(MsgBox::YES_NO_CANCEL | MsgBox::OK_BUTTON));
608 }
609
610 #[test]
611 fn wraps_long_lines_at_word_boundaries() {
612 let out = wrap_message(
613 "Parse error: line 1:1: expected 'program', found identifier 'hello'",
614 30,
615 );
616 for line in out.lines() {
617 assert!(line.chars().count() <= 30, "line too long: {line:?}");
618 }
619 let original_words: Vec<&str> =
622 "Parse error: line 1:1: expected 'program', found identifier 'hello'"
623 .split_whitespace()
624 .collect();
625 let wrapped_words: Vec<&str> = out.split_whitespace().collect();
626 assert_eq!(original_words, wrapped_words);
627 }
628
629 #[test]
630 fn preserves_existing_newlines() {
631 let out = wrap_message("first paragraph\nsecond paragraph", 40);
632 assert_eq!(out, "first paragraph\nsecond paragraph");
633 }
634
635 #[test]
636 fn breaks_overlong_words_character_wise() {
637 let path = "averylongfilenameWithoutSpaces.txt";
638 let out = wrap_message(path, 10);
639 for line in out.lines() {
640 assert!(line.chars().count() <= 10);
641 }
642 let recombined: String = out.lines().collect();
643 assert_eq!(recombined, path);
644 }
645}