返回 CodeWhale
welcome.rs
根目录 / crates / tui / src / tui / onboarding / welcome.rs
1 //! Calm welcome and ready screens for first run.
2 //!
3 //! The welcome is unnumbered and asks nothing: one headline, one supporting
4 //! sentence, one primary action, one exit. The ready screen closes the flow
5 //! by handing the user the composer pre-seeded with a first task — Enter
6 //! opens the product, never another educational surface.
7
8 use ratatui::style::{Modifier, Style};
9 use ratatui::text::{Line, Span};
10
11 use crate::tui::app::App;
12 use codewhale_localization::MessageId;
13 use codewhale_palette as palette;
14
15 pub fn lines(app: &App, width: usize) -> Vec<Line<'static>> {
16 let mut out = Vec::new();
17 headline(&mut out, app, MessageId::OnboardWelcomeTitle, width);
18 out.push(Line::from(""));
19 body(&mut out, app, MessageId::OnboardWelcomeLead, width);
20 out
21 }
22
23 pub fn ready_lines(app: &App, width: usize) -> Vec<Line<'static>> {
24 let mut out = Vec::new();
25 headline(&mut out, app, MessageId::OnboardReadyTitle, width);
26 out.push(Line::from(""));
27 body(&mut out, app, MessageId::OnboardReadyLead, width);
28 // The offline-explore notice is durable onboarding state, not a toast:
29 // trust decisions are allowed to replace status_message without hiding
30 // that no provider route is connected.
31 let notice = if app.onboarding_explore_offline {
32 Some(app.tr(MessageId::OnboardOfflineNotice).into_owned())
33 } else {
34 app.status_message.clone()
35 };
36 if let Some(message) = notice {
37 out.push(Line::from(""));
38 out.push(Line::from(Span::styled(
39 message,
40 Style::default().fg(palette::STATUS_WARNING),
41 )));
42 }
43 out
44 }
45
46 /// A headline is a whole sentence in several locales, so it wraps like the
47 /// body it sits above. Returning a single unwrapped `Line` left the very first
48 /// screen of first run reading "Codewhale arbeitet mit dir in diesem O" at 40
49 /// columns — cut mid-word, on the screen that introduces the product.
50 fn headline(out: &mut Vec<Line<'static>>, app: &App, id: MessageId, width: usize) {
51 for segment in super::wrap_words(&app.tr(id), width) {
52 out.push(Line::from(Span::styled(
53 segment,
54 Style::default()
55 .fg(palette::WHALE_HUMAN)
56 .add_modifier(Modifier::BOLD),
57 )));
58 }
59 }
60
61 fn body(out: &mut Vec<Line<'static>>, app: &App, id: MessageId, width: usize) {
62 for segment in super::wrap_words(&app.tr(id), width) {
63 out.push(Line::from(Span::styled(
64 segment,
65 Style::default().fg(palette::TEXT_PRIMARY),
66 )));
67 }
68 }
69
70 #[cfg(test)]
71 mod tests {
72 use super::*;
73 use crate::config::Config;
74 use crate::tui::app::TuiOptions;
75 use codewhale_localization::Locale;
76 use std::path::PathBuf;
77
78 fn test_app_with_locale(locale: Locale) -> App {
79 let options = TuiOptions {
80 ..crate::test_support::test_tui_options(PathBuf::from("."))
81 };
82 let mut app = App::new(options, &Config::default());
83 app.ui_locale = locale;
84 app
85 }
86
87 fn body(_app: &App, lines: Vec<Line<'static>>) -> String {
88 lines
89 .into_iter()
90 .flat_map(|line| {
91 line.spans
92 .into_iter()
93 .map(|span| span.content.to_string())
94 .collect::<Vec<_>>()
95 })
96 .collect::<Vec<_>>()
97 .join("\n")
98 }
99
100 #[test]
101 fn ready_screen_reads_offline_truth_from_typed_state() {
102 let mut app = test_app_with_locale(Locale::En);
103 app.onboarding_explore_offline = true;
104 app.status_message = Some("Workspace trust was not changed.".to_string());
105 let text = body(&app, ready_lines(&app, 70));
106 assert!(
107 text.contains(app.tr(MessageId::OnboardOfflineNotice).as_ref()),
108 "{text}"
109 );
110 assert!(!text.contains("Workspace trust was not changed."), "{text}");
111 }
112 }
113
114 #[cfg(test)]
115 mod narrow_locale_tests {
116 use super::*;
117 use crate::config::Config;
118 use crate::tui::app::TuiOptions;
119 use codewhale_localization::{Locale, MessageId, tr};
120 use std::path::PathBuf;
121 use unicode_width::UnicodeWidthStr;
122
123 fn probe_app() -> App {
124 let options = TuiOptions {
125 model: "test-model".to_string(),
126 ..crate::test_support::test_tui_options(PathBuf::from("workspace-fixture"))
127 };
128 App::new(options, &Config::default())
129 }
130
131 fn flatten(lines: Vec<Line<'static>>) -> Vec<String> {
132 lines
133 .iter()
134 .map(|line| {
135 line.spans
136 .iter()
137 .map(|span| span.content.as_ref())
138 .collect::<String>()
139 })
140 .collect()
141 }
142
143 /// The welcome headline is a whole sentence in most locales. It used to be
144 /// emitted as one unwrapped line, so the first screen of first run read
145 /// "Codewhale arbeitet mit dir in diesem O" in German at 40 columns.
146 #[test]
147 fn the_welcome_headline_survives_a_small_terminal_in_every_locale() {
148 let mut app = probe_app();
149 for locale in Locale::shipped().iter().copied() {
150 app.ui_locale = locale;
151 for width in [40usize, 60, 80] {
152 let rendered = flatten(lines(&app, width));
153 for row in &rendered {
154 assert!(
155 row.width() <= width,
156 "{locale:?} at {width}: row overflows: {row:?}",
157 );
158 }
159 // Whitespace removed, not collapsed: Japanese and Chinese wrap
160 // between characters with no space between them in the source.
161 let squash = |s: &str| s.chars().filter(|c| !c.is_whitespace()).collect::<String>();
162 let joined = squash(&rendered.join(" "));
163 let title = tr(locale, MessageId::OnboardWelcomeTitle);
164 assert!(
165 joined.contains(&squash(title.as_ref())),
166 "{locale:?} at {width}: headline was cut.\nwanted: {title}\ngot: {rendered:?}",
167 );
168 }
169 }
170 }
171 }
172
172 lines RUST