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