Skip to main content

atomcode_tuix/modals/
language_picker.rs

1// crates/atomcode-tuix/src/modals/language_picker.rs
2//
3// `/language` modal — language picker.
4//
5// Lists available locales with the current one pre-selected.
6// Up/Down navigates, Enter selects (persists to config + switches
7// global locale), Esc cancels. Renders as a MenuPayload above the
8// input box.
9
10use anyhow::Result;
11use crossterm::event::{KeyCode, KeyModifiers};
12
13use super::{Modal, ModalAction};
14use crate::event_loop::{build_status, Buffer, LoopCtx};
15use crate::i18n::{self, Locale};
16use crate::render::{MenuPayload, Renderer, UiLine};
17use crate::state::UiState;
18
19pub struct LanguagePicker {
20    options: Vec<(Locale, String, String)>,
21    selected: usize,
22}
23
24impl LanguagePicker {
25    pub fn open() -> Self {
26        let current = i18n::current_locale();
27        let options = vec![
28            (Locale::En, "English".to_string(), "en".to_string()),
29            (Locale::ZhCn, "简体中文".to_string(), "zh_CN".to_string()),
30        ];
31        let selected = options
32            .iter()
33            .position(|(loc, _, _)| *loc == current)
34            .unwrap_or(0);
35        Self { options, selected }
36    }
37}
38
39impl Modal for LanguagePicker {
40    fn handle_key(
41        &mut self,
42        code: KeyCode,
43        _mods: KeyModifiers,
44        buf: &mut Buffer,
45        state: &mut UiState,
46        ctx: &mut LoopCtx,
47        renderer: &mut dyn Renderer,
48    ) -> Result<ModalAction> {
49        match code {
50            KeyCode::Up => {
51                self.selected = self.selected.saturating_sub(1);
52                self.draw(buf, state, ctx, renderer);
53                Ok(ModalAction::Continue)
54            }
55            KeyCode::Down => {
56                let max = self.options.len().saturating_sub(1);
57                if self.selected < max {
58                    self.selected += 1;
59                }
60                self.draw(buf, state, ctx, renderer);
61                Ok(ModalAction::Continue)
62            }
63            KeyCode::Enter => {
64                let (locale, label, _) = &self.options[self.selected];
65                let locale = *locale;
66                let label = label.clone();
67                // Flip the global locale FIRST so the confirmation
68                // below renders in the just-picked language. Without
69                // this the "switched to 简体中文" line still comes
70                // back in English on a zh_CN selection.
71                i18n::set_locale(locale);
72                ctx.config.language = Some(locale);
73                let config_path = atomcode_core::config::Config::default_path();
74                if let Err(e) = ctx.config.save(&config_path) {
75                    // TODO: surface via renderer once a non-modal error display is available
76                    eprintln!("[language] failed to save config: {e}");
77                }
78                renderer.render(UiLine::CommandOutput(
79                    crate::i18n::t(crate::i18n::Msg::LanguageSwitched {
80                        label: &label,
81                        locale: &locale.to_string(),
82                    })
83                    .into_owned(),
84                ));
85                renderer.flush();
86                Ok(ModalAction::Close)
87            }
88            KeyCode::Esc => Ok(ModalAction::Close),
89            _ => Ok(ModalAction::Continue),
90        }
91    }
92
93    fn draw(&self, buf: &Buffer, state: &UiState, ctx: &LoopCtx, renderer: &mut dyn Renderer) {
94        let items: Vec<(String, String)> = self
95            .options
96            .iter()
97            .map(|(_, label, hint)| (label.clone(), hint.clone()))
98            .collect();
99        let payload = MenuPayload {
100            items,
101            selected: self.selected,
102            kind: crate::render::MenuKind::SlashCommand,
103        };
104        renderer.render(UiLine::InputPrompt {
105            buf: buf.text.clone(),
106            cursor_byte: buf.cursor,
107            menu: Some(payload),
108            status: build_status(state, ctx),
109            attachments: Vec::new(),
110        });
111        renderer.flush();
112    }
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118
119    #[test]
120    fn open_selects_current_locale() {
121        let _g = crate::i18n::test_lock();
122        crate::i18n::set_locale(Locale::ZhCn);
123        let picker = LanguagePicker::open();
124        assert_eq!(picker.selected, 1); // ZhCn is second option
125    }
126
127    #[test]
128    fn open_defaults_to_en() {
129        let _g = crate::i18n::test_lock();
130        crate::i18n::set_locale(Locale::En);
131        let picker = LanguagePicker::open();
132        assert_eq!(picker.selected, 0); // En is first option
133    }
134
135    /// Switching to zh_CN renders a Chinese confirmation line that
136    /// includes the success checkmark + the picked label + the locale
137    /// code. Regression guard for "no feedback after picking
138    /// a language" — the Enter handler is supposed to push a
139    /// CommandOutput line with these three markers visible, in the
140    /// freshly-picked locale.
141    #[test]
142    fn switch_confirmation_zh_cn_has_checkmark_label_and_locale() {
143        let _g = crate::i18n::test_lock();
144        crate::i18n::set_locale(Locale::ZhCn);
145        let msg = crate::i18n::t(crate::i18n::Msg::LanguageSwitched {
146            label: "简体中文",
147            locale: "zh_CN",
148        });
149        assert!(msg.contains("✓"), "missing checkmark: {}", msg);
150        assert!(msg.contains("简体中文"), "missing label: {}", msg);
151        assert!(msg.contains("zh_CN"), "missing locale code: {}", msg);
152        assert!(msg.contains("已切换"), "missing '已切换' verb: {}", msg);
153        assert!(msg.ends_with('\n'), "missing trailing newline: {:?}", msg);
154    }
155
156    #[test]
157    fn switch_confirmation_en_has_checkmark_label_and_locale() {
158        let _g = crate::i18n::test_lock();
159        crate::i18n::set_locale(Locale::En);
160        let msg = crate::i18n::t(crate::i18n::Msg::LanguageSwitched {
161            label: "English",
162            locale: "en",
163        });
164        assert!(msg.contains("✓"), "missing checkmark: {}", msg);
165        assert!(msg.contains("English"), "missing label: {}", msg);
166        assert!(msg.contains("(en)"), "missing locale code: {}", msg);
167        assert!(msg.to_lowercase().contains("switched"), "missing 'switched' verb: {}", msg);
168        assert!(msg.ends_with('\n'), "missing trailing newline: {:?}", msg);
169    }
170}