1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
//! Core trait, render context, and dispatch outcome for modals.
//!
//! The `Modal` trait abstracts every popup, prompt, and overlay that the
//! App can layer on top of the editor view. See [`super`] for the
//! `ModalStack` that owns these as `Box<dyn Modal>` and dispatches input.
use std::any::Any;
use std::time::Instant;
use crossterm::event::KeyEvent;
use ratatui::layout::Rect;
use ratatui::Frame;
use crate::app::App;
use crate::config::{Config, Theme};
pub use crate::ui::ModalKind;
/// Pure hit-test for the cached `esc` close-affordance rect. Shared by
/// every modal's `handle_click` so the bounds check has a single
/// definition.
pub fn esc_rect_hit(esc_rect: Option<ratatui::layout::Rect>, col: u16, row: u16) -> bool {
match esc_rect {
Some(r) => col >= r.x && col < r.x + r.width && row >= r.y && row < r.y + r.height,
None => false,
}
}
/// Helper: close a modal when `(col, row)` lands inside `esc_rect`,
/// otherwise keep it open. Concrete `Modal` impls call this from
/// their `handle_click` after delegating to the state's hit-test.
pub fn close_if_esc_clicked(
esc_rect: Option<ratatui::layout::Rect>,
col: u16,
row: u16,
) -> ModalOutcome {
if esc_rect_hit(esc_rect, col, row) {
ModalOutcome::Close
} else {
ModalOutcome::Continue
}
}
/// Read-only context handed to [`Modal::render`]. Centralises the
/// references every modal needs at draw time so individual modal
/// implementations don't have to pull them off `App` themselves.
pub struct ModalRenderCtx<'a> {
pub theme: &'a Theme,
pub config: &'a Config,
pub cursor_visible: bool,
}
/// Outcome of dispatching a key event to a modal.
///
/// Returned from [`Modal::handle_key`]. The dispatcher pops the modal
/// off the stack before invoking the handler — `Continue` re-pushes it,
/// `Close` drops it, `CloseAnd` drops it and runs the supplied callback
/// against the now-unborrowed `App`.
pub enum ModalOutcome {
/// Modal stays on the stack, no follow-up action.
Continue,
/// Modal stays on the stack; run the callback against `App` *after*
/// the modal is pushed back. Used by handlers that want to keep the
/// modal open while running a follow-up against the now-unborrowed
/// `App` (e.g. opening another modal on top of this one).
ContinueAnd(Box<dyn FnOnce(&mut App)>),
/// Modal is removed from the stack; no follow-up.
Close,
/// Modal is removed from the stack; run the callback against `App`.
/// Used for the common "close + dispatch" pattern (e.g. dirty-guard
/// closes then triggers a navigation).
CloseAnd(Box<dyn FnOnce(&mut App)>),
}
/// A modal popup or overlay that can sit on top of the editor view.
///
/// The topmost modal on the [`super::ModalStack`] absorbs all keyboard
/// and wheel input and renders last.
pub trait Modal {
/// Draw the modal into `area` of `frame`.
fn render(&mut self, frame: &mut Frame<'_>, area: Rect, ctx: &ModalRenderCtx<'_>);
/// Apply a keypress. Receives `&mut App` so handlers can flash
/// messages, persist config, dispatch follow-up actions, etc.
/// `doc_height` and `doc_width` are the document area dimensions
/// — needed by overlays that dispatch `Action`s through the same
/// pipeline as direct keystrokes.
fn handle_key(
&mut self,
key: KeyEvent,
app: &mut App,
doc_height: usize,
doc_width: usize,
) -> ModalOutcome;
/// Apply a bracketed paste. `text` is the raw clipboard payload as
/// crossterm delivered it; the modal is responsible for sanitizing
/// it (see [`crate::ui::sanitize_paste`]) and routing it to its
/// focused field. Default: no-op — modals without a text field
/// silently ignore pastes, matching the editor-less behaviour before
/// paste routing existed.
fn handle_paste(&mut self, _text: &str) -> ModalOutcome {
ModalOutcome::Continue
}
/// Apply a mouse-wheel delta. Default: no-op (modals without a
/// scrollable body ignore wheel events).
fn handle_wheel(&mut self, _delta: i32) {}
/// Apply a left-button mouse click at terminal coordinates
/// `(col, row)`. Receives `&mut App` (like [`Self::handle_key`]) so a
/// click can mutate config / flash / dispatch follow-ups directly — the
/// dispatcher pops the modal before calling this, so `self` and `app`
/// are disjoint borrows. Default: no-op. Modals that draw an `esc`
/// close button in their title bar override this to dismiss when the
/// click lands inside the cached hit-rect.
fn handle_click(&mut self, _col: u16, _row: u16, _app: &mut App) -> ModalOutcome {
ModalOutcome::Continue
}
/// The modal's visual urgency — drives title color. Default
/// [`ModalKind::Normal`]. Concrete modals store this as a field on
/// their struct and have this method, the `ModalView { kind }`
/// literal, and the constructor all read from `self.kind` so the
/// value can't drift between rendering and introspection.
#[allow(dead_code)]
fn kind(&self) -> ModalKind {
ModalKind::Normal
}
/// Whether `Esc` (and the `esc` close button) may dismiss this
/// modal. Returning `false` gates the modal: the user must
/// activate one of the explicit footer buttons. Default `true`.
/// Concrete modals store this as a field and route it to all three
/// consumers — `ModalView { dismissable }`, the
/// `state.handle_key(.., self.dismissable)` call, and this trait
/// method — so the rendered close hint, click hit-test, and Esc
/// behaviour stay in sync.
#[allow(dead_code)]
fn dismissable(&self) -> bool {
true
}
/// Earliest wall-clock instant at which this modal needs a redraw
/// to advance time-driven content (a spinner, a rotating tagline).
/// Aggregated into the run loop's blocking deadline via
/// [`super::ModalStack::next_deadline`]; the loop wakes then,
/// redraws, and the modal derives its new frame from elapsed time.
/// Default `None` — static modals never force a wake-up.
fn next_deadline(&self) -> Option<Instant> {
None
}
/// Type-erased self-reference, used by [`super::ModalStack`] for
/// type-aware operations (`remove_first<T>`, `contains<T>`). Every
/// implementation should be the trivial `fn as_any(&self) -> &dyn
/// Any { self }` — but the trait can't provide a default because
/// `Self: Any` isn't a supertrait bound (would force `'static` on
/// every implementor, which is true today but constrains future
/// adapter types).
fn as_any(&self) -> &dyn Any;
/// Mutable counterpart to [`Self::as_any`], used by
/// [`super::ModalStack::find_first_mut`] when a caller needs to
/// update a modal in place (e.g. refreshing the on-disk contents
/// carried by `DirtyConflictModal` after a new external write
/// arrives while a child reconciliation modal is open). Same
/// trivial body as `as_any`; kept required for the same reason
/// (avoiding a `'static` supertrait bound on `Modal`).
fn as_any_mut(&mut self) -> &mut dyn Any;
}