Skip to main content

frust_material/
lib.rs

1//! `frust-material`: the Material 3 (+Expressive) design-system catalog
2//! plugin.
3//!
4//! A **design-system plugin**: it sits beside the `frust` facade in an app's
5//! own dependency list (never inside it — see `docs/ARCHITECTURE.md`'s
6//! Facade/plugin boundary), built entirely on `frust`'s public
7//! `default-features = false` surface (`frust::authoring` plus the
8//! whole-crate `kurbo`/`peniko` valves) so it proves the same external
9//! design-system contract `examples/design-system-sample` does.
10//!
11//! # Flat symbol surface
12//!
13//! Every widget/spec type below is flat re-exported at the crate root — an
14//! app never names a submodule directly, e.g. `frust_material::AppBar`,
15//! `frust_material::card`, `frust_material::show_dialog`. This mirrors how
16//! the in-tree catalog it was extracted from is consumed through the `frust`
17//! facade (`frust::AppBar`, `frust::card`, ...).
18//!
19//! `RoundedPolygon` is the flat re-export of [`shapes::RoundedPolygon`], the
20//! feature-point geometry engine ported from `material_new_shapes` — every
21//! shape-morphing consumer in this crate, including
22//! [`mod@loading_indicator`]'s shape sequence, builds on this one engine;
23//! the older, since-removed radial-model polygon module has no remaining
24//! callers.
25//!
26//! [`icons`] is the one deliberate exception: its 88 generated
27//! [`frust::IconSource`] constants stay namespaced (`frust_material::icons::CHECK`),
28//! not flat re-exported at the crate root — see that module's own doc for
29//! provenance and the regen command.
30//!
31//! [`selection`]'s pure index-set controller functions (`is_selected`,
32//! `toggle`, `select_all`, ...) stay namespaced the same way
33//! (`frust_material::selection::toggle`) — their names are too generic to
34//! flatten safely without shadowing. The module's own widget types
35//! ([`selection_host`], [`selection_app_bar`]) are flat re-exported as
36//! usual.
37//!
38//! [`date_picker`]'s pure grid-geometry functions (`year_grid_height`,
39//! `calendar_day_view_height`, `year_span`, ...) stay namespaced for the same
40//! reason (`frust_material::date_picker::grid::year_span`); its widgets, value
41//! types ([`MaterialDate`], [`DateRange`], [`DatePickerState`]) and metric
42//! constants are flat re-exported as usual.
43//!
44//! # `install()`: this design system's one-line installer
45//!
46//! [`install`] seeds [`frust::set_default_theme`] with this crate's
47//! [`baseline`] token bundle and registers its bundled Roboto Flex and Roboto
48//! Mono typefaces so Material typography resolves out of the box. Call it from
49//! [`frust::app!`](frust::app)'s `setup = { .. }` block, **before** the shell
50//! is constructed — the one point a shell reads the default-theme slot:
51//!
52//! ```no_run
53//! # use frust::{AnyView, Component, any, text};
54//! # #[derive(Default)]
55//! # struct MyApp;
56//! # impl Component for MyApp {
57//! #     type State = ();
58//! #     fn init(&self) -> Self::State {}
59//! #     fn build(&self, _state: &mut Self::State) -> AnyView<Self::State> { any(text("hi")) }
60//! # }
61//! frust::app!(MyApp, setup = { frust_material::install(); });
62//! # fn main() {}
63//! ```
64//!
65//! A call after shell construction takes effect only on a later
66//! `clear_app_theme`-driven reseed, which may never happen — so a late call
67//! silently does nothing visible (the same timing contract
68//! `frust::glyph_theme::install` and `frust_shadcn::install` document).
69
70pub mod appbar;
71pub mod badge;
72pub mod button;
73pub mod button_group;
74pub mod card;
75pub mod card_list;
76pub mod carousel;
77pub mod checkbox;
78pub mod chips;
79pub mod date_picker;
80pub mod dialog;
81pub mod dismissible;
82pub mod divider;
83pub mod dropdown;
84pub mod expandable_list;
85pub mod fab;
86pub mod fab_menu;
87pub mod icon_button;
88pub mod icons;
89pub mod interaction;
90pub mod list_item;
91pub mod list_view;
92pub mod loading_indicator;
93pub mod menu;
94pub mod navbar;
95pub mod navigation_drawer;
96pub mod navigation_rail;
97pub mod overlay;
98mod press;
99pub mod progress;
100pub mod radio;
101pub mod refresh_indicator;
102pub mod search;
103pub mod segmented_button;
104pub mod selection;
105pub mod shapes;
106pub mod sheet;
107pub mod side_sheet;
108pub mod slider;
109pub mod snackbar;
110pub mod split_button;
111pub mod state_layer;
112pub mod switch;
113pub mod tabs;
114pub mod text_field;
115pub mod time_picker;
116pub mod toggle_button;
117mod tokens;
118pub mod toolbar;
119pub mod tooltip;
120
121pub use appbar::{
122    AppBar, AppBarCollapse, AppBarDensity, AppBarMetrics, AppBarShapeFamily, AppBarVariant,
123    AppBarView, AppBarWidget, BottomAppBar, BottomAppBarView, BottomAppBarWidget, SearchAppBar,
124    SliverAppBar, SliverAppBarView, SliverAppBarWidget, app_bar, bottom_app_bar, search_app_bar,
125    sliver_app_bar,
126};
127pub use badge::{BadgeAlignment, BadgeView, BadgeWidget, badge};
128pub use button::{
129    Button, ButtonDecoration, ButtonShape, ButtonSize, ButtonSurface, ButtonVariant, ButtonView,
130    ButtonWidget, ContentMetrics, DecorationOutcome, GradientAlignment, GradientButtonDecoration,
131    GradientProperty, GradientSpec, GradientStates, IconAlignment, LinearGradientSpec,
132    OverflowObserver, OverflowStrategy, RadialGradientSpec, SweepGradientSpec, button,
133    button_with_icon, constant_gradient, elevated_button, filled_button, implied_stops,
134    outlined_button, text_button, tonal_button,
135};
136pub use button_group::{
137    ButtonGroup, ButtonGroupAction, ButtonGroupDensity, ButtonGroupDirection, ButtonGroupOverflow,
138    ButtonGroupType, ButtonGroupView, ButtonGroupWidget, button_group, button_group_action,
139    button_group_actions, button_group_icon_action,
140};
141pub use card::{
142    CardVariant, CardView, CardWidget, card, elevated_card, filled_card, outlined_card,
143};
144pub use card_list::{
145    CARD_LIST_GAP, CARD_LIST_INNER_RADIUS, CARD_LIST_ITEM_PADDING, CARD_LIST_OUTER_RADIUS,
146    CardListView, CardListWidget, CardPosition, card_list, card_list_items, card_position,
147    card_radii,
148};
149pub use carousel::{
150    CAROUSEL_ITEM_PADDING, CAROUSEL_ITEM_RADIUS, CarouselAxis, CarouselChange, CarouselLayout,
151    CarouselView, CarouselWidget, HeroAlignment, UNCONTAINED_ITEM_EXTENT,
152    UNCONTAINED_SHRINK_EXTENT, carousel, contained_carousel, hero_carousel, uncontained_carousel,
153};
154pub use checkbox::{Checkbox, CheckboxView, CheckboxWidget, checkbox, tristate_checkbox};
155pub use chips::{
156    AssistChip, AssistChipView, AssistChipWidget, FilterChip, FilterChipView, FilterChipWidget,
157    InputChip, InputChipView, InputChipWidget, SuggestionChip, SuggestionChipView,
158    SuggestionChipWidget, assist_chip, filter_chip, input_chip, suggestion_chip,
159};
160pub use date_picker::{
161    ACTIONS_MIN_HEIGHT, ARROW_ICON_SIZE, ARROW_PADDING, CALENDAR_PADDING, CALENDAR_WIDTH,
162    CalendarDatePicker, CalendarDatePickerWidget, DAY_GRID_TOP_PADDING, DAY_ROW_HEIGHT, DAY_SIZE,
163    DAYS_PER_WEEK, DIALOG_PORTRAIT_CALENDAR_WIDTH, DIALOG_PORTRAIT_INPUT_WIDTH,
164    DISABLED_DAY_OPACITY, DateInputError, DateInputField, DateInputFieldWidget, DatePickerDialog,
165    DatePickerEntryMode, DatePickerMode, DatePickerState, DatePickerStrings, DateRange,
166    HEADER_PORTRAIT_HEIGHT, MAX_DAY_PICKER_HEIGHT, MAX_DAY_PICKER_ROW_COUNT, MAX_YEAR, MIN_YEAR,
167    MONTH_NAV_BUTTONS_WIDTH, MONTH_SCROLL_DURATION, MaterialDate, MonthGrid, OnDatePickerChange,
168    RANGE_HIGHLIGHT_ALPHA, SUB_HEADER_HEIGHT, SUB_HEADER_START_INSET, WEEKDAY_ROW_HEIGHT,
169    YEAR_COLUMN_COUNT, YEAR_GRID_PADDING, YEAR_ROW_HEIGHT, YEAR_ROW_SPACING, calendar_date_picker,
170    date_input_field, date_picker_dialog, days_in_month, is_leap_year, parse_bounded,
171    show_date_picker,
172};
173pub use dialog::{
174    DialogView, FullScreenDialogView, SelectionDialogView, dialog, full_screen_dialog,
175    selection_dialog, show_dialog, show_full_screen_dialog, show_selection_dialog,
176};
177pub use dismissible::{
178    DISMISS_ACTION_GAP, DISMISS_BACKGROUND_RADIUS, DISMISS_COLLAPSE_HANDOFF,
179    DISMISS_FLING_VELOCITY, DISMISS_FLY_OVERSHOOT, DISMISS_ICON_SIZE, DISMISS_THRESHOLD,
180    DismissBackground, DismissDirection, DismissibleView, DismissibleWidget, dismiss_background,
181    dismissible, speed_multiplier,
182};
183pub use divider::{DividerView, DividerWidget, divider};
184pub use dropdown::{
185    DROPDOWN_CHIP_H_PADDING, DROPDOWN_CHIP_ICON_SIZE, DROPDOWN_CHIP_LABEL_GAP,
186    DROPDOWN_CHIP_RADIUS, DROPDOWN_CHIP_RUN_SPACING, DROPDOWN_CHIP_SPACING,
187    DROPDOWN_CHIP_V_PADDING, DROPDOWN_CONTENT_PADDING, DROPDOWN_EMPTY_TEXT,
188    DROPDOWN_FIELD_H_PADDING, DROPDOWN_FIELD_HOVER_ALPHA, DROPDOWN_FIELD_LOADING_SIZE,
189    DROPDOWN_FIELD_LOADING_STROKE, DROPDOWN_FIELD_PRESSED_ALPHA, DROPDOWN_FIELD_V_PADDING,
190    DROPDOWN_HINT, DROPDOWN_ICON_GAP, DROPDOWN_ICON_SIZE, DROPDOWN_ITEM_GAP,
191    DROPDOWN_ITEM_H_PADDING, DROPDOWN_ITEM_HOVER_RADIUS, DROPDOWN_ITEM_INNER_RADIUS,
192    DROPDOWN_ITEM_OUTER_RADIUS, DROPDOWN_ITEM_PRESSED_RADIUS, DROPDOWN_ITEM_V_PADDING,
193    DROPDOWN_LOADING_PADDING, DROPDOWN_MESSAGE_PADDING, DROPDOWN_PANEL_GAP,
194    DROPDOWN_PANEL_MAX_HEIGHT, DROPDOWN_SEARCH_HINT, DropdownColors, DropdownFieldView,
195    DropdownFieldWidget, DropdownItem, DropdownPanelView, DropdownPanelWidget, DropdownView,
196    dropdown, dropdown_field, dropdown_filter, dropdown_item, dropdown_matches, dropdown_panel,
197};
198pub use expandable_list::{
199    EXPANDABLE_GAP, EXPANDABLE_HOVER_RADIUS, EXPANDABLE_ICON_ROTATION, EXPANDABLE_INNER_RADIUS,
200    EXPANDABLE_OUTER_RADIUS, EXPANDABLE_PRESSED_RADIUS, ExpandMode, ExpandableItem,
201    ExpandableListView, ExpandableListWidget, REVEAL_SPRING, expandable_item, expandable_list,
202    is_expanded, toggle_expanded,
203};
204pub use fab::{FabColor, FabSize, FabView, FabWidget, extended_fab, fab};
205pub use fab_menu::{FabMenu, FabMenuItem, FabMenuView, FabMenuWidget, fab_menu, fab_menu_item};
206pub use icon_button::{
207    BadgeValue, IconButton, IconButtonShape, IconButtonSize, IconButtonVariant, IconButtonView,
208    IconButtonWidget, IconButtonWidth, filled_icon_button, icon_button, outlined_icon_button,
209    tonal_icon_button,
210};
211pub use interaction::{HapticSignal, InteractionState, MaterialHaptics};
212pub use list_item::{
213    ListItem, ListItemLines, ListItemWidget, ONE_LINE_HEIGHT, THREE_LINE_HEIGHT, TWO_LINE_HEIGHT,
214    list_item,
215};
216pub use loading_indicator::{
217    LoadingIndicator, LoadingIndicatorVariant, LoadingIndicatorView, LoadingIndicatorWidget,
218    loading_indicator,
219};
220pub use menu::{
221    MenuAction, MenuColorStyle, MenuColors, MenuEntry, MenuGroup, MenuIcon, MenuNode,
222    MenuPanelView, MenuPanelWidget, MenuSelectable, MenuSelection, MenuSubmenu, MenuToggleable,
223    MenuView, menu, menu_entry, menu_group, menu_panel, menu_selectable, menu_submenu,
224    menu_toggleable,
225};
226pub use navbar::{
227    HEIGHT_MEDIUM as NAV_BAR_HEIGHT_MEDIUM, HEIGHT_SMALL as NAV_BAR_HEIGHT_SMALL,
228    INDICATOR_H as NAV_BAR_INDICATOR_H, INDICATOR_W as NAV_BAR_INDICATOR_W,
229    LEAD_SPRING as NAV_INDICATOR_LEAD_SPRING, NavBarIndicatorStyle, NavBarLabelBehavior,
230    NavBarSize, NavItem, NavigationBar, NavigationBarView, NavigationBarWidget,
231    TRAIL_SPRING as NAV_INDICATOR_TRAIL_SPRING, UNDERLINE_THICKNESS as NAV_BAR_UNDERLINE_THICKNESS,
232    nav_item, navigation_bar,
233};
234pub use navigation_drawer::{
235    DRAWER_WIDTH, DrawerContentView, DrawerContentWidget, DrawerDestination, DrawerSection,
236    NavigationDrawer, NavigationDrawerView, drawer_destination, drawer_section, navigation_drawer,
237    navigation_drawer_content, show_navigation_drawer,
238};
239pub use navigation_rail::{
240    NavigationRail, NavigationRailModality, NavigationRailType, NavigationRailView,
241    NavigationRailWidget, RAIL_COLLAPSED_WIDTH, RAIL_EXPAND_CURVE, RAIL_EXPAND_DURATION,
242    RAIL_EXPANDED_MAX_WIDTH, RAIL_EXPANDED_MIN_WIDTH, RAIL_ITEM_COLLAPSED_HEIGHT,
243    RAIL_ITEM_EXPANDED_HEIGHT, RAIL_LEAD_SPRING, RAIL_TRAIL_SPRING, RailDestination, RailFab,
244    RailLabelBehavior, RailSection, navigation_rail, rail_destination, rail_fab, rail_section,
245};
246pub use overlay::{
247    ANCHORED_ENTER_SCALE, AnchoredOverlayView, AnchoredOverlayWidget, ModalDismiss,
248    OVERLAY_ANCHOR_GAP, OVERLAY_DIALOG_MAX_WIDTH, OVERLAY_DIALOG_MIN_WIDTH, OVERLAY_EDGE_FRACTION,
249    OVERLAY_FLING_VELOCITY, OVERLAY_HANDLE_RESERVE, OVERLAY_SCRIM_ALPHA,
250    OVERLAY_SHEET_MAX_HEIGHT_FRACTION, OVERLAY_SIDE_SHEET_MAX_WIDTH, OverlayAlign, OverlayAnchor,
251    OverlayAnchorView, OverlayAnchorWidget, OverlayBorder, OverlayContainer, OverlayCorners,
252    OverlayElevation, OverlayEntrance, OverlayExtent, OverlayGeometry, OverlayLimit,
253    OverlayModalConfig, OverlayModalContent, OverlayModalView, OverlayModalWidget,
254    OverlayPlacement, OverlayRole, OverlaySide, anchored_overlay, overlay_anchor, overlay_modal,
255    place_anchored, show_overlay_modal,
256};
257pub use progress::{
258    AmplitudeCurve, CircularProgress, CircularProgressView, CircularProgressWidget,
259    CircularWavyProgress, LinearProgress, LinearProgressView, LinearProgressWidget,
260    LinearWavyProgress, ProgressSize, ProgressValue, circular_progress, circular_wavy_progress,
261    linear_progress, linear_wavy_progress,
262};
263pub use radio::{Radio, RadioView, RadioWidget, radio};
264pub use refresh_indicator::{RefreshIndicatorView, RefreshIndicatorWidget, refresh_indicator};
265pub use search::{
266    DockedSearchViewView, FULL_SCREEN_HEADER_HEIGHT, SEARCH_BAR_MIN_HEIGHT, SEARCH_BAR_MIN_WIDTH,
267    SEARCH_VIEW_COMPACT_MAX_WIDTH, SEARCH_VIEW_HEIGHT_FRACTION, SEARCH_VIEW_MIN_HEIGHT,
268    SEARCH_VIEW_MIN_WIDTH, SearchBarView, SearchBarWidget, SearchView, SearchViewContentWidget,
269    SearchViewMode, SearchViewView, search_bar, search_view, show_search_view,
270};
271pub use segmented_button::{
272    MAX_SEGMENTS, MIN_SEGMENTS, Segment, SegmentedButton, SegmentedButtonView,
273    SegmentedButtonWidget, segment, segmented_button,
274};
275pub use selection::{
276    SelectionAppBarView, SelectionAppBarWidget, SelectionHostView, SelectionHostWidget,
277    selection_app_bar, selection_host,
278};
279pub use shapes::{CornerRounding, RoundedPolygon};
280pub use sheet::{BottomSheetView, BottomSheetWidget, bottom_sheet, show_bottom_sheet};
281pub use side_sheet::{SIDE_SHEET_WIDTH, SideSheetView, show_side_sheet, side_sheet};
282pub use slider::{
283    RangeSliderView, RangeSliderWidget, Slider, SliderIconPosition, SliderRange, SliderTrackIcons,
284    SliderTrackKind, SliderView, SliderWidget, centered_slider, range_slider, slider,
285    vertical_centered_slider, vertical_slider, wavy_centered_slider, wavy_range_slider,
286    wavy_slider,
287};
288pub use snackbar::{
289    SnackbarController, SnackbarHostView, SnackbarHostWidget, SnackbarMessage, snackbar,
290    snackbar_host,
291};
292pub use split_button::{
293    CHEVRON_OPEN_TURNS, CHEVRON_ROTATION_DURATION, SPLIT_ELEVATED_INNER_GAP,
294    SPLIT_FOCUS_RING_OUTSET, SPLIT_INNER_GAP, SPLIT_MIN_TAP_TARGET, SPLIT_SHAPE_SPRING,
295    SplitButton, SplitButtonItem, SplitButtonMenuRoute, SplitButtonMenuStyle, SplitButtonShape,
296    SplitButtonSize, SplitButtonTrailingAlignment, SplitButtonVariant, SplitButtonView,
297    SplitButtonWidget, TRAILING_SELECTED_CORNER_PERCENT, split_button, split_button_item,
298};
299pub use switch::{Switch, SwitchView, SwitchWidget, switch};
300pub use tabs::{MIN_TABS, Tab, Tabs, TabsVariant, TabsView, TabsWidget, tab, tabs};
301pub use text_field::{TextField, TextFieldVariant, TextFieldView, TextFieldWidget, text_field};
302pub use time_picker::{
303    DIAL_SIZE, DIAL_SLOTS, DIALOG_LANDSCAPE_WIDTH, DIALOG_PORTRAIT_WIDTH, HEADER_LANDSCAPE_WIDTH,
304    TimeDialView, TimeDialWidget, TimeEntry, TimeInputView, TimeInputWidget, TimeOfDay, TimePicker,
305    TimePickerEntryMode, TimePickerMode, TimePickerOrientation, TimePickerStrings, TimePickerView,
306    show_time_picker, time_dial, time_input, time_picker,
307};
308pub use toggle_button::{
309    ToggleButton, ToggleButtonSize, ToggleButtonView, ToggleButtonWidget, elevated_toggle_button,
310    filled_toggle_button, outlined_toggle_button, text_toggle_button, toggle_button,
311    tonal_toggle_button,
312};
313pub use toolbar::{
314    DockedToolbar, FloatingToolbar, TOOLBAR_DOCKED_PAD_X, TOOLBAR_EXPAND_SPRING,
315    TOOLBAR_FAB_BASELINE, TOOLBAR_FAB_MEDIUM, TOOLBAR_FLOATING_PAD, TOOLBAR_GAP, TOOLBAR_HEIGHT,
316    TOOLBAR_MAX_INLINE_ACTIONS, TOOLBAR_REVEAL_FADE_START, TOOLBAR_SCREEN_OFFSET,
317    TOOLBAR_SETTLE_VELOCITY, TOOLBAR_TO_FAB_GAP, ToolbarAction, ToolbarColorStyle, ToolbarColors,
318    ToolbarElevation, ToolbarExitDirection, ToolbarFabPosition, ToolbarMorph, ToolbarPartition,
319    ToolbarScrollHide, ToolbarSize, ToolbarVariant, ToolbarView, ToolbarWidget, docked_toolbar,
320    floating_toolbar, partition_actions, toolbar_action,
321};
322pub use tooltip::{
323    RichTooltipView, TooltipAction, TooltipHover, TooltipTriggerView, TooltipTriggerWidget,
324    TooltipView, rich_tooltip, tooltip, tooltip_action, tooltip_trigger,
325};
326
327pub use tokens::{CorePalette, Hct, TonalPalette, from_seed, theme_from_seed};
328pub use tokens::{
329    MaterialDimensions, MaterialMotion, MaterialSemanticColors, MaterialSpacing, MaterialSpring,
330    MaterialTokens, baseline, color_scheme_dark, color_scheme_light, elevation, motion_scheme,
331    shape_scale, status_palette, type_scale,
332};
333
334/// Make Material 3 this app's starting design system.
335///
336/// Two process-global pushes, both public `frust` seams:
337///
338/// 1. `frust::set_default_theme(`[`baseline`]`())` — the *base* a shell
339///    seeds itself with instead of its built-in `Theme::neutral()` fallback.
340///    Deliberately not `frust::set_app_theme`: a seeded default does not pin
341///    brightness, so an app installed this way still follows system dark mode.
342/// 2. `frust::register_app_fonts` for both bundled faces (Roboto Flex, Roboto
343///    Mono), so the type scale's Roboto Flex stack and any mono-text component
344///    actually resolve. The faces are compiled in behind the crate's
345///    `bundled-fonts` feature (default on); an app that sets
346///    `default-features = false` on this dependency compiles in no font
347///    bytes, `install()` registers neither face, and text falls back to the
348///    platform's system faces through fontique (Roboto on Android).
349///
350/// # Timing: must run before the first frame
351///
352/// A shell reads the default-theme slot and drains the font registry **once, at
353/// construction**, before its first rebuild. A call after that takes effect only
354/// on a later `clear_app_theme`-driven reseed, which may never happen — so a late
355/// call silently does nothing visible.
356///
357/// The supported way to get the timing right on all three platforms is
358/// `frust::app!`'s setup block, which runs immediately before the root
359/// component's `Component::init` and therefore before any shell construction —
360/// see the crate docs for the full example.
361///
362/// # Thread contract and repeat calls
363///
364/// Both underlying seams are plain `Mutex`-guarded process-globals callable from
365/// any thread. Calling `install` twice is harmless but wasteful: the second
366/// `set_default_theme` replaces an identical value, and the font bytes are pushed
367/// (and later re-registered, shadowing the same family names) a second time. Call
368/// it once.
369pub fn install() {
370    frust::set_default_theme(baseline());
371    for bytes in tokens::font_data() {
372        frust::register_app_fonts(bytes.to_vec());
373    }
374}
375
376/// Coverage for the generated [`icons`] module — deliberately hand-written
377/// (not part of `scripts/gen_icons.py`'s output) since a generated file's
378/// content is machine-owned end to end.
379#[cfg(test)]
380mod icons_tests {
381    use frust::IconData;
382
383    use crate::icons;
384
385    /// The exact count `plugins/material/scripts/gen_icons.py`'s `ICON_SET`
386    /// enumerates — the 88 distinct `M3EIcons.*` names `material_3_expressive`'s
387    /// components and example app reference. A change here should come from
388    /// a regen (a new/removed `ICON_SET` entry), never a hand-edit.
389    const EXPECTED_ICON_COUNT: usize = 88;
390
391    #[test]
392    fn all_has_the_expected_count() {
393        assert_eq!(icons::ALL.len(), EXPECTED_ICON_COUNT);
394    }
395
396    #[test]
397    fn every_generated_icon_source_resolves_to_a_non_empty_path() {
398        for source in icons::ALL {
399            let data: IconData = (*source).into();
400            let (path, design) = data.resolve();
401            assert_eq!(design, 24.0);
402            assert!(
403                !path.elements().is_empty(),
404                "generated icon `{}` resolved to an empty path",
405                source.d
406            );
407        }
408    }
409
410    #[test]
411    fn spot_check_check_search_arrow_back() {
412        for source in [icons::CHECK, icons::SEARCH, icons::ARROW_BACK] {
413            let data: IconData = source.into();
414            let (path, design) = data.resolve();
415            assert_eq!(design, 24.0);
416            assert!(
417                !path.elements().is_empty(),
418                "spot-checked icon `{}` resolved to an empty path",
419                source.d
420            );
421        }
422    }
423
424    #[test]
425    fn no_two_entries_share_identical_path_data() {
426        let mut seen = std::collections::HashSet::new();
427        for source in icons::ALL {
428            assert!(
429                seen.insert(source.d),
430                "two icons::ALL entries share identical path data: {}",
431                source.d
432            );
433        }
434    }
435}
436
437#[cfg(test)]
438mod baseline_tests {
439    use frust::NativeTypefaces;
440
441    /// The baseline theme carries both NativeTypefaces extension (for native
442    /// controls) and the Material tokens.
443    #[test]
444    fn baseline_attaches_native_typefaces_and_material_tokens() {
445        let theme = crate::baseline();
446        assert!(
447            theme.extension::<NativeTypefaces>().is_some(),
448            "baseline theme must attach NativeTypefaces for native-widget font resolution"
449        );
450        assert!(
451            theme.extension::<crate::MaterialTokens>().is_some(),
452            "baseline theme must attach MaterialTokens"
453        );
454    }
455
456    /// Verify that font_data() returns non-empty bytes and basic TTF magic.
457    #[cfg(feature = "bundled-fonts")]
458    #[test]
459    fn font_bytes_are_non_empty_and_sniff_as_ttf() {
460        let fonts = crate::tokens::font_data();
461        assert_eq!(fonts.len(), 2, "expected Roboto Flex + Roboto Mono");
462        for (i, bytes) in fonts.iter().enumerate() {
463            assert!(!bytes.is_empty(), "font face {i} bytes must be non-empty");
464            assert!(
465                bytes.len() > 4,
466                "font face {i} must have at least 4 bytes for magic number"
467            );
468            // Check for TTF magic: 0x00010000 (big-endian) or 0x74727565 ('true')
469            let magic = u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]);
470            assert!(
471                magic == 0x00010000 || magic == 0x74727565,
472                "font face {i} must start with TTF magic bytes"
473            );
474        }
475    }
476
477    /// Read a big-endian `u16` out of an sfnt-family byte slice at `offset`.
478    #[cfg(feature = "bundled-fonts")]
479    fn read_u16(bytes: &[u8], offset: usize) -> u16 {
480        u16::from_be_bytes([bytes[offset], bytes[offset + 1]])
481    }
482
483    /// Read a big-endian `u32` out of an sfnt-family byte slice at `offset`.
484    #[cfg(feature = "bundled-fonts")]
485    fn read_u32(bytes: &[u8], offset: usize) -> u32 {
486        u32::from_be_bytes([
487            bytes[offset],
488            bytes[offset + 1],
489            bytes[offset + 2],
490            bytes[offset + 3],
491        ])
492    }
493
494    /// Minimal sfnt `fvar` table reader: locates the `fvar` table via the
495    /// sfnt table directory, then returns the axis tag of every axis record
496    /// (`fvar+8` axisCount, `fvar+4` offsetToAxesArray, 20-byte axis records —
497    /// see OpenType spec "Variable Fonts" § `fvar`). No third-party font
498    /// parser: the whole point of this check is a dependency-free regression
499    /// tripwire on `frust-material`'s own bundled bytes, mirroring the
500    /// existing hand-rolled TTF-magic sniff above.
501    ///
502    /// Returns an empty `Vec` for a font with no `fvar` table (i.e. a static,
503    /// non-variable font) rather than panicking.
504    #[cfg(feature = "bundled-fonts")]
505    fn fvar_axis_tags(bytes: &[u8]) -> Vec<[u8; 4]> {
506        let num_tables = read_u16(bytes, 4) as usize;
507        let mut fvar_offset = None;
508        for i in 0..num_tables {
509            let record = 12 + i * 16;
510            let tag = &bytes[record..record + 4];
511            if tag == b"fvar" {
512                fvar_offset = Some(read_u32(bytes, record + 8) as usize);
513                break;
514            }
515        }
516        let Some(fvar_offset) = fvar_offset else {
517            return Vec::new();
518        };
519
520        let axes_array_offset = read_u16(bytes, fvar_offset + 4) as usize;
521        let axis_count = read_u16(bytes, fvar_offset + 8) as usize;
522        let axis_size = read_u16(bytes, fvar_offset + 10) as usize;
523        assert_eq!(axis_size, 20, "fvar axis record size must be 20 bytes");
524
525        (0..axis_count)
526            .map(|i| {
527                let record = fvar_offset + axes_array_offset + i * axis_size;
528                let mut tag = [0u8; 4];
529                tag.copy_from_slice(&bytes[record..record + 4]);
530                tag
531            })
532            .collect()
533    }
534
535    /// Roboto Flex must be the `wght`-only instance `FONTS-LICENSE`'s
536    /// "Modification" record documents, not the 13-axis upstream font: one
537    /// `fvar` axis, tagged `wght`. A regression back to the full variable
538    /// font (or the loss of `wght` variability) must fail this test, not
539    /// silently re-bloat the plugin's bundled bytes.
540    ///
541    /// Indexes `font_data()[0]` directly rather than through
542    /// `tokens::ROBOTO_FLEX_VARIABLE_INDEX` (private to the `tokens` module):
543    /// `font_data`'s own doc comment documents the array's fixed order as
544    /// "Roboto Flex Variable, then Roboto Mono Variable", so `[0]` is Roboto
545    /// Flex by that public contract, not an incidental array position.
546    #[cfg(feature = "bundled-fonts")]
547    #[test]
548    fn roboto_flex_is_a_wght_only_instance() {
549        let bytes = crate::tokens::font_data()[0];
550        let axes = fvar_axis_tags(bytes);
551        assert_eq!(
552            axes,
553            vec![*b"wght"],
554            "Roboto Flex must carry exactly one fvar axis, `wght`; got {axes:?}"
555        );
556    }
557
558    /// Length ceiling on the vendored Roboto Flex bytes: well above the
559    /// ~176 KB instanced size, but far below the ~1.68 MB upstream 13-axis
560    /// font, so a regression to the full variable font fails this test
561    /// instead of silently landing in a release build. See
562    /// `roboto_flex_is_a_wght_only_instance` for why `[0]` is Roboto Flex.
563    #[cfg(feature = "bundled-fonts")]
564    #[test]
565    fn roboto_flex_is_smaller_than_the_upstream_variable_font() {
566        let bytes = crate::tokens::font_data()[0];
567        assert!(
568            bytes.len() < 200_000,
569            "Roboto Flex must be the wght-only instance (< 200,000 B); got {} B \
570             — did the bundled font regress to the full upstream variable font?",
571            bytes.len()
572        );
573    }
574
575    /// install() is safe to call twice; the second call is idempotent in terms
576    /// of theme identity (same value set twice) and font registration (faces
577    /// re-registered under the same family names, shadowing the first).
578    #[test]
579    fn install_is_safe_to_call_twice() {
580        crate::install();
581        crate::install();
582    }
583}