teksilo-widgets 0.9.0

Widget library for Teksilo — over a hundred widgets and layout primitives, from Button to TreeTableView.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
// SPDX-License-Identifier: MPL-2.0
// SPDX-FileCopyrightText: 2026 FernTech

//! Expand — a layout modifier that claims slack space in a stack and
//! stretches its child to fill the allocated bounds.
//!
//! Inside an [`HStack`](crate::primitives::HStack) or
//! [`VStack`](crate::primitives::VStack), `Expand` participates in the flex
//! distribution pass by reporting a non-zero `flex` weight (default `1.0`).
//! The parent stack distributes leftover space proportionally to each child's
//! flex weight. `Expand::new()` competes on **both axes**;
//! `Expand::horizontal()` and `Expand::vertical()` restrict competition to
//! the named axis so they do not accidentally steal slack from orthogonal
//! siblings. By default the wrapped child is stretched to the full allocated
//! rectangle; call `.align_child(alignment)` to keep the child at its natural
//! size and align it within the slot instead.
//!
//! The default flex basis is **zero** (CSS `flex-basis: 0`), giving exact
//! proportional ratios. Call `.respect_intrinsic()` to switch to **auto**
//! basis where the child's natural size acts as a floor before flex slack is
//! added.
//!
//! ```rust
//! # use teksilo_widgets::primitives::{HStack, Expand, RectWidget};
//! // Two panels sharing horizontal space in a 1:2 ratio
//! let _row = HStack::new()
//!     .child(Expand::new().flex(1.0).child(RectWidget::new()))
//!     .child(Expand::new().flex(2.0).child(RectWidget::new()));
//! ```

use teksilo_canvas::{Point, Rect, Size, SizeProposal};
use teksilo_core::widget::{
    LayoutContext, LayoutResponse, PaintContext, PendingChild, Widget, WidgetPlacement,
};
use teksilo_core::widget_id::WidgetId;
use teksilo_tokens::Alignment;

/// Layout modifier that claims space along one or both axes from its parent
/// and stretches its child to fill it.
///
/// In an `HStack` / `VStack`, `Expand` participates in flex slack
/// distribution: it returns a `LayoutResponse` with `flex` (default `1.0`),
/// so the parent stack hands it a share of the leftover space proportional
/// to flex. Default basis is **zero** — the wrapped child's natural size
/// does NOT count in the rigid pool, which gives clean ratio layouts. Call
/// [`Expand::respect_intrinsic`] to switch to **auto** basis (CSS
/// flex-basis: auto), where the child's natural size acts as a floor and
/// flex adds slack on top.
///
/// `Expand::new()` is the common case: claim space, fill the child.
/// Use `.flex(n)` to change the ratio (e.g. 1:2 by pairing `flex(1)` with
/// `flex(2)`). Use `.align_child(...)` to opt out of fill and align the
/// child at its natural size within the claimed bounds.
///
/// **`horizontal()` / `vertical()` semantics.** The named axis is the one
/// the wrapper *competes for slack on*. Both sizing and flex behavior
/// follow from that:
///
/// - **Sizing:** when the parent binds an axis (`proposal.{axis} = Some`),
///   the wrapper claims that axis regardless of its name. So
///   `Expand::vertical(child)` inside a `VStack` (which binds width and
///   leaves height open) fills the VStack's full width AND distributes
///   vertical slack via flex. Cross-axis collapse to child intrinsic only
///   happens when the parent left that axis open too.
///
/// - **Flex contribution:** the wrapper reports its `flex` weight only on
///   axes the parent is distributing (i.e. left open). `Expand::horizontal()`
///   inside a `VStack` reports `flex = 0` on the open vertical axis, so it
///   does NOT compete for vertical slack with siblings — it just claims
///   the cross-axis width and sits at its child's intrinsic height. Symmetric
///   for `Expand::vertical()` inside an `HStack`.
#[derive(Debug)]
pub struct Expand {
    child_id: Option<WidgetId>,
    pending_child: Option<PendingChild>,
    horizontal: bool,
    vertical: bool,
    flex: f32,
    /// When `Some`, the child is laid out at its natural size and aligned;
    /// when `None`, the child is stretched to the full Expand bounds.
    child_alignment: Option<Alignment>,
    /// When `true`, the wrapped child's natural size acts as a floor on the
    /// flex axis (CSS flex-basis: auto). When `false` (default), the
    /// wanted size on flex axes is `0` so the parent stack divides bounds
    /// purely by flex weight (CSS flex-basis: 0).
    respect_intrinsic: bool,
}

