frust_material/button/overflow.rs
1// Ported from material_3_expressive v1.0.8 (MIT, © 2026 Paa Developments),
2// whose `lib/components/buttons/` tree is itself vendored from m3e_buttons
3// (MIT, © 2026 Mudit Purohit) — `components/m3e_overflow_strategy.dart`
4// (the abstract family + its `id`s), `components/m3e_no_overflow_strategy.dart`,
5// `components/m3e_scroll_overflow_strategy.dart`, and
6// `styles/m3e_overflow_bottom_sheet_decoration.dart` +
7// `../toggle_button_group/components/m3e_toggle_button_group_layout.dart`'s
8// trigger-opens-a-sheet flow (the bottom-sheet strategy's real upstream
9// home); `components/m3e_base_button_state.dart:177` for a single
10// `M3EButton`'s own (unconditional) baseline behavior.
11// Porting decision: see this file's module doc, "Where these strategies
12// really live upstream" — this crate adapts a `ButtonGroup`-level
13// abstraction onto a single button's own seam; frust has no `ButtonGroup`
14// overflow story yet (`p2-15`'s job).
15
16//! Overflow strategies for [`super::ButtonWidget`]'s label: what happens once
17//! a layout pass finds [`super::ContentMetrics::overflows`] true.
18//!
19//! # Where these strategies really live upstream
20//!
21//! Read closely, `M3EOverflowStrategy` (`m3e_overflow_strategy.dart`) is
22//! *not* a single button's own overflow behavior — it is an
23//! `M3EButtonGroup`/`M3EToggleButtonGroup`-level abstraction over a **list**
24//! of button actions that does not all fit the group's row (`buildLayout`
25//! takes `actions: List<M3EButtonGroupAction>`, `visibleCount`, a
26//! `buildOverflowTrigger`, a `showOverflowMenu`, …). A lone `M3EButton`'s
27//! label has exactly one upstream behavior, unconditionally: ellipsize
28//! (`m3e_base_button_state.dart:177`'s `overflow: TextOverflow.ellipsis`) —
29//! there is no `M3EButton.overflowStrategy` prop anywhere in the reference.
30//!
31//! The group family's three concrete shapes are `M3ENoOverflowStrategy`
32//! (render every action inline, no trigger —
33//! `m3e_no_overflow_strategy.dart`), `M3EScrollOverflowStrategy` (wrap the
34//! row in a `SingleChildScrollView`, no trigger either —
35//! `m3e_scroll_overflow_strategy.dart:47`), and the group's default `menu`
36//! overflow mode's own trigger, which opens a picker styled by
37//! `M3EOverflowBottomSheetDecoration` when the app configures a bottom sheet
38//! presentation (`m3e_toggle_button_group_layout.dart`'s
39//! `_onCustomOverflowPressed` → `showOverflowMenu` → `onItemSelected` flow,
40//! `m3e_overflow_bottom_sheet_decoration.dart` for the sheet's own styling
41//! knobs).
42//!
43//! `frust_material` has no `ButtonGroup`-level overflow story yet (that is a
44//! later task's job) but already carries a single-button
45//! `ContentMetrics`/[`super::OverflowObserver`] seam built for exactly this
46//! question — its own doc anticipated this follow-up. This module adapts the
47//! group family's three-way *semantic* (do nothing extra / let the content
48//! pan within its own box / defer to a bigger surface) onto that
49//! single-button seam, porting each strategy's actual mechanism (ellipsize /
50//! user-drag pan / defer-off-widget) faithfully at the granularity frust has
51//! a widget for today, rather than inventing a `ButtonGroup` this task does
52//! not own.
53//!
54//! # The three strategies
55//!
56//! - [`OverflowStrategy::None`] (default) — today's baseline and the only
57//! change from "no strategy installed at all": the label ellipsizes to the
58//! width available to it, exactly matching *every* `M3EButton`'s one real
59//! behavior (`m3e_base_button_state.dart:177`). Named for
60//! `M3ENoOverflowStrategy`'s identical "nothing extra" idea one level up
61//! (render inline, no trigger, `id => 'none'`).
62//! - [`OverflowStrategy::Scroll`] — a **user drag** pans the full,
63//! unellipsized label within the button's own content box, clipped at its
64//! edges. Ported from `M3EScrollOverflowStrategy`'s
65//! `SingleChildScrollView(scrollDirection: direction, primary: false,
66//! child: ...)` (`m3e_scroll_overflow_strategy.dart:47`, `id => 'scroll'`)
67//! — a plain `SingleChildScrollView` carries no ticker or
68//! `AnimationController` of its own, so this is drag-driven, never an
69//! auto-advancing marquee (that question the task explicitly asked: the
70//! source answers "drag").
71//! - [`OverflowStrategy::BottomSheet`] — a press on an overflowing button
72//! does not fire normally; it instead calls
73//! [`super::OverflowObserver::overflow_pressed`], mirroring the group
74//! family's own trigger: pressing an overflow indicator never fires a
75//! hidden action directly, it opens a picker surface first
76//! (`_onCustomOverflowPressed` → `showOverflowMenu`; only *then* does
77//! `onItemSelected` fire the real action, `m3e_toggle_button_group_layout.dart:181`-`:203`).
78//! An installed [`super::OverflowObserver`] is this seam's hook for
79//! showing the actual sheet: [`mod@crate::sheet`]'s `show_bottom_sheet`
80//! needs a `NavigatorController<State>`, which a paint/event-time button
81//! widget has no way to reach — it is erased of its `State` type by the
82//! time [`super::ButtonWidget`] exists, the same erasure
83//! [`super::ButtonDecoration`] lives behind. An app installs a concrete
84//! `OverflowObserver` that already closed over its own
85//! `NavigatorController<AppState>` at the call site where `AppState` is
86//! still concrete — the same shape [`super::ButtonView`]'s own `on_press`
87//! is threaded through type erasure by. If `BottomSheet` is selected but no
88//! observer is installed, a press still fires normally rather than going
89//! silently dead — a deliberate safety net, not a second code path to the
90//! same place.
91//!
92//! # Selecting one
93//!
94//! [`super::ButtonView::overflow`] installs the strategy;
95//! [`super::ButtonView::overflow_observer`] installs the
96//! [`super::OverflowObserver`] the `BottomSheet` strategy calls into (also
97//! still notified of every [`super::ContentMetrics::measured`] pass exactly
98//! as before, regardless of strategy — that half of the seam is
99//! strategy-independent). Only the *overflowing* case differs by strategy —
100//! a button whose label fits behaves identically under all three: no clip,
101//! no drag tracking, a normal press.
102
103use super::core::ContentMetrics;
104
105/// Which behavior an overflowing label triggers, once a layout pass finds
106/// [`super::ContentMetrics::overflows`] true. See the [module docs](self).
107#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
108pub enum OverflowStrategy {
109 /// Ellipsize and do nothing else — every real `M3EButton`'s actual
110 /// behavior, and this seam's default.
111 #[default]
112 None,
113 /// While overflowing, a horizontal drag pans the unellipsized label
114 /// within the content box, clipped at its edges.
115 Scroll,
116 /// While overflowing, a press notifies
117 /// [`super::OverflowObserver::overflow_pressed`] instead of firing the
118 /// normal `on_press` — falling back to a normal press if no observer is
119 /// installed.
120 BottomSheet,
121}
122
123/// How far `metrics`' label can pan before its trailing edge reaches the
124/// content box's own edge — `0` when nothing needs to move. Shared by the
125/// [`OverflowStrategy::Scroll`] wiring in `core.rs`'s `layout`/`event`/`paint`
126/// passes so the three agree on one clamp.
127pub(super) fn max_scroll(metrics: ContentMetrics) -> f64 {
128 (metrics.painted_label_width - metrics.available_label_width).max(0.0)
129}
130
131#[cfg(test)]
132mod tests {
133 use super::*;
134
135 #[test]
136 fn the_default_strategy_is_none() {
137 assert_eq!(OverflowStrategy::default(), OverflowStrategy::None);
138 }
139
140 #[test]
141 fn max_scroll_is_zero_when_nothing_overflows() {
142 let metrics = ContentMetrics {
143 natural_label_width: 40.0,
144 available_label_width: 100.0,
145 painted_label_width: 40.0,
146 content_width: 40.0,
147 };
148 assert_eq!(max_scroll(metrics), 0.0);
149 }
150
151 #[test]
152 fn max_scroll_is_the_hidden_extent_when_overflowing() {
153 let metrics = ContentMetrics {
154 natural_label_width: 160.0,
155 available_label_width: 100.0,
156 painted_label_width: 160.0,
157 content_width: 160.0,
158 };
159 assert_eq!(max_scroll(metrics), 60.0);
160 }
161}