leadr/ui/
panel.rs

1use std::{collections::HashMap, time::Duration, io::Write};
2
3use crossterm::{QueueableCommand, cursor, style::Stylize, terminal};
4use serde::{Deserialize, Serialize};
5
6use crate::{
7    Mapping, Mappings,
8    error::LeadrError,
9    ui::{
10        area::{Area, ColumnLayout},
11        entry::Entry,
12        symbols::Symbols,
13        theme::Theme,
14    },
15};
16
17#[derive(Clone, Debug, Serialize, Deserialize)]
18pub enum BorderType {
19    Rounded,
20    Square,
21    Top,
22    None,
23}
24
25#[derive(Clone, Debug, Serialize, Deserialize)]
26pub struct Config {
27    pub border_type: BorderType,
28    pub column_layout: ColumnLayout,
29    pub enabled: bool,
30    pub height: u16,
31    pub padding: u16,
32    pub symbols: Symbols,
33    pub timeout: Duration,
34}
35
36impl std::default::Default for Config {
37    fn default() -> Self {
38        Self {
39            border_type: BorderType::Rounded,
40            column_layout: ColumnLayout::default(),
41            enabled: true,
42            height: 10,
43            padding: 2,
44            symbols: Symbols::default(),
45            timeout: Duration::from_millis(500),
46        }
47    }
48}
49
50pub struct Panel {
51    pub config: Config,
52    pub theme: Theme,
53    scroll_up: u16,
54}
55
56impl Panel {
57    pub fn try_new(config: Config, theme: Theme) -> Result<Self, LeadrError> {
58        let mut tty = std::fs::OpenOptions::new().write(true).open("/dev/tty")?;
59
60        let (_cols, rows) = terminal::size()?;
61        let cursor_line = std::env::var("LEADR_CURSOR_LINE")?.parse::<u16>()?;
62
63        let lines_below = rows.saturating_sub(cursor_line);
64        let scroll_up = config.height.saturating_sub(lines_below);
65
66        if scroll_up > 0 {
67            tty.queue(terminal::ScrollUp(scroll_up))?
68                .queue(cursor::MoveUp(scroll_up))?;
69        }
70
71        tty.flush()?;
72
73        Ok(Self {
74            config,
75            theme,
76            scroll_up,
77        })
78    }
79
80    pub fn clear(&self) -> std::io::Result<()> {
81        let mut stdout = std::fs::OpenOptions::new().write(true).open("/dev/tty")?;
82        let (_cols, rows) = terminal::size()?;
83        let start_y = rows.saturating_sub(self.config.height);
84
85        stdout
86            .queue(cursor::SavePosition)?
87            .queue(cursor::MoveTo(0, start_y))?
88            .queue(terminal::Clear(terminal::ClearType::FromCursorDown))?
89            .queue(cursor::RestorePosition)?;
90
91        if self.scroll_up > 0 {
92            stdout
93                .queue(terminal::ScrollDown(self.scroll_up))?
94                .queue(cursor::MoveDown(self.scroll_up))?;
95        }
96        stdout.flush()
97    }
98
99    pub fn draw(&self, sequence: &str, mappings: &Mappings) -> Result<(), LeadrError> {
100        let mut tty = std::fs::OpenOptions::new().write(true).open("/dev/tty")?;
101        let (cols, rows) = terminal::size()?;
102        let start_y = rows.saturating_sub(self.config.height);
103
104        let outer_area = Area {
105            x: self.config.padding,
106            y: start_y,
107            width: cols.saturating_sub(2 * self.config.padding),
108            height: self.config.height,
109        };
110
111        tty.queue(cursor::SavePosition)?;
112        self.draw_border(&mut tty, &outer_area)?;
113        let border_width = 1;
114        let footer_height = 2;
115
116        let next_options = mappings.grouped_next_options(sequence);
117        let mut keys: Vec<_> = next_options.keys().collect();
118        keys.sort();
119        let entry_area = Area {
120            x: outer_area.x + 1,
121            y: outer_area.y + 1,
122            width: outer_area.width.saturating_sub(2 * border_width),
123            height: outer_area
124                .height
125                .saturating_sub(2 * border_width + footer_height),
126        };
127
128        let required_num_columns = (keys.len() as f64 / entry_area.height as f64).ceil() as u16;
129        let columns =
130            entry_area.split_horizontally(&self.config.column_layout, &required_num_columns);
131        for (i, column) in columns.iter().enumerate() {
132            let column_keys = keys
133                .iter()
134                .skip(i * column.height as usize)
135                .take(column.height as usize)
136                .cloned()
137                .collect::<Vec<_>>();
138            self.draw_entries(&mut tty, column, &next_options, &column_keys)?;
139        }
140
141        let footer_area = Area {
142            x: outer_area.x + 1,
143            y: outer_area.y + outer_area.height - footer_height,
144            width: outer_area.width.saturating_sub(2 * border_width),
145            height: footer_height,
146        };
147        self.draw_footer(&mut tty, &footer_area, sequence)?;
148        tty.queue(cursor::RestorePosition)?;
149
150        Ok(())
151    }
152
153    fn draw_border(&self, tty: &mut std::fs::File, area: &Area) -> std::io::Result<()> {
154        let (top_left, top_right, bottom_left, bottom_right, horizontal, vertical) =
155            match self.config.border_type {
156                BorderType::Rounded => ('╭', '╮', '╰', '╯', '─', '│'),
157                BorderType::Square => ('┌', '┐', '└', '┘', '─', '│'),
158                BorderType::Top => ('─', '─', ' ', ' ', '─', ' '),
159                BorderType::None => (' ', ' ', ' ', ' ', ' ', ' '),
160            };
161
162        let inner_width = area.width.saturating_sub(2);
163        let horizontal_line = horizontal.to_string().repeat(inner_width.into());
164
165        tty.queue(cursor::MoveTo(area.x, area.y))?;
166
167        // Top border
168        if !matches!(self.config.border_type, BorderType::None) {
169            let top = format!(
170                "{tl}{line}{tr}",
171                line = horizontal_line,
172                tl = top_left,
173                tr = top_right,
174            )
175            .with(self.theme.accent.into())
176            .on(self.theme.background.into());
177            write!(tty, "{}", top)?;
178        }
179
180        // Vertical sides
181        for i in 1..self.config.height {
182            tty.queue(cursor::MoveTo(area.x, area.y + i))?;
183            let line = format!(
184                "{vl}{space}{vr}",
185                space = " ".repeat(inner_width.into()),
186                vl = vertical,
187                vr = vertical
188            )
189            .with(self.theme.accent.into())
190            .on(self.theme.background.into());
191            write!(tty, "{}", line)?;
192        }
193
194        // Bottom border
195        if matches!(
196            self.config.border_type,
197            BorderType::Rounded | BorderType::Square
198        ) {
199            tty.queue(cursor::MoveTo(area.x, area.y + area.height))?;
200            let bottom = format!(
201                "{bl}{line}{br}",
202                line = horizontal_line,
203                bl = bottom_left,
204                br = bottom_right
205            )
206            .with(self.theme.accent.into())
207            .on(self.theme.background.into());
208            write!(tty, "{}", bottom)?;
209        }
210
211        tty.flush()?;
212
213        Ok(())
214    }
215
216    pub fn draw_entries(
217        &self,
218        tty: &mut std::fs::File,
219        area: &Area,
220        next_options_map: &HashMap<String, Vec<&Mapping>>,
221        keys: &Vec<&String>,
222    ) -> std::io::Result<()> {
223        let mut line = area.y;
224
225        for key in keys {
226            if line >= area.y + area.height {
227                break; // stop if no more vertical space
228            }
229            tty.queue(cursor::MoveTo(area.x, line))?;
230
231            let mappings = &next_options_map[*key];
232            let stylized_entry = Entry::new(
233                key,
234                mappings,
235                area.width,
236                &self.config.symbols,
237                &self.theme,
238            );
239            stylized_entry.to_tty(tty)?;
240
241            line += 1;
242        }
243
244        Ok(())
245    }
246
247    pub fn draw_footer(
248        &self,
249        tty: &mut std::fs::File,
250        area: &Area,
251        sequence: &str,
252    ) -> std::io::Result<()> {
253        let help_text = "󱊷  close  󰁮  back";
254        let styled_help_text = help_text
255            .with(self.theme.text_primary.into())
256            .on(self.theme.background.into());
257        let center_x = area.x + (area.width.saturating_sub(help_text.chars().count() as u16)) / 2;
258        tty.queue(cursor::MoveTo(center_x, area.y))?;
259        write!(tty, "{}", styled_help_text)?;
260
261        tty.queue(cursor::MoveTo(area.x, area.y))?;
262        let arrow = self
263            .config
264            .symbols
265            .sequence_begin
266            .to_string()
267            .with(self.theme.text_secondary.into())
268            .on(self.theme.background.into());
269        write!(tty, "{}", arrow)?;
270        let sequence_text = sequence
271            .to_string()
272            .with(self.theme.text_primary.into())
273            .on(self.theme.background.into());
274        write!(tty, "{}", sequence_text)?;
275
276        Ok(())
277    }
278}
279
280impl Drop for Panel {
281    fn drop(&mut self) {
282        let _ = self.clear();
283    }
284}
285