1use 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
15fn 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
32fn 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
41pub(super) struct RenderCtx<'a> {
44 pub state: &'a WizardState,
45 pub leaf: usize,
47 pub lines: Vec<Line<'static>>,
48 pub cursor_line: Option<usize>,
50 pub cursor_col: usize,
52 pub theme: &'a Theme,
53}
54
55impl<'a> RenderCtx<'a> {
56 fn push_connector(&mut self, depth: u8) {
59 let (spans, _) = depth_prefix(depth, self.theme);
60 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 let _ = spans;
70 self.lines.push(line);
71 }
72
73 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 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 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 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 {
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 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 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 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 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 _ => {
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 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 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
340pub 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}