Skip to main content

hefesto_widgets/popup/
mod.rs

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/// Configuration for the background dot grid pattern.
22#[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/// A configurable popup widget for ratatui.
82///
83/// Supports absolute positioning via `position(x, y)`, relative positioning
84/// via `origin(x, y)`, auto-centering, configurable borders, header mode,
85/// background fill via `bg_color`, and customizable text alignment/wrapping.
86///
87/// By default a subtle diagonal dot pattern is rendered in the empty area
88/// outside the popup. Call [`no_background`](Self::no_background) to disable it,
89/// or [`background_dots`](Self::background_dots) to customize.
90#[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    /// Creates a new `Popup` with the given border color and default settings.
113    ///
114    /// Defaults: width/height `Auto` (80%/70% of area), border `Rounded`,
115    /// padding `1`, alignment `Center`, wrap with `trim: false`, no title,
116    /// no content, no header.
117    /// Creates a new `Popup` with the given border color and default settings.
118    ///
119    /// Defaults: width/height `Auto` (80%/70% of area), border `Rounded`,
120    /// padding `1`, alignment `Center`, wrap with `trim: false`, no title,
121    /// no content, no header. A subtle dot background is enabled by default.
122    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    /// Sets the title displayed in the popup border (non-header mode).
145    ///
146    /// When `BorderType::None` is set and header mode is off, the title
147    /// is rendered as a pseudo-header bar inside the popup content area.
148    pub fn title(mut self, title: &'a str) -> Self {
149        self.title = Some(title);
150        self
151    }
152
153    /// Sets the content lines to display inside the popup.
154    pub fn content(mut self, content: Vec<Line<'a>>) -> Self {
155        self.content = content;
156        self
157    }
158
159    /// Sets the preferred width: `Fixed(n)`, `Percent(p)`, `Auto`, or
160    /// `Max(max)`. When `position` or `origin` is set, the resulting width
161    /// is clamped to fit within the remaining area.
162    pub fn width(mut self, width: PopupSize) -> Self {
163        self.width = width;
164        self
165    }
166
167    /// Sets the preferred height. See [`width`](Self::width) for sizing
168    /// behavior.
169    pub fn height(mut self, height: PopupSize) -> Self {
170        self.height = height;
171        self
172    }
173
174    /// Sets the border style. When `BorderType::None`, no border is drawn
175    /// and the title (if set and header mode is off) renders as a
176    /// pseudo-header bar inside the content area.
177    pub fn border_type(mut self, border_type: BorderType) -> Self {
178        self.border_type = border_type;
179        self
180    }
181
182    /// Sets the inner padding between the border and the content.
183    pub fn padding(mut self, padding: u16) -> Self {
184        self.padding = padding;
185        self
186    }
187
188    /// Sets a custom message shown when content is empty.
189    pub fn empty_message(mut self, msg: &'a str) -> Self {
190        self.empty_message = Some(msg);
191        self
192    }
193
194    /// Places the popup at an absolute `(x, y)` position using the
195    /// configured width/height. Coordinates are clamped to the area bounds.
196    pub fn position(mut self, x: u16, y: u16) -> Self {
197        self.position = Some((x, y));
198        self
199    }
200
201    /// Places the popup at an `(x, y)` origin using the configured
202    /// width/height. Coordinates and size are clamped to the area bounds.
203    pub fn origin(mut self, x: u16, y: u16) -> Self {
204        self.origin = Some((x, y));
205        self
206    }
207
208    /// Enables header mode: renders a separate header bar below the border
209    /// instead of a border title.
210    pub fn header(mut self) -> Self {
211        self.header = true;
212        self
213    }
214
215    /// Returns whether header mode is enabled.
216    pub fn has_header(&self) -> bool {
217        self.header
218    }
219
220    /// Sets the border color.
221    pub fn border_color(mut self, color: Color) -> Self {
222        self.border_color = color;
223        self
224    }
225
226    /// Sets the background color for the entire popup area, including the
227    /// border cells (if any) and the content area.
228    ///
229    /// When `None` (default), the popup inherits the terminal's background.
230    /// The border's `fg` color is preserved on top of the background.
231    pub fn bg_color(mut self, color: Color) -> Self {
232        self.bg_color = Some(color);
233        self
234    }
235
236    /// Disables the background dot grid for this popup.
237    pub fn no_background(mut self) -> Self {
238        self.dot_grid = None;
239        self
240    }
241
242    /// Customizes the background dot grid (color, symbol, density for both axes).
243    ///
244    /// Spacing: 1 = every cell, 4 = every 4th cell.
245    /// Use [`background_spacing`](Self::background_spacing) to set x/y independently.
246    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    /// Sets horizontal and vertical spacing independently.
258    ///
259    /// For example `spacing(4, 2)` in Grid mode means one dot every 4
260    /// columns and every 2 rows.
261    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    /// Sets the dot pattern for the background grid.
276    ///
277    /// If no dot grid has been configured yet, creates one with defaults.
278    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    /// Attaches badges to this popup. Badges are rendered in the background
291    /// layer (low z-index) before the popup chrome, so the popup appears on top.
292    pub fn badges(mut self, badges: BadgeStack<'a>) -> Self {
293        self.badges = Some(badges);
294        self
295    }
296
297    /// Sets the z-index for render ordering.
298    ///
299    /// Higher values render on top. Default is 10.
300    /// Badges default to 5, so popups render on top by default.
301    /// Set `z_index` lower than the badge stack's max to render
302    /// the popup behind the badges.
303    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    /// Sets the text alignment inside the popup (default: `Center`).
317    pub fn alignment(mut self, alignment: Alignment) -> Self {
318        self.alignment = alignment;
319        self
320    }
321
322    /// Sets the wrap behavior (default: `Wrap { trim: false }`).
323    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    /// Returns the vertical offset from the top of the popup to where content
358    /// begins, accounting for the border, header bar, pseudo-header, and padding.
359    ///
360    /// Useful for consumers that render custom content inside the popup area.
361    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; // no padding on top in header mode
365        }
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    /// Resolves the final `Rect` for this popup within the given `area`.
375    ///
376    /// Priority: `position` > `origin` > centered. Coordinates and size are
377    /// clamped to ensure the popup stays within the area bounds.
378    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    /// Renders a header bar with the given `title` inside `area`.
399    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    /// Renders the popup chrome (clear, border/block, header) and returns
411    /// the inner content area.
412    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;