use super::button::Button;
use super::dialog::Dialog;
use super::input_line::InputLine;
use super::label::Label;
use super::static_text::StaticText;
use crate::app::Application;
use crate::core::command::{CM_CANCEL, CM_NO, CM_OK, CM_YES, CommandId};
use crate::core::geometry::Rect;
use std::cell::RefCell;
use std::rc::Rc;
use std::time::Duration;
pub const MF_WARNING: u16 = 0x0000;
pub const MF_ERROR: u16 = 0x0001;
pub const MF_INFORMATION: u16 = 0x0002;
pub const MF_CONFIRMATION: u16 = 0x0003;
pub const MF_YES_BUTTON: u16 = 0x0100;
pub const MF_NO_BUTTON: u16 = 0x0200;
pub const MF_OK_BUTTON: u16 = 0x0400;
pub const MF_CANCEL_BUTTON: u16 = 0x0800;
pub const MF_AUTO_DISMISS: u16 = 0x1000;
pub const MESSAGE_BOX_AUTO_DISMISS_TIMEOUT: Duration = Duration::from_secs(3);
pub const MF_YES_NO_CANCEL: u16 = MF_YES_BUTTON | MF_NO_BUTTON | MF_CANCEL_BUTTON;
pub const MF_OK_CANCEL: u16 = MF_OK_BUTTON | MF_CANCEL_BUTTON;
pub fn message_box(app: &mut Application, message: &str, options: u16) -> CommandId {
let (screen_w, screen_h) = app.terminal.size();
let target_w = 60usize;
let inner_w = target_w.saturating_sub(6).max(10);
let wrapped = wrap_message(message, inner_w);
let msg_width = wrapped
.lines()
.map(|l| l.chars().count())
.max()
.unwrap_or(20);
let msg_height = wrapped.lines().count().max(1);
let width = (msg_width + 6).min(target_w).max(30);
let chrome_rows = if has_buttons(options) { 6 } else { 4 };
let min_height = chrome_rows + 1;
let max_height = (screen_h as usize).saturating_sub(2).max(min_height);
let height = (msg_height + chrome_rows).min(max_height).max(min_height);
let x = (screen_w - width as i16) / 2;
let y = (screen_h - height as i16) / 2;
let bounds = Rect::new(x, y, x + width as i16, y + height as i16);
message_box_rect(app, bounds, &wrapped, options)
}
#[allow(unused_assignments)] fn wrap_message(message: &str, max_width: usize) -> String {
let max_width = max_width.max(1);
let mut out = String::new();
for (i, paragraph) in message.split('\n').enumerate() {
if i > 0 {
out.push('\n');
}
if paragraph.chars().count() <= max_width {
out.push_str(paragraph);
continue;
}
let mut line_len = 0usize;
let mut first_word_in_line = true;
for word in paragraph.split_whitespace() {
let word_chars: Vec<char> = word.chars().collect();
let word_len = word_chars.len();
if word_len > max_width {
if !first_word_in_line {
out.push('\n');
line_len = 0;
first_word_in_line = true;
}
let mut start = 0;
while start < word_len {
let end = (start + max_width).min(word_len);
if start > 0 {
out.push('\n');
}
for ch in &word_chars[start..end] {
out.push(*ch);
}
start = end;
}
line_len = (word_len % max_width).max(if word_len % max_width == 0 {
max_width
} else {
0
});
first_word_in_line = false;
continue;
}
let needed = if first_word_in_line {
word_len
} else {
line_len + 1 + word_len
};
if needed > max_width {
out.push('\n');
out.push_str(word);
line_len = word_len;
} else {
if !first_word_in_line {
out.push(' ');
line_len += 1;
}
out.push_str(word);
line_len += word_len;
}
first_word_in_line = false;
}
}
out
}
pub fn message_box_rect(
app: &mut Application,
bounds: Rect,
message: &str,
options: u16,
) -> CommandId {
let title = match options & 0x03 {
MF_WARNING => "\u{26A0} Warning",
MF_ERROR => "\u{274C} Error",
MF_INFORMATION => "\u{2139}\u{FE0F} Information",
MF_CONFIRMATION => "\u{2753} Confirm",
_ => "Message",
};
let mut dialog = Dialog::new(bounds, title);
let text_bottom = if has_buttons(options) {
bounds.height() - 4
} else {
bounds.height() - 2
};
let text_bounds = Rect::new(3, 1, bounds.width() - 2, text_bottom);
dialog.add(Box::new(StaticText::new(text_bounds, message)));
let button_configs = [
(MF_YES_BUTTON, " ~Y~es", CM_YES),
(MF_NO_BUTTON, " ~N~o", CM_NO),
(MF_OK_BUTTON, " ~O~K", CM_OK),
(MF_CANCEL_BUTTON, " ~C~ancel", CM_CANCEL),
];
let mut buttons = Vec::new();
for (flag, label, cmd) in &button_configs {
if options & flag != 0 {
buttons.push((*label, *cmd));
}
}
let button_y = bounds.height() - 4;
let total_width: usize = buttons.iter().map(|(label, _)| label.len() + 2).sum();
let mut x = (bounds.width_clamped() as usize - total_width) / 2;
let is_default = buttons.len() == 1 || (options & MF_OK_BUTTON != 0);
for (i, (label, cmd)) in buttons.iter().enumerate() {
let button_width = label.len() as i16;
let button_bounds = Rect::new(x as i16, button_y, x as i16 + button_width, button_y + 2);
let is_this_default = is_default && (i == 0 || *cmd == CM_OK);
dialog.add(Box::new(Button::new(
button_bounds,
label,
*cmd,
is_this_default,
)));
x += button_width as usize + 2;
}
if options & MF_AUTO_DISMISS != 0 {
dialog.set_auto_dismiss(
MESSAGE_BOX_AUTO_DISMISS_TIMEOUT,
auto_dismiss_command(&buttons.iter().map(|(_, cmd)| *cmd).collect::<Vec<_>>()),
);
}
dialog.set_initial_focus();
dialog.execute(app)
}
fn has_buttons(options: u16) -> bool {
options & (MF_YES_NO_CANCEL | MF_OK_BUTTON) != 0
}
fn auto_dismiss_command(commands: &[CommandId]) -> CommandId {
if commands.contains(&CM_OK) {
CM_OK
} else {
commands.first().copied().unwrap_or(CM_CANCEL)
}
}
pub fn message_box_ok(app: &mut Application, message: &str) -> CommandId {
message_box(app, message, MF_INFORMATION | MF_OK_BUTTON)
}
pub fn message_box_error(app: &mut Application, message: &str) -> CommandId {
message_box(app, message, MF_ERROR | MF_OK_BUTTON)
}
pub fn message_box_warning(app: &mut Application, message: &str) -> CommandId {
message_box(app, message, MF_WARNING | MF_OK_BUTTON)
}
pub fn confirmation_box(app: &mut Application, message: &str) -> CommandId {
message_box(app, message, MF_CONFIRMATION | MF_YES_NO_CANCEL)
}
pub fn confirmation_box_yes_no(app: &mut Application, message: &str) -> CommandId {
message_box(app, message, MF_CONFIRMATION | MF_YES_BUTTON | MF_NO_BUTTON)
}
pub fn confirmation_box_ok_cancel(app: &mut Application, message: &str) -> CommandId {
message_box(app, message, MF_CONFIRMATION | MF_OK_CANCEL)
}
pub fn input_box(
app: &mut Application,
title: &str,
label: &str,
initial: &str,
max_length: usize,
) -> Option<String> {
let label_len = label.len();
let width = (label_len + max_length + 12).min(60).max(30);
let height = 8;
let (screen_w, screen_h) = app.terminal.size();
let x = (screen_w - width as i16) / 2;
let y = (screen_h - height as i16) / 2;
let bounds = Rect::new(x, y, x + width as i16, y + height as i16);
input_box_rect(app, bounds, title, label, initial, max_length)
}
pub fn input_box_rect(
app: &mut Application,
bounds: Rect,
title: &str,
label: &str,
initial: &str,
max_length: usize,
) -> Option<String> {
let mut dialog = Dialog::new(bounds, title);
let data = Rc::new(RefCell::new(initial.to_string()));
let label_x = 2;
let label_width = label.len() as i16;
let label_bounds = Rect::new(label_x, 2, label_x + label_width, 3);
dialog.add(Box::new(Label::new(label_bounds, label)));
let input_x = label_x + label_width + 1;
let input_width = (bounds.width() - input_x - 3).min(max_length as i16 + 2);
let input_bounds = Rect::new(input_x, 2, input_x + input_width, 3);
dialog.add(Box::new(InputLine::new(
input_bounds,
max_length,
data.clone(),
)));
let button_y = bounds.height() - 4;
let ok_x = bounds.width() / 2 - 11;
let ok_bounds = Rect::new(ok_x, button_y, ok_x + 10, button_y + 2);
dialog.add(Box::new(Button::new(ok_bounds, " ~O~K", CM_OK, true)));
let cancel_x = ok_x + 12;
let cancel_bounds = Rect::new(cancel_x, button_y, cancel_x + 10, button_y + 2);
dialog.add(Box::new(Button::new(
cancel_bounds,
" ~C~ancel",
CM_CANCEL,
false,
)));
dialog.set_initial_focus();
let result = dialog.execute(app);
if result == CM_OK {
Some(data.borrow().clone())
} else {
None
}
}
pub fn search_box(app: &mut Application, title: &str) -> Option<String> {
let width = 50;
let height = 9;
let (screen_w, screen_h) = app.terminal.size();
let x = (screen_w - width) / 2;
let y = (screen_h - height) / 2;
let bounds = Rect::new(x, y, x + width, y + height);
let mut dialog = Dialog::new(bounds, title);
let data = Rc::new(RefCell::new(String::new()));
let label_bounds = Rect::new(2, 2, 20, 3);
dialog.add(Box::new(Label::new(label_bounds, "~F~ind:")));
let input_bounds = Rect::new(2, 3, width - 4, 4);
dialog.add(Box::new(InputLine::new(input_bounds, 100, data.clone())));
let ok_bounds = Rect::new(15, 5, 25, 7);
dialog.add(Box::new(Button::new(ok_bounds, " ~O~K", CM_OK, true)));
let cancel_bounds = Rect::new(27, 5, 37, 7);
dialog.add(Box::new(Button::new(
cancel_bounds,
" ~C~ancel",
CM_CANCEL,
false,
)));
dialog.set_initial_focus();
let result = dialog.execute(app);
if result == CM_OK {
let text = data.borrow().clone();
if !text.is_empty() { Some(text) } else { None }
} else {
None
}
}
pub fn search_replace_box(app: &mut Application, title: &str) -> Option<(String, String)> {
let width = 50;
let height = 13;
let (screen_w, screen_h) = app.terminal.size();
let x = (screen_w - width) / 2;
let y = (screen_h - height) / 2;
let bounds = Rect::new(x, y, x + width, y + height);
let mut dialog = Dialog::new(bounds, title);
let find_data = Rc::new(RefCell::new(String::new()));
let replace_data = Rc::new(RefCell::new(String::new()));
let label1_bounds = Rect::new(2, 2, 20, 3);
dialog.add(Box::new(Label::new(label1_bounds, "~F~ind:")));
let input1_bounds = Rect::new(2, 3, width - 4, 4);
dialog.add(Box::new(InputLine::new(
input1_bounds,
100,
find_data.clone(),
)));
let label2_bounds = Rect::new(2, 5, 20, 6);
dialog.add(Box::new(Label::new(label2_bounds, "~R~eplace with:")));
let input2_bounds = Rect::new(2, 6, width - 4, 7);
dialog.add(Box::new(InputLine::new(
input2_bounds,
100,
replace_data.clone(),
)));
let ok_bounds = Rect::new(15, 9, 25, 11);
dialog.add(Box::new(Button::new(ok_bounds, " ~O~K", CM_OK, true)));
let cancel_bounds = Rect::new(27, 9, 37, 11);
dialog.add(Box::new(Button::new(
cancel_bounds,
" ~C~ancel",
CM_CANCEL,
false,
)));
dialog.set_initial_focus();
let result = dialog.execute(app);
if result == CM_OK {
let find_text = find_data.borrow().clone();
if !find_text.is_empty() {
let replace_text = replace_data.borrow().clone();
Some((find_text, replace_text))
} else {
None
}
} else {
None
}
}
pub fn goto_line_box(app: &mut Application, title: &str) -> Option<usize> {
let width = 40;
let height = 8;
let (screen_w, screen_h) = app.terminal.size();
let x = (screen_w - width) / 2;
let y = (screen_h - height) / 2;
let bounds = Rect::new(x, y, x + width, y + height);
let mut dialog = Dialog::new(bounds, title);
let data = Rc::new(RefCell::new(String::new()));
let label_bounds = Rect::new(2, 2, 20, 3);
dialog.add(Box::new(Label::new(label_bounds, " ~L~ine number:")));
let input_bounds = Rect::new(2, 3, width - 4, 4);
dialog.add(Box::new(InputLine::new(input_bounds, 10, data.clone())));
let ok_bounds = Rect::new(10, 5, 20, 7);
dialog.add(Box::new(Button::new(ok_bounds, " ~O~K", CM_OK, true)));
let cancel_bounds = Rect::new(22, 5, 32, 7);
dialog.add(Box::new(Button::new(
cancel_bounds,
" ~C~ancel",
CM_CANCEL,
false,
)));
dialog.set_initial_focus();
let result = dialog.execute(app);
if result == CM_OK {
let text = data.borrow().clone();
text.parse::<usize>().ok()
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn auto_dismiss_prefers_ok_then_first_button() {
assert_eq!(auto_dismiss_command(&[CM_YES, CM_NO, CM_OK]), CM_OK);
assert_eq!(auto_dismiss_command(&[CM_YES, CM_NO]), CM_YES);
assert_eq!(auto_dismiss_command(&[]), CM_CANCEL);
}
#[test]
fn has_buttons_reflects_button_flags_only() {
assert!(has_buttons(MF_INFORMATION | MF_OK_BUTTON));
assert!(has_buttons(MF_CONFIRMATION | MF_YES_NO_CANCEL));
assert!(!has_buttons(MF_INFORMATION | MF_AUTO_DISMISS));
}
#[test]
fn auto_dismiss_flag_does_not_overlap_type_or_button_bits() {
assert_eq!(MF_AUTO_DISMISS & 0x0F, 0);
assert_eq!(MF_AUTO_DISMISS & (MF_YES_NO_CANCEL | MF_OK_BUTTON), 0);
}
#[test]
fn wraps_long_lines_at_word_boundaries() {
let out = wrap_message(
"Parse error: line 1:1: expected 'program', found identifier 'hello'",
30,
);
for line in out.lines() {
assert!(line.chars().count() <= 30, "line too long: {line:?}");
}
let original_words: Vec<&str> =
"Parse error: line 1:1: expected 'program', found identifier 'hello'"
.split_whitespace()
.collect();
let wrapped_words: Vec<&str> = out.split_whitespace().collect();
assert_eq!(original_words, wrapped_words);
}
#[test]
fn preserves_existing_newlines() {
let out = wrap_message("first paragraph\nsecond paragraph", 40);
assert_eq!(out, "first paragraph\nsecond paragraph");
}
#[test]
fn breaks_overlong_words_character_wise() {
let path = "averylongfilenameWithoutSpaces.txt";
let out = wrap_message(path, 10);
for line in out.lines() {
assert!(line.chars().count() <= 10);
}
let recombined: String = out.lines().collect();
assert_eq!(recombined, path);
}
}