Skip to main content

qframe/widgets/side_panel/
mod.rs

1//! Side panels: a surface docked to one side of a body that opens, closes and resizes.
2
3mod strip;
4#[cfg(test)]
5mod tests;
6
7use std::rc::Rc;
8use std::time::Duration;
9
10use crate::event::Event;
11use crate::geometry::{Rect, Size};
12use crate::keymap::Scope;
13use crate::motion::{Easing, steps};
14use crate::widget::{Axis as FlexAxis, EventCx, Flex, Length, MeasureCx, Node, NodeMut, PaintCx, View, Widget};
15
16use super::boundary::{self, Axis, Change, Toggle};
17use strip::{OpenMessage, STRIP, Strip, ViewMessage};
18
19type Part<'a, Msg> = Box<dyn FnOnce(&mut View<'_, Msg>) + 'a>;
20
21/// Which side a [`SidePanel`] is docked to.
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
23pub enum Side {
24    /// The left edge.
25    #[default]
26    Left,
27    /// The right edge.
28    Right,
29}
30
31/// What closing a [`SidePanel`] leaves behind.
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
33pub enum Closed {
34    /// The panel folds into its icon strip, which stays at the edge; without a strip nothing but
35    /// the edge column is left.
36    #[default]
37    Collapse,
38    /// The panel and its icon strip both go; the body takes the whole width but the one edge
39    /// column, which looks like the body until the pointer reaches it.
40    Hide,
41}
42
43/// A panel docked to the left or right of a body, on its own surface.
44///
45/// The application owns whether the panel is open, how wide it is and which view it shows. The
46/// plain panel is just a surface next to the body; capabilities are opt-in:
47///
48/// - [`SidePanel::on_toggle`] makes the edge between panel and body interactive. The edge is a
49///   tone difference, never a line: on hover it brightens one step and a two-cell toggle appears
50///   at its middle, a `‹` or `›` on a raised surface reaching one cell into the body. Pointing at
51///   the toggle raises it a step more and shows the pillar `▌` in its first cell, pressing it
52///   raises it one more; clicking toggles.
53///   Tab reaches the edge and shows the same toggle, where Enter or Space toggles, and the global
54///   action `toggle-panel` (`alt+b` by default) toggles from anywhere inside the panel or the
55///   body. Opening and closing slide over twice `motion.enter`. Dragging a closed edge half the
56///   minimum width into the body opens the panel.
57/// - [`SidePanel::on_resize`] lets the edge be dragged, and moved with ←/→ while focused, within
58///   [`SidePanel::limits`].
59/// - [`SidePanel::strip`] adds an activity bar: a column of view icons at the outer edge, beside
60///   the open panel and left behind when it collapses. [`SidePanel::active_view`] marks the shown
61///   view's icon, raised with the pillar. Clicking another icon switches to its view (opening
62///   the panel if it is closed); clicking the shown view's icon closes the panel. Tab reaches
63///   the strip, where ↑/↓ move and Enter or Space act like a click.
64/// - [`SidePanel::closed`] chooses what closing leaves: the strip ([`Closed::Collapse`], the
65///   default) or only an invisible edge column ([`Closed::Hide`]).
66///
67/// Style keys: `side-panel` (`bg`), `side-strip` (`bg`), `side-strip-item` (`fg`, `bg`, `pillar`)
68/// with `hover`, `selected`, `focus` and `pressed`, `split-handle` for the edge and `side-toggle`
69/// (`bg`, `fg`, `pillar`) with `hover`, `focus` and `pressed` for the toggle. Icons: `edge-left`,
70/// `edge-right`.
71pub struct SidePanel<'a, Msg> {
72    side: Side,
73    width: u16,
74    open: bool,
75    closed: Closed,
76    min: u16,
77    max: Option<u16>,
78    on_toggle: Option<OpenMessage<Msg>>,
79    on_resize: Option<Box<dyn Fn(u16) -> Msg>>,
80    strip: Vec<String>,
81    on_strip: Option<ViewMessage<Msg>>,
82    active_view: Option<u16>,
83    panel: Option<Part<'a, Msg>>,
84    body: Option<Part<'a, Msg>>,
85}
86
87impl<'a, Msg: 'static> SidePanel<'a, Msg> {
88    /// An open panel `width` columns wide, docked left.
89    #[must_use]
90    pub fn new(width: u16) -> Self {
91        Self {
92            side: Side::Left,
93            width,
94            open: true,
95            closed: Closed::Collapse,
96            min: 8,
97            max: None,
98            on_toggle: None,
99            on_resize: None,
100            strip: Vec::new(),
101            on_strip: None,
102            active_view: None,
103            panel: None,
104            body: None,
105        }
106    }
107
108    /// Docks the panel to `side`.
109    #[must_use]
110    pub fn side(mut self, side: Side) -> Self {
111        self.side = side;
112        self
113    }
114
115    /// Whether the panel is open; `true` by default.
116    #[must_use]
117    pub fn open(mut self, open: bool) -> Self {
118        self.open = open;
119        self
120    }
121
122    /// What closing leaves behind: [`Closed::Collapse`] (the default) keeps the icon strip,
123    /// [`Closed::Hide`] hides the strip too and leaves only the edge column.
124    #[must_use]
125    pub fn closed(mut self, closed: Closed) -> Self {
126        self.closed = closed;
127        self
128    }
129
130    /// Makes the edge toggle the panel; the message carries the new open state.
131    #[must_use]
132    pub fn on_toggle(mut self, message: impl Fn(bool) -> Msg + 'static) -> Self {
133        self.on_toggle = Some(Rc::new(message));
134        self
135    }
136
137    /// Makes the edge resize the panel; the message carries the new width within the limits.
138    #[must_use]
139    pub fn on_resize(mut self, message: impl Fn(u16) -> Msg + 'static) -> Self {
140        self.on_resize = Some(Box::new(message));
141        self
142    }
143
144    /// The narrowest and widest the panel can be, in columns: `limits(18, 48)` for a range, or
145    /// `limits(18, None)` for no upper limit. By default 8 and no upper limit; whatever the
146    /// limits, the body keeps at least a quarter of the area. A `max` below `min` counts as `min`.
147    #[must_use]
148    pub fn limits(mut self, min: u16, max: impl Into<Option<u16>>) -> Self {
149        self.min = min;
150        self.max = max.into();
151        self
152    }
153
154    /// Adds an activity bar of these view icons at the outer edge. Clicking an icon sends its
155    /// index, meaning "show this view": set the view and open the panel in `update`. The icon of
156    /// the [`active_view`](Self::active_view) instead closes the open panel through
157    /// [`on_toggle`](Self::on_toggle).
158    #[must_use]
159    pub fn strip(
160        mut self,
161        icons: impl IntoIterator<Item = impl Into<String>>,
162        message: impl Fn(u16) -> Msg + 'static,
163    ) -> Self {
164        self.strip = icons.into_iter().map(Into::into).collect();
165        self.on_strip = Some(Rc::new(message));
166        self
167    }
168
169    /// The index of the strip icon whose view the panel shows; it is raised with the pillar
170    /// while the panel is open.
171    #[must_use]
172    pub fn active_view(mut self, index: u16) -> Self {
173        self.active_view = Some(index);
174        self
175    }
176
177    /// The panel's content.
178    #[must_use]
179    pub fn panel(mut self, build: impl FnOnce(&mut View<'_, Msg>) + 'a) -> Self {
180        self.panel = Some(Box::new(build));
181        self
182    }
183
184    /// The body beside the panel.
185    #[must_use]
186    pub fn body(mut self, build: impl FnOnce(&mut View<'_, Msg>) + 'a) -> Self {
187        self.body = Some(Box::new(build));
188        self
189    }
190
191    /// Adds the panel and its body to `ui`, filling the space they get.
192    pub fn show<'v>(self, ui: &'v mut View<'_, Msg>) -> NodeMut<'v, Msg> {
193        let build = |part: Option<Part<'a, Msg>>| {
194            let mut children = Vec::new();
195            if let Some(part) = part {
196                part(&mut ui.nested(&mut children));
197            }
198            let mut node = Node::new(Flex::new(FlexAxis::Column, children), 0);
199            node.layout.width = Length::Fill(1);
200            node.layout.height = Length::Fill(1);
201            node
202        };
203        let mut parts = vec![build(self.panel), build(self.body)];
204        let has_strip = match self.on_strip {
205            Some(on_select) if !self.strip.is_empty() => {
206                let strip = Strip {
207                    side: self.side,
208                    icons: self.strip,
209                    active: self.active_view,
210                    open: self.open,
211                    on_select,
212                    on_toggle: self.on_toggle.clone(),
213                };
214                parts.push(Node::new(strip, 2));
215                true
216            }
217            _ => false,
218        };
219        let dock = Dock {
220            side: self.side,
221            width: self.width,
222            open: self.open,
223            closed: self.closed,
224            min: self.min,
225            max: self.max,
226            on_toggle: self.on_toggle,
227            on_resize: self.on_resize,
228            has_strip,
229            parts,
230        };
231        ui.add(dock).fill()
232    }
233}
234
235/// Index of the panel content, the body and the strip in [`Dock::parts`].
236const PANEL: usize = 0;
237const BODY: usize = 1;
238const STRIP_PART: usize = 2;
239
240struct Dock<Msg> {
241    side: Side,
242    width: u16,
243    open: bool,
244    closed: Closed,
245    min: u16,
246    /// The widest the application allows the panel to be; `None` for no limit.
247    max: Option<u16>,
248    on_toggle: Option<OpenMessage<Msg>>,
249    on_resize: Option<Box<dyn Fn(u16) -> Msg>>,
250    /// Whether `parts` holds a strip after the panel and the body.
251    has_strip: bool,
252    parts: Vec<Node<Msg>>,
253}
254
255impl<Msg: 'static> Dock<Msg> {
256    fn interactive(&self) -> bool {
257        self.on_toggle.is_some() || self.on_resize.is_some()
258    }
259
260    /// Columns the strip takes beside the open panel.
261    fn strip_width(&self) -> u16 {
262        if self.has_strip { STRIP } else { 0 }
263    }
264
265    /// Columns left at the edge when the panel is closed, not counting the edge column the
266    /// body gives up when nothing is left.
267    fn collapsed_width(&self) -> u16 {
268        match self.closed {
269            Closed::Collapse => self.strip_width(),
270            Closed::Hide => 0,
271        }
272    }
273
274    /// Room for the panel in `area`: the body keeps a quarter of it, and the strip its columns.
275    fn room(&self, area: Rect) -> u16 {
276        area.width.saturating_sub(area.width / 4).saturating_sub(self.strip_width())
277    }
278
279    /// The widest the panel can be in `area`: its limit, within the room.
280    fn max_width(&self, area: Rect) -> u16 {
281        let room = self.room(area);
282        self.max.map_or(room, |max| max.min(room))
283    }
284
285    /// The width the panel has in `area` when open: the application's width within the limits,
286    /// cut to the room even when that is below the minimum.
287    fn open_width(&self, area: Rect) -> u16 {
288        boundary::clamp_size(i32::from(self.width), self.min, self.max_width(area)).min(self.room(area))
289    }
290
291    /// Screen rectangle of a `width`-column strip on the docked side.
292    fn edge_rect(&self, area: Rect, width: u16) -> Rect {
293        match self.side {
294            Side::Left => Rect::new(area.x, area.y, width, area.height),
295            Side::Right => Rect::new(area.right() - i32::from(width), area.y, width, area.height),
296        }
297    }
298
299    /// The boundary column of a docked area `width` columns wide: its inner edge, or the body's
300    /// edge when nothing is docked.
301    fn handle_rect(&self, area: Rect, width: u16) -> Rect {
302        let panel = self.edge_rect(area, width.max(1));
303        match self.side {
304            Side::Left => Rect::new(panel.right() - 1, area.y, 1, area.height),
305            Side::Right => Rect::new(panel.x, area.y, 1, area.height),
306        }
307    }
308
309    fn toggle(&self, cx: &mut EventCx<'_, Msg>) {
310        if let Some(message) = &self.on_toggle {
311            cx.emit(message(!self.open));
312        }
313    }
314
315    fn resize(&self, cx: &mut EventCx<'_, Msg>, target: i32) {
316        let area = cx.area();
317        let width = boundary::clamp_size(target, self.min, self.max_width(area));
318        if let Some(message) = &self.on_resize
319            && self.open
320            && width != self.open_width(area)
321        {
322            cx.emit(message(width));
323        }
324    }
325
326    /// A drag on the closed edge, with the pointer at column `x` asking for a `target`-column
327    /// panel. Once the pointer is half the minimum width past the closed edge the panel opens,
328    /// at the dragged width when it can be resized; nearer, the drag is only held.
329    fn drag_open(&self, cx: &mut EventCx<'_, Msg>, x: i32, target: i32) {
330        let Some(toggle) = &self.on_toggle else { return };
331        let area = cx.area();
332        let edge = self.handle_rect(area, self.collapsed_width().min(area.width)).x;
333        let past_edge = match self.side {
334            Side::Left => x - edge,
335            Side::Right => edge - x,
336        };
337        if past_edge < i32::from((self.min / 2).max(1)) {
338            return;
339        }
340        cx.emit(toggle(true));
341        if let Some(message) = &self.on_resize {
342            let width = boundary::clamp_size(target, self.min, self.max_width(area));
343            if width != self.open_width(area) {
344                cx.emit(message(width));
345            }
346        }
347    }
348
349    /// Paints what moves while the panel opens and closes: the panel, and the strip too when it
350    /// hides with it. It is laid out at its open width with its inner edge on the visible inner
351    /// edge, and shows only its visible part, so its content never reflows while it slides.
352    fn paint_sliding(&self, cx: &mut PaintCx<'_>, area: Rect, width: u16, open_width: u16) {
353        let collapsed = self.collapsed_width();
354        let visible = self.edge_rect(area, width);
355        let sliding = width.saturating_sub(collapsed);
356        let strip = self.strip_width().saturating_sub(collapsed);
357        let layer_width = strip + open_width;
358        let (moving, layer_x) = match self.side {
359            Side::Left => (
360                Rect::new(visible.x + i32::from(collapsed), area.y, sliding, area.height),
361                visible.right() - i32::from(layer_width),
362            ),
363            Side::Right => (Rect::new(visible.x, area.y, sliding, area.height), visible.x),
364        };
365        let (strip_x, panel_x) = match self.side {
366            Side::Left => (layer_x, layer_x + i32::from(strip)),
367            Side::Right => (layer_x + i32::from(open_width), layer_x),
368        };
369        let panel = Rect::new(panel_x, area.y, open_width, area.height);
370        let background = cx.style("side-panel", None, &[]).text().bg.unwrap_or_else(|| cx.color("surface"));
371        // The panel's inner column is the edge, painted by the boundary.
372        let handle = u16::from(self.interactive());
373        let (content, inside) = match self.side {
374            Side::Left => (
375                Rect::new(panel.x, area.y, open_width.saturating_sub(handle), area.height),
376                Rect::new(moving.x, area.y, sliding.saturating_sub(handle), area.height),
377            ),
378            Side::Right => (
379                Rect::new(panel.x + i32::from(handle), area.y, open_width.saturating_sub(handle), area.height),
380                Rect::new(moving.x + i32::from(handle), area.y, sliding.saturating_sub(handle), area.height),
381            ),
382        };
383        cx.with_clip(moving, |cx| cx.clear(panel, background));
384        cx.with_clip(inside, |cx| {
385            cx.paint_child(&self.parts[PANEL], content);
386            if strip > 0 {
387                cx.paint_child(&self.parts[STRIP_PART], Rect::new(strip_x, area.y, strip, area.height));
388            }
389        });
390    }
391}
392
393impl<Msg: 'static> Widget<Msg> for Dock<Msg> {
394    fn measure(&self, _cx: &mut MeasureCx<'_>, available: Size) -> Size {
395        available
396    }
397
398    fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
399        let open_width = self.open_width(area);
400        let full = (self.strip_width() + open_width).min(area.width);
401        let collapsed = self.collapsed_width().min(full);
402        let duration = cx.env().theme().motion().enter * 2;
403        let progress = cx.animate("open", if self.open { 1.0 } else { 0.0 }, duration, Easing::EaseOut);
404        let width = collapsed + steps(progress, full - collapsed);
405        // A handle over the body's edge column when nothing is docked.
406        let gutter = u16::from(self.interactive() && width == 0);
407
408        let body_rect = match self.side {
409            Side::Left => Rect::new(
410                area.x + i32::from(width + gutter),
411                area.y,
412                area.width.saturating_sub(width + gutter),
413                area.height,
414            ),
415            Side::Right => Rect::new(area.x, area.y, area.width.saturating_sub(width + gutter), area.height),
416        };
417        // The strip of a collapsing panel stays put at the outer edge.
418        if collapsed > 0 {
419            cx.paint_child(&self.parts[STRIP_PART], self.edge_rect(area, collapsed));
420        }
421        if width > collapsed {
422            self.paint_sliding(cx, area, width, open_width);
423        } else if self.has_strip
424            && collapsed == 0
425            && self.interactive()
426            && cx.focused() == Some(self.parts[STRIP_PART].id())
427        {
428            // A hidden strip cannot keep focus: the edge, which reopens it, takes over.
429            cx.request_focus(cx.id());
430            cx.request_frame_in(Duration::ZERO);
431        }
432        // Painted after the strip and the panel, so Tab goes from the edge to the strip, the
433        // panel's content and then the body.
434        cx.paint_child(&self.parts[BODY], body_rect);
435        if self.interactive() {
436            let arrow = match (self.side, self.open) {
437                (Side::Left, true) | (Side::Right, false) => "edge-left",
438                (Side::Left, false) | (Side::Right, true) => "edge-right",
439            };
440            // The toggle reaches into the body, never over the panel's own content.
441            let toggle = self.on_toggle.as_ref().map(|_| Toggle { arrow, reach_after: self.side == Side::Left });
442            boundary::paint(cx, self.handle_rect(area, width), toggle);
443        }
444        if progress > 0.0 && progress < 1.0 {
445            cx.request_frame_in(Duration::from_millis(16));
446        }
447    }
448
449    fn event(&self, cx: &mut EventCx<'_, Msg>, event: &Event) -> bool {
450        if let Event::Key(key) = event
451            && self.on_toggle.is_some()
452            && cx.env().keymap().chords_for(Scope::Global, "toggle-panel").contains(&key.chord)
453        {
454            self.toggle(cx);
455            return true;
456        }
457        if !self.interactive() {
458            return false;
459        }
460        let area = cx.area();
461        let current = i32::from(self.open_width(area));
462        let outward = match self.side {
463            Side::Left => 1,
464            Side::Right => -1,
465        };
466        match boundary::event(cx, event, Axis::Columns) {
467            Change::Ignored => false,
468            Change::Used => true,
469            Change::Activate => {
470                self.toggle(cx);
471                true
472            }
473            Change::DragTo(x) => {
474                // The panel's width with its edge under the pointer, beside the strip.
475                let target = match self.side {
476                    Side::Left => x - area.x + 1,
477                    Side::Right => area.right() - x,
478                } - i32::from(self.strip_width());
479                if self.open {
480                    self.resize(cx, target);
481                } else {
482                    self.drag_open(cx, x, target);
483                }
484                true
485            }
486            Change::Nudge(cells) => {
487                self.resize(cx, current + cells * outward);
488                true
489            }
490            Change::Jump(end) => {
491                let wide = (end && outward > 0) || (!end && outward < 0);
492                self.resize(cx, if wide { i32::MAX } else { 0 });
493                true
494            }
495        }
496    }
497
498    fn focusable(&self) -> bool {
499        self.interactive()
500    }
501
502    fn children(&self) -> &[Node<Msg>] {
503        &self.parts
504    }
505
506    fn children_mut(&mut self) -> &mut [Node<Msg>] {
507        &mut self.parts
508    }
509}