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
34impl<'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#[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}