Skip to main content

sequoia_elements/
tab_strip.rs

1use std::rc::Rc;
2
3use sequoia_core::{Color, Length};
4use sequoia_input::{CursorIcon, Event, EventPhase, PointerButton, PointerEvent};
5use sequoia_reactive::Signal;
6use sequoia_style::VisualState;
7use sequoia_view::prelude::*;
8
9use crate::Container;
10
11#[derive(Clone, Copy, PartialEq, Eq, Debug)]
12pub struct TabTear(pub usize);
13
14const HEIGHT: f32 = 34.0;
15const PAD_X: f32 = 12.0;
16const GAP: f32 = 8.0;
17const FONT_SIZE: f32 = 11.0;
18const ACCENT_W: f32 = 1.0;
19
20#[derive(Clone)]
21pub struct TabStrip {
22    active: Signal<usize>,
23    labels: Vec<String>,
24    closable: Vec<bool>,
25    tearable: Vec<bool>,
26    on_select: Option<Rc<dyn Fn(usize)>>,
27    on_close: Option<Rc<dyn Fn(usize)>>,
28    on_tear_end: Option<Rc<dyn Fn(usize, DragEnd)>>,
29    tear_preview_for: Option<Rc<dyn Fn(usize) -> AnyView>>,
30    on_tear_start: Option<Rc<dyn Fn(usize)>>,
31    height: f32,
32    equal_width: bool,
33    pad_x: f32,
34    gap: f32,
35    font_size: f32,
36    active_bg: Color,
37    accent: Color,
38    label_color: Color,
39    active_label_color: Color,
40    close_color: Color,
41}
42
43impl TabStrip {
44    pub fn new(active: Signal<usize>, labels: impl IntoIterator<Item = impl Into<String>>) -> Self {
45        let labels: Vec<String> = labels.into_iter().map(Into::into).collect();
46        Self {
47            active,
48            closable: vec![false; labels.len()],
49            tearable: vec![false; labels.len()],
50            labels,
51            on_select: None,
52            on_close: None,
53            on_tear_end: None,
54            tear_preview_for: None,
55            on_tear_start: None,
56            height: HEIGHT,
57            equal_width: true,
58            pad_x: PAD_X,
59            gap: GAP,
60            font_size: FONT_SIZE,
61
62            active_bg: Color::hex(0x333333),
63            accent: Color::hex(0x1C80E3),
64            label_color: Color::hex(0x9A9A9A),
65            active_label_color: Color::hex(0xD4D4D4),
66            close_color: Color::hex(0x9A9A9A),
67        }
68    }
69
70    pub fn on_select(mut self, f: impl Fn(usize) + 'static) -> Self {
71        self.on_select = Some(Rc::new(f));
72        self
73    }
74
75    pub fn on_close(mut self, f: impl Fn(usize) + 'static) -> Self {
76        self.on_close = Some(Rc::new(f));
77        self
78    }
79
80    pub fn on_tear_end(mut self, f: impl Fn(usize, DragEnd) + 'static) -> Self {
81        self.on_tear_end = Some(Rc::new(f));
82        self
83    }
84
85    pub fn on_tear_start(mut self, f: impl Fn(usize) + 'static) -> Self {
86        self.on_tear_start = Some(Rc::new(f));
87        self
88    }
89
90    pub fn closable(mut self, flags: impl IntoIterator<Item = bool>) -> Self {
91        self.closable = flags.into_iter().collect();
92        self.closable.resize(self.labels.len(), false);
93        self
94    }
95
96    pub fn tearable(mut self, flags: impl IntoIterator<Item = bool>) -> Self {
97        self.tearable = flags.into_iter().collect();
98        self.tearable.resize(self.labels.len(), false);
99        self
100    }
101
102    pub fn tear_preview_for(mut self, f: impl Fn(usize) -> AnyView + 'static) -> Self {
103        self.tear_preview_for = Some(Rc::new(f));
104        self
105    }
106
107    pub fn height(mut self, v: f32) -> Self {
108        self.height = v.max(0.0);
109        self
110    }
111
112    pub fn equal_width(mut self, on: bool) -> Self {
113        self.equal_width = on;
114        self
115    }
116
117    pub fn padding_x(mut self, v: f32) -> Self {
118        self.pad_x = v.max(0.0);
119        self
120    }
121
122    pub fn gap(mut self, v: f32) -> Self {
123        self.gap = v.max(0.0);
124        self
125    }
126
127    pub fn font_size(mut self, v: f32) -> Self {
128        self.font_size = v.max(0.0);
129        self
130    }
131
132    pub fn active_bg(mut self, color: Color) -> Self {
133        self.active_bg = color;
134        self
135    }
136
137    pub fn accent(mut self, color: Color) -> Self {
138        self.accent = color;
139        self
140    }
141
142    pub fn label_color(mut self, color: Color) -> Self {
143        self.label_color = color;
144        self
145    }
146
147    pub fn active_label_color(mut self, color: Color) -> Self {
148        self.active_label_color = color;
149        self
150    }
151
152    pub fn close_color(mut self, color: Color) -> Self {
153        self.close_color = color;
154        self
155    }
156
157    fn tab(&self, i: usize, label: &str) -> impl View + use<> {
158        let active = self.active.get_untracked() == i;
159
160        let mut box_ = Container::new().height(self.height).background(if active {
161            self.active_bg
162        } else {
163            Color::TRANSPARENT
164        });
165        if active {
166            box_ = box_.border_top(ACCENT_W, self.accent);
167        }
168        let mut row = Row::new()
169            .height(self.height)
170            .gap(self.gap)
171            .padding_x(self.pad_x)
172            .align_items(Align::Center)
173            .child(
174                Text::new(label.to_string())
175                    .size(self.font_size)
176                    .bold()
177                    .color(if active {
178                        self.active_label_color
179                    } else {
180                        self.label_color
181                    }),
182            );
183        if self.closable.get(i).copied().unwrap_or(false) {
184            let close_color = self.close_color;
185            let on_close = self.on_close.clone();
186            row = row.child(
187                Container::new()
188                    .child(Text::new("×").size(self.font_size).color(close_color))
189                    .padding_xy(4.0, 2.0)
190                    .on_click(move || {
191                        if let Some(f) = &on_close {
192                            f(i);
193                        }
194                    }),
195            );
196        }
197
198        let active = self.active.clone();
199        let on_select = self.on_select.clone();
200        let click = Container::new()
201            .child(row)
202            .width(Length::Fill(1.0))
203            .height(Length::Fill(1.0))
204            .on_click(move || {
205                active.set(i);
206                if let Some(f) = &on_select {
207                    f(i);
208                }
209            });
210        if self.tearable.get(i).copied().unwrap_or(false) {
211            let on_tear_end = self.on_tear_end.clone();
212            let on_tear_start = self.on_tear_start.clone();
213            let preview = match &self.tear_preview_for {
214                Some(f) => f(i),
215                None => AnyView::new(tear_preview(
216                    label,
217                    self.font_size,
218                    self.active_bg,
219                    self.active_label_color,
220                )),
221            };
222            box_.child(
223                TearGrab::new(click, i)
224                    .on_tear_start(move |idx| {
225                        if let Some(f) = &on_tear_start {
226                            f(idx);
227                        }
228                    })
229                    .on_tear_end(move |idx, end| {
230                        if let Some(f) = &on_tear_end {
231                            f(idx, end);
232                        }
233                    })
234                    .preview(preview),
235            )
236        } else {
237            box_.child(click)
238        }
239    }
240}
241
242impl View for TabStrip {
243    fn body(&self) -> impl View + 'static {
244        let _ = self.active.get();
245        let mut row = Row::new()
246            .fill_width()
247            .height(self.height)
248            .align_items(Align::Stretch);
249        for (i, label) in self.labels.iter().enumerate() {
250            let tab = self.tab(i, label);
251            row = if self.equal_width {
252                row.child_flex(tab, 1.0)
253            } else {
254                row.child(tab)
255            };
256        }
257        row
258    }
259
260    fn layout_style(&self) -> LayoutStyle {
261        LayoutStyle {
262            width: Length::Fill(1.0),
263            height: Length::Px(self.height),
264            ..LayoutStyle::default()
265        }
266    }
267
268    fn has_visual_state(&self) -> bool {
269        true
270    }
271}
272
273fn tear_preview(label: &str, font_size: f32, bg: Color, fg: Color) -> impl View + use<> {
274    Container::new()
275        .background(bg)
276        .radius(4.0)
277        .padding_xy(12.0, 8.0)
278        .child(
279            Text::new(label.to_string())
280                .size(font_size)
281                .bold()
282                .color(fg),
283        )
284}
285
286#[derive(Clone)]
287pub(crate) struct TearGrab {
288    child: AnyView,
289    index: usize,
290    preview: Option<AnyView>,
291    on_tear_start: Option<Rc<dyn Fn(usize)>>,
292    on_tear_end: Option<Rc<dyn Fn(usize, DragEnd)>>,
293}
294
295impl TearGrab {
296    pub(crate) fn new<V: View + 'static>(child: V, index: usize) -> Self {
297        Self {
298            child: AnyView::new(child),
299            index,
300            preview: None,
301            on_tear_start: None,
302            on_tear_end: None,
303        }
304    }
305
306    pub(crate) fn preview<V: View + 'static>(mut self, preview: V) -> Self {
307        self.preview = Some(AnyView::new(preview));
308        self
309    }
310
311    pub(crate) fn on_tear_start(mut self, f: impl Fn(usize) + 'static) -> Self {
312        self.on_tear_start = Some(Rc::new(f));
313        self
314    }
315
316    pub(crate) fn on_tear_end(mut self, f: impl Fn(usize, DragEnd) + 'static) -> Self {
317        self.on_tear_end = Some(Rc::new(f));
318        self
319    }
320}
321
322impl View for TearGrab {
323    fn body(&self) -> impl View + 'static {
324        Unit
325    }
326
327    fn children_override(&self) -> Option<Vec<AnyView>> {
328        Some(vec![self.child.clone()])
329    }
330
331    fn layout_style(&self) -> LayoutStyle {
332        LayoutStyle {
333            width: Length::Fit,
334            height: Length::Fit,
335            ..LayoutStyle::default()
336        }
337    }
338
339    fn has_visual_state(&self) -> bool {
340        true
341    }
342
343    fn cursor_icon(&self, state: VisualState) -> CursorIcon {
344        if state.pressed {
345            CursorIcon::Grabbing
346        } else {
347            CursorIcon::Grab
348        }
349    }
350
351    fn on_event(&self, event: &Event, ctx: &mut EventCtx) {
352        if ctx.phase == EventPhase::Bubble {
353            return;
354        }
355        match event {
356            Event::Pointer(PointerEvent::Down {
357                button: PointerButton::Left,
358                pos,
359                ..
360            }) if ctx.rect.contains(*pos) => {
361                let mut opts = DragOptions::new().threshold(DRAG_THRESHOLD);
362                if let Some(pv) = self.preview.clone() {
363                    opts = opts.preview(pv);
364                }
365                ctx.begin_drag(TabTear(self.index), opts);
366            }
367            _ => {}
368        }
369    }
370
371    fn on_drag_end(&self, end: DragEnd, _ctx: &mut EventCtx) {
372        if let Some(f) = &self.on_tear_end {
373            f(self.index, end);
374        }
375    }
376
377    fn on_drag_start(&self, _ctx: &mut EventCtx) {
378        if let Some(f) = &self.on_tear_start {
379            f(self.index);
380        }
381    }
382}
383
384#[cfg(test)]
385mod tests {
386    use super::*;
387
388    #[test]
389    fn tearable_defaults_to_none_and_resizes() {
390        let s = TabStrip::new(Signal::new(0usize), ["A", "B", "C"]);
391        assert!(s.tearable.iter().all(|c| !c));
392        let s = s.tearable([false, true]);
393        assert!(!s.tearable[0] && s.tearable[1] && !s.tearable[2]);
394    }
395
396    #[test]
397    fn tear_payload_carries_tab_index() {
398        use sequoia_view::DragPayload;
399        let p = DragPayload::new(TabTear(1));
400        assert!(p.is::<TabTear>());
401        assert_eq!(p.get::<TabTear>(), Some(&TabTear(1)));
402        assert!(!p.is::<String>());
403    }
404
405    #[test]
406    fn closable_defaults_to_none_and_resizes() {
407        let s = TabStrip::new(Signal::new(0usize), ["A", "B", "C"]);
408        assert!(s.closable.iter().all(|c| !c));
409
410        let s = s.closable([true]);
411        assert!(s.closable[0] && !s.closable[1] && !s.closable[2]);
412    }
413
414    #[test]
415    fn strip_is_fill_width_with_fixed_height() {
416        let s = TabStrip::new(Signal::new(0usize), ["A"]).height(28.0);
417        let style = s.layout_style();
418        assert_eq!(style.width, Length::Fill(1.0));
419        assert_eq!(style.height, Length::Px(28.0));
420    }
421}