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}
17
18#[derive(Debug, Clone)]
19pub enum WidgetKind {
20    /// Container — arranges children according to `flex_dir`.
21    Panel(FlexDir),
22    /// Static or dynamic text label.
23    Label(String),
24    /// Clickable button with text.
25    Button(String),
26    /// Minecraft item icon (static, known at build time).
27    ItemSlot(String),
28    /// Inventory-slot decoration: an item icon plus a stack-count overlay,
29    /// both supplied as plain data at tree-build time (like every other
30    /// widget here) — this crate has no notion of "the currently open menu"
31    /// or how to query it; that's the caller's job (see `yog-inventory`).
32    /// Empty `item_id` draws an empty slot box.
33    InvSlot { item_id: String, count: u32 },
34    /// Minecraft texture blit (via `draw2d_mc_tex`) of a raw resource path,
35    /// drawn scaled into the widget. Prefer [`WidgetKind::Image`].
36    McImage { id: String, img_w: f32, img_h: f32 },
37    /// An image from a Yog image source (`item:apple`, `sprite:button`,
38    /// `mymod:gui/logo`, ...) — see [`Image`] and [`image`].
39    Image(Image),
40    /// Invisible spacer.
41    Spacer,
42    /// Single-line text input — see [`TextBox`] and
43    /// [`TextBoxState`](crate::TextBoxState), which owns the text and drives it.
44    TextBox(TextBox),
45}
46
47/// An image widget's data — see [`image`].
48#[derive(Debug, Clone, PartialEq)]
49pub struct Image {
50    /// A Yog image source (see [`yog_gfx::image`]).
51    pub source: String,
52    /// How the image fills the widget (default [`Stretch::Uniform`]).
53    pub stretch: Stretch,
54    /// The part of the image drawn, `[u, v, w, h]` in the image's pixels
55    /// (`None` = all of it). Textures only — items and sprites are whole.
56    pub region: Option<[f32; 4]>,
57    /// Color multiplier, `0xAARRGGBB` (`0xFFFFFFFF` = none; items ignore it).
58    pub tint: u32,
59}
60
61pub use yog_gfx::image::Stretch;
62
63/// A text box's content and editing state, as drawn this frame. Built by
64/// [`text_box`] (text, placeholder, max length, event names); the caret,
65/// selection and horizontal scroll are filled in each frame by
66/// [`TextBoxState::apply`](crate::TextBoxState::apply).
67#[derive(Debug, Clone, Default, PartialEq)]
68pub struct TextBox {
69    pub text: String,
70    /// Shown greyed while the text is empty.
71    pub placeholder: String,
72    /// Longest text allowed, in characters; 0 = unlimited.
73    pub max_length: usize,
74    /// Event name for text changes (see `TextBoxState::event_name`).
75    pub on_change: Option<String>,
76    /// Event name for Enter (see `TextBoxState::event_name`).
77    pub on_submit: Option<String>,
78    /// Caret position in characters — `Some` while the box has focus.
79    pub caret: Option<usize>,
80    /// Draw the caret this frame (it blinks).
81    pub caret_visible: bool,
82    /// Selected characters `start..end` (empty = none).
83    pub selection: (usize, usize),
84    /// First visible character (horizontal scroll).
85    pub first: usize,
86}
87
88/// Placeholder text color of a text box.
89pub const PLACEHOLDER_COLOR: u32 = 0xFF_707070;
90/// Border of an unfocused text box (vanilla `EditBox`).
91pub const TEXT_BOX_BORDER: u32 = 0xFF_A0A0A0;
92/// Border of a focused text box unless `focus_color` is set.
93pub const TEXT_BOX_FOCUS_BORDER: u32 = 0xFF_FFFFFF;
94/// Selection highlight behind selected text.
95pub const SELECTION_COLOR: u32 = 0xFF_2850A0;
96
97/// WinForms-style dock — which edge(s) the widget attaches to inside its parent.
98///
99/// - `None`   — normal flex positioning (default)
100/// - `Fill`   — stretch to fill all remaining space (both axes)
101/// - `Left`   — full height, natural width, hugs left edge; other children flow right
102/// - `Right`  — full height, natural width, hugs right edge
103/// - `Top`    — full width, natural height, hugs top edge; other children flow down
104/// - `Bottom` — full width, natural height, hugs bottom edge
105#[derive(Debug, Clone, Copy, PartialEq, Default)]
106pub enum Dock {
107    #[default]
108    None,
109    Fill,
110    Left,
111    Right,
112    Top,
113    Bottom,
114}
115
116/// How a focused widget shows its focus indicator.
117#[derive(Debug, Clone, Copy, PartialEq, Default)]
118pub enum FocusStyle {
119    /// 1px outline using `focus_color` (default, amber).
120    #[default]
121    Outline,
122    /// Solid background fill using `focus_color`.
123    Fill,
124    /// No visual indicator.
125    None,
126}
127
128/// A size value — either a fixed pixel amount, a CSS-style percentage of the
129/// parent's own resolved content size (0.0–100.0), or `Auto` (size to
130/// content/available space, this crate's original behaviour).
131#[derive(Debug, Clone, Copy, PartialEq, Default)]
132pub enum Units {
133    #[default]
134    Auto,
135    Px(f32),
136    /// Percent of the parent's resolved content size along the same axis.
137    Percent(f32),
138}
139
140impl From<f32> for Units {
141    /// `0.0` maps to `Auto` (matches this crate's original `0.0 = auto` convention,
142    /// so every existing `.w(18.0)`/`.h(0.0)` call keeps meaning exactly what it did).
143    fn from(v: f32) -> Self {
144        if v <= 0.0 {
145            Units::Auto
146        } else {
147            Units::Px(v)
148        }
149    }
150}
151
152impl Units {
153    /// Resolve against `avail` (the parent's resolved content size on this
154    /// axis). `None` for `Auto` — caller falls back to its own auto-sizing.
155    pub fn resolve(self, avail: f32) -> Option<f32> {
156        match self {
157            Units::Auto => None,
158            Units::Px(v) => Some(v),
159            Units::Percent(p) => Some(avail * p / 100.0),
160        }
161    }
162
163    pub fn is_auto(self) -> bool {
164        matches!(self, Units::Auto)
165    }
166}
167
168/// Visual and layout style for a widget.
169#[derive(Debug, Clone)]
170pub struct Style {
171    pub w: Units, // explicit width; Auto = size to content/available space
172    pub h: Units, // explicit height; Auto = size to content/available space
173    pub min_w: f32,
174    pub min_h: f32,
175    pub flex: f32,        // grow factor inside flex container (main axis)
176    pub dock: Dock,       // WinForms-style edge attachment
177    pub gap: f32,         // spacing between children
178    pub pad: [f32; 4],    // top, right, bottom, left — space INSIDE the border
179    pub margin: [f32; 4], // top, right, bottom, left — space OUTSIDE the border
180    pub bg: u32,          // background colour 0xAARRGGBB; 0 = transparent
181    pub color: u32,       // text colour
182    pub align: Align,
183    pub font_scale: f32,
184    pub text_shadow: bool, // MC drop-shadow behind text (HUD default; books disable it)
185    pub focus_style: FocusStyle,
186    pub focus_color: u32, // 0 = default amber 0xFF_FFE040
187    /// Labels/buttons: never word-wrap, even if narrower than the text.
188    /// For single-line titles/headers that must stay on one line — matches
189    /// Patchouli, where such text overflows its box horizontally rather than
190    /// wrapping onto a second line.
191    pub no_wrap: bool,
192    /// Absolute position `(x, y)` inside the parent's content box (inside its
193    /// padding) — WPF `Canvas` / WinForms `Location` style. A positioned
194    /// widget is taken out of the parent's flex flow: it takes no main-axis
195    /// space, ignores `flex`/`dock`, and is drawn in tree order (later
196    /// siblings on top). `None` (default) = normal flex positioning.
197    pub pos: Option<(f32, f32)>,
198    /// Labels/buttons: a single line (implies `no_wrap`) cut with `...` when
199    /// wider than the box, instead of overflowing it.
200    pub ellipsis: bool,
201    /// Containers: children are drawn (and hit) only inside this widget's
202    /// rect — anything scrolled or overflowing past it is cut off.
203    pub clip: bool,
204    /// Containers: vertical scroll offset of the children in GUI pixels
205    /// (0 = top). Layout clamps it to the content — the effective value is
206    /// [`LayoutNode::scroll_y`](crate::LayoutNode::scroll_y).
207    pub scroll_y: f32,
208    /// Containers: reserve a strip on the right edge and draw a vertical
209    /// scrollbar there whenever the content overflows (see
210    /// [`ScrollState`](crate::ScrollState) for driving it).
211    pub scrollbar: bool,
212    /// Font id for text — empty (default) = Minecraft's default font
213    /// (`minecraft:default`); also `minecraft:uniform`, `minecraft:alt`,
214    /// `minecraft:illageralt` or a resource-pack font such as `mymod:fancy`.
215    /// See [`crate::text`] for how non-default fonts are measured.
216    pub font: String,
217}
218
219impl Default for Style {
220    fn default() -> Self {
221        Self {
222            w: Units::Auto,
223            h: Units::Auto,
224            min_w: 4.0,
225            min_h: 4.0,
226            flex: 0.0,
227            dock: Dock::None,
228            gap: 2.0,
229            pad: [0.0; 4],
230            margin: [0.0; 4],
231            bg: 0,
232            color: 0xFF_CCCCAA,
233            align: Align::Start,
234            font_scale: 1.0,
235            text_shadow: true,
236            focus_style: FocusStyle::default(),
237            focus_color: 0,
238            no_wrap: false,
239            pos: None,
240            ellipsis: false,
241            clip: false,
242            scroll_y: 0.0,
243            scrollbar: false,
244            font: String::new(),
245        }
246    }
247}
248
249// ── Builder API ───────────────────────────────────────────────────────────────
250
251impl Widget {
252    fn new(kind: WidgetKind) -> Self {
253        let flex_dir = if let WidgetKind::Panel(dir) = &kind {
254            *dir
255        } else {
256            FlexDir::Column
257        };
258        Self {
259            kind,
260            id: None,
261            style: Style::default(),
262            flex_dir,
263            children: Vec::new(),
264            on_click: None,
265            enabled: true,
266            focused: false,
267        }
268    }
269
270    pub fn id(mut self, id: impl Into<String>) -> Self {
271        self.id = Some(id.into());
272        self
273    }
274    pub fn on_click(mut self, ev: impl Into<String>) -> Self {
275        self.on_click = Some(ev.into());
276        self
277    }
278    pub fn child(mut self, w: Widget) -> Self {
279        self.children.push(w);
280        self
281    }
282
283    /// Accepts a plain px value (`0.0` = auto) or a [`Units`] directly —
284    /// e.g. `.w(18.0)` or `.w(Units::Percent(60.0))`.
285    pub fn w(mut self, w: impl Into<Units>) -> Self {
286        self.style.w = w.into();
287        self
288    }
289    pub fn h(mut self, h: impl Into<Units>) -> Self {
290        self.style.h = h.into();
291        self
292    }
293    pub fn min_w(mut self, v: f32) -> Self {
294        self.style.min_w = v;
295        self
296    }
297    pub fn min_h(mut self, v: f32) -> Self {
298        self.style.min_h = v;
299        self
300    }
301    pub fn flex(mut self, v: f32) -> Self {
302        self.style.flex = v;
303        self
304    }
305    pub fn flex_dir(mut self, v: FlexDir) -> Self {
306        self.flex_dir = v;
307        self
308    }
309    pub fn gap(mut self, v: f32) -> Self {
310        self.style.gap = v;
311        self
312    }
313    pub fn bg(mut self, v: u32) -> Self {
314        self.style.bg = v;
315        self
316    }
317    pub fn color(mut self, v: u32) -> Self {
318        self.style.color = v;
319        self
320    }
321    pub fn align(mut self, v: Align) -> Self {
322        self.style.align = v;
323        self
324    }
325    pub fn font_scale(mut self, v: f32) -> Self {
326        self.style.font_scale = v;
327        self
328    }
329    pub fn shadow(mut self, v: bool) -> Self {
330        self.style.text_shadow = v;
331        self
332    }
333    pub fn no_wrap(mut self) -> Self {
334        self.style.no_wrap = true;
335        self
336    }
337    pub fn focus_style(mut self, v: FocusStyle) -> Self {
338        self.style.focus_style = v;
339        self
340    }
341    pub fn focus_color(mut self, v: u32) -> Self {
342        self.style.focus_color = v;
343        self
344    }
345    pub fn dock(mut self, v: Dock) -> Self {
346        self.style.dock = v;
347        self
348    }
349    pub fn enabled(mut self, v: bool) -> Self {
350        self.enabled = v;
351        self
352    }
353    pub fn focused(mut self, v: bool) -> Self {
354        self.focused = v;
355        self
356    }
357    /// Position absolutely at `(x, y)` inside the parent's content box — see [`Style::pos`].
358    pub fn at(mut self, x: f32, y: f32) -> Self {
359        self.style.pos = Some((x, y));
360        self
361    }
362    /// Single line, cut with `...` when too wide — see [`Style::ellipsis`].
363    pub fn ellipsis(mut self) -> Self {
364        self.style.no_wrap = true;
365        self.style.ellipsis = true;
366        self
367    }
368    /// Clip children to this widget's rect — see [`Style::clip`].
369    pub fn clip(mut self, v: bool) -> Self {
370        self.style.clip = v;
371        self
372    }
373    /// Vertical scroll offset of the children — see [`Style::scroll_y`].
374    pub fn scroll_y(mut self, v: f32) -> Self {
375        self.style.scroll_y = v;
376        self
377    }
378    /// Draw a vertical scrollbar on overflow — see [`Style::scrollbar`].
379    pub fn scrollbar(mut self, v: bool) -> Self {
380        self.style.scrollbar = v;
381        self
382    }
383    /// Font for this widget's text — see [`Style::font`].
384    pub fn font(mut self, font: impl Into<String>) -> Self {
385        self.style.font = font.into();
386        self
387    }
388    /// Text box: greyed text shown while it's empty.
389    pub fn placeholder(mut self, text: impl Into<String>) -> Self {
390        if let WidgetKind::TextBox(t) = &mut self.kind {
391            t.placeholder = text.into();
392        }
393        self
394    }
395    /// Text box: longest text allowed, in characters (0 = unlimited).
396    pub fn max_length(mut self, n: usize) -> Self {
397        if let WidgetKind::TextBox(t) = &mut self.kind {
398            t.max_length = n;
399        }
400        self
401    }
402    /// Text box: event name raised when the text changes.
403    pub fn on_change(mut self, ev: impl Into<String>) -> Self {
404        if let WidgetKind::TextBox(t) = &mut self.kind {
405            t.on_change = Some(ev.into());
406        }
407        self
408    }
409    /// Text box: event name raised by Enter.
410    pub fn on_submit(mut self, ev: impl Into<String>) -> Self {
411        if let WidgetKind::TextBox(t) = &mut self.kind {
412            t.on_submit = Some(ev.into());
413        }
414        self
415    }
416    /// Image: how it fills the widget (WPF's `Stretch`).
417    pub fn stretch(mut self, stretch: Stretch) -> Self {
418        if let WidgetKind::Image(i) = &mut self.kind {
419            i.stretch = stretch;
420        }
421        self
422    }
423    /// Image: draw only `[u, v, w, h]` of it (texture pixels).
424    pub fn region(mut self, region: [f32; 4]) -> Self {
425        if let WidgetKind::Image(i) = &mut self.kind {
426            i.region = Some(region);
427        }
428        self
429    }
430    /// Image: multiply its colors by `tint` (`0xAARRGGBB`).
431    pub fn tint(mut self, tint: u32) -> Self {
432        if let WidgetKind::Image(i) = &mut self.kind {
433            i.tint = tint;
434        }
435        self
436    }
437    /// Adds every widget in `children`, in order.
438    pub fn children(mut self, children: impl IntoIterator<Item = Widget>) -> Self {
439        self.children.extend(children);
440        self
441    }
442
443    /// The first widget in this subtree (depth-first, self included) whose
444    /// [`Widget::id`] is `id` — for changing a built tree before laying it
445    /// out (dynamic text, enabled state, ...).
446    pub fn find_mut(&mut self, id: &str) -> Option<&mut Widget> {
447        if self.id.as_deref() == Some(id) {
448            return Some(self);
449        }
450        self.children.iter_mut().find_map(|c| c.find_mut(id))
451    }
452
453    /// Replaces the text of a label, button or text box; `false` for any
454    /// other kind.
455    pub fn set_text(&mut self, text: impl Into<String>) -> bool {
456        match &mut self.kind {
457            WidgetKind::Label(t) | WidgetKind::Button(t) => {
458                *t = text.into();
459                true
460            }
461            WidgetKind::TextBox(t) => {
462                t.text = text.into();
463                true
464            }
465            _ => false,
466        }
467    }
468
469    pub fn padding(mut self, top: f32, right: f32, bottom: f32, left: f32) -> Self {
470        self.style.pad = [top, right, bottom, left];
471        self
472    }
473    pub fn margin(mut self, top: f32, right: f32, bottom: f32, left: f32) -> Self {
474        self.style.margin = [top, right, bottom, left];
475        self
476    }
477}
478
479// ── Widget constructors ───────────────────────────────────────────────────────
480
481pub fn panel(dir: FlexDir) -> Widget {
482    Widget::new(WidgetKind::Panel(dir))
483}
484/// A vertically scrolling column: clips its children, scrolls them by
485/// [`Widget::scroll_y`] and draws a scrollbar when they overflow — WPF's
486/// `ScrollViewer` around a `StackPanel`. Give it a fixed (or flex) height.
487pub fn scroll_view() -> Widget {
488    panel(FlexDir::Column).clip(true).scrollbar(true)
489}
490pub fn label(text: impl Into<String>) -> Widget {
491    Widget::new(WidgetKind::Label(text.into()))
492}
493pub fn button(text: impl Into<String>) -> Widget {
494    Widget::new(WidgetKind::Button(text.into()))
495}
496pub fn item_slot(item_id: impl Into<String>) -> Widget {
497    Widget::new(WidgetKind::ItemSlot(item_id.into()))
498}
499/// An inventory-slot decoration (icon + stack-count overlay) for `item_id`
500/// (empty string = empty slot) with `count` items. Purely decorative — real
501/// drag-and-drop still goes through the vanilla `Slot` this overlays.
502pub fn inv_slot(item_id: impl Into<String>, count: u32) -> Widget {
503    Widget::new(WidgetKind::InvSlot {
504        item_id: item_id.into(),
505        count,
506    })
507    .w(18.0)
508    .h(18.0)
509}
510/// A raw Minecraft texture (`"minecraft:textures/..."`), `img_w × img_h`
511/// pixels, stretched over the widget. Version-dependent: prefer [`image`].
512pub fn mc_image(id: impl Into<String>, img_w: f32, img_h: f32) -> Widget {
513    Widget::new(WidgetKind::McImage {
514        id: id.into(),
515        img_w,
516        img_h,
517    })
518    .w(img_w)
519    .h(img_h)
520}
521/// An image from a Yog image source — `"item:apple"`, `"block:stone"`,
522/// `"sprite:button"`, `"mymod:gui/logo"` (see [`yog_gfx::image`]) — the
523/// host resolves it for its Minecraft version. Sized to the image's natural
524/// size (16×16 until the host told it) unless given `w`/`h`; scaled into the
525/// widget per [`Widget::stretch`] (default `Uniform`), nearest-neighbour.
526pub fn image(source: impl Into<String>) -> Widget {
527    Widget::new(WidgetKind::Image(Image {
528        source: source.into(),
529        stretch: Stretch::Uniform,
530        region: None,
531        tint: 0xFFFF_FFFF,
532    }))
533}
534pub fn spacer() -> Widget {
535    Widget::new(WidgetKind::Spacer)
536}
537/// A single-line text input holding `text` — black box, grey border (white
538/// while focused), like vanilla's `EditBox`. Its text, caret and focus live
539/// in a [`TextBoxState`](crate::TextBoxState) that the code-behind keeps and
540/// applies to it each frame.
541pub fn text_box(text: impl Into<String>) -> Widget {
542    Widget::new(WidgetKind::TextBox(TextBox {
543        text: text.into(),
544        ..TextBox::default()
545    }))
546    .bg(0xFF_000000)
547    .color(0xFF_E0E0E0)
548    .padding(0.0, 4.0, 0.0, 4.0)
549}