1use alloc::string::String;
4use alloc::vec::Vec;
5
6use denise::Pen;
7use denise::theme::{AA, contrast_x100, derive_content};
8use denise::{Color, ElementState, InputEvent, KeyCode, Point, PointerButton, Rect, Role, Theme};
9use denise_text::{TextEngine, TextStyle};
10
11use crate::widget::{
12 Event, EventCtx, Handled, MeasureCtx, Measured, Offer, PaintCtx, VisualState, Widget,
13};
14use crate::widgets::describe::{
15 Describe, DynDescribe, Group, Mismatch, Payload, Property, PropertyKind, ROLES, Value,
16};
17use crate::widgets::style::{
18 Align, ClickPair, Intent, draw_aligned, hovered_row, interactive_pair, muted,
19};
20
21#[derive(Clone, Debug)]
97pub struct Tabs<M> {
98 labels: Vec<String>,
99 colors: Vec<Option<Color>>,
101 selected: usize,
102 report: Report<M>,
103 closable: bool,
105 role: Role,
106 style: TextStyle,
107 over_pages: bool,
116 hovered: Option<usize>,
118 press: Option<Press>,
120 clicks: ClickPair,
121}
122
123#[derive(Clone, Copy, Debug, PartialEq, Eq)]
125pub enum TabEvent {
126 Selected(usize),
128 Close(usize),
131 Moved {
135 from: usize,
137 to: usize,
139 },
140 Activated(usize),
142 Menu {
145 index: usize,
147 at: Point,
149 },
150}
151
152#[derive(Debug)]
154enum Report<M> {
155 Nothing,
156 Index(fn(usize) -> M),
157 Events(fn(TabEvent) -> M),
158}
159
160impl<M> Clone for Report<M> {
163 fn clone(&self) -> Self {
164 *self
165 }
166}
167
168impl<M> Copy for Report<M> {}
169
170#[derive(Clone, Copy, Debug)]
172struct Press {
173 index: usize,
175 from: usize,
177 button: PointerButton,
178 on_close: bool,
180 start: Point,
181 grab: i32,
184 dragging: Option<i32>,
186}
187
188impl<M> Tabs<M> {
189 pub fn new(
192 labels: impl IntoIterator<Item = impl Into<String>>,
193 message: fn(usize) -> M,
194 ) -> Self {
195 Self::reporting(labels, Report::Index(message))
196 }
197
198 pub fn with_events(
201 labels: impl IntoIterator<Item = impl Into<String>>,
202 message: fn(TabEvent) -> M,
203 ) -> Self {
204 Self::reporting(labels, Report::Events(message))
205 }
206
207 pub fn inert(labels: impl IntoIterator<Item = impl Into<String>>) -> Self {
209 Self::reporting(labels, Report::Nothing)
210 }
211
212 fn reporting(labels: impl IntoIterator<Item = impl Into<String>>, report: Report<M>) -> Self {
213 let labels: Vec<String> = labels.into_iter().map(Into::into).collect();
214 Self {
215 colors: alloc::vec![None; labels.len()],
216 labels,
217 selected: 0,
218 report,
219 closable: false,
220 role: Role::Primary,
221 style: TextStyle::built_in(16),
222 over_pages: false,
223 hovered: None,
224 press: None,
225 clicks: ClickPair::default(),
226 }
227 }
228
229 #[must_use]
235 pub fn over_pages(mut self) -> Self {
236 self.over_pages = true;
237 self
238 }
239
240 #[inline]
242 pub const fn is_over_pages(&self) -> bool {
243 self.over_pages
244 }
245
246 pub fn strip_height(&self, theme: &Theme) -> i32 {
257 theme.metrics.size_field.max(1)
258 }
259
260 fn band(&self, bounds: Rect, theme: &Theme) -> Rect {
262 if !self.over_pages {
263 return bounds;
264 }
265 let height = bounds.height.min(self.strip_height(theme));
266 Rect::new(bounds.x, bounds.y, bounds.width, height)
267 }
268
269 pub fn with_selected(mut self, index: usize) -> Self {
271 self.selected = self.clamp(index);
272 self
273 }
274
275 pub fn with_role(mut self, role: Role) -> Self {
282 self.role = role;
283 self
284 }
285
286 pub fn with_style(mut self, style: TextStyle) -> Self {
288 self.style = style;
289 self
290 }
291
292 #[must_use]
298 pub fn with_close_buttons(mut self, on: bool) -> Self {
299 self.closable = on;
300 self
301 }
302
303 #[must_use]
305 pub fn with_colors(mut self, colors: impl IntoIterator<Item = Option<Color>>) -> Self {
306 self.set_colors(colors);
307 self
308 }
309
310 #[inline]
312 pub const fn selected(&self) -> usize {
313 self.selected
314 }
315
316 #[inline]
318 pub fn selected_label(&self) -> Option<&str> {
319 self.labels.get(self.selected).map(String::as_str)
320 }
321
322 pub fn set_selected(&mut self, index: usize) {
324 self.selected = self.clamp(index);
325 }
326
327 #[inline]
329 pub fn labels(&self) -> &[String] {
330 &self.labels
331 }
332
333 pub fn set_labels(&mut self, labels: impl IntoIterator<Item = impl Into<String>>) {
338 self.labels = labels.into_iter().map(Into::into).collect();
339 self.colors.resize(self.labels.len(), None);
340 self.selected = self.clamp(self.selected);
341 self.forget_pointer();
342 }
343
344 pub fn set_label(&mut self, index: usize, label: impl Into<String>) {
346 if let Some(slot) = self.labels.get_mut(index) {
347 *slot = label.into();
348 }
349 }
350
351 #[inline]
353 pub fn colors(&self) -> &[Option<Color>] {
354 &self.colors
355 }
356
357 pub fn set_colors(&mut self, colors: impl IntoIterator<Item = Option<Color>>) {
360 self.colors = colors.into_iter().collect();
361 self.colors.resize(self.labels.len(), None);
362 }
363
364 pub fn set_color(&mut self, index: usize, color: Option<Color>) {
366 if let Some(slot) = self.colors.get_mut(index) {
367 *slot = color;
368 }
369 }
370
371 pub fn move_tab(&mut self, from: usize, to: usize) {
374 let count = self.labels.len();
375 if from >= count || to >= count || from == to {
376 return;
377 }
378 let label = self.labels.remove(from);
379 self.labels.insert(to, label);
380 let color = self.colors.remove(from);
381 self.colors.insert(to, color);
382 self.selected = moved_index(self.selected, from, to);
383 self.hovered = None;
384 self.clicks.forget();
385 }
386
387 #[inline]
389 pub const fn has_close_buttons(&self) -> bool {
390 self.closable
391 }
392
393 pub fn set_close_buttons(&mut self, on: bool) {
395 self.closable = on;
396 }
397
398 pub fn set_role(&mut self, role: Role) {
400 self.role = role;
401 }
402
403 pub fn set_style(&mut self, style: TextStyle) {
405 self.style = style;
406 }
407
408 pub fn preferred_width(&self, engine: &mut TextEngine) -> i32 {
415 self.widths(engine).iter().sum()
416 }
417
418 fn widths(&self, engine: &mut TextEngine) -> Vec<i32> {
420 widths(&self.labels, self.style, self.closable, engine)
421 }
422
423 fn layout(&self, band: Rect, engine: &mut TextEngine) -> Vec<Rect> {
425 lay_out(
426 &self.labels,
427 self.style,
428 self.closable,
429 self.selected,
430 band,
431 engine,
432 )
433 }
434
435 #[inline]
436 fn clamp(&self, index: usize) -> usize {
437 index.min(self.labels.len().saturating_sub(1))
438 }
439
440 fn step(&self, forward: bool) -> usize {
442 let count = self.labels.len();
443 if count == 0 {
444 return 0;
445 }
446 if forward {
447 (self.selected + 1) % count
448 } else {
449 (self.selected + count - 1) % count
450 }
451 }
452
453 fn forget_pointer(&mut self) {
455 self.hovered = None;
456 self.press = None;
457 self.clicks.forget();
458 }
459
460 fn reports_events(&self) -> bool {
461 matches!(self.report, Report::Events(_))
462 }
463
464 fn emit(&self, ctx: &mut EventCtx<'_, M>, event: TabEvent) {
467 match (self.report, event) {
468 (Report::Events(message), event) => ctx.emit(message(event)),
469 (Report::Index(message), TabEvent::Selected(index)) => ctx.emit(message(index)),
470 _ => {}
471 }
472 }
473
474 fn select(&mut self, index: usize, ctx: &mut EventCtx<'_, M>) -> Handled {
475 if index == self.selected {
476 return Handled::Yes;
479 }
480 self.selected = index;
481 self.emit(ctx, TabEvent::Selected(index));
482 Handled::Yes
483 }
484
485 fn close_rect(&self, tab: Rect, band: Rect) -> Rect {
488 close_rect(self.style.size_px, tab, band)
489 }
490
491 fn pressed(
492 &mut self,
493 button: PointerButton,
494 position: Point,
495 band: Rect,
496 ctx: &mut EventCtx<'_, M>,
497 ) -> Handled {
498 if !self.reports_events() {
499 return Handled::No;
500 }
501 let tabs = self.layout(band, ctx.text);
502 let Some(index) = hit(band, &tabs, position) else {
503 self.press = None;
504 return Handled::No;
505 };
506 match button {
507 PointerButton::Right => {
508 self.press = None;
509 self.emit(
510 ctx,
511 TabEvent::Menu {
512 index,
513 at: position,
514 },
515 );
516 Handled::Yes
517 }
518 PointerButton::Left | PointerButton::Middle => {
519 let on_close = button == PointerButton::Left
520 && self.closable
521 && self.close_rect(tabs[index], band).contains(position);
522 self.press = Some(Press {
523 index,
524 from: index,
525 button,
526 on_close,
527 start: position,
528 grab: position.x - tabs[index].x,
529 dragging: None,
530 });
531 Handled::Yes
532 }
533 PointerButton::Other(_) => Handled::No,
534 }
535 }
536
537 fn pointer_moved(&mut self, position: Point, band: Rect, ctx: &mut EventCtx<'_, M>) -> Handled {
538 let tabs = self.layout(band, ctx.text);
539 let over = hit(band, &tabs, position);
540 let mut changed = false;
542 if over != self.hovered {
543 self.hovered = over;
544 changed = self.closable;
545 }
546 let Some(mut press) = self.press else {
547 return if changed { Handled::Yes } else { Handled::No };
548 };
549 if press.button != PointerButton::Left || press.on_close {
550 return if changed { Handled::Yes } else { Handled::No };
551 }
552 if press.dragging.is_none()
553 && (position.x - press.start.x).abs() < drag_threshold(self.style.size_px)
554 {
555 return if changed { Handled::Yes } else { Handled::No };
556 }
557 press.dragging = Some(position.x);
558 let Some(tab) = tabs.get(press.index) else {
559 self.press = None;
560 return Handled::Yes;
561 };
562 let centre = position.x - press.grab + tab.width / 2;
563 if let Some(to) = drag_target(&tabs, press.index, centre) {
564 self.move_tab(press.index, to);
565 press.index = to;
566 }
567 self.press = Some(press);
568 Handled::Yes
569 }
570
571 fn released(
572 &mut self,
573 button: PointerButton,
574 position: Point,
575 band: Rect,
576 ctx: &mut EventCtx<'_, M>,
577 ) -> Handled {
578 let tabs = self.layout(band, ctx.text);
579 if !self.reports_events() {
580 return match hit(band, &tabs, position) {
581 Some(index) => self.select(index, ctx),
582 None => Handled::No,
583 };
584 }
585 let Some(press) = self.press.take() else {
586 return Handled::No;
587 };
588 if press.button != button {
589 return Handled::No;
590 }
591 if press.dragging.is_some() {
592 if press.index != press.from {
593 self.emit(
594 ctx,
595 TabEvent::Moved {
596 from: press.from,
597 to: press.index,
598 },
599 );
600 }
601 self.clicks.forget();
602 return self.select(press.index, ctx);
603 }
604 if hit(band, &tabs, position) != Some(press.index) {
607 return Handled::Yes;
608 }
609 let index = press.index;
610 if button == PointerButton::Middle {
611 self.emit(ctx, TabEvent::Close(index));
612 return Handled::Yes;
613 }
614 if press.on_close {
615 if self.close_rect(tabs[index], band).contains(position) {
616 self.emit(ctx, TabEvent::Close(index));
617 }
618 return Handled::Yes;
619 }
620 self.select(index, ctx);
621 if self.clicks.classify(index, ctx.now_ms, false) == Intent::Activate {
622 self.emit(ctx, TabEvent::Activated(index));
623 }
624 Handled::Yes
625 }
626}
627
628pub fn tab_rect<M: 'static>(
636 ui: &mut crate::Ui<M>,
637 id: crate::NodeId,
638 index: usize,
639) -> Option<Rect> {
640 let bounds = ui.bounds(id)?;
641 let theme = *ui.theme();
642 let (band, labels, style, closable, selected) = {
643 let strip = ui.widget::<Tabs<M>>(id)?;
644 (
645 strip.band(bounds, &theme),
646 strip.labels.clone(),
647 strip.style,
648 strip.closable,
649 strip.selected,
650 )
651 };
652 lay_out(&labels, style, closable, selected, band, ui.text_mut())
653 .get(index)
654 .copied()
655}
656
657fn widths(
660 labels: &[String],
661 style: TextStyle,
662 closable: bool,
663 engine: &mut TextEngine,
664) -> Vec<i32> {
665 let pad = padding(style.size_px);
666 let close = if closable {
667 close_size(style.size_px)
668 } else {
669 0
670 };
671 labels
672 .iter()
673 .map(|label| engine.measure_line(style, label) + pad * 2 + close)
674 .collect()
675}
676
677fn lay_out(
680 labels: &[String],
681 style: TextStyle,
682 closable: bool,
683 selected: usize,
684 band: Rect,
685 engine: &mut TextEngine,
686) -> Vec<Rect> {
687 let mut tabs = place(band, &widths(labels, style, closable, engine));
688 let shift = reveal_shift(band, &tabs, selected);
689 for tab in &mut tabs {
690 tab.x -= shift;
691 }
692 tabs
693}
694
695fn label_colors(
706 theme: &denise::Theme,
707 state: VisualState,
708) -> (denise::Color, denise::Color, denise::Color) {
709 let (surface, content) = interactive_pair(theme, Role::Base100, state);
710 (surface, content, muted(surface, content))
711}
712
713const TINT: u8 = 64;
719
720fn tinted(surface: Color, color: Color) -> Color {
722 surface.mix(color, TINT)
723}
724
725fn labels_on(tint: Color, content: Color) -> (Color, Color) {
731 let selected = if contrast_x100(tint, content) >= AA {
732 content
733 } else {
734 derive_content(tint, AA)
735 };
736 (selected, muted(tint, selected))
737}
738
739#[inline]
741const fn padding(size_px: u16) -> i32 {
742 let value = size_px as i32;
743 if value < 8 { 8 } else { value }
744}
745
746#[inline]
748const fn close_size(size_px: u16) -> i32 {
749 let value = size_px as i32;
750 if value < 12 { 12 } else { value }
751}
752
753#[inline]
755const fn rule_thickness(band: Rect) -> i32 {
756 let value = band.height / 10;
757 if value < 2 { 2 } else { value }
758}
759
760#[inline]
763const fn drag_threshold(size_px: u16) -> i32 {
764 let value = size_px as i32 / 3;
765 if value < 4 { 4 } else { value }
766}
767
768fn close_rect(size_px: u16, tab: Rect, band: Rect) -> Rect {
770 let size = close_size(size_px);
771 let pad = padding(size_px);
772 let y = tab.y + (tab.height - rule_thickness(band) - size) / 2;
773 Rect::new(tab.right() - pad / 2 - size, y, size, size)
774}
775
776fn place(bounds: Rect, widths: &[i32]) -> Vec<Rect> {
782 let mut x = bounds.x;
783 widths
784 .iter()
785 .map(|width| {
786 let rect = Rect::new(x, bounds.y, *width, bounds.height);
787 x += width;
788 rect
789 })
790 .collect()
791}
792
793fn reveal_shift(band: Rect, tabs: &[Rect], selected: usize) -> i32 {
796 let Some(tab) = tabs.get(selected) else {
797 return 0;
798 };
799 let overflow = tab.right() - band.right();
800 if overflow <= 0 {
801 return 0;
802 }
803 overflow.min(tab.x - band.x).max(0)
804}
805
806fn hit(bounds: Rect, tabs: &[Rect], point: Point) -> Option<usize> {
808 if !bounds.contains(point) {
809 return None;
810 }
811 tabs.iter().position(|tab| tab.contains(point))
812}
813
814fn drag_target(tabs: &[Rect], index: usize, centre: i32) -> Option<usize> {
821 let middle = |tab: &Rect| tab.x + tab.width / 2;
822 let mut to = index;
823 while to + 1 < tabs.len() && centre > middle(&tabs[to + 1]) {
824 to += 1;
825 }
826 if to == index {
827 while to > 0 && centre < middle(&tabs[to - 1]) {
828 to -= 1;
829 }
830 }
831 (to != index).then_some(to)
832}
833
834const fn moved_index(index: usize, from: usize, to: usize) -> usize {
836 if index == from {
837 to
838 } else if from < index && index <= to {
839 index - 1
840 } else if to <= index && index < from {
841 index + 1
842 } else {
843 index
844 }
845}
846
847fn draw_cross(canvas: &mut Pen<'_>, rect: Rect, color: Color) {
849 let inset = rect.width / 4;
850 let (x0, y0) = ((rect.x + inset) * 256, (rect.y + inset) * 256);
851 let (x1, y1) = ((rect.right() - inset) * 256, (rect.bottom() - inset) * 256);
852 let half = (rect.width * 256 / 14).max(128) * 181 / 256;
855 canvas.fill_polygon_fx(
856 &[
857 (x0 + half, y0 - half),
858 (x1 + half, y1 - half),
859 (x1 - half, y1 + half),
860 (x0 - half, y0 + half),
861 ],
862 color,
863 );
864 canvas.fill_polygon_fx(
865 &[
866 (x1 + half, y0 + half),
867 (x0 + half, y1 + half),
868 (x0 - half, y1 - half),
869 (x1 - half, y0 - half),
870 ],
871 color,
872 );
873}
874
875impl<M: 'static> Widget<M> for Tabs<M> {
876 fn describe(&self) -> Option<&dyn DynDescribe> {
877 Some(self)
878 }
879
880 fn describe_mut(&mut self) -> Option<&mut dyn DynDescribe> {
881 Some(self)
882 }
883 fn measure(&self, ctx: &mut MeasureCtx<'_>, _offered: Offer) -> Measured {
884 Measured::both(
885 self.preferred_width(ctx.text),
886 ctx.theme.metrics.size_field.max(1),
887 )
888 }
889
890 fn paint(&self, ctx: &mut PaintCtx<'_>, canvas: &mut Pen<'_>) {
891 let bounds = self.band(ctx.bounds, ctx.theme);
895 if bounds.is_empty() || self.labels.is_empty() {
896 return;
897 }
898 let mut tabs = self.layout(bounds, ctx.text);
899
900 let thickness = rule_thickness(bounds);
904 let rule = Rect::new(
905 bounds.x,
906 bounds.bottom() - thickness,
907 bounds.width,
908 thickness,
909 );
910 canvas.fill_rect(rule, ctx.theme.color(Role::Base300));
911
912 let (surface, content, resting) = label_colors(ctx.theme, ctx.state);
915 let underline = if ctx.state.contains(VisualState::DISABLED) {
916 resting
917 } else {
918 ctx.theme.color(self.role)
919 };
920 let hovered = hovered_row(ctx.state, self.hovered);
921 let close = if self.closable {
922 close_size(self.style.size_px)
923 } else {
924 0
925 };
926
927 let dragged = self
930 .press
931 .and_then(|press| press.dragging.map(|x| (press.index, x - press.grab)));
932 if let Some((index, x)) = dragged
933 && let Some(tab) = tabs.get_mut(index)
934 {
935 tab.x = x.clamp(bounds.x, (bounds.right() - tab.width).max(bounds.x));
936 }
937 let order = (0..tabs.len())
938 .filter(|&index| Some(index) != dragged.map(|(d, _)| d))
939 .chain(dragged.map(|(d, _)| d));
940
941 for index in order {
942 let tab = tabs[index];
943 let chosen = index == self.selected;
944 let face = Rect::new(tab.x, tab.y, tab.width, tab.height - thickness);
945 let (on, off) = match self.colors.get(index).copied().flatten() {
946 Some(color) => {
947 let tint = tinted(surface, color);
948 canvas.fill_rect(face, tint);
949 canvas.fill_rect(Rect::new(tab.x, tab.y, tab.width, thickness), color);
950 labels_on(tint, content)
951 }
952 None => {
953 if dragged.is_some_and(|(d, _)| d == index) {
954 canvas.fill_rect(face, surface);
957 }
958 (content, resting)
959 }
960 };
961 if chosen {
962 canvas.fill_rect(Rect::new(tab.x, rule.y, tab.width, thickness), underline);
963 }
964 let text = Rect::new(tab.x, tab.y, tab.width - close, tab.height - thickness);
967 draw_aligned(
968 canvas,
969 ctx.text,
970 self.style,
971 text,
972 (Align::Center, Align::Center),
973 &self.labels[index],
974 if chosen { on } else { off },
975 );
976 if self.closable && (chosen || hovered == Some(index)) {
977 draw_cross(
978 canvas,
979 self.close_rect(tab, bounds),
980 if chosen { on } else { off },
981 );
982 }
983 }
984 }
985
986 fn on_event(&mut self, event: &Event<'_>, ctx: &mut EventCtx<'_, M>) -> Handled {
987 if self.labels.is_empty() {
988 return Handled::No;
989 }
990 let band = self.band(ctx.bounds, ctx.theme);
991 let chosen = match event {
992 Event::Input(InputEvent::PointerMoved { position }) => {
993 return self.pointer_moved(*position, band, ctx);
994 }
995 Event::Input(InputEvent::PointerButton {
996 button,
997 state: ElementState::Down,
998 position,
999 ..
1000 }) => return self.pressed(*button, *position, band, ctx),
1001 Event::Input(InputEvent::PointerButton {
1002 button,
1003 state: ElementState::Up,
1004 position,
1005 ..
1006 }) => return self.released(*button, *position, band, ctx),
1007 Event::Input(InputEvent::TouchUp {
1008 position,
1009 cancelled: false,
1010 ..
1011 }) => {
1012 let tabs = self.layout(band, ctx.text);
1013 hit(band, &tabs, *position)
1014 }
1015 Event::Input(InputEvent::Key {
1018 code,
1019 state: ElementState::Down,
1020 ..
1021 }) if ctx.state.contains(VisualState::FOCUSED) => match code {
1022 KeyCode::ArrowLeft => Some(self.step(false)),
1023 KeyCode::ArrowRight => Some(self.step(true)),
1024 KeyCode::Home => Some(0),
1025 KeyCode::End => Some(self.labels.len() - 1),
1026 _ => return Handled::No,
1027 },
1028 _ => return Handled::No,
1029 };
1030
1031 match chosen {
1032 Some(chosen) => self.select(chosen, ctx),
1033 None => Handled::No,
1034 }
1035 }
1036
1037 fn accepts_pointer(&self) -> bool {
1038 true
1039 }
1040
1041 fn focusable(&self) -> bool {
1043 !self.labels.is_empty()
1044 }
1045}
1046
1047impl<M> Describe for Tabs<M> {
1048 const KIND: &'static str = "tabs";
1049 const DOC: &'static str = "A row of labels where one is selected, for switching what is below.";
1050 const GROUP: Group = Group::Container;
1051 const ICON: &'static denise::icon::Icon = &super::icons::TABS;
1052
1053 const PROPERTIES: &'static [Property] = &[
1054 Property::new(
1055 "tab",
1056 PropertyKind::List,
1057 "The section names, as `tab` child nodes. Real data: a form's sections are the form's. A `tab` that carries children carries that section's page with it.",
1058 ),
1059 Property::new(
1060 "selected",
1061 PropertyKind::Int {
1062 min: 0,
1063 max: i32::MAX,
1064 },
1065 "Index of the selected tab. A strip with tabs always has one, so this is never unset.",
1066 ),
1067 Property::new(
1068 "on-change",
1069 PropertyKind::Message(Payload::Index),
1070 "Emitted with the newly selected tab's index.",
1071 ),
1072 Property::new(
1073 "role",
1074 PropertyKind::Enum(ROLES),
1075 "Colour role of the selected tab's underline, and only that.",
1076 ),
1077 Property::new(
1078 "size",
1079 PropertyKind::Int { min: 6, max: 96 },
1080 "Text size in logical pixels.",
1081 )
1082 .in_pixels(),
1083 ];
1084
1085 fn get(&self, name: &str) -> Option<Value> {
1086 Some(match name {
1087 "selected" => Value::Int(i32::try_from(self.selected).unwrap_or(i32::MAX)),
1088 "role" => Value::role(self.role),
1089 "size" => Value::Int(i32::from(self.style.size_px)),
1090 _ => return None,
1091 })
1092 }
1093
1094 fn apply(&mut self, name: &str, value: Value) -> Result<(), Mismatch> {
1095 match name {
1096 "selected" => self.set_selected(value.as_index()?),
1099 "on-change" | "tab" => return Err(Mismatch::Supplied),
1103 "role" => self.role = value.as_role()?,
1104 "size" => self.style.size_px = value.as_size()?,
1105 _ => return Err(Mismatch::Unknown),
1106 }
1107 Ok(())
1108 }
1109}
1110
1111#[cfg(test)]
1112mod tests {
1113 use super::*;
1114 use denise::Theme;
1115 use denise::theme;
1116
1117 fn tabs() -> Tabs<usize> {
1118 Tabs::new(["Oversikt", "Alarmer", "Innstillinger"], |index| index)
1119 }
1120
1121 #[test]
1125 fn tabs_are_laid_end_to_end_at_their_own_widths() {
1126 let bounds = Rect::new(10, 20, 300, 40);
1127 let placed = place(bounds, &[60, 90, 40]);
1128
1129 assert_eq!(placed[0].x, bounds.x);
1130 for pair in placed.windows(2) {
1131 assert_eq!(pair[1].x, pair[0].right(), "a gap or an overlap");
1132 }
1133 assert_eq!(placed.last().expect("a tab").right(), bounds.x + 190);
1134 for tab in &placed {
1135 assert_eq!(tab.y, bounds.y);
1136 assert_eq!(tab.height, bounds.height);
1137 }
1138 }
1139
1140 #[test]
1144 fn tabs_wider_than_the_strip_are_still_placed() {
1145 let bounds = Rect::new(0, 0, 100, 40);
1146 let placed = place(bounds, &[60, 90, 40]);
1147 assert_eq!(placed.len(), 3);
1148 assert!(
1149 placed[2].x > bounds.right(),
1150 "the last tab should be past the edge"
1151 );
1152 }
1153
1154 #[test]
1158 fn a_point_lands_in_the_tab_that_contains_it() {
1159 let bounds = Rect::new(10, 20, 300, 40);
1160 let widths = [60, 90, 40];
1161 let placed = place(bounds, &widths);
1162
1163 assert_eq!(hit(bounds, &placed, Point::new(11, 30)), Some(0));
1164 assert_eq!(hit(bounds, &placed, Point::new(69, 30)), Some(0));
1165 assert_eq!(hit(bounds, &placed, Point::new(70, 30)), Some(1));
1166 assert_eq!(hit(bounds, &placed, Point::new(199, 30)), Some(2));
1167 assert_eq!(
1168 hit(bounds, &placed, Point::new(200, 30)),
1169 None,
1170 "the right edge is exclusive: 160..200 ends at 199"
1171 );
1172 assert_eq!(
1173 hit(bounds, &placed, Point::new(280, 30)),
1174 None,
1175 "and past the last tab is strip, not tab"
1176 );
1177 assert_eq!(hit(bounds, &placed, Point::new(5, 30)), None, "left of it");
1178 assert_eq!(hit(bounds, &placed, Point::new(100, 5)), None, "above it");
1179 }
1180
1181 #[test]
1183 fn the_selection_wraps_and_the_ends_are_reachable() {
1184 let mut tabs = tabs();
1185 assert_eq!(tabs.step(true), 1);
1186 tabs.set_selected(2);
1187 assert_eq!(tabs.step(true), 0, "past the end comes back to the start");
1188 tabs.set_selected(0);
1189 assert_eq!(tabs.step(false), 2, "and before the start goes to the end");
1190 }
1191
1192 #[test]
1194 fn a_single_tab_strip_steps_to_itself() {
1195 let tabs: Tabs<usize> = Tabs::new(["Bare én"], |index| index);
1196 assert_eq!(tabs.step(true), 0);
1197 assert_eq!(tabs.step(false), 0);
1198 }
1199
1200 #[test]
1202 fn an_empty_strip_is_inert_rather_than_broken() {
1203 let mut tabs: Tabs<usize> = Tabs::inert(Vec::<String>::new());
1204 assert_eq!(tabs.selected(), 0);
1205 assert_eq!(tabs.selected_label(), None);
1206 assert_eq!(tabs.step(true), 0);
1207 assert!(!Widget::<usize>::focusable(&tabs));
1208 tabs.set_selected(9);
1209 assert_eq!(tabs.selected(), 0);
1210 assert!(place(Rect::new(0, 0, 100, 40), &[]).is_empty());
1211 }
1212
1213 #[test]
1216 fn the_selection_survives_the_labels_changing() {
1217 let mut tabs = tabs();
1218 tabs.set_selected(2);
1219 assert_eq!(tabs.selected_label(), Some("Innstillinger"));
1220 tabs.set_labels(["Bare én"]);
1221 assert_eq!(tabs.selected(), 0);
1222 assert_eq!(tabs.selected_label(), Some("Bare én"));
1223 }
1224
1225 #[test]
1228 fn the_preferred_width_is_the_sum_of_the_tabs() {
1229 let mut engine = TextEngine::new();
1230 let tabs = tabs();
1231 let widths = tabs.widths(&mut engine);
1232 assert_eq!(widths.len(), 3);
1233 assert_eq!(
1234 tabs.preferred_width(&mut engine),
1235 widths.iter().sum::<i32>()
1236 );
1237 assert!(
1238 widths[2] > widths[1],
1239 "a longer label should make a wider tab"
1240 );
1241 }
1242
1243 #[test]
1247 fn close_buttons_widen_every_tab_by_the_same_amount() {
1248 let mut engine = TextEngine::new();
1249 let plain = tabs().widths(&mut engine);
1250 let closable = tabs().with_close_buttons(true).widths(&mut engine);
1251 for (plain, closable) in plain.iter().zip(&closable) {
1252 assert_eq!(closable - plain, close_size(16));
1253 }
1254 }
1255
1256 #[test]
1259 fn the_close_button_sits_inside_the_end_of_its_tab() {
1260 let band = Rect::new(0, 0, 400, 40);
1261 let tab = Rect::new(100, 0, 120, 40);
1262 let close = close_rect(16, tab, band);
1263 assert!(close.x > tab.x && close.right() < tab.right());
1264 assert!(close.bottom() <= band.bottom() - rule_thickness(band));
1265 assert!(close.y >= tab.y);
1266 }
1267
1268 #[test]
1271 fn the_selected_tab_is_slid_into_view() {
1272 let band = Rect::new(0, 0, 100, 40);
1273 let placed = place(band, &[60, 90, 40]);
1274 assert_eq!(reveal_shift(band, &placed, 0), 0, "already visible");
1275 let shift = reveal_shift(band, &placed, 2);
1276 assert_eq!(
1277 placed[2].right() - shift,
1278 band.right(),
1279 "its end at the edge"
1280 );
1281
1282 let placed = place(band, &[60, 300]);
1284 assert_eq!(placed[1].x - reveal_shift(band, &placed, 1), band.x);
1285 }
1286
1287 #[test]
1291 fn a_dragged_tab_passes_its_neighbours_at_their_centres_and_stays_passed() {
1292 let band = Rect::new(0, 0, 400, 40);
1293 for widths in [[40, 120, 60], [120, 40, 60]] {
1294 let placed = place(band, &widths);
1295 let neighbour = placed[1].x + placed[1].width / 2;
1296 assert_eq!(drag_target(&placed, 0, neighbour), None, "on the centre");
1297 assert_eq!(drag_target(&placed, 0, neighbour + 1), Some(1));
1298
1299 let moved = place(band, &[widths[1], widths[0], widths[2]]);
1301 assert_eq!(drag_target(&moved, 1, neighbour + 1), None, "{widths:?}");
1302 }
1303 let placed = place(band, &[40, 40, 40, 40]);
1304 assert_eq!(drag_target(&placed, 0, 150), Some(3), "several at once");
1305 assert_eq!(drag_target(&placed, 3, 10), Some(0), "and back");
1306 }
1307
1308 #[test]
1310 fn moving_a_tab_takes_its_colour_and_the_selection_with_it() {
1311 let red = Color::rgb(220, 50, 50);
1312 let mut tabs = tabs().with_colors([Some(red)]);
1313 tabs.set_selected(1);
1314 tabs.move_tab(0, 2);
1315 assert_eq!(tabs.labels(), ["Alarmer", "Innstillinger", "Oversikt"]);
1316 assert_eq!(tabs.colors(), [None, None, Some(red)]);
1317 assert_eq!(tabs.selected_label(), Some("Alarmer"));
1318
1319 for (from, to) in [(0, 2), (2, 0), (1, 1), (0, 9)] {
1320 let mut tabs = tabs.clone();
1321 let before = tabs.selected_label().map(String::from);
1322 tabs.move_tab(from, to);
1323 assert_eq!(tabs.selected_label().map(String::from), before);
1324 }
1325 }
1326
1327 #[test]
1329 fn there_is_one_colour_per_tab() {
1330 let blue = Color::rgb(50, 90, 220);
1331 let mut tabs = tabs().with_colors([Some(blue); 5]);
1332 assert_eq!(tabs.colors().len(), 3, "cut to the tabs");
1333 tabs.set_labels(["En", "To", "Tre", "Fire"]);
1334 assert_eq!(tabs.colors(), [Some(blue), Some(blue), Some(blue), None]);
1335 tabs.set_color(9, Some(blue));
1336 tabs.set_color(3, Some(blue));
1337 assert_eq!(tabs.colors()[3], Some(blue));
1338 }
1339
1340 #[test]
1345 fn both_label_colours_are_readable_on_the_panel_in_every_theme() {
1346 use denise::theme::{AA_LARGE, contrast_x100};
1347
1348 for theme in Theme::BUILT_IN {
1349 for state in [
1350 VisualState::NONE,
1351 VisualState::HOVERED,
1352 VisualState::FOCUSED,
1353 VisualState::DISABLED,
1354 ] {
1355 let (surface, selected, resting) = label_colors(&theme, state);
1356 for (which, colour) in [("selected", selected), ("unselected", resting)] {
1357 let ratio = contrast_x100(surface, colour);
1358 assert!(
1359 ratio >= AA_LARGE,
1360 "{} {state:?} {which}: label on the panel is {ratio}, floor \
1361 is {AA_LARGE}",
1362 theme.name
1363 );
1364 }
1365 }
1366 }
1367 }
1368
1369 #[test]
1373 fn labels_are_readable_on_a_tab_of_any_colour() {
1374 use denise::theme::AA_LARGE;
1375
1376 let colours = [
1377 Color::rgb(229, 72, 77),
1378 Color::rgb(247, 144, 9),
1379 Color::rgb(245, 208, 0),
1380 Color::rgb(48, 164, 108),
1381 Color::rgb(18, 165, 148),
1382 Color::rgb(62, 99, 221),
1383 Color::rgb(142, 78, 198),
1384 Color::rgb(214, 64, 159),
1385 Color::rgb(128, 128, 128),
1386 Color::WHITE,
1387 Color::BLACK,
1388 ];
1389 for theme in Theme::BUILT_IN {
1390 let (surface, content, _) = label_colors(&theme, VisualState::NONE);
1391 for colour in colours {
1392 let tint = tinted(surface, colour);
1393 let (selected, resting) = labels_on(tint, content);
1394 for (which, label) in [("selected", selected), ("resting", resting)] {
1395 let ratio = contrast_x100(tint, label);
1396 assert!(
1397 ratio >= AA_LARGE,
1398 "{} {colour:?} {which}: {ratio}",
1399 theme.name
1400 );
1401 }
1402 }
1403 }
1404 }
1405
1406 #[test]
1409 fn the_muted_label_is_actually_different_from_the_selected_one() {
1410 for theme in Theme::BUILT_IN {
1411 let (_, selected, resting) = label_colors(&theme, VisualState::NONE);
1412 assert_ne!(resting, selected, "{}", theme.name);
1413 }
1414 }
1415
1416 #[test]
1419 fn a_disabled_strip_does_not_mute_a_colour_that_has_nothing_left_to_give() {
1420 for theme in Theme::BUILT_IN {
1421 let (_, selected, resting) = label_colors(&theme, VisualState::DISABLED);
1422 assert_eq!(
1423 resting, selected,
1424 "{}: a disabled label was muted below its own floor",
1425 theme.name
1426 );
1427 }
1428 }
1429
1430 #[test]
1432 fn padding_survives_an_absurdly_small_font() {
1433 assert!(padding(0) >= 8);
1434 assert!(padding(6) >= 8);
1435 assert_eq!(padding(16), 16);
1436 let _ = theme::DARK;
1437 }
1438}