basalt_tui/
sidepanel.rs

1use std::marker::PhantomData;
2
3use basalt_core::obsidian::Note;
4use ratatui::{
5    buffer::Buffer,
6    layout::{Alignment, Constraint, Layout, Rect},
7    style::{Style, Stylize},
8    text::Line,
9    widgets::{Block, BorderType, List, ListItem, ListState, StatefulWidgetRef},
10};
11
12#[derive(Debug, Default, Clone, PartialEq)]
13pub struct SidePanelState<'a> {
14    pub(crate) title: &'a str,
15    pub(crate) selected_item_index: Option<usize>,
16    pub(crate) items: Vec<Note>,
17    pub(crate) open: bool,
18    list_state: ListState,
19}
20
21/// Calculates the vertical offset of list items in rows.
22///
23/// When the selected item is near the end of the list and there aren't enough items
24/// remaining to keep the selection vertically centered, we shift the offset to show
25/// as many trailing items as possible instead of centering the selection.
26///
27/// This prevents empty lines from appearing at the bottom of the list when the
28/// selection moves toward the end.
29///
30/// Without this check, you'd see output like:
31/// ╭────────╮
32/// │ 3 item │
33/// │>4 item │
34/// │ 5 item │
35/// │        │
36/// ╰────────╯
37///
38/// With this check, the list scrolls up to fill the remaining space:
39/// ╭────────╮
40/// │ 2 item │
41/// │ 3 item │
42/// │>4 item │
43/// │ 5 item │
44/// ╰────────╯
45///
46/// The goal is to avoid showing unnecessary blank rows and to maximize visible items.
47fn calculate_offset(row: usize, items_count: usize, window_height: usize) -> usize {
48    let half = window_height / 2;
49
50    if row + half > items_count.saturating_sub(1) {
51        items_count.saturating_sub(window_height)
52    } else {
53        row.saturating_sub(half)
54    }
55}
56
57impl<'a> SidePanelState<'a> {
58    pub fn new(title: &'a str, items: Vec<Note>) -> Self {
59        SidePanelState {
60            items,
61            title,
62            selected_item_index: None,
63            list_state: ListState::default().with_selected(Some(0)),
64            open: true,
65        }
66    }
67
68    pub fn open(self) -> Self {
69        Self { open: true, ..self }
70    }
71
72    pub fn close(self) -> Self {
73        Self {
74            open: false,
75            ..self
76        }
77    }
78
79    pub fn toggle(self) -> Self {
80        Self {
81            open: !self.open,
82            ..self
83        }
84    }
85
86    pub fn update_offset_mut(&mut self, window_height: usize) -> &Self {
87        if !self.items.is_empty() {
88            let idx = self.list_state.selected().unwrap_or_default();
89            let items_count = self.items.len();
90
91            let offset = calculate_offset(idx, items_count, window_height);
92
93            let list_state = &mut self.list_state;
94            *list_state.offset_mut() = offset;
95        }
96
97        self
98    }
99
100    pub fn select(&self) -> Self {
101        Self {
102            selected_item_index: self.list_state.selected(),
103            ..self.clone()
104        }
105    }
106
107    pub fn selected(&self) -> Option<usize> {
108        self.selected_item_index
109    }
110
111    pub fn is_open(&self) -> bool {
112        self.open
113    }
114
115    pub fn next(mut self) -> Self {
116        let index = self
117            .list_state
118            .selected()
119            .map(|i| (i + 1).min(self.items.len().saturating_sub(1)));
120
121        self.list_state.select(index);
122
123        Self {
124            list_state: self.list_state,
125            ..self
126        }
127    }
128
129    pub fn previous(mut self) -> Self {
130        self.list_state.select_previous();
131
132        Self {
133            list_state: self.list_state,
134            ..self
135        }
136    }
137}
138
139#[derive(Default)]
140pub struct SidePanel<'a> {
141    _lifetime: PhantomData<&'a ()>,
142}
143
144impl<'a> StatefulWidgetRef for SidePanel<'a> {
145    type State = SidePanelState<'a>;
146
147    fn render_ref(&self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
148        let block = Block::bordered()
149            .border_type(BorderType::Rounded)
150            .title_style(Style::default().italic().bold());
151
152        let items: Vec<ListItem> = state
153            .items
154            .iter()
155            .enumerate()
156            .map(|(i, item)| match state.selected() {
157                Some(selected) if selected == i => ListItem::new(if state.open {
158                    format!("◆ {}", item.name)
159                } else {
160                    "◆".to_string()
161                }),
162                _ if state.open => ListItem::new(format!("  {}", item.name)),
163                _ => ListItem::new("◦"),
164            })
165            .collect();
166
167        let inner_area = block.inner(area);
168
169        state.update_offset_mut(inner_area.height.into());
170
171        if state.open {
172            List::new(items.to_vec())
173                .block(
174                    block
175                        .title(format!(" {} ", state.title))
176                        .title(Line::from(" ◀ ").alignment(Alignment::Right)),
177                )
178                .highlight_style(Style::new().reversed().dark_gray())
179                .highlight_symbol(" ")
180                .render_ref(area, buf, &mut state.list_state);
181        } else {
182            let layout = Layout::horizontal([Constraint::Length(5)]).split(area);
183
184            List::new(items)
185                .block(block.title(" ▶ "))
186                .highlight_style(Style::new().reversed().dark_gray())
187                .highlight_symbol(" ")
188                .render_ref(layout[0], buf, &mut state.list_state);
189        }
190    }
191}