Skip to main content

git_same/setup/
ui.rs

1//! Setup wizard render dispatcher.
2
3use super::screens;
4use super::state::{SetupState, SetupStep};
5use crate::banner;
6use ratatui::layout::{Alignment, Constraint, Layout, Rect};
7use ratatui::style::{Color, Modifier, Style};
8use ratatui::text::{Line, Span};
9use ratatui::widgets::{Block, Borders, Paragraph};
10use ratatui::Frame;
11
12/// Render the setup wizard.
13pub fn render(state: &SetupState, frame: &mut Frame) {
14    let area = frame.area();
15    let height = area.height;
16    let path_popup_active = state.step == SetupStep::SelectPath && state.path_browse_mode;
17
18    // Graceful degradation for small terminals
19    let show_banner = height >= 30;
20    let show_progress = height >= 20;
21
22    let mut constraints = Vec::new();
23    if show_banner {
24        constraints.push(Constraint::Length(6)); // Banner
25    }
26    constraints.push(Constraint::Length(2)); // Title
27    if show_progress {
28        constraints.push(Constraint::Length(4)); // Step progress indicator (with border)
29    }
30    constraints.push(Constraint::Min(10)); // Step content (with border)
31    constraints.push(Constraint::Length(2)); // Status bar
32
33    let chunks = Layout::vertical(constraints).split(area);
34
35    let mut idx = 0;
36
37    // Banner
38    if show_banner {
39        if state.step == SetupStep::Complete {
40            let phase = (state.tick_count % 100) as f64 / 100.0;
41            banner::render_animated_banner(frame, chunks[idx], phase);
42        } else {
43            banner::render_banner(frame, chunks[idx]);
44        }
45        idx += 1;
46    }
47
48    // Title
49    let title_text = if state.is_first_setup {
50        "Workspace Setup"
51    } else {
52        "New Workspace"
53    };
54    let title = Paragraph::new(title_text)
55        .style(
56            Style::default()
57                .fg(if path_popup_active {
58                    Color::DarkGray
59                } else {
60                    Color::White
61                })
62                .add_modifier(Modifier::BOLD),
63        )
64        .alignment(Alignment::Center);
65    frame.render_widget(title, chunks[idx]);
66    idx += 1;
67
68    // Step progress indicator
69    if show_progress {
70        let progress_block = Block::default()
71            .borders(Borders::ALL)
72            .border_style(Style::default().fg(Color::DarkGray));
73        let progress_inner = progress_block.inner(chunks[idx]);
74        frame.render_widget(progress_block, chunks[idx]);
75        render_step_progress(state, frame, progress_inner, path_popup_active);
76        idx += 1;
77    }
78
79    // Step content
80    let content_area = chunks[idx];
81    let content_block = Block::default()
82        .borders(Borders::ALL)
83        .border_style(Style::default().fg(Color::DarkGray));
84    let content_inner = content_block.inner(content_area);
85    frame.render_widget(content_block, content_area);
86    idx += 1;
87
88    match state.step {
89        SetupStep::Requirements => screens::requirements::render(state, frame, content_inner),
90        SetupStep::SelectProvider => screens::provider::render(state, frame, content_inner),
91        SetupStep::Authenticate => screens::auth::render(state, frame, content_inner),
92        SetupStep::SelectOrgs => screens::orgs::render(state, frame, content_inner),
93        SetupStep::SelectPath => screens::path::render(state, frame, content_inner),
94        SetupStep::Confirm => screens::confirm::render(state, frame, content_inner),
95        SetupStep::Complete => screens::complete::render(state, frame, content_inner),
96    }
97
98    // Status bar
99    render_status_bar(state, frame, chunks[idx]);
100}
101
102/// Render the step progress indicator with nodes and connectors.
103fn render_step_progress(state: &SetupState, frame: &mut Frame, area: Rect, dimmed: bool) {
104    let steps = ["Reqs", "Provider", "Auth", "Orgs", "Path", "Save"];
105    let current = state.step_number(); // 1-6 for steps, 6 for Complete
106
107    let green = if dimmed {
108        Style::default().fg(Color::DarkGray)
109    } else {
110        Style::default().fg(Color::Rgb(21, 128, 61))
111    };
112    let green_bold = green.add_modifier(Modifier::BOLD);
113    let cyan_bold = if dimmed {
114        Style::default()
115            .fg(Color::DarkGray)
116            .add_modifier(Modifier::BOLD)
117    } else {
118        Style::default()
119            .fg(Color::Cyan)
120            .add_modifier(Modifier::BOLD)
121    };
122    let dim = Style::default().fg(Color::DarkGray);
123
124    // 6 nodes + 5 connectors = 11 segments. Ratio: 3/28 per node, 2/28 per connector (6*3 + 5*2 = 28)
125    let segments = Layout::horizontal([
126        Constraint::Ratio(3, 28),
127        Constraint::Ratio(2, 28),
128        Constraint::Ratio(3, 28),
129        Constraint::Ratio(2, 28),
130        Constraint::Ratio(3, 28),
131        Constraint::Ratio(2, 28),
132        Constraint::Ratio(3, 28),
133        Constraint::Ratio(2, 28),
134        Constraint::Ratio(3, 28),
135        Constraint::Ratio(2, 28),
136        Constraint::Ratio(3, 28),
137    ])
138    .split(area);
139
140    let mut node_spans: Vec<Span> = Vec::new();
141    let mut label_spans: Vec<Span> = Vec::new();
142
143    for (i, label) in steps.iter().enumerate() {
144        let step_num = i + 1;
145        let node_width = segments[i * 2].width as usize;
146        let node_style = if step_num < current || state.step == SetupStep::Complete {
147            green_bold
148        } else if step_num == current {
149            cyan_bold
150        } else {
151            dim
152        };
153        let label_style = if step_num < current || state.step == SetupStep::Complete {
154            green
155        } else if step_num == current {
156            cyan_bold
157        } else {
158            dim
159        };
160
161        let node_text = if step_num < current || state.step == SetupStep::Complete {
162            "(\u{2713})".to_string()
163        } else {
164            format!("({})", step_num)
165        };
166
167        node_spans.push(Span::styled(
168            center_cell(&node_text, node_width),
169            node_style,
170        ));
171        label_spans.push(Span::styled(center_cell(label, node_width), label_style));
172
173        if i < steps.len() - 1 {
174            let connector_width = segments[i * 2 + 1].width as usize;
175            let connector_done = step_num < current || state.step == SetupStep::Complete;
176            let connector_style = if connector_done { green } else { dim };
177            node_spans.push(Span::styled(
178                connector_cell(connector_width, connector_done),
179                connector_style,
180            ));
181            label_spans.push(Span::raw(" ".repeat(connector_width)));
182        }
183    }
184
185    let widget = Paragraph::new(vec![Line::from(node_spans), Line::from(label_spans)]);
186    frame.render_widget(widget, area);
187}
188
189fn center_cell(text: &str, width: usize) -> String {
190    if width == 0 {
191        return String::new();
192    }
193
194    let text = if text.chars().count() > width {
195        text.chars().take(width).collect::<String>()
196    } else {
197        text.to_string()
198    };
199    let text_width = text.chars().count();
200    let left_pad = (width - text_width) / 2;
201    let right_pad = width - text_width - left_pad;
202    format!("{}{}{}", " ".repeat(left_pad), text, " ".repeat(right_pad))
203}
204
205fn connector_cell(width: usize, completed: bool) -> String {
206    if width == 0 {
207        return String::new();
208    }
209
210    if completed {
211        return "\u{2501}".repeat(width);
212    }
213
214    // Dashed connector for upcoming steps.
215    let mut out = String::with_capacity(width);
216    for i in 0..width {
217        if i % 2 == 0 {
218            out.push('\u{2500}');
219        } else {
220            out.push(' ');
221        }
222    }
223    out
224}
225
226/// Render the 2-line status bar with actions and navigation hints.
227fn render_status_bar(state: &SetupState, frame: &mut Frame, area: Rect) {
228    let path_popup_active = state.step == SetupStep::SelectPath && state.path_browse_mode;
229    let blue = if path_popup_active {
230        Style::default()
231            .fg(Color::DarkGray)
232            .add_modifier(Modifier::BOLD)
233    } else {
234        Style::default()
235            .fg(Color::Rgb(37, 99, 235))
236            .add_modifier(Modifier::BOLD)
237    };
238    let dim = Style::default().fg(Color::DarkGray);
239    let yellow = if path_popup_active {
240        Style::default().fg(Color::DarkGray)
241    } else {
242        Style::default().fg(Color::Yellow)
243    };
244
245    let top_center = match state.step {
246        SetupStep::Requirements => {
247            if state.checks_loading {
248                vec![Span::styled("Checking system requirements...", yellow)]
249            } else if state.check_results.iter().any(|r| r.critical && !r.passed) {
250                vec![Span::styled(
251                    "Fix critical requirements to continue",
252                    yellow,
253                )]
254            } else if !state.check_results.is_empty() {
255                vec![
256                    Span::styled("[Enter]", blue),
257                    Span::styled(" Continue to setup", dim),
258                ]
259            } else {
260                vec![Span::styled("Preparing...", dim)]
261            }
262        }
263        SetupStep::SelectProvider => vec![
264            Span::styled("[↑] [↓]", blue),
265            Span::styled(" Select provider", dim),
266        ],
267        SetupStep::Authenticate => {
268            use super::state::AuthStatus;
269            match &state.auth_status {
270                AuthStatus::Pending | AuthStatus::Failed(_) => vec![
271                    Span::styled("[Enter]", blue),
272                    Span::styled(" Authenticate", dim),
273                ],
274                AuthStatus::Success => vec![
275                    Span::styled("[Enter]", blue),
276                    Span::styled(" Continue", dim),
277                ],
278                AuthStatus::Checking => vec![Span::styled("Authenticating...", yellow)],
279            }
280        }
281        SetupStep::SelectPath => {
282            if state.path_browse_mode {
283                vec![Span::styled("Folder popup active", dim)]
284            } else {
285                vec![
286                    Span::styled("[b]", blue),
287                    Span::styled(" Open Folder Navigator", dim),
288                ]
289            }
290        }
291        SetupStep::SelectOrgs => {
292            if state.org_loading {
293                vec![Span::styled("Discovering organizations...", yellow)]
294            } else {
295                vec![
296                    Span::styled("[Space]", blue),
297                    Span::styled(" Toggle  ", dim),
298                    Span::styled("[a]", blue),
299                    Span::styled(" All  ", dim),
300                    Span::styled("[n]", blue),
301                    Span::styled(" None", dim),
302                ]
303            }
304        }
305        SetupStep::Confirm => vec![
306            Span::styled("[Enter]", blue),
307            Span::styled(" Save workspace", dim),
308        ],
309        SetupStep::Complete => vec![
310            Span::styled("[Enter]", blue),
311            Span::styled(" Dashboard  ", dim),
312            Span::styled("[s]", blue),
313            Span::styled(" Sync Now", dim),
314        ],
315    };
316
317    let bottom_left = if path_popup_active {
318        vec![
319            Span::styled("[Esc]", blue),
320            Span::styled(" Close Popup", dim),
321        ]
322    } else {
323        vec![
324            Span::styled("[q]", blue),
325            Span::styled(" Quit  ", dim),
326            Span::styled("[Esc]", blue),
327            Span::styled(" Back", dim),
328        ]
329    };
330
331    let bottom_right = match state.step {
332        SetupStep::SelectProvider | SetupStep::SelectOrgs => vec![
333            Span::styled("[↑] [↓]", blue),
334            Span::styled(" Move  ", dim),
335            Span::styled("[←] [→]", blue),
336            Span::styled(" Step  ", dim),
337            Span::styled("[Enter]", blue),
338            Span::styled(" Next Step", dim),
339        ],
340        SetupStep::SelectPath => {
341            if state.path_browse_mode {
342                vec![Span::styled("Use popup arrows and Enter", dim)]
343            } else {
344                vec![
345                    Span::styled("[←]", blue),
346                    Span::styled(" Back Step  ", dim),
347                    Span::styled("[Enter]", blue),
348                    Span::styled(" Next Step  ", dim),
349                    Span::styled("[b]", blue),
350                    Span::styled(" Browse folders", dim),
351                ]
352            }
353        }
354        _ => vec![
355            Span::styled("[←] [→]", blue),
356            Span::styled(" Step  ", dim),
357            Span::styled("[Enter]", blue),
358            Span::styled(" Next Step", dim),
359        ],
360    };
361
362    let rows = Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).split(area);
363    let step_num = state.step_number();
364    let step_text = if step_num > 0 {
365        Some(format!("Step {} of {}", step_num, SetupState::TOTAL_STEPS))
366    } else {
367        None
368    };
369
370    let step_width = step_text
371        .as_ref()
372        .map(|s| s.chars().count() as u16 + 1)
373        .unwrap_or(0);
374    let top_cols =
375        Layout::horizontal([Constraint::Length(step_width), Constraint::Min(0)]).split(rows[0]);
376
377    if let Some(text) = step_text {
378        frame.render_widget(
379            Paragraph::new(Line::from(Span::styled(text, dim))),
380            top_cols[0],
381        );
382    }
383    frame.render_widget(
384        Paragraph::new(Line::from(top_center)).alignment(Alignment::Center),
385        top_cols[1],
386    );
387
388    let bottom_cols =
389        Layout::horizontal([Constraint::Length(24), Constraint::Min(0)]).split(rows[1]);
390    frame.render_widget(Paragraph::new(Line::from(bottom_left)), bottom_cols[0]);
391    frame.render_widget(
392        Paragraph::new(Line::from(bottom_right)).right_aligned(),
393        bottom_cols[1],
394    );
395}
396
397#[cfg(test)]
398#[path = "ui_tests.rs"]
399mod tests;