Skip to main content

turbo_vision/views/
msgbox.rs

1// (C) 2025 - Enzo Lombardi
2
3//! `MsgBox` - message box utilities for displaying alerts and confirmations.
4
5use 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    /// Message-box options (Borland: `mfXxxx`): a type in the low four bits,
19    /// button flags above, and behaviour flags.
20    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        /// Close the message box on its own after
31        /// [`MESSAGE_BOX_AUTO_DISMISS_TIMEOUT`] if the user has not dismissed
32        /// it. The result is the default button's command (OK when present,
33        /// otherwise the first button).
34        const AUTO_DISMISS = 0x1000;
35        const YES_NO_CANCEL = 0x0E00;
36        const OK_CANCEL = 0x0C00;
37    }
38}
39
40/// The type bits of a `MsgBox` (Borland: `mfWarning`..`mfAbout`).
41const 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
50/// How long an `MsgBox::AUTO_DISMISS` message box stays open before closing itself.
51pub const MESSAGE_BOX_AUTO_DISMISS_TIMEOUT: Duration = Duration::from_secs(3);
52
53/// Display a message box with the given message and options.
54///
55/// Long messages are word-wrapped to fit the dialog's text area —
56/// `StaticText` itself only honours explicit `\n`, so without this the
57/// tail of a long line would be clipped right at the frame. The
58/// dialog stays at a fixed 60-column max width; long messages grow
59/// the dialog vertically instead.
60pub fn message_box(app: &mut Application, message: &str, options: MsgBox) -> CommandId {
61    let (screen_w, screen_h) = app.terminal.size();
62
63    // Fixed dialog width — keep modal dialogs neat and consistent.
64    let target_w = 60usize;
65    // Inner text area: x=3, x_end=W-2 in message_box_rect → 5 chars
66    // of frame/margins. Leave one extra column of breathing room so a
67    // wrapped line never sits flush against the right frame.
68    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    // Frame, one row of padding above and below the text, and the
81    // button row only when there is at least one button to show.
82    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/// Word-wrap `message` so every line is at most `max_width` characters
96/// wide. Existing newlines act as hard breaks; words longer than
97/// `max_width` (e.g. file paths) are split character-wise rather than
98/// dropped, so the user always sees the full content.
99#[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            // Words longer than the wrap width can't fit on a single
124            // line — break them forcibly so we never lose content.
125            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
174/// Display a message box at a specific location
175pub fn message_box_rect(
176    app: &mut Application,
177    bounds: Rect,
178    message: &str,
179    options: MsgBox,
180) -> CommandId {
181    // Determine title based on message type
182    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    // Add static text with message (one row higher). Left-align so
194    // wrapped lines hang from a consistent left margin instead of
195    // each being independently centered (which makes a wrapped
196    // diagnostic look like staggered poetry).
197    // Without buttons the text area runs down to the bottom frame.
198    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    // Determine which buttons to show
207    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    // Calculate button positions (one row higher)
222    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    // Add buttons
227    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
247/// Whether `options` asks for at least one button.
248fn has_buttons(options: MsgBox) -> bool {
249    options.intersects(MsgBox::YES_NO_CANCEL | MsgBox::OK_BUTTON)
250}
251
252/// The command an auto-dismissed message box reports: OK when present,
253/// otherwise the first button, matching the default-button choice above.
254fn 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
262/// Display a simple message box with OK button
263///
264/// Returns `CM_OK` when dismissed.
265///
266/// # Example
267/// ```ignore
268/// use turbo_vision::views::msgbox::message_box_ok;
269///
270/// message_box_ok(&mut app, "File saved successfully!");
271/// ```
272pub fn message_box_ok(app: &mut Application, message: &str) -> CommandId {
273    message_box(app, message, MsgBox::INFORMATION | MsgBox::OK_BUTTON)
274}
275
276/// Display an error message box with OK button
277///
278/// Returns `CM_OK` when dismissed.
279///
280/// # Example
281/// ```ignore
282/// use turbo_vision::views::msgbox::message_box_error;
283///
284/// message_box_error(&mut app, "Failed to open file");
285/// ```
286pub fn message_box_error(app: &mut Application, message: &str) -> CommandId {
287    message_box(app, message, MsgBox::ERROR | MsgBox::OK_BUTTON)
288}
289
290/// Display a warning message box with OK button
291///
292/// Returns `CM_OK` when dismissed.
293pub fn message_box_warning(app: &mut Application, message: &str) -> CommandId {
294    message_box(app, message, MsgBox::WARNING | MsgBox::OK_BUTTON)
295}
296
297/// Display a confirmation dialog with Yes/No/Cancel buttons
298///
299/// Returns `CM_YES`, `CM_NO`, or `CM_CANCEL` based on user choice.
300///
301/// # Example
302/// ```ignore
303/// use turbo_vision::views::msgbox::{confirmation_box, CM_YES, CM_NO};
304///
305/// match confirmation_box(&mut app, "Save changes?") {
306///     result if result == CM_YES => { /* save */ },
307///     result if result == CM_NO => { /* don't save */ },
308///     _ => { /* cancel */ },
309/// }
310/// ```
311pub fn confirmation_box(app: &mut Application, message: &str) -> CommandId {
312    message_box(app, message, MsgBox::CONFIRMATION | MsgBox::YES_NO_CANCEL)
313}
314
315/// Display a confirmation dialog with Yes/No buttons
316///
317/// Returns `CM_YES` or `CM_NO` based on user choice.
318pub 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
326/// Display a confirmation dialog with OK/Cancel buttons
327///
328/// Returns `CM_OK` or `CM_CANCEL` based on user choice.
329pub fn confirmation_box_ok_cancel(app: &mut Application, message: &str) -> CommandId {
330    message_box(app, message, MsgBox::CONFIRMATION | MsgBox::OK_CANCEL)
331}
332
333/// Display an input box that prompts the user for a string
334pub fn input_box(
335    app: &mut Application,
336    title: &str,
337    label: &str,
338    initial: &str,
339    max_length: usize,
340) -> Option<String> {
341    // Calculate dialog size
342    let label_len = label.len();
343    let width = (label_len + max_length + 12).clamp(30, 60);
344    let height = 8;
345
346    // Center on screen
347    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
356/// Display an input box at a specific location
357pub 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    // Add label
368    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    // Add input line
374    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    // Add OK button
382    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    // Add Cancel button
388    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
403/// Display a search dialog that prompts the user for search text
404///
405/// Returns `Some(search_text)` if OK was pressed, None if cancelled
406///
407/// # Example
408/// ```ignore
409/// use turbo_vision::views::msgbox::search_box;
410///
411/// if let Some(text) = search_box(&mut app, "Search") {
412///     // Perform search with text
413/// }
414/// ```
415pub fn search_box(app: &mut Application, title: &str) -> Option<String> {
416    // Calculate dialog size
417    let width = 50;
418    let height = 9;
419
420    // Center on screen
421    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    // Add label
430    let label_bounds = Rect::new(2, 2, 20, 3);
431    dialog.add(Label::new(label_bounds, "~F~ind:"));
432
433    // Add input line
434    let input_bounds = Rect::new(2, 3, width - 4, 4);
435    let input = dialog.add_typed(InputLine::new(input_bounds, 100));
436
437    // Add OK button
438    let ok_bounds = Rect::new(15, 5, 25, 7);
439    dialog.add(Button::new(ok_bounds, " ~O~K", CM_OK, true));
440
441    // Add Cancel button
442    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
460/// Display a search and replace dialog that prompts for find and replace text
461///
462/// Returns `Some((find_text`, `replace_text`)) if OK was pressed, None if cancelled
463///
464/// # Example
465/// ```ignore
466/// use turbo_vision::views::msgbox::search_replace_box;
467///
468/// if let Some((find, replace)) = search_replace_box(&mut app, "Replace") {
469///     // Perform search and replace
470/// }
471/// ```
472pub fn search_replace_box(app: &mut Application, title: &str) -> Option<(String, String)> {
473    // Calculate dialog size
474    let width = 50;
475    let height = 13;
476
477    // Center on screen
478    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    // Add find label
487    let label1_bounds = Rect::new(2, 2, 20, 3);
488    dialog.add(Label::new(label1_bounds, "~F~ind:"));
489
490    // Add find input line
491    let input1_bounds = Rect::new(2, 3, width - 4, 4);
492    let find_input = dialog.add_typed(InputLine::new(input1_bounds, 100));
493
494    // Add replace label
495    let label2_bounds = Rect::new(2, 5, 20, 6);
496    dialog.add(Label::new(label2_bounds, "~R~eplace with:"));
497
498    // Add replace input line
499    let input2_bounds = Rect::new(2, 6, width - 4, 7);
500    let replace_input = dialog.add_typed(InputLine::new(input2_bounds, 100));
501
502    // Add OK button
503    let ok_bounds = Rect::new(15, 9, 25, 11);
504    dialog.add(Button::new(ok_bounds, " ~O~K", CM_OK, true));
505
506    // Add Cancel button
507    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
531/// Display a goto line dialog that prompts for a line number
532///
533/// Returns `Some(line_number)` if OK was pressed, None if cancelled or invalid
534///
535/// # Example
536/// ```ignore
537/// use turbo_vision::views::msgbox::goto_line_box;
538///
539/// if let Some(line) = goto_line_box(&mut app, "Go to Line") {
540///     // Jump to line number
541/// }
542/// ```
543pub fn goto_line_box(app: &mut Application, title: &str) -> Option<usize> {
544    // Calculate dialog size
545    let width = 40;
546    let height = 8;
547
548    // Center on screen
549    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    // Add label
558    let label_bounds = Rect::new(2, 2, 20, 3);
559    dialog.add(Label::new(label_bounds, " ~L~ine number:"));
560
561    // Add input line
562    let input_bounds = Rect::new(2, 3, width - 4, 4);
563    let input = dialog.add_typed(InputLine::new(input_bounds, 10));
564
565    // Add OK button
566    let ok_bounds = Rect::new(10, 5, 20, 7);
567    dialog.add(Button::new(ok_bounds, " ~O~K", CM_OK, true));
568
569    // Add Cancel button
570    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        // Round-tripping by re-joining whitespace must give the original
620        // message back — wrapping must not lose or reorder words.
621        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}