Skip to main content

tui_kit/wizard/
render.rs

1//! Rendering logic for the wizard widget.
2
3use ratatui::{
4    layout::Rect,
5    text::{Line, Span},
6    widgets::{Block, Borders, Clear, Paragraph, Wrap},
7    Frame,
8};
9use unicode_width::UnicodeWidthStr;
10
11use crate::Theme;
12use super::types::{WizardState, WizardStep, WizardStepKind};
13use super::helpers::max_sub_label;
14
15// ── Depth prefix ──────────────────────────────────────────────────────────────
16
17/// Build the indentation prefix spans and column offset for any nesting depth.
18///
19/// Depth 0: `"  "`  (2 cols)
20/// Each additional level appends `"│  "` (+3 cols).
21fn depth_prefix(depth: u8, theme: &Theme) -> (Vec<Span<'static>>, usize) {
22    let mut spans: Vec<Span<'static>> = vec![Span::raw("  ")];
23    let mut cols = 2usize;
24    for _ in 0..depth {
25        spans.push(Span::styled("│", theme.hint));
26        spans.push(Span::raw("  "));
27        cols += 3;
28    }
29    (spans, cols)
30}
31
32// ── Bullet helpers ────────────────────────────────────────────────────────────
33
34fn bullet(completed: bool, active: bool, section_active: bool) -> &'static str {
35    if section_active   { "◉" }
36    else if completed   { "●" }
37    else if active      { "○" }
38    else                { "◌" }
39}
40
41// ── RenderCtx ─────────────────────────────────────────────────────────────────
42
43pub(super) struct RenderCtx<'a> {
44    pub state:       &'a WizardState,
45    /// DFS leaf counter — mutated as we walk the tree.
46    pub leaf:        usize,
47    pub lines:       Vec<Line<'static>>,
48    /// Content-line index of the active input row (for terminal cursor placement).
49    pub cursor_line: Option<usize>,
50    /// Display-column offset for the terminal cursor within that line.
51    pub cursor_col:  usize,
52    pub theme:       &'a Theme,
53}
54
55impl<'a> RenderCtx<'a> {
56    // ── Low-level line builders ───────────────────────────────────────────────
57
58    fn push_connector(&mut self, depth: u8) {
59        let (spans, _) = depth_prefix(depth, self.theme);
60        // Connectors show the vertical bar for the *next* level — so they use
61        // depth+1 prefix but we just want to append a │ at the current depth.
62        // Simplest correct form: re-use depth_prefix but emit connector bar.
63        let line = {
64            let (mut s, _) = depth_prefix(depth, self.theme);
65            s.push(Span::styled("│", self.theme.hint));
66            Line::from(s)
67        };
68        // discard unused spans from first call
69        let _ = spans;
70        self.lines.push(line);
71    }
72
73    /// Push one labelled step row.  Returns the column where the value starts.
74    fn push_step_row(
75        &mut self,
76        label: &'static str,
77        value_text: &str,
78        bul: &'static str,
79        bul_style: ratatui::style::Style,
80        depth: u8,
81        label_pad: usize,
82    ) -> usize {
83        let (mut spans, prefix_cols) = depth_prefix(depth, self.theme);
84        let value_col = prefix_cols + 1 + 2 + label_pad + 2;
85        spans.push(Span::styled(bul, bul_style));
86        spans.push(Span::raw("  "));
87        spans.push(Span::styled(
88            format!("{:<pad$}  ", label, pad = label_pad),
89            self.theme.shortcut_key,
90        ));
91        if !value_text.is_empty() {
92            spans.push(Span::styled(value_text.to_string(), self.theme.body));
93        }
94        self.lines.push(Line::from(spans));
95        value_col
96    }
97
98    // ── Render a slice of steps ───────────────────────────────────────────────
99
100    pub(super) fn render_steps(&mut self, steps: &'static [WizardStep], depth: u8, skip_first_connector: bool) {
101        let max_label = steps.iter().map(|s| s.label.chars().count()).max().unwrap_or(4);
102
103        for (i, step) in steps.iter().enumerate() {
104            if !skip_first_connector || i > 0 {
105                self.push_connector(depth);
106            }
107
108            match &step.kind {
109
110                // ── Section ──────────────────────────────────────────────────
111                WizardStepKind::Section(children) => {
112                    let first_leaf = self.leaf;
113                    let n_leaves   = super::helpers::count_leaves(children);
114                    let last_leaf  = first_leaf + n_leaves.saturating_sub(1);
115
116                    let completed  = self.state.current > last_leaf;
117                    let active     = self.state.current >= first_leaf && self.state.current <= last_leaf;
118                    let bul        = bullet(completed, active, active);
119                    let bul_style  = if active { self.theme.shortcut_key } else { self.theme.hint };
120
121                    self.push_step_row(step.label, "", bul, bul_style, depth, max_label);
122
123                    if active || completed {
124                        self.render_steps(children, depth + 1, false);
125                        self.push_connector(depth);
126                    }
127                    if !active && !completed { self.leaf += n_leaves; }
128                }
129
130                // ── Array ─────────────────────────────────────────────────────
131                WizardStepKind::Array(sub_steps) => {
132                    let leaf_idx   = self.leaf;
133                    self.leaf += 1;
134
135                    let completed  = self.state.current > leaf_idx;
136                    let active     = self.state.current == leaf_idx;
137                    let arr        = self.state.array_states.get(&leaf_idx);
138                    let item_count = arr.map(|a| a.items.len()).unwrap_or(0);
139                    let is_expanded = arr.map(|a| a.expanded).unwrap_or(false);
140                    let header_sel  = arr.map(|a| a.header_sel).unwrap_or(0);
141                    let arr_selected = arr.map(|a| a.selected).unwrap_or(0);
142                    let item_btn_sel = arr.map(|a| a.item_btn_sel).unwrap_or(0);
143                    let editing_item = arr.and_then(|a| a.editing.as_ref()).map(|s| s.item_idx);
144
145                    let has_items  = item_count > 0 || editing_item.is_some();
146                    let bul        = bullet(completed, active, active && has_items && is_expanded);
147                    let bul_style  = if active || completed { self.theme.shortcut_key } else { self.theme.hint };
148
149                    // Header row: "○  fields    [ + add ]  [n]"
150                    // When collapsed & active: the focused header button is highlighted.
151                    // When expanded or completed: buttons shown dimly.
152                    {
153                        let (add_style, badge_style) = if active && !is_expanded {
154                            (
155                                if header_sel == 0 { self.theme.selection } else { self.theme.hint },
156                                if header_sel == 1 { self.theme.selection } else { self.theme.hint },
157                            )
158                        } else {
159                            (self.theme.hint, self.theme.hint)
160                        };
161                        let (mut spans, _) = depth_prefix(depth, self.theme);
162                        spans.push(Span::styled(bul, bul_style));
163                        spans.push(Span::raw("  "));
164                        spans.push(Span::styled(
165                            format!("{:<pad$}  ", step.label, pad = max_label),
166                            self.theme.shortcut_key,
167                        ));
168                        if active || completed {
169                            spans.push(Span::styled("[ + add ]", add_style));
170                            spans.push(Span::raw("  "));
171                        }
172                        spans.push(Span::styled(format!("[{}]", item_count), badge_style));
173                        self.lines.push(Line::from(spans));
174                    }
175
176                    // Items — only shown when expanded (respects collapsed state even when completed)
177                    if is_expanded && (active || completed) {
178                        let items = self.state.array_states.get(&leaf_idx)
179                            .map(|a| a.items.clone()).unwrap_or_default();
180                        let lp = max_sub_label(sub_steps);
181
182                        for j in 0..items.len() {
183                            self.push_connector(depth + 1);
184                            let is_editing  = editing_item == Some(j);
185                            let is_selected = active && editing_item.is_none() && j == arr_selected;
186
187                            // Item header: "●  #j  [ remove ]" (remove only when selected)
188                            let (item_bul, item_bul_style) = if is_editing {
189                                ("○", self.theme.shortcut_key)
190                            } else if is_selected {
191                                ("►", self.theme.selection)
192                            } else {
193                                ("●", self.theme.hint)
194                            };
195                            let (mut header_spans, _) = depth_prefix(depth + 1, self.theme);
196                            header_spans.push(Span::styled(item_bul, item_bul_style));
197                            header_spans.push(Span::raw("  "));
198                            header_spans.push(Span::styled(format!("#{}", j + 1), item_bul_style));
199                            if is_selected && !is_editing {
200                                header_spans.push(Span::raw("  "));
201                                let remove_style = if item_btn_sel == 1 { self.theme.selection } else { self.theme.hint };
202                                header_spans.push(Span::styled("[ remove ]", remove_style));
203                            }
204                            self.lines.push(Line::from(header_spans));
205
206                            // Sub-steps beneath item header
207                            if is_editing {
208                                let session    = self.state.array_states[&leaf_idx].editing.as_ref().unwrap();
209                                let sub_step   = session.sub_step;
210                                let buffer     = session.buffer.clone();
211                                let buf_cursor = session.buf_cursor;
212                                let select_idx = session.select_idx;
213                                let item_vals  = items[j].clone();
214                                self.push_edit_rows(sub_steps, depth + 2, lp, sub_step, &buffer, buf_cursor, select_idx, &item_vals);
215                            } else {
216                                for (k, sub) in sub_steps.iter().enumerate() {
217                                    self.push_connector(depth + 2);
218                                    let val     = items[j].get(k).cloned().unwrap_or_default();
219                                    let display = if val.is_empty() { "(none)".to_string() } else { val };
220                                    self.push_step_row(sub.label, &display, "●", self.theme.hint, depth + 2, lp);
221                                }
222                            }
223                        }
224
225                        self.push_connector(depth);
226                    }
227                }
228
229                // ── Buttons ───────────────────────────────────────────────────
230                WizardStepKind::Buttons(labels) => {
231                    let leaf_idx = self.leaf;
232                    self.leaf += 1;
233                    let completed = self.state.current > leaf_idx;
234                    let active    = self.state.current == leaf_idx;
235
236                    let (mut spans, _) = depth_prefix(depth, self.theme);
237                    let bul_ch = if active { "○" } else if completed { "●" } else { "◌" };
238                    let bul_st = if active { self.theme.shortcut_key } else { self.theme.hint };
239                    spans.push(Span::styled(bul_ch, bul_st));
240                    spans.push(Span::raw("  "));
241                    for (j, &lbl) in labels.iter().enumerate() {
242                        if j > 0 { spans.push(Span::raw("  ")); }
243                        let is_sel = active && j == self.state.button_selected;
244                        let style = if is_sel { self.theme.selection }
245                                    else if active || completed { self.theme.shortcut_key }
246                                    else { self.theme.hint };
247                        spans.push(Span::styled(format!("[ {} ]", lbl), style));
248                    }
249                    self.lines.push(Line::from(spans));
250                }
251
252                // ── Regular leaf (Leaf, Optional, Select) ─────────────────────
253                _ => {
254                    let leaf_idx = self.leaf;
255                    self.leaf += 1;
256
257                    let active = self.state.current == leaf_idx;
258                    let has_stored = leaf_idx < self.state.values.len()
259                        && !self.state.values[leaf_idx].is_empty();
260                    // Show as completed if past OR if it has a stored value (cycling support)
261                    let completed = !active && (self.state.current > leaf_idx || has_stored);
262                    let bul       = bullet(completed, active, false);
263                    let bul_style = if active || completed { self.theme.shortcut_key } else { self.theme.hint };
264
265                    let value_text: String = if completed {
266                        let v = self.state.values.get(leaf_idx).map(|s| s.as_str()).unwrap_or("");
267                        if v.is_empty() { "(none)".into() } else { v.into() }
268                    } else if active && leaf_idx < self.state.input_count {
269                        match &step.kind {
270                            WizardStepKind::Select(opts) => opts.first().copied().unwrap_or("").to_string(),
271                            _ => self.state.buffer.clone(),
272                        }
273                    } else {
274                        String::new()
275                    };
276
277                    let value_col = self.push_step_row(step.label, &value_text, bul, bul_style, depth, max_label);
278
279                    if active && leaf_idx < self.state.input_count
280                        && matches!(step.kind, WizardStepKind::Leaf | WizardStepKind::Optional)
281                    {
282                        let cursor_display = UnicodeWidthStr::width(&self.state.buffer[..self.state.cursor]);
283                        self.cursor_line = Some(self.lines.len() - 1);
284                        self.cursor_col  = value_col + cursor_display;
285                    }
286                }
287            }
288        }
289    }
290
291    // ── In-progress item edit rows ────────────────────────────────────────────
292
293    fn push_edit_rows(
294        &mut self,
295        sub_steps: &'static [WizardStep],
296        depth: u8,
297        label_pad: usize,
298        sub_step: usize,
299        buffer: &str,
300        buf_cursor: usize,
301        select_idx: usize,
302        item_values: &[String],
303    ) {
304        for (i, sub) in sub_steps.iter().enumerate() {
305            if i > 0 { self.push_connector(depth); }
306
307            let is_active = i == sub_step;
308            let bul       = if is_active { "○" } else if i < sub_step { "●" } else { "◌" };
309            let bul_style = if is_active { self.theme.shortcut_key } else { self.theme.hint };
310
311            let value_text: String = match &sub.kind {
312                WizardStepKind::Select(opts) if is_active => format!("◀ {} ▶", opts[select_idx]),
313                _ if is_active                            => buffer.to_string(),
314                _ if i < sub_step                         => item_values.get(i).cloned().unwrap_or_default(),
315                _                                         => String::new(),
316            };
317
318            let (mut spans, prefix_cols) = depth_prefix(depth, self.theme);
319            spans.push(Span::styled(bul, bul_style));
320            spans.push(Span::raw("  "));
321            spans.push(Span::styled(
322                format!("{:<pad$}  ", sub.label, pad = label_pad),
323                if is_active { self.theme.shortcut_key } else { self.theme.hint },
324            ));
325            if !value_text.is_empty() {
326                spans.push(Span::styled(value_text.clone(), self.theme.body));
327            }
328            self.lines.push(Line::from(spans));
329
330            if is_active && matches!(sub.kind, WizardStepKind::Leaf | WizardStepKind::Optional) {
331                let value_col  = prefix_cols + 1 + 2 + label_pad + 2;
332                let cursor_disp = UnicodeWidthStr::width(&buffer[..buf_cursor]);
333                self.cursor_line = Some(self.lines.len() - 1);
334                self.cursor_col  = value_col + cursor_disp;
335            }
336        }
337    }
338}
339
340// ── Public render entry point ─────────────────────────────────────────────────
341
342/// Render the wizard as a popup inside `area`.
343pub fn render_wizard(f: &mut Frame, state: &WizardState, area: Rect, title: &str, theme: &Theme) {
344    let mut ctx = RenderCtx {
345        state,
346        leaf: 0,
347        lines: vec![],
348        cursor_line: None,
349        cursor_col: 0,
350        theme,
351    };
352
353    ctx.render_steps(state.steps, 0, true);
354
355    let paragraph = Paragraph::new(ratatui::text::Text::from(ctx.lines))
356        .block(
357            Block::default()
358                .borders(Borders::ALL)
359                .border_style(theme.border_popup)
360                .title(title.to_string())
361                .title_style(theme.border_popup.add_modifier(ratatui::style::Modifier::BOLD)),
362        )
363        .wrap(Wrap { trim: false });
364
365    f.render_widget(Clear, area);
366    f.render_widget(paragraph, area);
367
368    if let Some(line) = ctx.cursor_line {
369        let inner = area.inner(ratatui::layout::Margin { horizontal: 1, vertical: 1 });
370        let cx = inner.x + ctx.cursor_col as u16;
371        let cy = inner.y + line as u16;
372        if cx < inner.x + inner.width && cy < inner.y + inner.height {
373            f.set_cursor_position((cx, cy));
374        }
375    }
376}