use std::rc::Rc;
use frust::Theme;
use frust::authoring::Role;
use frust::authoring::text::{
FontFamily, FontWeight, GenericSlot, TextContext, TextLayout, TextStyle,
};
use frust::authoring::{
BoxConstraints, BuildCtx, ChangeFlags, EventCtx, EventResult, InputEvent, LayoutCtx, PaintCtx,
PaintScene, PointerPhase, ScrollDelta, SemanticsCtx, View, Widget,
};
use frust::input::{TOUCH_SLOP, WHEEL_LINE_PX};
use frust::{AnimationController, Curve, FrameTime};
use kurbo::{Point, Rect, Size, Vec2};
use peniko::Color;
use std::time::Duration;
use crate::press::presses;
const TAB_PAD_X: f64 = 4.0;
const TAB_PAD_Y: f64 = 10.0;
const TAB_GAP: f64 = 20.0;
const TAB_FONT_SIZE: f32 = 12.0;
const INDICATOR_H: f64 = 2.0;
const STRIP_BORDER_H: f64 = 1.0;
const INDICATOR_DURATION: Duration = Duration::from_millis(220);
const INDICATOR_CURVE: Curve = Curve::Cubic(0.34, 1.35, 0.64, 1.0);
const TAB_ACCENT: Color = Color::from_rgb8(0xff, 0xb6, 0x27);
const TAB_INACTIVE: Color = Color::from_rgb8(0x6b, 0x65, 0x56);
const TAB_STRIP_BORDER: Color = Color::from_rgb8(0x2a, 0x2d, 0x33);
fn resolve_tab_colors(theme: Option<&Theme>) -> (Color, Color, Color) {
match theme {
Some(theme) => {
let scheme = theme.scheme();
(
scheme.primary,
scheme.on_surface_variant,
scheme.outline_variant,
)
}
None => (TAB_ACCENT, TAB_INACTIVE, TAB_STRIP_BORDER),
}
}
type OnSelect<State> = Rc<dyn Fn(&mut State, usize)>;
pub struct TabsView<State: 'static> {
labels: Vec<String>,
selected: usize,
on_select: OnSelect<State>,
}
pub fn tabs<State: 'static, F: Fn(&mut State, usize) + 'static>(
labels: Vec<String>,
selected: usize,
on_select: F,
) -> TabsView<State> {
TabsView {
labels,
selected,
on_select: Rc::new(on_select),
}
}
struct GlyphLabel {
content: String,
layout: Option<TextLayout>,
laid_out_style: Option<TextStyle>,
laid_out_max_width: Option<f32>,
}
impl GlyphLabel {
fn new(content: impl Into<String>) -> Self {
Self {
content: content.into(),
layout: None,
laid_out_style: None,
laid_out_max_width: None,
}
}
fn set_content(&mut self, content: impl Into<String>) {
let content = content.into();
if self.content != content {
self.content = content;
self.layout = None;
}
}
fn layout(&mut self, ctx: &mut LayoutCtx, style: &TextStyle, max_width: Option<f32>) -> Size {
if let Some(cached) = &self.layout
&& self.laid_out_max_width == max_width
&& self.laid_out_style.as_ref() == Some(style)
{
return cached.size();
}
let text_ctx = ctx.text_context::<TextContext>();
let laid = text_ctx.layout(&self.content, style, max_width);
let size = laid.size();
self.layout = Some(laid);
self.laid_out_style = Some(style.clone());
self.laid_out_max_width = max_width;
size
}
fn paint(&self, origin: Point, scene: &mut dyn PaintScene) {
if let Some(layout) = &self.layout {
for run in layout.to_scene_runs(origin) {
scene.draw_glyph_run(run);
}
}
}
}
struct TabEntry {
label: GlyphLabel,
text: String,
x: f64,
width: f64,
label_size: Size,
}
fn ui_face() -> FontFamily {
FontFamily::stack_with_generic(["IBM Plex Mono"], GenericSlot::Monospace)
}
fn tab_style(theme: Option<&Theme>, color: Color) -> TextStyle {
TextStyle {
family: theme.map_or_else(ui_face, |t| t.type_scale.title_small.family.clone()),
weight: FontWeight::MEDIUM,
..TextStyle::new(TAB_FONT_SIZE, color)
}
}
fn lerp_geom(from: (f64, f64), to: (f64, f64), t: f64) -> (f64, f64) {
(from.0 + (to.0 - from.0) * t, from.1 + (to.1 - from.1) * t)
}
pub struct TabsWidget {
tabs: Vec<TabEntry>,
selected: usize,
on_select: frust::authoring::ErasedArgCallback<usize>,
height: f64,
indicator: AnimationController,
anim_from: (f64, f64),
anim_to: (f64, f64),
displayed: (f64, f64),
have_geometry: bool,
pending: bool,
pressed: Option<usize>,
captured: Option<usize>,
scroll_x: f64,
overflow: f64,
viewport_w: f64,
down_active: bool,
panning: bool,
down_start: Point,
last_pan: Point,
}
impl<State: 'static> View<State> for TabsView<State> {
type Element = TabsWidget;
fn build(&self, _ctx: &mut BuildCtx<'_>) -> TabsWidget {
let tabs = self
.labels
.iter()
.map(|l| TabEntry {
label: GlyphLabel::new(l.clone()),
text: l.clone(),
x: 0.0,
width: 0.0,
label_size: Size::ZERO,
})
.collect();
TabsWidget {
tabs,
selected: self.selected,
on_select: frust::authoring::erase_callback_arg(&self.on_select),
height: 0.0,
indicator: AnimationController::new(INDICATOR_DURATION).with_curve(INDICATOR_CURVE),
anim_from: (0.0, 0.0),
anim_to: (0.0, 0.0),
displayed: (0.0, 0.0),
have_geometry: false,
pending: false,
pressed: None,
captured: None,
scroll_x: 0.0,
overflow: 0.0,
viewport_w: 0.0,
down_active: false,
panning: false,
down_start: Point::ZERO,
last_pan: Point::ZERO,
}
}
fn rebuild(
&self,
prev: &Self,
element: &mut TabsWidget,
_ctx: &mut BuildCtx<'_>,
) -> ChangeFlags {
element.on_select = frust::authoring::erase_callback_arg(&self.on_select);
let mut flags = ChangeFlags::NONE;
if prev.labels.len() != self.labels.len() {
element.tabs = self
.labels
.iter()
.map(|l| TabEntry {
label: GlyphLabel::new(l.clone()),
text: l.clone(),
x: 0.0,
width: 0.0,
label_size: Size::ZERO,
})
.collect();
element.have_geometry = false;
flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
} else {
for (entry, label) in element.tabs.iter_mut().zip(self.labels.iter()) {
if &entry.text != label {
entry.label.set_content(label.clone());
entry.text = label.clone();
flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
}
}
}
if prev.selected != self.selected {
element.selected = self.selected;
element.pending = true;
flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
}
flags
}
}
impl TabsWidget {
fn target_geometry(&self) -> (f64, f64) {
self.tabs
.get(self.selected)
.map(|t| (t.x, t.width))
.unwrap_or((0.0, 0.0))
}
fn hit_index(&self, pos: Point) -> Option<usize> {
let content = Point::new(pos.x + self.scroll_x, pos.y);
self.tabs.iter().position(|t| {
Rect::from_origin_size(Point::new(t.x, 0.0), Size::new(t.width, self.height))
.contains(content)
})
}
fn set_scroll_x(&mut self, value: f64) {
self.scroll_x = value.clamp(0.0, self.overflow);
}
fn scroll_selected_into_view(&mut self) {
if self.overflow <= 0.0 {
return;
}
let Some(tab) = self.tabs.get(self.selected) else {
return;
};
let left = tab.x;
let right = tab.x + tab.width;
if left < self.scroll_x {
self.set_scroll_x(left);
} else if right > self.scroll_x + self.viewport_w {
self.set_scroll_x(right - self.viewport_w);
}
}
fn advance_indicator(&mut self, now: FrameTime) -> bool {
if self.indicator.is_animating() {
let animating = self.indicator.advance(now);
self.displayed = lerp_geom(self.anim_from, self.anim_to, self.indicator.value());
animating
} else {
self.displayed = self.anim_to;
false
}
}
}
impl Widget for TabsWidget {
fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
let theme = Theme::from_layout_ctx(ctx);
let (accent, inactive, _) = resolve_tab_colors(theme);
let active_style = tab_style(theme, accent);
let inactive_style = tab_style(theme, inactive);
let mut x = 0.0;
let mut content_height = 0.0_f64;
for (i, entry) in self.tabs.iter_mut().enumerate() {
let style = if i == self.selected {
&active_style
} else {
&inactive_style
};
let label_size = entry.label.layout(ctx, style, None);
entry.label_size = label_size;
entry.x = x;
entry.width = label_size.width + TAB_PAD_X * 2.0;
content_height = content_height.max(label_size.height);
x += entry.width + TAB_GAP;
}
let total_width = if self.tabs.is_empty() {
0.0
} else {
x - TAB_GAP
};
let height = content_height + TAB_PAD_Y * 2.0;
self.height = height;
let viewport_w = if bc.max().width.is_finite() {
bc.max().width
} else {
total_width
};
self.viewport_w = viewport_w;
self.overflow = (total_width - viewport_w).max(0.0);
self.set_scroll_x(self.scroll_x);
if self.pending {
self.scroll_selected_into_view();
}
let target = self.target_geometry();
if !self.have_geometry {
self.displayed = target;
self.anim_from = target;
self.anim_to = target;
self.have_geometry = true;
} else if self.pending {
self.anim_from = self.displayed;
self.anim_to = target;
self.indicator =
AnimationController::new(INDICATOR_DURATION).with_curve(INDICATOR_CURVE);
self.indicator.forward();
self.pending = false;
} else if self.indicator.is_animating() {
self.anim_to = target;
} else {
self.displayed = target;
self.anim_from = target;
self.anim_to = target;
}
bc.constrain(Size::new(total_width, height))
}
fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
let theme = Theme::from_paint_ctx(ctx);
let (accent, _inactive, border) = resolve_tab_colors(theme);
if self.advance_indicator(ctx.frame_time()) {
ctx.request_frame();
}
let origin = ctx.origin();
let size = ctx.size();
scene.push_clip(origin, size);
scene.fill_rect(
origin + Vec2::new(0.0, size.height - STRIP_BORDER_H),
Size::new(size.width, STRIP_BORDER_H),
border,
);
for entry in &self.tabs {
let label_y = origin.y + (size.height - entry.label_size.height) / 2.0;
entry.label.paint(
Point::new(origin.x + entry.x + TAB_PAD_X - self.scroll_x, label_y),
scene,
);
}
let (ind_x, ind_w) = self.displayed;
scene.fill_rect(
origin + Vec2::new(ind_x - self.scroll_x, size.height - INDICATOR_H),
Size::new(ind_w, INDICATOR_H),
accent,
);
scene.pop_clip();
}
fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
if let InputEvent::Scroll { delta, .. } = event {
if self.overflow > 0.0 {
let dx = match delta {
ScrollDelta::Lines(x, _) => x * WHEEL_LINE_PX,
ScrollDelta::Pixels(x, _) => *x,
};
if dx != 0.0 {
self.set_scroll_x(self.scroll_x + dx);
ctx.request_redraw();
return EventResult::Handled;
}
}
return EventResult::Ignored;
}
let InputEvent::Pointer(p) = event else {
return EventResult::Ignored;
};
match p.phase {
PointerPhase::Down => {
if !presses(p) {
return EventResult::Ignored;
}
self.down_start = p.position;
self.panning = false;
match self.hit_index(p.position) {
Some(i) => {
self.pressed = Some(i);
self.captured = Some(i);
self.down_active = true;
ctx.capture_pointer();
ctx.request_redraw();
EventResult::Handled
}
None if self.overflow > 0.0 => {
self.pressed = None;
self.captured = None;
self.down_active = true;
ctx.capture_pointer();
EventResult::Handled
}
None => EventResult::Ignored,
}
}
PointerPhase::Move => {
if !self.down_active {
return EventResult::Ignored;
}
if self.panning {
let dx = p.position.x - self.last_pan.x;
self.last_pan = p.position;
self.set_scroll_x(self.scroll_x - dx);
ctx.request_redraw();
} else if self.overflow > 0.0
&& (p.position.x - self.down_start.x).abs() > TOUCH_SLOP
{
self.panning = true;
self.pressed = None;
self.last_pan = p.position;
ctx.request_redraw();
} else if let Some(cap) = self.captured {
self.pressed = (self.hit_index(p.position) == Some(cap)).then_some(cap);
ctx.request_redraw();
}
EventResult::Handled
}
PointerPhase::Up => {
if !self.down_active {
return EventResult::Ignored;
}
if !self.panning
&& let Some(cap) = self.captured
&& self.hit_index(p.position) == Some(cap)
{
(self.on_select)(ctx, cap);
}
self.pressed = None;
self.captured = None;
self.down_active = false;
self.panning = false;
ctx.request_redraw();
EventResult::Handled
}
PointerPhase::Cancel => {
if !self.down_active {
return EventResult::Ignored;
}
self.pressed = None;
self.captured = None;
self.down_active = false;
self.panning = false;
ctx.request_redraw();
EventResult::Handled
}
}
}
fn semantics(&self, ctx: &mut SemanticsCtx) {
ctx.push_container(
Role::TabList,
|_| {},
|ctx| {
for (i, entry) in self.tabs.iter().enumerate() {
ctx.push_node(Role::Tab, |node| {
node.set_label(entry.text.as_str());
node.set_selected(i == self.selected);
});
}
},
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use frust::authoring::PointerButton;
use std::any::Any;
#[derive(Default)]
struct Recorder {
rects: Vec<(Point, Size, Color)>,
glyph_colors: Vec<Color>,
}
impl PaintScene for Recorder {
fn fill_rect(&mut self, o: Point, s: Size, c: Color) {
self.rects.push((o, s, c));
}
fn draw_text(&mut self, _o: Point, _t: &str) {}
fn draw_glyph_run(&mut self, run: frust::authoring::scene::GlyphRun) {
if let peniko::Brush::Solid(c) = run.brush {
self.glyph_colors.push(c);
}
}
}
fn build(view: &TabsView<Vec<usize>>) -> TabsWidget {
let mut counter = 0u64;
View::<Vec<usize>>::build(view, &mut BuildCtx::new(&mut counter))
}
fn labels() -> Vec<String> {
vec![
"panes".into(),
"tabs".into(),
"sessions".into(),
"logs".into(),
]
}
fn layout(w: &mut TabsWidget, theme: Option<&Theme>) -> Size {
let mut tcx = TextContext::new();
let mut lctx = match theme {
Some(t) => {
LayoutCtx::with_resources(Some(&mut tcx as &mut dyn Any), Some(t as &dyn Any))
}
None => LayoutCtx::with_text_context(&mut tcx as &mut dyn Any),
};
w.layout(&mut lctx, &BoxConstraints::loose(Size::new(800.0, 100.0)))
}
fn paint(w: &mut TabsWidget, size: Size, theme: Option<&Theme>) -> Recorder {
let mut rec = Recorder::default();
let mut pctx = match theme {
Some(t) => PaintCtx::new(Point::ZERO, size).with_theme(t),
None => PaintCtx::new(Point::ZERO, size),
};
w.paint(&mut pctx, &mut rec);
rec
}
fn ft_ms(ms: f64) -> FrameTime {
FrameTime::from_nanos((ms * 1_000_000.0) as u64)
}
fn ev(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
InputEvent::Pointer(frust_core::PointerEvent {
phase,
position: Point::new(x, y),
button: PointerButton::Primary,
})
}
#[test]
fn indicator_seeds_under_the_initially_selected_tab() {
let view: TabsView<Vec<usize>> = tabs(labels(), 1, |_s, _i| {});
let mut w = build(&view);
layout(&mut w, None);
assert!(!w.indicator.is_animating());
assert_eq!(w.displayed, (w.tabs[1].x, w.tabs[1].width));
}
#[test]
fn indicator_animates_between_positions_and_lands_exactly_under_active_tab() {
let prev: TabsView<Vec<usize>> = tabs(labels(), 0, |_s, _i| {});
let mut w = build(&prev);
layout(&mut w, None);
let start = w.displayed;
assert_eq!(start, (w.tabs[0].x, w.tabs[0].width));
let next: TabsView<Vec<usize>> = tabs(labels(), 2, |_s, _i| {});
let mut counter = 0u64;
View::<Vec<usize>>::rebuild(&next, &prev, &mut w, &mut BuildCtx::new(&mut counter));
layout(&mut w, None);
let target = (w.tabs[2].x, w.tabs[2].width);
assert!(w.advance_indicator(ft_ms(0.0)));
assert!(w.advance_indicator(ft_ms(80.0)));
let mid = w.displayed;
assert!(
mid.0 > start.0 && mid.0 < target.0 + 1.0,
"x moved toward target: {mid:?}"
);
assert_ne!(mid, start);
assert_ne!(mid, target);
assert!(!w.advance_indicator(ft_ms(500.0)));
assert_eq!(w.displayed, target);
assert!(!w.indicator.is_animating());
}
#[test]
fn selection_is_controlled_never_self_mutating() {
let view: TabsView<Vec<usize>> = tabs(labels(), 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, None);
let mut log: Vec<usize> = Vec::new();
let state_any: &mut dyn Any = &mut log;
let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(400.0, w.height));
let cx = w.tabs[2].x + w.tabs[2].width / 2.0;
w.event(&mut ctx, &ev(PointerPhase::Down, cx, w.height / 2.0));
w.event(&mut ctx, &ev(PointerPhase::Up, cx, w.height / 2.0));
assert_eq!(log, vec![2]);
assert_eq!(w.selected, 0);
}
#[test]
fn cancel_clears_press_without_firing() {
let view: TabsView<Vec<usize>> = tabs(labels(), 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, None);
let mut log: Vec<usize> = Vec::new();
let state_any: &mut dyn Any = &mut log;
let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(400.0, w.height));
let cx = w.tabs[1].x + w.tabs[1].width / 2.0;
w.event(&mut ctx, &ev(PointerPhase::Down, cx, w.height / 2.0));
assert_eq!(w.captured, Some(1));
w.event(&mut ctx, &ev(PointerPhase::Cancel, cx, w.height / 2.0));
assert_eq!(w.captured, None);
w.event(&mut ctx, &ev(PointerPhase::Up, cx, w.height / 2.0));
assert!(log.is_empty());
}
#[test]
fn unthemed_paint_uses_fallback_accent_and_border() {
let view: TabsView<Vec<usize>> = tabs(labels(), 0, |_s, _i| {});
let mut w = build(&view);
let size = layout(&mut w, None);
let rec = paint(&mut w, size, None);
assert_eq!(rec.rects.first().unwrap().2, TAB_STRIP_BORDER);
assert_eq!(rec.rects.last().unwrap().2, TAB_ACCENT);
assert_eq!(rec.glyph_colors[0], TAB_ACCENT);
assert_eq!(rec.glyph_colors[1], TAB_INACTIVE);
}
#[test]
fn glyph_dark_theme_resolves_accent_and_border() {
let theme = crate::baseline();
let view: TabsView<Vec<usize>> = tabs(labels(), 0, |_s, _i| {});
let mut w = build(&view);
let size = layout(&mut w, Some(&theme));
let rec = paint(&mut w, size, Some(&theme));
let scheme = theme.scheme();
assert_eq!(rec.rects.first().unwrap().2, scheme.outline_variant);
assert_eq!(rec.rects.last().unwrap().2, scheme.primary);
assert_eq!(rec.glyph_colors[0], scheme.primary);
}
#[test]
fn glyph_light_theme_resolves_accent_and_border() {
let theme = crate::baseline().with_brightness(frust::Brightness::Light);
let view: TabsView<Vec<usize>> = tabs(labels(), 0, |_s, _i| {});
let mut w = build(&view);
let size = layout(&mut w, Some(&theme));
let rec = paint(&mut w, size, Some(&theme));
let scheme = theme.scheme();
assert_eq!(rec.rects.last().unwrap().2, scheme.primary);
assert_eq!(rec.rects.first().unwrap().2, scheme.outline_variant);
}
#[test]
fn semantics_yields_a_tablist_of_tabs_with_selection() {
fn logic(_s: &mut ()) -> TabsView<()> {
tabs(
vec!["panes".into(), "tabs".into(), "logs".into()],
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(400.0, 60.0), &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");
assert_eq!(tablist.1.children().len(), 3);
let selected = update
.nodes
.iter()
.find(|(_, n)| n.role() == Role::Tab && n.label() == Some("tabs"))
.expect("the selected tab");
assert_eq!(selected.1.is_selected(), Some(true));
let unselected = update
.nodes
.iter()
.find(|(_, n)| n.role() == Role::Tab && n.label() == Some("panes"))
.expect("an unselected tab");
assert_eq!(unselected.1.is_selected(), Some(false));
}
fn layout_w(w: &mut TabsWidget, theme: Option<&Theme>, width: f64) -> Size {
let mut tcx = TextContext::new();
let mut lctx = match theme {
Some(t) => {
LayoutCtx::with_resources(Some(&mut tcx as &mut dyn Any), Some(t as &dyn Any))
}
None => LayoutCtx::with_text_context(&mut tcx as &mut dyn Any),
};
w.layout(&mut lctx, &BoxConstraints::loose(Size::new(width, 100.0)))
}
fn scroll_x_ev(dx: f64) -> InputEvent {
InputEvent::Scroll {
position: Point::new(1.0, 1.0),
delta: ScrollDelta::Pixels(dx, 0.0),
}
}
fn dispatch(w: &mut TabsWidget, events: &[InputEvent]) -> Vec<usize> {
let mut log: Vec<usize> = Vec::new();
let sa: &mut dyn Any = &mut log;
let mut ctx = EventCtx::new(sa, Point::ZERO, Size::new(w.viewport_w, w.height));
for e in events {
w.event(&mut ctx, e);
}
log
}
#[test]
fn narrow_width_overflows_and_pan_clamps_to_range() {
let view: TabsView<Vec<usize>> = tabs(labels(), 0, |_s, _i| {});
let mut w = build(&view);
layout_w(&mut w, None, 30.0);
assert!(w.overflow > 0.0, "a 30px viewport overflows four tabs");
assert_eq!(w.scroll_x, 0.0, "starts unpanned");
dispatch(&mut w, &[scroll_x_ev(10_000.0)]);
assert_eq!(w.scroll_x, w.overflow);
dispatch(&mut w, &[scroll_x_ev(-10_000.0)]);
assert_eq!(w.scroll_x, 0.0);
}
#[test]
fn drag_past_slop_pans_and_does_not_select() {
let view: TabsView<Vec<usize>> = tabs(labels(), 0, |s: &mut Vec<usize>, i| s.push(i));
let mut w = build(&view);
layout_w(&mut w, None, 30.0);
let hy = w.height / 2.0;
let log = dispatch(
&mut w,
&[
ev(PointerPhase::Down, 25.0, hy),
ev(PointerPhase::Move, 0.0, hy),
ev(PointerPhase::Move, -30.0, hy),
ev(PointerPhase::Up, -30.0, hy),
],
);
assert!(w.scroll_x > 0.0, "the drag panned the strip");
assert!(log.is_empty(), "a pan never fires on_select");
assert_eq!(w.selected, 0, "controlled selection is untouched");
}
#[test]
fn sub_slop_release_still_selects() {
let view: TabsView<Vec<usize>> = tabs(labels(), 0, |s: &mut Vec<usize>, i| s.push(i));
let mut w = build(&view);
layout_w(&mut w, None, 30.0);
let cx = w.tabs[0].x + w.tabs[0].width / 2.0;
let hy = w.height / 2.0;
let log = dispatch(
&mut w,
&[
ev(PointerPhase::Down, cx, hy),
ev(PointerPhase::Move, cx + 5.0, hy),
ev(PointerPhase::Up, cx, hy),
],
);
assert_eq!(w.scroll_x, 0.0, "a sub-slop press does not pan");
assert_eq!(log, vec![0], "the tab tap still fires");
}
#[test]
fn hit_test_respects_scroll_x() {
let view: TabsView<Vec<usize>> = tabs(labels(), 0, |_s, _i| {});
let mut w = build(&view);
layout_w(&mut w, None, 30.0);
w.set_scroll_x(w.tabs[1].x);
assert_eq!(w.scroll_x, w.tabs[1].x, "the target pan is within range");
assert_eq!(w.hit_index(Point::new(1.0, w.height / 2.0)), Some(1));
}
#[test]
fn confirmed_selection_scrolls_into_view() {
let prev: TabsView<Vec<usize>> = tabs(labels(), 0, |_s, _i| {});
let mut w = build(&prev);
layout_w(&mut w, None, 30.0);
assert_eq!(w.scroll_x, 0.0);
let last = labels().len() - 1;
let next: TabsView<Vec<usize>> = tabs(labels(), last, |_s, _i| {});
let mut counter = 0u64;
View::<Vec<usize>>::rebuild(&next, &prev, &mut w, &mut BuildCtx::new(&mut counter));
layout_w(&mut w, None, 30.0);
assert_eq!(w.scroll_x, w.overflow);
let right = w.tabs[last].x + w.tabs[last].width;
assert!(
right <= w.scroll_x + w.viewport_w + 0.01,
"last tab visible"
);
}
#[test]
fn wheel_delta_pans_when_overflowing() {
let view: TabsView<Vec<usize>> = tabs(labels(), 0, |_s, _i| {});
let mut w = build(&view);
layout_w(&mut w, None, 30.0);
dispatch(&mut w, &[scroll_x_ev(20.0)]);
assert_eq!(w.scroll_x, 20.0, "a horizontal wheel delta pans");
}
#[test]
fn fits_case_has_no_overflow_and_ignores_wheel() {
let view: TabsView<Vec<usize>> = tabs(labels(), 0, |_s, _i| {});
let mut w = build(&view);
layout_w(&mut w, None, 800.0);
assert_eq!(w.overflow, 0.0, "four tabs fit in 800px");
dispatch(&mut w, &[scroll_x_ev(50.0)]);
assert_eq!(w.scroll_x, 0.0);
}
#[cfg(feature = "bundled-fonts")]
fn two_tabs(_: &mut ()) -> TabsView<()> {
tabs(vec!["shell".into(), "logs".into()], 0, |_s: &mut (), _i| {})
}
#[cfg(feature = "bundled-fonts")]
#[test]
fn labels_paint_in_their_role_face_under_the_glyph_theme() {
use crate::badge::typeface_probe::{Face, painted_faces};
let faces = painted_faces(two_tabs, crate::baseline(), Size::new(400.0, 60.0));
assert_eq!(faces, [Face::PlexMono; 2]);
}
#[cfg(feature = "bundled-fonts")]
#[test]
fn labels_follow_a_live_theme_family_swap() {
use crate::badge::typeface_probe::{Face, faces_across_a_live_swap};
let (before, after) = faces_across_a_live_swap(two_tabs, Size::new(400.0, 60.0));
assert_eq!(before, [Face::PlexMono; 2]);
assert_eq!(after, [Face::SpaceMono; 2]);
}
}