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}