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
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
//! First-run welcome modal: an adapter over [`crate::ui::WelcomeState`] that
//! holds the in-flight choices, routes the Theme button to
//! [`crate::app::App::open_theme_picker`], and persists on Save.
use std::any::Any;
use crossterm::event::KeyEvent;
use ratatui::layout::Rect;
use ratatui::Frame;
use super::types::{Modal, ModalKind, ModalOutcome, ModalRenderCtx};
use crate::app::App;
use crate::config::sections::VIM_HANDLER;
use crate::config::Config;
use crate::terminal::Capabilities;
use crate::ui::{WelcomeResponse, WelcomeState, WelcomeView};
pub struct WelcomeModal {
state: WelcomeState,
fingerprint: String,
}
impl WelcomeModal {
/// The first-run instance, or `None` once `config.editor.show_welcome` is
/// off.
pub fn from_state(caps: &Capabilities, config: &Config) -> Option<Self> {
if !config.editor.show_welcome {
return None;
}
// Not dismissable: on a first run Save is the only resolution, the
// "Show on next launch" toggle stands in for Cancel, and there is no
// prior choice to overwrite.
Some(Self::build(caps, config, false))
}
/// The on-demand instance (`Action::OpenWelcome`, the capabilities notice's
/// "Adjust settings"), built unconditionally and from the *live* `caps`, so
/// reopening after a terminal change re-derives `full_color` /
/// `image_capable`.
///
/// Dismissable, unlike the first-run instance: the user already has choices
/// on disk, and below truecolor `WelcomeState::new` forces images and
/// diagrams to `Never`. Without an `Esc` that writes nothing, merely
/// *looking* at this surface from a weaker terminal would overwrite the
/// settings chosen on a capable one.
pub fn new(caps: &Capabilities, config: &Config) -> Self {
Self::build(caps, config, true)
}
/// Park focus on Save, so a test needn't count Tab presses.
#[cfg(test)]
pub(crate) fn focus_save_for_test(&mut self) {
self.state.focused = crate::ui::WelcomeFocus::Save;
}
fn build(caps: &Capabilities, config: &Config, dismissable: bool) -> Self {
Self {
state: WelcomeState::new(
caps,
config.images.enabled,
config.images.remote_policy,
config.figures.enabled,
config.modal.handler == VIM_HANDLER,
config.editor.check_for_updates,
)
.with_dismissable(dismissable),
fingerprint: caps.fingerprint(),
}
}
}
impl Modal for WelcomeModal {
fn render(&mut self, frame: &mut Frame<'_>, area: Rect, ctx: &ModalRenderCtx<'_>) {
let view = WelcomeView {
theme: ctx.theme,
theme_name: &ctx.config.theme,
};
frame.render_stateful_widget(view, area, &mut self.state);
}
fn handle_key(
&mut self,
key: KeyEvent,
_app: &mut App,
_doc_height: usize,
_doc_width: usize,
) -> ModalOutcome {
match self.state.handle_key(&key) {
WelcomeResponse::Continue => ModalOutcome::Continue,
WelcomeResponse::OpenThemePicker => {
ModalOutcome::ContinueAnd(Box::new(|app| app.open_theme_picker()))
}
WelcomeResponse::Save => self.save_outcome(),
// Plain `Close`, deliberately: no config write, and no fingerprint
// seeding — an on-demand opening is not the first-visit notice and
// shouldn't silence it.
WelcomeResponse::Cancel => ModalOutcome::Close,
}
}
fn handle_wheel(&mut self, delta: i32) {
self.state.handle_wheel(delta);
}
fn handle_click(&mut self, col: u16, row: u16, _app: &mut App) -> ModalOutcome {
match self.state.handle_click(col, row) {
WelcomeResponse::Continue => ModalOutcome::Continue,
WelcomeResponse::OpenThemePicker => {
ModalOutcome::ContinueAnd(Box::new(|app| app.open_theme_picker()))
}
WelcomeResponse::Save => self.save_outcome(),
// As in `handle_key`: no config write, no fingerprint seeding.
WelcomeResponse::Cancel => ModalOutcome::Close,
}
}
fn kind(&self) -> ModalKind {
ModalKind::Normal
}
fn dismissable(&self) -> bool {
// Shared with the `Esc` arm and the rendered affordance.
self.state.dismissable
}
fn as_any(&self) -> &dyn Any {
self
}
fn as_any_mut(&mut self) -> &mut dyn Any {
self
}
}
impl WelcomeModal {
/// Build a `CloseAnd` outcome that writes the user's choices and persists.
///
/// The three image fields are written only on an image-capable terminal (an
/// image protocol *and* 24-bit color). Elsewhere the `Never` that
/// `WelcomeState::new` forces is a *session* fact — `App::media_renderable`
/// refuses to decode there whatever `config` says — and persisting it would
/// overwrite the `Always` chosen on a capable terminal sharing the same
/// dotfile. Same reasoning that keeps the indexed-color theme substitution
/// out of `Config::save`, and what makes the modal safe to reopen on
/// demand.
fn save_outcome(&self) -> ModalOutcome {
let images = self.state.images;
let remote = self.state.remote;
let diagrams = self.state.diagrams;
let use_vim = self.state.use_vim;
let check_for_updates = self.state.check_for_updates;
let dont_show_again = self.state.dont_show_again;
let image_capable = self.state.image_capable;
let fingerprint = self.fingerprint.clone();
ModalOutcome::CloseAnd(Box::new(move |app| {
// See the doc comment: forced-off values stay session-only.
if image_capable {
app.config.images.enabled = images;
app.config.images.remote_policy = remote;
app.config.figures.enabled = diagrams;
}
// Terminal-independent, and this both persists `modal.handler` and
// updates the running session's modal-editing state.
app.set_vim_enabled(use_vim);
// Likewise: a network preference, not a rendering capability.
app.config.editor.check_for_updates = check_for_updates;
app.config.editor.show_welcome = !dont_show_again;
// This modal already showed the capability summary, so seed the
// seen set or the standalone notice fires on the next launch. The
// fingerprint now lives in `state.toml`, so this outcome writes both
// files: the config settings above and the state below.
if !app.state.seen_terminal_fingerprints.contains(&fingerprint) {
app.state.seen_terminal_fingerprints.push(fingerprint);
}
app.save_config_with_flash("failed to persist welcome modal preferences");
// The state write is best-effort: `save_config_with_flash` already flashed for the
// config half, so a failure here only logs (a second flash would double up) and the
// notice simply re-fires next launch.
if let Err(e) = app.state.save() {
tracing::warn!(error = %e, "failed to persist welcome modal seen-terminal state");
}
app.dispatch_image_decodes();
app.editor.refresh_parsed();
}))
}
}
#[cfg(test)]
mod tests {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use super::*;
use crate::app::test_utils::make_app;
use crate::terminal::{Capabilities, ColorDepth};
use crate::ui::WelcomeFocus;
fn caps_full() -> Capabilities {
Capabilities {
color_depth: ColorDepth::TrueColor,
..Capabilities::default()
}
}
/// `make_app()` plus the config-isolation guard: Save runs a real
/// `Config::save`, which unguarded rewrites the developer's own config.
fn isolated_app() -> (crate::test_env::ConfigIsolation, crate::app::App) {
let iso = crate::test_env::config_isolation();
let app = make_app();
(iso, app)
}
/// Drive Save and run the resulting closure against `app`.
fn save(modal: &mut WelcomeModal, app: &mut crate::app::App) {
modal.focus_save_for_test();
let outcome = modal.handle_key(
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
app,
24,
80,
);
match outcome {
ModalOutcome::CloseAnd(f) => f(app),
_ => panic!("Save should close and persist"),
}
}
#[test]
fn save_persists_the_check_for_updates_choice() {
// `save_outcome` captures four adjacent `bool`s into one closure, so
// pin that this toggle lands in its own field.
let (_iso, mut app) = isolated_app();
assert!(app.config.editor.check_for_updates, "on by default");
let mut modal = WelcomeModal::new(&caps_full(), &app.config);
modal.state.focused = WelcomeFocus::CheckUpdates;
modal
.state
.handle_key(&KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
assert!(!modal.state.check_for_updates, "the row flipped");
// The vim toggle sits directly above it and must be unaffected.
let vim_before = app.config.modal.handler.clone();
save(&mut modal, &mut app);
assert!(!app.config.editor.check_for_updates);
assert_eq!(app.config.modal.handler, vim_before);
}
#[test]
fn save_leaves_the_update_check_on_when_untouched() {
let (_iso, mut app) = isolated_app();
let mut modal = WelcomeModal::new(&caps_full(), &app.config);
save(&mut modal, &mut app);
assert!(app.config.editor.check_for_updates);
}
#[test]
fn the_update_check_choice_survives_a_weak_terminal() {
// Images and figures are forced to `Never` below truecolor and not
// persisted; a network preference must be written regardless.
let (_iso, mut app) = isolated_app();
let caps = Capabilities {
color_depth: ColorDepth::Ansi256,
..Capabilities::minimal()
};
let mut modal = WelcomeModal::new(&caps, &app.config);
modal.state.focused = WelcomeFocus::CheckUpdates;
modal
.state
.handle_key(&KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
save(&mut modal, &mut app);
assert!(!app.config.editor.check_for_updates);
}
}