Skip to main content

fret_ui_kit/declarative/
container_queries.rs

1use std::sync::Arc;
2
3use fret_core::{Px, Rect};
4use fret_ui::element::{AnyElement, LayoutQueryRegionProps};
5use fret_ui::{ElementContext, GlobalElementId, Invalidation, UiHost};
6
7use crate::{IntoUiElement, collect_children};
8
9/// Tailwind-compatible width breakpoints.
10///
11/// These are provided as a convenience for shadcn-aligned recipes. Consumers are free to define
12/// their own breakpoint tables.
13pub mod tailwind {
14    use fret_core::Px;
15
16    pub const SM: Px = Px(640.0);
17    pub const MD: Px = Px(768.0);
18    pub const LG: Px = Px(1024.0);
19    pub const XL: Px = Px(1280.0);
20    pub const XXL: Px = Px(1536.0);
21}
22
23#[derive(Debug, Clone, Copy)]
24pub struct ContainerQueryHysteresis {
25    pub up: Px,
26    pub down: Px,
27}
28
29impl Default for ContainerQueryHysteresis {
30    fn default() -> Self {
31        // Keep the default small: enough to avoid single-pixel oscillation without delaying
32        // responsive behavior too much in editor-grade resize drags.
33        Self {
34            up: Px(8.0),
35            down: Px(8.0),
36        }
37    }
38}
39
40#[derive(Debug, Default, Clone, Copy)]
41struct ContainerBreakpointsState {
42    /// 0 = base value, i>0 selects `breakpoints[i-1]`.
43    active_index: usize,
44    initialized: bool,
45}
46
47#[derive(Debug, Default, Clone, Copy)]
48struct ContainerWidthAtLeastState {
49    active: bool,
50    initialized: bool,
51}
52
53fn container_breakpoints_init_active_index<T: Copy>(width: Px, breakpoints: &[(Px, T)]) -> usize {
54    let mut active_index = 0;
55    for (i, (min_width, _)) in breakpoints.iter().enumerate() {
56        if width.0 >= min_width.0 {
57            active_index = i + 1;
58        }
59    }
60    active_index
61}
62
63fn container_breakpoints_apply_hysteresis<T: Copy>(
64    width: Px,
65    breakpoints: &[(Px, T)],
66    hysteresis: ContainerQueryHysteresis,
67    mut active_index: usize,
68) -> usize {
69    loop {
70        if active_index >= breakpoints.len() {
71            break;
72        }
73        let next_min_width = breakpoints[active_index].0;
74        if width.0 >= next_min_width.0 + hysteresis.up.0 {
75            active_index = active_index.saturating_add(1);
76            continue;
77        }
78        break;
79    }
80
81    loop {
82        if active_index == 0 {
83            break;
84        }
85        let cur_min_width = breakpoints[active_index - 1].0;
86        if width.0 < cur_min_width.0 - hysteresis.down.0 {
87            active_index = active_index.saturating_sub(1);
88            continue;
89        }
90        break;
91    }
92
93    active_index
94}
95
96fn container_width_at_least_init(width: Px, threshold: Px) -> bool {
97    width.0 >= threshold.0
98}
99
100fn container_width_at_least_apply_hysteresis(
101    width: Px,
102    threshold: Px,
103    hysteresis: ContainerQueryHysteresis,
104    active: bool,
105) -> bool {
106    if !active && width.0 >= threshold.0 + hysteresis.up.0 {
107        return true;
108    }
109    if active && width.0 < threshold.0 - hysteresis.down.0 {
110        return false;
111    }
112    active
113}
114
115/// Marks a subtree as a container-query region.
116///
117/// This is a mechanism-only wrapper: it is paint- and input-transparent, but records committed
118/// bounds that can be read via [`ElementContext::layout_query_bounds`] (ADR 0231).
119#[track_caller]
120pub fn container_query_region_with_id<H, I, T>(
121    cx: &mut ElementContext<'_, H>,
122    name: impl Into<Arc<str>>,
123    mut props: LayoutQueryRegionProps,
124    f: impl FnOnce(&mut ElementContext<'_, H>, GlobalElementId) -> I,
125) -> AnyElement
126where
127    H: UiHost,
128    I: IntoIterator<Item = T>,
129    T: IntoUiElement<H>,
130{
131    props.name = Some(name.into());
132    cx.layout_query_region_with_id(props, move |cx, id| {
133        let items = f(cx, id);
134        collect_children(cx, items)
135    })
136}
137
138#[track_caller]
139pub fn container_query_region<H, I, T>(
140    cx: &mut ElementContext<'_, H>,
141    name: impl Into<Arc<str>>,
142    props: LayoutQueryRegionProps,
143    f: impl FnOnce(&mut ElementContext<'_, H>) -> I,
144) -> AnyElement
145where
146    H: UiHost,
147    I: IntoIterator<Item = T>,
148    T: IntoUiElement<H>,
149{
150    container_query_region_with_id(cx, name, props, |cx, _id| f(cx))
151}
152
153/// Resolves a breakpoint-driven variant based on the committed width of a query region.
154///
155/// Contract notes:
156///
157/// - Observations are frame-lagged (read last committed bounds only).
158/// - Width changes participate in invalidation via `layout_query_bounds` (ADR 0231 D4).
159/// - Hysteresis is applied to avoid oscillation when layout branches affect container size.
160///
161/// Breakpoint table semantics:
162///
163/// - `base` is returned when no breakpoints match.
164/// - Each `(min_width, value)` activates when `width >= min_width`.
165/// - The table must be sorted by ascending `min_width`.
166#[track_caller]
167pub fn container_breakpoints<H, T: Copy>(
168    cx: &mut ElementContext<'_, H>,
169    region: GlobalElementId,
170    invalidation: Invalidation,
171    base: T,
172    breakpoints: &[(Px, T)],
173    hysteresis: ContainerQueryHysteresis,
174) -> T
175where
176    H: UiHost,
177{
178    let rect: Option<Rect> = cx.layout_query_bounds(region, invalidation);
179    let Some(width) = rect.map(|r| r.size.width) else {
180        return base;
181    };
182
183    cx.slot_state(ContainerBreakpointsState::default, |st| {
184        if !st.initialized {
185            st.active_index = container_breakpoints_init_active_index(width, breakpoints);
186            st.initialized = true;
187        }
188
189        st.active_index =
190            container_breakpoints_apply_hysteresis(width, breakpoints, hysteresis, st.active_index);
191
192        if st.active_index == 0 {
193            return base;
194        }
195        breakpoints
196            .get(st.active_index - 1)
197            .map(|(_, v)| *v)
198            .unwrap_or(base)
199    })
200}
201
202/// Returns whether a container-query region's committed width is at least `threshold`.
203///
204/// This is a convenience wrapper for the common "single breakpoint" case.
205///
206/// - Observations are frame-lagged (read last committed bounds only).
207/// - Hysteresis is applied to avoid oscillation near the threshold.
208/// - When the width is not yet known, returns `default_when_unknown`.
209#[track_caller]
210pub fn container_width_at_least<H: UiHost>(
211    cx: &mut ElementContext<'_, H>,
212    region: GlobalElementId,
213    invalidation: Invalidation,
214    default_when_unknown: bool,
215    threshold: Px,
216    hysteresis: ContainerQueryHysteresis,
217) -> bool {
218    let rect: Option<Rect> = cx.layout_query_bounds(region, invalidation);
219    let Some(width) = rect.map(|r| r.size.width) else {
220        return default_when_unknown;
221    };
222
223    cx.slot_state(ContainerWidthAtLeastState::default, |st| {
224        if !st.initialized {
225            st.active = container_width_at_least_init(width, threshold);
226            st.initialized = true;
227        }
228
229        st.active =
230            container_width_at_least_apply_hysteresis(width, threshold, hysteresis, st.active);
231        st.active
232    })
233}
234
235#[cfg(test)]
236mod tests {
237    use super::*;
238
239    #[test]
240    fn container_breakpoints_hysteresis_transitions() {
241        let breakpoints = &[
242            (tailwind::SM, 1usize),
243            (tailwind::MD, 2usize),
244            (tailwind::LG, 3usize),
245        ];
246        let hysteresis = ContainerQueryHysteresis {
247            up: Px(8.0),
248            down: Px(8.0),
249        };
250
251        // Initialize at SM.
252        let mut active_index = container_breakpoints_init_active_index(Px(700.0), breakpoints);
253        assert_eq!(active_index, 1);
254
255        // Approach MD but do not cross until width >= MD + up.
256        active_index = container_breakpoints_apply_hysteresis(
257            Px(770.0),
258            breakpoints,
259            hysteresis,
260            active_index,
261        );
262        assert_eq!(active_index, 1);
263        active_index = container_breakpoints_apply_hysteresis(
264            Px(776.0),
265            breakpoints,
266            hysteresis,
267            active_index,
268        );
269        assert_eq!(active_index, 2);
270
271        // Approach back below MD but do not drop until width < MD - down.
272        active_index = container_breakpoints_apply_hysteresis(
273            Px(762.0),
274            breakpoints,
275            hysteresis,
276            active_index,
277        );
278        assert_eq!(active_index, 2);
279        active_index = container_breakpoints_apply_hysteresis(
280            Px(759.0),
281            breakpoints,
282            hysteresis,
283            active_index,
284        );
285        assert_eq!(active_index, 1);
286
287        // Jump up multiple breakpoints in one update.
288        active_index = container_breakpoints_apply_hysteresis(
289            Px(2000.0),
290            breakpoints,
291            hysteresis,
292            active_index,
293        );
294        assert_eq!(active_index, 3);
295    }
296
297    #[test]
298    fn container_width_at_least_hysteresis_transitions() {
299        let threshold = tailwind::MD;
300        let hysteresis = ContainerQueryHysteresis::default();
301
302        let mut active = container_width_at_least_init(Px(700.0), threshold);
303        assert!(!active);
304
305        // Do not cross until width >= threshold + up.
306        active =
307            container_width_at_least_apply_hysteresis(Px(770.0), threshold, hysteresis, active);
308        assert!(!active);
309        active =
310            container_width_at_least_apply_hysteresis(Px(776.0), threshold, hysteresis, active);
311        assert!(active);
312
313        // Do not drop until width < threshold - down.
314        active =
315            container_width_at_least_apply_hysteresis(Px(762.0), threshold, hysteresis, active);
316        assert!(active);
317        active =
318            container_width_at_least_apply_hysteresis(Px(759.0), threshold, hysteresis, active);
319        assert!(!active);
320    }
321}