Skip to main content

kimun_notes/app_screen/
onboarding.rs

1//! The Onboarding screen — Kimün's guided setup. One screen, six steps
2//! (welcome → workspace → nerd fonts → theme → editor backend → summary),
3//! rendered as a centered dialog floating over a blank backdrop so it reads
4//! as a setup assistant running *for* the app rather than a screen *of* the
5//! app.
6//!
7//! Choices are staged in a local `Draft` and committed only when the user
8//! finishes the summary step (`AppEvent::OnboardingFinished`); Esc discards.
9//! Theme and nerd-font selections preview live on the dialog itself.
10
11use async_trait::async_trait;
12use ratatui::Frame;
13use ratatui::crossterm::event::{KeyCode, KeyEvent};
14use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect};
15use ratatui::style::{Modifier, Style};
16use ratatui::widgets::{Block, Borders, Clear, List, ListItem, Paragraph, Wrap};
17
18use crate::app_screen::{AppScreen, ScreenKind};
19use crate::components::dir_browser::FileBrowserState;
20use crate::components::event_state::EventState;
21use crate::components::events::{AppEvent, AppTx, InputEvent, ScreenEvent};
22use crate::components::single_line_input::SingleLineInput;
23use crate::settings::config_migration::CURRENT_CONFIG_VERSION;
24use crate::settings::icons::Icons;
25use crate::settings::themes::Theme;
26use crate::settings::{AppSettings, EditorBackendSetting, SharedSettings};
27
28// ── Step enum ────────────────────────────────────────────────────────────────
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum OnbStep {
32    Welcome,
33    Workspace,
34    NerdFonts,
35    Theme,
36    Backend,
37    Updates,
38    Summary,
39}
40
41impl OnbStep {
42    pub(crate) const ORDER: [OnbStep; 7] = [
43        OnbStep::Welcome,
44        OnbStep::Workspace,
45        OnbStep::NerdFonts,
46        OnbStep::Theme,
47        OnbStep::Backend,
48        OnbStep::Updates,
49        OnbStep::Summary,
50    ];
51
52    pub(crate) fn index(self) -> usize {
53        Self::ORDER.iter().position(|s| *s == self).unwrap_or(0)
54    }
55
56    fn next(self) -> Option<OnbStep> {
57        Self::ORDER.get(self.index() + 1).copied()
58    }
59
60    fn prev(self) -> Option<OnbStep> {
61        self.index().checked_sub(1).map(|i| Self::ORDER[i])
62    }
63}
64
65// ── Draft ────────────────────────────────────────────────────────────────────
66
67/// Staged choices — applied to shared settings only on Finish.
68struct Draft {
69    /// `Some((name, path))` only on first run; rerun never mutates workspaces.
70    workspace: Option<(String, std::path::PathBuf)>,
71    use_nerd_fonts: bool,
72    update_check: bool,
73    theme_name: String,
74    editor_backend: EditorBackendSetting,
75}
76
77// ── Overlay ───────────────────────────────────────────────────────────────────
78
79/// Modal sub-states layered over the current step.
80enum OnbOverlay {
81    None,
82    Browser(FileBrowserState),
83    NewDir(FileBrowserState, SingleLineInput),
84    ConfirmQuit,
85    ConfirmDiscard,
86}
87
88// ── BACKENDS constant ─────────────────────────────────────────────────────────
89
90// Descriptions stay short enough for one dialog row — wrapped continuation
91// lines would break out of the marker column.
92const BACKENDS: [(EditorBackendSetting, &str, &str); 3] = [
93    (
94        EditorBackendSetting::Plain,
95        "plain",
96        "simple editing, no modes (the default)",
97    ),
98    (
99        EditorBackendSetting::Vim,
100        "vim",
101        "built-in vim emulation, no external programs",
102    ),
103    (
104        EditorBackendSetting::Nvim,
105        "nvim",
106        "your real Neovim embedded; requires nvim",
107    ),
108];
109
110// ── Banner ────────────────────────────────────────────────────────────────────
111
112/// ASCII-art "Kimün" wordmark for the welcome step. All rows are the same
113/// width so per-line centering keeps the letters aligned as one block; the
114/// double quote in the top row is the u's diaeresis.
115const KIMUN_BANNER: [&str; 5] = [
116    r#" _  ___           _   _       "#,
117    r#"| |/ (_)_ __ ___ (_) (_)_ __  "#,
118    r#"| ' /| | '_ ` _ \| | | | '_ \ "#,
119    r#"| . \| | | | | | | |_| | | | |"#,
120    r#"|_|\_\_|_| |_| |_|\__,_|_| |_|"#,
121];
122
123/// Column span of the ü diaeresis `(_) (_)` in the banner (rows 0/1).
124const UMLAUT_COLS: std::ops::Range<usize> = 17..24;
125const UMLAUT_DOTS: &str = "(_) (_)";
126/// Elastic in-between frame: the dots compress before launch and on landing.
127const UMLAUT_SQUASH: &str = "<_> <_>";
128/// Deepest compression: flattened circles at the bottom of the bounce.
129const UMLAUT_SQUASH_FULL: &str = "-=- -=-";
130
131/// One animation frame of the ü diaeresis bounce.
132#[derive(Clone, Copy, PartialEq, Eq, Debug)]
133enum UmlautFrame {
134    /// Dots sit in their home cells on banner row 1.
135    Rest,
136    /// Dots squash on row 1 — anticipation before a hop, recoil after one.
137    Squash,
138    /// Dots fully squashed on row 1 — peak compression at launch and impact.
139    SquashFull,
140    /// Dots are airborne on banner row 0.
141    Up,
142}
143
144/// The ü dots hop up one row once, compressing through a squash frame into a
145/// fully-squashed frame before launch, and back through both on landing, then
146/// rest. Frame durations are uneven to fake physics: squashes are short snaps
147/// (50–100 ms) while the airborne frame lingers (300 ms) so the dots seem to
148/// launch fast and hang at the apex. Timed in 50 ms micro-slots over a
149/// 36-slot (1.8 s) cycle.
150/// Width of one micro-slot in ms and the slot count per full cycle.
151const UMLAUT_SLOT_MS: u128 = 50;
152const UMLAUT_CYCLE_SLOTS: u128 = 26;
153
154/// Maps a micro-slot index (0..UMLAUT_CYCLE_SLOTS) to its bounce phase. Pure
155/// over the slot so it can be tested without depending on wall-clock timing.
156fn umlaut_frame_for_slot(slot: u128) -> UmlautFrame {
157    match slot {
158        0..=2 => UmlautFrame::Rest,
159        15..=17 | 23..=25 => UmlautFrame::Squash,
160        18..=22 => UmlautFrame::SquashFull,
161        _ => UmlautFrame::Up, // 3..=14
162    }
163}
164
165fn umlaut_frame(elapsed: std::time::Duration) -> UmlautFrame {
166    umlaut_frame_for_slot(elapsed.as_millis() / UMLAUT_SLOT_MS % UMLAUT_CYCLE_SLOTS)
167}
168
169// ── Screen struct ─────────────────────────────────────────────────────────────
170
171pub struct OnboardingScreen {
172    settings: SharedSettings,
173    theme: Theme,
174    icons: Icons,
175    pub(crate) step: OnbStep,
176    pub(crate) first_run: bool,
177    draft: Draft,
178    themes: Vec<Theme>,
179    theme_idx: usize,
180    backend_idx: usize,
181    nvim_available: bool,
182    name_input: SingleLineInput,
183    name_editing: bool,
184    /// User explicitly committed a name edit — directory picks stop
185    /// overwriting the name with the basename suggestion.
186    name_edited: bool,
187    overlay: OnbOverlay,
188    flash: Option<String>,
189    /// When the screen mounted — drives the umlaut bounce animation frame.
190    started: std::time::Instant,
191    /// Redraw ticker for the welcome-step animation; aborted on exit.
192    anim: Option<tokio::task::JoinHandle<()>>,
193}
194
195// ── Constructor ───────────────────────────────────────────────────────────────
196
197impl OnboardingScreen {
198    pub fn new(settings: SharedSettings) -> Self {
199        let s = settings.read().unwrap();
200        let first_run = s.resolve_workspace_path().is_none();
201        let themes = s.theme_list();
202        let current_theme_name = s.effective_theme_name();
203        let theme_idx = themes
204            .iter()
205            .position(|t| t.name == current_theme_name)
206            .unwrap_or(0);
207        let draft = Draft {
208            workspace: if first_run {
209                AppSettings::default_workspace_suggestion().map(|p| (suggest_name(&p), p))
210            } else {
211                None
212            },
213            use_nerd_fonts: s.use_nerd_fonts,
214            update_check: s.update_check(),
215            // Keep the configured name even when its theme file is missing
216            // from theme_list() — the draft must never silently rewrite a
217            // setting the user didn't touch (dirty()/finish() would persist
218            // the substitution). Same for a configured-but-unavailable nvim
219            // backend below: the row renders disabled, the setting survives.
220            theme_name: current_theme_name,
221            editor_backend: s.editor_backend,
222        };
223        let backend_idx = BACKENDS
224            .iter()
225            .position(|(b, _, _)| *b == draft.editor_backend)
226            .unwrap_or(0);
227        let theme = s.get_theme();
228        let icons = Icons::new(draft.use_nerd_fonts);
229        let nvim_available = nvim_on_path(s.nvim_path.as_deref());
230        let name_input = SingleLineInput::with_value(
231            draft
232                .workspace
233                .as_ref()
234                .map(|(n, _)| n.clone())
235                .unwrap_or_default(),
236        );
237        drop(s);
238        Self {
239            settings,
240            theme,
241            icons,
242            step: OnbStep::Welcome,
243            first_run,
244            draft,
245            themes,
246            theme_idx,
247            backend_idx,
248            nvim_available,
249            name_input,
250            name_editing: false,
251            name_edited: false,
252            overlay: OnbOverlay::None,
253            flash: None,
254            started: std::time::Instant::now(),
255            anim: None,
256        }
257    }
258}
259
260// ── Free functions ────────────────────────────────────────────────────────────
261
262/// Derive a workspace name from a directory: basename, lowercased. Falls back
263/// to "notes" when the basename is empty or invalid.
264fn suggest_name(path: &std::path::Path) -> String {
265    let name = path
266        .file_name()
267        .map(|n| n.to_string_lossy().to_lowercase())
268        .unwrap_or_default();
269    if kimun_core::nfs::filename::validate_filename(&name).is_ok() && !name.is_empty() {
270        name
271    } else {
272        "notes".to_string()
273    }
274}
275
276/// `nvim` reachable? Explicit configured path wins; otherwise scan PATH.
277fn nvim_on_path(configured: Option<&std::path::Path>) -> bool {
278    if let Some(p) = configured {
279        return p.is_file();
280    }
281    let Some(paths) = std::env::var_os("PATH") else {
282        return false;
283    };
284    let exe = if cfg!(windows) { "nvim.exe" } else { "nvim" };
285    std::env::split_paths(&paths).any(|d| d.join(exe).is_file())
286}
287
288// ── AppScreen impl ────────────────────────────────────────────────────────────
289
290#[async_trait]
291impl AppScreen for OnboardingScreen {
292    async fn on_enter(&mut self, tx: &AppTx) {
293        // 50 ms ticker matches umlaut_frame's micro-slots so short squash
294        // frames aren't skipped, but a Redraw is only sent when the frame
295        // actually changes (~4 per cycle) — the other steps don't animate
296        // and must not run the render pipeline 20×/s for identical frames.
297        let tx2 = tx.clone();
298        let started = self.started;
299        self.anim = Some(tokio::spawn(async move {
300            let mut ticker = tokio::time::interval(std::time::Duration::from_millis(50));
301            let mut last = umlaut_frame(started.elapsed());
302            loop {
303                ticker.tick().await;
304                let frame = umlaut_frame(started.elapsed());
305                if frame != last {
306                    last = frame;
307                    if tx2.send(AppEvent::Redraw).is_err() {
308                        break;
309                    }
310                }
311            }
312        }));
313    }
314
315    async fn on_exit(&mut self, _tx: &AppTx) {
316        if let Some(handle) = self.anim.take() {
317            handle.abort();
318        }
319    }
320
321    fn handle_input(&mut self, event: &InputEvent, tx: &AppTx) -> EventState {
322        let InputEvent::Key(key) = event else {
323            return EventState::NotConsumed;
324        };
325        if self.handle_overlay_key(key, tx) {
326            tx.send(AppEvent::Redraw).ok();
327            return EventState::Consumed;
328        }
329        match key.code {
330            // While the name field is in edit mode, Esc must exit the edit
331            // (handled by workspace_step_key in Task 5), not cancel the flow.
332            KeyCode::Esc if !self.name_editing => self.on_cancel(tx),
333            KeyCode::Left | KeyCode::BackTab if !self.name_editing => self.go_prev(),
334            KeyCode::Right | KeyCode::Tab if !self.name_editing => {
335                if self.step == OnbStep::Workspace
336                    && self.first_run
337                    && self.draft.workspace.is_none()
338                {
339                    self.flash = Some("choose a directory first (b to browse)".to_string());
340                } else {
341                    self.go_next();
342                }
343            }
344            _ => self.handle_step_key(key, tx),
345        }
346        tx.send(AppEvent::Redraw).ok();
347        EventState::Consumed
348    }
349
350    fn render(&mut self, f: &mut Frame) {
351        self.render_dialog(f);
352    }
353
354    fn get_kind(&self) -> ScreenKind {
355        ScreenKind::Onboarding
356    }
357}
358
359// ── Internal helpers ──────────────────────────────────────────────────────────
360
361impl OnboardingScreen {
362    fn go_next(&mut self) {
363        if let Some(n) = self.step.next() {
364            self.step = n;
365            self.name_editing = false;
366            self.flash = None;
367        }
368    }
369
370    fn go_prev(&mut self) {
371        if let Some(p) = self.step.prev() {
372            self.step = p;
373            self.name_editing = false;
374            self.flash = None;
375        }
376    }
377
378    fn dirty(&self) -> bool {
379        let s = self.settings.read().unwrap();
380        let effective_theme = s.effective_theme_name();
381        s.use_nerd_fonts != self.draft.use_nerd_fonts
382            || s.editor_backend != self.draft.editor_backend
383            || s.update_check() != self.draft.update_check
384            || (!self.draft.theme_name.is_empty() && effective_theme != self.draft.theme_name)
385    }
386
387    fn on_cancel(&mut self, tx: &AppTx) {
388        if self.first_run {
389            self.overlay = OnbOverlay::ConfirmQuit;
390        } else if self.dirty() {
391            self.overlay = OnbOverlay::ConfirmDiscard;
392        } else {
393            tx.send(AppEvent::OpenScreen(ScreenEvent::Start)).ok();
394        }
395    }
396
397    fn handle_step_key(&mut self, key: &KeyEvent, tx: &AppTx) {
398        match self.step {
399            OnbStep::Welcome => self.welcome_step_key(key),
400            OnbStep::Workspace => self.workspace_step_key(key),
401            OnbStep::NerdFonts => self.nerd_fonts_step_key(key),
402            OnbStep::Theme => self.theme_step_key(key),
403            OnbStep::Backend => self.backend_step_key(key),
404            OnbStep::Updates => self.updates_step_key(key),
405            OnbStep::Summary => self.summary_step_key(key, tx),
406        }
407    }
408
409    fn welcome_step_key(&mut self, key: &KeyEvent) {
410        // Only Enter advances; Left/Right/Esc are caught by handle_input.
411        if key.code == KeyCode::Enter {
412            self.go_next();
413        }
414    }
415
416    fn nerd_fonts_step_key(&mut self, key: &KeyEvent) {
417        match key.code {
418            KeyCode::Up => self.set_nerd_fonts(false),
419            KeyCode::Down => self.set_nerd_fonts(true),
420            KeyCode::Char(' ') => {
421                let next = !self.draft.use_nerd_fonts;
422                self.set_nerd_fonts(next);
423            }
424            KeyCode::Enter => self.go_next(),
425            _ => {}
426        }
427    }
428
429    fn set_nerd_fonts(&mut self, on: bool) {
430        self.draft.use_nerd_fonts = on;
431        self.icons = Icons::new(on); // live preview
432    }
433
434    fn updates_step_key(&mut self, key: &KeyEvent) {
435        // Mirrors the Nerd Fonts step: Up/Down pick, Space toggles, Enter advances.
436        match key.code {
437            KeyCode::Up => self.draft.update_check = true,
438            KeyCode::Down => self.draft.update_check = false,
439            KeyCode::Char(' ') => self.draft.update_check = !self.draft.update_check,
440            KeyCode::Enter => self.go_next(),
441            _ => {}
442        }
443    }
444
445    fn workspace_step_key(&mut self, key: &KeyEvent) {
446        if !self.first_run {
447            if key.code == KeyCode::Enter {
448                self.go_next();
449            }
450            return;
451        }
452        if self.name_editing {
453            match key.code {
454                // Esc abandons the edit, keeping the previous name.
455                KeyCode::Esc => {
456                    self.name_editing = false;
457                    self.flash = None;
458                }
459                KeyCode::Enter => {
460                    let name = self.name_input.value().trim().to_lowercase();
461                    if name.is_empty()
462                        || kimun_core::nfs::filename::validate_filename(&name).is_err()
463                    {
464                        self.flash = Some("invalid workspace name".to_string());
465                        return;
466                    }
467                    if let Some((n, _)) = self.draft.workspace.as_mut() {
468                        *n = name;
469                        self.name_edited = true;
470                    }
471                    self.name_editing = false;
472                    self.flash = None;
473                }
474                _ => {
475                    let _ = self.name_input.handle_key(key);
476                }
477            }
478            return;
479        }
480        match key.code {
481            KeyCode::Enter => {
482                if self.draft.workspace.is_some() {
483                    self.go_next();
484                } else {
485                    self.flash = Some("choose a directory first (b to browse)".to_string());
486                }
487            }
488            KeyCode::Char('b') => {
489                let start = self
490                    .draft
491                    .workspace
492                    .as_ref()
493                    .and_then(|(_, p)| p.parent().map(|p| p.to_path_buf()))
494                    .or_else(|| {
495                        AppSettings::default_workspace_suggestion()
496                            .and_then(|p| p.parent().map(|p| p.to_path_buf()))
497                    })
498                    .or_else(|| {
499                        kimun_core::system::browse_root()
500                            .ok()
501                            .map(|p| p.into_path_buf())
502                    })
503                    .unwrap_or_else(|| std::path::PathBuf::from("/"));
504                self.overlay = OnbOverlay::Browser(FileBrowserState::load(start));
505            }
506            KeyCode::Char('e') => {
507                // Without a workspace entry there is nothing to write the
508                // name into — committing would silently discard it.
509                if let Some((n, _)) = self.draft.workspace.as_ref() {
510                    self.name_input.set_value(n.clone());
511                    self.name_editing = true;
512                } else {
513                    self.flash = Some("choose a directory first (b to browse)".to_string());
514                }
515            }
516            _ => {}
517        }
518    }
519
520    fn handle_overlay_key(&mut self, key: &KeyEvent, tx: &AppTx) -> bool {
521        use ratatui::crossterm::event::KeyModifiers;
522        match std::mem::replace(&mut self.overlay, OnbOverlay::None) {
523            OnbOverlay::None => false,
524            OnbOverlay::Browser(mut fb) => {
525                let offset = if fb.has_parent { 1 } else { 0 };
526                let total = fb.entries.len() + offset;
527                match key.code {
528                    KeyCode::Esc => {}
529                    KeyCode::Up if total > 0 => {
530                        let cur = fb.list_state.selected().unwrap_or(0);
531                        fb.list_state.select(Some((cur + total - 1) % total));
532                        self.overlay = OnbOverlay::Browser(fb);
533                    }
534                    KeyCode::Down if total > 0 => {
535                        let cur = fb.list_state.selected().unwrap_or(0);
536                        fb.list_state.select(Some((cur + 1) % total));
537                        self.overlay = OnbOverlay::Browser(fb);
538                    }
539                    KeyCode::Left => {
540                        fb.go_up();
541                        self.overlay = OnbOverlay::Browser(fb);
542                    }
543                    KeyCode::Enter if key.modifiers.contains(KeyModifiers::CONTROL) => {
544                        self.confirm_directory(fb.current_path.clone());
545                    }
546                    KeyCode::Right | KeyCode::Enter => {
547                        if let Some(idx) = fb.list_state.selected() {
548                            if fb.has_parent && idx == 0 {
549                                fb.go_up();
550                            } else if let Some(entry) = fb.entries.get(idx - offset).cloned() {
551                                fb.navigate_into(entry);
552                            }
553                        }
554                        self.overlay = OnbOverlay::Browser(fb);
555                    }
556                    KeyCode::Char('c') if key.modifiers.is_empty() => {
557                        self.confirm_directory(fb.current_path.clone());
558                    }
559                    KeyCode::Char('n') if key.modifiers.is_empty() => {
560                        self.overlay = OnbOverlay::NewDir(fb, SingleLineInput::new());
561                    }
562                    // Shift is fine (uppercase jump targets); Ctrl/Alt chords
563                    // must not be mistaken for plain letters.
564                    KeyCode::Char(c)
565                        if !key
566                            .modifiers
567                            .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) =>
568                    {
569                        fb.jump_to_char(c);
570                        self.overlay = OnbOverlay::Browser(fb);
571                    }
572                    _ => self.overlay = OnbOverlay::Browser(fb),
573                }
574                true
575            }
576            OnbOverlay::NewDir(mut fb, mut input) => {
577                match key.code {
578                    KeyCode::Esc => self.overlay = OnbOverlay::Browser(fb),
579                    KeyCode::Enter => match fb.create_dir(input.value()) {
580                        Ok(_) => self.overlay = OnbOverlay::Browser(fb),
581                        Err(e) => {
582                            self.flash = Some(format!("cannot create directory: {e}"));
583                            self.overlay = OnbOverlay::NewDir(fb, input);
584                        }
585                    },
586                    _ => {
587                        let _ = input.handle_key(key);
588                        self.overlay = OnbOverlay::NewDir(fb, input);
589                    }
590                }
591                true
592            }
593            OnbOverlay::ConfirmQuit => {
594                match key.code {
595                    KeyCode::Enter => {
596                        tx.send(AppEvent::Quit).ok();
597                    }
598                    KeyCode::Esc => {}
599                    _ => self.overlay = OnbOverlay::ConfirmQuit,
600                }
601                true
602            }
603            OnbOverlay::ConfirmDiscard => {
604                match key.code {
605                    KeyCode::Enter => {
606                        tx.send(AppEvent::OpenScreen(ScreenEvent::Start)).ok();
607                    }
608                    KeyCode::Esc => {}
609                    _ => self.overlay = OnbOverlay::ConfirmDiscard,
610                }
611                true
612            }
613        }
614    }
615
616    fn confirm_directory(&mut self, chosen: std::path::PathBuf) {
617        // A name the user explicitly edited survives re-picking the
618        // directory; only un-edited names follow the basename suggestion.
619        let name = match &self.draft.workspace {
620            Some((n, _)) if self.name_edited => n.clone(),
621            _ => suggest_name(&chosen),
622        };
623        self.draft.workspace = Some((name, chosen));
624        self.flash = None;
625    }
626}
627
628// ── Rendering ─────────────────────────────────────────────────────────────────
629
630impl OnboardingScreen {
631    /// Dialog rect: percentage-sized but capped so big terminals don't get a
632    /// sparse, oversized box. Content is designed for ~66×24.
633    fn dialog_rect(area: Rect) -> Rect {
634        let w = (area.width as u32 * 62 / 100).min(66) as u16;
635        let h = (area.height as u32 * 75 / 100).min(24) as u16;
636        crate::components::fixed_centered_rect(
637            w.max(40).min(area.width),
638            h.max(14).min(area.height),
639            area,
640        )
641    }
642
643    fn render_dialog(&mut self, f: &mut Frame) {
644        // Backdrop: a flat, empty surface in the preview theme. Nothing of
645        // the app shows through — the dialog is the only thing on screen.
646        f.render_widget(Block::default().style(self.theme.base_style()), f.area());
647
648        let area = Self::dialog_rect(f.area());
649        f.render_widget(Clear, area);
650        let block = Block::default()
651            .title(" Kimün Setup ")
652            .borders(Borders::ALL)
653            .border_style(Style::default().fg(self.theme.accent.to_ratatui()))
654            .style(self.theme.base_style());
655        let inner = block.inner(area);
656        f.render_widget(block, area);
657
658        let rows = Layout::default()
659            .direction(Direction::Vertical)
660            .constraints([
661                Constraint::Length(2), // header: step title + progress
662                Constraint::Min(0),    // step body (inset)
663                Constraint::Length(1), // flash line
664                Constraint::Length(1), // key hints
665            ])
666            .split(inner);
667
668        self.render_header(f, rows[0]);
669
670        // Add horizontal breathing room around the body.
671        let body_area = Rect {
672            x: rows[1].x + 2,
673            width: rows[1].width.saturating_sub(4),
674            ..rows[1]
675        };
676
677        match self.step {
678            OnbStep::Welcome => self.render_welcome_step(f, body_area),
679            OnbStep::Workspace => self.render_workspace_step(f, body_area),
680            OnbStep::NerdFonts => self.render_nerd_fonts_step(f, body_area),
681            OnbStep::Theme => self.render_theme_step(f, body_area),
682            OnbStep::Backend => self.render_backend_step(f, body_area),
683            OnbStep::Updates => self.render_updates_step(f, body_area),
684            OnbStep::Summary => self.render_summary_step(f, body_area),
685        }
686        if let Some(msg) = &self.flash {
687            f.render_widget(
688                Paragraph::new(format!(" {msg}"))
689                    .style(Style::default().fg(self.theme.accent.to_ratatui())),
690                rows[2],
691            );
692        }
693        self.render_hints(f, rows[3]);
694        self.render_overlay(f);
695    }
696
697    fn render_header(&self, f: &mut Frame, area: Rect) {
698        let idx = self.step.index();
699        let dots: String = (0..OnbStep::ORDER.len())
700            .map(|i| if i == idx { "●" } else { "○" })
701            .collect::<Vec<_>>()
702            .join(" ");
703        let title = match self.step {
704            OnbStep::Welcome => "Welcome",
705            OnbStep::Workspace => "Workspace",
706            OnbStep::NerdFonts => "Nerd Fonts",
707            OnbStep::Theme => "Theme",
708            OnbStep::Backend => "Editor Backend",
709            OnbStep::Updates => "Updates",
710            OnbStep::Summary => "Summary",
711        };
712
713        // Split the 2-row header area into title line and progress line.
714        let header_rows = Layout::default()
715            .direction(Direction::Vertical)
716            .constraints([Constraint::Length(1), Constraint::Length(1)])
717            .split(area);
718
719        f.render_widget(
720            Paragraph::new(title).alignment(Alignment::Center).style(
721                Style::default()
722                    .fg(self.theme.accent.to_ratatui())
723                    .add_modifier(Modifier::BOLD),
724            ),
725            header_rows[0],
726        );
727        f.render_widget(
728            Paragraph::new(format!("{dots}   {} / {}", idx + 1, OnbStep::ORDER.len()))
729                .alignment(Alignment::Center)
730                .style(Style::default().fg(self.theme.fg_secondary.to_ratatui())),
731            header_rows[1],
732        );
733    }
734
735    fn render_hints(&self, f: &mut Frame, area: Rect) {
736        let hints = match self.step {
737            OnbStep::Welcome => " Enter: start  ←/→: steps  Esc: cancel",
738            OnbStep::Workspace if self.first_run => {
739                " Enter: accept  b: browse  e: edit name  ←/→: steps  Esc: cancel"
740            }
741            OnbStep::Summary => " Enter: finish  ←: back  Esc: cancel",
742            _ => " ↑/↓: select  Enter/→: next  ←: back  Esc: cancel",
743        };
744        f.render_widget(
745            Paragraph::new(hints)
746                .alignment(Alignment::Center)
747                .style(Style::default().fg(self.theme.fg_secondary.to_ratatui())),
748            area,
749        );
750    }
751
752    fn render_welcome_step(&mut self, f: &mut Frame, area: Rect) {
753        let rows = Layout::default()
754            .direction(Direction::Vertical)
755            .constraints([
756                Constraint::Length(KIMUN_BANNER.len() as u16 + 1),
757                Constraint::Min(0),
758            ])
759            .split(area);
760
761        // Bounce frame: squash the ü dots in place, or hop them from row 1 up
762        // into row 0, clearing their home cells. All-ASCII rows, so
763        // byte-indexed ranges are safe.
764        let (row0, row1) = match umlaut_frame(self.started.elapsed()) {
765            UmlautFrame::Rest => (KIMUN_BANNER[0].to_string(), KIMUN_BANNER[1].to_string()),
766            UmlautFrame::Squash => {
767                let mut row1 = KIMUN_BANNER[1].to_string();
768                row1.replace_range(UMLAUT_COLS, UMLAUT_SQUASH);
769                (KIMUN_BANNER[0].to_string(), row1)
770            }
771            UmlautFrame::SquashFull => {
772                // The flattened dots draw their own top border with `=`, so
773                // the row-0 `_` caps above them disappear too.
774                let mut row0 = KIMUN_BANNER[0].to_string();
775                row0.replace_range(UMLAUT_COLS, "       ");
776                let mut row1 = KIMUN_BANNER[1].to_string();
777                row1.replace_range(UMLAUT_COLS, UMLAUT_SQUASH_FULL);
778                (row0, row1)
779            }
780            UmlautFrame::Up => {
781                let mut row0 = KIMUN_BANNER[0].to_string();
782                row0.replace_range(UMLAUT_COLS, UMLAUT_DOTS);
783                let mut row1 = KIMUN_BANNER[1].to_string();
784                row1.replace_range(UMLAUT_COLS, "       ");
785                (row0, row1)
786            }
787        };
788        let banner: Vec<ratatui::text::Line> = std::iter::once(row0)
789            .chain(std::iter::once(row1))
790            .chain(KIMUN_BANNER[2..].iter().map(|r| r.to_string()))
791            .map(|row| {
792                ratatui::text::Line::styled(
793                    row,
794                    Style::default().fg(self.theme.accent.to_ratatui()),
795                )
796            })
797            .collect();
798        f.render_widget(
799            Paragraph::new(banner)
800                .style(self.theme.base_style())
801                .alignment(Alignment::Center),
802            rows[0],
803        );
804
805        let text = "Welcome to Kimün!\n\
806            \n\
807            This guided setup walks you through the essentials —\n\
808            where your notes live, how the app looks, and which\n\
809            editor setup drives it. One setting per step, each\n\
810            explained as you go.\n\
811            \n\
812            Nothing is applied until you confirm the final summary,\n\
813            and everything stays adjustable later in Preferences.\n\
814            \n\
815            Press Enter to begin.";
816        f.render_widget(
817            Paragraph::new(text)
818                .style(self.theme.base_style())
819                .alignment(Alignment::Center)
820                .wrap(Wrap { trim: false }),
821            rows[1],
822        );
823    }
824
825    fn render_workspace_step(&mut self, f: &mut Frame, area: Rect) {
826        let rows = Layout::default()
827            .direction(Direction::Vertical)
828            .constraints([Constraint::Length(5), Constraint::Min(0)])
829            .split(area);
830
831        // No hard line breaks — the dialog width varies with the terminal,
832        // so wrapping is left to the Paragraph.
833        let desc = if self.first_run {
834            "A workspace is where your notes live: one directory on disk, \
835             holding plain Markdown files. Kimün indexes it for search and \
836             links. You can add more workspaces later in Preferences."
837        } else {
838            "Your workspaces. This step is informational — add, rename or \
839             remove workspaces in Preferences (palette: \"preferences\")."
840        };
841        f.render_widget(
842            Paragraph::new(desc)
843                .style(self.theme.base_style())
844                .wrap(Wrap { trim: true }),
845            rows[0],
846        );
847
848        if self.first_run {
849            let (name, path) = match &self.draft.workspace {
850                Some((n, p)) => (n.clone(), p.display().to_string()),
851                None => ("—".to_string(), "no directory chosen (press b)".to_string()),
852            };
853            let body = Layout::default()
854                .direction(Direction::Vertical)
855                .constraints([Constraint::Length(1), Constraint::Length(1)])
856                .split(rows[1]);
857            f.render_widget(
858                Paragraph::new(format!("  Directory:  {path}")).style(self.theme.base_style()),
859                body[0],
860            );
861            if self.name_editing {
862                f.render_widget(
863                    Paragraph::new("  Name:       ").style(self.theme.base_style()),
864                    body[1],
865                );
866                self.name_input.render(
867                    f,
868                    body[1],
869                    Style::default()
870                        .fg(self.theme.accent.to_ratatui())
871                        .add_modifier(Modifier::BOLD),
872                    14,
873                    true,
874                );
875            } else {
876                f.render_widget(
877                    Paragraph::new(format!("  Name:       {name}")).style(self.theme.base_style()),
878                    body[1],
879                );
880            }
881        } else {
882            let s = self.settings.read().unwrap();
883            let current = s.current_workspace_name().unwrap_or_default();
884            let mut items: Vec<ListItem> = Vec::new();
885            if let Some(wc) = s.workspace_config.as_ref() {
886                for (name, entry) in &wc.workspaces {
887                    let marker = if *name == current { "●" } else { " " };
888                    items.push(ListItem::new(format!(
889                        " {marker} {name}  —  {}",
890                        entry.effective_path().display()
891                    )));
892                }
893            }
894            drop(s);
895            f.render_widget(List::new(items).style(self.theme.base_style()), rows[1]);
896        }
897    }
898
899    fn render_nerd_fonts_step(&mut self, f: &mut Frame, area: Rect) {
900        let nerd = Icons::new(true);
901        let ascii = Icons::new(false);
902        // Each icon sits in a fixed display-width cell so the nerd glyphs
903        // line up column-for-column with their ASCII counterparts.
904        const CELL: usize = 6;
905        let sample = |i: &Icons| {
906            [i.directory, i.note, i.journal, i.info, i.rail_find]
907                .iter()
908                .map(|icon| {
909                    let w = unicode_width::UnicodeWidthStr::width(*icon);
910                    format!("{icon}{}", " ".repeat(CELL.saturating_sub(w)))
911                })
912                .collect::<String>()
913        };
914        let selected = self.draft.use_nerd_fonts;
915        let mark = |sel: bool| if sel { "▶" } else { " " };
916        let text = format!(
917            "Nerd Fonts are patched terminal fonts with extra icons. If the \
918             bottom sample row below shows icons (not boxes or question \
919             marks), your terminal supports them.\n\n\
920             {} Plain ASCII      {}\n\
921             {} Nerd Fonts       {}\n",
922            mark(!selected),
923            sample(&ascii),
924            mark(selected),
925            sample(&nerd),
926        );
927        f.render_widget(
928            // trim: false — the sample rows align on a leading marker column
929            // that trimming would eat for the unselected row.
930            Paragraph::new(text)
931                .style(self.theme.base_style())
932                .wrap(Wrap { trim: false }),
933            area,
934        );
935    }
936    fn render_updates_step(&mut self, f: &mut Frame, area: Rect) {
937        let selected = self.draft.update_check;
938        let mark = |sel: bool| if sel { "▶" } else { " " };
939        let text = format!(
940            "kimün can check GitHub for a newer release on startup and show a \
941             small notice in the editor footer. Nothing is sent — it only reads \
942             the public releases list. You can change this anytime in \
943             Preferences.\n\n\
944             {} On   — check for updates on startup\n\
945             {} Off  — never check\n",
946            mark(selected),
947            mark(!selected),
948        );
949        f.render_widget(
950            Paragraph::new(text)
951                .style(self.theme.base_style())
952                .wrap(Wrap { trim: false }),
953            area,
954        );
955    }
956
957    fn theme_step_key(&mut self, key: &KeyEvent) {
958        match key.code {
959            KeyCode::Up if self.theme_idx > 0 => {
960                self.theme_idx -= 1;
961                self.apply_theme_preview();
962            }
963            KeyCode::Down if self.theme_idx + 1 < self.themes.len() => {
964                self.theme_idx += 1;
965                self.apply_theme_preview();
966            }
967            KeyCode::Enter => self.go_next(),
968            _ => {}
969        }
970    }
971
972    fn apply_theme_preview(&mut self) {
973        if let Some(t) = self.themes.get(self.theme_idx) {
974            self.draft.theme_name = t.name.clone();
975            self.theme = t.clone().adapt_to_terminal();
976        }
977    }
978
979    fn render_theme_step(&mut self, f: &mut Frame, area: Rect) {
980        let rows = Layout::default()
981            .direction(Direction::Vertical)
982            .constraints([Constraint::Length(3), Constraint::Min(0)])
983            .split(area);
984        f.render_widget(
985            Paragraph::new(
986                "The color theme for the whole app. The dialog previews your\n\
987                 selection live. Custom themes: ~/.config/kimun/themes/*.toml",
988            )
989            .style(self.theme.base_style())
990            .wrap(Wrap { trim: true }),
991            rows[0],
992        );
993        let items: Vec<ListItem> = self
994            .themes
995            .iter()
996            .map(|t| ListItem::new(format!("  {}", t.name)))
997            .collect();
998        let mut state = ratatui::widgets::ListState::default();
999        state.select(Some(self.theme_idx));
1000        let list = List::new(items)
1001            .style(self.theme.base_style())
1002            .highlight_symbol("▶ ")
1003            .highlight_style(Style::default().add_modifier(Modifier::BOLD));
1004        f.render_stateful_widget(list, rows[1], &mut state);
1005    }
1006
1007    fn backend_step_key(&mut self, key: &KeyEvent) {
1008        match key.code {
1009            KeyCode::Up => self.move_backend(-1),
1010            KeyCode::Down => self.move_backend(1),
1011            KeyCode::Enter => self.go_next(),
1012            _ => {}
1013        }
1014    }
1015
1016    fn move_backend(&mut self, delta: isize) {
1017        let len = BACKENDS.len() as isize;
1018        let mut idx = self.backend_idx as isize;
1019        loop {
1020            idx += delta;
1021            if idx < 0 || idx >= len {
1022                return; // stay at the edges
1023            }
1024            let (backend, _, _) = BACKENDS[idx as usize];
1025            if backend == EditorBackendSetting::Nvim && !self.nvim_available {
1026                continue; // hop over the disabled entry
1027            }
1028            self.backend_idx = idx as usize;
1029            self.draft.editor_backend = backend;
1030            return;
1031        }
1032    }
1033
1034    fn render_backend_step(&mut self, f: &mut Frame, area: Rect) {
1035        let mut lines = vec![
1036            "Which engine drives the note editor. One config axis, three".to_string(),
1037            "values — changeable anytime in Preferences.".to_string(),
1038            String::new(),
1039        ];
1040        for (i, (backend, name, desc)) in BACKENDS.iter().enumerate() {
1041            let mark = if i == self.backend_idx { "▶" } else { " " };
1042            let disabled = *backend == EditorBackendSetting::Nvim && !self.nvim_available;
1043            if disabled {
1044                lines.push(format!(
1045                    "{mark} {name}  (nvim not found — install it or set its path in Preferences)"
1046                ));
1047            } else {
1048                lines.push(format!("{mark} {name}  —  {desc}"));
1049            }
1050        }
1051        f.render_widget(
1052            Paragraph::new(lines.join("\n"))
1053                .style(self.theme.base_style())
1054                .wrap(Wrap { trim: false }),
1055            area,
1056        );
1057    }
1058
1059    fn summary_step_key(&mut self, key: &KeyEvent, tx: &AppTx) {
1060        if key.code == KeyCode::Enter {
1061            self.finish(tx);
1062        }
1063    }
1064
1065    /// Commit the draft: create + register the workspace (first run only),
1066    /// apply fonts/theme/backend, persist, and hand off to the App loop, which rebuilds the vault.
1067    fn finish(&mut self, tx: &AppTx) {
1068        // Filesystem work happens before the settings lock is taken — a slow
1069        // mount must not stall settings readers for its duration.
1070        let workspace = if self.first_run {
1071            let Some((name, path)) = self.draft.workspace.clone() else {
1072                self.flash = Some("no workspace configured".to_string());
1073                self.step = OnbStep::Workspace;
1074                return;
1075            };
1076            let existed = path.is_dir();
1077            if let Err(e) = kimun_core::system::create_dir(&path) {
1078                // `create_dir` can fail *after* creating the directory — it
1079                // canonicalizes as a second step — so this needs the same
1080                // rollback as the `add_workspace` failure below. Non-recursive,
1081                // so a pre-existing or non-empty directory is never touched.
1082                if !existed {
1083                    kimun_core::system::remove_empty_dir(&path).ok();
1084                }
1085                self.flash = Some(format!("cannot create {}: {e}", path.display()));
1086                self.step = OnbStep::Workspace;
1087                return;
1088            }
1089            Some((name, path, existed))
1090        } else {
1091            None
1092        };
1093        let mut s = self.settings.write().unwrap();
1094        if let Some((name, path, existed)) = workspace {
1095            let wc = s
1096                .workspace_config
1097                .get_or_insert_with(crate::settings::workspace_config::WorkspaceConfig::new_empty);
1098            if let Err(e) = wc.add_workspace(name, path.clone()) {
1099                drop(s);
1100                // Roll back a directory this run created. Non-recursive, so a
1101                // pre-existing or non-empty directory is never touched.
1102                if !existed {
1103                    kimun_core::system::remove_empty_dir(&path).ok();
1104                }
1105                self.flash = Some(e.to_string());
1106                self.step = OnbStep::Workspace;
1107                return;
1108            }
1109            s.config_version = CURRENT_CONFIG_VERSION;
1110        }
1111        s.use_nerd_fonts = self.draft.use_nerd_fonts;
1112        s.editor_backend = self.draft.editor_backend;
1113        s.workspace_config
1114            .get_or_insert_with(crate::settings::workspace_config::WorkspaceConfig::new_empty)
1115            .global
1116            .update_check = self.draft.update_check;
1117        s.set_theme(self.draft.theme_name.clone());
1118        if let Err(e) = s.save_to_disk() {
1119            tracing::error!("failed to save settings after onboarding: {e}");
1120        }
1121        drop(s);
1122        tx.send(AppEvent::OnboardingFinished).ok();
1123    }
1124
1125    fn render_summary_step(&mut self, f: &mut Frame, area: Rect) {
1126        let s = self.settings.read().unwrap();
1127        let workspace_line = match (&self.draft.workspace, self.first_run) {
1128            (Some((name, path)), _) => format!("{name}  —  {}", path.display()),
1129            (None, false) => {
1130                let n = s.current_workspace_name().unwrap_or_default();
1131                format!("{n}  (unchanged)")
1132            }
1133            (None, true) => "NOT CONFIGURED — go back to step 1".to_string(),
1134        };
1135        drop(s);
1136        let (_, backend_name, _) = BACKENDS[self.backend_idx];
1137        let kv_rows = [
1138            format!("Workspace:       {workspace_line}"),
1139            format!(
1140                "Nerd fonts:      {}",
1141                if self.draft.use_nerd_fonts {
1142                    "on"
1143                } else {
1144                    "off"
1145                }
1146            ),
1147            format!("Theme:           {}", self.draft.theme_name),
1148            format!("Editor backend:  {backend_name}"),
1149        ];
1150        // Pad the key-value rows to a common width so per-line centering
1151        // keeps their columns aligned as one block.
1152        let block_width = kv_rows.iter().map(|l| l.chars().count()).max().unwrap_or(0);
1153        let mut text = String::from(
1154            "Review your choices. Enter applies them all at once;\n\
1155             everything stays adjustable in Preferences.\n\n",
1156        );
1157        for row in &kv_rows {
1158            let pad = block_width - row.chars().count();
1159            text.push_str(row);
1160            text.extend(std::iter::repeat_n(' ', pad));
1161            text.push('\n');
1162        }
1163        text.push_str("\n[ Press Enter to finish ]");
1164        f.render_widget(
1165            Paragraph::new(text)
1166                .style(self.theme.base_style())
1167                .alignment(Alignment::Center)
1168                .wrap(Wrap { trim: false }),
1169            area,
1170        );
1171    }
1172
1173    fn render_overlay(&mut self, f: &mut Frame) {
1174        match &mut self.overlay {
1175            OnbOverlay::None => {}
1176            OnbOverlay::Browser(fb) | OnbOverlay::NewDir(fb, _) => {
1177                let area = crate::components::centered_rect(55, 70, f.area());
1178                f.render_widget(Clear, area);
1179                let block = Block::default()
1180                    .title(" Choose Notes Directory ")
1181                    .borders(Borders::ALL)
1182                    .border_style(Style::default().fg(self.theme.accent.to_ratatui()))
1183                    .style(self.theme.base_style());
1184                let inner = block.inner(area);
1185                f.render_widget(block, area);
1186                let rows = Layout::default()
1187                    .direction(Direction::Vertical)
1188                    .constraints([
1189                        Constraint::Length(1),
1190                        Constraint::Min(0),
1191                        Constraint::Length(1),
1192                    ])
1193                    .split(inner);
1194                f.render_widget(
1195                    Paragraph::new(fb.current_path.to_string_lossy().into_owned())
1196                        .style(self.theme.base_style()),
1197                    rows[0],
1198                );
1199                let mut items: Vec<ListItem> = Vec::new();
1200                if fb.has_parent {
1201                    items.push(ListItem::new("  ../"));
1202                }
1203                for e in &fb.entries {
1204                    items.push(ListItem::new(format!(
1205                        "  {}/",
1206                        e.file_name().unwrap_or_default().to_string_lossy()
1207                    )));
1208                }
1209                let list = List::new(items)
1210                    .highlight_symbol("▶ ")
1211                    .highlight_style(Style::default().add_modifier(Modifier::BOLD));
1212                f.render_stateful_widget(list, rows[1], &mut fb.list_state);
1213                f.render_widget(
1214                    Paragraph::new("Enter: open  c: choose  n: new dir  Esc: back")
1215                        .style(self.theme.base_style()),
1216                    rows[2],
1217                );
1218            }
1219            OnbOverlay::ConfirmQuit => {
1220                render_confirm_box(
1221                    f,
1222                    &self.theme,
1223                    " Quit Setup? ",
1224                    "No workspace is configured — Kimün cannot run\nwithout one. Quit anyway?\n\n  Enter: quit    Esc: back to setup",
1225                );
1226            }
1227            OnbOverlay::ConfirmDiscard => {
1228                render_confirm_box(
1229                    f,
1230                    &self.theme,
1231                    " Discard Changes? ",
1232                    "Your setup changes have not been applied.\n\n  Enter: discard    Esc: back to setup",
1233                );
1234            }
1235        }
1236        // NewDir input prompt floats over the browser — second borrow scope.
1237        if let OnbOverlay::NewDir(_, input) = &mut self.overlay {
1238            let prompt = crate::components::fixed_centered_rect(40, 3, f.area());
1239            f.render_widget(Clear, prompt);
1240            let theme = &self.theme;
1241            let pblock = Block::default()
1242                .title(" New Directory ")
1243                .borders(Borders::ALL)
1244                .border_style(Style::default().fg(theme.accent.to_ratatui()))
1245                .style(theme.base_style());
1246            let pinner = pblock.inner(prompt);
1247            f.render_widget(pblock, prompt);
1248            input.render(f, pinner, theme.base_style(), 0, true);
1249        }
1250    }
1251}
1252
1253// ── Free rendering helpers ────────────────────────────────────────────────────
1254
1255fn render_confirm_box(f: &mut Frame, theme: &Theme, title: &str, body: &str) {
1256    let area = crate::components::fixed_centered_rect(52, 7, f.area());
1257    f.render_widget(Clear, area);
1258    let block = Block::default()
1259        .title(title.to_string())
1260        .borders(Borders::ALL)
1261        .border_style(Style::default().fg(theme.accent.to_ratatui()))
1262        .style(theme.base_style());
1263    let inner = block.inner(area);
1264    f.render_widget(block, area);
1265    f.render_widget(
1266        Paragraph::new(body.to_string())
1267            .style(theme.base_style())
1268            .wrap(Wrap { trim: false }),
1269        inner,
1270    );
1271}
1272
1273// ── Tests ─────────────────────────────────────────────────────────────────────
1274
1275#[cfg(test)]
1276mod tests {
1277    use super::*;
1278    use crate::settings::AppSettings;
1279    use crate::test_support::key_event;
1280    use ratatui::crossterm::event::KeyCode;
1281    use std::sync::{Arc, RwLock};
1282    use tokio::sync::mpsc::unbounded_channel;
1283
1284    fn shared_defaults() -> crate::settings::SharedSettings {
1285        Arc::new(RwLock::new(AppSettings::default()))
1286    }
1287
1288    fn shared_with_workspace() -> crate::settings::SharedSettings {
1289        use crate::settings::workspace_config::WorkspaceConfig;
1290        let mut s = AppSettings::default();
1291        let mut wc = WorkspaceConfig::new_empty();
1292        wc.add_workspace(
1293            "notes".to_string(),
1294            std::env::temp_dir().join("kimun_onb_ws"),
1295        )
1296        .unwrap();
1297        s.workspace_config = Some(wc);
1298        Arc::new(RwLock::new(s))
1299    }
1300
1301    #[test]
1302    fn first_run_detected_from_missing_workspace() {
1303        let screen = OnboardingScreen::new(shared_defaults());
1304        assert!(screen.first_run);
1305        let screen = OnboardingScreen::new(shared_with_workspace());
1306        assert!(!screen.first_run);
1307    }
1308
1309    #[test]
1310    fn kind_is_onboarding_and_starts_on_welcome_step() {
1311        let screen = OnboardingScreen::new(shared_defaults());
1312        assert_eq!(screen.get_kind() as u8, ScreenKind::Onboarding as u8);
1313        assert_eq!(screen.step, OnbStep::Welcome);
1314    }
1315
1316    #[test]
1317    fn left_right_navigate_steps_within_bounds() {
1318        let (tx, _rx) = unbounded_channel();
1319        // Rerun screen starts at Welcome; Right advances to Workspace.
1320        let mut screen = OnboardingScreen::new(shared_with_workspace());
1321        assert_eq!(screen.step, OnbStep::Welcome);
1322        screen.handle_input(&key_event(KeyCode::Right), &tx);
1323        assert_eq!(screen.step, OnbStep::Workspace);
1324        screen.handle_input(&key_event(KeyCode::Left), &tx);
1325        assert_eq!(screen.step, OnbStep::Welcome);
1326        screen.handle_input(&key_event(KeyCode::Left), &tx);
1327        assert_eq!(screen.step, OnbStep::Welcome);
1328    }
1329
1330    #[test]
1331    fn renders_dialog_with_progress_header() {
1332        let mut screen = OnboardingScreen::new(shared_with_workspace());
1333        let backend = ratatui::backend::TestBackend::new(100, 32);
1334        let mut terminal = ratatui::Terminal::new(backend).unwrap();
1335        terminal.draw(|f| screen.render(f)).unwrap();
1336        let flat: String = terminal
1337            .backend()
1338            .buffer()
1339            .content
1340            .iter()
1341            .map(|c| c.symbol())
1342            .collect();
1343        assert!(flat.contains("Kimün Setup"));
1344        assert!(flat.contains("1 / 7"));
1345    }
1346
1347    #[test]
1348    fn first_run_workspace_step_prefills_suggestion() {
1349        let screen = OnboardingScreen::new(shared_defaults());
1350        let (name, path) = screen.draft.workspace.clone().expect("suggestion expected");
1351        assert!(path.ends_with("kimun-notes"));
1352        assert_eq!(name, "kimun-notes");
1353    }
1354
1355    #[test]
1356    fn first_run_enter_on_valid_workspace_advances() {
1357        let (tx, _rx) = unbounded_channel();
1358        let mut screen = OnboardingScreen::new(shared_defaults());
1359        // Start at Welcome — advance past it first.
1360        screen.step = OnbStep::Workspace;
1361        screen.handle_input(&key_event(KeyCode::Enter), &tx);
1362        assert_eq!(screen.step, OnbStep::NerdFonts);
1363    }
1364
1365    #[test]
1366    fn first_run_right_blocked_without_workspace_draft() {
1367        let (tx, _rx) = unbounded_channel();
1368        let mut screen = OnboardingScreen::new(shared_defaults());
1369        screen.step = OnbStep::Workspace;
1370        screen.draft.workspace = None;
1371        screen.handle_input(&key_event(KeyCode::Right), &tx);
1372        assert_eq!(
1373            screen.step,
1374            OnbStep::Workspace,
1375            "cannot advance without a workspace"
1376        );
1377        assert!(screen.flash.is_some());
1378    }
1379
1380    #[test]
1381    fn rerun_workspace_step_is_informational_and_lists_workspaces() {
1382        let (tx, _rx) = unbounded_channel();
1383        let mut screen = OnboardingScreen::new(shared_with_workspace());
1384        assert!(screen.draft.workspace.is_none());
1385        screen.step = OnbStep::Workspace;
1386        screen.handle_input(&key_event(KeyCode::Enter), &tx);
1387        assert_eq!(screen.step, OnbStep::NerdFonts);
1388
1389        let backend = ratatui::backend::TestBackend::new(100, 32);
1390        let mut terminal = ratatui::Terminal::new(backend).unwrap();
1391        screen.step = OnbStep::Workspace;
1392        terminal.draw(|f| screen.render(f)).unwrap();
1393        let flat: String = terminal
1394            .backend()
1395            .buffer()
1396            .content
1397            .iter()
1398            .map(|c| c.symbol())
1399            .collect();
1400        assert!(
1401            flat.contains("notes"),
1402            "workspace list should show the entry name"
1403        );
1404        assert!(
1405            flat.contains("Preferences"),
1406            "should point at Preferences for management"
1407        );
1408    }
1409
1410    #[test]
1411    fn name_edit_mode_validates_and_lowercases() {
1412        let (tx, _rx) = unbounded_channel();
1413        let mut screen = OnboardingScreen::new(shared_defaults());
1414        screen.step = OnbStep::Workspace;
1415        screen.handle_input(&key_event(KeyCode::Char('e')), &tx);
1416        assert!(screen.name_editing);
1417        screen.handle_input(&key_event(KeyCode::Char('X')), &tx);
1418        screen.handle_input(&key_event(KeyCode::Enter), &tx);
1419        assert!(!screen.name_editing);
1420        let (name, _) = screen.draft.workspace.clone().unwrap();
1421        assert_eq!(name, "kimun-notesx");
1422    }
1423
1424    #[test]
1425    fn name_edit_rejects_invalid_name_and_stays_editing() {
1426        let (tx, _rx) = unbounded_channel();
1427        let mut screen = OnboardingScreen::new(shared_defaults());
1428        screen.step = OnbStep::Workspace;
1429        screen.handle_input(&key_event(KeyCode::Char('e')), &tx);
1430        assert!(screen.name_editing);
1431        // "?" is invalid on at least one major filesystem.
1432        screen.handle_input(&key_event(KeyCode::Char('?')), &tx);
1433        screen.handle_input(&key_event(KeyCode::Enter), &tx);
1434        assert!(screen.name_editing, "invalid name must keep edit mode open");
1435        assert!(screen.flash.is_some(), "invalid name must flash");
1436        let (name, _) = screen.draft.workspace.clone().unwrap();
1437        assert_eq!(name, "kimun-notes", "draft name unchanged on invalid input");
1438    }
1439
1440    #[test]
1441    fn nerd_fonts_toggle_updates_draft_and_preview_icons() {
1442        let (tx, _rx) = unbounded_channel();
1443        let mut screen = OnboardingScreen::new(shared_with_workspace());
1444        screen.step = OnbStep::NerdFonts;
1445        assert!(!screen.draft.use_nerd_fonts);
1446        screen.handle_input(&key_event(KeyCode::Down), &tx); // select "nerd fonts"
1447        assert!(screen.draft.use_nerd_fonts);
1448        assert!(!screen.icons.info.is_ascii(), "preview icons follow draft");
1449        screen.handle_input(&key_event(KeyCode::Up), &tx);
1450        assert!(!screen.draft.use_nerd_fonts);
1451        assert!(screen.icons.info.is_ascii());
1452    }
1453
1454    #[test]
1455    fn nerd_fonts_step_renders_both_sample_rows() {
1456        let mut screen = OnboardingScreen::new(shared_with_workspace());
1457        screen.step = OnbStep::NerdFonts;
1458        let backend = ratatui::backend::TestBackend::new(100, 32);
1459        let mut terminal = ratatui::Terminal::new(backend).unwrap();
1460        terminal.draw(|f| screen.render(f)).unwrap();
1461        let flat: String = terminal
1462            .backend()
1463            .buffer()
1464            .content
1465            .iter()
1466            .map(|c| c.symbol())
1467            .collect();
1468        assert!(flat.contains("ASCII"), "ascii row labeled");
1469        assert!(flat.contains("Nerd Fonts"), "nerd row labeled");
1470    }
1471
1472    #[test]
1473    fn theme_selection_updates_draft_and_live_preview() {
1474        let (tx, _rx) = unbounded_channel();
1475        let mut screen = OnboardingScreen::new(shared_with_workspace());
1476        screen.step = OnbStep::Theme;
1477        assert!(screen.themes.len() >= 2, "need at least two builtin themes");
1478        screen.theme_idx = 0;
1479        if let Some(t) = screen.themes.first() {
1480            screen.draft.theme_name = t.name.clone();
1481        }
1482        let before = screen.draft.theme_name.clone();
1483        screen.handle_input(&key_event(KeyCode::Down), &tx);
1484        assert_ne!(screen.draft.theme_name, before);
1485        assert_eq!(
1486            screen.theme.name, screen.draft.theme_name,
1487            "dialog restyles live"
1488        );
1489    }
1490
1491    #[test]
1492    fn backend_selection_skips_unavailable_nvim() {
1493        let (tx, _rx) = unbounded_channel();
1494        let mut screen = OnboardingScreen::new(shared_with_workspace());
1495        screen.step = OnbStep::Backend;
1496        screen.nvim_available = false;
1497        screen.backend_idx = 1; // vim
1498        screen.draft.editor_backend = EditorBackendSetting::Vim;
1499        screen.handle_input(&key_event(KeyCode::Down), &tx);
1500        assert_eq!(
1501            screen.draft.editor_backend,
1502            EditorBackendSetting::Vim,
1503            "selection must not land on disabled nvim"
1504        );
1505        screen.nvim_available = true;
1506        screen.handle_input(&key_event(KeyCode::Down), &tx);
1507        assert_eq!(screen.draft.editor_backend, EditorBackendSetting::Nvim);
1508    }
1509
1510    #[test]
1511    fn browser_confirm_updates_draft_and_suggested_name() {
1512        let tmp = std::env::temp_dir().join(format!("kimun_onb_browse_{}", std::process::id()));
1513        std::fs::create_dir_all(tmp.join("My-Vault")).unwrap();
1514        let (tx, _rx) = unbounded_channel();
1515        let mut screen = OnboardingScreen::new(shared_defaults());
1516        screen.overlay = OnbOverlay::Browser(FileBrowserState::load(tmp.join("My-Vault")));
1517        screen.handle_input(&key_event(KeyCode::Char('c')), &tx);
1518        let (name, path) = screen.draft.workspace.clone().unwrap();
1519        assert_eq!(path, tmp.join("My-Vault"));
1520        assert_eq!(name, "my-vault");
1521        assert!(matches!(screen.overlay, OnbOverlay::None));
1522        std::fs::remove_dir_all(&tmp).ok();
1523    }
1524
1525    #[tokio::test]
1526    async fn finish_commits_draft_creates_dir_and_emits_finished() {
1527        let tmp = std::env::temp_dir().join(format!("kimun_onb_fin_{}", std::process::id()));
1528        std::fs::remove_dir_all(&tmp).ok();
1529        let (tx, mut rx) = unbounded_channel();
1530        let settings = shared_defaults();
1531        let cfg = std::env::temp_dir().join(format!("kimun_onb_cfg_{}.toml", std::process::id()));
1532        settings.write().unwrap().config_file = Some(cfg.clone());
1533        let mut screen = OnboardingScreen::new(settings.clone());
1534        screen.draft.workspace = Some(("myws".to_string(), tmp.clone()));
1535        screen.draft.use_nerd_fonts = true;
1536        screen.draft.editor_backend = EditorBackendSetting::Vim;
1537        screen.step = OnbStep::Summary;
1538
1539        screen.handle_input(&key_event(KeyCode::Enter), &tx);
1540
1541        assert!(tmp.is_dir(), "workspace directory created at finish");
1542        let s = settings.read().unwrap();
1543        assert!(s.use_nerd_fonts);
1544        assert_eq!(s.editor_backend, EditorBackendSetting::Vim);
1545        assert_eq!(s.current_workspace_name().as_deref(), Some("myws"));
1546        assert_eq!(s.theme, screen.draft.theme_name);
1547        drop(s);
1548        let mut got_finished = false;
1549        while let Ok(msg) = rx.try_recv() {
1550            if matches!(msg, AppEvent::OnboardingFinished) {
1551                got_finished = true;
1552            }
1553        }
1554        assert!(got_finished);
1555        std::fs::remove_dir_all(&tmp).ok();
1556        std::fs::remove_file(&cfg).ok();
1557    }
1558
1559    #[tokio::test]
1560    async fn rerun_finish_never_touches_workspaces() {
1561        let (tx, _rx) = unbounded_channel();
1562        let settings = shared_with_workspace();
1563        let cfg = std::env::temp_dir().join(format!("kimun_onb_cfg_r_{}.toml", std::process::id()));
1564        settings.write().unwrap().config_file = Some(cfg.clone());
1565        let names_before: Vec<String> = settings
1566            .read()
1567            .unwrap()
1568            .workspace_config
1569            .as_ref()
1570            .unwrap()
1571            .workspaces
1572            .keys()
1573            .cloned()
1574            .collect();
1575        let mut screen = OnboardingScreen::new(settings.clone());
1576        screen.draft.use_nerd_fonts = true;
1577        screen.step = OnbStep::Summary;
1578        screen.handle_input(&key_event(KeyCode::Enter), &tx);
1579        let names_after: Vec<String> = settings
1580            .read()
1581            .unwrap()
1582            .workspace_config
1583            .as_ref()
1584            .unwrap()
1585            .workspaces
1586            .keys()
1587            .cloned()
1588            .collect();
1589        assert_eq!(names_before, names_after);
1590        assert!(
1591            settings.read().unwrap().use_nerd_fonts,
1592            "fonts applied on rerun finish"
1593        );
1594        std::fs::remove_file(&cfg).ok();
1595    }
1596
1597    #[test]
1598    fn esc_first_run_opens_quit_confirm_then_quits() {
1599        let (tx, mut rx) = unbounded_channel();
1600        let mut screen = OnboardingScreen::new(shared_defaults());
1601        screen.handle_input(&key_event(KeyCode::Esc), &tx);
1602        assert!(matches!(screen.overlay, OnbOverlay::ConfirmQuit));
1603        screen.handle_input(&key_event(KeyCode::Enter), &tx);
1604        let mut got_quit = false;
1605        while let Ok(msg) = rx.try_recv() {
1606            if matches!(msg, AppEvent::Quit) {
1607                got_quit = true;
1608            }
1609        }
1610        assert!(got_quit);
1611    }
1612
1613    #[test]
1614    fn esc_rerun_clean_goes_straight_to_start() {
1615        let (tx, mut rx) = unbounded_channel();
1616        let mut screen = OnboardingScreen::new(shared_with_workspace());
1617        screen.handle_input(&key_event(KeyCode::Esc), &tx);
1618        let mut got_start = false;
1619        while let Ok(msg) = rx.try_recv() {
1620            if matches!(msg, AppEvent::OpenScreen(ScreenEvent::Start)) {
1621                got_start = true;
1622            }
1623        }
1624        assert!(got_start, "clean rerun Esc leaves without confirmation");
1625    }
1626
1627    #[test]
1628    fn esc_rerun_dirty_asks_discard_and_settings_stay_untouched() {
1629        let (tx, mut rx) = unbounded_channel();
1630        let settings = shared_with_workspace();
1631        let mut screen = OnboardingScreen::new(settings.clone());
1632        screen.set_nerd_fonts(true); // dirty the draft
1633        screen.handle_input(&key_event(KeyCode::Esc), &tx);
1634        assert!(matches!(screen.overlay, OnbOverlay::ConfirmDiscard));
1635        screen.handle_input(&key_event(KeyCode::Enter), &tx);
1636        assert!(!settings.read().unwrap().use_nerd_fonts, "draft discarded");
1637        let mut got_start = false;
1638        while let Ok(msg) = rx.try_recv() {
1639            if matches!(msg, AppEvent::OpenScreen(ScreenEvent::Start)) {
1640                got_start = true;
1641            }
1642        }
1643        assert!(got_start);
1644    }
1645
1646    #[test]
1647    fn full_first_run_walkthrough_with_enter_commits_defaults() {
1648        let tmp = std::env::temp_dir().join(format!("kimun_onb_walk_{}", std::process::id()));
1649        std::fs::remove_dir_all(&tmp).ok();
1650        let cfg =
1651            std::env::temp_dir().join(format!("kimun_onb_walk_cfg_{}.toml", std::process::id()));
1652        let (tx, mut rx) = unbounded_channel();
1653        let settings = shared_defaults();
1654        settings.write().unwrap().config_file = Some(cfg.clone());
1655        let mut screen = OnboardingScreen::new(settings.clone());
1656        // Point the suggested workspace at a scratch dir.
1657        screen.draft.workspace = Some(("walkthrough".to_string(), tmp.clone()));
1658
1659        for _ in 0..7 {
1660            screen.handle_input(&key_event(KeyCode::Enter), &tx);
1661        }
1662
1663        assert!(tmp.is_dir(), "workspace dir created");
1664        let s = settings.read().unwrap();
1665        assert_eq!(s.current_workspace_name().as_deref(), Some("walkthrough"));
1666        assert!(!s.use_nerd_fonts, "default kept");
1667        drop(s);
1668        let mut got_finished = false;
1669        while let Ok(msg) = rx.try_recv() {
1670            if matches!(msg, AppEvent::OnboardingFinished) {
1671                got_finished = true;
1672            }
1673        }
1674        assert!(
1675            got_finished,
1676            "six Enters from first step must finish the flow"
1677        );
1678        std::fs::remove_dir_all(&tmp).ok();
1679        std::fs::remove_file(&cfg).ok();
1680    }
1681
1682    #[test]
1683    fn umlaut_bounce_phases_and_columns() {
1684        use std::time::Duration;
1685
1686        // Phase logic is asserted on slot indices, not wall-clock times, so the
1687        // test survives any retuning of slot width or cycle length.
1688        assert_eq!(umlaut_frame_for_slot(0), UmlautFrame::Rest);
1689        // The cycle must contain every phase, in the expected order of first
1690        // appearance: rest -> up -> squash -> full squash.
1691        let phases: Vec<UmlautFrame> = (0..UMLAUT_CYCLE_SLOTS).map(umlaut_frame_for_slot).collect();
1692        assert!(phases.contains(&UmlautFrame::Rest));
1693        assert!(phases.contains(&UmlautFrame::Up));
1694        assert!(phases.contains(&UmlautFrame::Squash));
1695        assert!(phases.contains(&UmlautFrame::SquashFull));
1696        // A squash must always bracket a full squash — never jump rest/up
1697        // straight to peak compression.
1698        for slot in 0..UMLAUT_CYCLE_SLOTS {
1699            if umlaut_frame_for_slot(slot) == UmlautFrame::SquashFull {
1700                let prev =
1701                    umlaut_frame_for_slot((slot + UMLAUT_CYCLE_SLOTS - 1) % UMLAUT_CYCLE_SLOTS);
1702                let next = umlaut_frame_for_slot((slot + 1) % UMLAUT_CYCLE_SLOTS);
1703                assert!(
1704                    matches!(prev, UmlautFrame::Squash | UmlautFrame::SquashFull),
1705                    "full squash at slot {slot} not preceded by a squash"
1706                );
1707                assert!(
1708                    matches!(next, UmlautFrame::Squash | UmlautFrame::SquashFull),
1709                    "full squash at slot {slot} not followed by a squash"
1710                );
1711            }
1712        }
1713        // umlaut_frame must agree with the slot mapping and wrap cleanly.
1714        let cycle_ms = UMLAUT_SLOT_MS * UMLAUT_CYCLE_SLOTS;
1715        for slot in 0..UMLAUT_CYCLE_SLOTS {
1716            let t = slot * UMLAUT_SLOT_MS;
1717            let expected = umlaut_frame_for_slot(slot);
1718            assert_eq!(umlaut_frame(Duration::from_millis(t as u64)), expected);
1719            assert_eq!(
1720                umlaut_frame(Duration::from_millis((t + cycle_ms) as u64)),
1721                expected,
1722                "cycle must wrap at {cycle_ms} ms"
1723            );
1724        }
1725        // The hop rewrites exactly the diaeresis columns — guard the range
1726        // against future banner edits.
1727        assert_eq!(&KIMUN_BANNER[1][UMLAUT_COLS], UMLAUT_DOTS);
1728        // Squash glyphs must fill the same span exactly.
1729        assert_eq!(UMLAUT_SQUASH.len(), UMLAUT_DOTS.len());
1730        assert_eq!(UMLAUT_SQUASH_FULL.len(), UMLAUT_DOTS.len());
1731    }
1732
1733    #[test]
1734    fn welcome_step_enter_advances_and_renders_intro() {
1735        let (tx, _rx) = unbounded_channel();
1736        let mut screen = OnboardingScreen::new(shared_with_workspace());
1737        assert_eq!(screen.step, OnbStep::Welcome);
1738        let backend = ratatui::backend::TestBackend::new(100, 32);
1739        let mut terminal = ratatui::Terminal::new(backend).unwrap();
1740        terminal.draw(|f| screen.render(f)).unwrap();
1741        let flat: String = terminal
1742            .backend()
1743            .buffer()
1744            .content
1745            .iter()
1746            .map(|c| c.symbol())
1747            .collect();
1748        assert!(flat.contains("Welcome"));
1749        assert!(flat.contains("guided setup"));
1750        screen.handle_input(&key_event(KeyCode::Enter), &tx);
1751        assert_eq!(screen.step, OnbStep::Workspace);
1752    }
1753
1754    #[test]
1755    fn esc_in_name_edit_cancels_without_committing() {
1756        let (tx, _rx) = unbounded_channel();
1757        let mut screen = OnboardingScreen::new(shared_defaults());
1758        screen.step = OnbStep::Workspace;
1759        screen.handle_input(&key_event(KeyCode::Char('e')), &tx);
1760        screen.handle_input(&key_event(KeyCode::Char('?')), &tx);
1761        screen.handle_input(&key_event(KeyCode::Esc), &tx);
1762        assert!(!screen.name_editing, "Esc must always exit edit mode");
1763        let (name, _) = screen.draft.workspace.clone().unwrap();
1764        assert_eq!(name, "kimun-notes", "Esc must not commit the buffer");
1765        assert!(
1766            matches!(screen.overlay, OnbOverlay::None),
1767            "Esc consumed by the edit must not open quit confirm"
1768        );
1769    }
1770
1771    #[test]
1772    fn edited_name_survives_browser_confirm() {
1773        let tmp = std::env::temp_dir().join(format!("kimun_onb_keepname_{}", std::process::id()));
1774        std::fs::create_dir_all(tmp.join("My-Vault")).unwrap();
1775        let (tx, _rx) = unbounded_channel();
1776        let mut screen = OnboardingScreen::new(shared_defaults());
1777        screen.step = OnbStep::Workspace;
1778        screen.handle_input(&key_event(KeyCode::Char('e')), &tx);
1779        screen.handle_input(&key_event(KeyCode::Char('z')), &tx);
1780        screen.handle_input(&key_event(KeyCode::Enter), &tx);
1781        screen.overlay = OnbOverlay::Browser(FileBrowserState::load(tmp.join("My-Vault")));
1782        screen.handle_input(&key_event(KeyCode::Char('c')), &tx);
1783        let (name, path) = screen.draft.workspace.clone().unwrap();
1784        assert_eq!(path, tmp.join("My-Vault"));
1785        assert_eq!(
1786            name, "kimun-notesz",
1787            "explicit edit must survive directory pick"
1788        );
1789        std::fs::remove_dir_all(&tmp).ok();
1790    }
1791
1792    #[test]
1793    fn ctrl_chords_in_browser_do_not_confirm_or_jump() {
1794        use ratatui::crossterm::event::{KeyEvent, KeyEventKind, KeyEventState, KeyModifiers};
1795        let (tx, _rx) = unbounded_channel();
1796        let mut screen = OnboardingScreen::new(shared_defaults());
1797        screen.step = OnbStep::Workspace;
1798        screen.draft.workspace = None;
1799        screen.overlay = OnbOverlay::Browser(FileBrowserState::load(std::env::temp_dir()));
1800        let ctrl_c = InputEvent::Key(KeyEvent {
1801            code: KeyCode::Char('c'),
1802            modifiers: KeyModifiers::CONTROL,
1803            kind: KeyEventKind::Press,
1804            state: KeyEventState::NONE,
1805        });
1806        screen.handle_input(&ctrl_c, &tx);
1807        assert!(
1808            screen.draft.workspace.is_none(),
1809            "Ctrl+C must not confirm the directory"
1810        );
1811        assert!(
1812            matches!(screen.overlay, OnbOverlay::Browser(_)),
1813            "browser must stay open"
1814        );
1815    }
1816
1817    #[test]
1818    fn missing_theme_keeps_configured_name_and_stays_clean() {
1819        let shared = shared_with_workspace();
1820        shared.write().unwrap().theme = "ghost-theme".to_string();
1821        let screen = OnboardingScreen::new(shared);
1822        assert_eq!(
1823            screen.draft.theme_name, "ghost-theme",
1824            "draft must not substitute a fallback for a missing theme"
1825        );
1826        assert!(!screen.dirty(), "untouched screen must not report changes");
1827    }
1828
1829    #[test]
1830    fn unavailable_nvim_keeps_configured_backend_and_stays_clean() {
1831        let shared = shared_with_workspace();
1832        {
1833            let mut s = shared.write().unwrap();
1834            s.editor_backend = EditorBackendSetting::Nvim;
1835            s.nvim_path = Some(std::path::PathBuf::from("/nonexistent/nvim-binary"));
1836        }
1837        let screen = OnboardingScreen::new(shared);
1838        assert!(!screen.nvim_available);
1839        assert_eq!(
1840            screen.draft.editor_backend,
1841            EditorBackendSetting::Nvim,
1842            "constructor must not rewrite the configured backend"
1843        );
1844        assert!(!screen.dirty(), "untouched screen must not report changes");
1845    }
1846
1847    #[tokio::test]
1848    async fn finish_rolls_back_created_dir_when_registration_fails() {
1849        let tmp = std::env::temp_dir().join(format!("kimun_onb_rollback_{}", std::process::id()));
1850        std::fs::create_dir_all(&tmp).unwrap();
1851        // A leftover workspace entry named "notes" with no current workspace:
1852        // first_run is true, but registering another "notes" must fail.
1853        let shared = {
1854            use crate::settings::workspace_config::WorkspaceConfig;
1855            let mut s = AppSettings::default();
1856            let mut wc = WorkspaceConfig::new_empty();
1857            wc.add_workspace("notes".to_string(), tmp.join("other"))
1858                .unwrap();
1859            wc.global.current_workspace = String::new();
1860            s.workspace_config = Some(wc);
1861            Arc::new(RwLock::new(s))
1862        };
1863        let (tx, _rx) = unbounded_channel();
1864        let mut screen = OnboardingScreen::new(shared);
1865        assert!(screen.first_run);
1866        let new_dir = tmp.join("fresh");
1867        screen.draft.workspace = Some(("notes".to_string(), new_dir.clone()));
1868        screen.step = OnbStep::Summary;
1869        screen.finish(&tx);
1870        assert!(screen.flash.is_some(), "duplicate name must flash an error");
1871        assert_eq!(screen.step, OnbStep::Workspace);
1872        assert!(
1873            !new_dir.exists(),
1874            "directory created by finish must be rolled back"
1875        );
1876        std::fs::remove_dir_all(&tmp).ok();
1877    }
1878}