use alloc::string::String;
use alloc::vec::Vec;
use denise::Pen;
use denise::theme::{AA, contrast_x100, derive_content};
use denise::{Color, ElementState, InputEvent, KeyCode, Point, PointerButton, Rect, Role, Theme};
use denise_text::{TextEngine, TextStyle};
use crate::widget::{
Event, EventCtx, Handled, MeasureCtx, Measured, Offer, PaintCtx, VisualState, Widget,
};
use crate::widgets::describe::{
Describe, DynDescribe, Group, Mismatch, Payload, Property, PropertyKind, ROLES, Value,
};
use crate::widgets::style::{
Align, ClickPair, Intent, draw_aligned, hovered_row, interactive_pair, muted,
};
#[derive(Clone, Debug)]
pub struct Tabs<M> {
labels: Vec<String>,
colors: Vec<Option<Color>>,
selected: usize,
report: Report<M>,
closable: bool,
role: Role,
style: TextStyle,
over_pages: bool,
hovered: Option<usize>,
press: Option<Press>,
clicks: ClickPair,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TabEvent {
Selected(usize),
Close(usize),
Moved {
from: usize,
to: usize,
},
Activated(usize),
Menu {
index: usize,
at: Point,
},
}
#[derive(Debug)]
enum Report<M> {
Nothing,
Index(fn(usize) -> M),
Events(fn(TabEvent) -> M),
}
impl<M> Clone for Report<M> {
fn clone(&self) -> Self {
*self
}
}
impl<M> Copy for Report<M> {}
#[derive(Clone, Copy, Debug)]
struct Press {
index: usize,
from: usize,
button: PointerButton,
on_close: bool,
start: Point,
grab: i32,
dragging: Option<i32>,
}
impl<M> Tabs<M> {
pub fn new(
labels: impl IntoIterator<Item = impl Into<String>>,
message: fn(usize) -> M,
) -> Self {
Self::reporting(labels, Report::Index(message))
}
pub fn with_events(
labels: impl IntoIterator<Item = impl Into<String>>,
message: fn(TabEvent) -> M,
) -> Self {
Self::reporting(labels, Report::Events(message))
}
pub fn inert(labels: impl IntoIterator<Item = impl Into<String>>) -> Self {
Self::reporting(labels, Report::Nothing)
}
fn reporting(labels: impl IntoIterator<Item = impl Into<String>>, report: Report<M>) -> Self {
let labels: Vec<String> = labels.into_iter().map(Into::into).collect();
Self {
colors: alloc::vec![None; labels.len()],
labels,
selected: 0,
report,
closable: false,
role: Role::Primary,
style: TextStyle::built_in(16),
over_pages: false,
hovered: None,
press: None,
clicks: ClickPair::default(),
}
}
#[must_use]
pub fn over_pages(mut self) -> Self {
self.over_pages = true;
self
}
#[inline]
pub const fn is_over_pages(&self) -> bool {
self.over_pages
}
pub fn strip_height(&self, theme: &Theme) -> i32 {
theme.metrics.size_field.max(1)
}
fn band(&self, bounds: Rect, theme: &Theme) -> Rect {
if !self.over_pages {
return bounds;
}
let height = bounds.height.min(self.strip_height(theme));
Rect::new(bounds.x, bounds.y, bounds.width, height)
}
pub fn with_selected(mut self, index: usize) -> Self {
self.selected = self.clamp(index);
self
}
pub fn with_role(mut self, role: Role) -> Self {
self.role = role;
self
}
pub fn with_style(mut self, style: TextStyle) -> Self {
self.style = style;
self
}
#[must_use]
pub fn with_close_buttons(mut self, on: bool) -> Self {
self.closable = on;
self
}
#[must_use]
pub fn with_colors(mut self, colors: impl IntoIterator<Item = Option<Color>>) -> Self {
self.set_colors(colors);
self
}
#[inline]
pub const fn selected(&self) -> usize {
self.selected
}
#[inline]
pub fn selected_label(&self) -> Option<&str> {
self.labels.get(self.selected).map(String::as_str)
}
pub fn set_selected(&mut self, index: usize) {
self.selected = self.clamp(index);
}
#[inline]
pub fn labels(&self) -> &[String] {
&self.labels
}
pub fn set_labels(&mut self, labels: impl IntoIterator<Item = impl Into<String>>) {
self.labels = labels.into_iter().map(Into::into).collect();
self.colors.resize(self.labels.len(), None);
self.selected = self.clamp(self.selected);
self.forget_pointer();
}
pub fn set_label(&mut self, index: usize, label: impl Into<String>) {
if let Some(slot) = self.labels.get_mut(index) {
*slot = label.into();
}
}
#[inline]
pub fn colors(&self) -> &[Option<Color>] {
&self.colors
}
pub fn set_colors(&mut self, colors: impl IntoIterator<Item = Option<Color>>) {
self.colors = colors.into_iter().collect();
self.colors.resize(self.labels.len(), None);
}
pub fn set_color(&mut self, index: usize, color: Option<Color>) {
if let Some(slot) = self.colors.get_mut(index) {
*slot = color;
}
}
pub fn move_tab(&mut self, from: usize, to: usize) {
let count = self.labels.len();
if from >= count || to >= count || from == to {
return;
}
let label = self.labels.remove(from);
self.labels.insert(to, label);
let color = self.colors.remove(from);
self.colors.insert(to, color);
self.selected = moved_index(self.selected, from, to);
self.hovered = None;
self.clicks.forget();
}
#[inline]
pub const fn has_close_buttons(&self) -> bool {
self.closable
}
pub fn set_close_buttons(&mut self, on: bool) {
self.closable = on;
}
pub fn set_role(&mut self, role: Role) {
self.role = role;
}
pub fn set_style(&mut self, style: TextStyle) {
self.style = style;
}
pub fn preferred_width(&self, engine: &mut TextEngine) -> i32 {
self.widths(engine).iter().sum()
}
fn widths(&self, engine: &mut TextEngine) -> Vec<i32> {
widths(&self.labels, self.style, self.closable, engine)
}
fn layout(&self, band: Rect, engine: &mut TextEngine) -> Vec<Rect> {
lay_out(
&self.labels,
self.style,
self.closable,
self.selected,
band,
engine,
)
}
#[inline]
fn clamp(&self, index: usize) -> usize {
index.min(self.labels.len().saturating_sub(1))
}
fn step(&self, forward: bool) -> usize {
let count = self.labels.len();
if count == 0 {
return 0;
}
if forward {
(self.selected + 1) % count
} else {
(self.selected + count - 1) % count
}
}
fn forget_pointer(&mut self) {
self.hovered = None;
self.press = None;
self.clicks.forget();
}
fn reports_events(&self) -> bool {
matches!(self.report, Report::Events(_))
}
fn emit(&self, ctx: &mut EventCtx<'_, M>, event: TabEvent) {
match (self.report, event) {
(Report::Events(message), event) => ctx.emit(message(event)),
(Report::Index(message), TabEvent::Selected(index)) => ctx.emit(message(index)),
_ => {}
}
}
fn select(&mut self, index: usize, ctx: &mut EventCtx<'_, M>) -> Handled {
if index == self.selected {
return Handled::Yes;
}
self.selected = index;
self.emit(ctx, TabEvent::Selected(index));
Handled::Yes
}
fn close_rect(&self, tab: Rect, band: Rect) -> Rect {
close_rect(self.style.size_px, tab, band)
}
fn pressed(
&mut self,
button: PointerButton,
position: Point,
band: Rect,
ctx: &mut EventCtx<'_, M>,
) -> Handled {
if !self.reports_events() {
return Handled::No;
}
let tabs = self.layout(band, ctx.text);
let Some(index) = hit(band, &tabs, position) else {
self.press = None;
return Handled::No;
};
match button {
PointerButton::Right => {
self.press = None;
self.emit(
ctx,
TabEvent::Menu {
index,
at: position,
},
);
Handled::Yes
}
PointerButton::Left | PointerButton::Middle => {
let on_close = button == PointerButton::Left
&& self.closable
&& self.close_rect(tabs[index], band).contains(position);
self.press = Some(Press {
index,
from: index,
button,
on_close,
start: position,
grab: position.x - tabs[index].x,
dragging: None,
});
Handled::Yes
}
PointerButton::Other(_) => Handled::No,
}
}
fn pointer_moved(&mut self, position: Point, band: Rect, ctx: &mut EventCtx<'_, M>) -> Handled {
let tabs = self.layout(band, ctx.text);
let over = hit(band, &tabs, position);
let mut changed = false;
if over != self.hovered {
self.hovered = over;
changed = self.closable;
}
let Some(mut press) = self.press else {
return if changed { Handled::Yes } else { Handled::No };
};
if press.button != PointerButton::Left || press.on_close {
return if changed { Handled::Yes } else { Handled::No };
}
if press.dragging.is_none()
&& (position.x - press.start.x).abs() < drag_threshold(self.style.size_px)
{
return if changed { Handled::Yes } else { Handled::No };
}
press.dragging = Some(position.x);
let Some(tab) = tabs.get(press.index) else {
self.press = None;
return Handled::Yes;
};
let centre = position.x - press.grab + tab.width / 2;
if let Some(to) = drag_target(&tabs, press.index, centre) {
self.move_tab(press.index, to);
press.index = to;
}
self.press = Some(press);
Handled::Yes
}
fn released(
&mut self,
button: PointerButton,
position: Point,
band: Rect,
ctx: &mut EventCtx<'_, M>,
) -> Handled {
let tabs = self.layout(band, ctx.text);
if !self.reports_events() {
return match hit(band, &tabs, position) {
Some(index) => self.select(index, ctx),
None => Handled::No,
};
}
let Some(press) = self.press.take() else {
return Handled::No;
};
if press.button != button {
return Handled::No;
}
if press.dragging.is_some() {
if press.index != press.from {
self.emit(
ctx,
TabEvent::Moved {
from: press.from,
to: press.index,
},
);
}
self.clicks.forget();
return self.select(press.index, ctx);
}
if hit(band, &tabs, position) != Some(press.index) {
return Handled::Yes;
}
let index = press.index;
if button == PointerButton::Middle {
self.emit(ctx, TabEvent::Close(index));
return Handled::Yes;
}
if press.on_close {
if self.close_rect(tabs[index], band).contains(position) {
self.emit(ctx, TabEvent::Close(index));
}
return Handled::Yes;
}
self.select(index, ctx);
if self.clicks.classify(index, ctx.now_ms, false) == Intent::Activate {
self.emit(ctx, TabEvent::Activated(index));
}
Handled::Yes
}
}
pub fn tab_rect<M: 'static>(
ui: &mut crate::Ui<M>,
id: crate::NodeId,
index: usize,
) -> Option<Rect> {
let bounds = ui.bounds(id)?;
let theme = *ui.theme();
let (band, labels, style, closable, selected) = {
let strip = ui.widget::<Tabs<M>>(id)?;
(
strip.band(bounds, &theme),
strip.labels.clone(),
strip.style,
strip.closable,
strip.selected,
)
};
lay_out(&labels, style, closable, selected, band, ui.text_mut())
.get(index)
.copied()
}
fn widths(
labels: &[String],
style: TextStyle,
closable: bool,
engine: &mut TextEngine,
) -> Vec<i32> {
let pad = padding(style.size_px);
let close = if closable {
close_size(style.size_px)
} else {
0
};
labels
.iter()
.map(|label| engine.measure_line(style, label) + pad * 2 + close)
.collect()
}
fn lay_out(
labels: &[String],
style: TextStyle,
closable: bool,
selected: usize,
band: Rect,
engine: &mut TextEngine,
) -> Vec<Rect> {
let mut tabs = place(band, &widths(labels, style, closable, engine));
let shift = reveal_shift(band, &tabs, selected);
for tab in &mut tabs {
tab.x -= shift;
}
tabs
}
fn label_colors(
theme: &denise::Theme,
state: VisualState,
) -> (denise::Color, denise::Color, denise::Color) {
let (surface, content) = interactive_pair(theme, Role::Base100, state);
(surface, content, muted(surface, content))
}
const TINT: u8 = 64;
fn tinted(surface: Color, color: Color) -> Color {
surface.mix(color, TINT)
}
fn labels_on(tint: Color, content: Color) -> (Color, Color) {
let selected = if contrast_x100(tint, content) >= AA {
content
} else {
derive_content(tint, AA)
};
(selected, muted(tint, selected))
}
#[inline]
const fn padding(size_px: u16) -> i32 {
let value = size_px as i32;
if value < 8 { 8 } else { value }
}
#[inline]
const fn close_size(size_px: u16) -> i32 {
let value = size_px as i32;
if value < 12 { 12 } else { value }
}
#[inline]
const fn rule_thickness(band: Rect) -> i32 {
let value = band.height / 10;
if value < 2 { 2 } else { value }
}
#[inline]
const fn drag_threshold(size_px: u16) -> i32 {
let value = size_px as i32 / 3;
if value < 4 { 4 } else { value }
}
fn close_rect(size_px: u16, tab: Rect, band: Rect) -> Rect {
let size = close_size(size_px);
let pad = padding(size_px);
let y = tab.y + (tab.height - rule_thickness(band) - size) / 2;
Rect::new(tab.right() - pad / 2 - size, y, size, size)
}
fn place(bounds: Rect, widths: &[i32]) -> Vec<Rect> {
let mut x = bounds.x;
widths
.iter()
.map(|width| {
let rect = Rect::new(x, bounds.y, *width, bounds.height);
x += width;
rect
})
.collect()
}
fn reveal_shift(band: Rect, tabs: &[Rect], selected: usize) -> i32 {
let Some(tab) = tabs.get(selected) else {
return 0;
};
let overflow = tab.right() - band.right();
if overflow <= 0 {
return 0;
}
overflow.min(tab.x - band.x).max(0)
}
fn hit(bounds: Rect, tabs: &[Rect], point: Point) -> Option<usize> {
if !bounds.contains(point) {
return None;
}
tabs.iter().position(|tab| tab.contains(point))
}
fn drag_target(tabs: &[Rect], index: usize, centre: i32) -> Option<usize> {
let middle = |tab: &Rect| tab.x + tab.width / 2;
let mut to = index;
while to + 1 < tabs.len() && centre > middle(&tabs[to + 1]) {
to += 1;
}
if to == index {
while to > 0 && centre < middle(&tabs[to - 1]) {
to -= 1;
}
}
(to != index).then_some(to)
}
const fn moved_index(index: usize, from: usize, to: usize) -> usize {
if index == from {
to
} else if from < index && index <= to {
index - 1
} else if to <= index && index < from {
index + 1
} else {
index
}
}
fn draw_cross(canvas: &mut Pen<'_>, rect: Rect, color: Color) {
let inset = rect.width / 4;
let (x0, y0) = ((rect.x + inset) * 256, (rect.y + inset) * 256);
let (x1, y1) = ((rect.right() - inset) * 256, (rect.bottom() - inset) * 256);
let half = (rect.width * 256 / 14).max(128) * 181 / 256;
canvas.fill_polygon_fx(
&[
(x0 + half, y0 - half),
(x1 + half, y1 - half),
(x1 - half, y1 + half),
(x0 - half, y0 + half),
],
color,
);
canvas.fill_polygon_fx(
&[
(x1 + half, y0 + half),
(x0 + half, y1 + half),
(x0 - half, y1 - half),
(x1 - half, y0 - half),
],
color,
);
}
impl<M: 'static> Widget<M> for Tabs<M> {
fn describe(&self) -> Option<&dyn DynDescribe> {
Some(self)
}
fn describe_mut(&mut self) -> Option<&mut dyn DynDescribe> {
Some(self)
}
fn measure(&self, ctx: &mut MeasureCtx<'_>, _offered: Offer) -> Measured {
Measured::both(
self.preferred_width(ctx.text),
ctx.theme.metrics.size_field.max(1),
)
}
fn paint(&self, ctx: &mut PaintCtx<'_>, canvas: &mut Pen<'_>) {
let bounds = self.band(ctx.bounds, ctx.theme);
if bounds.is_empty() || self.labels.is_empty() {
return;
}
let mut tabs = self.layout(bounds, ctx.text);
let thickness = rule_thickness(bounds);
let rule = Rect::new(
bounds.x,
bounds.bottom() - thickness,
bounds.width,
thickness,
);
canvas.fill_rect(rule, ctx.theme.color(Role::Base300));
let (surface, content, resting) = label_colors(ctx.theme, ctx.state);
let underline = if ctx.state.contains(VisualState::DISABLED) {
resting
} else {
ctx.theme.color(self.role)
};
let hovered = hovered_row(ctx.state, self.hovered);
let close = if self.closable {
close_size(self.style.size_px)
} else {
0
};
let dragged = self
.press
.and_then(|press| press.dragging.map(|x| (press.index, x - press.grab)));
if let Some((index, x)) = dragged
&& let Some(tab) = tabs.get_mut(index)
{
tab.x = x.clamp(bounds.x, (bounds.right() - tab.width).max(bounds.x));
}
let order = (0..tabs.len())
.filter(|&index| Some(index) != dragged.map(|(d, _)| d))
.chain(dragged.map(|(d, _)| d));
for index in order {
let tab = tabs[index];
let chosen = index == self.selected;
let face = Rect::new(tab.x, tab.y, tab.width, tab.height - thickness);
let (on, off) = match self.colors.get(index).copied().flatten() {
Some(color) => {
let tint = tinted(surface, color);
canvas.fill_rect(face, tint);
canvas.fill_rect(Rect::new(tab.x, tab.y, tab.width, thickness), color);
labels_on(tint, content)
}
None => {
if dragged.is_some_and(|(d, _)| d == index) {
canvas.fill_rect(face, surface);
}
(content, resting)
}
};
if chosen {
canvas.fill_rect(Rect::new(tab.x, rule.y, tab.width, thickness), underline);
}
let text = Rect::new(tab.x, tab.y, tab.width - close, tab.height - thickness);
draw_aligned(
canvas,
ctx.text,
self.style,
text,
(Align::Center, Align::Center),
&self.labels[index],
if chosen { on } else { off },
);
if self.closable && (chosen || hovered == Some(index)) {
draw_cross(
canvas,
self.close_rect(tab, bounds),
if chosen { on } else { off },
);
}
}
}
fn on_event(&mut self, event: &Event<'_>, ctx: &mut EventCtx<'_, M>) -> Handled {
if self.labels.is_empty() {
return Handled::No;
}
let band = self.band(ctx.bounds, ctx.theme);
let chosen = match event {
Event::Input(InputEvent::PointerMoved { position }) => {
return self.pointer_moved(*position, band, ctx);
}
Event::Input(InputEvent::PointerButton {
button,
state: ElementState::Down,
position,
..
}) => return self.pressed(*button, *position, band, ctx),
Event::Input(InputEvent::PointerButton {
button,
state: ElementState::Up,
position,
..
}) => return self.released(*button, *position, band, ctx),
Event::Input(InputEvent::TouchUp {
position,
cancelled: false,
..
}) => {
let tabs = self.layout(band, ctx.text);
hit(band, &tabs, *position)
}
Event::Input(InputEvent::Key {
code,
state: ElementState::Down,
..
}) if ctx.state.contains(VisualState::FOCUSED) => match code {
KeyCode::ArrowLeft => Some(self.step(false)),
KeyCode::ArrowRight => Some(self.step(true)),
KeyCode::Home => Some(0),
KeyCode::End => Some(self.labels.len() - 1),
_ => return Handled::No,
},
_ => return Handled::No,
};
match chosen {
Some(chosen) => self.select(chosen, ctx),
None => Handled::No,
}
}
fn accepts_pointer(&self) -> bool {
true
}
fn focusable(&self) -> bool {
!self.labels.is_empty()
}
}
impl<M> Describe for Tabs<M> {
const KIND: &'static str = "tabs";
const DOC: &'static str = "A row of labels where one is selected, for switching what is below.";
const GROUP: Group = Group::Container;
const ICON: &'static denise::icon::Icon = &super::icons::TABS;
const PROPERTIES: &'static [Property] = &[
Property::new(
"tab",
PropertyKind::List,
"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.",
),
Property::new(
"selected",
PropertyKind::Int {
min: 0,
max: i32::MAX,
},
"Index of the selected tab. A strip with tabs always has one, so this is never unset.",
),
Property::new(
"on-change",
PropertyKind::Message(Payload::Index),
"Emitted with the newly selected tab's index.",
),
Property::new(
"role",
PropertyKind::Enum(ROLES),
"Colour role of the selected tab's underline, and only that.",
),
Property::new(
"size",
PropertyKind::Int { min: 6, max: 96 },
"Text size in logical pixels.",
)
.in_pixels(),
];
fn get(&self, name: &str) -> Option<Value> {
Some(match name {
"selected" => Value::Int(i32::try_from(self.selected).unwrap_or(i32::MAX)),
"role" => Value::role(self.role),
"size" => Value::Int(i32::from(self.style.size_px)),
_ => return None,
})
}
fn apply(&mut self, name: &str, value: Value) -> Result<(), Mismatch> {
match name {
"selected" => self.set_selected(value.as_index()?),
"on-change" | "tab" => return Err(Mismatch::Supplied),
"role" => self.role = value.as_role()?,
"size" => self.style.size_px = value.as_size()?,
_ => return Err(Mismatch::Unknown),
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use denise::Theme;
use denise::theme;
fn tabs() -> Tabs<usize> {
Tabs::new(["Oversikt", "Alarmer", "Innstillinger"], |index| index)
}
#[test]
fn tabs_are_laid_end_to_end_at_their_own_widths() {
let bounds = Rect::new(10, 20, 300, 40);
let placed = place(bounds, &[60, 90, 40]);
assert_eq!(placed[0].x, bounds.x);
for pair in placed.windows(2) {
assert_eq!(pair[1].x, pair[0].right(), "a gap or an overlap");
}
assert_eq!(placed.last().expect("a tab").right(), bounds.x + 190);
for tab in &placed {
assert_eq!(tab.y, bounds.y);
assert_eq!(tab.height, bounds.height);
}
}
#[test]
fn tabs_wider_than_the_strip_are_still_placed() {
let bounds = Rect::new(0, 0, 100, 40);
let placed = place(bounds, &[60, 90, 40]);
assert_eq!(placed.len(), 3);
assert!(
placed[2].x > bounds.right(),
"the last tab should be past the edge"
);
}
#[test]
fn a_point_lands_in_the_tab_that_contains_it() {
let bounds = Rect::new(10, 20, 300, 40);
let widths = [60, 90, 40];
let placed = place(bounds, &widths);
assert_eq!(hit(bounds, &placed, Point::new(11, 30)), Some(0));
assert_eq!(hit(bounds, &placed, Point::new(69, 30)), Some(0));
assert_eq!(hit(bounds, &placed, Point::new(70, 30)), Some(1));
assert_eq!(hit(bounds, &placed, Point::new(199, 30)), Some(2));
assert_eq!(
hit(bounds, &placed, Point::new(200, 30)),
None,
"the right edge is exclusive: 160..200 ends at 199"
);
assert_eq!(
hit(bounds, &placed, Point::new(280, 30)),
None,
"and past the last tab is strip, not tab"
);
assert_eq!(hit(bounds, &placed, Point::new(5, 30)), None, "left of it");
assert_eq!(hit(bounds, &placed, Point::new(100, 5)), None, "above it");
}
#[test]
fn the_selection_wraps_and_the_ends_are_reachable() {
let mut tabs = tabs();
assert_eq!(tabs.step(true), 1);
tabs.set_selected(2);
assert_eq!(tabs.step(true), 0, "past the end comes back to the start");
tabs.set_selected(0);
assert_eq!(tabs.step(false), 2, "and before the start goes to the end");
}
#[test]
fn a_single_tab_strip_steps_to_itself() {
let tabs: Tabs<usize> = Tabs::new(["Bare én"], |index| index);
assert_eq!(tabs.step(true), 0);
assert_eq!(tabs.step(false), 0);
}
#[test]
fn an_empty_strip_is_inert_rather_than_broken() {
let mut tabs: Tabs<usize> = Tabs::inert(Vec::<String>::new());
assert_eq!(tabs.selected(), 0);
assert_eq!(tabs.selected_label(), None);
assert_eq!(tabs.step(true), 0);
assert!(!Widget::<usize>::focusable(&tabs));
tabs.set_selected(9);
assert_eq!(tabs.selected(), 0);
assert!(place(Rect::new(0, 0, 100, 40), &[]).is_empty());
}
#[test]
fn the_selection_survives_the_labels_changing() {
let mut tabs = tabs();
tabs.set_selected(2);
assert_eq!(tabs.selected_label(), Some("Innstillinger"));
tabs.set_labels(["Bare én"]);
assert_eq!(tabs.selected(), 0);
assert_eq!(tabs.selected_label(), Some("Bare én"));
}
#[test]
fn the_preferred_width_is_the_sum_of_the_tabs() {
let mut engine = TextEngine::new();
let tabs = tabs();
let widths = tabs.widths(&mut engine);
assert_eq!(widths.len(), 3);
assert_eq!(
tabs.preferred_width(&mut engine),
widths.iter().sum::<i32>()
);
assert!(
widths[2] > widths[1],
"a longer label should make a wider tab"
);
}
#[test]
fn close_buttons_widen_every_tab_by_the_same_amount() {
let mut engine = TextEngine::new();
let plain = tabs().widths(&mut engine);
let closable = tabs().with_close_buttons(true).widths(&mut engine);
for (plain, closable) in plain.iter().zip(&closable) {
assert_eq!(closable - plain, close_size(16));
}
}
#[test]
fn the_close_button_sits_inside_the_end_of_its_tab() {
let band = Rect::new(0, 0, 400, 40);
let tab = Rect::new(100, 0, 120, 40);
let close = close_rect(16, tab, band);
assert!(close.x > tab.x && close.right() < tab.right());
assert!(close.bottom() <= band.bottom() - rule_thickness(band));
assert!(close.y >= tab.y);
}
#[test]
fn the_selected_tab_is_slid_into_view() {
let band = Rect::new(0, 0, 100, 40);
let placed = place(band, &[60, 90, 40]);
assert_eq!(reveal_shift(band, &placed, 0), 0, "already visible");
let shift = reveal_shift(band, &placed, 2);
assert_eq!(
placed[2].right() - shift,
band.right(),
"its end at the edge"
);
let placed = place(band, &[60, 300]);
assert_eq!(placed[1].x - reveal_shift(band, &placed, 1), band.x);
}
#[test]
fn a_dragged_tab_passes_its_neighbours_at_their_centres_and_stays_passed() {
let band = Rect::new(0, 0, 400, 40);
for widths in [[40, 120, 60], [120, 40, 60]] {
let placed = place(band, &widths);
let neighbour = placed[1].x + placed[1].width / 2;
assert_eq!(drag_target(&placed, 0, neighbour), None, "on the centre");
assert_eq!(drag_target(&placed, 0, neighbour + 1), Some(1));
let moved = place(band, &[widths[1], widths[0], widths[2]]);
assert_eq!(drag_target(&moved, 1, neighbour + 1), None, "{widths:?}");
}
let placed = place(band, &[40, 40, 40, 40]);
assert_eq!(drag_target(&placed, 0, 150), Some(3), "several at once");
assert_eq!(drag_target(&placed, 3, 10), Some(0), "and back");
}
#[test]
fn moving_a_tab_takes_its_colour_and_the_selection_with_it() {
let red = Color::rgb(220, 50, 50);
let mut tabs = tabs().with_colors([Some(red)]);
tabs.set_selected(1);
tabs.move_tab(0, 2);
assert_eq!(tabs.labels(), ["Alarmer", "Innstillinger", "Oversikt"]);
assert_eq!(tabs.colors(), [None, None, Some(red)]);
assert_eq!(tabs.selected_label(), Some("Alarmer"));
for (from, to) in [(0, 2), (2, 0), (1, 1), (0, 9)] {
let mut tabs = tabs.clone();
let before = tabs.selected_label().map(String::from);
tabs.move_tab(from, to);
assert_eq!(tabs.selected_label().map(String::from), before);
}
}
#[test]
fn there_is_one_colour_per_tab() {
let blue = Color::rgb(50, 90, 220);
let mut tabs = tabs().with_colors([Some(blue); 5]);
assert_eq!(tabs.colors().len(), 3, "cut to the tabs");
tabs.set_labels(["En", "To", "Tre", "Fire"]);
assert_eq!(tabs.colors(), [Some(blue), Some(blue), Some(blue), None]);
tabs.set_color(9, Some(blue));
tabs.set_color(3, Some(blue));
assert_eq!(tabs.colors()[3], Some(blue));
}
#[test]
fn both_label_colours_are_readable_on_the_panel_in_every_theme() {
use denise::theme::{AA_LARGE, contrast_x100};
for theme in Theme::BUILT_IN {
for state in [
VisualState::NONE,
VisualState::HOVERED,
VisualState::FOCUSED,
VisualState::DISABLED,
] {
let (surface, selected, resting) = label_colors(&theme, state);
for (which, colour) in [("selected", selected), ("unselected", resting)] {
let ratio = contrast_x100(surface, colour);
assert!(
ratio >= AA_LARGE,
"{} {state:?} {which}: label on the panel is {ratio}, floor \
is {AA_LARGE}",
theme.name
);
}
}
}
}
#[test]
fn labels_are_readable_on_a_tab_of_any_colour() {
use denise::theme::AA_LARGE;
let colours = [
Color::rgb(229, 72, 77),
Color::rgb(247, 144, 9),
Color::rgb(245, 208, 0),
Color::rgb(48, 164, 108),
Color::rgb(18, 165, 148),
Color::rgb(62, 99, 221),
Color::rgb(142, 78, 198),
Color::rgb(214, 64, 159),
Color::rgb(128, 128, 128),
Color::WHITE,
Color::BLACK,
];
for theme in Theme::BUILT_IN {
let (surface, content, _) = label_colors(&theme, VisualState::NONE);
for colour in colours {
let tint = tinted(surface, colour);
let (selected, resting) = labels_on(tint, content);
for (which, label) in [("selected", selected), ("resting", resting)] {
let ratio = contrast_x100(tint, label);
assert!(
ratio >= AA_LARGE,
"{} {colour:?} {which}: {ratio}",
theme.name
);
}
}
}
}
#[test]
fn the_muted_label_is_actually_different_from_the_selected_one() {
for theme in Theme::BUILT_IN {
let (_, selected, resting) = label_colors(&theme, VisualState::NONE);
assert_ne!(resting, selected, "{}", theme.name);
}
}
#[test]
fn a_disabled_strip_does_not_mute_a_colour_that_has_nothing_left_to_give() {
for theme in Theme::BUILT_IN {
let (_, selected, resting) = label_colors(&theme, VisualState::DISABLED);
assert_eq!(
resting, selected,
"{}: a disabled label was muted below its own floor",
theme.name
);
}
}
#[test]
fn padding_survives_an_absurdly_small_font() {
assert!(padding(0) >= 8);
assert!(padding(6) >= 8);
assert_eq!(padding(16), 16);
let _ = theme::DARK;
}
}