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 on_select = self.on_select.clone();
199        let click = Container::new()
200            .child(row)
201            .width(Length::Fill(1.0))
202            .height(Length::Fill(1.0))
203            .on_click(move || {
204                if let Some(f) = &on_select {
205                    f(i);
206                }
207            });
208        if self.tearable.get(i).copied().unwrap_or(false) {
209            let on_tear_end = self.on_tear_end.clone();
210            let on_tear_start = self.on_tear_start.clone();
211            let preview = match &self.tear_preview_for {
212                Some(f) => f(i),
213                None => AnyView::new(tear_preview(
214                    label,
215                    self.font_size,
216                    self.active_bg,
217                    self.active_label_color,
218                )),
219            };
220            box_.child(
221                TearGrab::new(click, i)
222                    .on_tear_start(move |idx| {
223                        if let Some(f) = &on_tear_start {
224                            f(idx);
225                        }
226                    })
227                    .on_tear_end(move |idx, end| {
228                        if let Some(f) = &on_tear_end {
229                            f(idx, end);
230                        }
231                    })
232                    .preview(preview),
233            )
234        } else {
235            box_.child(click)
236        }
237    }
238}
239
240impl View for TabStrip {
241    fn body(&self) -> impl View + 'static {
242        let _ = self.active.get();
243        let mut row = Row::new()
244            .fill_width()
245            .height(self.height)
246            .align_items(Align::Stretch);
247        for (i, label) in self.labels.iter().enumerate() {
248            let tab = self.tab(i, label);
249            row = if self.equal_width {
250                row.child_flex(tab, 1.0)
251            } else {
252                row.child(tab)
253            };
254        }
255        row
256    }
257
258    fn layout_style(&self) -> LayoutStyle {
259        LayoutStyle {
260            width: Length::Fill(1.0),
261            height: Length::Px(self.height),
262            ..LayoutStyle::default()
263        }
264    }
265
266    fn has_visual_state(&self) -> bool {
267        true
268    }
269}
270
271fn tear_preview(label: &str, font_size: f32, bg: Color, fg: Color) -> impl View + use<> {
272    Container::new()
273        .background(bg)
274        .radius(4.0)
275        .padding_xy(12.0, 8.0)
276        .child(
277            Text::new(label.to_string())
278                .size(font_size)
279                .bold()
280                .color(fg),
281        )
282}
283
284#[derive(Clone)]
285pub(crate) struct TearGrab {
286    child: AnyView,
287    index: usize,
288    preview: Option<AnyView>,
289    on_tear_start: Option<Rc<dyn Fn(usize)>>,
290    on_tear_end: Option<Rc<dyn Fn(usize, DragEnd)>>,
291}
292
293impl TearGrab {
294    pub(crate) fn new<V: View + 'static>(child: V, index: usize) -> Self {
295        Self {
296            child: AnyView::new(child),
297            index,
298            preview: None,
299            on_tear_start: None,
300            on_tear_end: None,
301        }
302    }
303
304    pub(crate) fn preview<V: View + 'static>(mut self, preview: V) -> Self {
305        self.preview = Some(AnyView::new(preview));
306        self
307    }
308
309    pub(crate) fn on_tear_start(mut self, f: impl Fn(usize) + 'static) -> Self {
310        self.on_tear_start = Some(Rc::new(f));
311        self
312    }
313
314    pub(crate) fn on_tear_end(mut self, f: impl Fn(usize, DragEnd) + 'static) -> Self {
315        self.on_tear_end = Some(Rc::new(f));
316        self
317    }
318}
319
320impl View for TearGrab {
321    fn body(&self) -> impl View + 'static {
322        Unit
323    }
324
325    fn children_override(&self) -> Option<Vec<AnyView>> {
326        Some(vec![self.child.clone()])
327    }
328
329    fn layout_style(&self) -> LayoutStyle {
330        LayoutStyle {
331            width: Length::Fit,
332            height: Length::Fit,
333            ..LayoutStyle::default()
334        }
335    }
336
337    fn has_visual_state(&self) -> bool {
338        true
339    }
340
341    fn cursor_icon(&self, state: VisualState) -> CursorIcon {
342        if state.pressed {
343            CursorIcon::Grabbing
344        } else {
345            CursorIcon::Grab
346        }
347    }
348
349    fn on_event(&self, event: &Event, ctx: &mut EventCtx) {
350        if ctx.phase == EventPhase::Bubble {
351            return;
352        }
353        match event {
354            Event::Pointer(PointerEvent::Down {
355                button: PointerButton::Left,
356                pos,
357                ..
358            }) if ctx.rect.contains(*pos) => {
359                let mut opts = DragOptions::new().threshold(DRAG_THRESHOLD);
360                if let Some(pv) = self.preview.clone() {
361                    opts = opts.preview(pv);
362                }
363                ctx.begin_drag(TabTear(self.index), opts);
364            }
365            _ => {}
366        }
367    }
368
369    fn on_drag_end(&self, end: DragEnd, _ctx: &mut EventCtx) {
370        if let Some(f) = &self.on_tear_end {
371            f(self.index, end);
372        }
373    }
374
375    fn on_drag_start(&self, _ctx: &mut EventCtx) {
376        if let Some(f) = &self.on_tear_start {
377            f(self.index);
378        }
379    }
380}
381
382#[cfg(test)]
383mod tests {
384    use super::*;
385
386    #[test]
387    fn tearable_defaults_to_none_and_resizes() {
388        let s = TabStrip::new(Signal::new(0usize), ["A", "B", "C"]);
389        assert!(s.tearable.iter().all(|c| !c));
390        let s = s.tearable([false, true]);
391        assert!(!s.tearable[0] && s.tearable[1] && !s.tearable[2]);
392    }
393
394    #[test]
395    fn tear_payload_carries_tab_index() {
396        use sequoia_view::DragPayload;
397        let p = DragPayload::new(TabTear(1));
398        assert!(p.is::<TabTear>());
399        assert_eq!(p.get::<TabTear>(), Some(&TabTear(1)));
400        assert!(!p.is::<String>());
401    }
402
403    #[test]
404    fn closable_defaults_to_none_and_resizes() {
405        let s = TabStrip::new(Signal::new(0usize), ["A", "B", "C"]);
406        assert!(s.closable.iter().all(|c| !c));
407
408        let s = s.closable([true]);
409        assert!(s.closable[0] && !s.closable[1] && !s.closable[2]);
410    }
411
412    #[test]
413    fn strip_is_fill_width_with_fixed_height() {
414        let s = TabStrip::new(Signal::new(0usize), ["A"]).height(28.0);
415        let style = s.layout_style();
416        assert_eq!(style.width, Length::Fill(1.0));
417        assert_eq!(style.height, Length::Px(28.0));
418    }
419}