1use ratatui::{
2 buffer::Buffer,
3 layout::{Alignment, Constraint, Layout, Rect},
4 text::Line,
5 style::{Color, Style},
6 widgets::{
7 Block, Borders, Clear, Padding, Paragraph, Widget, Wrap,
8 },
9};
10
11use crate::badge::BadgeStack;
12use crate::styles::decorate_with_dots_with_pattern;
13use crate::styles::BACKGROUND_DOT_SYMBOL;
14use crate::styles::DotPattern;
15
16const BORDER_STYLES: Style = Style::new().bold();
17const AUTO_WIDTH_PCT: u16 = 80;
18const AUTO_HEIGHT_PCT: u16 = 70;
19const EMPTY_MSG: &str = "No content";
20
21#[derive(Clone)]
23pub struct DotGridConfig {
24 pub color: Color,
25 pub symbol: String,
26 pub density_x: u16,
27 pub density_y: u16,
28 pub pattern: DotPattern,
29}
30
31impl Default for DotGridConfig {
32 fn default() -> Self {
33 Self {
34 color: Color::DarkGray,
35 symbol: BACKGROUND_DOT_SYMBOL.to_string(),
36 density_x: 4,
37 density_y: 2,
38 pattern: DotPattern::default(),
39 }
40 }
41}
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub enum PopupSize {
45 Fixed(u16),
46 Percent(u16),
47 Auto,
48 Max(u16),
49}
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
52pub enum BorderType {
53 Plain,
54 #[default]
55 Rounded,
56 Double,
57 Thick,
58 QuadrantInside,
59 QuadrantOutside,
60 None,
61}
62
63impl BorderType {
64 pub(crate) fn has_border(&self) -> bool {
65 !matches!(self, Self::None)
66 }
67
68 pub fn to_ratatui(&self) -> ratatui::widgets::BorderType {
69 match self {
70 Self::Plain => ratatui::widgets::BorderType::Plain,
71 Self::Rounded => ratatui::widgets::BorderType::Rounded,
72 Self::Double => ratatui::widgets::BorderType::Double,
73 Self::Thick => ratatui::widgets::BorderType::Thick,
74 Self::QuadrantInside => ratatui::widgets::BorderType::QuadrantInside,
75 Self::QuadrantOutside => ratatui::widgets::BorderType::QuadrantOutside,
76 Self::None => panic!("BorderType::None must be filtered before calling to_ratatui"),
77 }
78 }
79}
80
81#[derive(Clone)]
91pub struct Popup<'a> {
92 width: PopupSize,
93 height: PopupSize,
94 border_color: Color,
95 border_type: BorderType,
96 padding: u16,
97 title: Option<&'a str>,
98 content: Vec<Line<'a>>,
99 empty_message: Option<&'a str>,
100 position: Option<(u16, u16)>,
101 origin: Option<(u16, u16)>,
102 header: bool,
103 alignment: Alignment,
104 wrap: Wrap,
105 bg_color: Option<Color>,
106 dot_grid: Option<DotGridConfig>,
107 badges: Option<BadgeStack<'a>>,
108 z_index: u16,
109}
110
111impl<'a> Popup<'a> {
112 pub fn new(border_color: Color) -> Self {
123 Self {
124 width: PopupSize::Auto,
125 height: PopupSize::Auto,
126 border_color,
127 border_type: BorderType::Rounded,
128 padding: 1,
129 title: None,
130 content: vec![],
131 empty_message: None,
132 position: None,
133 origin: None,
134 header: false,
135 alignment: Alignment::Center,
136 wrap: Wrap { trim: false },
137 bg_color: None,
138 dot_grid: Some(DotGridConfig::default()),
139 badges: None,
140 z_index: 10,
141 }
142 }
143
144 pub fn title(mut self, title: &'a str) -> Self {
149 self.title = Some(title);
150 self
151 }
152
153 pub fn content(mut self, content: Vec<Line<'a>>) -> Self {
155 self.content = content;
156 self
157 }
158
159 pub fn width(mut self, width: PopupSize) -> Self {
163 self.width = width;
164 self
165 }
166
167 pub fn height(mut self, height: PopupSize) -> Self {
170 self.height = height;
171 self
172 }
173
174 pub fn border_type(mut self, border_type: BorderType) -> Self {
178 self.border_type = border_type;
179 self
180 }
181
182 pub fn padding(mut self, padding: u16) -> Self {
184 self.padding = padding;
185 self
186 }
187
188 pub fn empty_message(mut self, msg: &'a str) -> Self {
190 self.empty_message = Some(msg);
191 self
192 }
193
194 pub fn position(mut self, x: u16, y: u16) -> Self {
197 self.position = Some((x, y));
198 self
199 }
200
201 pub fn origin(mut self, x: u16, y: u16) -> Self {
204 self.origin = Some((x, y));
205 self
206 }
207
208 pub fn header(mut self) -> Self {
211 self.header = true;
212 self
213 }
214
215 pub fn has_header(&self) -> bool {
217 self.header
218 }
219
220 pub fn border_color(mut self, color: Color) -> Self {
222 self.border_color = color;
223 self
224 }
225
226 pub fn bg_color(mut self, color: Color) -> Self {
232 self.bg_color = Some(color);
233 self
234 }
235
236 pub fn no_background(mut self) -> Self {
238 self.dot_grid = None;
239 self
240 }
241
242 pub fn background_dots(mut self, color: Color, symbol: &str, density: u16) -> Self {
247 self.dot_grid = Some(DotGridConfig {
248 color,
249 symbol: symbol.to_string(),
250 density_x: density,
251 density_y: density,
252 pattern: DotPattern::default(),
253 });
254 self
255 }
256
257 pub fn background_spacing(mut self, density_x: u16, density_y: u16) -> Self {
262 if let Some(ref mut dg) = self.dot_grid {
263 dg.density_x = density_x;
264 dg.density_y = density_y;
265 } else {
266 self.dot_grid = Some(DotGridConfig {
267 density_x,
268 density_y,
269 ..DotGridConfig::default()
270 });
271 }
272 self
273 }
274
275 pub fn background_pattern(mut self, pattern: DotPattern) -> Self {
279 if let Some(ref mut dg) = self.dot_grid {
280 dg.pattern = pattern;
281 } else {
282 self.dot_grid = Some(DotGridConfig {
283 pattern,
284 ..DotGridConfig::default()
285 });
286 }
287 self
288 }
289
290 pub fn badges(mut self, badges: BadgeStack<'a>) -> Self {
293 self.badges = Some(badges);
294 self
295 }
296
297 pub fn z_index(mut self, z: u16) -> Self {
304 self.z_index = z;
305 self
306 }
307
308 pub(crate) fn get_border_color(&self) -> Color {
309 self.border_color
310 }
311
312 pub(crate) fn get_height(&self) -> PopupSize {
313 self.height
314 }
315
316 pub fn alignment(mut self, alignment: Alignment) -> Self {
318 self.alignment = alignment;
319 self
320 }
321
322 pub fn wrap(mut self, wrap: Wrap) -> Self {
324 self.wrap = wrap;
325 self
326 }
327
328 fn resolve(&self, available: u16) -> u16 {
329 match self.width {
330 PopupSize::Fixed(v) => v,
331 PopupSize::Percent(p) => available * p / 100,
332 PopupSize::Auto => available * AUTO_WIDTH_PCT / 100,
333 PopupSize::Max(max) => (available * AUTO_WIDTH_PCT / 100).min(max),
334 }
335 }
336
337 fn resolve_height(&self, available: u16) -> u16 {
338 match self.height {
339 PopupSize::Fixed(v) => v,
340 PopupSize::Percent(p) => available * p / 100,
341 PopupSize::Auto => available * AUTO_HEIGHT_PCT / 100,
342 PopupSize::Max(max) => (available * AUTO_HEIGHT_PCT / 100).min(max),
343 }
344 }
345
346 pub(crate) fn centered_rect(area: Rect, width: u16, height: u16) -> Rect {
347 Rect {
348 x: (area.width.saturating_sub(width)) / 2,
349 y: (area.height.saturating_sub(height)) / 2,
350 width: width.min(area.width),
351 height: height.min(area.height),
352 }
353 }
354}
355
356impl Popup<'_> {
357 pub fn content_offset(&self) -> u16 {
362 let border_size: u16 = if self.border_type.has_border() { 1 } else { 0 };
363 if self.header {
364 return border_size + 2; }
366 let extra: u16 = if !self.border_type.has_border() && self.title.is_some() {
367 1
368 } else {
369 0
370 };
371 border_size + extra + self.padding
372 }
373
374 pub fn resolve_rect(&self, area: Rect) -> Rect {
379 let w = self.resolve(area.width);
380 let h = self.resolve_height(area.height);
381 if let Some((x, y)) = self.position {
382 let x = x.min(area.width.saturating_sub(1));
383 let y = y.min(area.height.saturating_sub(1));
384 let w = w.min(area.width - x);
385 let h = h.min(area.height - y);
386 Rect { x, y, width: w, height: h }
387 } else if let Some((ox, oy)) = self.origin {
388 let ox = ox.min(area.width.saturating_sub(1));
389 let oy = oy.min(area.height.saturating_sub(1));
390 let w = w.min(area.width - ox);
391 let h = h.min(area.height - oy);
392 Rect { x: ox, y: oy, width: w, height: h }
393 } else {
394 Self::centered_rect(area, w, h)
395 }
396 }
397
398 pub fn render_header(area: Rect, buf: &mut Buffer, border_color: Color, title: &str) {
400 let block = Block::bordered()
401 .borders(Borders::BOTTOM)
402 .border_style(BORDER_STYLES.fg(border_color))
403 .padding(Padding::new(1, 1, 0, 0));
404 let inner = block.inner(area);
405 let para = Paragraph::new(title);
406 para.render(inner, buf);
407 block.render(area, buf);
408 }
409
410 pub fn render_inner(&self, area: Rect, buf: &mut Buffer) -> Rect {
413 if let Some(ref cfg) = self.dot_grid {
414 decorate_with_dots_with_pattern(
415 buf, area, cfg.color, &cfg.symbol, cfg.density_x, cfg.density_y, cfg.pattern,
416 );
417 }
418
419 let badges_on_top = self
420 .badges
421 .as_ref()
422 .map_or(false, |b| b.max_z_index() >= self.z_index);
423
424 if !badges_on_top {
425 if let Some(ref badges) = self.badges {
426 badges.render_all(area, buf);
427 }
428 }
429
430 let popup_rect = self.resolve_rect(area);
431 Clear.render(popup_rect, buf);
432
433 let padding = if self.header {
434 Padding::new(self.padding, self.padding, 0, self.padding)
435 } else {
436 Padding::new(self.padding, self.padding, self.padding, self.padding)
437 };
438
439 let bg_style = self.bg_color.map(|c| Style::new().bg(c));
440
441 let block = if self.border_type.has_border() {
442 let mut b = Block::bordered()
443 .border_type(self.border_type.to_ratatui())
444 .border_style(BORDER_STYLES.fg(self.border_color))
445 .padding(padding);
446
447 if let Some(s) = bg_style {
448 b = b.style(s);
449 }
450
451 if !self.header {
452 if let Some(title) = self.title {
453 b = b.title(title);
454 }
455 }
456
457 b
458 } else {
459 let mut b = Block::default().padding(padding);
460
461 if let Some(s) = bg_style {
462 b = b.style(s);
463 }
464
465 b
466 };
467
468 let inner = block.inner(popup_rect);
469 block.render(popup_rect, buf);
470
471 if badges_on_top {
472 if let Some(ref badges) = self.badges {
473 badges.render_all(area, buf);
474 }
475 }
476
477 if !self.border_type.has_border() && self.title.is_some() && !self.header {
478 let chunks = Layout::vertical([
479 Constraint::Length(1),
480 Constraint::Min(0),
481 ]).split(inner);
482
483 let mut title_style = BORDER_STYLES.fg(self.border_color);
484 if let Some(bg) = self.bg_color {
485 title_style = title_style.bg(bg);
486 }
487
488 Paragraph::new(Line::from(self.title.unwrap_or("")))
489 .style(title_style)
490 .render(chunks[0], buf);
491
492 chunks[1]
493 } else if self.header {
494 let chunks = Layout::vertical([
495 Constraint::Length(2),
496 Constraint::Min(0),
497 ]).split(inner);
498
499 Self::render_header(
500 chunks[0], buf,
501 self.border_color,
502 self.title.unwrap_or(""),
503 );
504
505 chunks[1]
506 } else {
507 inner
508 }
509 }
510}
511
512impl Widget for Popup<'_> {
513 fn render(self, area: Rect, buf: &mut Buffer) {
514 let inner = self.render_inner(area, buf);
515
516 let display: Vec<Line<'_>> = if self.content.is_empty() {
517 let msg = self.empty_message.unwrap_or(EMPTY_MSG);
518 vec![Line::from(msg)]
519 } else {
520 self.content
521 };
522
523 let para = Paragraph::new(display)
524 .alignment(self.alignment)
525 .wrap(self.wrap);
526 para.render(inner, buf);
527 }
528}
529
530#[cfg(test)]
531mod tests;