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