git_igitt/
ui.rs

1use crate::app::{ActiveView, App, DiffMode};
2use crate::dialogs::FileDialog;
3use crate::util::syntax_highlight::as_styled;
4use crate::widgets::branches_view::{BranchList, BranchListItem};
5use crate::widgets::commit_view::CommitView;
6use crate::widgets::files_view::{FileList, FileListItem};
7use crate::widgets::graph_view::GraphView;
8use crate::widgets::models_view::ModelListState;
9use lazy_static::lazy_static;
10use tui::backend::Backend;
11use tui::layout::{Constraint, Direction, Layout, Rect};
12use tui::style::{Color, Modifier, Style};
13use tui::text::{Span, Spans, Text};
14use tui::widgets::{
15    Block, BorderType, Borders, Clear, List, ListItem as TuiListItem, Paragraph, Wrap,
16};
17use tui::Frame;
18
19lazy_static! {
20    pub static ref HINT_STYLE: Style = Style::default().fg(Color::Cyan);
21}
22
23pub fn draw_open_repo<B: Backend>(f: &mut Frame<B>, dialog: &mut FileDialog) {
24    let chunks = Layout::default()
25        .direction(Direction::Vertical)
26        .constraints([Constraint::Length(4), Constraint::Min(0)].as_ref())
27        .split(f.size());
28
29    let top_chunks = Layout::default()
30        .direction(Direction::Vertical)
31        .constraints([Constraint::Length(3), Constraint::Min(1)].as_ref())
32        .split(chunks[0]);
33
34    let location_block = Block::default().borders(Borders::ALL).title(" Path ");
35
36    let paragraph = Paragraph::new(format!("{}", &dialog.location.display())).block(location_block);
37    f.render_widget(paragraph, top_chunks[0]);
38
39    let help = Paragraph::new("  Navigate with Arrows, confirm with Enter, abort with Esc.");
40    f.render_widget(help, top_chunks[1]);
41
42    let list_block = Block::default()
43        .borders(Borders::ALL)
44        .title(" Open repository ");
45
46    let items: Vec<_> = dialog
47        .dirs
48        .iter()
49        .map(|f| {
50            if dialog.color {
51                if f.1 {
52                    TuiListItem::new(&f.0[..]).style(Style::default().fg(Color::LightGreen))
53                } else {
54                    TuiListItem::new(&f.0[..])
55                }
56            } else if f.1 {
57                TuiListItem::new(format!("+ {}", &f.0[..]))
58            } else {
59                TuiListItem::new(format!("  {}", &f.0[..]))
60            }
61        })
62        .collect();
63
64    let mut list = List::new(items).block(list_block).highlight_symbol("> ");
65
66    if dialog.color {
67        list = list.highlight_style(Style::default().add_modifier(Modifier::UNDERLINED));
68    }
69
70    f.render_stateful_widget(list, chunks[1], &mut dialog.state);
71
72    if let Some(error) = &dialog.error_message {
73        draw_error_dialog(f, f.size(), error, dialog.color);
74    }
75}
76
77pub fn draw<B: Backend>(f: &mut Frame<B>, app: &mut App) {
78    if let ActiveView::Help(scroll) = app.active_view {
79        draw_help(f, f.size(), scroll);
80        return;
81    }
82
83    if let (ActiveView::Models, Some(model_state)) = (&app.active_view, &mut app.models_state) {
84        let chunks = Layout::default()
85            .direction(Direction::Vertical)
86            .constraints([Constraint::Length(1), Constraint::Min(0)].as_ref())
87            .split(f.size());
88
89        let help = Paragraph::new("  Enter = confirm, P = permanent, Esc = abort.");
90        f.render_widget(help, chunks[0]);
91
92        draw_models(f, chunks[1], app.color, model_state);
93        return;
94    }
95
96    if app.is_fullscreen {
97        let view = if app.active_view == ActiveView::Search {
98            app.prev_active_view.as_ref().unwrap_or(&ActiveView::Graph)
99        } else {
100            &app.active_view
101        };
102        match view {
103            ActiveView::Branches => draw_branches(f, f.size(), app),
104            ActiveView::Graph => draw_graph(f, f.size(), app),
105            ActiveView::Commit => draw_commit(f, f.size(), app),
106            ActiveView::Files => draw_files(f, f.size(), app),
107            ActiveView::Diff => draw_diff(f, f.size(), app),
108            _ => {}
109        }
110    } else {
111        let base_split = if app.horizontal_split {
112            Direction::Horizontal
113        } else {
114            Direction::Vertical
115        };
116        let sub_split = if app.horizontal_split {
117            Direction::Vertical
118        } else {
119            Direction::Horizontal
120        };
121
122        let show_branches = app.show_branches || app.active_view == ActiveView::Branches;
123
124        let top_chunks = Layout::default()
125            .direction(Direction::Horizontal)
126            .constraints(
127                [
128                    Constraint::Length(if show_branches { 25 } else { 0 }),
129                    Constraint::Min(0),
130                ]
131                .as_ref(),
132            )
133            .split(f.size());
134
135        let chunks = Layout::default()
136            .direction(base_split)
137            .constraints([Constraint::Percentage(50), Constraint::Percentage(50)].as_ref())
138            .split(top_chunks[1]);
139
140        let right_chunks = Layout::default()
141            .direction(sub_split)
142            .constraints([Constraint::Percentage(50), Constraint::Percentage(50)].as_ref())
143            .split(chunks[1]);
144
145        match app.active_view {
146            ActiveView::Search => {
147                if let Some(prev) = &app.prev_active_view {
148                    match prev {
149                        ActiveView::Files | ActiveView::Diff => draw_diff(f, chunks[0], app),
150                        _ => draw_graph(f, chunks[0], app),
151                    }
152                } else {
153                    draw_graph(f, chunks[0], app)
154                }
155            }
156            ActiveView::Files | ActiveView::Diff => draw_diff(f, chunks[0], app),
157            _ => draw_graph(f, chunks[0], app),
158        }
159
160        if show_branches {
161            draw_branches(f, top_chunks[0], app);
162        }
163        draw_commit(f, right_chunks[0], app);
164        draw_files(f, right_chunks[1], app);
165    }
166
167    if let Some(error) = &app.error_message {
168        draw_error_dialog(f, f.size(), error, app.color);
169    } else if app.active_view == ActiveView::Search {
170        draw_search_dialog(f, f.size(), &app.search_term);
171    }
172}
173
174fn create_title<'a>(title: &'a str, hint: &'a str, color: bool) -> Spans<'a> {
175    Spans(vec![
176        Span::raw(format!(" {} ", title)),
177        if color {
178            Span::styled(hint, *HINT_STYLE)
179        } else {
180            Span::raw(hint)
181        },
182    ])
183}
184
185fn draw_graph<B: Backend>(f: &mut Frame<B>, target: Rect, app: &mut App) {
186    let title = format!("Graph - {}", app.repo_name);
187    let mut block = Block::default().borders(Borders::ALL).title(create_title(
188        &title,
189        " <-Branches | Commit-> ",
190        app.color,
191    ));
192
193    if app.active_view == ActiveView::Graph {
194        block = block.border_type(BorderType::Thick);
195    }
196
197    let mut graph = GraphView::default().block(block).highlight_symbol(">", "#");
198
199    if app.color {
200        graph = graph.highlight_style(Style::default().add_modifier(Modifier::UNDERLINED));
201    }
202
203    f.render_stateful_widget(graph, target, &mut app.graph_state);
204}
205
206fn draw_branches<B: Backend>(f: &mut Frame<B>, target: Rect, app: &mut App) {
207    let color = app.color;
208
209    let mut block = Block::default().borders(Borders::ALL).title(create_title(
210        "Branches",
211        " Graph-> ",
212        app.color,
213    ));
214
215    if let Some(state) = &mut app.graph_state.branches {
216        if app.active_view == ActiveView::Branches {
217            block = block.border_type(BorderType::Thick);
218        }
219
220        let items: Vec<_> = state
221            .items
222            .iter()
223            .map(|item| {
224                BranchListItem::new(
225                    if color {
226                        Span::styled(&item.name, Style::default().fg(Color::Indexed(item.color)))
227                    } else {
228                        Span::raw(&item.name)
229                    },
230                    &item.branch_type,
231                )
232            })
233            .collect();
234
235        let mut list = BranchList::new(items).block(block).highlight_symbol("> ");
236
237        if color {
238            list = list.highlight_style(Style::default().add_modifier(Modifier::UNDERLINED));
239        }
240
241        f.render_stateful_widget(list, target, &mut state.state);
242    } else {
243        if app.active_view == ActiveView::Files {
244            block = block.border_type(BorderType::Thick);
245        }
246        f.render_widget(block, target);
247    }
248}
249
250fn draw_commit<B: Backend>(f: &mut Frame<B>, target: Rect, app: &mut App) {
251    let mut block = Block::default().borders(Borders::ALL).title(create_title(
252        "Commit",
253        " <-Graph | Files-> ",
254        app.color,
255    ));
256
257    if app.active_view == ActiveView::Commit {
258        block = block.border_type(BorderType::Thick);
259    }
260
261    let commit = CommitView::default().block(block).highlight_symbol(">");
262
263    f.render_stateful_widget(commit, target, &mut app.commit_state);
264}
265
266fn draw_files<B: Backend>(f: &mut Frame<B>, target: Rect, app: &mut App) {
267    let color = app.color;
268    if let Some(state) = &mut app.commit_state.content {
269        let title = format!(
270            "Files ({}..{})",
271            &state.compare_oid.to_string()[..7],
272            &state.oid.to_string()[..7]
273        );
274        let mut block = Block::default().borders(Borders::ALL).title(create_title(
275            &title,
276            " <-Commit | Diff-> ",
277            app.color,
278        ));
279
280        if app.active_view == ActiveView::Files {
281            block = block.border_type(BorderType::Thick);
282        }
283
284        let items: Vec<_> = state
285            .diffs
286            .items
287            .iter()
288            .map(|item| {
289                if color {
290                    let style = Style::default().fg(item.diff_type.to_color());
291                    FileListItem::new(
292                        Span::styled(&item.file, style),
293                        Span::styled(format!("{} ", item.diff_type.to_string()), style),
294                    )
295                } else {
296                    FileListItem::new(
297                        Span::raw(&item.file),
298                        Span::raw(format!("{} ", item.diff_type.to_string())),
299                    )
300                }
301            })
302            .collect();
303
304        let mut list = FileList::new(items).block(block).highlight_symbol("> ");
305
306        if color {
307            list = list.highlight_style(Style::default().add_modifier(Modifier::UNDERLINED));
308        }
309
310        f.render_stateful_widget(list, target, &mut state.diffs.state);
311    } else {
312        let mut block = Block::default().borders(Borders::ALL).title(create_title(
313            "Files",
314            " <-Commit | Diff-> ",
315            app.color,
316        ));
317        if app.active_view == ActiveView::Files {
318            block = block.border_type(BorderType::Thick);
319        }
320        f.render_widget(block, target);
321    }
322}
323
324fn draw_diff<B: Backend>(f: &mut Frame<B>, target: Rect, app: &mut App) {
325    if let Some(state) = &app.diff_state.content {
326        let title = match app.diff_options.diff_mode {
327            DiffMode::Diff => format!(
328                "Diff ({}..{})",
329                &state.compare_oid.to_string()[..7],
330                &state.oid.to_string()[..7]
331            ),
332            DiffMode::Old => format!("Diff (old: {})", &state.compare_oid.to_string()[..7],),
333            DiffMode::New => format!("Diff (new: {})", &state.oid.to_string()[..7],),
334        };
335        let mut block = Block::default().borders(Borders::ALL).title(create_title(
336            &title,
337            " <-Files ",
338            app.color,
339        ));
340        if app.active_view == ActiveView::Diff {
341            block = block.border_type(BorderType::Thick);
342        }
343
344        let styles = [
345            Style::default().fg(Color::LightGreen),
346            Style::default().fg(Color::LightRed),
347            Style::default().fg(Color::LightBlue),
348            Style::default(),
349        ];
350
351        let mut text = Text::from("");
352        if app.diff_options.diff_mode == DiffMode::Diff {
353            let (space_old_ln, space_new_ln, empty_old_ln, empty_new_ln) =
354                if app.diff_options.line_numbers {
355                    let mut max_old_ln = None;
356                    let mut max_new_ln = None;
357
358                    for (_, old_ln, new_ln) in state.diffs.iter().rev() {
359                        if max_old_ln.is_none() {
360                            if let Some(old_ln) = old_ln {
361                                max_old_ln = Some(*old_ln);
362                            }
363                        }
364                        if max_new_ln.is_none() {
365                            if let Some(new_ln) = new_ln {
366                                max_new_ln = Some(*new_ln);
367                            }
368                        }
369                        if max_old_ln.is_some() && max_new_ln.is_some() {
370                            break;
371                        }
372                    }
373
374                    let space_old_ln =
375                        std::cmp::max(3, (max_old_ln.unwrap_or(0) as f32).log10().ceil() as usize);
376                    let space_new_ln =
377                        std::cmp::max(3, (max_new_ln.unwrap_or(0) as f32).log10().ceil() as usize)
378                            + 1;
379
380                    (
381                        space_old_ln,
382                        space_new_ln,
383                        " ".repeat(space_old_ln),
384                        " ".repeat(space_new_ln),
385                    )
386                } else {
387                    (0, 0, String::new(), String::new())
388                };
389
390            for (line, old_ln, new_ln) in &state.diffs {
391                let ln = if line.starts_with("@@ ") {
392                    if let Some(pos) = line.find(" @@ ") {
393                        &line[..pos + 3]
394                    } else {
395                        line
396                    }
397                } else {
398                    line
399                };
400
401                if app.diff_options.line_numbers && (old_ln.is_some() || new_ln.is_some()) {
402                    let l1 = old_ln
403                        .map(|v| format!("{:>width$}", v, width = space_old_ln))
404                        .unwrap_or_else(|| empty_old_ln.clone());
405                    let l2 = new_ln
406                        .map(|v| format!("{:>width$}", v, width = space_new_ln))
407                        .unwrap_or_else(|| empty_new_ln.clone());
408                    let fmt = format!("{}{}|", l1, l2);
409
410                    text.extend(style_diff_line(Some(fmt), ln, &styles, app.color));
411                } else {
412                    text.extend(style_diff_line(None, ln, &styles, app.color));
413                }
414            }
415        } else {
416            if !state.diffs.is_empty() {
417                text.extend(style_diff_line(None, &state.diffs[0].0, &styles, false));
418            }
419            if !state.diffs.len() > 1 {
420                if let Some(txt) = &state.highlighted {
421                    text.extend(as_styled(txt));
422                } else {
423                    // TODO: Due to a bug in tui-rs (?), it is necessary to trim line ends.
424                    // Otherwise, artifacts of the previous buffer may occur
425                    if state.diffs.len() > 1 {
426                        for line in state.diffs[1].0.lines() {
427                            let trim = line.trim_end();
428                            if trim.is_empty() {
429                                text.extend(Text::raw("\n"));
430                            } else {
431                                let styled = style_diff_line(None, trim, &styles, false);
432                                text.extend(styled);
433                            }
434                        }
435                    }
436                }
437            }
438        }
439
440        let mut paragraph = Paragraph::new(text).block(block).scroll(state.scroll);
441
442        if app.diff_options.wrap_lines {
443            paragraph = paragraph.wrap(Wrap { trim: false });
444        }
445
446        f.render_widget(paragraph, target);
447    } else {
448        let mut block = Block::default().borders(Borders::ALL).title(create_title(
449            "Diff",
450            " <-Files ",
451            app.color,
452        ));
453        if app.active_view == ActiveView::Diff {
454            block = block.border_type(BorderType::Thick);
455        }
456        f.render_widget(block, target);
457    }
458}
459
460fn style_diff_line<'a>(
461    prefix: Option<String>,
462    line: &'a str,
463    styles: &'a [Style; 4],
464    color: bool,
465) -> Text<'a> {
466    if !color {
467        if let Some(prefix) = prefix {
468            Text::raw(format!("{}{}", prefix, line))
469        } else {
470            Text::raw(line)
471        }
472    } else {
473        let style = if line.starts_with('+') {
474            styles[0]
475        } else if line.starts_with('-') {
476            styles[1]
477        } else if line.starts_with('@') {
478            styles[2]
479        } else {
480            styles[3]
481        };
482        if let Some(prefix) = prefix {
483            Text::styled(format!("{}{}", prefix, line), style)
484        } else {
485            Text::styled(line, style)
486        }
487    }
488}
489
490fn draw_models<B: Backend>(
491    f: &mut Frame<B>,
492    target: Rect,
493    color: bool,
494    state: &mut ModelListState,
495) {
496    let block = Block::default()
497        .borders(Borders::ALL)
498        .title(" Branching model ");
499
500    let items: Vec<_> = state
501        .models
502        .iter()
503        .map(|m| TuiListItem::new(&m[..]))
504        .collect();
505
506    let mut list = List::new(items).block(block).highlight_symbol("> ");
507
508    if color {
509        list = list.highlight_style(Style::default().add_modifier(Modifier::UNDERLINED));
510    }
511
512    f.render_stateful_widget(list, target, &mut state.state);
513}
514
515fn draw_help<B: Backend>(f: &mut Frame<B>, target: Rect, scroll: u16) {
516    let block = Block::default()
517        .borders(Borders::ALL)
518        .title(" Help [back with Esc] ");
519
520    let paragraph = Paragraph::new(
521        "\n\
522         General\n  \
523         \n  \
524           F1/H               Show this help\n  \
525           Q                  Quit\n  \
526           Ctrl + O           Open repository\n  \
527           M                  Set branching model\n  \
528         \n\
529         Layout/panels\n  \
530         \n  \
531           Left/Right         Change panel\n  \
532           Tab                Panel to fullscreen\n  \
533           Esc                Return to default view\n  \
534           L                  Toggle horizontal/vertical layout\n  \
535           B                  Toggle show branch list\n  \
536         \n\
537         Navigate/select\n  \
538         \n  \
539           Up/Down            Select / navigate / scroll\n  \
540           Shift + Up/Down    Navigate fast\n  \
541           Home/End           Navigate to HEAD/last\n  \
542           Ctrl + Up/Down     Secondary selection (compare arbitrary commits)\n  \
543           Backspace          Clear secondary selection\n  \
544           Ctrl + Left/Right  Scroll horizontal\n  \
545           Enter              Jump to selected branch/tag\n  \
546         \n\
547         Search\n  \
548         \n  \
549           F3/Ctrl+F          Open search dialog\n  \
550           F3                 Continue search\n  \
551         \n\
552         Diffs panel\n  \
553         \n  \
554           +/-                Increase/decrease number of diff context lines\n  \
555           D/N/O              Show diff or new/old version of file\n  \
556           Ctrl + L           Toggle line numbers\n  \
557           Ctrl + W           Toggle line wrapping\n  \
558           S                  Toggle syntax highlighting (new/old file only, turn off if too slow)",
559    )
560    .block(block)
561    .scroll((scroll, 0));
562
563    f.render_widget(paragraph, target);
564}
565
566fn draw_error_dialog<B: Backend>(f: &mut Frame<B>, target: Rect, error: &str, color: bool) {
567    let mut block = Block::default()
568        .title(" Error - Press Enter to continue ")
569        .borders(Borders::ALL)
570        .border_type(BorderType::Thick);
571
572    if color {
573        block = block.border_style(Style::default().fg(Color::LightRed));
574    }
575
576    let paragraph = Paragraph::new(error).block(block).wrap(Wrap { trim: true });
577
578    let area = centered_rect(60, 12, target);
579    f.render_widget(Clear, area);
580    f.render_widget(paragraph, area);
581}
582
583fn draw_search_dialog<B: Backend>(f: &mut Frame<B>, target: Rect, search: &Option<String>) {
584    let block = Block::default()
585        .title(" Search - Search with Enter, abort with Esc ")
586        .borders(Borders::ALL)
587        .border_type(BorderType::Thick);
588
589    let empty = "".to_string();
590    let text = &search.as_ref().unwrap_or(&empty)[..];
591    let paragraph = Paragraph::new(format!("{}_", text))
592        .block(block)
593        .wrap(Wrap { trim: true });
594
595    let area = centered_rect(60, 12, target);
596    f.render_widget(Clear, area);
597    f.render_widget(paragraph, area);
598}
599
600/// helper function to create a centered rect using up
601/// certain percentage of the available rect `r`
602fn centered_rect(size_x: u16, size_y: u16, r: Rect) -> Rect {
603    let size_x = std::cmp::min(size_x, r.width);
604    let size_y = std::cmp::min(size_y, r.height);
605
606    let popup_layout = Layout::default()
607        .direction(Direction::Vertical)
608        .constraints(
609            [
610                Constraint::Length((r.height - size_y) / 2),
611                Constraint::Min(size_y),
612                Constraint::Length((r.height - size_y) / 2),
613            ]
614            .as_ref(),
615        )
616        .split(r);
617
618    Layout::default()
619        .direction(Direction::Horizontal)
620        .constraints(
621            [
622                Constraint::Length((r.width - size_x) / 2),
623                Constraint::Min(size_x),
624                Constraint::Length((r.width - size_x) / 2),
625            ]
626            .as_ref(),
627        )
628        .split(popup_layout[1])[1]
629}