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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Default)]
24pub enum BadgeAnchor {
25 #[default]
27 TopLeft,
28 Top,
30 TopRight,
32 BottomLeft,
34 Bottom,
36 BottomRight,
38 Left,
40 Right,
42}
43
44#[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 layer: u16,
65}
66
67impl<'a> Badge<'a> {
68 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 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 pub fn z_index(mut self, z: u16) -> Self {
158 self.z_index = z;
159 self
160 }
161
162 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#[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 pub fn push(mut self, badge: Badge<'a>) -> Self {
275 self.badges.push(badge);
276 self
277 }
278
279 pub(crate) fn max_z_index(&self) -> u16 {
281 self.badges.iter().map(|b| b.z_index).max().unwrap_or(0)
282 }
283
284 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#[derive(Default)]
351pub struct BadgeStackState {
352 dismissed: HashSet<usize>,
353}
354
355impl BadgeStackState {
356 pub fn dismiss(&mut self, index: usize) {
358 self.dismissed.insert(index);
359 }
360
361 pub fn is_visible(&self, index: usize) -> bool {
363 !self.dismissed.contains(&index)
364 }
365
366 pub fn reset(&mut self) {
368 self.dismissed.clear();
369 }
370}
371
372fn 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;