void_graph/widget/
ref_list.rs1use ratatui::{
8 buffer::Buffer,
9 layout::Rect,
10 style::{Modifier, Style},
11 text::{Line, Span},
12 widgets::{Block, Borders, StatefulWidget, Widget},
13};
14
15use crate::color::ColorTheme;
16use crate::void_backend::{RefKind, VoidHead, VoidRef};
17
18#[derive(Debug, Clone)]
20enum RefNode {
21 Category { name: String, expanded: bool },
23 Leaf { ref_: VoidRef },
25}
26
27#[derive(Debug)]
29pub struct RefListState {
30 refs: Vec<VoidRef>,
32 head: Option<VoidHead>,
34 nodes: Vec<RefNode>,
36 selected: usize,
38 offset: usize,
40 branches_expanded: bool,
42 tags_expanded: bool,
43}
44
45impl RefListState {
46 pub fn new(refs: Vec<VoidRef>, head: Option<VoidHead>) -> Self {
48 let branches_expanded = true;
49 let tags_expanded = true;
50
51 let mut state = Self {
52 refs,
53 head,
54 nodes: Vec::new(),
55 selected: 0,
56 offset: 0,
57 branches_expanded,
58 tags_expanded,
59 };
60 state.rebuild_nodes();
61 state
62 }
63
64 fn rebuild_nodes(&mut self) {
66 self.nodes.clear();
67
68 let branches: Vec<_> = self
70 .refs
71 .iter()
72 .filter(|r| r.kind == RefKind::Branch)
73 .cloned()
74 .collect();
75
76 if !branches.is_empty() {
77 self.nodes.push(RefNode::Category {
78 name: "Branches".to_string(),
79 expanded: self.branches_expanded,
80 });
81
82 if self.branches_expanded {
83 for branch in branches {
84 self.nodes.push(RefNode::Leaf { ref_: branch });
85 }
86 }
87 }
88
89 let tags: Vec<_> = self
91 .refs
92 .iter()
93 .filter(|r| r.kind == RefKind::Tag)
94 .cloned()
95 .collect();
96
97 if !tags.is_empty() {
98 self.nodes.push(RefNode::Category {
99 name: "Tags".to_string(),
100 expanded: self.tags_expanded,
101 });
102
103 if self.tags_expanded {
104 for tag in tags {
105 self.nodes.push(RefNode::Leaf { ref_: tag });
106 }
107 }
108 }
109 }
110
111 pub fn is_empty(&self) -> bool {
113 self.refs.is_empty()
114 }
115
116 pub fn selected_ref_name(&self) -> Option<&str> {
118 self.nodes.get(self.selected).and_then(|node| match node {
119 RefNode::Leaf { ref_ } => Some(ref_.name.as_str()),
120 RefNode::Category { .. } => None,
121 })
122 }
123
124 pub fn selected_ref(&self) -> Option<&VoidRef> {
126 self.nodes.get(self.selected).and_then(|node| match node {
127 RefNode::Leaf { ref_ } => Some(ref_),
128 RefNode::Category { .. } => None,
129 })
130 }
131
132 pub fn select_next(&mut self, viewport_height: usize) {
134 if self.selected < self.nodes.len().saturating_sub(1) {
135 self.selected += 1;
136 if self.selected >= self.offset + viewport_height {
138 self.offset = self.selected - viewport_height + 1;
139 }
140 }
141 }
142
143 pub fn select_prev(&mut self) {
145 if self.selected > 0 {
146 self.selected -= 1;
147 if self.selected < self.offset {
149 self.offset = self.selected;
150 }
151 }
152 }
153
154 pub fn toggle_selected(&mut self) {
156 if let Some(RefNode::Category { name, expanded }) = self.nodes.get(self.selected).cloned() {
157 if name == "Branches" {
158 self.branches_expanded = !expanded;
159 } else if name == "Tags" {
160 self.tags_expanded = !expanded;
161 }
162 self.rebuild_nodes();
163 }
164 }
165
166 pub fn select_first(&mut self) {
168 self.selected = 0;
169 self.offset = 0;
170 }
171
172 pub fn select_last(&mut self, viewport_height: usize) {
174 self.selected = self.nodes.len().saturating_sub(1);
175 if self.nodes.len() > viewport_height {
176 self.offset = self.nodes.len() - viewport_height;
177 }
178 }
179
180 fn is_head_branch(&self, name: &str) -> bool {
182 matches!(&self.head, Some(VoidHead::Branch(b)) if b == name)
183 }
184}
185
186pub struct RefList<'a> {
188 theme: &'a ColorTheme,
189}
190
191impl<'a> RefList<'a> {
192 pub fn new(theme: &'a ColorTheme) -> Self {
194 Self { theme }
195 }
196}
197
198impl<'a> StatefulWidget for RefList<'a> {
199 type State = RefListState;
200
201 fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
202 let block = Block::default()
203 .borders(Borders::ALL)
204 .title(" Refs ");
205 let inner = block.inner(area);
206 block.render(area, buf);
207
208 if state.nodes.is_empty() {
209 return;
210 }
211
212 let viewport_height = inner.height as usize;
213
214 for (i, idx) in (state.offset..).take(viewport_height).enumerate() {
215 if idx >= state.nodes.len() {
216 break;
217 }
218
219 let node = &state.nodes[idx];
220 let is_selected = idx == state.selected;
221 let y = inner.y + i as u16;
222
223 let base_style = if is_selected {
224 Style::default()
225 .fg(self.theme.ref_selected_fg)
226 .bg(self.theme.ref_selected_bg)
227 } else {
228 Style::default()
229 };
230
231 let line = match node {
232 RefNode::Category { name, expanded } => {
233 let arrow = if *expanded { " " } else { " " };
234 Line::from(vec![
235 Span::styled(arrow, base_style.add_modifier(Modifier::BOLD)),
236 Span::styled(name.clone(), base_style.add_modifier(Modifier::BOLD)),
237 ])
238 }
239 RefNode::Leaf { ref_ } => {
240 let indent = " ";
241 let color = match ref_.kind {
242 RefKind::Branch => {
243 if state.is_head_branch(&ref_.name) {
244 self.theme.list_head_fg
245 } else {
246 self.theme.list_ref_branch_fg
247 }
248 }
249 RefKind::Tag => self.theme.list_ref_tag_fg,
250 };
251
252 let name_style = if is_selected {
253 base_style
254 } else {
255 Style::default().fg(color)
256 };
257
258 let mut spans = vec![Span::styled(indent, base_style)];
259
260 if state.is_head_branch(&ref_.name) {
262 spans.push(Span::styled(
263 "* ",
264 if is_selected {
265 base_style.add_modifier(Modifier::BOLD)
266 } else {
267 Style::default()
268 .fg(self.theme.list_head_fg)
269 .add_modifier(Modifier::BOLD)
270 },
271 ));
272 }
273
274 spans.push(Span::styled(ref_.name.clone(), name_style));
275 Line::from(spans)
276 }
277 };
278
279 buf.set_line(inner.x, y, &line, inner.width);
280 }
281 }
282}
283
284#[cfg(test)]
285mod tests {
286 use super::*;
287 use crate::void_backend::CommitCid;
288
289 fn make_refs() -> Vec<VoidRef> {
290 vec![
291 VoidRef {
292 name: "main".to_string(),
293 kind: RefKind::Branch,
294 target: CommitCid("aaa".to_string()),
295 },
296 VoidRef {
297 name: "feature".to_string(),
298 kind: RefKind::Branch,
299 target: CommitCid("bbb".to_string()),
300 },
301 VoidRef {
302 name: "v1.0".to_string(),
303 kind: RefKind::Tag,
304 target: CommitCid("ccc".to_string()),
305 },
306 ]
307 }
308
309 #[test]
310 fn test_ref_list_state_new() {
311 let refs = make_refs();
312 let state = RefListState::new(refs, Some(VoidHead::Branch("main".to_string())));
313
314 assert_eq!(state.nodes.len(), 5);
316 }
317
318 #[test]
319 fn test_ref_list_toggle_category() {
320 let refs = make_refs();
321 let mut state = RefListState::new(refs, None);
322
323 assert_eq!(state.nodes.len(), 5);
325
326 state.selected = 0;
328 state.toggle_selected();
329
330 assert_eq!(state.nodes.len(), 3);
332 }
333
334 #[test]
335 fn test_selected_ref() {
336 let refs = make_refs();
337 let state = RefListState::new(refs, None);
338
339 assert!(state.selected_ref_name().is_none());
341 }
342}