Skip to main content

git_igitt/widgets/
graph_view.rs

1use crate::util::ctrl_chars::CtrlChars;
2use crate::widgets::branches_view::BranchItem;
3use crate::widgets::list::StatefulList;
4use gleisbau::graph::GitGraph;
5use std::iter::Iterator;
6use tui::buffer::Buffer;
7use tui::layout::Rect;
8use tui::style::Style;
9use tui::widgets::{Block, StatefulWidget, Widget};
10use unicode_width::UnicodeWidthStr;
11
12const SCROLL_MARGIN: usize = 3;
13const SCROLLBAR_STR: &str = "\u{2588}";
14
15#[derive(Default)]
16pub struct GraphViewState {
17    pub graph: Option<GitGraph>,
18    pub graph_lines: Vec<String>,
19    pub text_lines: Vec<String>,
20    pub indices: Vec<usize>,
21    pub offset: usize,
22    pub selected: Option<usize>,
23    pub branches: Option<StatefulList<BranchItem>>,
24    pub secondary_selected: Option<usize>,
25    pub secondary_changed: bool,
26}
27
28impl GraphViewState {
29    pub fn move_selection(&mut self, steps: usize, down: bool) -> bool {
30        let changed = if let Some(sel) = self.selected {
31            let new_idx = if down {
32                std::cmp::min(
33                    sel.saturating_add(steps),
34                    self.indices.len().saturating_sub(1),
35                )
36            } else {
37                sel.saturating_sub(steps)
38            };
39            self.selected = Some(new_idx);
40            new_idx != sel
41        } else if !self.graph_lines.is_empty() {
42            self.selected = Some(0);
43            true
44        } else {
45            false
46        };
47        if changed {
48            self.secondary_changed = false;
49        }
50        changed
51    }
52    pub fn move_secondary_selection(&mut self, steps: usize, down: bool) -> bool {
53        let changed = if let Some(sel) = self.secondary_selected {
54            let new_idx = if down {
55                std::cmp::min(
56                    sel.saturating_add(steps),
57                    self.indices.len().saturating_sub(1),
58                )
59            } else {
60                sel.saturating_sub(steps)
61            };
62            self.secondary_selected = Some(new_idx);
63            new_idx != sel
64        } else if !self.graph_lines.is_empty() {
65            if let Some(sel) = self.selected {
66                let new_idx = if down {
67                    std::cmp::min(
68                        sel.saturating_add(steps),
69                        self.indices.len().saturating_sub(1),
70                    )
71                } else {
72                    sel.saturating_sub(steps)
73                };
74                self.secondary_selected = Some(new_idx);
75                new_idx != sel
76            } else {
77                false
78            }
79        } else {
80            false
81        };
82        if changed {
83            self.secondary_changed = true;
84        }
85        changed
86    }
87}
88
89#[derive(Default)]
90pub struct GraphView<'a> {
91    block: Option<Block<'a>>,
92    highlight_symbol: Option<&'a str>,
93    secondary_highlight_symbol: Option<&'a str>,
94    style: Style,
95    highlight_style: Style,
96}
97
98impl<'a> GraphView<'a> {
99    pub fn block(mut self, block: Block<'a>) -> GraphView<'a> {
100        self.block = Some(block);
101        self
102    }
103
104    pub fn style(mut self, style: Style) -> GraphView<'a> {
105        self.style = style;
106        self
107    }
108
109    pub fn highlight_symbol(
110        mut self,
111        highlight_symbol: &'a str,
112        secondary_highlight_symbol: &'a str,
113    ) -> GraphView<'a> {
114        self.highlight_symbol = Some(highlight_symbol);
115        self.secondary_highlight_symbol = Some(secondary_highlight_symbol);
116        self
117    }
118
119    pub fn highlight_style(mut self, style: Style) -> GraphView<'a> {
120        self.highlight_style = style;
121        self
122    }
123}
124
125impl StatefulWidget for GraphView<'_> {
126    type State = GraphViewState;
127
128    fn render(mut self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
129        buf.set_style(area, self.style);
130        let list_area = match self.block.take() {
131            Some(b) => {
132                let inner_area = b.inner(area);
133                b.render(area, buf);
134                inner_area
135            }
136            None => area,
137        };
138
139        if list_area.width < 1 || list_area.height < 1 {
140            return;
141        }
142
143        if state.graph_lines.is_empty() || state.indices.is_empty() {
144            return;
145        }
146        let list_height = list_area.height as usize;
147
148        let mut start = state.offset;
149
150        let height = std::cmp::min(
151            list_height,
152            state.graph_lines.len().saturating_sub(state.offset),
153        );
154        let mut end = start + height;
155
156        let max_graph_idx = state.graph_lines.len().saturating_sub(1);
157        let max_indices_idx = state.indices.len().saturating_sub(1);
158
159        let selected_row = state
160            .selected
161            .and_then(|idx| state.indices.get(idx).copied());
162        let selected = selected_row.unwrap_or(0).min(max_graph_idx);
163
164        let secondary_selected_row = state
165            .secondary_selected
166            .and_then(|idx| state.indices.get(idx).copied());
167        let secondary_selected = secondary_selected_row.unwrap_or(0).min(max_graph_idx);
168
169        let selected_index = if state.secondary_changed {
170            state.secondary_selected.unwrap_or(0).min(max_indices_idx)
171        } else {
172            state.selected.unwrap_or(0).min(max_indices_idx)
173        };
174        let move_to_selected = if state.secondary_changed {
175            secondary_selected
176        } else {
177            selected
178        };
179
180        let move_to_end = if selected_index >= max_indices_idx {
181            max_graph_idx
182        } else {
183            state.indices[selected_index + 1]
184                .saturating_sub(1)
185                .max(move_to_selected + SCROLL_MARGIN)
186                .min(max_graph_idx)
187        };
188        let move_to_start = move_to_selected.saturating_sub(SCROLL_MARGIN);
189
190        if move_to_end >= end {
191            let diff = move_to_end + 1 - end;
192            end += diff;
193            start += diff;
194        }
195        if move_to_start < start {
196            let diff = start - move_to_start;
197            end -= diff;
198            start -= diff;
199        }
200        state.offset = start;
201
202        let highlight_symbol = self.highlight_symbol.unwrap_or("");
203        let secondary_highlight_symbol = self.secondary_highlight_symbol.unwrap_or("");
204
205        let blank_symbol = " ".repeat(highlight_symbol.width());
206
207        let style = Style::default();
208        for (current_height, (i, (graph_item, text_item))) in state
209            .graph_lines
210            .iter()
211            .zip(state.text_lines.iter())
212            .enumerate()
213            .skip(state.offset)
214            .take(end - start)
215            .enumerate()
216        {
217            let (x, y) = (list_area.left(), list_area.top() + current_height as u16);
218
219            let is_selected = selected_row.map(|s| s == i).unwrap_or(false);
220            let is_sec_selected = secondary_selected_row.map(|s| s == i).unwrap_or(false);
221            let elem_x = {
222                let symbol = if is_selected {
223                    highlight_symbol
224                } else if is_sec_selected {
225                    secondary_highlight_symbol
226                } else {
227                    &blank_symbol
228                };
229                let (x, _) = buf.set_stringn(x, y, symbol, list_area.width as usize, style);
230                x
231            };
232
233            let area = Rect {
234                x,
235                y,
236                width: list_area.width,
237                height: 1,
238            };
239
240            let max_element_width = (list_area.width - (elem_x - x)) as usize;
241
242            let mut body = CtrlChars::parse(graph_item).into_text();
243            body.extend(CtrlChars::parse(&format!("  {}", text_item)).into_text());
244
245            let mut x = elem_x;
246            let mut remaining_width = max_element_width as u16;
247            for txt in body {
248                for line in txt.lines {
249                    if remaining_width == 0 {
250                        break;
251                    }
252                    let pos = buf.set_spans(x, y, &line, remaining_width);
253                    let w = pos.0.saturating_sub(x);
254                    x = pos.0;
255                    remaining_width = remaining_width.saturating_sub(w);
256                }
257            }
258
259            if is_selected || is_sec_selected {
260                buf.set_style(area, self.highlight_style);
261            }
262        }
263
264        let scroll_start = list_area.top() as usize
265            + (((list_height * start) as f32 / state.graph_lines.len() as f32).ceil() as usize)
266                .min(list_height - 1);
267        let scroll_height = (((list_height * list_height) as f32 / state.graph_lines.len() as f32)
268            .floor() as usize)
269            .clamp(1, list_height);
270
271        if scroll_height < list_height {
272            for y in scroll_start..(scroll_start + scroll_height) {
273                buf.set_string(
274                    list_area.left() + list_area.width,
275                    y as u16,
276                    SCROLLBAR_STR,
277                    self.style,
278                );
279            }
280        }
281    }
282}
283
284impl Widget for GraphView<'_> {
285    fn render(self, area: Rect, buf: &mut Buffer) {
286        let mut state = GraphViewState::default();
287        StatefulWidget::render(self, area, buf, &mut state);
288    }
289}