git_igitt/widgets/
graph_view.rs1use 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}