Skip to main content

qcode/ui/
home.rs

1//! The home screen: the logo over the menu that opens everything else.
2
3use qframe::prelude::*;
4
5use crate::Msg as AppMsg;
6use crate::ui::logo::Logo;
7
8/// Width of the menu. Wide enough for the longest label with a workspace name beside it, narrow
9/// enough to read as one column under the logo; a narrower terminal shrinks it.
10const MENU_WIDTH: u16 = 34;
11
12/// A row of the home menu.
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum Entry {
15    /// Goes back to the workspaces that were open, the way a browser brings back its windows.
16    /// Takes the first row whenever there is something to go back to.
17    Continue,
18    /// Takes the first row while no workspace has been opened yet.
19    NewWorkspace,
20    /// All workspaces.
21    Workspaces,
22    /// The harness profiles workspaces are opened with.
23    Profiles,
24    /// The model services a harness can be pointed at, and the keys they are reached with.
25    Providers,
26    /// Language, theme and the container engine.
27    Settings,
28    /// Leaves the application.
29    Quit,
30}
31
32impl Entry {
33    /// The translated label.
34    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    /// The icon drawn before the label. Both first rows are about a workspace, so they share one.
47    ///
48    /// `project` is the name the framework's icon set gives that shape; it is the set's word, not
49    /// a word QCode shows anyone.
50    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/// What can happen on the home screen.
62///
63/// Opening a row is not here: a row opens a screen, which is the application's to decide, so the
64/// menu sends [`AppMsg::Open`] straight away rather than passing it through this module.
65#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66pub enum Msg {
67    /// The selection moved to a row.
68    Select(usize),
69}
70
71/// The home screen's state: which workspaces "Continue" goes back to and where the selection
72/// sits.
73#[derive(Debug, Clone, PartialEq, Eq)]
74pub struct Home {
75    open: Vec<String>,
76    selected: usize,
77}
78
79impl Home {
80    /// A home screen whose first row goes back to `open`, the names of the workspaces that were
81    /// open in rail order, or starts a new workspace when there are none.
82    #[must_use]
83    pub fn new(open: Vec<String>) -> Self {
84        Self { open, selected: 0 }
85    }
86
87    /// Takes `open` as the workspaces "Continue" goes back to, keeping the selection where it is.
88    pub fn set_open(&mut self, open: Vec<String>) {
89        self.open = open;
90    }
91
92    /// The rows, in order. The first one either continues where the person left off or starts
93    /// their first workspace.
94    #[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    /// What the "Continue" row says beside its label: the first workspace, and how many more
101    /// there are after it.
102    ///
103    /// One workspace or several is a choice of layout, not of grammar, so it is made here rather
104    /// than by a language's plural forms: Russian files 21 under "one" and Japanese has no "one" at
105    /// all, and either would drop the count or show "+0".
106    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/// The control that takes the keyboard when the screen opens: the menu, which is the whole
116/// screen. Without it the hint under the screen would promise arrow keys that go nowhere until
117/// something else is pressed first.
118#[must_use]
119pub fn entry() -> &'static str {
120    MENU
121}
122
123/// The name of the menu, for the focus and the tests.
124const MENU: &str = "menu";
125
126/// Applies a home screen message.
127pub 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
138/// Draws the logo, the tagline and the menu, centred in the body.
139pub 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/// The keys of the home screen that are not in the keymap, for the key list.
166#[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    /// A terminal wide enough for the logo and the menu.
181    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    /// The home screen of a session that left `ids` open, in `locale`.
253    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        // Russian files 21 under the same plural form as 1, which is where the count was lost.
272        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        // Japanese has no plural "one", so a count of one reached the several-workspaces text.
280        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        // Sixteen rows hold the drawing, the tagline and the whole menu; eleven hold the name in
322        // place of the drawing; ten hold the menu alone.
323        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        // In ASCII mode the logo is painted in colour rather than written in characters, so the
343        // text of the screen alone cannot tell whether it is there. Only the logo sits above the
344        // tagline, so a coloured cell there is the logo.
345        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        // The bar of the selected row is a glyph in Nerd Font and Unicode; ASCII paints it in
369        // colour, so its first row starts at the icon.
370        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}