rat_popup/
popup.rs

1use crate::_private::NonExhaustive;
2use crate::event::PopupOutcome;
3use crate::{Placement, PopupConstraint};
4use rat_event::util::MouseFlags;
5use rat_event::{ct_event, HandleEvent, Popup};
6use rat_focus::{FocusFlag, HasFocus};
7use rat_reloc::{relocate_area, RelocatableState};
8use rat_scrolled::{Scroll, ScrollArea, ScrollAreaState, ScrollState, ScrollStyle};
9use ratatui::buffer::Buffer;
10use ratatui::layout::{Alignment, Rect, Size};
11use ratatui::prelude::BlockExt;
12use ratatui::style::{Style, Stylize};
13use ratatui::widgets::{Block, Padding, StatefulWidget};
14use std::cell::Cell;
15use std::cmp::max;
16
17/// Provides the core for popup widgets.
18///
19/// This does widget can calculate the placement of a popup widget
20/// using the [placement](PopupCore::constraint), [offset](PopupCore::offset)
21/// and the outer [boundary](PopupCore::boundary).
22///
23/// It provides the widget area as [widget_area](PopupCoreState::widget_area).
24/// It's up to the user to render the actual content for the popup.
25///
26/// ## Event handling
27///
28/// The widget will detect any suspicious mouse activity outside its bounds
29/// and returns [PopupOutcome::Hide] if it finds such.
30///
31/// The widget doesn't change its active/visible state by itself,
32/// it's up to the caller to do this.
33///
34/// __See__
35/// See the examples some variants.
36///
37#[derive(Debug, Clone)]
38pub struct PopupCore<'a> {
39    pub style: Style,
40
41    pub constraint: Cell<PopupConstraint>,
42    pub offset: (i16, i16),
43    pub boundary_area: Option<Rect>,
44
45    pub block: Option<Block<'a>>,
46    pub h_scroll: Option<Scroll<'a>>,
47    pub v_scroll: Option<Scroll<'a>>,
48
49    pub non_exhaustive: NonExhaustive,
50}
51
52/// Complete styles for the popup.
53#[derive(Debug, Clone)]
54pub struct PopupStyle {
55    /// Baseline style.
56    pub style: Style,
57    /// Extra offset added after applying the constraints.
58    pub offset: Option<(i16, i16)>,
59    /// Block for the popup.
60    pub block: Option<Block<'static>>,
61    /// Style for the block border.
62    pub border_style: Option<Style>,
63    /// Style for scroll bars.
64    pub scroll: Option<ScrollStyle>,
65    /// Placement
66    pub alignment: Option<Alignment>,
67    /// Placement
68    pub placement: Option<Placement>,
69
70    /// non-exhaustive struct.
71    pub non_exhaustive: NonExhaustive,
72}
73
74/// State for the PopupCore.
75#[derive(Debug)]
76pub struct PopupCoreState {
77    /// Area for the widget.
78    /// This is the area given to render(), corrected by the
79    /// given constraints.
80    /// __read only__. renewed for each render.
81    pub area: Rect,
82    /// Z-Index for the popup.
83    pub area_z: u16,
84    /// Area where the widget can render it's content.
85    /// __read only__. renewed for each render.
86    pub widget_area: Rect,
87
88    /// Horizontal scroll state if active.
89    /// __read+write__
90    pub h_scroll: ScrollState,
91    /// Vertical scroll state if active.
92    /// __read+write__
93    pub v_scroll: ScrollState,
94
95    /// Active flag for the popup.
96    ///
97    /// Uses a ContainerFlag that can be combined with the FocusFlags
98    /// your widget uses for handling its focus to detect the
99    /// transition 'Did the popup loose focus and should it be closed now'.
100    ///
101    /// If you don't rely on Focus this way, this will just be a boolean
102    /// flag that indicates active/visible.
103    ///
104    /// __See__
105    /// See the examples how to use for both cases.
106    /// __read+write__
107    pub active: FocusFlag,
108
109    /// Mouse flags.
110    /// __read+write__
111    pub mouse: MouseFlags,
112
113    /// non-exhaustive struct.
114    pub non_exhaustive: NonExhaustive,
115}
116
117impl Default for PopupCore<'_> {
118    fn default() -> Self {
119        Self {
120            style: Default::default(),
121            constraint: Cell::new(PopupConstraint::None),
122            offset: (0, 0),
123            boundary_area: None,
124            block: None,
125            h_scroll: None,
126            v_scroll: None,
127            non_exhaustive: NonExhaustive,
128        }
129    }
130}
131
132impl<'a> PopupCore<'a> {
133    /// New.
134    pub fn new() -> Self {
135        Self::default()
136    }
137
138    /// Placement of the popup widget + the area of the main widget.
139    pub fn ref_constraint(&self, constraint: PopupConstraint) -> &Self {
140        self.constraint.set(constraint);
141        self
142    }
143
144    /// Placement of the popup widget + the area of the main widget.
145    pub fn constraint(self, constraint: PopupConstraint) -> Self {
146        self.constraint.set(constraint);
147        self
148    }
149
150    /// Adds an extra offset to the widget area.
151    ///
152    /// This can be used to
153    /// * place the widget under the mouse cursor.
154    /// * align the widget not by the outer bounds but by
155    ///   the text content.
156    pub fn offset(mut self, offset: (i16, i16)) -> Self {
157        self.offset = offset;
158        self
159    }
160
161    /// Sets only the x offset.
162    /// See [offset](Self::offset)
163    pub fn x_offset(mut self, offset: i16) -> Self {
164        self.offset.0 = offset;
165        self
166    }
167
168    /// Sets only the y offset.
169    /// See [offset](Self::offset)
170    pub fn y_offset(mut self, offset: i16) -> Self {
171        self.offset.1 = offset;
172        self
173    }
174
175    /// Sets outer boundaries for the resulting widget.
176    ///
177    /// This will be used to ensure that the widget is fully visible,
178    /// after calculation its position using the other parameters.
179    ///
180    /// If not set it will use [Buffer::area] for this.
181    pub fn boundary(mut self, boundary: Rect) -> Self {
182        self.boundary_area = Some(boundary);
183        self
184    }
185
186    /// Set styles
187    pub fn styles(mut self, styles: PopupStyle) -> Self {
188        self.style = styles.style;
189        if let Some(offset) = styles.offset {
190            self.offset = offset;
191        }
192        self.block = self.block.map(|v| v.style(self.style));
193        if let Some(border_style) = styles.border_style {
194            self.block = self.block.map(|v| v.border_style(border_style));
195        }
196        if let Some(block) = styles.block {
197            self.block = Some(block);
198        }
199        if let Some(styles) = styles.scroll {
200            if let Some(h_scroll) = self.h_scroll {
201                self.h_scroll = Some(h_scroll.styles(styles.clone()));
202            }
203            if let Some(v_scroll) = self.v_scroll {
204                self.v_scroll = Some(v_scroll.styles(styles));
205            }
206        }
207
208        self
209    }
210
211    /// Base style for the popup.
212    pub fn style(mut self, style: Style) -> Self {
213        self.style = style;
214        self.block = self.block.map(|v| v.style(self.style));
215        self
216    }
217
218    /// Block
219    pub fn block(mut self, block: Block<'a>) -> Self {
220        self.block = Some(block);
221        self.block = self.block.map(|v| v.style(self.style));
222        self
223    }
224
225    /// Block
226    pub fn block_opt(mut self, block: Option<Block<'a>>) -> Self {
227        self.block = block;
228        self.block = self.block.map(|v| v.style(self.style));
229        self
230    }
231
232    /// Horizontal scroll
233    pub fn h_scroll(mut self, h_scroll: Scroll<'a>) -> Self {
234        self.h_scroll = Some(h_scroll);
235        self
236    }
237
238    /// Horizontal scroll
239    pub fn h_scroll_opt(mut self, h_scroll: Option<Scroll<'a>>) -> Self {
240        self.h_scroll = h_scroll;
241        self
242    }
243
244    /// Vertical scroll
245    pub fn v_scroll(mut self, v_scroll: Scroll<'a>) -> Self {
246        self.v_scroll = Some(v_scroll);
247        self
248    }
249
250    /// Vertical scroll
251    pub fn v_scroll_opt(mut self, v_scroll: Option<Scroll<'a>>) -> Self {
252        self.v_scroll = v_scroll;
253        self
254    }
255
256    /// Get the padding the block imposes as  Size.
257    pub fn get_block_size(&self) -> Size {
258        let area = Rect::new(0, 0, 20, 20);
259        let inner = self.block.inner_if_some(area);
260        Size {
261            width: (inner.left() - area.left()) + (area.right() - inner.right()),
262            height: (inner.top() - area.top()) + (area.bottom() - inner.bottom()),
263        }
264    }
265
266    /// Get the padding the block imposes as Padding.
267    pub fn get_block_padding(&self) -> Padding {
268        let area = Rect::new(0, 0, 20, 20);
269        let inner = self.block.inner_if_some(area);
270        Padding {
271            left: inner.left() - area.left(),
272            right: area.right() - inner.right(),
273            top: inner.top() - area.top(),
274            bottom: area.bottom() - inner.bottom(),
275        }
276    }
277
278    /// Calculate the inner area.
279    pub fn inner(&self, area: Rect) -> Rect {
280        self.block.inner_if_some(area)
281    }
282
283    /// Run the layout to calculate the popup area before rendering.
284    pub fn layout(&self, area: Rect, buf: &Buffer) -> Rect {
285        self._layout(area, self.boundary_area.unwrap_or(buf.area))
286    }
287}
288
289impl<'a> StatefulWidget for &'a PopupCore<'a> {
290    type State = PopupCoreState;
291
292    fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
293        render_popup(self, area, buf, state);
294    }
295}
296
297impl StatefulWidget for PopupCore<'_> {
298    type State = PopupCoreState;
299
300    fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
301        render_popup(&self, area, buf, state);
302    }
303}
304
305fn render_popup(widget: &PopupCore<'_>, area: Rect, buf: &mut Buffer, state: &mut PopupCoreState) {
306    if !state.active.is_focused() {
307        state.clear_areas();
308        return;
309    }
310
311    state.area = widget._layout(area, widget.boundary_area.unwrap_or(buf.area));
312
313    reset_buf_area(state.area, buf);
314
315    let sa = ScrollArea::new()
316        .block(widget.block.as_ref())
317        .h_scroll(widget.h_scroll.as_ref())
318        .v_scroll(widget.v_scroll.as_ref())
319        .style(fallback_popup_style(widget.style));
320
321    state.widget_area = sa.inner(state.area, Some(&state.h_scroll), Some(&state.v_scroll));
322
323    sa.render(
324        state.area,
325        buf,
326        &mut ScrollAreaState::new()
327            .h_scroll(&mut state.h_scroll)
328            .v_scroll(&mut state.v_scroll),
329    );
330}
331
332/// Fallback for popup style.
333pub fn fallback_popup_style(style: Style) -> Style {
334    if style.fg.is_some() || style.bg.is_some() {
335        style
336    } else {
337        style.black().on_gray()
338    }
339}
340
341/// Reset an area of the buffer.
342pub fn reset_buf_area(area: Rect, buf: &mut Buffer) {
343    for y in area.top()..area.bottom() {
344        for x in area.left()..area.right() {
345            if let Some(cell) = buf.cell_mut((x, y)) {
346                cell.reset();
347            }
348        }
349    }
350}
351
352impl PopupCore<'_> {
353    fn _layout(&self, area: Rect, boundary_area: Rect) -> Rect {
354        // helper fn
355        fn center(len: u16, within: u16) -> u16 {
356            ((within as i32 - len as i32) / 2).clamp(0, i16::MAX as i32) as u16
357        }
358        let middle = center;
359        fn right(len: u16, within: u16) -> u16 {
360            within.saturating_sub(len)
361        }
362        let bottom = right;
363
364        // offsets may change
365        let mut offset = self.offset;
366
367        let mut area = match self.constraint.get() {
368            PopupConstraint::None => area,
369            PopupConstraint::Above(Alignment::Left, rel) => Rect::new(
370                rel.x,
371                rel.y.saturating_sub(area.height),
372                area.width,
373                area.height,
374            ),
375            PopupConstraint::Above(Alignment::Center, rel) => Rect::new(
376                rel.x + center(area.width, rel.width),
377                rel.y.saturating_sub(area.height),
378                area.width,
379                area.height,
380            ),
381            PopupConstraint::Above(Alignment::Right, rel) => Rect::new(
382                rel.x + right(area.width, rel.width),
383                rel.y.saturating_sub(area.height),
384                area.width,
385                area.height,
386            ),
387            PopupConstraint::Below(Alignment::Left, rel) => Rect::new(
388                rel.x, //
389                rel.bottom(),
390                area.width,
391                area.height,
392            ),
393            PopupConstraint::Below(Alignment::Center, rel) => Rect::new(
394                rel.x + center(area.width, rel.width),
395                rel.bottom(),
396                area.width,
397                area.height,
398            ),
399            PopupConstraint::Below(Alignment::Right, rel) => Rect::new(
400                rel.x + right(area.width, rel.width),
401                rel.bottom(),
402                area.width,
403                area.height,
404            ),
405
406            PopupConstraint::Left(Alignment::Left, rel) => Rect::new(
407                rel.x.saturating_sub(area.width),
408                rel.y,
409                area.width,
410                area.height,
411            ),
412            PopupConstraint::Left(Alignment::Center, rel) => Rect::new(
413                rel.x.saturating_sub(area.width),
414                rel.y + middle(area.height, rel.height),
415                area.width,
416                area.height,
417            ),
418            PopupConstraint::Left(Alignment::Right, rel) => Rect::new(
419                rel.x.saturating_sub(area.width),
420                rel.y + bottom(area.height, rel.height),
421                area.width,
422                area.height,
423            ),
424            PopupConstraint::Right(Alignment::Left, rel) => Rect::new(
425                rel.right(), //
426                rel.y,
427                area.width,
428                area.height,
429            ),
430            PopupConstraint::Right(Alignment::Center, rel) => Rect::new(
431                rel.right(),
432                rel.y + middle(area.height, rel.height),
433                area.width,
434                area.height,
435            ),
436            PopupConstraint::Right(Alignment::Right, rel) => Rect::new(
437                rel.right(),
438                rel.y + bottom(area.height, rel.height),
439                area.width,
440                area.height,
441            ),
442
443            PopupConstraint::Position(x, y) => Rect::new(
444                x, //
445                y,
446                area.width,
447                area.height,
448            ),
449
450            PopupConstraint::AboveOrBelow(Alignment::Left, rel) => {
451                if area.height.saturating_add_signed(-self.offset.1) < rel.y {
452                    Rect::new(
453                        rel.x,
454                        rel.y.saturating_sub(area.height),
455                        area.width,
456                        area.height,
457                    )
458                } else {
459                    offset = (offset.0, -offset.1);
460                    Rect::new(
461                        rel.x, //
462                        rel.bottom(),
463                        area.width,
464                        area.height,
465                    )
466                }
467            }
468            PopupConstraint::AboveOrBelow(Alignment::Center, rel) => {
469                if area.height.saturating_add_signed(-self.offset.1) < rel.y {
470                    Rect::new(
471                        rel.x + center(area.width, rel.width),
472                        rel.y.saturating_sub(area.height),
473                        area.width,
474                        area.height,
475                    )
476                } else {
477                    offset = (offset.0, -offset.1);
478                    Rect::new(
479                        rel.x + center(area.width, rel.width), //
480                        rel.bottom(),
481                        area.width,
482                        area.height,
483                    )
484                }
485            }
486            PopupConstraint::AboveOrBelow(Alignment::Right, rel) => {
487                if area.height.saturating_add_signed(-self.offset.1) < rel.y {
488                    Rect::new(
489                        rel.x + right(area.width, rel.width),
490                        rel.y.saturating_sub(area.height),
491                        area.width,
492                        area.height,
493                    )
494                } else {
495                    offset = (offset.0, -offset.1);
496                    Rect::new(
497                        rel.x + right(area.width, rel.width), //
498                        rel.bottom(),
499                        area.width,
500                        area.height,
501                    )
502                }
503            }
504            PopupConstraint::BelowOrAbove(Alignment::Left, rel) => {
505                if (rel.bottom() + area.height).saturating_add_signed(self.offset.1)
506                    <= boundary_area.height
507                {
508                    Rect::new(
509                        rel.x, //
510                        rel.bottom(),
511                        area.width,
512                        area.height,
513                    )
514                } else {
515                    offset = (offset.0, -offset.1);
516                    Rect::new(
517                        rel.x,
518                        rel.y.saturating_sub(area.height),
519                        area.width,
520                        area.height,
521                    )
522                }
523            }
524            PopupConstraint::BelowOrAbove(Alignment::Center, rel) => {
525                if (rel.bottom() + area.height).saturating_add_signed(self.offset.1)
526                    <= boundary_area.height
527                {
528                    Rect::new(
529                        rel.x + center(area.width, rel.width), //
530                        rel.bottom(),
531                        area.width,
532                        area.height,
533                    )
534                } else {
535                    offset = (offset.0, -offset.1);
536                    Rect::new(
537                        rel.x + center(area.width, rel.width),
538                        rel.y.saturating_sub(area.height),
539                        area.width,
540                        area.height,
541                    )
542                }
543            }
544            PopupConstraint::BelowOrAbove(Alignment::Right, rel) => {
545                if (rel.bottom() + area.height).saturating_add_signed(self.offset.1)
546                    <= boundary_area.height
547                {
548                    Rect::new(
549                        rel.x + right(area.width, rel.width), //
550                        rel.bottom(),
551                        area.width,
552                        area.height,
553                    )
554                } else {
555                    offset = (offset.0, -offset.1);
556                    Rect::new(
557                        rel.x + right(area.width, rel.width),
558                        rel.y.saturating_sub(area.height),
559                        area.width,
560                        area.height,
561                    )
562                }
563            }
564        };
565
566        // offset
567        area.x = area.x.saturating_add_signed(offset.0);
568        area.y = area.y.saturating_add_signed(offset.1);
569
570        // keep in sight
571        if area.left() < boundary_area.left() {
572            area.x = boundary_area.left();
573        }
574        if area.right() >= boundary_area.right() {
575            let corr = area.right().saturating_sub(boundary_area.right());
576            area.x = max(boundary_area.left(), area.x.saturating_sub(corr));
577        }
578        if area.top() < boundary_area.top() {
579            area.y = boundary_area.top();
580        }
581        if area.bottom() >= boundary_area.bottom() {
582            let corr = area.bottom().saturating_sub(boundary_area.bottom());
583            area.y = max(boundary_area.top(), area.y.saturating_sub(corr));
584        }
585
586        // shrink to size
587        if area.right() > boundary_area.right() {
588            let corr = area.right() - boundary_area.right();
589            area.width = area.width.saturating_sub(corr);
590        }
591        if area.bottom() > boundary_area.bottom() {
592            let corr = area.bottom() - boundary_area.bottom();
593            area.height = area.height.saturating_sub(corr);
594        }
595
596        area
597    }
598}
599
600impl Default for PopupStyle {
601    fn default() -> Self {
602        Self {
603            style: Default::default(),
604            offset: None,
605            block: None,
606            border_style: None,
607            scroll: None,
608            alignment: None,
609            placement: None,
610            non_exhaustive: NonExhaustive,
611        }
612    }
613}
614
615impl Clone for PopupCoreState {
616    fn clone(&self) -> Self {
617        Self {
618            area: self.area,
619            area_z: self.area_z,
620            widget_area: self.widget_area,
621            h_scroll: self.h_scroll.clone(),
622            v_scroll: self.v_scroll.clone(),
623            active: FocusFlag::named(self.active.name()),
624            mouse: Default::default(),
625            non_exhaustive: NonExhaustive,
626        }
627    }
628}
629
630impl Default for PopupCoreState {
631    fn default() -> Self {
632        Self {
633            area: Default::default(),
634            area_z: 1,
635            widget_area: Default::default(),
636            h_scroll: Default::default(),
637            v_scroll: Default::default(),
638            active: FocusFlag::named("popup"),
639            mouse: Default::default(),
640            non_exhaustive: NonExhaustive,
641        }
642    }
643}
644
645impl RelocatableState for PopupCoreState {
646    fn relocate(&mut self, shift: (i16, i16), clip: Rect) {
647        self.area = relocate_area(self.area, shift, clip);
648        self.widget_area = relocate_area(self.widget_area, shift, clip);
649    }
650}
651
652impl PopupCoreState {
653    /// New
654    #[inline]
655    pub fn new() -> Self {
656        Default::default()
657    }
658
659    /// New with a focus name.
660    pub fn named(name: &str) -> Self {
661        Self {
662            active: FocusFlag::named(name),
663            ..Default::default()
664        }
665    }
666
667    /// Set the z-index of the popup.
668    pub fn set_area_z(&mut self, z: u16) {
669        self.area_z = z;
670    }
671
672    /// The z-index of the popup.
673    pub fn area_z(&self) -> u16 {
674        self.area_z
675    }
676
677    /// Is the popup active/visible.
678    pub fn is_active(&self) -> bool {
679        self.active.is_focused()
680    }
681
682    /// Flip visibility of the popup.
683    pub fn flip_active(&mut self) {
684        self.set_active(!self.is_active());
685    }
686
687    /// Show the popup.
688    /// This will set gained/lost flags according to the change.
689    /// If the popup is hidden this will clear all flags.
690    pub fn set_active(&mut self, active: bool) -> bool {
691        let old_value = self.is_active();
692        if active {
693            if !self.is_active() {
694                self.active.set(true);
695                self.active.set_gained(true);
696                self.active.set_lost(false);
697            } else {
698                self.active.set_gained(false);
699                self.active.set_lost(false);
700            }
701        } else {
702            if self.is_active() {
703                self.active.set(false);
704                self.active.set_gained(false);
705                self.active.set_lost(true);
706            } else {
707                self.active.set_gained(false);
708                self.active.set_lost(false);
709            }
710        }
711        old_value != self.is_active()
712    }
713
714    /// Clear the areas.
715    pub fn clear_areas(&mut self) {
716        self.area = Default::default();
717        self.widget_area = Default::default();
718        self.v_scroll.area = Default::default();
719        self.h_scroll.area = Default::default();
720    }
721}
722
723impl HandleEvent<crossterm::event::Event, Popup, PopupOutcome> for PopupCoreState {
724    fn handle(&mut self, event: &crossterm::event::Event, _qualifier: Popup) -> PopupOutcome {
725        if self.is_active() {
726            match event {
727                ct_event!(mouse down Left for x,y)
728                | ct_event!(mouse down Right for x,y)
729                | ct_event!(mouse down Middle for x,y)
730                    if !self.area.contains((*x, *y).into()) =>
731                {
732                    PopupOutcome::Hide
733                }
734                _ => PopupOutcome::Continue,
735            }
736        } else {
737            PopupOutcome::Continue
738        }
739    }
740}