impl Expand {
    /// Expand on both axes. Default `flex(1)`, child fills bounds.
    pub fn new() -> Self {
        Self {
            child_id: None,
            pending_child: None,
            horizontal: true,
            vertical: true,
            flex: 1.0,
            child_alignment: None,
            respect_intrinsic: false,
        }
    }

    /// Compete for slack on the horizontal axis only. Inside an `HStack`,
    /// distributes flex on width while claiming bound height as-is. Inside
    /// a `VStack` (which binds width and distributes height), claims the
    /// VStack's full width but reports `flex = 0` so it doesn't steal
    /// vertical slack from siblings — height stays at child intrinsic.
    pub fn horizontal() -> Self {
        Self {
            child_id: None,
            pending_child: None,
            horizontal: true,
            vertical: false,
            flex: 1.0,
            child_alignment: None,
            respect_intrinsic: false,
        }
    }

    /// Compete for slack on the vertical axis only. Inside a `VStack`,
    /// distributes flex on height while claiming bound width as-is. Inside
    /// an `HStack` (which binds height and distributes width), claims the
    /// HStack's full height but reports `flex = 0` so it doesn't steal
    /// horizontal slack from siblings — width stays at child intrinsic.
    pub fn vertical() -> Self {
        Self {
            child_id: None,
            pending_child: None,
            horizontal: false,
            vertical: true,
            flex: 1.0,
            child_alignment: None,
            respect_intrinsic: false,
        }
    }

    /// Override the flex weight reported to a parent stack. `flex(0)` opts
    /// out of slack distribution (the wrapper still claims any offered
    /// proposal, useful inside non-stack containers). Default: `1.0`.
    pub fn flex(mut self, flex: f32) -> Self {
        self.flex = flex.max(0.0);
        self
    }

    /// Opt out of stretching the child. The child is laid out at its
    /// natural size and positioned within the Expand's bounds according
    /// to `alignment`.
    pub fn align_child(mut self, alignment: Alignment) -> Self {
        self.child_alignment = Some(alignment);
        self
    }

    /// Switch to **auto** flex basis — the wrapped child's natural size
    /// acts as a floor on each flex axis, and the parent stack adds slack
    /// on top via the flex weight. Useful when the wrapper sits inside an
    /// unconstrained parent (e.g. an outer `VStack` with `height = None`),
    /// where the default zero-basis would let the child overflow because
    /// the parent has no bound to share.
    ///
    /// Trade-off: with `respect_intrinsic`, exact ratios bend by content
    /// width — `[Expand::flex(1).child(60), Expand::flex(2).child(40)]` in
    /// 300 px gives `60 + 66 = 126` and `40 + 133 = 173` rather than
    /// `100 / 200`. Without it (the default), the same layout splits
    /// exactly `100 / 200`.
    ///
    /// # Do not use this inside a *bounded* parent
    ///
    /// The floor is a hard one: `Expand` reports `shrink = 0`, so if the
    /// child's natural size exceeds what the parent can offer, the resulting
    /// over-constraint deficit **cannot be absorbed** and later siblings are
    /// pushed outside the bounds.
    ///
    /// This bites hardest with children whose natural size is large and
    /// content-driven. A vertical [`TabBar`](crate::TabBar)
    /// answers an unbounded height query with its *stacked* height — every tab,
    /// one below another. So:
    ///
    /// ```ignore
    /// // 21 tabs => the bar's natural height is ~1050 dp.
    /// VStack::new()
    ///     .child(Expand::vertical().respect_intrinsic().child(tab_widget))
    ///     .child(status_bar)
    /// ```
    ///
    /// makes the `VStack` want `1050 + status_bar`, at *every* window size. The
    /// status bar is placed at y=1050 and stays below the fold until the window
    /// is grown past it — the bar never scrolls, because it was never asked to
    /// fit. Dropping `respect_intrinsic()` fixes it: the bar takes the slack
    /// left after the status bar and scrolls its tabs internally.
    ///
    /// Rule of thumb: reach for this only when the parent genuinely has no
    /// bound to share (`height = None`). When the parent is bounded — a window
    /// root, a sized pane — the default zero basis is what you want.
    pub fn respect_intrinsic(mut self) -> Self {
        self.respect_intrinsic = true;
        self
    }

