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