runa_tui/ui/
widgets.rs

1use crate::app::AppState;
2use crate::ui::{ActionMode, InputMode};
3use ratatui::{
4    Frame,
5    layout::{Alignment, Rect},
6    style::{Color, Style},
7    text::{Line, Span},
8    widgets::{Block, Borders, Clear, Paragraph},
9};
10use serde::de::Error;
11use serde::{Deserialize, Deserializer};
12use std::time::Instant;
13use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
14
15pub enum InputKey {
16    Char(char),
17    Name(&'static str),
18}
19
20#[derive(Clone, Copy, Debug)]
21pub enum PopupPosition {
22    Center,
23    Top,
24    Bottom,
25    Left,
26    Right,
27    TopLeft,
28    TopRight,
29    BottomLeft,
30    BottomRight,
31    Custom(u16, u16),
32}
33
34// Deserialize so that the runa.toml custom position and size can be made simpler instead of just
35// standard serde [derive(Deserialize)]
36// position = "top_left"
37// position = "bottomright"
38// position = [25, 60]
39// position = { x = 42, y = 80 }
40impl<'de> Deserialize<'de> for PopupPosition {
41    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
42    where
43        D: Deserializer<'de>,
44    {
45        #[derive(Deserialize)]
46        #[serde(untagged)]
47        enum Helper {
48            Str(String),
49            Arr([u16; 2]),
50            XY { x: u16, y: u16 },
51        }
52
53        match Helper::deserialize(deserializer)? {
54            Helper::Str(ref s) if s.eq_ignore_ascii_case("center") => Ok(PopupPosition::Center),
55            Helper::Str(ref s) if s.eq_ignore_ascii_case("top") => Ok(PopupPosition::Top),
56            Helper::Str(ref s) if s.eq_ignore_ascii_case("bottom") => Ok(PopupPosition::Bottom),
57            Helper::Str(ref s) if s.eq_ignore_ascii_case("left") => Ok(PopupPosition::Left),
58            Helper::Str(ref s) if s.eq_ignore_ascii_case("right") => Ok(PopupPosition::Right),
59            Helper::Str(ref s)
60                if s.eq_ignore_ascii_case("top_left") || s.eq_ignore_ascii_case("topleft") =>
61            {
62                Ok(PopupPosition::TopLeft)
63            }
64            Helper::Str(ref s)
65                if s.eq_ignore_ascii_case("top_right") || s.eq_ignore_ascii_case("topright") =>
66            {
67                Ok(PopupPosition::TopRight)
68            }
69            Helper::Str(ref s)
70                if s.eq_ignore_ascii_case("bottom_left")
71                    || s.eq_ignore_ascii_case("bottomleft") =>
72            {
73                Ok(PopupPosition::BottomLeft)
74            }
75            Helper::Str(ref s)
76                if s.eq_ignore_ascii_case("bottom_right")
77                    || s.eq_ignore_ascii_case("bottomright") =>
78            {
79                Ok(PopupPosition::BottomRight)
80            }
81            Helper::Str(s) => Err(D::Error::custom(format!("invalid PopupPosition: '{}'", s))),
82            Helper::Arr([x, y]) => Ok(PopupPosition::Custom(x, y)),
83            Helper::XY { x, y } => Ok(PopupPosition::Custom(x, y)),
84        }
85    }
86}
87
88/// Preset popup sizes.
89#[derive(Clone, Copy, Debug)]
90pub enum PopupSize {
91    Small,
92    Medium,
93    Large,
94    Custom(u16, u16),
95}
96
97impl<'de> Deserialize<'de> for PopupSize {
98    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
99    where
100        D: Deserializer<'de>,
101    {
102        #[derive(Deserialize)]
103        #[serde(untagged)]
104        enum Helper {
105            Str(String),
106            Arr([u16; 2]),
107            Obj { w: u16, h: u16 },
108        }
109
110        match Helper::deserialize(deserializer)? {
111            Helper::Str(ref s) if s.eq_ignore_ascii_case("small") => Ok(PopupSize::Small),
112            Helper::Str(ref s) if s.eq_ignore_ascii_case("medium") => Ok(PopupSize::Medium),
113            Helper::Str(ref s) if s.eq_ignore_ascii_case("large") => Ok(PopupSize::Large),
114            Helper::Str(s) => Err(D::Error::custom(format!("invalid PopupSize: '{}'", s))),
115            Helper::Arr([w, h]) => Ok(PopupSize::Custom(w, h)),
116            Helper::Obj { w, h } => Ok(PopupSize::Custom(w, h)),
117        }
118    }
119}
120
121impl PopupSize {
122    pub fn percentages(&self) -> (u16, u16) {
123        match self {
124            PopupSize::Small => (24, 7),
125            PopupSize::Medium => (26, 14),
126            PopupSize::Large => (32, 40),
127            PopupSize::Custom(w, h) => (*w, *h),
128        }
129    }
130}
131
132pub struct PopupStyle {
133    pub border: Borders,
134    pub border_style: Style,
135    pub bg: Style,
136    pub fg: Style,
137    pub title: Option<Span<'static>>,
138}
139
140impl Default for PopupStyle {
141    fn default() -> Self {
142        Self {
143            border: Borders::ALL,
144            border_style: Style::default().fg(Color::White),
145            bg: Style::default().bg(Color::Black),
146            fg: Style::default().fg(Color::Reset),
147            title: None,
148        }
149    }
150}
151
152pub fn popup_area(area: Rect, size: PopupSize, pos: PopupPosition) -> Rect {
153    let (w_pct, h_pct) = size.percentages();
154    let w = (area.width * w_pct / 100).max(1).min(area.width);
155    let h = (area.height * h_pct / 100).max(1).min(area.height);
156
157    match pos {
158        PopupPosition::Center => Rect {
159            x: area.x + (area.width - w) / 2,
160            y: area.y + (area.height - h) / 2,
161            width: w,
162            height: h,
163        },
164        PopupPosition::Top => Rect {
165            x: area.x + (area.width - w) / 2,
166            y: area.y,
167            width: w,
168            height: h,
169        },
170        PopupPosition::Bottom => Rect {
171            x: area.x + (area.width - w) / 2,
172            y: area.y + area.height - h,
173            width: w,
174            height: h,
175        },
176        PopupPosition::Left => Rect {
177            x: area.x,
178            y: area.y + (area.height - h) / 2,
179            width: w,
180            height: h,
181        },
182        PopupPosition::Right => Rect {
183            x: area.x + area.width - w,
184            y: area.y + (area.height - h) / 2,
185            width: w,
186            height: h,
187        },
188        PopupPosition::TopLeft => Rect {
189            x: area.x,
190            y: area.y,
191            width: w,
192            height: h,
193        },
194        PopupPosition::TopRight => Rect {
195            x: area.x + area.width - w,
196            y: area.y,
197            width: w,
198            height: h,
199        },
200        PopupPosition::BottomLeft => Rect {
201            x: area.x,
202            y: area.y + area.height - h,
203            width: w,
204            height: h,
205        },
206        PopupPosition::BottomRight => Rect {
207            x: area.x + area.width - w,
208            y: area.y + area.height - h,
209            width: w,
210            height: h,
211        },
212        PopupPosition::Custom(xp, yp) => {
213            let x = area.x + ((area.width - w) * xp / 100).min(area.width - w);
214            let y = area.y + ((area.height - h) * yp / 100).min(area.height - h);
215            Rect {
216                x,
217                y,
218                width: w,
219                height: h,
220            }
221        }
222    }
223}
224
225pub fn draw_popup(
226    frame: &mut Frame,
227    area: Rect,
228    pos: PopupPosition,
229    size: PopupSize,
230    style: &PopupStyle,
231    content: impl Into<String>,
232    alignment: Option<Alignment>,
233) {
234    let popup = popup_area(area, size, pos);
235
236    frame.render_widget(Clear, popup);
237
238    let mut block = Block::default()
239        .borders(style.border)
240        .border_style(style.border_style)
241        .style(style.bg);
242
243    if let Some(title) = &style.title {
244        block = block.title(title.clone());
245    }
246
247    let para = Paragraph::new(content.into())
248        .block(block)
249        .alignment(alignment.unwrap_or(Alignment::Left));
250    frame.render_widget(para, popup);
251}
252
253pub fn get_pane_block(title: &str, app: &AppState) -> Block<'static> {
254    let mut block = Block::default();
255    if app.config().display().is_split() {
256        block = block
257            .borders(Borders::ALL)
258            .border_style(app.config().theme().accent().as_style());
259        if app.config().display().titles() {
260            block = block.title(title.to_string());
261        }
262    }
263    block
264}
265
266pub fn draw_separator(frame: &mut Frame, area: Rect, style: Style) {
267    frame.render_widget(
268        Block::default().borders(Borders::LEFT).border_style(style),
269        area,
270    );
271}
272
273pub fn draw_input_popup(frame: &mut Frame, app: &AppState, accent_style: Style) {
274    if let ActionMode::Input { mode, prompt } = &app.actions().mode() {
275        let widget = app.config().theme().widget();
276        let posititon = widget.position().unwrap_or(PopupPosition::Center);
277        let size = widget.size().unwrap_or(PopupSize::Small);
278        let confirm_size = widget.confirm_size_or(PopupSize::Large);
279
280        if *mode == InputMode::ConfirmDelete {
281            let action_targets = app.nav().get_action_targets();
282            let targets: Vec<String> = action_targets
283                .iter()
284                .map(|p| {
285                    p.file_name()
286                        .map(|n| n.to_string_lossy().into_owned())
287                        .unwrap_or_default()
288                })
289                .collect();
290            let preview = if targets.len() == 1 {
291                format!("\nFile to delete: {}", targets[0])
292            } else if targets.len() > 1 {
293                format!(
294                    "\nFiles to delete ({}):\n{}",
295                    targets.len(),
296                    targets
297                        .iter()
298                        .map(|n| format!("  - {}", n))
299                        .collect::<Vec<_>>()
300                        .join("\n")
301                )
302            } else {
303                String::new()
304            };
305
306            let popup_style = PopupStyle {
307                border: Borders::ALL,
308                border_style: widget.border_or(Style::default().fg(Color::Red)),
309                bg: widget.bg_or(Style::default().bg(Color::Reset)),
310                fg: widget.fg_or(Style::default().fg(Color::Reset)),
311                title: Some(" Confirm Delete ".into()),
312            };
313            draw_popup(
314                frame,
315                frame.area(),
316                posititon,
317                confirm_size,
318                &popup_style,
319                format!("{prompt}{preview}"),
320                Some(Alignment::Left),
321            );
322        } else {
323            let popup_style = PopupStyle {
324                border: Borders::ALL,
325                border_style: widget.border_or(accent_style),
326                bg: widget.bg_or(Style::default().bg(Color::Reset)),
327                fg: widget.fg_or(Style::default().fg(Color::Reset)),
328                title: Some(Span::styled(
329                    format!(" {} ", prompt),
330                    widget.fg_or(Style::default().fg(Color::Reset)),
331                )),
332            };
333            let input_text = app.actions().input_buffer();
334            let popup_area = popup_area(frame.area(), size, posititon);
335            let visible_width = popup_area.width.saturating_sub(2) as usize;
336            let input_width = input_text.width();
337            let display_input = if input_width > visible_width {
338                let mut current_w = 0;
339                let mut start = input_text.len();
340                for (idx, ch) in input_text.char_indices().rev() {
341                    current_w += ch.width().unwrap_or(0);
342                    if current_w > visible_width {
343                        start = idx + ch.len_utf8();
344                        break;
345                    }
346                }
347                &input_text[start..]
348            } else {
349                input_text
350            };
351
352            draw_popup(
353                frame,
354                frame.area(),
355                posititon,
356                size,
357                &popup_style,
358                display_input,
359                Some(Alignment::Left),
360            );
361
362            let cursor_offset = display_input.width() as u16;
363            frame.set_cursor_position((popup_area.x + 1 + cursor_offset, popup_area.y + 1));
364        }
365    }
366}
367
368pub fn draw_status_line(frame: &mut Frame, app: &crate::app::AppState) {
369    let area = frame.area();
370
371    let count = match app.actions().clipboard() {
372        Some(set) => set.len(),
373        None => 0,
374    };
375    let filter = app.nav().filter();
376    let now = Instant::now();
377
378    let mut parts = Vec::new();
379    if count > 0 && (app.notification_time().is_some_and(|until| until > now)) {
380        let yank_msg = { format!("Yanked files: {count}") };
381        parts.push(yank_msg);
382    }
383    if !filter.is_empty() {
384        parts.push(format!("Filter: \"{filter}\""));
385    }
386
387    let msg = parts.join(" | ");
388    if !msg.is_empty() {
389        let rect = Rect {
390            x: area.x,
391            y: area.y,
392            width: area.width,
393            height: 1,
394        };
395        let line = Line::from(Span::styled(msg, Style::default().fg(Color::Gray)));
396        let paragraph = Paragraph::new(line).alignment(ratatui::layout::Alignment::Right);
397        frame.render_widget(paragraph, rect);
398    }
399}