1use qframe::prelude::*;
4
5use crate::Msg as AppMsg;
6use crate::ui::logo::Logo;
7
8const MENU_WIDTH: u16 = 34;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum Entry {
15 Continue,
18 NewWorkspace,
20 Workspaces,
22 Profiles,
24 Providers,
26 Settings,
28 Quit,
30}
31
32impl Entry {
33 pub(crate) fn label(self) -> String {
35 match self {
36 Self::Continue => t!("home.continue"),
37 Self::NewWorkspace => t!("home.new-workspace"),
38 Self::Workspaces => t!("home.workspaces"),
39 Self::Profiles => t!("home.profiles"),
40 Self::Providers => t!("provider.menu"),
41 Self::Settings => t!("home.settings"),
42 Self::Quit => t!("home.quit"),
43 }
44 }
45
46 pub(crate) fn icon(self) -> &'static str {
51 match self {
52 Self::Continue | Self::NewWorkspace | Self::Workspaces => "workspace",
53 Self::Profiles => "profile",
54 Self::Providers => "category-network",
55 Self::Settings => "settings",
56 Self::Quit => "power",
57 }
58 }
59}
60
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66pub enum Msg {
67 Select(usize),
69}
70
71#[derive(Debug, Clone, PartialEq, Eq)]
74pub struct Home {
75 open: Vec<String>,
76 selected: usize,
77}
78
79impl Home {
80 #[must_use]
83 pub fn new(open: Vec<String>) -> Self {
84 Self { open, selected: 0 }
85 }
86
87 pub fn set_open(&mut self, open: Vec<String>) {
89 self.open = open;
90 }
91
92 #[must_use]
95 pub fn entries(&self) -> [Entry; 6] {
96 let first = if self.open.is_empty() { Entry::NewWorkspace } else { Entry::Continue };
97 [first, Entry::Workspaces, Entry::Profiles, Entry::Providers, Entry::Settings, Entry::Quit]
98 }
99
100 fn detail(&self) -> Option<String> {
107 let first = self.open.first()?;
108 Some(match self.open.len() {
109 1 => t!("home.continue-one", first = first.as_str()),
110 n => t!("home.continue-more", first = first.as_str(), more = n - 1),
111 })
112 }
113}
114
115#[must_use]
119pub fn entry() -> &'static str {
120 MENU
121}
122
123const MENU: &str = "menu";
125
126pub fn update(home: &mut Home, message: Msg) -> Command<AppMsg> {
128 match message {
129 Msg::Select(index) => {
130 if index < home.entries().len() {
131 home.selected = index;
132 }
133 }
134 }
135 Command::none()
136}
137
138pub fn view(home: &Home, ui: &mut View<'_, AppMsg>) {
140 let entries = home.entries();
141 let items = entries.into_iter().map(|entry| {
142 let item = ListItem::new(entry.label()).icon(entry.icon(), None);
143 match (entry, home.detail()) {
144 (Entry::Continue, Some(detail)) => item.detail(detail),
145 _ => item,
146 }
147 });
148 let menu = List::new(items)
149 .selected(Some(home.selected))
150 .on_select(|index| AppMsg::Home(Msg::Select(index)))
151 .on_activate(move |index| AppMsg::Open(entries[index.min(entries.len() - 1)]));
152 let menu = menu.wrap(true);
153
154 ui.column(|ui| {
155 ui.add(Logo::new()).width(Length::Cells(Logo::WIDTH));
156 ui.add(Text::new(t!("app.tagline")).role("secondary"));
157 ui.add(menu).id(MENU).width(Length::Cells(MENU_WIDTH));
158 })
159 .fill()
160 .gap(1)
161 .align(Align::Center)
162 .justify(Align::Center);
163}
164
165#[must_use]
167pub fn hints(icons: &qframe::icons::Icons) -> Vec<(String, String)> {
168 let move_keys = format!("{}{}", icons.glyph("arrow-up"), icons.glyph("arrow-down"));
169 vec![(move_keys, t!("hints.move")), (icons.glyph("enter").into_owned(), t!("hints.open"))]
170}
171
172#[cfg(test)]
173mod tests {
174 use qframe::icons::GlyphMode;
175 use qframe::runtime::Harness;
176
177 use super::{Entry, Home};
178 use crate::{QCode, testing};
179
180 const SIZE: (u16, u16) = (90, 30);
182
183 fn home(recent: Option<&str>, width: u16, height: u16) -> Harness<QCode> {
184 let store = testing::scratch("home-store");
185 let recent: Vec<&str> = recent.into_iter().collect();
186 let app = testing::app(testing::config(&store, &recent), &testing::settled(), None);
187 testing::harness(app, width, height)
188 }
189
190 #[test]
191 fn the_logo_stands_over_the_menu() {
192 let harness = home(None, SIZE.0, SIZE.1);
193 let screen = harness.screen();
194 assert!(screen.contains("███▀ ███ ██████"), "the wordmark is drawn:\n{screen}");
195 for label in ["New workspace", "Workspaces", "Profiles", "Providers", "Settings", "Quit"] {
196 assert!(screen.contains(label), "`{label}` is missing:\n{screen}");
197 }
198 let (_, logo_row) = harness.find("Coding agents").expect("the tagline is on screen");
199 let (_, menu_row) = harness.find("Workspaces").expect("the menu is on screen");
200 assert!(logo_row < menu_row, "the tagline sits above the menu:\n{screen}");
201 }
202
203 #[test]
204 fn the_tagline_calls_what_runs_in_the_container_a_coding_agent_in_every_language() {
205 let mut harness = home(None, SIZE.0, SIZE.1);
206 let screen = harness.screen();
207 assert!(screen.contains("Coding agents, always inside a container"), "{screen}");
208 for (code, words) in [
209 ("tr", "Kodlama ajanları"),
210 ("de", "Coding-Agenten"),
211 ("es", "Agentes de programación"),
212 ("fr", "agents de programmation"),
213 ("pt-BR", "Agentes de programação"),
214 ("ru", "Агенты"),
215 ("zh-Hans", "智能体"),
216 ("ja", "エージェント"),
217 ] {
218 harness.set_locale(code).render();
219 let screen = harness.screen();
220 assert!(screen.contains(words), "{code} says `{words}` under the logo:\n{screen}");
221 }
222 }
223
224 #[test]
225 fn a_recent_workspace_takes_the_first_row_and_names_itself() {
226 let harness = home(Some("firefly"), SIZE.0, SIZE.1);
227 let screen = harness.screen();
228 assert!(screen.contains("Continue"), "{screen}");
229 assert!(screen.contains("firefly"), "{screen}");
230 assert!(!screen.contains("New workspace"), "{screen}");
231 }
232
233 #[test]
234 fn without_a_recent_workspace_the_menu_offers_a_new_one() {
235 let screen = home(None, SIZE.0, SIZE.1).screen();
236 assert!(screen.contains("New workspace"), "{screen}");
237 assert!(!screen.contains("Continue"), "{screen}");
238 }
239
240 #[test]
241 fn continue_stands_while_there_is_something_to_go_back_to() {
242 let mut one = Home::new(vec!["Firefly".to_owned()]);
243 let mut three = Home::new(vec!["Firefly".to_owned(), "Moth".to_owned(), "Lantern".to_owned()]);
244 assert_eq!(one.entries()[0], Entry::Continue);
245 one.set_open(Vec::new());
246 three.selected = 3;
247 three.set_open(vec!["Moth".to_owned()]);
248 assert_eq!(one.entries()[0], Entry::NewWorkspace, "nothing to go back to is a new workspace again");
249 assert_eq!(three.selected, 3, "a new list of workspaces leaves the selection where it was");
250 }
251
252 fn continued(what: &str, ids: &[String], locale: &str) -> String {
254 let session = testing::scratch(what);
255 let _ = std::fs::remove_dir_all(&session);
256 std::fs::create_dir_all(&session).expect("a folder");
257 let file = session.join("session.toml");
258 let text: String = ids.iter().map(|id| format!("[[workspace]]\nid = \"{id}\"\n\n")).collect();
259 std::fs::write(&file, text).expect("a session file");
260 let store = testing::scratch("home-store");
261 let app = testing::app(testing::config(&store, &[]), &testing::settled(), None).with_session(Some(file));
262 let mut harness = testing::harness(app, SIZE.0, SIZE.1);
263 harness.set_locale(locale).render();
264 let screen = harness.screen();
265 let _ = std::fs::remove_dir_all(&session);
266 screen
267 }
268
269 #[test]
270 fn twenty_one_open_workspaces_keep_their_count_in_russian() {
271 let ids: Vec<String> = (0..21).map(|at| format!("ws{at}")).collect();
273 let screen = continued("home-twenty-one", &ids, "ru");
274 assert!(screen.contains("ws0 +20"), "{screen}");
275 }
276
277 #[test]
278 fn one_open_workspace_shows_no_count_in_japanese() {
279 let screen = continued("home-one-ja", &["firefly".to_owned()], "ja");
281 assert!(screen.contains("firefly"), "{screen}");
282 assert!(!screen.contains("+0"), "{screen}");
283 }
284
285 #[test]
286 fn continue_reads_in_turkish_with_its_count() {
287 let session = testing::scratch("home-session");
288 let _ = std::fs::remove_dir_all(&session);
289 let file = session.join("session.toml");
290 std::fs::create_dir_all(&session).expect("a folder");
291 std::fs::write(
292 &file,
293 "[[workspace]]\nid = \"firefly\"\n\n[[workspace]]\nid = \"moth\"\n\n[[workspace]]\nid = \"lantern\"\n",
294 )
295 .expect("a session file");
296 let store = testing::scratch("home-store");
297 let app = testing::app(testing::config(&store, &[]), &testing::settled(), None).with_session(Some(file));
298 let mut harness = testing::harness(app, SIZE.0, SIZE.1);
299 assert!(
300 harness.screen().contains("Continue") && harness.screen().contains("firefly +2"),
301 "{}",
302 harness.screen()
303 );
304 harness.set_locale("tr").render();
305 let screen = harness.screen();
306 assert!(screen.contains("Devam et") && screen.contains("firefly +2"), "{screen}");
307 let _ = std::fs::remove_dir_all(&session);
308 }
309
310 #[test]
311 fn a_narrow_screen_keeps_the_menu_and_writes_the_logo_small() {
312 let harness = home(None, 16, 14);
313 let screen = harness.screen();
314 assert!(screen.contains("QCode"), "the logo falls back to the plain name:\n{screen}");
315 assert!(!screen.contains('▀'), "no room for block glyphs:\n{screen}");
316 assert!(screen.contains("Quit"), "{screen}");
317 }
318
319 #[test]
320 fn a_short_screen_drops_the_logo_before_it_drops_a_menu_row() {
321 let drawn = home(None, SIZE.0, 16).screen();
324 assert!(drawn.contains("█████████▄"), "{drawn}");
325 let named = home(None, SIZE.0, 11).screen();
326 assert!(!named.contains('█'), "the drawing gives way first:\n{named}");
327 assert!(named.contains("QCode"), "{named}");
328 let bare = home(None, SIZE.0, 10).screen();
329 assert!(!bare.contains("QCode"), "the logo goes rather than the menu:\n{bare}");
330 for screen in [&drawn, &named, &bare] {
331 assert!(screen.contains("New workspace") && screen.contains("Quit"), "the menu is whole:\n{screen}");
332 }
333 }
334
335 #[test]
336 fn ascii_mode_draws_nothing_but_ascii() {
337 let mut harness = home(None, SIZE.0, SIZE.1);
338 harness.set_glyph_mode(GlyphMode::Ascii).render();
339 let screen = harness.screen();
340 assert!(screen.is_ascii(), "{screen}");
341 assert!(screen.contains("Quit"), "{screen}");
342 let (_, tagline) = harness.find("Coding agents").expect("the tagline is on screen");
346 let rows = 0..u16::try_from(tagline).expect("the tagline row fits the screen");
347 let background = harness.bg(0, 0);
348 let painted = rows.flat_map(|y| (0..SIZE.0).map(move |x| (x, y))).any(|(x, y)| harness.bg(x, y) != background);
349 assert!(painted, "the logo is painted in cells of colour:\n{screen}");
350 }
351
352 #[test]
353 fn nothing_is_bracketed_lined_or_framed() {
354 let mut harness = home(Some("firefly"), SIZE.0, SIZE.1);
355 for mode in [GlyphMode::Nerd, GlyphMode::Unicode, GlyphMode::Ascii] {
356 harness.set_glyph_mode(mode).render();
357 let screen = harness.screen();
358 for forbidden in ['[', ']', '(', ')', '{', '}', '|', '┌', '─', '│', '+'] {
359 assert!(!screen.contains(forbidden), "`{forbidden}` in {mode:?}:\n{screen}");
360 }
361 assert!(!screen.contains("=="), "{screen}");
362 assert!(!screen.contains("->"), "{screen}");
363 }
364 }
365
366 #[test]
367 fn every_row_carries_its_icon_in_every_glyph_mode() {
368 let mut harness = home(Some("firefly"), SIZE.0, SIZE.1);
371 for (mode, expected) in [
372 (
373 GlyphMode::Nerd,
374 [
375 "▌ \u{f009} Continue firefly",
376 "\u{f009} Workspaces",
377 "\u{f007} Profiles",
378 "\u{f0ac} Providers",
379 "\u{f013} Settings",
380 "\u{f011} Quit",
381 ],
382 ),
383 (
384 GlyphMode::Unicode,
385 [
386 "▌ ◰ Continue firefly",
387 "◰ Workspaces",
388 "◉ Profiles",
389 "◎ Providers",
390 "▤ Settings",
391 "○ Quit",
392 ],
393 ),
394 (
395 GlyphMode::Ascii,
396 ["# Continue firefly", "# Workspaces", "@ Profiles", "~ Providers", "* Settings", "x Quit"],
397 ),
398 ] {
399 harness.set_glyph_mode(mode).render();
400 let screen = harness.screen();
401 let (_, first) = harness.find("Continue").expect("the menu is on screen");
402 let first = usize::try_from(first).expect("a row on the screen");
403 let rows: Vec<&str> = screen.lines().skip(first).take(6).map(str::trim).collect();
404 assert_eq!(rows, expected, "{mode:?}:\n{screen}");
405 }
406 }
407
408 #[test]
409 fn the_keyboard_moves_the_selection_and_opens_a_row() {
410 let mut harness = home(None, SIZE.0, SIZE.1);
411 assert!(harness.is_focused("menu"), "the menu takes the first focus:\n{}", harness.screen());
412 harness.press("end").press("enter");
413 assert!(harness.quit_requested(), "the last row leaves the application");
414 }
415
416 #[test]
417 fn reduced_motion_keeps_the_menu_working() {
418 let mut harness = home(None, SIZE.0, SIZE.1);
419 harness.set_reduced_motion(true).press("down").render();
420 let screen = harness.screen();
421 assert!(screen.contains("Workspaces"), "{screen}");
422 harness.press("end").press("enter");
423 assert!(harness.quit_requested(), "{screen}");
424 }
425
426 #[test]
427 fn clicking_the_last_row_leaves() {
428 let mut harness = home(None, SIZE.0, SIZE.1);
429 harness.click_text("Quit");
430 assert!(harness.quit_requested());
431 }
432
433 #[test]
434 fn turkish_reads_as_turkish() {
435 let mut harness = home(Some("firefly"), SIZE.0, SIZE.1);
436 harness.set_locale("tr").render();
437 let screen = harness.screen();
438 for label in ["Devam et", "Çalışma alanları", "Profiller", "Ayarlar", "Çıkış", "kapsayıcının"] {
439 assert!(screen.contains(label), "`{label}` is missing:\n{screen}");
440 }
441 }
442}