Skip to main content

yog_ui/
widget.rs

1//! Widget types and styling.
2
3use crate::layout::{Align, FlexDir};
4
5/// A single widget in the UI tree.
6#[derive(Debug, Clone)]
7pub struct Widget {
8    pub kind: WidgetKind,
9    pub id: Option<String>,
10    pub style: Style,
11    pub flex_dir: FlexDir,
12    pub children: Vec<Widget>,
13    pub on_click: Option<String>,
14    pub enabled: bool,
15    pub focused: bool,
16    /// Typed click target set by generated code (yog-kit) — like
17    /// [`Self::on_click`], but no string to parse. See [`Action`].
18    pub action: Option<Action>,
19    /// `false` = collapsed: takes no space, isn't drawn and can't be hit
20    /// (its layout node is a zero rect without children).
21    pub visible: bool,
22    /// Interaction state (hover, pressed, selected, ...) the host sets each
23    /// frame — picks the [`Self::states`] patches applied when drawing.
24    pub state: StateFlags,
25    /// Paint-only style overrides per state (`hover:bg="..."` in `.yui`).
26    pub states: Option<Box<StateStyles>>,
27}
28
29/// A typed click target: `owner` 0 = the screen, `k` = items control `k`;
30/// `slot` = the command (or [`Action::SELECT`]); `index` = the item index
31/// inside an items control (0 elsewhere).
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
33pub struct Action {
34    pub owner: u16,
35    pub slot: u16,
36    pub index: u32,
37}
38
39impl Action {
40    /// The `slot` of an items control's item container: clicking selects it.
41    pub const SELECT: u16 = u16::MAX;
42
43    /// A screen-level command `slot`.
44    pub const fn new(slot: u16) -> Self {
45        Self {
46            owner: 0,
47            slot,
48            index: 0,
49        }
50    }
51}
52
53/// Interaction state bits of a widget — see [`Widget::state`].
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
55pub struct StateFlags(pub u8);
56
57impl StateFlags {
58    pub const NONE: Self = Self(0);
59    pub const HOVER: Self = Self(1);
60    pub const PRESSED: Self = Self(2);
61    pub const FOCUSED: Self = Self(4);
62    pub const SELECTED: Self = Self(8);
63    pub const CHECKED: Self = Self(16);
64    pub const DISABLED: Self = Self(32);
65
66    /// Every bit of `o` is set.
67    pub const fn contains(self, o: Self) -> bool {
68        self.0 & o.0 == o.0
69    }
70    pub fn insert(&mut self, o: Self) {
71        self.0 |= o.0;
72    }
73    pub fn remove(&mut self, o: Self) {
74        self.0 &= !o.0;
75    }
76    /// Sets or clears the bits of `o`.
77    pub fn set(&mut self, o: Self, on: bool) {
78        if on {
79            self.insert(o)
80        } else {
81            self.remove(o)
82        }
83    }
84    pub const fn union(self, o: Self) -> Self {
85        Self(self.0 | o.0)
86    }
87    pub const fn is_empty(self) -> bool {
88        self.0 == 0
89    }
90}
91
92impl std::ops::BitOr for StateFlags {
93    type Output = Self;
94    fn bitor(self, o: Self) -> Self {
95        self.union(o)
96    }
97}
98
99/// Paint-only style overrides — never affect layout.
100#[derive(Debug, Clone, Default, PartialEq)]
101pub struct StylePatch {
102    pub bg: Option<u32>,
103    pub color: Option<u32>,
104    pub shadow: Option<bool>,
105    pub focus_color: Option<u32>,
106}
107
108impl StylePatch {
109    /// `true` when it overrides nothing.
110    pub fn is_empty(&self) -> bool {
111        *self == Self::default()
112    }
113
114    fn apply(&self, s: &mut Style) {
115        if let Some(v) = self.bg {
116            s.bg = v;
117        }
118        if let Some(v) = self.color {
119            s.color = v;
120        }
121        if let Some(v) = self.shadow {
122            s.text_shadow = v;
123        }
124        if let Some(v) = self.focus_color {
125            s.focus_color = v;
126        }
127    }
128}
129
130/// A widget's state patches: each applies when the widget's state contains
131/// its flags; later rules win.
132#[derive(Debug, Clone, Default, PartialEq)]
133pub struct StateStyles {
134    pub rules: Vec<(StateFlags, StylePatch)>,
135}
136
137#[derive(Debug, Clone)]
138pub enum WidgetKind {
139    /// Container — arranges children according to `flex_dir`.
140    Panel(FlexDir),
141    /// Static or dynamic text label.
142    Label(String),
143    /// Clickable button with text.
144    Button(String),
145    /// Minecraft item icon (static, known at build time).
146    ItemSlot(String),
147    /// Inventory-slot decoration: an item icon plus a stack-count overlay,
148    /// both supplied as plain data at tree-build time (like every other
149    /// widget here) — this crate has no notion of "the currently open menu"
150    /// or how to query it; that's the caller's job (see `yog-inventory`).
151    /// Empty `item_id` draws an empty slot box.
152    InvSlot { item_id: String, count: u32 },
153    /// An image from a Yog image source (`item:apple`, `sprite:button`,
154    /// `mymod:gui/logo`, ...) — see [`Image`] and [`image`].
155    Image(Image),
156    /// Invisible spacer.
157    Spacer,
158    /// Single-line text input — see [`TextBox`] and
159    /// [`TextBoxState`](crate::TextBoxState), which owns the text and drives it.
160    TextBox(TextBox),
161}
162
163/// An image widget's data — see [`image`].
164#[derive(Debug, Clone, PartialEq)]
165pub struct Image {
166    /// A Yog image source (see [`yog_gfx::image`]).
167    pub source: String,
168    /// How the image fills the widget (default [`Stretch::Uniform`]).
169    pub stretch: Stretch,
170    /// The part of the image drawn, `[u, v, w, h]` in the image's pixels
171    /// (`None` = all of it). Textures only — items and sprites are whole.
172    pub region: Option<[f32; 4]>,
173    /// Color multiplier, `0xAARRGGBB` (`0xFFFFFFFF` = none; items ignore it).
174    pub tint: u32,
175}
176
177pub use yog_gfx::image::Stretch;
178
179/// A text box's content and editing state, as drawn this frame. Built by
180/// [`text_box`] (text, placeholder, max length, event names); the caret,
181/// selection and horizontal scroll are filled in each frame by
182/// [`TextBoxState::apply`](crate::TextBoxState::apply).
183#[derive(Debug, Clone, Default, PartialEq)]
184pub struct TextBox {
185    pub text: String,
186    /// Shown greyed while the text is empty.
187    pub placeholder: String,
188    /// Longest text allowed, in characters; 0 = unlimited.
189    pub max_length: usize,
190    /// Event name for text changes (see `TextBoxState::event_name`).
191    pub on_change: Option<String>,
192    /// Event name for Enter (see `TextBoxState::event_name`).
193    pub on_submit: Option<String>,
194    /// Caret position in characters — `Some` while the box has focus.
195    pub caret: Option<usize>,
196    /// Draw the caret this frame (it blinks).
197    pub caret_visible: bool,
198    /// Selected characters `start..end` (empty = none).
199    pub selection: (usize, usize),
200    /// First visible character (horizontal scroll).
201    pub first: usize,
202}
203
204/// Placeholder text color of a text box.
205pub const PLACEHOLDER_COLOR: u32 = 0xFF_707070;
206/// Border of an unfocused text box (vanilla `EditBox`).
207pub const TEXT_BOX_BORDER: u32 = 0xFF_A0A0A0;
208/// Border of a focused text box unless `focus_color` is set.
209pub const TEXT_BOX_FOCUS_BORDER: u32 = 0xFF_FFFFFF;
210/// Selection highlight behind selected text.
211pub const SELECTION_COLOR: u32 = 0xFF_2850A0;
212
213/// WinForms-style dock — which edge(s) the widget attaches to inside its parent.
214///
215/// - `None`   — normal flex positioning (default)
216/// - `Fill`   — stretch to fill all remaining space (both axes)
217/// - `Left`   — full height, natural width, hugs left edge; other children flow right
218/// - `Right`  — full height, natural width, hugs right edge
219/// - `Top`    — full width, natural height, hugs top edge; other children flow down
220/// - `Bottom` — full width, natural height, hugs bottom edge
221#[derive(Debug, Clone, Copy, PartialEq, Default)]
222pub enum Dock {
223    #[default]
224    None,
225    Fill,
226    Left,
227    Right,
228    Top,
229    Bottom,
230}
231
232/// How a focused widget shows its focus indicator.
233#[derive(Debug, Clone, Copy, PartialEq, Default)]
234pub enum FocusStyle {
235    /// 1px outline using `focus_color` (default, amber).
236    #[default]
237    Outline,
238    /// Solid background fill using `focus_color`.
239    Fill,
240    /// No visual indicator.
241    None,
242}
243
244/// A size value — either a fixed pixel amount, a CSS-style percentage of the
245/// parent's own resolved content size (0.0–100.0), or `Auto` (size to
246/// content/available space, this crate's original behaviour).
247#[derive(Debug, Clone, Copy, PartialEq, Default)]
248pub enum Units {
249    #[default]
250    Auto,
251    Px(f32),
252    /// Percent of the parent's resolved content size along the same axis.
253    Percent(f32),
254}
255
256impl From<f32> for Units {
257    /// `0.0` maps to `Auto` (matches this crate's original `0.0 = auto` convention,
258    /// so every existing `.w(18.0)`/`.h(0.0)` call keeps meaning exactly what it did).
259    fn from(v: f32) -> Self {
260        if v <= 0.0 {
261            Units::Auto
262        } else {
263            Units::Px(v)
264        }
265    }
266}
267
268impl Units {
269    /// Resolve against `avail` (the parent's resolved content size on this
270    /// axis). `None` for `Auto` — caller falls back to its own auto-sizing.
271    pub fn resolve(self, avail: f32) -> Option<f32> {
272        match self {
273            Units::Auto => None,
274            Units::Px(v) => Some(v),
275            Units::Percent(p) => Some(avail * p / 100.0),
276        }
277    }
278
279    pub fn is_auto(self) -> bool {
280        matches!(self, Units::Auto)
281    }
282}
283
284/// Visual and layout style for a widget.
285#[derive(Debug, Clone)]
286pub struct Style {
287    pub w: Units, // explicit width; Auto = size to content/available space
288    pub h: Units, // explicit height; Auto = size to content/available space
289    pub min_w: f32,
290    pub min_h: f32,
291    pub flex: f32,        // grow factor inside flex container (main axis)
292    pub dock: Dock,       // WinForms-style edge attachment
293    pub gap: f32,         // spacing between children
294    pub pad: [f32; 4],    // top, right, bottom, left — space INSIDE the border
295    pub margin: [f32; 4], // top, right, bottom, left — space OUTSIDE the border
296    pub bg: u32,          // background colour 0xAARRGGBB; 0 = transparent
297    pub color: u32,       // text colour
298    pub align: Align,
299    pub font_scale: f32,
300    pub text_shadow: bool, // MC drop-shadow behind text (HUD default; books disable it)
301    pub focus_style: FocusStyle,
302    pub focus_color: u32, // 0 = default amber 0xFF_FFE040
303    /// Labels/buttons: never word-wrap, even if narrower than the text.
304    /// For single-line titles/headers that must stay on one line — matches
305    /// Patchouli, where such text overflows its box horizontally rather than
306    /// wrapping onto a second line.
307    pub no_wrap: bool,
308    /// Absolute position `(x, y)` inside the parent's content box (inside its
309    /// padding) — WPF `Canvas` / WinForms `Location` style. A positioned
310    /// widget is taken out of the parent's flex flow: it takes no main-axis
311    /// space, ignores `flex`/`dock`, and is drawn in tree order (later
312    /// siblings on top). `None` (default) = normal flex positioning.
313    pub pos: Option<(f32, f32)>,
314    /// Labels/buttons: a single line (implies `no_wrap`) cut with `...` when
315    /// wider than the box, instead of overflowing it.
316    pub ellipsis: bool,
317    /// Containers: children are drawn (and hit) only inside this widget's
318    /// rect — anything scrolled or overflowing past it is cut off.
319    pub clip: bool,
320    /// Containers: vertical scroll offset of the children in GUI pixels
321    /// (0 = top). Layout clamps it to the content — the effective value is
322    /// [`LayoutNode::scroll_y`](crate::LayoutNode::scroll_y).
323    pub scroll_y: f32,
324    /// Containers: reserve a strip on the right edge and draw a vertical
325    /// scrollbar there whenever the content overflows (see
326    /// [`ScrollState`](crate::ScrollState) for driving it).
327    pub scrollbar: bool,
328    /// Font id for text — empty (default) = Minecraft's default font
329    /// (`minecraft:default`); also `minecraft:uniform`, `minecraft:alt`,
330    /// `minecraft:illageralt` or a resource-pack font such as `mymod:fancy`.
331    /// See [`crate::text`] for how non-default fonts are measured.
332    pub font: String,
333}
334
335impl Default for Style {
336    fn default() -> Self {
337        Self {
338            w: Units::Auto,
339            h: Units::Auto,
340            min_w: 4.0,
341            min_h: 4.0,
342            flex: 0.0,
343            dock: Dock::None,
344            gap: 2.0,
345            pad: [0.0; 4],
346            margin: [0.0; 4],
347            bg: 0,
348            color: 0xFF_CCCCAA,
349            align: Align::Start,
350            font_scale: 1.0,
351            text_shadow: true,
352            focus_style: FocusStyle::default(),
353            focus_color: 0,
354            no_wrap: false,
355            pos: None,
356            ellipsis: false,
357            clip: false,
358            scroll_y: 0.0,
359            scrollbar: false,
360            font: String::new(),
361        }
362    }
363}
364
365// ── Builder API ───────────────────────────────────────────────────────────────
366
367impl Widget {
368    fn new(kind: WidgetKind) -> Self {
369        let flex_dir = if let WidgetKind::Panel(dir) = &kind {
370            *dir
371        } else {
372            FlexDir::Column
373        };
374        Self {
375            kind,
376            id: None,
377            style: Style::default(),
378            flex_dir,
379            children: Vec::new(),
380            on_click: None,
381            enabled: true,
382            focused: false,
383            action: None,
384            visible: true,
385            state: StateFlags::NONE,
386            states: None,
387        }
388    }
389
390    /// Typed click target — see [`Action`].
391    pub fn action(mut self, a: Action) -> Self {
392        self.action = Some(a);
393        self
394    }
395    /// `false` = collapsed — see [`Widget::visible`].
396    pub fn visible(mut self, v: bool) -> Self {
397        self.visible = v;
398        self
399    }
400    /// Adds a paint-only override applied while the state contains `when`
401    /// (later calls win over earlier ones).
402    pub fn state_style(mut self, when: StateFlags, patch: StylePatch) -> Self {
403        self.states
404            .get_or_insert_with(Default::default)
405            .rules
406            .push((when, patch));
407        self
408    }
409
410    /// The descendant at child-index `path` (`&[]` = self); `None` past the
411    /// tree.
412    pub fn get_path(&self, path: &[u16]) -> Option<&Widget> {
413        let mut w = self;
414        for &i in path {
415            w = w.children.get(i as usize)?;
416        }
417        Some(w)
418    }
419    /// [`Self::get_path`], mutably.
420    pub fn get_path_mut(&mut self, path: &[u16]) -> Option<&mut Widget> {
421        let mut w = self;
422        for &i in path {
423            w = w.children.get_mut(i as usize)?;
424        }
425        Some(w)
426    }
427    /// The descendant at `path`. Panics on a bad path — for generated code,
428    /// whose paths are fixed when it's generated.
429    pub fn at_path(&self, path: &[u16]) -> &Widget {
430        self.get_path(path)
431            .unwrap_or_else(|| panic!("yog-ui: no widget at path {path:?}"))
432    }
433    /// [`Self::at_path`], mutably.
434    pub fn at_path_mut(&mut self, path: &[u16]) -> &mut Widget {
435        self.get_path_mut(path)
436            .unwrap_or_else(|| panic!("yog-ui: no widget at path {path:?}"))
437    }
438
439    /// Whether clicking it does something (an [`Action`] or `on_click`).
440    pub fn is_interactive(&self) -> bool {
441        self.action.is_some() || self.on_click.is_some()
442    }
443
444    /// Sets (or clears) `mask` on this widget and its descendants, stopping
445    /// at descendants that are interactive themselves (a button in a row
446    /// keeps its own hover) — so a row's labels follow the row's state.
447    pub fn set_state_inherited(&mut self, mask: StateFlags, on: bool) {
448        self.state.set(mask, on);
449        for c in &mut self.children {
450            if !c.is_interactive() {
451                c.set_state_inherited(mask, on);
452            }
453        }
454    }
455
456    /// Clears `mask` on the whole subtree (interactive descendants too).
457    pub fn clear_state_deep(&mut self, mask: StateFlags) {
458        self.state.remove(mask);
459        for c in &mut self.children {
460            c.clear_state_deep(mask);
461        }
462    }
463
464    /// The style drawn: [`Self::style`] plus the [`Self::states`] patches
465    /// matching [`Self::state`] (with DISABLED while `enabled` is false).
466    pub fn effective_style(&self) -> std::borrow::Cow<'_, Style> {
467        let mut state = self.state;
468        if !self.enabled {
469            state.insert(StateFlags::DISABLED);
470        }
471        self.effective_style_in(state)
472    }
473
474    /// [`Self::effective_style`] for an explicit `state`.
475    pub fn effective_style_in(&self, state: StateFlags) -> std::borrow::Cow<'_, Style> {
476        let Some(states) = &self.states else {
477            return std::borrow::Cow::Borrowed(&self.style);
478        };
479        let mut matching = states
480            .rules
481            .iter()
482            .filter(|(when, _)| !when.is_empty() && state.contains(*when))
483            .peekable();
484        if matching.peek().is_none() {
485            return std::borrow::Cow::Borrowed(&self.style);
486        }
487        let mut s = self.style.clone();
488        for (_, patch) in matching {
489            patch.apply(&mut s);
490        }
491        std::borrow::Cow::Owned(s)
492    }
493
494    pub fn id(mut self, id: impl Into<String>) -> Self {
495        self.id = Some(id.into());
496        self
497    }
498    pub fn on_click(mut self, ev: impl Into<String>) -> Self {
499        self.on_click = Some(ev.into());
500        self
501    }
502    pub fn child(mut self, w: Widget) -> Self {
503        self.children.push(w);
504        self
505    }
506
507    /// Accepts a plain px value (`0.0` = auto) or a [`Units`] directly —
508    /// e.g. `.w(18.0)` or `.w(Units::Percent(60.0))`.
509    pub fn w(mut self, w: impl Into<Units>) -> Self {
510        self.style.w = w.into();
511        self
512    }
513    pub fn h(mut self, h: impl Into<Units>) -> Self {
514        self.style.h = h.into();
515        self
516    }
517    pub fn min_w(mut self, v: f32) -> Self {
518        self.style.min_w = v;
519        self
520    }
521    pub fn min_h(mut self, v: f32) -> Self {
522        self.style.min_h = v;
523        self
524    }
525    pub fn flex(mut self, v: f32) -> Self {
526        self.style.flex = v;
527        self
528    }
529    pub fn flex_dir(mut self, v: FlexDir) -> Self {
530        self.flex_dir = v;
531        self
532    }
533    pub fn gap(mut self, v: f32) -> Self {
534        self.style.gap = v;
535        self
536    }
537    pub fn bg(mut self, v: u32) -> Self {
538        self.style.bg = v;
539        self
540    }
541    pub fn color(mut self, v: u32) -> Self {
542        self.style.color = v;
543        self
544    }
545    pub fn align(mut self, v: Align) -> Self {
546        self.style.align = v;
547        self
548    }
549    pub fn font_scale(mut self, v: f32) -> Self {
550        self.style.font_scale = v;
551        self
552    }
553    pub fn shadow(mut self, v: bool) -> Self {
554        self.style.text_shadow = v;
555        self
556    }
557    pub fn no_wrap(mut self) -> Self {
558        self.style.no_wrap = true;
559        self
560    }
561    pub fn focus_style(mut self, v: FocusStyle) -> Self {
562        self.style.focus_style = v;
563        self
564    }
565    pub fn focus_color(mut self, v: u32) -> Self {
566        self.style.focus_color = v;
567        self
568    }
569    pub fn dock(mut self, v: Dock) -> Self {
570        self.style.dock = v;
571        self
572    }
573    pub fn enabled(mut self, v: bool) -> Self {
574        self.enabled = v;
575        self
576    }
577    pub fn focused(mut self, v: bool) -> Self {
578        self.focused = v;
579        self
580    }
581    /// Position absolutely at `(x, y)` inside the parent's content box — see [`Style::pos`].
582    pub fn at(mut self, x: f32, y: f32) -> Self {
583        self.style.pos = Some((x, y));
584        self
585    }
586    /// Single line, cut with `...` when too wide — see [`Style::ellipsis`].
587    pub fn ellipsis(mut self) -> Self {
588        self.style.no_wrap = true;
589        self.style.ellipsis = true;
590        self
591    }
592    /// Clip children to this widget's rect — see [`Style::clip`].
593    pub fn clip(mut self, v: bool) -> Self {
594        self.style.clip = v;
595        self
596    }
597    /// Vertical scroll offset of the children — see [`Style::scroll_y`].
598    pub fn scroll_y(mut self, v: f32) -> Self {
599        self.style.scroll_y = v;
600        self
601    }
602    /// Draw a vertical scrollbar on overflow — see [`Style::scrollbar`].
603    pub fn scrollbar(mut self, v: bool) -> Self {
604        self.style.scrollbar = v;
605        self
606    }
607    /// Font for this widget's text — see [`Style::font`].
608    pub fn font(mut self, font: impl Into<String>) -> Self {
609        self.style.font = font.into();
610        self
611    }
612    /// Text box: greyed text shown while it's empty.
613    pub fn placeholder(mut self, text: impl Into<String>) -> Self {
614        if let WidgetKind::TextBox(t) = &mut self.kind {
615            t.placeholder = text.into();
616        }
617        self
618    }
619    /// Text box: longest text allowed, in characters (0 = unlimited).
620    pub fn max_length(mut self, n: usize) -> Self {
621        if let WidgetKind::TextBox(t) = &mut self.kind {
622            t.max_length = n;
623        }
624        self
625    }
626    /// Text box: event name raised when the text changes.
627    pub fn on_change(mut self, ev: impl Into<String>) -> Self {
628        if let WidgetKind::TextBox(t) = &mut self.kind {
629            t.on_change = Some(ev.into());
630        }
631        self
632    }
633    /// Text box: event name raised by Enter.
634    pub fn on_submit(mut self, ev: impl Into<String>) -> Self {
635        if let WidgetKind::TextBox(t) = &mut self.kind {
636            t.on_submit = Some(ev.into());
637        }
638        self
639    }
640    /// Image: how it fills the widget (WPF's `Stretch`).
641    pub fn stretch(mut self, stretch: Stretch) -> Self {
642        if let WidgetKind::Image(i) = &mut self.kind {
643            i.stretch = stretch;
644        }
645        self
646    }
647    /// Image: draw only `[u, v, w, h]` of it (texture pixels).
648    pub fn region(mut self, region: [f32; 4]) -> Self {
649        if let WidgetKind::Image(i) = &mut self.kind {
650            i.region = Some(region);
651        }
652        self
653    }
654    /// Image: multiply its colors by `tint` (`0xAARRGGBB`).
655    pub fn tint(mut self, tint: u32) -> Self {
656        if let WidgetKind::Image(i) = &mut self.kind {
657            i.tint = tint;
658        }
659        self
660    }
661    /// Adds every widget in `children`, in order.
662    pub fn children(mut self, children: impl IntoIterator<Item = Widget>) -> Self {
663        self.children.extend(children);
664        self
665    }
666
667    /// The first widget in this subtree (depth-first, self included) whose
668    /// [`Widget::id`] is `id` — for changing a built tree before laying it
669    /// out (dynamic text, enabled state, ...).
670    pub fn find_mut(&mut self, id: &str) -> Option<&mut Widget> {
671        if self.id.as_deref() == Some(id) {
672            return Some(self);
673        }
674        self.children.iter_mut().find_map(|c| c.find_mut(id))
675    }
676
677    /// Replaces the text of a label, button or text box; `false` for any
678    /// other kind.
679    pub fn set_text(&mut self, text: impl Into<String>) -> bool {
680        match &mut self.kind {
681            WidgetKind::Label(t) | WidgetKind::Button(t) => {
682                *t = text.into();
683                true
684            }
685            WidgetKind::TextBox(t) => {
686                t.text = text.into();
687                true
688            }
689            _ => false,
690        }
691    }
692
693    pub fn padding(mut self, top: f32, right: f32, bottom: f32, left: f32) -> Self {
694        self.style.pad = [top, right, bottom, left];
695        self
696    }
697    pub fn margin(mut self, top: f32, right: f32, bottom: f32, left: f32) -> Self {
698        self.style.margin = [top, right, bottom, left];
699        self
700    }
701}
702
703// ── Widget constructors ───────────────────────────────────────────────────────
704
705pub fn panel(dir: FlexDir) -> Widget {
706    Widget::new(WidgetKind::Panel(dir))
707}
708/// A vertically scrolling column: clips its children, scrolls them by
709/// [`Widget::scroll_y`] and draws a scrollbar when they overflow — WPF's
710/// `ScrollViewer` around a `StackPanel`. Give it a fixed (or flex) height.
711pub fn scroll_view() -> Widget {
712    panel(FlexDir::Column).clip(true).scrollbar(true)
713}
714pub fn label(text: impl Into<String>) -> Widget {
715    Widget::new(WidgetKind::Label(text.into()))
716}
717pub fn button(text: impl Into<String>) -> Widget {
718    Widget::new(WidgetKind::Button(text.into()))
719}
720pub fn item_slot(item_id: impl Into<String>) -> Widget {
721    Widget::new(WidgetKind::ItemSlot(item_id.into()))
722}
723/// An inventory-slot decoration (icon + stack-count overlay) for `item_id`
724/// (empty string = empty slot) with `count` items. Purely decorative — real
725/// drag-and-drop still goes through the vanilla `Slot` this overlays.
726pub fn inv_slot(item_id: impl Into<String>, count: u32) -> Widget {
727    Widget::new(WidgetKind::InvSlot {
728        item_id: item_id.into(),
729        count,
730    })
731    .w(18.0)
732    .h(18.0)
733}
734/// An image from a Yog image source — `"item:apple"`, `"block:stone"`,
735/// `"sprite:button"`, `"mymod:gui/logo"` (see [`yog_gfx::image`]) — the
736/// host resolves it for its Minecraft version. Sized to the image's natural
737/// size (16×16 until the host told it) unless given `w`/`h`; scaled into the
738/// widget per [`Widget::stretch`] (default `Uniform`), nearest-neighbour.
739pub fn image(source: impl Into<String>) -> Widget {
740    Widget::new(WidgetKind::Image(Image {
741        source: source.into(),
742        stretch: Stretch::Uniform,
743        region: None,
744        tint: 0xFFFF_FFFF,
745    }))
746}
747pub fn spacer() -> Widget {
748    Widget::new(WidgetKind::Spacer)
749}
750/// A single-line text input holding `text` — black box, grey border (white
751/// while focused), like vanilla's `EditBox`. Its text, caret and focus live
752/// in a [`TextBoxState`](crate::TextBoxState) that the code-behind keeps and
753/// applies to it each frame.
754pub fn text_box(text: impl Into<String>) -> Widget {
755    Widget::new(WidgetKind::TextBox(TextBox {
756        text: text.into(),
757        ..TextBox::default()
758    }))
759    .bg(0xFF_000000)
760    .color(0xFF_E0E0E0)
761    .padding(0.0, 4.0, 0.0, 4.0)
762}