rat_popup/
popup.rs

1use crate::_private::NonExhaustive;
2use crate::event::PopupOutcome;
3use crate::{Placement, PopupConstraint};
4use rat_event::util::MouseFlags;
5use rat_event::{HandleEvent, Popup, ct_event};
6use rat_reloc::{RelocatableState, relocate_area};
7use ratatui::buffer::Buffer;
8use ratatui::layout::{Alignment, Rect};
9use ratatui::style::{Style, Stylize};
10use ratatui::widgets::StatefulWidget;
11use std::cell::Cell;
12use std::cmp::max;
13use std::rc::Rc;
14
15/// Provides the core for popup widgets.
16///
17/// This widget can calculate the placement of a popup widget
18/// using [placement](PopupCore::constraint), [offset](PopupCore::offset)
19/// and the outer [boundary](PopupCore::boundary).
20///
21/// It provides the widget area as [area](PopupCoreState::area).
22///
23/// After rendering the PopupCore the main widget can render it's
24/// content in the calculated [PopupCoreState::area].
25///
26/// ## Event handling
27///
28/// Will detect any mouse-clicks outside its area and
29/// return [PopupOutcome::Hide]. Actually showing/hiding the popup is
30/// the job of the main widget.
31///
32/// __See__
33/// See the examples some variants.
34///
35#[derive(Debug, Clone)]
36pub struct PopupCore {
37    /// Constraints for the popup.
38    pub constraint: Cell<PopupConstraint>,
39    /// Extra offset after calculating the position
40    /// with constraint.
41    pub offset: (i16, i16),
42    /// Outer boundary for the popup-placement.
43    /// If not set uses the buffer-area.
44    pub boundary_area: Option<Rect>,
45
46    pub non_exhaustive: NonExhaustive,
47}
48
49/// Complete styles for the popup.
50#[derive(Debug, Clone)]
51pub struct PopupStyle {
52    /// Extra offset added after applying the constraints.
53    pub offset: Option<(i16, i16)>,
54    /// Alignment.
55    pub alignment: Option<Alignment>,
56    /// Placement
57    pub placement: Option<Placement>,
58
59    /// non-exhaustive struct.
60    pub non_exhaustive: NonExhaustive,
61}
62
63/// State for the PopupCore.
64#[derive(Debug)]
65pub struct PopupCoreState {
66    /// Area for the widget.
67    /// This is the area given to render(), corrected by the
68    /// given constraints.
69    /// __read only__. renewed for each render.
70    pub area: Rect,
71    /// Z-Index for the popup.
72    pub area_z: u16,
73
74    /// Active flag for the popup.
75    ///
76    /// __read+write__
77    pub active: Rc<Cell<bool>>,
78
79    /// Mouse flags.
80    /// __read+write__
81    pub mouse: MouseFlags,
82
83    /// non-exhaustive struct.
84    pub non_exhaustive: NonExhaustive,
85}
86
87impl Default for PopupCore {
88    fn default() -> Self {
89        Self {
90            constraint: Cell::new(PopupConstraint::None),
91            offset: (0, 0),
92            boundary_area: None,
93            non_exhaustive: NonExhaustive,
94        }
95    }
96}
97
98impl PopupCore {
99    /// New.
100    pub fn new() -> Self {
101        Self::default()
102    }
103
104    /// Placement constraints for the popup widget.
105    pub fn ref_constraint(&self, constraint: PopupConstraint) -> &Self {
106        self.constraint.set(constraint);
107        self
108    }
109
110    /// Placement constraints for the popup widget.
111    pub fn constraint(self, constraint: PopupConstraint) -> Self {
112        self.constraint.set(constraint);
113        self
114    }
115
116    /// Adds an extra offset to the widget area.
117    ///
118    /// This can be used to
119    /// * place the widget under the mouse cursor.
120    /// * align the widget not by the outer bounds but by
121    ///   the text content.
122    pub fn offset(mut self, offset: (i16, i16)) -> Self {
123        self.offset = offset;
124        self
125    }
126
127    /// Sets only the x offset.
128    /// See [offset](Self::offset)
129    pub fn x_offset(mut self, offset: i16) -> Self {
130        self.offset.0 = offset;
131        self
132    }
133
134    /// Sets only the y offset.
135    /// See [offset](Self::offset)
136    pub fn y_offset(mut self, offset: i16) -> Self {
137        self.offset.1 = offset;
138        self
139    }
140
141    /// Sets outer boundaries for the popup widget.
142    ///
143    /// This will be used to ensure that the popup widget is fully visible.
144    /// First it tries to move the popup in a way that is fully inside
145    /// this area. If this is not enought the popup area will be clipped.
146    ///
147    /// If this is not set, [Buffer::area] will be used instead.
148    pub fn boundary(mut self, boundary: Rect) -> Self {
149        self.boundary_area = Some(boundary);
150        self
151    }
152
153    /// Set styles
154    pub fn styles(mut self, styles: PopupStyle) -> Self {
155        if let Some(offset) = styles.offset {
156            self.offset = offset;
157        }
158
159        self
160    }
161
162    /// Run the layout to calculate the popup area before rendering.
163    pub fn layout(&self, area: Rect, buf: &Buffer) -> Rect {
164        self._layout(area, self.boundary_area.unwrap_or(buf.area))
165    }
166}
167
168impl StatefulWidget for &PopupCore {
169    type State = PopupCoreState;
170
171    fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
172        render_popup(self, area, buf, state);
173    }
174}
175
176impl StatefulWidget for PopupCore {
177    type State = PopupCoreState;
178
179    fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
180        render_popup(&self, area, buf, state);
181    }
182}
183
184fn render_popup(widget: &PopupCore, area: Rect, buf: &mut Buffer, state: &mut PopupCoreState) {
185    if !state.active.get() {
186        state.clear_areas();
187        return;
188    }
189
190    state.area = widget._layout(area, widget.boundary_area.unwrap_or(buf.area));
191
192    reset_buf_area(state.area, buf);
193}
194
195/// Fallback for popup style.
196pub fn fallback_popup_style(style: Style) -> Style {
197    if style.fg.is_some() || style.bg.is_some() {
198        style
199    } else {
200        style.black().on_gray()
201    }
202}
203
204/// Reset an area of the buffer.
205pub fn reset_buf_area(area: Rect, buf: &mut Buffer) {
206    for y in area.top()..area.bottom() {
207        for x in area.left()..area.right() {
208            if let Some(cell) = buf.cell_mut((x, y)) {
209                cell.reset();
210            }
211        }
212    }
213}
214
215impl PopupCore {
216    fn _layout(&self, area: Rect, boundary_area: Rect) -> Rect {
217        // helper fn
218        fn center(len: u16, within: u16) -> u16 {
219            ((within as i32 - len as i32) / 2).clamp(0, i16::MAX as i32) as u16
220        }
221        let middle = center;
222        fn right(len: u16, within: u16) -> u16 {
223            within.saturating_sub(len)
224        }
225        let bottom = right;
226
227        // offsets may change
228        let mut offset = self.offset;
229
230        let mut area = match self.constraint.get() {
231            PopupConstraint::None => area,
232            PopupConstraint::Above(Alignment::Left, rel) => Rect::new(
233                rel.x,
234                rel.y.saturating_sub(area.height),
235                area.width,
236                area.height,
237            ),
238            PopupConstraint::Above(Alignment::Center, rel) => Rect::new(
239                rel.x + center(area.width, rel.width),
240                rel.y.saturating_sub(area.height),
241                area.width,
242                area.height,
243            ),
244            PopupConstraint::Above(Alignment::Right, rel) => Rect::new(
245                rel.x + right(area.width, rel.width),
246                rel.y.saturating_sub(area.height),
247                area.width,
248                area.height,
249            ),
250            PopupConstraint::Below(Alignment::Left, rel) => Rect::new(
251                rel.x, //
252                rel.bottom(),
253                area.width,
254                area.height,
255            ),
256            PopupConstraint::Below(Alignment::Center, rel) => Rect::new(
257                rel.x + center(area.width, rel.width),
258                rel.bottom(),
259                area.width,
260                area.height,
261            ),
262            PopupConstraint::Below(Alignment::Right, rel) => Rect::new(
263                rel.x + right(area.width, rel.width),
264                rel.bottom(),
265                area.width,
266                area.height,
267            ),
268
269            PopupConstraint::Left(Alignment::Left, rel) => Rect::new(
270                rel.x.saturating_sub(area.width),
271                rel.y,
272                area.width,
273                area.height,
274            ),
275            PopupConstraint::Left(Alignment::Center, rel) => Rect::new(
276                rel.x.saturating_sub(area.width),
277                rel.y + middle(area.height, rel.height),
278                area.width,
279                area.height,
280            ),
281            PopupConstraint::Left(Alignment::Right, rel) => Rect::new(
282                rel.x.saturating_sub(area.width),
283                rel.y + bottom(area.height, rel.height),
284                area.width,
285                area.height,
286            ),
287            PopupConstraint::Right(Alignment::Left, rel) => Rect::new(
288                rel.right(), //
289                rel.y,
290                area.width,
291                area.height,
292            ),
293            PopupConstraint::Right(Alignment::Center, rel) => Rect::new(
294                rel.right(),
295                rel.y + middle(area.height, rel.height),
296                area.width,
297                area.height,
298            ),
299            PopupConstraint::Right(Alignment::Right, rel) => Rect::new(
300                rel.right(),
301                rel.y + bottom(area.height, rel.height),
302                area.width,
303                area.height,
304            ),
305
306            PopupConstraint::Position(x, y) => Rect::new(
307                x, //
308                y,
309                area.width,
310                area.height,
311            ),
312
313            PopupConstraint::AboveOrBelow(Alignment::Left, rel) => {
314                if area.height.saturating_add_signed(-self.offset.1) < rel.y {
315                    Rect::new(
316                        rel.x,
317                        rel.y.saturating_sub(area.height),
318                        area.width,
319                        area.height,
320                    )
321                } else {
322                    offset = (offset.0, -offset.1);
323                    Rect::new(
324                        rel.x, //
325                        rel.bottom(),
326                        area.width,
327                        area.height,
328                    )
329                }
330            }
331            PopupConstraint::AboveOrBelow(Alignment::Center, rel) => {
332                if area.height.saturating_add_signed(-self.offset.1) < rel.y {
333                    Rect::new(
334                        rel.x + center(area.width, rel.width),
335                        rel.y.saturating_sub(area.height),
336                        area.width,
337                        area.height,
338                    )
339                } else {
340                    offset = (offset.0, -offset.1);
341                    Rect::new(
342                        rel.x + center(area.width, rel.width), //
343                        rel.bottom(),
344                        area.width,
345                        area.height,
346                    )
347                }
348            }
349            PopupConstraint::AboveOrBelow(Alignment::Right, rel) => {
350                if area.height.saturating_add_signed(-self.offset.1) < rel.y {
351                    Rect::new(
352                        rel.x + right(area.width, rel.width),
353                        rel.y.saturating_sub(area.height),
354                        area.width,
355                        area.height,
356                    )
357                } else {
358                    offset = (offset.0, -offset.1);
359                    Rect::new(
360                        rel.x + right(area.width, rel.width), //
361                        rel.bottom(),
362                        area.width,
363                        area.height,
364                    )
365                }
366            }
367            PopupConstraint::BelowOrAbove(Alignment::Left, rel) => {
368                if (rel.bottom() + area.height).saturating_add_signed(self.offset.1)
369                    <= boundary_area.height
370                {
371                    Rect::new(
372                        rel.x, //
373                        rel.bottom(),
374                        area.width,
375                        area.height,
376                    )
377                } else {
378                    offset = (offset.0, -offset.1);
379                    Rect::new(
380                        rel.x,
381                        rel.y.saturating_sub(area.height),
382                        area.width,
383                        area.height,
384                    )
385                }
386            }
387            PopupConstraint::BelowOrAbove(Alignment::Center, rel) => {
388                if (rel.bottom() + area.height).saturating_add_signed(self.offset.1)
389                    <= boundary_area.height
390                {
391                    Rect::new(
392                        rel.x + center(area.width, rel.width), //
393                        rel.bottom(),
394                        area.width,
395                        area.height,
396                    )
397                } else {
398                    offset = (offset.0, -offset.1);
399                    Rect::new(
400                        rel.x + center(area.width, rel.width),
401                        rel.y.saturating_sub(area.height),
402                        area.width,
403                        area.height,
404                    )
405                }
406            }
407            PopupConstraint::BelowOrAbove(Alignment::Right, rel) => {
408                if (rel.bottom() + area.height).saturating_add_signed(self.offset.1)
409                    <= boundary_area.height
410                {
411                    Rect::new(
412                        rel.x + right(area.width, rel.width), //
413                        rel.bottom(),
414                        area.width,
415                        area.height,
416                    )
417                } else {
418                    offset = (offset.0, -offset.1);
419                    Rect::new(
420                        rel.x + right(area.width, rel.width),
421                        rel.y.saturating_sub(area.height),
422                        area.width,
423                        area.height,
424                    )
425                }
426            }
427        };
428
429        // offset
430        area.x = area.x.saturating_add_signed(offset.0);
431        area.y = area.y.saturating_add_signed(offset.1);
432
433        // keep in sight
434        if area.left() < boundary_area.left() {
435            area.x = boundary_area.left();
436        }
437        if area.right() >= boundary_area.right() {
438            let corr = area.right().saturating_sub(boundary_area.right());
439            area.x = max(boundary_area.left(), area.x.saturating_sub(corr));
440        }
441        if area.top() < boundary_area.top() {
442            area.y = boundary_area.top();
443        }
444        if area.bottom() >= boundary_area.bottom() {
445            let corr = area.bottom().saturating_sub(boundary_area.bottom());
446            area.y = max(boundary_area.top(), area.y.saturating_sub(corr));
447        }
448
449        // shrink to size
450        if area.right() > boundary_area.right() {
451            let corr = area.right() - boundary_area.right();
452            area.width = area.width.saturating_sub(corr);
453        }
454        if area.bottom() > boundary_area.bottom() {
455            let corr = area.bottom() - boundary_area.bottom();
456            area.height = area.height.saturating_sub(corr);
457        }
458
459        area
460    }
461}
462
463impl Default for PopupStyle {
464    fn default() -> Self {
465        Self {
466            offset: None,
467            alignment: None,
468            placement: None,
469            non_exhaustive: NonExhaustive,
470        }
471    }
472}
473
474impl Clone for PopupCoreState {
475    fn clone(&self) -> Self {
476        Self {
477            area: self.area,
478            area_z: self.area_z,
479            active: self.active.clone(),
480            mouse: Default::default(),
481            non_exhaustive: NonExhaustive,
482        }
483    }
484}
485
486impl Default for PopupCoreState {
487    fn default() -> Self {
488        Self {
489            area: Default::default(),
490            area_z: 1,
491            active: Default::default(),
492            mouse: Default::default(),
493            non_exhaustive: NonExhaustive,
494        }
495    }
496}
497
498impl RelocatableState for PopupCoreState {
499    fn relocate(&mut self, shift: (i16, i16), clip: Rect) {
500        self.area = relocate_area(self.area, shift, clip);
501    }
502}
503
504impl PopupCoreState {
505    /// New
506    #[inline]
507    pub fn new() -> Self {
508        Default::default()
509    }
510
511    /// Is the popup active/visible.
512    pub fn is_active(&self) -> bool {
513        self.active.get()
514    }
515
516    /// Flip visibility of the popup.
517    pub fn flip_active(&mut self) {
518        self.set_active(!self.is_active());
519    }
520
521    /// Show the popup.
522    /// This will set gained/lost flags according to the change.
523    /// If the popup is hidden this will clear all flags.
524    pub fn set_active(&mut self, active: bool) -> bool {
525        let old_value = self.is_active();
526        self.active.set(active);
527        old_value != self.is_active()
528    }
529
530    /// Clear all stored areas.
531    pub fn clear_areas(&mut self) {
532        self.area = Default::default();
533    }
534}
535
536impl HandleEvent<crossterm::event::Event, Popup, PopupOutcome> for PopupCoreState {
537    fn handle(&mut self, event: &crossterm::event::Event, _qualifier: Popup) -> PopupOutcome {
538        if self.is_active() {
539            match event {
540                ct_event!(mouse down Left for x,y)
541                | ct_event!(mouse down Right for x,y)
542                | ct_event!(mouse down Middle for x,y)
543                    if !self.area.contains((*x, *y).into()) =>
544                {
545                    PopupOutcome::Hide
546                }
547                _ => PopupOutcome::Continue,
548            }
549        } else {
550            PopupOutcome::Continue
551        }
552    }
553}