Skip to main content

hefesto_widgets/badge/
mod.rs

1use std::collections::{BTreeMap, HashSet};
2
3use ratatui::{
4    buffer::Buffer,
5    layout::{Alignment, Rect},
6    style::{Color, Style},
7    text::Line,
8    widgets::{Block, Clear, Padding, Paragraph, StatefulWidget, Widget, Wrap},
9};
10
11use crate::popup::PopupSize;
12use crate::BorderType;
13
14const CLOSE_SYMBOL: &str = "×";
15const BADGE_GAP_X: u16 = 1;
16const BADGE_GAP_Y: u16 = 0;
17const BORDER_STYLES: Style = Style::new().bold();
18
19/// Dónde se ancla un badge al borde de la pantalla.
20///
21/// Los badges en el mismo [`BadgeAnchor`] se apilan automáticamente
22/// con un gap de 1 celda.
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Default)]
24pub enum BadgeAnchor {
25    /// Esquina superior izquierda. Stack horizontal hacia la derecha.
26    #[default]
27    TopLeft,
28    /// Borde superior, centrado. Stack horizontal hacia la derecha.
29    Top,
30    /// Esquina superior derecha. Stack horizontal hacia la izquierda.
31    TopRight,
32    /// Esquina inferior izquierda. Stack horizontal hacia la derecha.
33    BottomLeft,
34    /// Borde inferior, centrado. Stack horizontal hacia la derecha.
35    Bottom,
36    /// Esquina inferior derecha. Stack horizontal hacia la izquierda.
37    BottomRight,
38    /// Borde izquierdo, centrado verticalmente. Stack vertical hacia abajo.
39    Left,
40    /// Borde derecho, centrado verticalmente. Stack vertical hacia abajo.
41    Right,
42}
43
44/// Un badge individual con borde, contenido y posición de anclaje.
45///
46/// Se usa dentro de un [`BadgeStack`] que maneja el layout automático.
47#[derive(Clone)]
48pub struct Badge<'a> {
49    width: PopupSize,
50    height: PopupSize,
51    border_color: Color,
52    border_type: BorderType,
53    padding: u16,
54    title: Option<&'a str>,
55    content: Vec<Line<'a>>,
56    anchor: BadgeAnchor,
57    closable: bool,
58    bg_color: Option<Color>,
59    style: Style,
60    alignment: Alignment,
61    z_index: u16,
62    /// Nivel/fila dentro del mismo anchor.
63    /// 0 = más cercano al borde, 1 = una fila/columna arriba, etc.
64    layer: u16,
65}
66
67impl<'a> Badge<'a> {
68    /// Crea un badge con texto plano y color de borde.
69    ///
70    /// Defaults: width `Fixed(20)`, height `Fixed(3)`, anchor `TopLeft`,
71    /// border `Rounded`, padding `0`, closable `true`, alignment `Left`.
72    pub fn new(content: &'a str, border_color: Color) -> Self {
73        Self {
74            width: PopupSize::Fixed(20),
75            height: PopupSize::Fixed(3),
76            border_color,
77            border_type: BorderType::Rounded,
78            padding: 0,
79            title: None,
80            content: vec![Line::from(content)],
81            anchor: BadgeAnchor::default(),
82            closable: true,
83            bg_color: None,
84            style: Style::default(),
85            alignment: Alignment::Left,
86            z_index: 5,
87            layer: 0,
88        }
89    }
90
91    /// Crea un badge con contenido custom (`Vec<Line>`) y color de borde.
92    pub fn with_content(content: Vec<Line<'a>>, border_color: Color) -> Self {
93        Self {
94            content,
95            ..Self::new("", border_color)
96        }
97    }
98
99    pub fn anchor(mut self, anchor: BadgeAnchor) -> Self {
100        self.anchor = anchor;
101        self
102    }
103
104    pub fn alignment(mut self, alignment: Alignment) -> Self {
105        self.alignment = alignment;
106        self
107    }
108
109    pub fn width(mut self, width: PopupSize) -> Self {
110        self.width = width;
111        self
112    }
113
114    pub fn height(mut self, height: PopupSize) -> Self {
115        self.height = height;
116        self
117    }
118
119    pub fn closable(mut self, closable: bool) -> Self {
120        self.closable = closable;
121        self
122    }
123
124    pub fn title(mut self, title: &'a str) -> Self {
125        self.title = Some(title);
126        self
127    }
128
129    pub fn border_type(mut self, bt: BorderType) -> Self {
130        self.border_type = bt;
131        self
132    }
133
134    pub fn border_color(mut self, color: Color) -> Self {
135        self.border_color = color;
136        self
137    }
138
139    pub fn bg_color(mut self, color: Color) -> Self {
140        self.bg_color = Some(color);
141        self
142    }
143
144    pub fn padding(mut self, padding: u16) -> Self {
145        self.padding = padding;
146        self
147    }
148
149    pub fn style(mut self, style: Style) -> Self {
150        self.style = style;
151        self
152    }
153
154    /// Controla el orden de renderizado dentro del mismo anchor.
155    /// Menor valor = se renderiza primero (atrás).
156    /// Mayor valor = se renderiza después (adelante).
157    pub fn z_index(mut self, z: u16) -> Self {
158        self.z_index = z;
159        self
160    }
161
162    /// Fila/nivel dentro del mismo anchor.
163    /// `0` (default) = más cercano al borde.
164    /// `1` = una fila arriba (bottom anchors) / abajo (top anchors).
165    pub fn layer(mut self, layer: u16) -> Self {
166        self.layer = layer;
167        self
168    }
169
170    fn resolve(&self, available: u16, size: PopupSize) -> u16 {
171        match size {
172            PopupSize::Fixed(v) => v,
173            PopupSize::Percent(p) => available * p / 100,
174            PopupSize::Auto => available * 80 / 100,
175            PopupSize::Max(max) => (available * 80 / 100).min(max),
176        }
177    }
178
179    fn resolve_width(&self, area_width: u16) -> u16 {
180        self.resolve(area_width, self.width)
181    }
182
183    fn resolve_height(&self, area_height: u16) -> u16 {
184        self.resolve(area_height, self.height)
185    }
186
187    fn render_at(self, rect: Rect, buf: &mut Buffer) {
188        Clear.render(rect, buf);
189
190        let bg_style = self.bg_color.map(|c| Style::new().bg(c));
191
192        if self.border_type.has_border() {
193            let mut b = Block::bordered()
194                .border_type(self.border_type.to_ratatui())
195                .border_style(BORDER_STYLES.fg(self.border_color))
196                .padding(Padding::new(
197                    self.padding,
198                    self.padding,
199                    self.padding,
200                    self.padding,
201                ));
202
203            if let Some(s) = bg_style {
204                b = b.style(s);
205            }
206
207            if let Some(t) = self.title {
208                b = b.title_top(Line::from(t).left_aligned());
209            }
210
211            if self.closable {
212                b = b.title_top(Line::from(CLOSE_SYMBOL).right_aligned());
213            }
214
215            let inner = b.inner(rect);
216            b.render(rect, buf);
217
218            Paragraph::new(self.content)
219                .style(self.style)
220                .alignment(self.alignment)
221                .wrap(Wrap { trim: false })
222                .render(inner, buf);
223        } else {
224            let mut b = Block::default().padding(Padding::new(
225                self.padding,
226                self.padding,
227                self.padding,
228                self.padding,
229            ));
230
231            if let Some(s) = bg_style {
232                b = b.style(s);
233            }
234
235            let inner = b.inner(rect);
236            b.render(rect, buf);
237
238            Paragraph::new(self.content)
239                .style(self.style)
240                .alignment(self.alignment)
241                .wrap(Wrap { trim: false })
242                .render(inner, buf);
243        }
244    }
245}
246
247/// Contenedor que acumula múltiples [`Badge`]s y los renderiza
248/// anclados a los bordes del área.
249///
250/// Los badges se agrupan por [`BadgeAnchor`] y se apilan automáticamente
251/// (gap de 1 celda) en la dirección correspondiente.
252///
253/// # Ejemplo
254///
255/// ```no_run
256/// use ratatui::style::Color;
257/// use hefesto_widgets::{Badge, BadgeAnchor, BadgeStack};
258///
259/// let stack = BadgeStack::new()
260///     .push(Badge::new(" main", Color::Green).anchor(BadgeAnchor::TopLeft))
261///     .push(Badge::new(" 3 warnings", Color::Yellow).anchor(BadgeAnchor::TopRight));
262/// ```
263#[derive(Clone, Default)]
264pub struct BadgeStack<'a> {
265    badges: Vec<Badge<'a>>,
266}
267
268impl<'a> BadgeStack<'a> {
269    pub fn new() -> Self {
270        Self { badges: Vec::new() }
271    }
272
273    /// Agrega un badge al stack.
274    pub fn push(mut self, badge: Badge<'a>) -> Self {
275        self.badges.push(badge);
276        self
277    }
278
279    /// Retorna el z-index máximo entre todos los badges del stack.
280    pub(crate) fn max_z_index(&self) -> u16 {
281        self.badges.iter().map(|b| b.z_index).max().unwrap_or(0)
282    }
283
284    /// Renderiza todos los badges con un estado interno default
285    /// (todos visibles). No consume `self`.
286    ///
287    /// Útil para renderizar badges como fondo de otro widget
288    /// (ej: dentro de un [`Popup`](crate::Popup)).
289    pub fn render_all(&self, area: Rect, buf: &mut Buffer) {
290        let mut grouped: BTreeMap<(BadgeAnchor, u16), Vec<&Badge<'_>>> = BTreeMap::new();
291        for badge in &self.badges {
292            grouped.entry((badge.anchor, badge.layer)).or_default().push(badge);
293        }
294
295        let mut by_anchor: BTreeMap<BadgeAnchor, Vec<(u16, Vec<&Badge<'_>>)>> = BTreeMap::new();
296        for ((anchor, layer), badges) in grouped {
297            by_anchor.entry(anchor).or_default().push((layer, badges));
298        }
299        for layers in by_anchor.values_mut() {
300            layers.sort_by_key(|(l, _)| *l);
301        }
302
303        for (anchor, layers) in by_anchor {
304            let mut offset: u16 = 0;
305
306            for (_layer, mut badges) in layers {
307                badges.sort_by_key(|b| b.z_index);
308
309                let sizes: Vec<(u16, u16)> = badges
310                    .iter()
311                    .map(|b| (b.resolve_width(area.width), b.resolve_height(area.height)))
312                    .collect();
313
314                let rects = group_rects(area, anchor, &sizes, offset);
315
316                let max_dim = match anchor {
317                    BadgeAnchor::TopLeft
318                    | BadgeAnchor::Top
319                    | BadgeAnchor::TopRight
320                    | BadgeAnchor::BottomLeft
321                    | BadgeAnchor::Bottom
322                    | BadgeAnchor::BottomRight => {
323                        sizes.iter().map(|(_, h)| *h).max().unwrap_or(0)
324                    }
325                    BadgeAnchor::Left | BadgeAnchor::Right => {
326                        sizes.iter().map(|(w, _)| *w).max().unwrap_or(0)
327                    }
328                };
329                offset += max_dim + BADGE_GAP_Y;
330
331                for (badge, rect) in badges.into_iter().zip(rects) {
332                    badge.clone().render_at(rect, buf);
333                }
334            }
335        }
336    }
337}
338
339/// Estado mutable del [`BadgeStack`].
340///
341/// Trackea qué badges están descartados (dismissed).
342///
343/// ```no_run
344/// use hefesto_widgets::BadgeStackState;
345///
346/// let mut state = BadgeStackState::default();
347/// state.dismiss(2);
348/// assert!(!state.is_visible(2));
349/// ```
350#[derive(Default)]
351pub struct BadgeStackState {
352    dismissed: HashSet<usize>,
353}
354
355impl BadgeStackState {
356    /// Marca un badge como descartado (no se renderiza).
357    pub fn dismiss(&mut self, index: usize) {
358        self.dismissed.insert(index);
359    }
360
361    /// `true` si el badge en `index` debe ser visible.
362    pub fn is_visible(&self, index: usize) -> bool {
363        !self.dismissed.contains(&index)
364    }
365
366    /// Reabre todos los badges descartados.
367    pub fn reset(&mut self) {
368        self.dismissed.clear();
369    }
370}
371
372/// Calcula los `Rect`s para un grupo de badges con el mismo anclaje.
373///
374/// `layer_offset` desplaza la capa según la dirección del anchor:
375/// - top anchors → desplaza hacia abajo (aumenta y)
376/// - bottom anchors → desplaza hacia arriba (disminuye y)
377/// - left anchors → desplaza hacia la derecha (aumenta x)
378/// - right anchors → desplaza hacia la izquierda (disminuye x)
379fn group_rects(area: Rect, anchor: BadgeAnchor, sizes: &[(u16, u16)], layer_offset: u16) -> Vec<Rect> {
380    let gap = BADGE_GAP_X;
381    let max_w = area.width;
382    let max_h = area.height;
383
384    if sizes.is_empty() {
385        return Vec::new();
386    }
387
388    match anchor {
389        BadgeAnchor::TopLeft
390        | BadgeAnchor::Top
391        | BadgeAnchor::Bottom
392        | BadgeAnchor::BottomLeft => {
393            let y0 = match anchor {
394                BadgeAnchor::TopLeft | BadgeAnchor::Top => layer_offset,
395                BadgeAnchor::BottomLeft | BadgeAnchor::Bottom => {
396                    let max_badge_h = sizes.iter().map(|(_, h)| *h).max().unwrap_or(0);
397                    max_h.saturating_sub(max_badge_h + layer_offset)
398                }
399                _ => unreachable!(),
400            };
401
402            let total_w: u16 = sizes
403                .iter()
404                .map(|(w, _)| w + gap)
405                .sum::<u16>()
406                .saturating_sub(gap);
407
408            let start_x = match anchor {
409                BadgeAnchor::TopLeft | BadgeAnchor::BottomLeft => 0,
410                BadgeAnchor::Top | BadgeAnchor::Bottom => max_w.saturating_sub(total_w) / 2,
411                _ => unreachable!(),
412            };
413
414            let mut x = start_x;
415            sizes
416                .iter()
417                .map(|(w, h)| {
418                    let rect = Rect {
419                        x: x.min(max_w.saturating_sub(1)),
420                        y: y0,
421                        width: (*w).min(max_w.saturating_sub(x)),
422                        height: (*h).min(max_h.saturating_sub(y0)),
423                    };
424                    x += w + gap;
425                    rect
426                })
427                .collect()
428        }
429
430        BadgeAnchor::TopRight | BadgeAnchor::BottomRight => {
431            let y0 = match anchor {
432                BadgeAnchor::TopRight => layer_offset,
433                BadgeAnchor::BottomRight => {
434                    let max_badge_h = sizes.iter().map(|(_, h)| *h).max().unwrap_or(0);
435                    max_h.saturating_sub(max_badge_h + layer_offset)
436                }
437                _ => unreachable!(),
438            };
439
440            let total_w: u16 = sizes
441                .iter()
442                .map(|(w, _)| w + gap)
443                .sum::<u16>()
444                .saturating_sub(gap);
445
446            let start_x = max_w.saturating_sub(total_w);
447
448            let mut x = start_x;
449            sizes
450                .iter()
451                .map(|(w, h)| {
452                    let rect = Rect {
453                        x: x.min(max_w.saturating_sub(1)),
454                        y: y0,
455                        width: (*w).min(max_w.saturating_sub(x)),
456                        height: (*h).min(max_h.saturating_sub(y0)),
457                    };
458                    x += w + gap;
459                    rect
460                })
461                .collect()
462        }
463
464        BadgeAnchor::Left | BadgeAnchor::Right => {
465            let x0 = match anchor {
466                BadgeAnchor::Left => layer_offset,
467                BadgeAnchor::Right => {
468                    let max_badge_w = sizes.iter().map(|(w, _)| *w).max().unwrap_or(0);
469                    max_w.saturating_sub(max_badge_w + layer_offset)
470                }
471                _ => unreachable!(),
472            };
473
474            let total_h: u16 = sizes
475                .iter()
476                .map(|(_, h)| h + gap)
477                .sum::<u16>()
478                .saturating_sub(gap);
479
480            let start_y = max_h.saturating_sub(total_h) / 2;
481
482            let mut y = start_y;
483            sizes
484                .iter()
485                .map(|(w, h)| {
486                    let rect = Rect {
487                        x: x0,
488                        y: y.min(max_h.saturating_sub(1)),
489                        width: (*w).min(max_w.saturating_sub(x0)),
490                        height: (*h).min(max_h.saturating_sub(y)),
491                    };
492                    y += h + gap;
493                    rect
494                })
495                .collect()
496        }
497    }
498}
499
500impl StatefulWidget for BadgeStack<'_> {
501    type State = BadgeStackState;
502
503    fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
504        let mut grouped: BTreeMap<(BadgeAnchor, u16), Vec<Badge<'_>>> = BTreeMap::new();
505
506        for (i, badge) in self.badges.into_iter().enumerate() {
507            if state.is_visible(i) {
508                grouped.entry((badge.anchor, badge.layer)).or_default().push(badge);
509            }
510        }
511
512        let mut by_anchor: BTreeMap<BadgeAnchor, Vec<(u16, Vec<Badge<'_>>)>> = BTreeMap::new();
513        for ((anchor, layer), badges) in grouped {
514            by_anchor.entry(anchor).or_default().push((layer, badges));
515        }
516        for layers in by_anchor.values_mut() {
517            layers.sort_by_key(|(l, _)| *l);
518        }
519
520        for (anchor, layers) in by_anchor {
521            let mut offset: u16 = 0;
522
523            for (_layer, mut badges) in layers {
524                badges.sort_by_key(|b| b.z_index);
525
526                let sizes: Vec<(u16, u16)> = badges
527                    .iter()
528                    .map(|b| (b.resolve_width(area.width), b.resolve_height(area.height)))
529                    .collect();
530
531                let rects = group_rects(area, anchor, &sizes, offset);
532
533                let max_dim = match anchor {
534                    BadgeAnchor::TopLeft
535                    | BadgeAnchor::Top
536                    | BadgeAnchor::TopRight
537                    | BadgeAnchor::BottomLeft
538                    | BadgeAnchor::Bottom
539                    | BadgeAnchor::BottomRight => {
540                        sizes.iter().map(|(_, h)| *h).max().unwrap_or(0)
541                    }
542                    BadgeAnchor::Left | BadgeAnchor::Right => {
543                        sizes.iter().map(|(w, _)| *w).max().unwrap_or(0)
544                    }
545                };
546                offset += max_dim + BADGE_GAP_Y;
547
548                for (badge, rect) in badges.into_iter().zip(rects) {
549                    badge.render_at(rect, buf);
550                }
551            }
552        }
553    }
554}
555
556#[cfg(test)]
557mod tests;