Skip to main content

qframe/widgets/
sections.rs

1//! Titled sections that open and close: the one model behind [`Accordion`](super::Accordion)
2//! and [`WidgetDock`](super::WidgetDock).
3//!
4//! The model owns what both views share: the title row and its states, the keyboard cursor,
5//! clicking and pressing Enter to toggle, the height animation, and dragging a title to a new
6//! place. The views only decide how much height an open body gets.
7
8use std::time::Duration;
9
10use crate::event::{Event, MouseButton, MouseKind};
11use crate::geometry::{Rect, Size, clamp_u16};
12use crate::keymap::{Key, Modifiers};
13use crate::motion::{Easing, Tween, steps};
14use crate::style::CellStyle;
15use crate::text;
16use crate::theme::State;
17use crate::widget::{EventCx, MeasureCx, Node, PaintCx};
18
19use super::cells;
20use super::row;
21
22/// The title of one section of an [`Accordion`](super::Accordion) or a
23/// [`WidgetDock`](super::WidgetDock): a label with an optional icon and a faint detail on the
24/// right, such as a count.
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct Section {
27    title: String,
28    icon: Option<String>,
29    detail: Option<String>,
30}
31
32impl Section {
33    /// A section titled `title`.
34    #[must_use]
35    pub fn new(title: impl Into<String>) -> Self {
36        Self { title: title.into(), icon: None, detail: None }
37    }
38
39    /// Icon key drawn before the title.
40    #[must_use]
41    pub fn icon(mut self, key: impl Into<String>) -> Self {
42        self.icon = Some(key.into());
43        self
44    }
45
46    /// Faint text at the right end of the title row, e.g. `3 running`.
47    #[must_use]
48    pub fn detail(mut self, detail: impl Into<String>) -> Self {
49        self.detail = Some(detail.into());
50        self
51    }
52}
53
54impl From<&str> for Section {
55    fn from(title: &str) -> Self {
56        Self::new(title)
57    }
58}
59
60impl From<String> for Section {
61    fn from(title: String) -> Self {
62        Self::new(title)
63    }
64}
65
66/// Builds a message from a section index and its new open state.
67pub(crate) type ToggleSection<Msg> = Box<dyn Fn(usize, bool) -> Msg>;
68
69/// Builds a message from a section's old and new index.
70pub(crate) type MoveSection<Msg> = Box<dyn Fn(usize, usize) -> Msg>;
71
72/// How open bodies get their height.
73#[derive(Debug, Clone, Copy, PartialEq, Eq)]
74pub(crate) enum Flow {
75    /// Every open body takes its natural height; the whole widget grows (accordion).
76    Natural,
77    /// Open bodies share the area the widget was given (dock).
78    Share,
79}
80
81/// Rows a drag must travel before it starts, so a slightly shaky click still toggles.
82const DRAG_THRESHOLD: i32 = 1;
83
84pub(crate) struct Sections<Msg> {
85    pub(crate) sections: Vec<Section>,
86    pub(crate) open: Vec<bool>,
87    pub(crate) bodies: Vec<Node<Msg>>,
88    pub(crate) on_toggle: Option<ToggleSection<Msg>>,
89    pub(crate) on_move: Option<MoveSection<Msg>>,
90    pub(crate) single: bool,
91}
92
93#[derive(Debug, Default)]
94struct SectionsMemory {
95    cursor: usize,
96    /// Title rows as last painted, by section index.
97    titles: Vec<Rect>,
98    /// Top and bottom row of every section as last painted outside a drag.
99    blocks: Vec<(i32, i32)>,
100    pressed: Option<(usize, i32)>,
101    drag: Option<Drag>,
102    /// Opening progress of every section, by title so it follows a section that moves.
103    reveal: Vec<(String, Tween)>,
104}
105
106#[derive(Debug, Clone, Copy)]
107struct Drag {
108    from: usize,
109    pointer: i32,
110    target: usize,
111}
112
113/// Where one entry of the painted stack goes.
114enum Entry {
115    Section(usize),
116    Drop,
117}
118
119impl<Msg: 'static> Sections<Msg> {
120    pub(crate) fn new(sections: Vec<Section>) -> Self {
121        Self { sections, open: Vec::new(), bodies: Vec::new(), on_toggle: None, on_move: None, single: false }
122    }
123
124    fn is_open(&self, index: usize) -> bool {
125        self.open.get(index).copied().unwrap_or(false)
126    }
127
128    fn body(&self, index: usize) -> Option<&Node<Msg>> {
129        self.bodies.get(index)
130    }
131
132    /// Rows between sections.
133    fn gap(env: &crate::env::Env) -> u16 {
134        env.theme().style("section", None, &[]).cells("gap").unwrap_or(1)
135    }
136
137    /// Height of `index`'s body with padding when open.
138    fn natural(&self, cx: &mut MeasureCx<'_>, index: usize, width: u16) -> u16 {
139        let padding = crate::style::WidgetStyle::new(cx.env().theme().style("section-body", None, &[]), 0.0).padding();
140        let inner = Size::new(width.saturating_sub(padding.horizontal()), u16::MAX);
141        let content = self.body(index).map_or(0, |body| cx.measure_child(body, inner).height);
142        content.saturating_add(padding.vertical())
143    }
144
145    pub(crate) fn measure(&self, cx: &mut MeasureCx<'_>, available: Size) -> Size {
146        if self.sections.is_empty() {
147            return Size::default();
148        }
149        let count = u16::try_from(self.sections.len()).unwrap_or(u16::MAX);
150        let mut height = count.saturating_add(Self::gap(cx.env()).saturating_mul(count - 1));
151        let mut width = 0;
152        for (index, section) in self.sections.iter().enumerate() {
153            let detail = section.detail.as_deref().map_or(0, |d| text::width(d).saturating_add(2));
154            let icon = if section.icon.is_some() { 2 } else { 0 };
155            width = width.max(cells::sum([text::width(&section.title), detail, icon, 7]));
156            if self.is_open(index) {
157                height = height.saturating_add(self.natural(cx, index, available.width));
158                if let Some(body) = self.body(index) {
159                    width = width.max(cx.measure_child(body, available).width + 4);
160                }
161            }
162        }
163        Size::new(width, height).min(available)
164    }
165
166    /// The opening progress of `index`, moving towards its open state. With `Flow::Natural`
167    /// closing is immediate, because the widget has already given its rows back to the layout.
168    fn progress(&self, cx: &mut PaintCx<'_>, index: usize, flow: Flow) -> f32 {
169        let target = if self.is_open(index) { 1.0 } else { 0.0 };
170        if cx.reduced_motion() {
171            return target;
172        }
173        let now = cx.now();
174        let duration = cx.env().theme().motion().enter * 2;
175        let title = self.sections[index].title.clone();
176        let memory = cx.memory::<SectionsMemory>();
177        let tween = match memory.reveal.iter_mut().find(|(key, _)| *key == title) {
178            Some((_, tween)) => tween,
179            None => {
180                memory.reveal.push((title, Tween::settled(target)));
181                &mut memory.reveal.last_mut().expect("a tween was just pushed").1
182            }
183        };
184        if (tween.target() - target).abs() > f32::EPSILON {
185            if flow == Flow::Natural && target < 0.5 {
186                *tween = Tween::settled(0.0);
187            } else {
188                tween.retarget(target, now, duration, Easing::EaseOut);
189            }
190        }
191        let (value, running) = (tween.value(now), tween.is_running(now));
192        if running {
193            cx.request_frame_in(Duration::from_millis(16));
194        }
195        value
196    }
197
198    pub(crate) fn paint(&self, cx: &mut PaintCx<'_>, area: Rect, flow: Flow) {
199        let count = self.sections.len();
200        let gap = Self::gap(cx.env());
201        let body_style = cx.style("section-body", None, &[]);
202        let padding = body_style.padding();
203        let body_bg = body_style.text().bg;
204        let (cursor, drag) = {
205            let memory = cx.memory::<SectionsMemory>();
206            forget_gone(&mut memory.reveal, &self.sections);
207            memory.cursor = memory.cursor.min(count.saturating_sub(1));
208            (memory.cursor, memory.drag.filter(|drag| drag.from < count))
209        };
210
211        let progress: Vec<f32> = (0..count).map(|index| self.progress(cx, index, flow)).collect();
212        let naturals: Vec<u16> = (0..count)
213            .map(|index| {
214                let showing = self.is_open(index) || progress[index] > 0.0;
215                if showing { self.natural(&mut MeasureCx::new(cx.env()), index, area.width) } else { 0 }
216            })
217            .collect();
218        let allotted = match flow {
219            Flow::Natural => naturals.clone(),
220            Flow::Share => {
221                let titles = u16::try_from(count).unwrap_or(u16::MAX);
222                let gaps = gap.saturating_mul(titles.saturating_sub(1));
223                // While dragging, the dragged body is folded away and its title becomes the ghost.
224                let mut wants = naturals.clone();
225                if let Some(drag) = drag {
226                    wants[drag.from] = 0;
227                }
228                share(&wants, area.height.saturating_sub(titles + gaps))
229            }
230        };
231
232        let entries: Vec<Entry> = match drag {
233            Some(drag) => {
234                let mut order: Vec<Entry> = (0..count).filter(|i| *i != drag.from).map(Entry::Section).collect();
235                order.insert(drag.target.min(order.len()), Entry::Drop);
236                order
237            }
238            None => (0..count).map(Entry::Section).collect(),
239        };
240
241        let focused = cx.is_focused();
242        let pointer = cx.pointer();
243        let mut titles = vec![Rect::default(); count];
244        let mut blocks = vec![(0, 0); count];
245        let mut y = area.y;
246        for (position, entry) in entries.iter().enumerate() {
247            if position > 0 {
248                y += i32::from(gap);
249            }
250            let row = Rect::new(area.x, y, area.width, 1);
251            let index = match entry {
252                Entry::Drop => {
253                    let color = cx.style("section-drop", None, &[]).text().bg.unwrap_or_else(|| cx.color("active"));
254                    cx.clear(row, color);
255                    y += 1;
256                    continue;
257                }
258                Entry::Section(index) => *index,
259            };
260            let hovered = drag.is_none() && pointer.is_some_and(|(px, py)| row.contains(px, py));
261            self.paint_title(cx, row, index, hovered, focused && cursor == index, false);
262            cx.register_hit(row);
263            titles[index] = row;
264            let visible = steps(progress[index], allotted[index]);
265            let top = y;
266            y += 1;
267            if visible > 0 {
268                let body = Rect::new(area.x, y, area.width, visible);
269                if let Some(bg) = body_bg {
270                    cx.clear(body, bg);
271                }
272                if let Some(node) = self.body(index) {
273                    let natural = naturals[index].max(allotted[index]);
274                    let full = Rect::new(area.x, y, area.width, natural).inset(padding);
275                    let inner_height = allotted[index].saturating_sub(padding.vertical());
276                    let content = Rect::new(full.x, full.y, full.width, inner_height);
277                    cx.with_clip(body, |cx| cx.paint_child(node, content));
278                }
279                y += i32::from(visible);
280            }
281            blocks[index] = (top, y);
282        }
283
284        if let Some(drag) = drag {
285            let ghost_y = drag.pointer.clamp(area.y, last_row(area));
286            self.paint_title(cx, Rect::new(area.x, ghost_y, area.width, 1), drag.from, false, false, true);
287        } else {
288            let memory = cx.memory::<SectionsMemory>();
289            memory.titles = titles;
290            memory.blocks = blocks;
291        }
292    }
293
294    fn paint_title(&self, cx: &mut PaintCx<'_>, row: Rect, index: usize, hovered: bool, cursor: bool, ghost: bool) {
295        let section = &self.sections[index];
296        let open = self.is_open(index);
297        let mut states = Vec::new();
298        if hovered {
299            states.push(State::Hover);
300        }
301        if cursor {
302            states.push(State::Focus);
303        }
304        if open {
305            states.push(State::Checked);
306        }
307        let variant = ghost.then_some("ghost");
308        let style = cx.style("section-title", variant, &states);
309        let title_style = CellStyle { bg: None, ..style.text() };
310        // A title row always has a surface, even in a theme that gives it no background.
311        cx.clear(row, style.text().bg.unwrap_or_else(|| cx.color("raised")));
312        let slide = cx.env().slide() && (hovered || cursor || ghost);
313        let detail_width = section.detail.as_deref().map_or(0, |d| text::width(d).saturating_add(2));
314        // The detail keeps a margin of two cells on the right; it gives way on narrow rows.
315        let show_detail = detail_width > 0 && row.width.saturating_sub(5) > detail_width.saturating_add(8);
316        let chevron_key = if open { "section-open" } else { "section-closed" };
317        let chevron = cx.env().icons().glyph(chevron_key).into_owned();
318        let chevron_style = CellStyle { bg: None, ..cx.style("section-chevron", None, &states).text() };
319        let icon: Vec<row::Mark> =
320            section.icon.iter().map(|icon| (cx.env().icons().glyph(icon).into_owned(), title_style)).collect();
321        // The chevron is a fixed mark: only the icon and the title slide.
322        let parts = row::Parts {
323            fixed: &[(chevron, chevron_style)],
324            sliding: &icon,
325            label: &section.title,
326            trailing: 1 + if show_detail { detail_width } else { 0 },
327            indent: 0,
328        };
329        row::paint_parts(cx, row, &style, slide, &parts);
330        if let (true, Some(detail)) = (show_detail, &section.detail) {
331            let detail_style = cx.style("section-detail", None, &states).text();
332            let width = text::width(detail);
333            cx.text(row.right() - 2 - i32::from(width), row.y, detail, CellStyle { bg: None, ..detail_style }, width);
334        }
335    }
336
337    fn toggle(&self, cx: &mut EventCx<'_, Msg>, index: usize) {
338        let Some(message) = &self.on_toggle else {
339            return;
340        };
341        let open = !self.is_open(index);
342        if open && self.single {
343            for other in (0..self.sections.len()).filter(|i| *i != index && self.is_open(*i)) {
344                cx.emit(message(other, false));
345            }
346        }
347        cx.emit(message(index, open));
348    }
349
350    fn move_section(&self, cx: &mut EventCx<'_, Msg>, from: usize, to: usize) {
351        if let Some(message) = &self.on_move
352            && from != to
353            && to < self.sections.len()
354        {
355            cx.memory::<SectionsMemory>().cursor = to;
356            cx.emit(message(from, to));
357        }
358    }
359
360    pub(crate) fn event(&self, cx: &mut EventCx<'_, Msg>, event: &Event) -> bool {
361        let count = self.sections.len();
362        if count == 0 {
363            return false;
364        }
365        match event {
366            Event::Key(key) => {
367                let cursor = cx.memory::<SectionsMemory>().cursor.min(count - 1);
368                let reorder = Modifiers { ctrl: true, shift: true, alt: false };
369                if self.on_move.is_some() && key.chord.mods == reorder {
370                    let to = match key.chord.key {
371                        Key::Up => cursor.checked_sub(1),
372                        Key::Down => Some(cursor + 1).filter(|to| *to < count),
373                        _ => return false,
374                    };
375                    if let Some(to) = to {
376                        self.move_section(cx, cursor, to);
377                    }
378                    return true;
379                }
380                let target = if key.is_plain(Key::Up) {
381                    cursor.checked_sub(1)
382                } else if key.is_plain(Key::Down) {
383                    Some(cursor + 1).filter(|next| *next < count)
384                } else if key.is_plain(Key::Home) {
385                    Some(0)
386                } else if key.is_plain(Key::End) {
387                    Some(count - 1)
388                } else if key.is_plain(Key::Enter) || key.is_plain(Key::Space) {
389                    if self.on_toggle.is_none() {
390                        return false;
391                    }
392                    self.toggle(cx, cursor);
393                    return true;
394                } else {
395                    return false;
396                };
397                match target {
398                    Some(target) => {
399                        cx.memory::<SectionsMemory>().cursor = target;
400                        true
401                    }
402                    None => false,
403                }
404            }
405            Event::Mouse(mouse) => self.mouse(cx, mouse.kind, mouse.x, mouse.y),
406            _ => false,
407        }
408    }
409
410    fn mouse(&self, cx: &mut EventCx<'_, Msg>, kind: MouseKind, x: i32, y: i32) -> bool {
411        let area = cx.area();
412        let memory = cx.memory::<SectionsMemory>();
413        let under = memory.titles.iter().position(|row| row.contains(x, y));
414        match kind {
415            MouseKind::Down(MouseButton::Left) => {
416                let Some(index) = under else {
417                    return false;
418                };
419                memory.cursor = index;
420                memory.pressed = Some((index, y));
421                cx.capture_pointer();
422                true
423            }
424            MouseKind::Drag(MouseButton::Left) => {
425                let Some((from, start)) = memory.pressed else {
426                    return false;
427                };
428                if self.on_move.is_none() {
429                    return true;
430                }
431                if memory.drag.is_none() && (y - start).abs() < DRAG_THRESHOLD {
432                    return true;
433                }
434                let target = memory
435                    .blocks
436                    .iter()
437                    .enumerate()
438                    .filter(|(index, (top, bottom))| *index != from && (top + bottom) / 2 < y)
439                    .count();
440                memory.drag = Some(Drag { from, pointer: y.clamp(area.y, last_row(area)), target });
441                true
442            }
443            MouseKind::Up(MouseButton::Left) => {
444                let pressed = memory.pressed.take();
445                let drag = memory.drag.take();
446                match (pressed, drag) {
447                    (_, Some(drag)) => self.move_section(cx, drag.from, drag.target),
448                    (Some((index, _)), None) if under == Some(index) => self.toggle(cx, index),
449                    _ => {}
450                }
451                pressed.is_some()
452            }
453            _ => false,
454        }
455    }
456}
457
458/// Drops the opening progress of sections that are gone, so renaming or removing sections does
459/// not grow the memory for as long as the widget lives.
460fn forget_gone(reveal: &mut Vec<(String, Tween)>, sections: &[Section]) {
461    reveal.retain(|(title, _)| sections.iter().any(|section| section.title == *title));
462}
463
464/// The last row of `area`, or its top row when it has no height: a dock can lose its rows while a
465/// title is being dragged.
466fn last_row(area: Rect) -> i32 {
467    (area.bottom() - 1).max(area.y)
468}
469
470/// Shares `available` rows between bodies wanting `wants` rows: small bodies get all they
471/// want, the rest split what is left evenly.
472fn share(wants: &[u16], available: u16) -> Vec<u16> {
473    let mut given = vec![0; wants.len()];
474    let mut order: Vec<usize> = (0..wants.len()).filter(|i| wants[*i] > 0).collect();
475    order.sort_by_key(|i| wants[*i]);
476    let mut left = available;
477    let mut remaining = clamp_u16(i32::try_from(order.len()).unwrap_or(i32::MAX));
478    for index in order {
479        let fair = left / remaining.max(1);
480        given[index] = wants[index].min(fair);
481        left -= given[index];
482        remaining -= 1;
483    }
484    given
485}
486
487#[cfg(test)]
488mod tests {
489    use super::{Section, Tween, forget_gone, share};
490
491    #[test]
492    fn small_bodies_keep_their_height_and_large_ones_split_the_rest() {
493        assert_eq!(share(&[3, 0, 20, 20], 21), vec![3, 0, 9, 9]);
494        assert_eq!(share(&[3, 4], 30), vec![3, 4]);
495        assert_eq!(share(&[10, 10, 10], 10), vec![3, 3, 4]);
496        assert_eq!(share(&[], 10), Vec::<u16>::new());
497    }
498
499    #[test]
500    fn progress_of_sections_that_are_gone_is_forgotten() {
501        let mut reveal = vec![("Git".to_owned(), Tween::settled(1.0)), ("Old name".to_owned(), Tween::settled(0.0))];
502        forget_gone(&mut reveal, &[Section::new("Git"), Section::new("Ports")]);
503        let titles: Vec<&str> = reveal.iter().map(|(title, _)| title.as_str()).collect();
504        assert_eq!(titles, ["Git"]);
505    }
506}