1use 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#[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
65struct Draft {
69 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
77enum OnbOverlay {
81 None,
82 Browser(FileBrowserState),
83 NewDir(FileBrowserState, SingleLineInput),
84 ConfirmQuit,
85 ConfirmDiscard,
86}
87
88const 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
110const KIMUN_BANNER: [&str; 5] = [
116 r#" _ ___ _ _ "#,
117 r#"| |/ (_)_ __ ___ (_) (_)_ __ "#,
118 r#"| ' /| | '_ ` _ \| | | | '_ \ "#,
119 r#"| . \| | | | | | | |_| | | | |"#,
120 r#"|_|\_\_|_| |_| |_|\__,_|_| |_|"#,
121];
122
123const UMLAUT_COLS: std::ops::Range<usize> = 17..24;
125const UMLAUT_DOTS: &str = "(_) (_)";
126const UMLAUT_SQUASH: &str = "<_> <_>";
128const UMLAUT_SQUASH_FULL: &str = "-=- -=-";
130
131#[derive(Clone, Copy, PartialEq, Eq, Debug)]
133enum UmlautFrame {
134 Rest,
136 Squash,
138 SquashFull,
140 Up,
142}
143
144const UMLAUT_SLOT_MS: u128 = 50;
152const UMLAUT_CYCLE_SLOTS: u128 = 26;
153
154fn 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, }
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
169pub 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 name_edited: bool,
187 overlay: OnbOverlay,
188 flash: Option<String>,
189 started: std::time::Instant,
191 anim: Option<tokio::task::JoinHandle<()>>,
193}
194
195impl 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 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
260fn 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
276fn 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#[async_trait]
291impl AppScreen for OnboardingScreen {
292 async fn on_enter(&mut self, tx: &AppTx) {
293 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 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
359impl 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 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); }
433
434 fn updates_step_key(&mut self, key: &KeyEvent) {
435 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 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 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 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 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
628impl OnboardingScreen {
631 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 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), Constraint::Min(0), Constraint::Length(1), Constraint::Length(1), ])
666 .split(inner);
667
668 self.render_header(f, rows[0]);
669
670 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 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 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 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 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 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 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; }
1024 let (backend, _, _) = BACKENDS[idx as usize];
1025 if backend == EditorBackendSetting::Nvim && !self.nvim_available {
1026 continue; }
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 fn finish(&mut self, tx: &AppTx) {
1068 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 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 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 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 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
1253fn 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#[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 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 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 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); 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; 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); 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 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 assert_eq!(umlaut_frame_for_slot(0), UmlautFrame::Rest);
1689 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 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 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 assert_eq!(&KIMUN_BANNER[1][UMLAUT_COLS], UMLAUT_DOTS);
1728 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 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}