Skip to main content

lattice_host/tutor/
mode.rs

1//! T.3 — TutorMode minor mode + headerline provider.
2//!
3//! `TutorMode` is a marker minor mode activated on the tutor buffer
4//! by `do_tutor`. It owns:
5//!   - the `<CR>` / `:tutor-next` / `:tutor-prev` keymap layer
6//!   - `TutorHeaderlineProvider` — emits a single virtual row above
7//!     line 0 showing lesson/exercise progress and the current hint.
8//!
9//! T.4 wires `TutorSession` as a `BufferLocal` and populates the
10//! headerline's `TutorViewState` from the live session.
11
12use std::sync::Arc;
13
14use lattice_cells::{Cell, HeaderlineRow, SimpleHeaderlineHandle};
15use lattice_mode::registry::ModeRegistry;
16use lattice_mode::{Keymap, LifecycleFuture, Mode, ModeContext, ModeId, ModeKind};
17
18// ──────────────────────────────────────────────────────────────
19// TutorMode — the minor mode
20// ──────────────────────────────────────────────────────────────
21
22/// `tutor-mode` minor mode. Marker bit: activated on the tutor
23/// buffer by `do_tutor`; the keymap layer (keyed by this mode's
24/// `ModeId`) gates the `<CR>`/`:tutor-next`/`:tutor-prev` chords
25/// so they are invisible on non-tutor buffers.
26///
27/// Bindings are pushed via `tutor_mode_layer_bindings` at boot
28/// (same explicit `push_layer` path as `diff-mode`; migrating to
29/// the `Mode::keymap()` translate pass is tracked separately).
30pub struct TutorMode;
31
32impl TutorMode {
33    pub fn mode_id() -> ModeId {
34        ModeId::new("tutor-mode")
35    }
36}
37
38impl Mode for TutorMode {
39    type Guard = ();
40
41    fn id(&self) -> ModeId {
42        Self::mode_id()
43    }
44
45    fn kind(&self) -> ModeKind {
46        ModeKind::Minor
47    }
48
49    /// K.2.4 path: bindings contributed by the mode itself, picked up
50    /// by `translate_mode_keymaps` at boot. Resolves `"ex:tutor-next"`
51    /// and `"ex:tutor-prev"` against the `CommandRegistry` (registered
52    /// in `lattice_grammar::ex_commands::populate`).
53    fn keymap(&self) -> Keymap {
54        use std::sync::OnceLock;
55        static ENTRIES: OnceLock<Vec<lattice_mode::KeymapEntry>> = OnceLock::new();
56        Keymap::from_entries(ENTRIES.get_or_init(|| {
57            vec![
58                lattice_mode::keymap_entry! {
59                    mode: Normal, chord: "<CR>",
60                    doc: "Advance to the next tutor exercise (or lesson)",
61                    cmd: "ex:tutor-next"
62                },
63                lattice_mode::keymap_entry! {
64                    mode: Normal, chord: "<C-j>",
65                    doc: "Advance to the next tutor exercise (or lesson)",
66                    cmd: "ex:tutor-next"
67                },
68                lattice_mode::keymap_entry! {
69                    mode: Normal, chord: "<C-k>",
70                    doc: "Retreat to the previous tutor exercise",
71                    cmd: "ex:tutor-prev"
72                },
73            ]
74        }))
75    }
76
77    fn on_activate(&self, _ctx: ModeContext) -> LifecycleFuture<'_, ()> {
78        Box::pin(async { Ok(()) })
79    }
80}
81
82/// Register `tutor-mode` against `registry`. Called from the
83/// editor boot path before `translate_mode_keymaps` runs the
84/// K.2.4 pass that picks up `TutorMode::keymap()`.
85pub fn register_tutor_modes(registry: &mut ModeRegistry) {
86    registry
87        .register(TutorMode)
88        .expect("tutor-mode must register without conflict");
89}
90
91// ──────────────────────────────────────────────────────────────
92// TutorViewState — shared between mode and provider
93// ──────────────────────────────────────────────────────────────
94
95/// Display state for the tutor headerline. Updated by T.4 when
96/// the session advances or a hint becomes active.
97#[derive(Debug, Default)]
98pub struct TutorViewState {
99    /// Colored spans: `(text, 0xRRGGBB fg)`. Empty → provider emits nothing.
100    pub spans: Vec<(String, u32)>,
101    /// Row background color (`0xRRGGBB`); `0` = transparent.
102    pub row_bg: u32,
103}
104
105impl TutorViewState {
106    /// Recompute spans from `session` using the retro HUD palette.
107    /// `kind` selects the display variant (normal vs. stage-clear flash).
108    /// Version tracking is handled by [`SimpleHeaderlineHandle::update`].
109    pub fn update_for_display(&mut self, session: &super::TutorSession, kind: TutorHudKind) {
110        let (spans, row_bg) = match kind {
111            TutorHudKind::Normal => tutor_headerline_spans(session),
112            TutorHudKind::StageClear => tutor_stage_clear_spans(session),
113        };
114        self.spans = spans;
115        self.row_bg = row_bg;
116    }
117}
118
119/// Selects which HUD variant to render.
120///
121/// `dispatch.rs` passes this to `TutorViewState::update_for_display`
122/// so that all formatting stays inside `tutor_mode`.
123#[derive(Copy, Clone, Debug)]
124pub enum TutorHudKind {
125    /// Normal display — derives presentation from `session.state`.
126    Normal,
127    /// Stage-clear flash — shown immediately when the auto-detector
128    /// confirms the condition is met, before the user presses `<CR>`.
129    StageClear,
130}
131
132// ──────────────────────────────────────────────────────────────
133// TutorHeaderlineProvider — virtual row above line 0
134// ──────────────────────────────────────────────────────────────
135
136pub const TUTOR_PROVIDER_TAG: u64 = 0x7475_746F_725F_6865; // "tutor_he"
137
138// ──────────────────────────────────────────────────────────────
139// TutorHeaderlineState — BufferLocal handle to the shared view state
140// ──────────────────────────────────────────────────────────────
141
142/// Buffer-local handle for updating the tutor headerline. Wraps a
143/// [`SimpleHeaderlineHandle<TutorViewState>`] so dispatch can push
144/// session state without holding a reference to the provider itself.
145#[derive(Clone)]
146pub struct TutorHeaderlineState(pub SimpleHeaderlineHandle<TutorViewState>);
147
148impl lattice_mode::BufferLocal for TutorHeaderlineState {
149    const NAME: &'static str = "tutor-mode.headerline";
150    const DOC: &'static str = "SimpleHeaderlineHandle for the tutor buffer's sticky HUD row.";
151    const OWNER_MODE: &'static str = "tutor-mode";
152
153    fn describe(&self) -> String {
154        format!("version={}", self.0.version())
155    }
156}
157
158// ──────────────────────────────────────────────────────────────
159// Headerline text helper
160// ──────────────────────────────────────────────────────────────
161
162// ──────────────────────────────────────────────────────────────
163// Retro HUD palette — all values are 0xRRGGBB
164// ──────────────────────────────────────────────────────────────
165
166mod pal {
167    pub const BG: u32 = 0x0a0a1a;
168    pub const HEART_ON: u32 = 0xff2a2a;
169    pub const HEART_OFF: u32 = 0x4a2020;
170    pub const SEP: u32 = 0x333355;
171    pub const LV_LABEL: u32 = 0x00ccaa;
172    pub const LV_NUM: u32 = 0xe0e0e0;
173    pub const SCORE_LABEL: u32 = 0xffd700;
174    pub const SCORE_VAL: u32 = 0xffffff;
175    pub const HI_LABEL: u32 = 0xff8c00;
176    pub const HI_VAL: u32 = 0xffd700;
177    pub const DESC: u32 = 0x88ccff;
178    pub const HINT_LABEL: u32 = 0xff8c00;
179    pub const HINT_VAL: u32 = 0xffcc44;
180    pub const CLEAR: u32 = 0x00ff88;
181    pub const GAME_OVER: u32 = 0xff4444;
182    pub const WIN: u32 = 0xff00ff;
183    pub const DONE: u32 = 0xffd700;
184    pub const KEY_HINT: u32 = 0x666699;
185}
186
187/// Build colored HUD spans for the normal session display.
188///
189/// Returns `(spans, row_bg)` where each span is `(text, 0xRRGGBB fg)`.
190fn tutor_headerline_spans(session: &super::TutorSession) -> (Vec<(String, u32)>, u32) {
191    use super::TutorGameState;
192
193    let mut s: Vec<(String, u32)> = Vec::new();
194    let sep = (" | ".to_owned(), pal::SEP);
195
196    match &session.state {
197        TutorGameState::AllComplete => {
198            s.push((" *** YOU WIN! *** ".to_owned(), pal::WIN));
199            s.push(sep.clone());
200            s.push(("ALL LESSONS DONE".to_owned(), pal::DONE));
201            s.push(sep.clone());
202            s.push(("FINAL SCORE: ".to_owned(), pal::SCORE_LABEL));
203            s.push((format!("{}", session.score), pal::SCORE_VAL));
204            s.push((" ".to_owned(), pal::SCORE_VAL));
205        }
206        TutorGameState::GameOver => {
207            s.push((" === GAME OVER === ".to_owned(), pal::GAME_OVER));
208            s.push(sep.clone());
209            s.push(("LV.".to_owned(), pal::LV_LABEL));
210            s.push((
211                format!("{}-{}", session.lesson, session.current + 1),
212                pal::LV_NUM,
213            ));
214            s.push(sep.clone());
215            s.push(("SCORE: ".to_owned(), pal::SCORE_LABEL));
216            s.push((format!("{:>5}", session.score), pal::SCORE_VAL));
217            s.push(sep.clone());
218            s.push(("<CR>=skip  <C-k>=retry ".to_owned(), pal::KEY_HINT));
219        }
220        TutorGameState::Active => {
221            if session.is_complete() {
222                let new_record = session.score > session.high_score && session.high_score > 0;
223                s.push((" *** LESSON CLEAR! *** ".to_owned(), pal::CLEAR));
224                s.push(sep.clone());
225                s.push(("LV.".to_owned(), pal::LV_LABEL));
226                s.push((format!("{}", session.lesson), pal::LV_NUM));
227                s.push(sep.clone());
228                s.push(("SCORE: ".to_owned(), pal::SCORE_LABEL));
229                s.push((format!("{:>5}", session.score), pal::SCORE_VAL));
230                if session.high_score > 0 {
231                    s.push(sep.clone());
232                    s.push(("HI: ".to_owned(), pal::HI_LABEL));
233                    s.push((format!("{:>5}", session.high_score), pal::HI_VAL));
234                }
235                if new_record {
236                    s.push(("  NEW RECORD!".to_owned(), pal::WIN));
237                }
238                s.push(sep.clone());
239                s.push(("<CR>=next lesson ".to_owned(), pal::KEY_HINT));
240            } else {
241                s.push((" ".to_owned(), pal::SEP));
242                for i in 0..super::MAX_LIVES {
243                    if i < session.lives {
244                        s.push(("♥".to_owned(), pal::HEART_ON));
245                    } else {
246                        s.push(("♡".to_owned(), pal::HEART_OFF));
247                    }
248                }
249                s.push(sep.clone());
250                s.push(("LV.".to_owned(), pal::LV_LABEL));
251                s.push((
252                    format!("{}-{}", session.lesson, session.current + 1),
253                    pal::LV_NUM,
254                ));
255                s.push(sep.clone());
256                s.push(("SCORE: ".to_owned(), pal::SCORE_LABEL));
257                s.push((format!("{:>5}", session.score), pal::SCORE_VAL));
258                if session.high_score > 0 {
259                    s.push(sep.clone());
260                    s.push(("HI: ".to_owned(), pal::HI_LABEL));
261                    s.push((format!("{:>5}", session.high_score), pal::HI_VAL));
262                }
263                if let Some(ex) = session.current_exercise() {
264                    s.push(sep.clone());
265                    s.push((ex.description.clone(), pal::DESC));
266                    if session.attempt_count >= 2 && !ex.hint.is_empty() {
267                        s.push(("  Hint: ".to_owned(), pal::HINT_LABEL));
268                        s.push((ex.hint.clone(), pal::HINT_VAL));
269                    }
270                }
271                s.push((" ".to_owned(), pal::SEP));
272            }
273        }
274    }
275
276    (s, pal::BG)
277}
278
279/// STAGE CLEAR flash — shown when auto-detect fires, before `<CR>`.
280fn tutor_stage_clear_spans(session: &super::TutorSession) -> (Vec<(String, u32)>, u32) {
281    let ex_id = session
282        .current_exercise()
283        .map(|e| e.id.clone())
284        .unwrap_or_default();
285    let sep = (" | ".to_owned(), pal::SEP);
286    let s = vec![
287        (" *** STAGE CLEAR! *** ".to_owned(), pal::CLEAR),
288        sep.clone(),
289        ("LV.".to_owned(), pal::LV_LABEL),
290        (
291            format!("{}-{}", session.lesson, session.current + 1),
292            pal::LV_NUM,
293        ),
294        sep.clone(),
295        ("SCORE: ".to_owned(), pal::SCORE_LABEL),
296        (format!("{:>5}", session.score), pal::SCORE_VAL),
297        sep.clone(),
298        (format!("Ex {} done", ex_id), pal::DESC),
299        ("  <CR>=next ".to_owned(), pal::KEY_HINT),
300    ];
301    (s, pal::BG)
302}
303
304/// Render function for [`SimpleHeaderlineHandle<TutorViewState>`].
305///
306/// Converts the current session spans into cells and returns a
307/// [`HeaderlineRow`] with the tutor's retro background, or `None`
308/// when there is nothing to display yet.
309pub fn render_tutor_headerline(s: &TutorViewState) -> Option<HeaderlineRow> {
310    if s.spans.is_empty() {
311        return None;
312    }
313    let cells: Arc<[Cell]> = s
314        .spans
315        .iter()
316        .flat_map(|(text, fg)| {
317            let fg = *fg;
318            text.chars().map(move |c| Cell::new(c as u32, fg, 0, 0))
319        })
320        .collect::<Vec<_>>()
321        .into();
322    Some(HeaderlineRow {
323        cells,
324        bg: Some(s.row_bg),
325    })
326}