use std::rc::Rc;
use std::time::Duration;
use frust::authoring::text::{FontWeight, LineHeight, TextContext, TextLayout, TextStyle};
use frust::authoring::{
BoxConstraints, BuildCtx, ChangeFlags, CornerRadii, ErasedCallback, EventCtx, EventResult,
InputEvent, LayoutCtx, PaintCtx, PaintScene, PointerPhase, Role, SemanticsCtx, View, Widget,
};
use frust::{AnimationController, FrameTime, IconData, IconSource, SpringDesc, Theme};
use kurbo::{Affine, BezPath, Point, Rect, Size, Vec2};
use peniko::{Brush, Color};
use crate::interaction::{
DISABLED_CONTENT_OPACITY, HapticSignal, InteractionState, MaterialHaptics,
};
use crate::press::presses;
pub const MIN_TABS: usize = 2;
const HEIGHT: f64 = 48.0;
const ICON_SIZE: f64 = 24.0;
const INDICATOR_HEIGHT: f64 = 3.0;
const INDICATOR_CORNER_RADIUS: f64 = 3.0;
const DIVIDER_WIDTH: f64 = 1.0;
const INDICATOR_SPRING: SpringDesc = SpringDesc {
mass: 1.0,
stiffness: 700.0,
damping_ratio: 0.9,
};
const INDICATOR_ANIM_PERIOD: Duration = Duration::from_millis(300);
const INDICATOR_FLING_VELOCITY: f64 = 4.0;
const INDICATOR_RETARGET_TOLERANCE: f64 = 0.1;
const PRIMARY: Color = Color::from_rgb8(0x67, 0x50, 0xA4);
const SURFACE: Color = Color::from_rgb8(0xFE, 0xF7, 0xFF);
const ON_SURFACE: Color = Color::from_rgb8(0x1D, 0x1B, 0x20);
const ON_SURFACE_VARIANT: Color = Color::from_rgb8(0x49, 0x45, 0x4F);
const SURFACE_CONTAINER_HIGHEST: Color = Color::from_rgb8(0xE6, 0xE0, 0xE9);
const TITLE_SMALL: (f32, f32, f32, FontWeight) = (14.0, 20.0, 0.1, FontWeight::MEDIUM);
const SHAPING_INK: Color = Color::BLACK;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum TabsVariant {
#[default]
Primary,
Secondary,
}
struct Colors {
background: Color,
divider: Color,
primary: Color,
on_surface: Color,
on_surface_variant: Color,
}
fn resolve_colors(theme: Option<&Theme>) -> Colors {
match theme {
Some(theme) => {
let s = theme.scheme();
Colors {
background: s.surface,
divider: s.surface_container_highest,
primary: s.primary,
on_surface: s.on_surface,
on_surface_variant: s.on_surface_variant,
}
}
None => Colors {
background: SURFACE,
divider: SURFACE_CONTAINER_HIGHEST,
primary: PRIMARY,
on_surface: ON_SURFACE,
on_surface_variant: ON_SURFACE_VARIANT,
},
}
}
fn with_alpha(color: Color, alpha: f32) -> Color {
let c = color.components;
Color::new([c[0], c[1], c[2], alpha])
}
fn tab_fg(colors: &Colors, selected: bool, enabled: bool) -> Color {
if !enabled {
return with_alpha(colors.on_surface, DISABLED_CONTENT_OPACITY);
}
if selected {
colors.primary
} else {
colors.on_surface_variant
}
}
fn label_style(theme: Option<&Theme>) -> TextStyle {
let mut style = match theme {
Some(theme) => theme.type_scale.title_small.clone(),
None => {
let (size, line_height, letter_spacing, weight) = TITLE_SMALL;
let mut style = TextStyle::new(size, SHAPING_INK);
style.line_height = LineHeight::Absolute(line_height);
style.letter_spacing = letter_spacing;
style.weight = weight;
style
}
};
style.color = SHAPING_INK;
style
}
fn same_icon_source(a: Option<IconSource>, b: Option<IconSource>) -> bool {
match (a, b) {
(Some(x), Some(y)) => x.d == y.d && x.design == y.design,
(None, None) => true,
_ => false,
}
}
struct LabelRun {
content: String,
layout: Option<TextLayout>,
shaped_for: Option<TextStyle>,
}
impl LabelRun {
fn new(content: String) -> Self {
Self {
content,
layout: None,
shaped_for: None,
}
}
fn set_content(&mut self, content: &str) {
if self.content != content {
self.content = content.to_string();
self.layout = None;
self.shaped_for = None;
}
}
fn content(&self) -> &str {
&self.content
}
fn shape(&mut self, ctx: &mut LayoutCtx, style: &TextStyle) -> Size {
if let Some(cached) = &self.layout
&& self.shaped_for.as_ref() == Some(style)
{
return cached.size();
}
let text_ctx = ctx.text_context::<TextContext>();
let laid = text_ctx.layout(&self.content, style, None);
let size = laid.size();
self.layout = Some(laid);
self.shaped_for = Some(style.clone());
size
}
fn size(&self) -> Size {
self.layout.as_ref().map_or(Size::ZERO, TextLayout::size)
}
fn paint(&self, origin: Point, color: Color, scene: &mut dyn PaintScene) {
let Some(layout) = &self.layout else {
return;
};
for mut run in layout.to_scene_runs(origin) {
run.brush = Brush::Solid(color);
scene.draw_glyph_run(run);
}
}
}
fn lerp(from: f64, to: f64, t: f64) -> f64 {
from + (to - from) * t
}
#[derive(Clone, Copy, Debug)]
struct IndicatorMotion {
from_left: f64,
to_left: f64,
from_width: f64,
to_width: f64,
anim: AnimationController,
seeded: bool,
}
impl IndicatorMotion {
fn new() -> Self {
Self {
from_left: 0.0,
to_left: 0.0,
from_width: 0.0,
to_width: 0.0,
anim: AnimationController::new(INDICATOR_ANIM_PERIOD),
seeded: false,
}
}
fn retarget(&mut self, left: f64, width: f64) -> bool {
if !self.seeded {
self.snap_to(left, width);
return false;
}
if (self.to_left - left).abs() <= INDICATOR_RETARGET_TOLERANCE
&& (self.to_width - width).abs() <= INDICATOR_RETARGET_TOLERANCE
{
return false;
}
self.from_left = self.left();
self.from_width = self.width();
self.to_left = left;
self.to_width = width;
self.anim = AnimationController::new(INDICATOR_ANIM_PERIOD);
self.anim.fling(INDICATOR_FLING_VELOCITY, INDICATOR_SPRING);
true
}
fn snap_to(&mut self, left: f64, width: f64) {
self.from_left = left;
self.to_left = left;
self.from_width = width;
self.to_width = width;
self.anim = AnimationController::new(INDICATOR_ANIM_PERIOD);
self.seeded = true;
}
fn snap(&mut self) {
self.from_left = self.to_left;
self.from_width = self.to_width;
self.anim.stop();
}
fn advance(&mut self, now: FrameTime) -> bool {
self.anim.advance(now)
}
fn is_seeded(&self) -> bool {
self.seeded
}
fn factor(&self) -> f64 {
let raw = self.anim.value();
if raw.is_finite() { raw } else { 0.0 }
}
fn left(&self) -> f64 {
lerp(self.from_left, self.to_left, self.factor())
}
fn width(&self) -> f64 {
lerp(self.from_width, self.to_width, self.factor()).max(0.0)
}
}
pub struct Tab {
label: Option<String>,
icon: Option<IconSource>,
enabled: bool,
}
pub fn tab() -> Tab {
Tab {
label: None,
icon: None,
enabled: true,
}
}
impl Tab {
pub fn label(mut self, label: impl Into<String>) -> Self {
self.label = Some(label.into());
self
}
pub fn icon(mut self, icon: IconSource) -> Self {
self.icon = Some(icon);
self
}
pub fn enabled(mut self, enabled: bool) -> Self {
self.enabled = enabled;
self
}
}
type OnSelected<State> = Rc<dyn Fn(&mut State, usize)>;
pub struct TabsView<State: 'static> {
tabs: Vec<Tab>,
selected: usize,
variant: TabsVariant,
on_selected: OnSelected<State>,
}
pub fn tabs<State: 'static, F: Fn(&mut State, usize) + 'static>(
tabs: impl IntoIterator<Item = Tab>,
selected: usize,
on_selected: F,
) -> TabsView<State> {
let tabs: Vec<Tab> = tabs.into_iter().collect();
debug_assert!(
tabs.len() >= MIN_TABS,
"a tab bar needs {MIN_TABS}+ tabs (m3e_tabs.dart:27), got {}",
tabs.len()
);
for t in &tabs {
debug_assert!(
t.label.is_some() || t.icon.is_some(),
"a tab needs a label or an icon (m3e_tab.dart:8-11)"
);
}
TabsView {
tabs,
selected,
variant: TabsVariant::Primary,
on_selected: Rc::new(on_selected),
}
}
#[allow(non_snake_case)]
pub fn Tabs<State: 'static, F: Fn(&mut State, usize) + 'static>(
tabs_: impl IntoIterator<Item = Tab>,
selected: usize,
on_selected: F,
) -> TabsView<State> {
tabs(tabs_, selected, on_selected)
}
impl<State: 'static> TabsView<State> {
pub fn variant(mut self, variant: TabsVariant) -> Self {
self.variant = variant;
self
}
}
fn bind_on_tap<State: 'static>(on_selected: &OnSelected<State>, idx: usize) -> ErasedCallback {
let callback = on_selected.clone();
Box::new(move |ctx: &mut EventCtx| {
MaterialHaptics::fire(HapticSignal::None);
let state = ctx.state_mut::<State>();
callback(state, idx);
})
}
fn bind_on_taps<State: 'static>(
tabs: &[Tab],
on_selected: &OnSelected<State>,
) -> Vec<ErasedCallback> {
(0..tabs.len())
.map(|i| bind_on_tap::<State>(on_selected, i))
.collect()
}
type TabRuntime = (
Vec<Option<LabelRun>>,
Vec<Option<(BezPath, f64)>>,
Vec<bool>,
);
fn build_tab_runtime(tabs: &[Tab]) -> TabRuntime {
let labels = tabs
.iter()
.map(|t| t.label.as_ref().map(|l| LabelRun::new(l.clone())))
.collect();
let icon_geoms = tabs
.iter()
.map(|t| t.icon.map(|src| IconData::from(src).resolve()))
.collect();
let enabled_flags = tabs.iter().map(|t| t.enabled).collect();
(labels, icon_geoms, enabled_flags)
}
pub struct TabsWidget {
labels: Vec<Option<LabelRun>>,
icon_geoms: Vec<Option<(BezPath, f64)>>,
enabled_flags: Vec<bool>,
selected: usize,
variant: TabsVariant,
tab_rects: Vec<Rect>,
content_rects: Vec<Rect>,
interactions: Vec<InteractionState>,
indicator: IndicatorMotion,
armed: bool,
active_tab: Option<usize>,
pressed_inside: bool,
on_taps: Vec<ErasedCallback>,
}
impl TabsWidget {
fn tab_at(&self, pos: Point) -> Option<usize> {
self.tab_rects.iter().position(|r| r.contains(pos))
}
fn indicator_target(&self, i: usize) -> (f64, f64) {
let rect = match self.variant {
TabsVariant::Primary => self.content_rects.get(i).copied().unwrap_or(Rect::ZERO),
TabsVariant::Secondary => self.tab_rects.get(i).copied().unwrap_or(Rect::ZERO),
};
(rect.x0, rect.width())
}
}
impl<State: 'static> View<State> for TabsView<State> {
type Element = TabsWidget;
fn build(&self, _ctx: &mut BuildCtx<'_>) -> TabsWidget {
let (labels, icon_geoms, enabled_flags) = build_tab_runtime(&self.tabs);
let on_taps = bind_on_taps::<State>(&self.tabs, &self.on_selected);
let n = self.tabs.len();
TabsWidget {
labels,
icon_geoms,
enabled_flags,
selected: self.selected,
variant: self.variant,
tab_rects: vec![Rect::ZERO; n],
content_rects: vec![Rect::ZERO; n],
interactions: vec![InteractionState::new(); n],
indicator: IndicatorMotion::new(),
armed: false,
active_tab: None,
pressed_inside: false,
on_taps,
}
}
fn rebuild(
&self,
prev: &Self,
element: &mut TabsWidget,
_ctx: &mut BuildCtx<'_>,
) -> ChangeFlags {
let mut flags = ChangeFlags::NONE;
if prev.tabs.len() != self.tabs.len() {
let (labels, icon_geoms, enabled_flags) = build_tab_runtime(&self.tabs);
element.labels = labels;
element.icon_geoms = icon_geoms;
element.enabled_flags = enabled_flags;
element.tab_rects = vec![Rect::ZERO; self.tabs.len()];
element.content_rects = vec![Rect::ZERO; self.tabs.len()];
element.interactions = vec![InteractionState::new(); self.tabs.len()];
element.indicator = IndicatorMotion::new();
element.armed = false;
element.active_tab = None;
element.pressed_inside = false;
flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
} else {
for (i, (prev_t, next_t)) in prev.tabs.iter().zip(self.tabs.iter()).enumerate() {
match (&prev_t.label, &next_t.label) {
(None, None) => {}
(Some(p), Some(n)) if p == n => {}
(_, Some(n)) => {
let run =
element.labels[i].get_or_insert_with(|| LabelRun::new(String::new()));
run.set_content(n);
flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
}
(Some(_), None) => {
element.labels[i] = None;
flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
}
}
if !same_icon_source(prev_t.icon, next_t.icon) {
element.icon_geoms[i] = next_t.icon.map(|src| IconData::from(src).resolve());
flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
}
if element.enabled_flags[i] != next_t.enabled {
element.enabled_flags[i] = next_t.enabled;
flags |= ChangeFlags::PAINT;
}
}
}
let n = self.tabs.len();
let next_selected = if n == 0 { 0 } else { self.selected.min(n - 1) };
let selection_or_variant_changed =
element.selected != next_selected || element.variant != self.variant;
element.selected = next_selected;
element.variant = self.variant;
if selection_or_variant_changed {
flags |= ChangeFlags::PAINT;
if !flags.needs_layout() {
let (left, width) = element.indicator_target(element.selected);
element.indicator.retarget(left, width);
}
}
element.on_taps = bind_on_taps::<State>(&self.tabs, &self.on_selected);
flags
}
}
impl Widget for TabsWidget {
fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
let width = if bc.max().width.is_finite() {
bc.max().width
} else {
0.0
};
let n = self.labels.len();
if n == 0 {
return bc.constrain(Size::new(width, HEIGHT));
}
let style = {
let theme = Theme::from_layout_ctx(ctx);
label_style(theme)
};
let slot_w = width / n as f64;
self.tab_rects.clear();
self.content_rects.clear();
for i in 0..n {
let has_icon = self.icon_geoms[i].is_some();
let (label_w, label_h) = if let Some(run) = self.labels[i].as_mut() {
let size = run.shape(ctx, &style);
(size.width, size.height)
} else {
(0.0, 0.0)
};
let icon_w = if has_icon { ICON_SIZE } else { 0.0 };
let icon_h = if has_icon { ICON_SIZE } else { 0.0 };
let content_w = label_w.max(icon_w);
let content_h = icon_h + label_h;
let slot_x = i as f64 * slot_w;
self.tab_rects
.push(Rect::new(slot_x, 0.0, slot_x + slot_w, HEIGHT));
let content_x = slot_x + (slot_w - content_w) / 2.0;
let content_y = (HEIGHT - content_h) / 2.0;
self.content_rects.push(Rect::new(
content_x,
content_y,
content_x + content_w,
content_y + content_h,
));
}
let selected = self.selected.min(n - 1);
let (left, target_width) = self.indicator_target(selected);
self.indicator.retarget(left, target_width);
bc.constrain(Size::new(width, HEIGHT))
}
fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
if self.indicator.is_seeded() {
let reduce_motion = Theme::from_paint_ctx(ctx).is_some_and(|t| t.motion.reduce_motion);
if reduce_motion {
let before = (self.indicator.left(), self.indicator.width());
self.indicator.snap();
let after = (self.indicator.left(), self.indicator.width());
if before != after {
ctx.request_frame();
}
} else if self.indicator.advance(ctx.frame_time()) {
ctx.request_frame();
}
}
let origin = ctx.origin();
let size = ctx.size();
let n = self.tab_rects.len();
let colors = {
let theme = Theme::from_paint_ctx(ctx);
resolve_colors(theme)
};
scene.fill_rect(origin, size, colors.background);
scene.stroke_line(
Point::new(origin.x, origin.y + size.height),
Point::new(origin.x + size.width, origin.y + size.height),
DIVIDER_WIDTH,
colors.divider,
);
for i in 0..n {
let tab_rect = self.tab_rects[i];
let content_rect = self.content_rects[i];
let enabled = self.enabled_flags[i];
let selected = i == self.selected;
let opacity = self.interactions[i].resolve_opacity();
if opacity > 0.0 {
let tab_origin = Point::new(origin.x + tab_rect.x0, origin.y + tab_rect.y0);
scene.fill_rect(
tab_origin,
tab_rect.size(),
with_alpha(colors.primary, opacity),
);
}
let fg = tab_fg(&colors, selected, enabled);
let has_icon = self.icon_geoms[i].is_some();
let icon_w = if has_icon { ICON_SIZE } else { 0.0 };
let icon_h = if has_icon { ICON_SIZE } else { 0.0 };
let label_w = self.labels[i].as_ref().map_or(0.0, |run| run.size().width);
if let Some((path, design)) = &self.icon_geoms[i] {
let scale = if *design > 0.0 {
ICON_SIZE / *design
} else {
1.0
};
let icon_x = content_rect.x0 + (content_rect.width() - icon_w) / 2.0;
let icon_y = content_rect.y0;
let transform = Affine::translate(Vec2::new(icon_x, icon_y)) * Affine::scale(scale);
let scaled = transform * path.clone();
scene.fill_path(origin, &scaled, &Brush::Solid(fg));
}
if let Some(run) = &self.labels[i] {
let label_x = content_rect.x0 + (content_rect.width() - label_w) / 2.0;
let label_y = content_rect.y0 + icon_h;
run.paint(
Point::new(origin.x + label_x, origin.y + label_y),
fg,
scene,
);
}
}
if self.indicator.is_seeded() {
let left = self.indicator.left();
let width = self.indicator.width();
let indicator_origin =
Point::new(origin.x + left, origin.y + HEIGHT - INDICATOR_HEIGHT);
scene.fill_rounded_rect_radii(
indicator_origin,
Size::new(width, INDICATOR_HEIGHT),
CornerRadii::new(INDICATOR_CORNER_RADIUS, INDICATOR_CORNER_RADIUS, 0.0, 0.0),
colors.primary,
);
}
}
fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
let InputEvent::Pointer(p) = event else {
return EventResult::Ignored;
};
match p.phase {
PointerPhase::Down => {
if !presses(p) {
return EventResult::Ignored;
}
let Some(i) = self.tab_at(p.position) else {
return EventResult::Ignored;
};
if !self.enabled_flags[i] {
return EventResult::Ignored;
}
self.armed = true;
self.active_tab = Some(i);
self.pressed_inside = true;
self.interactions[i].set_pressed(true);
ctx.capture_pointer();
ctx.request_redraw();
EventResult::Handled
}
PointerPhase::Move => {
if !self.armed {
return EventResult::Ignored;
}
let Some(i) = self.active_tab else {
return EventResult::Ignored;
};
let inside = self
.tab_rects
.get(i)
.is_some_and(|r| r.contains(p.position));
if inside != self.pressed_inside {
self.pressed_inside = inside;
self.interactions[i].set_pressed(inside);
ctx.request_redraw();
}
EventResult::Handled
}
PointerPhase::Up => {
if !self.armed {
return EventResult::Ignored;
}
if let Some(i) = self.active_tab {
self.interactions[i].set_pressed(false);
if self.pressed_inside {
(self.on_taps[i])(ctx);
}
}
self.armed = false;
self.pressed_inside = false;
self.active_tab = None;
ctx.request_redraw();
EventResult::Handled
}
PointerPhase::Cancel => {
if !self.armed {
return EventResult::Ignored;
}
if let Some(i) = self.active_tab {
self.interactions[i].set_pressed(false);
}
self.armed = false;
self.pressed_inside = false;
self.active_tab = None;
ctx.request_redraw();
EventResult::Handled
}
}
}
fn semantics(&self, ctx: &mut SemanticsCtx) {
let labels = &self.labels;
let enabled_flags = &self.enabled_flags;
let selected = self.selected;
ctx.push_container(
Role::TabList,
|_| {},
|ctx| {
for i in 0..labels.len() {
let label = labels[i].as_ref().map(LabelRun::content);
let enabled = enabled_flags[i];
ctx.push_node(Role::Tab, |node| {
if let Some(l) = label {
node.set_label(l);
}
node.set_selected(i == selected);
if !enabled {
node.set_disabled();
}
});
}
},
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use frust::authoring::{PointerButton, PointerEvent};
use std::any::Any;
fn ft_secs(s: f64) -> FrameTime {
FrameTime::from_nanos((s * 1_000_000_000.0) as u64)
}
fn build_view(selected: usize) -> TabsView<Vec<usize>> {
tabs(
[tab().label("One"), tab().label("Two"), tab().label("Three")],
selected,
|s: &mut Vec<usize>, i| s.push(i),
)
}
fn build(view: &TabsView<Vec<usize>>) -> TabsWidget {
let mut counter = 0u64;
View::<Vec<usize>>::build(view, &mut BuildCtx::new(&mut counter))
}
fn layout_themed(w: &mut TabsWidget, width: f64, theme: Option<&Theme>) -> Size {
let bc = BoxConstraints::loose(Size::new(width, f64::INFINITY));
let mut tcx = TextContext::new();
let mut ctx =
LayoutCtx::with_resources(Some(&mut tcx as &mut dyn Any), theme.map(|t| t as &dyn Any));
w.layout(&mut ctx, &bc)
}
fn layout(w: &mut TabsWidget, width: f64) -> Size {
layout_themed(w, width, None)
}
fn ev(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
InputEvent::Pointer(PointerEvent {
phase,
position: Point::new(x, y),
button: PointerButton::Primary,
})
}
#[allow(clippy::ptr_arg)]
fn dispatch(w: &mut TabsWidget, state: &mut Vec<usize>, event: &InputEvent) {
let state_any: &mut dyn Any = state;
let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(300.0, HEIGHT));
w.event(&mut ctx, event);
}
#[test]
fn indicator_spring_matches_material_spatial_default() {
let token = crate::tokens::MaterialSpring::SPATIAL_DEFAULT;
assert_eq!(INDICATOR_SPRING.stiffness, token.stiffness);
assert_eq!(INDICATOR_SPRING.damping_ratio, token.damping_ratio);
assert_eq!(INDICATOR_SPRING.mass, 1.0);
assert_eq!(INDICATOR_SPRING.stiffness, 700.0);
assert_eq!(INDICATOR_SPRING.damping_ratio, 0.9);
}
#[test]
fn indicator_slides_between_two_tabs_start_mid_end() {
let mut motion = IndicatorMotion::new();
assert!(
!motion.retarget(0.0, 40.0),
"the mount frame seeds, it does not animate"
);
assert_eq!(
(motion.left(), motion.width()),
(0.0, 40.0),
"start: unseeded snap"
);
assert!(
motion.retarget(120.0, 60.0),
"a real geometry change starts a spring leg"
);
assert_eq!(
(motion.left(), motion.width()),
(0.0, 40.0),
"mid-flight starts exactly where the previous frame painted"
);
motion.advance(ft_secs(0.0));
motion.advance(ft_secs(0.05));
let (mid_left, mid_width) = (motion.left(), motion.width());
assert!(
mid_left > 0.0 && mid_left < 120.0,
"left sampled strictly between start and end, got {mid_left}"
);
assert!(
mid_width > 40.0 && mid_width < 60.0,
"width sampled strictly between start and end, got {mid_width}"
);
let mut t = 0.05;
for _ in 0..600 {
t += 1.0 / 60.0;
if !motion.advance(ft_secs(t)) {
break;
}
}
assert_eq!(
(motion.left(), motion.width()),
(120.0, 60.0),
"end: settles exactly on the new tab's geometry"
);
}
#[test]
fn a_target_inside_the_dead_band_is_ignored() {
let mut motion = IndicatorMotion::new();
motion.retarget(10.0, 40.0);
assert!(
!motion.retarget(
10.0 + INDICATOR_RETARGET_TOLERANCE / 2.0,
40.0 + INDICATOR_RETARGET_TOLERANCE / 2.0
),
"a sub-tolerance change must not restart the spring"
);
assert!(motion.retarget(10.0 + INDICATOR_RETARGET_TOLERANCE * 2.0, 40.0));
}
#[test]
#[should_panic(expected = "needs 2+ tabs")]
fn fewer_than_two_tabs_panics() {
let _ = tabs::<(), _>([tab().label("Only")], 0, |_s: &mut (), _i| {});
}
#[test]
#[should_panic(expected = "label or an icon")]
fn a_tab_with_neither_label_nor_icon_panics() {
let _ = tabs::<(), _>(
[
Tab {
label: None,
icon: None,
enabled: true,
},
tab().label("B"),
],
0,
|_s: &mut (), _i| {},
);
}
#[test]
fn tabs_always_divide_the_available_width_evenly_no_scroll_variant() {
let many: Vec<Tab> = (0..6).map(|i| tab().label(format!("Tab {i}"))).collect();
let view: TabsView<()> = tabs(many, 0, |_s: &mut (), _i: usize| {});
let mut counter = 0u64;
let mut w = View::<()>::build(&view, &mut BuildCtx::new(&mut counter));
let size = layout(&mut w, 300.0);
assert_eq!(size.width, 300.0);
assert_eq!(size.height, HEIGHT);
let slot_w = 300.0 / 6.0;
for (i, rect) in w.tab_rects.iter().enumerate() {
assert_eq!(rect.x0, i as f64 * slot_w);
assert_eq!(rect.width(), slot_w);
}
}
#[test]
fn primary_indicator_matches_content_width_not_slot_width() {
let view = build_view(0);
let mut w = build(&view);
layout(&mut w, 300.0);
let (left, width) = w.indicator_target(0);
let slot = w.tab_rects[0];
assert!(
width < slot.width(),
"primary indicator narrower than the slot"
);
assert!(left > slot.x0, "content indented from the slot's left edge");
assert!(
left + width < slot.x1,
"content indented from the slot's right edge too"
);
}
#[test]
fn secondary_indicator_spans_the_whole_slot() {
let view = build_view(0).variant(TabsVariant::Secondary);
let mut w = build(&view);
layout(&mut w, 300.0);
let (left, width) = w.indicator_target(0);
let slot = w.tab_rects[0];
assert_eq!((left, width), (slot.x0, slot.width()));
}
#[test]
fn is_controlled_selection_only_moves_via_rebuild() {
let mut w = build(&build_view(0));
layout(&mut w, 300.0);
let mut state = Vec::new();
dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 150.0, 20.0));
dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 150.0, 20.0));
assert_eq!(w.selected, 0, "widget did not self-mutate");
assert_eq!(state, vec![1]);
let next = build_view(1);
let prev = build_view(0);
let mut counter = 0u64;
View::<Vec<usize>>::rebuild(&next, &prev, &mut w, &mut BuildCtx::new(&mut counter));
assert_eq!(w.selected, 1);
}
#[test]
fn tap_reports_the_tapped_tabs_index() {
let mut w = build(&build_view(0));
layout(&mut w, 300.0);
let mut state = Vec::new();
dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 250.0, 20.0));
dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 250.0, 20.0));
assert_eq!(state, vec![2]);
}
#[test]
fn up_outside_the_pressed_tab_does_not_fire() {
let mut w = build(&build_view(0));
layout(&mut w, 300.0);
let mut state = Vec::new();
dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 10.0, 20.0));
dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 250.0, 20.0));
dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 250.0, 20.0));
assert!(state.is_empty());
}
#[test]
fn cancel_clears_the_press_without_firing() {
let mut w = build(&build_view(0));
layout(&mut w, 300.0);
let mut state = Vec::new();
dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 10.0, 20.0));
assert!(w.armed);
dispatch(&mut w, &mut state, &ev(PointerPhase::Cancel, 10.0, 20.0));
assert!(!w.armed);
assert!(state.is_empty());
}
#[test]
fn disabled_tab_swallows_the_press_and_does_not_fire() {
let view: TabsView<Vec<usize>> = tabs(
[tab().label("A"), tab().label("B").enabled(false)],
0,
|s: &mut Vec<usize>, i| s.push(i),
);
let mut counter = 0u64;
let mut w = View::<Vec<usize>>::build(&view, &mut BuildCtx::new(&mut counter));
layout(&mut w, 200.0);
let mut state = Vec::new();
dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 150.0, 20.0));
dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 150.0, 20.0));
assert!(
state.is_empty(),
"a disabled tab must not report a selection"
);
assert!(!w.armed, "a disabled tab must not arm a press at all");
}
#[test]
fn reduce_motion_snaps_the_indicator_and_requests_one_frame_never_layout() {
let mut theme = crate::baseline();
theme.motion.reduce_motion = true;
let prev = build_view(0);
let mut w = build(&prev);
layout_themed(&mut w, 300.0, Some(&theme));
let next = build_view(2);
let mut counter = 0u64;
View::<Vec<usize>>::rebuild(&next, &prev, &mut w, &mut BuildCtx::new(&mut counter));
struct NullScene;
impl PaintScene for NullScene {
fn fill_rect(&mut self, _o: Point, _s: Size, _c: Color) {}
fn draw_text(&mut self, _o: Point, _t: &str) {}
}
let mut scene = NullScene;
let mut ctx = PaintCtx::new(Point::ZERO, Size::new(300.0, HEIGHT)).with_theme(&theme);
w.paint(&mut ctx, &mut scene);
assert!(
ctx.needs_frame(),
"the snap moved the indicator, so one more frame must be requested"
);
assert!(
!ctx.needs_layout(),
"the indicator is paint-only — it must never feed layout"
);
let mut ctx2 = PaintCtx::new(Point::ZERO, Size::new(300.0, HEIGHT)).with_theme(&theme);
w.paint(&mut ctx2, &mut scene);
assert!(
!ctx2.needs_frame(),
"already at rest — no further frame needed"
);
}
#[test]
fn animating_indicator_requests_a_frame_each_advance() {
let prev = build_view(0);
let mut w = build(&prev);
layout(&mut w, 300.0);
let next = build_view(2);
let mut counter = 0u64;
View::<Vec<usize>>::rebuild(&next, &prev, &mut w, &mut BuildCtx::new(&mut counter));
struct NullScene;
impl PaintScene for NullScene {
fn fill_rect(&mut self, _o: Point, _s: Size, _c: Color) {}
fn draw_text(&mut self, _o: Point, _t: &str) {}
}
let mut scene = NullScene;
let mut ctx = PaintCtx::new(Point::ZERO, Size::new(300.0, HEIGHT));
w.paint(&mut ctx, &mut scene);
assert!(
ctx.needs_frame(),
"mid-flight spring keeps asking for frames"
);
assert!(!ctx.needs_layout(), "never feeds layout");
}
#[test]
fn divider_is_stroked_along_the_bottom_edge() {
let mut w = build(&build_view(0));
layout(&mut w, 300.0);
#[derive(Default)]
struct StrokeRecorder {
lines: Vec<(Point, Point, f64)>,
}
impl PaintScene for StrokeRecorder {
fn fill_rect(&mut self, _o: Point, _s: Size, _c: Color) {}
fn draw_text(&mut self, _o: Point, _t: &str) {}
fn stroke_line(&mut self, p0: Point, p1: Point, width: f64, _c: Color) {
self.lines.push((p0, p1, width));
}
}
let mut rec = StrokeRecorder::default();
let mut ctx = PaintCtx::new(Point::ZERO, Size::new(300.0, HEIGHT));
w.paint(&mut ctx, &mut rec);
assert_eq!(rec.lines.len(), 1);
let (p0, p1, width) = rec.lines[0];
assert_eq!(p0.y, HEIGHT);
assert_eq!(p1.y, HEIGHT);
assert_eq!(width, DIVIDER_WIDTH);
}
#[test]
fn semantics_yields_a_tablist_of_tabs_with_selection_and_disabled() {
fn logic(_s: &mut ()) -> TabsView<()> {
tabs::<(), _>(
[tab().label("A"), tab().label("B").enabled(false)],
1,
|_s: &mut (), _i| {},
)
}
let mut root: frust_core::RenderRoot<(), TabsView<()>> = frust_core::RenderRoot::new();
let mut state = ();
root.rebuild(&mut logic, &mut state);
let mut tcx = TextContext::new();
root.layout_with_text(Size::new(300.0, HEIGHT), &mut tcx as &mut dyn Any);
let update = root.semantics();
let tablist = update
.nodes
.iter()
.find(|(_, n)| n.role() == Role::TabList)
.expect("a TabList container node is contributed");
assert_eq!(tablist.1.children().len(), 2);
let tabs_found: Vec<_> = update
.nodes
.iter()
.filter(|(_, n)| n.role() == Role::Tab)
.collect();
assert_eq!(tabs_found.len(), 2);
let a = tabs_found
.iter()
.find(|(_, n)| n.label() == Some("A"))
.expect("tab A is present");
assert_eq!(a.1.is_selected(), Some(false));
assert!(!a.1.is_disabled());
let b = tabs_found
.iter()
.find(|(_, n)| n.label() == Some("B"))
.expect("tab B is present");
assert_eq!(b.1.is_selected(), Some(true));
assert!(b.1.is_disabled());
}
}