use crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEventKind};
use ratatui::{
prelude::*,
style::{Color, Style},
widgets::*,
};
use crate::app::Action;
use crate::menus::{centered_popup, Menu, PopupSize};
use crate::mouse_input::MouseInput;
#[derive(Debug, Clone, Copy)]
pub enum MessageKind {
Info,
Warning,
Error,
}
#[derive(Debug, Clone)]
pub struct Message {
open: bool,
pub text: String,
pub rect: Rect,
pub kind: MessageKind,
offset: u16,
total_lines: u16,
visible_height: u16,
}
impl Message {
pub fn new(text: &str) -> Self {
Self {
open: true,
text: text.to_string(),
rect: Rect::default(),
kind: MessageKind::Info,
offset: 0,
total_lines: 0,
visible_height: 0,
}
}
pub fn new_disabled() -> Self {
Self {
open: false,
..Self::new("")
}
}
}
impl Message {
fn close(&mut self) {
self.open = false;
}
fn overflows(&self) -> bool {
self.total_lines > self.visible_height
}
fn max_offset(&self) -> u16 {
self.total_lines.saturating_sub(self.visible_height)
}
fn half_page(&self) -> u16 {
(self.visible_height / 2).max(1)
}
fn scroll_down(&mut self, lines: u16) {
self.offset = (self.offset + lines).min(self.max_offset());
}
fn scroll_up(&mut self, lines: u16) {
self.offset = self.offset.saturating_sub(lines);
}
}
impl Menu for Message {
fn is_open(&self) -> bool {
self.open
}
fn render(&mut self, f: &mut Frame, _area: &Rect) {
let color = match self.kind {
MessageKind::Info => Color::Blue,
MessageKind::Warning => Color::Yellow,
MessageKind::Error => Color::Red,
};
let title = match self.kind {
MessageKind::Info => "Info",
MessageKind::Warning => "Warning",
MessageKind::Error => "Error",
};
let block = Block::default()
.borders(Borders::ALL)
.title(title)
.border_type(BorderType::Rounded)
.title_top(Line::from("<Esc> to close").alignment(Alignment::Right));
let paragraph = Paragraph::new(self.text.clone())
.style(Style::default().fg(color))
.wrap(Wrap { trim: true })
.block(block.clone());
let text_area_width = (0.8 * (f.area().width as f32)) as u16;
let text_lines = paragraph.line_count(text_area_width) as u16;
let rect = centered_popup(
f.area(),
PopupSize::Fixed(text_area_width),
PopupSize::Fixed(text_lines.saturating_add(2)),
);
self.rect = rect;
self.total_lines = text_lines;
self.visible_height = rect.height.saturating_sub(2);
self.offset = self.offset.min(self.max_offset());
let paragraph = if self.overflows() {
let mut block = block;
if self.offset > 0 {
block = block.title_bottom(Line::from(" â–² ").alignment(Alignment::Left));
}
if self.offset < self.max_offset() {
block = block.title_bottom(Line::from(" â–¼ more ").alignment(Alignment::Right));
}
paragraph.scroll((self.offset, 0)).block(block)
} else {
paragraph
};
f.render_widget(Clear, rect);
f.render_widget(paragraph, rect);
}
fn input(&mut self, _action: &mut Action, key_event: KeyEvent) -> bool {
if !self.overflows() {
self.close();
return true;
}
match key_event.code {
KeyCode::Down | KeyCode::Char('j') => self.scroll_down(1),
KeyCode::Up | KeyCode::Char('k') => self.scroll_up(1),
KeyCode::PageDown | KeyCode::Char('d') => self.scroll_down(self.half_page()),
KeyCode::PageUp | KeyCode::Char('u') => self.scroll_up(self.half_page()),
KeyCode::Char('g') => self.offset = 0,
KeyCode::Char('G') => self.offset = self.max_offset(),
_ => self.close(),
}
true
}
fn mouse_input(&mut self, _action: &mut Action, mouse_input: &mut MouseInput) {
if let Some(mouse_event_kind) = mouse_input.kind() {
match mouse_event_kind {
MouseEventKind::Down(MouseButton::Left)
if !self.rect.contains(mouse_input.get_position()) || !self.overflows() =>
{
self.close();
}
MouseEventKind::ScrollDown => self.scroll_down(1),
MouseEventKind::ScrollUp => self.scroll_up(1),
_ => {}
}
mouse_input.click();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crossterm::event::{KeyModifiers, MouseEvent};
use ratatui::backend::TestBackend;
fn key(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
fn render(message: &mut Message, width: u16, height: u16) {
let backend = TestBackend::new(width, height);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|f| {
let area = f.area();
message.render(f, &area);
})
.unwrap();
}
fn long_message() -> Message {
let text = (1..=50)
.map(|i| format!("line {}", i))
.collect::<Vec<_>>()
.join("\n");
Message::new(&text)
}
#[test]
fn test_short_message_closes_on_any_key() {
let mut message = Message::new("all good");
render(&mut message, 40, 20);
assert!(!message.overflows());
let mut action = Action::None;
assert!(message.input(&mut action, key(KeyCode::Char('j'))));
assert!(!message.is_open());
}
#[test]
fn test_long_message_scrolls_and_closes_on_esc() {
let mut message = long_message();
render(&mut message, 30, 10);
assert!(message.overflows());
let mut action = Action::None;
message.input(&mut action, key(KeyCode::Char('j')));
message.input(&mut action, key(KeyCode::Down));
assert_eq!(message.offset, 2);
assert!(message.is_open());
message.input(&mut action, key(KeyCode::Char('k')));
assert_eq!(message.offset, 1);
message.input(&mut action, key(KeyCode::Esc));
assert!(!message.is_open());
}
#[test]
fn test_offset_clamps_at_top_and_bottom() {
let mut message = long_message();
render(&mut message, 30, 10);
let max = message.max_offset();
assert!(max > 0);
let mut action = Action::None;
message.input(&mut action, key(KeyCode::Char('G')));
assert_eq!(message.offset, max);
message.input(&mut action, key(KeyCode::Char('j')));
assert_eq!(message.offset, max);
message.input(&mut action, key(KeyCode::PageDown));
assert_eq!(message.offset, max);
message.input(&mut action, key(KeyCode::Char('g')));
assert_eq!(message.offset, 0);
message.input(&mut action, key(KeyCode::Char('k')));
assert_eq!(message.offset, 0);
assert!(message.is_open());
}
#[test]
fn test_half_page_scrolling() {
let mut message = long_message();
render(&mut message, 30, 10);
let half = message.half_page();
assert!(half >= 1);
let mut action = Action::None;
message.input(&mut action, key(KeyCode::Char('d')));
assert_eq!(message.offset, half);
message.input(&mut action, key(KeyCode::PageUp));
assert_eq!(message.offset, 0);
assert!(message.is_open());
}
#[test]
fn test_new_message_resets_offset() {
let mut message = long_message();
render(&mut message, 30, 10);
let mut action = Action::None;
message.input(&mut action, key(KeyCode::Char('G')));
assert!(message.offset > 0);
message = Message::new("fresh message");
assert_eq!(message.offset, 0);
assert!(message.is_open());
}
#[test]
fn test_mouse_scroll_and_clicks() {
let mut message = long_message();
render(&mut message, 30, 10);
let inside = Position::new(message.rect.x + 1, message.rect.y + 1);
let mouse = |kind: MouseEventKind, pos: Position| {
let mut input = MouseInput::new();
input.event = Some(MouseEvent {
kind,
column: pos.x,
row: pos.y,
modifiers: KeyModifiers::NONE,
});
input
};
let mut action = Action::None;
let mut input = mouse(MouseEventKind::ScrollDown, inside);
message.mouse_input(&mut action, &mut input);
assert_eq!(message.offset, 1);
assert!(input.handled);
let mut input = mouse(MouseEventKind::ScrollUp, inside);
message.mouse_input(&mut action, &mut input);
assert_eq!(message.offset, 0);
let mut input = mouse(MouseEventKind::Down(MouseButton::Left), inside);
message.mouse_input(&mut action, &mut input);
assert!(message.is_open());
let outside = Position::new(0, 0);
assert!(!message.rect.contains(outside));
let mut input = mouse(MouseEventKind::Down(MouseButton::Left), outside);
message.mouse_input(&mut action, &mut input);
assert!(!message.is_open());
let mut message = Message::new("all good");
render(&mut message, 40, 20);
let inside = Position::new(message.rect.x + 1, message.rect.y + 1);
let mut input = mouse(MouseEventKind::Down(MouseButton::Left), inside);
message.mouse_input(&mut action, &mut input);
assert!(!message.is_open());
}
}