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
172
173
174
175
176
177
178
//! Dirty-buffer guard shown before navigating away from an unsaved
//! document. Two buttons: Save / Discard. Escape (or the `esc`
//! close hint) abandons the navigation entirely. Carries the pending
//! navigation target across the modal's lifetime so the App can
//! resume it once the user picks a button.
use std::any::Any;
use crossterm::event::KeyEvent;
use ratatui::layout::Rect;
use ratatui::text::Line;
use ratatui::Frame;
use super::chrome::ModalChrome;
use super::types::{Modal, ModalKind, ModalOutcome, ModalRenderCtx};
use crate::app::nav::NavPending;
use crate::app::App;
use crate::ui::{ModalButton, ModalResponse};
pub struct DirtyGuardModal {
body: Vec<Line<'static>>,
buttons: Vec<ModalButton>,
chrome: ModalChrome,
/// The destination that was about to be followed when the guard
/// fired. Restored to the App via the close callback after Save
/// or Discard.
///
/// An `Option` purely so the close callback can take ownership of
/// it; a resolved guard is dropped immediately afterwards.
pending: Option<NavPending>,
/// The deep link's `#fragment`, when the link carried one. It
/// rides along with `pending` so answering the guard resumes the
/// *whole* link — dropping it here would land the reader at the top
/// of the target document instead of the section they clicked.
fragment: Option<String>,
}
impl DirtyGuardModal {
pub(crate) fn new(
current_display: &str,
pending: NavPending,
fragment: Option<String>,
) -> Self {
let body = vec![
Line::raw(format!("{current_display} has unsaved changes.")),
Line::raw(""),
Line::raw(format!(
"Opening {} will abandon them.",
pending.display_name()
)),
Line::raw(""),
Line::raw("What would you like to do?"),
];
Self {
body,
buttons: vec![ModalButton::new("Save"), ModalButton::new("Discard")],
chrome: ModalChrome::new(ModalKind::Warning, true),
pending: Some(pending),
fragment,
}
}
/// Map a resolved response to an outcome. Shared by the key and
/// click paths so a mouse click on a button behaves exactly like
/// pressing it.
///
/// The cursor-visibility calls read the cached doc dimensions from
/// `App` (`last_doc_height` / `last_doc_width`) rather than taking
/// them as parameters: `Modal::handle_click` has no live `DocDims`
/// to thread in, so both paths share the same App-sourced values.
///
/// They are a correction for the document the modal was covering,
/// and so are skipped on every branch where the navigation actually
/// happened: [`App::navigate_to_pending`] owns the new document's
/// viewport, and a deep link's fragment jump moves `scroll` without
/// moving the cursor (a freshly loaded editor starts in
/// `Mode::Preview`), so re-asserting visibility on top of it scrolls
/// the reader back to line 0 and throws the jump away.
fn resolve(&mut self, response: ModalResponse) -> ModalOutcome {
match response {
ModalResponse::Continue => ModalOutcome::Continue,
ModalResponse::Cancelled => ModalOutcome::CloseAnd(Box::new(move |app| {
let (h, w) = (app.last_doc_height, app.last_doc_width);
app.editor.ensure_cursor_visible(h, w);
})),
ModalResponse::ButtonPressed(idx) => {
let Some(pending) = self.pending.take() else {
return ModalOutcome::Close;
};
let fragment = self.fragment.take();
match idx {
0 => ModalOutcome::CloseAnd(Box::new(move |app| {
let (h, w) = (app.last_doc_height, app.last_doc_width);
if app.editor.buffer.path().is_some() {
match app.save_buffer() {
Ok(()) => {
if app.navigate_to_pending(pending, fragment, h, w) {
return;
}
}
Err(e) => {
tracing::warn!(target: "link", error = %e, "save-before-navigate failed");
}
}
} else {
// No path yet — prompt for one, then follow
// the pending navigation once it's written.
// The correction below still applies to *this*
// document, which is what stays on screen
// while the Save-as modal is open.
app.open_save_as_modal(Some(Box::new(move |app| {
let (h, w) = (app.last_doc_height, app.last_doc_width);
let _ = app.navigate_to_pending(pending, fragment, h, w);
})));
}
app.editor.ensure_cursor_visible(h, w);
})),
_ => ModalOutcome::CloseAnd(Box::new(move |app| {
app.editor.dirty = false;
let (h, w) = (app.last_doc_height, app.last_doc_width);
if !app.navigate_to_pending(pending, fragment, h, w) {
app.editor.ensure_cursor_visible(h, w);
}
})),
}
}
}
}
}
impl Modal for DirtyGuardModal {
fn render(&mut self, frame: &mut Frame<'_>, area: Rect, ctx: &ModalRenderCtx<'_>) {
self.chrome.render(
frame,
area,
ctx,
"Unsaved changes",
&self.body,
&self.buttons,
);
}
fn handle_key(
&mut self,
key: KeyEvent,
_app: &mut App,
_doc_height: usize,
_doc_width: usize,
) -> ModalOutcome {
let response = self.chrome.on_key(&key, self.buttons.len());
self.resolve(response)
}
fn handle_wheel(&mut self, delta: i32) {
self.chrome.on_wheel(delta);
}
fn handle_click(&mut self, col: u16, row: u16, _app: &mut App) -> ModalOutcome {
let response = self.chrome.on_click(col, row);
self.resolve(response)
}
fn kind(&self) -> ModalKind {
self.chrome.kind()
}
fn dismissable(&self) -> bool {
self.chrome.dismissable()
}
fn as_any(&self) -> &dyn Any {
self
}
fn as_any_mut(&mut self) -> &mut dyn Any {
self
}
}