    /// Set child by pre-registered ID.
    pub fn child_id(mut self, id: WidgetId) -> Self {
        self.pending_child = Some(PendingChild::Id(id));
        self
    }

    /// Set an inline child widget (deferred insertion).
    pub fn child(mut self, widget: impl Widget + 'static) -> Self {
        self.pending_child = Some(PendingChild::Deferred(Box::new(widget)));
        self
    }
}

impl Default for Expand {
    fn default() -> Self {
        Self::new()
    }
}

impl Widget for Expand {
    fn build(&mut self, ctx: &mut teksilo_core::build_context::BuildContext) -> Vec<WidgetId> {
        if let Some(pending) = self.pending_child.take() {
            self.child_id = Some(match pending {
                PendingChild::Id(id) => id,
                PendingChild::Deferred(w) => ctx.add_boxed(w),
            });
        }
        self.child_id.into_iter().collect()
    }

    fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
        // Measure against the parent's own proposal, NOT `unspecified()`.
        //
        // The two are identical on any axis the parent left open — which is every axis
        // whose measurement we actually consume below, since each `child_size` read sits
        // in a `None` arm. What differs is the *other* axis: passing the bound one
        // through lets a child whose size on one axis depends on the other report the
        // truth.
        //
        // Wrapping text is the case that exposed it. `TextWidget` in `Wrap` mode has no
        // basis for wrapping without a width, so it measures as a **single line**
        // (text_widget.rs, the `None => layout_single_line` arm). With `unspecified()`,
        // an `Expand::horizontal` inside a width-bounded `HStack` therefore reported a
        // one-line height for a paragraph that paints three — and the parent sized its
        // chrome to that lie, so the text rendered *outside* its own container. Found in
        // Skribisto, where a toast body spilled over the status bar.
        //
        // Flex-basis semantics are unaffected: `basis_w` / `basis_h` are only read when
        // the parent left that axis open, so the proposal we forward has it open too.
        let child_size = self
            .child_id
            .and_then(|id| ctx.child_size(id, proposal))
            .unwrap_or(Size::ZERO);

        // Two separate concerns:
        //
        // 1. **Sizing.** Whether we *can* fill an axis depends on whether
        //    the parent bound it. If `proposal.{axis}` is `Some(_)`, the
        //    parent is offering exact space — claim it on every axis,
        //    regardless of `horizontal` / `vertical` (otherwise an
        //    `Expand::vertical` inside a `VStack` would collapse on the
        //    cross axis to its child's intrinsic width). When the parent
        //    leaves an axis open (`None`), we want pure slack on flex
        //    axes (basis 0) or child's natural size as a floor when
        //    `respect_intrinsic` is set.
        //
        // 2. **Flex contribution.** A wrapper should only ask for slack
        //    on its *named* axis: `Expand::horizontal()` in a `VStack`
        //    must NOT compete for the VStack's vertical slack, otherwise
        //    a horizontal-fill wrapper would steal vertical space from
        //    siblings. The parent's distributing axis is whichever side
        //    of the proposal it left open. So we report `self.flex` only
        //    when the open axis matches one of our named axes.
        let basis_w = if self.respect_intrinsic {
            child_size.width
        } else {
            0.0
        };
        let basis_h = if self.respect_intrinsic {
            child_size.height
        } else {
            0.0
        };
        let w = match proposal.width {
            Some(pw) => pw,
            None if self.horizontal => basis_w,
            None => child_size.width,
        };
        let h = match proposal.height {
            Some(ph) => ph,
            None if self.vertical => basis_h,
            None => child_size.height,
        };

