use crossterm::event::KeyEvent;
use ratatui::{buffer::Buffer, layout::Rect};
use rtcom_config::ModalStyle;
use rtcom_core::{LineEndingConfig, SerialConfig};
pub enum DialogOutcome {
Consumed,
Close,
Action(DialogAction),
Push(Box<dyn Dialog + Send>),
}
impl core::fmt::Debug for DialogOutcome {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
Self::Consumed => f.write_str("Consumed"),
Self::Close => f.write_str("Close"),
Self::Action(a) => f.debug_tuple("Action").field(a).finish(),
Self::Push(d) => f.debug_tuple("Push").field(&d.title()).finish(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DialogAction {
ApplyLive(SerialConfig),
ApplyAndSave(SerialConfig),
ApplyLineEndingsLive(LineEndingConfig),
ApplyLineEndingsAndSave(LineEndingConfig),
SetDtr(bool),
SetRts(bool),
SendBreak,
WriteProfile,
ReadProfile,
ApplyModalStyleLive(ModalStyle),
ApplyModalStyleAndSave(ModalStyle),
}
pub trait Dialog {
fn title(&self) -> &str;
fn render(&self, area: Rect, buf: &mut Buffer);
fn handle_key(&mut self, key: KeyEvent) -> DialogOutcome;
fn preferred_size(&self, outer: Rect) -> Rect {
centred_rect(outer, 30, 12)
}
}
#[must_use]
pub fn centred_rect(outer: Rect, width: u16, height: u16) -> Rect {
let clamped_w = width.min(outer.width);
let clamped_h = height.min(outer.height);
let x = outer.x + (outer.width.saturating_sub(clamped_w)) / 2;
let y = outer.y + (outer.height.saturating_sub(clamped_h)) / 2;
Rect {
x,
y,
width: clamped_w,
height: clamped_h,
}
}
pub struct ModalStack {
stack: Vec<Box<dyn Dialog + Send>>,
}
impl Default for ModalStack {
fn default() -> Self {
Self::new()
}
}
impl ModalStack {
#[must_use]
pub const fn new() -> Self {
Self { stack: Vec::new() }
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.stack.is_empty()
}
#[must_use]
pub fn depth(&self) -> usize {
self.stack.len()
}
#[must_use]
pub fn top(&self) -> Option<&(dyn Dialog + Send)> {
self.stack.last().map(AsRef::as_ref)
}
pub fn push(&mut self, dialog: Box<dyn Dialog + Send>) {
self.stack.push(dialog);
}
pub fn pop(&mut self) -> Option<Box<dyn Dialog + Send>> {
self.stack.pop()
}
pub fn clear(&mut self) {
self.stack.clear();
}
pub fn handle_key(&mut self, key: KeyEvent) -> DialogOutcome {
let Some(top) = self.stack.last_mut() else {
return DialogOutcome::Consumed;
};
let outcome = top.handle_key(key);
match outcome {
DialogOutcome::Close => {
self.stack.pop();
DialogOutcome::Close
}
DialogOutcome::Push(dialog) => {
self.stack.push(dialog);
DialogOutcome::Consumed
}
other => other,
}
}
}
#[cfg(test)]
#[allow(
clippy::doc_markdown,
clippy::unnecessary_literal_bound,
reason = "test code mirrors the T10 spec verbatim"
)]
mod tests {
use super::*;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::{buffer::Buffer, layout::Rect};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
struct CountingDialog {
count: Arc<AtomicUsize>,
}
impl Dialog for CountingDialog {
fn title(&self) -> &str {
"counting"
}
fn render(&self, _area: Rect, _buf: &mut Buffer) {}
fn handle_key(&mut self, _key: KeyEvent) -> DialogOutcome {
self.count.fetch_add(1, Ordering::SeqCst);
DialogOutcome::Consumed
}
}
struct ClosingDialog;
impl Dialog for ClosingDialog {
fn title(&self) -> &str {
"closing"
}
fn render(&self, _area: Rect, _buf: &mut Buffer) {}
fn handle_key(&mut self, key: KeyEvent) -> DialogOutcome {
if key.code == KeyCode::Esc {
DialogOutcome::Close
} else {
DialogOutcome::Consumed
}
}
}
#[test]
fn modal_stack_starts_empty() {
let stack = ModalStack::new();
assert!(stack.is_empty());
assert!(stack.top().is_none());
}
#[test]
fn modal_stack_push_pop() {
let mut stack = ModalStack::new();
stack.push(Box::new(ClosingDialog));
assert!(!stack.is_empty());
assert_eq!(stack.top().map(Dialog::title), Some("closing"));
let popped = stack.pop();
assert!(popped.is_some());
assert!(stack.is_empty());
}
#[test]
fn modal_stack_routes_keys_to_top() {
let count = Arc::new(AtomicUsize::new(0));
let mut stack = ModalStack::new();
stack.push(Box::new(CountingDialog {
count: count.clone(),
}));
let _ = stack.handle_key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE));
assert_eq!(count.load(Ordering::SeqCst), 1);
}
#[test]
fn modal_stack_close_outcome_pops_top() {
let mut stack = ModalStack::new();
stack.push(Box::new(ClosingDialog));
stack.push(Box::new(ClosingDialog));
assert_eq!(stack.depth(), 2);
let _ = stack.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert_eq!(stack.depth(), 1);
}
#[test]
fn modal_stack_handle_key_on_empty_is_noop() {
let mut stack = ModalStack::new();
let outcome = stack.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert!(matches!(outcome, DialogOutcome::Consumed));
}
#[test]
fn dialog_action_apply_live_carries_config() {
use rtcom_core::SerialConfig;
let cfg = SerialConfig::default();
let action = DialogAction::ApplyLive(cfg);
match action {
DialogAction::ApplyLive(_) => {}
_ => panic!("wrong variant"),
}
}
#[test]
fn dialog_preferred_size_default_is_30x12_centred() {
let d = ClosingDialog;
let outer = Rect {
x: 0,
y: 0,
width: 80,
height: 24,
};
let pref = d.preferred_size(outer);
assert_eq!(pref.width, 30);
assert_eq!(pref.height, 12);
assert_eq!(pref.x, 25);
assert_eq!(pref.y, 6);
}
#[test]
fn centred_rect_clips_to_outer() {
let outer = Rect {
x: 0,
y: 0,
width: 20,
height: 5,
};
let r = centred_rect(outer, 30, 12);
assert_eq!(r.width, 20);
assert_eq!(r.height, 5);
assert_eq!(r.x, 0);
assert_eq!(r.y, 0);
}
}