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}