1use ratatui::{
7 layout::{Constraint, Layout, Rect},
8 style::{Color, Modifier, Style},
9 text::{Line, Span},
10 widgets::{Block, Borders, List, ListItem, Paragraph},
11 Frame,
12};
13
14use crossterm::event::{KeyCode, KeyEvent};
15
16use crate::banner::render_banner;
17use crate::tui::app::App;
18
19pub fn handle_key(app: &mut App, key: KeyEvent) {
20 let num_items = 2; match key.code {
22 KeyCode::Tab | KeyCode::Down => {
23 app.settings_index = (app.settings_index + 1) % num_items;
24 }
25 KeyCode::Up => {
26 app.settings_index = (app.settings_index + num_items - 1) % num_items;
27 }
28 KeyCode::Char('c') => {
29 if let Ok(path) = crate::config::Config::default_path() {
31 if let Some(parent) = path.parent() {
32 if let Err(e) = open_directory(parent) {
33 app.error_message = Some(format!(
34 "Failed to open config directory '{}': {}",
35 parent.display(),
36 e
37 ));
38 }
39 }
40 }
41 }
42 KeyCode::Char('d') => {
43 app.dry_run = !app.dry_run;
44 }
45 KeyCode::Char('m') => {
46 app.sync_pull = !app.sync_pull;
47 }
48 _ => {}
49 }
50}
51
52#[cfg(target_os = "macos")]
53fn open_directory(path: &std::path::Path) -> std::io::Result<()> {
54 std::process::Command::new("open").arg(path).spawn()?;
55 Ok(())
56}
57
58#[cfg(target_os = "windows")]
59fn open_directory(path: &std::path::Path) -> std::io::Result<()> {
60 std::process::Command::new("explorer").arg(path).spawn()?;
61 Ok(())
62}
63
64#[cfg(all(not(target_os = "macos"), not(target_os = "windows")))]
65fn open_directory(path: &std::path::Path) -> std::io::Result<()> {
66 std::process::Command::new("xdg-open").arg(path).spawn()?;
67 Ok(())
68}
69
70pub fn render(app: &App, frame: &mut Frame) {
71 let chunks = Layout::vertical([
72 Constraint::Length(6), Constraint::Length(3), Constraint::Min(5), Constraint::Length(2), ])
77 .split(frame.area());
78
79 render_banner(frame, chunks[0]);
80
81 let title = Paragraph::new(Line::from(vec![Span::styled(
83 " Settings ",
84 Style::default()
85 .fg(Color::Cyan)
86 .add_modifier(Modifier::BOLD),
87 )]))
88 .block(
89 Block::default()
90 .borders(Borders::ALL)
91 .border_style(Style::default().fg(Color::DarkGray)),
92 )
93 .centered();
94 frame.render_widget(title, chunks[1]);
95
96 let panes = Layout::horizontal([Constraint::Percentage(25), Constraint::Percentage(75)])
98 .split(chunks[2]);
99
100 render_category_nav(app, frame, panes[0]);
101
102 match app.settings_index {
103 0 => render_requirements_detail(app, frame, panes[1]),
104 1 => render_options_detail(app, frame, panes[1]),
105 _ => render_requirements_detail(app, frame, panes[1]),
106 }
107
108 render_bottom_actions(app, frame, chunks[3]);
109}
110
111fn render_category_nav(app: &App, frame: &mut Frame, area: Rect) {
112 let header_style = Style::default()
113 .fg(Color::White)
114 .add_modifier(Modifier::BOLD);
115
116 let items: Vec<ListItem> = vec![
117 ListItem::new(Line::from(Span::styled(" Global", header_style))),
119 nav_item("Requirements", app.settings_index == 0),
121 nav_item("Options", app.settings_index == 1),
123 ];
124
125 let list = List::new(items).block(
126 Block::default()
127 .borders(Borders::ALL)
128 .border_style(Style::default().fg(Color::DarkGray)),
129 );
130 frame.render_widget(list, area);
131}
132
133fn nav_item(label: &str, selected: bool) -> ListItem<'static> {
134 let (marker, style) = if selected {
135 (
136 ">",
137 Style::default()
138 .fg(Color::Cyan)
139 .add_modifier(Modifier::BOLD),
140 )
141 } else {
142 (" ", Style::default())
143 };
144 ListItem::new(Line::from(vec![
145 Span::styled(format!(" {} ", marker), style),
146 Span::styled(label.to_string(), style),
147 ]))
148}
149
150fn render_requirements_detail(app: &App, frame: &mut Frame, area: Rect) {
151 let dim = Style::default().fg(Color::DarkGray);
152 let section_style = Style::default()
153 .fg(Color::White)
154 .add_modifier(Modifier::BOLD);
155 let pass_style = Style::default()
156 .fg(Color::Rgb(21, 128, 61))
157 .add_modifier(Modifier::BOLD);
158 let fail_style = Style::default().fg(Color::Red).add_modifier(Modifier::BOLD);
159
160 let mut lines = vec![
161 Line::from(""),
162 Line::from(Span::styled(" Requirements", section_style)),
163 Line::from(""),
164 ];
165
166 if app.check_results.is_empty() {
167 let msg = if app.checks_loading {
168 " Loading..."
169 } else {
170 " Checks not yet run"
171 };
172 lines.push(Line::from(Span::styled(msg, dim)));
173 } else {
174 for check in &app.check_results {
175 let (marker, marker_style) = if check.passed {
176 ("\u{2713}", pass_style)
177 } else {
178 ("\u{2717}", fail_style)
179 };
180 let mut spans = vec![
181 Span::styled(" ", dim),
182 Span::styled(marker.to_string(), marker_style),
183 Span::styled(format!(" {:<14}", check.name), dim),
184 Span::styled(&check.message, dim),
185 ];
186 if !check.passed && check.critical {
187 spans.push(Span::styled(" (critical)", fail_style));
188 }
189 lines.push(Line::from(spans));
190 if !check.passed {
191 if let Some(suggestion) = &check.suggestion {
192 lines.push(Line::from(vec![
193 Span::styled(" ", dim),
194 Span::styled(suggestion, dim),
195 ]));
196 }
197 }
198 }
199 }
200
201 let content = Paragraph::new(lines).block(
202 Block::default()
203 .borders(Borders::ALL)
204 .border_style(Style::default().fg(Color::DarkGray)),
205 );
206 frame.render_widget(content, area);
207}
208
209fn render_options_detail(app: &App, frame: &mut Frame, area: Rect) {
210 let dim = Style::default().fg(Color::DarkGray);
211 let key_style = Style::default()
212 .fg(Color::Rgb(37, 99, 235))
213 .add_modifier(Modifier::BOLD);
214 let section_style = Style::default()
215 .fg(Color::White)
216 .add_modifier(Modifier::BOLD);
217 let active_style = Style::default()
218 .fg(Color::Rgb(21, 128, 61))
219 .add_modifier(Modifier::BOLD);
220
221 let config_path = crate::config::Config::default_path()
222 .ok()
223 .and_then(|p| p.parent().map(|parent| parent.display().to_string()))
224 .unwrap_or_else(|| "~/.config/git-same".to_string());
225
226 let (dry_yes, dry_no) = if app.dry_run {
228 (active_style, dim)
229 } else {
230 (dim, active_style)
231 };
232
233 let (mode_fetch, mode_pull) = if app.sync_pull {
235 (dim, active_style)
236 } else {
237 (active_style, dim)
238 };
239
240 let lines = vec![
241 Line::from(""),
242 Line::from(Span::styled(" Global Config", section_style)),
243 Line::from(""),
244 Line::from(vec![Span::styled(
245 format!(" Concurrency: {}", app.config.concurrency),
246 dim,
247 )]),
248 Line::from(""),
249 Line::from(Span::styled(" Options", section_style)),
250 Line::from(""),
251 Line::from(vec![
252 Span::styled(" ", dim),
253 Span::styled("[d]", key_style),
254 Span::styled(" Dry run: ", dim),
255 Span::styled("Yes", dry_yes),
256 Span::styled(" / ", dim),
257 Span::styled("No", dry_no),
258 ]),
259 Line::from(vec![
260 Span::styled(" ", dim),
261 Span::styled("[m]", key_style),
262 Span::styled(" Mode: ", dim),
263 Span::styled("Fetch", mode_fetch),
264 Span::styled(" / ", dim),
265 Span::styled("Pull", mode_pull),
266 ]),
267 Line::from(""),
268 Line::from(Span::styled(" Folders", section_style)),
269 Line::from(""),
270 Line::from(vec![
271 Span::styled(" ", dim),
272 Span::styled("[c]", key_style),
273 Span::styled(" Config folder", dim),
274 Span::styled(format!(" \u{2014} {}", config_path), dim),
275 ]),
276 ];
277
278 let content = Paragraph::new(lines).block(
279 Block::default()
280 .borders(Borders::ALL)
281 .border_style(Style::default().fg(Color::DarkGray)),
282 );
283 frame.render_widget(content, area);
284}
285
286fn render_bottom_actions(app: &App, frame: &mut Frame, area: Rect) {
287 let rows = Layout::vertical([
288 Constraint::Length(1), Constraint::Length(1), ])
291 .split(area);
292
293 let dim = Style::default().fg(Color::DarkGray);
294 let key_style = Style::default()
295 .fg(Color::Rgb(37, 99, 235))
296 .add_modifier(Modifier::BOLD);
297
298 let mut action_spans = vec![];
300 if app.settings_index == 1 {
301 action_spans.extend([
302 Span::raw(" "),
303 Span::styled("[c]", key_style),
304 Span::styled(" Config", dim),
305 Span::raw(" "),
306 Span::styled("[d]", key_style),
307 Span::styled(" Dry-run", dim),
308 Span::raw(" "),
309 Span::styled("[m]", key_style),
310 Span::styled(" Mode", dim),
311 ]);
312 }
313 let actions = Paragraph::new(vec![Line::from(action_spans)]).centered();
314
315 let nav_cols =
317 Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)]).split(rows[1]);
318
319 let left_spans = vec![
320 Span::raw(" "),
321 Span::styled("[q]", key_style),
322 Span::styled(" Quit", dim),
323 Span::raw(" "),
324 Span::styled("[Esc]", key_style),
325 Span::styled(" Back", dim),
326 ];
327
328 let right_spans = vec![
329 Span::styled("[Tab]", key_style),
330 Span::styled(" Switch", dim),
331 Span::raw(" "),
332 Span::styled("[\u{2191}]", key_style),
333 Span::raw(" "),
334 Span::styled("[\u{2193}]", key_style),
335 Span::styled(" Move", dim),
336 Span::raw(" "),
337 ];
338
339 frame.render_widget(actions, rows[0]);
340 frame.render_widget(Paragraph::new(vec![Line::from(left_spans)]), nav_cols[0]);
341 frame.render_widget(
342 Paragraph::new(vec![Line::from(right_spans)]).right_aligned(),
343 nav_cols[1],
344 );
345}
346
347#[cfg(test)]
348#[path = "settings_tests.rs"]
349mod tests;