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