        // Flex axis logic: a parent stack distributes slack on the axis
        // it left open in the proposal. Only contribute flex on an axis
        // where (a) the parent is distributing (proposal=None on it),
        // and (b) we want to expand on that axis. When both axes are
        // bound or both are unspecified, report the full flex weight —
        // the value is moot in non-stack contexts and the unspecified
        // case happens during intrinsic measurement where the caller
        // wants to know our "would-be" flex.
        let flex = match (proposal.width, proposal.height) {
            (None, Some(_)) if !self.horizontal => 0.0,
            (Some(_), None) if !self.vertical => 0.0,
            _ => self.flex,
        };

        LayoutResponse::flexible(Size::new(w, h), flex)
    }

    fn place_children(
        &self,
        bounds: Rect,
        _proposal: SizeProposal,
        children: &mut [WidgetPlacement],
        ctx: &LayoutContext,
    ) {
        for child in children.iter_mut() {
            if let Some(alignment) = self.child_alignment {
                // Align mode: child takes its natural size, capped by the
                // slot; we position it. Offering the bounds (instead of an
                // unbounded `unspecified()`) lets adaptive children respond —
                // an ellipsis `TextWidget` truncates at the slot width rather
                // than being placed at its full untruncated line and
                // overflowing the slot. Rigid children ignore the proposal
                // and are aligned at their natural size as before.
                let child_size = ctx
                    .child_size(child.id, SizeProposal::exact(bounds.width, bounds.height))
                    .unwrap_or(bounds.size());
                let rtl = ctx.is_rtl();
                let (dx, dy) = alignment.resolve(
                    (child_size.width, child_size.height),
                    (bounds.width, bounds.height),
                    rtl,
                );
                child.origin = Point::new(bounds.x + dx, bounds.y + dy);
                child.size = child_size;
            } else {
                // Fill mode (default): child takes the full Expand bounds.
                child.origin = bounds.origin();
                child.size = bounds.size();
            }
        }
    }

    fn paint(&self, _bounds: Rect, _canvas: &mut teksilo_canvas::Canvas, _ctx: &PaintContext) {}

    fn children(&self) -> Vec<WidgetId> {
        self.child_id.into_iter().collect()
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use teksilo_core::widget_tree::WidgetTree;

    #[derive(Debug)]
    struct FixedLeaf(f32, f32);
    impl Widget for FixedLeaf {
        fn layout_response(&self, _proposal: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
            Size::new(self.0, self.1).into()
        }
    }

    /// A child whose height depends on the width it is measured at — the shape of every
    /// wrapping paragraph, and the one `unspecified()` measurement could not see.
    #[derive(Debug)]
    struct WrappingLeaf {
        /// Total width the content needs on one line.
        natural_width: f32,
        line_height: f32,
    }
    impl Widget for WrappingLeaf {
        fn layout_response(&self, proposal: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
            match proposal.width {
                // Bounded: wrap into as many lines as it takes.
                Some(w) if w > 0.0 => {
                    let lines = (self.natural_width / w).ceil().max(1.0);
                    Size::new(w, lines * self.line_height).into()
                }
                // Unbounded: exactly what `TextWidget` does in `Wrap` mode — no basis for
                // wrapping, so report a single line.
                _ => Size::new(self.natural_width, self.line_height).into(),
            }
        }
    }

    /// `Expand` must measure its child against the parent's own proposal, so a child
    /// whose height depends on its width reports the height it will really occupy.
    ///
    /// Regression: `Expand::layout_response` measured with `SizeProposal::unspecified()`,
    /// so a wrapping paragraph inside a width-bounded stack reported ONE line while
    /// painting four. Every container above it sized to the one-line lie and the text
    /// rendered outside its own chrome — seen in Skribisto as a toast body spilling over
    /// the status bar.
    #[test]
    fn expand_measures_a_width_dependent_child_at_the_width_it_will_get() {
        let mut tree = WidgetTree::new();
        let child = tree.add(WrappingLeaf {
            natural_width: 400.0,
            line_height: 16.0,
        });
        let expand = tree.add(Expand::horizontal().child_id(child));

        // The parent binds width and leaves height open — exactly what `ToastHost`
        // proposes to each toast surface.
        tree.layout(SizeProposal {
            width: Some(100.0),
            height: None,
        });

        let eb = tree.bounds(expand);
        assert!(
            (eb.height - 64.0).abs() < 0.01,
            "400px of content at 100px wide is four 16px lines; \
             got {} (a one-line answer means the bound width was discarded)",
            eb.height
        );
    }

    /// The companion property: on an axis the parent left open, the measurement is
    /// unchanged — the forwarded proposal has that axis open too, so flex-basis and
    /// shrink-wrap semantics are exactly what they were.
    #[test]
    fn expand_still_shrink_wraps_on_a_fully_open_proposal() {
        let mut tree = WidgetTree::new();
        let child = tree.add(WrappingLeaf {
            natural_width: 400.0,
            line_height: 16.0,
        });
        let expand = tree.add(Expand::horizontal().child_id(child));

        tree.layout(SizeProposal::unspecified());

        let eb = tree.bounds(expand);
        assert!(
            (eb.height - 16.0).abs() < 0.01,
            "with no width to wrap at, the child's single-line height stands (got {})",
            eb.height
        );
    }

    #[test]
    fn expand_at_root_fills_proposal() {
        // At the tree root, the proposal IS the bounds. Expand claims it
        // and fills its child to those bounds.
        let mut tree = WidgetTree::new();
        let child = tree.add(FixedLeaf(40.0, 20.0));
        let expand = tree.add(Expand::new().child_id(child));
        tree.layout(SizeProposal::exact(200.0, 100.0));

        let eb = tree.bounds(expand);
        assert!((eb.width - 200.0).abs() < 0.01);
        assert!((eb.height - 100.0).abs() < 0.01);

        // Default fill mode: child stretches to full Expand bounds.
        let cb = tree.bounds(child);
        assert!((cb.width - 200.0).abs() < 0.01);
        assert!((cb.height - 100.0).abs() < 0.01);
    }

    #[test]
    fn align_child_top_trailing() {
        let mut tree = WidgetTree::new();
        let child = tree.add(FixedLeaf(40.0, 20.0));
        let _expand = tree.add(
            Expand::new()
                .align_child(Alignment::TOP_TRAILING)
                .child_id(child),
        );
        tree.layout(SizeProposal::exact(200.0, 100.0));

        let cb = tree.bounds(child);
        // Child stays at natural 40x20, placed top-trailing.
        assert!((cb.width - 40.0).abs() < 0.01);
        assert!((cb.height - 20.0).abs() < 0.01);
        assert!((cb.x - 160.0).abs() < 0.01); // 200 - 40
        assert!((cb.y - 0.0).abs() < 0.01); // top
    }

    #[test]
    fn flex_default_is_one() {
        let theme = teksilo_core::presets::intui::light();
        let ctx = LayoutContext::for_testing(&theme);
        let r = Expand::new().layout_response(SizeProposal::unspecified(), &ctx);
        assert_eq!(r.flex, 1.0);
    }

    #[test]
    fn flex_zero_opts_out() {
        let theme = teksilo_core::presets::intui::light();
        let ctx = LayoutContext::for_testing(&theme);
        let r = Expand::new()
            .flex(0.0)
            .layout_response(SizeProposal::unspecified(), &ctx);
        assert_eq!(r.flex, 0.0);
    }
}