Skip to main content

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}