Skip to main content

yog_ui/
widget.rs

1//! Widget types and styling.
2
3use yog_gfx::{Color, Rect};
4
5use crate::layout::{Align, FlexDir};
6
7mod builder;
8mod make;
9
10/// The ids of inventory widgets ([`crate::inventory_ids`]).
11pub use crate::inventory_ids::{MACHINE_PREFIX, PLAYER_INVENTORY, SLOT_PREFIX};
12
13pub use make::{
14    HOTBAR_GAP, button, grid, image, inv_slot, item_slot, label, machine, panel, player_inventory,
15    scroll_view, slot, slot_accepting, spacer, surface, text_box,
16};
17
18/// A single widget in the UI tree.
19#[derive(Debug, Clone)]
20pub struct Widget {
21    pub kind: WidgetKind,
22    pub id: Option<String>,
23    pub style: Style,
24    pub flex_dir: FlexDir,
25    pub children: Vec<Widget>,
26    pub on_click: Option<String>,
27    pub enabled: bool,
28    pub focused: bool,
29    /// Typed click target set by generated code (yog-kit) — like
30    /// [`Self::on_click`], but no string to parse. See [`Action`].
31    pub action: Option<Action>,
32    /// `false` = collapsed: takes no space, isn't drawn and can't be hit
33    /// (its layout node is a zero rect without children).
34    pub visible: bool,
35    /// Interaction state (hover, pressed, selected, ...) the host sets each
36    /// frame — picks the [`Self::states`] patches applied when drawing.
37    pub state: StateFlags,
38    /// Paint-only style overrides per state (`hover:bg="..."` in `.yui`).
39    pub states: Option<Box<StateStyles>>,
40    /// Text shown in a tooltip while the cursor rests on it (`\n` breaks
41    /// lines) — see [`crate::UiRoot::tooltip_at`].
42    pub tooltip: Option<String>,
43}
44
45/// A typed click target: `owner` 0 = the screen, `k` = items control `k`;
46/// `slot` = the command (or [`Action::SELECT`]); `index` = the item index
47/// inside an items control (0 elsewhere).
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
49pub struct Action {
50    pub owner: u16,
51    pub slot: u16,
52    pub index: u32,
53}
54
55impl Action {
56    /// The `slot` of an items control's item container: clicking selects it.
57    pub const SELECT: u16 = u16::MAX;
58
59    /// A screen-level command `slot`.
60    pub const fn new(slot: u16) -> Self {
61        Self {
62            owner: 0,
63            slot,
64            index: 0,
65        }
66    }
67}
68
69/// Interaction state bits of a widget — see [`Widget::state`].
70#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
71pub struct StateFlags(pub u8);
72
73impl StateFlags {
74    pub const NONE: Self = Self(0);
75    pub const HOVER: Self = Self(1);
76    pub const PRESSED: Self = Self(2);
77    pub const FOCUSED: Self = Self(4);
78    pub const SELECTED: Self = Self(8);
79    pub const CHECKED: Self = Self(16);
80    pub const DISABLED: Self = Self(32);
81
82    /// Every bit of `o` is set.
83    pub const fn contains(self, o: Self) -> bool {
84        self.0 & o.0 == o.0
85    }
86    pub fn insert(&mut self, o: Self) {
87        self.0 |= o.0;
88    }
89    pub fn remove(&mut self, o: Self) {
90        self.0 &= !o.0;
91    }
92    /// Sets or clears the bits of `o`.
93    pub fn set(&mut self, o: Self, on: bool) {
94        if on { self.insert(o) } else { self.remove(o) }
95    }
96    pub const fn union(self, o: Self) -> Self {
97        Self(self.0 | o.0)
98    }
99    pub const fn is_empty(self) -> bool {
100        self.0 == 0
101    }
102}
103
104impl std::ops::BitOr for StateFlags {
105    type Output = Self;
106    fn bitor(self, o: Self) -> Self {
107        self.union(o)
108    }
109}
110
111/// Paint-only style overrides — never affect layout.
112#[derive(Debug, Clone, Default, PartialEq)]
113pub struct StylePatch {
114    pub bg: Option<Color>,
115    pub bg_image: Option<String>,
116    pub color: Option<Color>,
117    pub shadow: Option<bool>,
118    pub focus_color: Option<Color>,
119}
120
121impl StylePatch {
122    /// `true` when it overrides nothing.
123    pub fn is_empty(&self) -> bool {
124        *self == Self::default()
125    }
126
127    fn apply(&self, s: &mut Style) {
128        if let Some(v) = self.bg {
129            s.bg = v;
130        }
131        if let Some(v) = &self.bg_image {
132            s.bg_image.clone_from(v);
133        }
134        if let Some(v) = self.color {
135            s.color = v;
136        }
137        if let Some(v) = self.shadow {
138            s.text_shadow = v;
139        }
140        if let Some(v) = self.focus_color {
141            s.focus_color = v;
142        }
143    }
144}
145
146/// A widget's state patches: each applies when the widget's state contains
147/// its flags; later rules win.
148#[derive(Debug, Clone, Default, PartialEq)]
149pub struct StateStyles {
150    pub rules: Vec<(StateFlags, StylePatch)>,
151}
152
153#[derive(Debug, Clone)]
154pub enum WidgetKind {
155    /// Container — arranges children according to `flex_dir`.
156    Panel(FlexDir),
157    /// Container — WPF's `Grid`: rows and columns sized in pixels, to
158    /// content (`Auto`) or as shares of the rest (`*`); each child sits in
159    /// the cell(s) its [`Style::cell`] names — see [`grid`] and
160    /// [`GridSpec`].
161    Grid(Box<GridSpec>),
162    /// Static or dynamic text label.
163    Label(String),
164    /// Clickable button with text.
165    Button(String),
166    /// Minecraft item icon (static, known at build time).
167    ItemSlot(String),
168    /// Inventory-slot decoration: an item icon plus a stack-count overlay,
169    /// both supplied as plain data at tree-build time (like every other
170    /// widget here) — this crate has no notion of "the currently open menu"
171    /// or how to query it; that's the caller's job (see `yog-inventory`).
172    /// Empty `item_id` draws an empty slot box.
173    InvSlot { item_id: String, count: u32 },
174    /// An image from a Yog image source (`item:apple`, `sprite:button`,
175    /// `mymod:gui/logo`, ...) — see [`Image`] and [`image`].
176    Image(Image),
177    /// Invisible spacer.
178    Spacer,
179    /// Drawn by the code: the [`Surface`](crate::Surface) registered under
180    /// this id (see [`crate::surface`] and [`surface`]).
181    Surface(String),
182    /// Single-line text input — see [`TextBox`] and
183    /// [`TextBoxState`](crate::TextBoxState), which owns the text and drives it.
184    TextBox(TextBox),
185}
186
187/// How a grid row or column is sized — WPF's `GridLength`.
188#[derive(Debug, Clone, Copy, PartialEq)]
189pub enum GridLength {
190    /// Fits its content: the largest child in it (`Auto`).
191    Auto,
192    /// A fixed size in GUI pixels (`26`).
193    Px(f32),
194    /// A share of the space the other rows/columns leave (`*` = `Star(1.0)`,
195    /// `2*` = `Star(2.0)`). Along an axis being measured for content (an
196    /// auto-sized grid in a scroll view, ...) it acts as [`Self::Auto`].
197    Star(f32),
198}
199
200impl GridLength {
201    /// `*`.
202    pub const STAR: Self = Self::Star(1.0);
203
204    /// A size as `.yui` writes it: `Auto` (any case), `*`, `2*`, `26`.
205    pub fn parse(text: &str) -> Option<GridLength> {
206        let text = text.trim();
207        if text.eq_ignore_ascii_case(AUTO) {
208            return Some(GridLength::Auto);
209        }
210        if let Some(weight) = text.strip_suffix(STAR) {
211            let weight = weight.trim();
212            return if weight.is_empty() {
213                Some(GridLength::STAR)
214            } else {
215                weight.parse().ok().map(GridLength::Star)
216            };
217        }
218        text.parse().ok().map(GridLength::Px)
219    }
220}
221
222/// How `.yui` writes [`GridLength::Auto`] and a share ([`GridLength::Star`]).
223const AUTO: &str = "auto";
224const STAR: char = '*';
225
226/// One row or column of a [`GridSpec`]: its size and the limits on it.
227#[derive(Debug, Clone, Copy, PartialEq)]
228pub struct Track {
229    pub size: GridLength,
230    /// Never smaller than this (WPF's `MinHeight`/`MinWidth`).
231    pub min: f32,
232    /// Never larger than this (`f32::INFINITY` = no limit).
233    pub max: f32,
234}
235
236impl Track {
237    pub const fn new(size: GridLength) -> Self {
238        Self {
239            size,
240            min: 0.0,
241            max: f32::INFINITY,
242        }
243    }
244    /// `Auto`.
245    pub const fn auto() -> Self {
246        Self::new(GridLength::Auto)
247    }
248    /// A fixed `px`.
249    pub const fn px(px: f32) -> Self {
250        Self::new(GridLength::Px(px))
251    }
252    /// `weight*`.
253    pub const fn star(weight: f32) -> Self {
254        Self::new(GridLength::Star(weight))
255    }
256    pub const fn min(mut self, v: f32) -> Self {
257        self.min = v;
258        self
259    }
260    pub const fn max(mut self, v: f32) -> Self {
261        self.max = v;
262        self
263    }
264}
265
266impl From<GridLength> for Track {
267    fn from(size: GridLength) -> Self {
268        Self::new(size)
269    }
270}
271
272/// A grid's rows, columns and the space between them. No rows (columns)
273/// = one `*` row (column), like WPF.
274#[derive(Debug, Clone, Default, PartialEq)]
275pub struct GridSpec {
276    pub rows: Vec<Track>,
277    pub cols: Vec<Track>,
278    /// Space between rows (WinUI's `RowSpacing`).
279    pub row_spacing: f32,
280    /// Space between columns (WinUI's `ColumnSpacing`).
281    pub col_spacing: f32,
282}
283
284/// The grid cell(s) a widget occupies — WPF's `Grid.Row`, `Grid.Column`,
285/// `Grid.RowSpan`, `Grid.ColumnSpan`. Only a [`grid`] reads it; out-of-range
286/// values are clamped to the grid (like WPF).
287#[derive(Debug, Clone, Copy, PartialEq, Eq)]
288pub struct Cell {
289    pub row: u16,
290    pub col: u16,
291    /// Rows spanned, at least 1.
292    pub row_span: u16,
293    /// Columns spanned, at least 1.
294    pub col_span: u16,
295}
296
297impl Default for Cell {
298    fn default() -> Self {
299        Self {
300            row: 0,
301            col: 0,
302            row_span: 1,
303            col_span: 1,
304        }
305    }
306}
307
308/// Where a widget sits horizontally in the space its parent gives it — WPF's
309/// `HorizontalAlignment`. In a grid cell `None` means `Stretch`; in a
310/// Column it overrides the parent's `align` for this child.
311#[derive(Debug, Clone, Copy, PartialEq, Eq)]
312pub enum HAlign {
313    Left,
314    Center,
315    Right,
316    /// Fill the width (unless the widget has an explicit `w`).
317    Stretch,
318}
319
320/// Where a widget sits vertically — WPF's `VerticalAlignment`; see [`HAlign`].
321#[derive(Debug, Clone, Copy, PartialEq, Eq)]
322pub enum VAlign {
323    Top,
324    Center,
325    Bottom,
326    /// Fill the height (unless the widget has an explicit `h`).
327    Stretch,
328}
329
330/// An image widget's data — see [`image`].
331#[derive(Debug, Clone, PartialEq)]
332pub struct Image {
333    /// A Yog image source (see [`yog_gfx::image`]).
334    pub source: String,
335    /// How the image fills the widget (default [`Stretch::Uniform`]).
336    pub stretch: Stretch,
337    /// The part of the image drawn, `[u, v, w, h]` in the image's pixels
338    /// (`None` = all of it). Textures only — items and sprites are whole.
339    pub region: Option<Rect>,
340    /// Color multiplier, `0xAARRGGBB` (`0xFFFFFFFF` = none; items ignore it).
341    pub tint: Color,
342}
343
344pub use yog_gfx::image::Stretch;
345
346/// A text box's content and editing state, as drawn this frame. Built by
347/// [`text_box`] (text, placeholder, max length, event names); the caret,
348/// selection and horizontal scroll are filled in each frame by
349/// [`TextBoxState::apply`](crate::TextBoxState::apply).
350#[derive(Debug, Clone, Default, PartialEq)]
351pub struct TextBox {
352    pub text: String,
353    /// Shown greyed while the text is empty.
354    pub placeholder: String,
355    /// Longest text allowed, in characters; 0 = unlimited.
356    pub max_length: usize,
357    /// Event name for text changes (see `TextBoxState::event_name`).
358    pub on_change: Option<String>,
359    /// Event name for Enter (see `TextBoxState::event_name`).
360    pub on_submit: Option<String>,
361    /// Caret position in characters — `Some` while the box has focus.
362    pub caret: Option<usize>,
363    /// Draw the caret this frame (it blinks).
364    pub caret_visible: bool,
365    /// Selected characters `start..end` (empty = none).
366    pub selection: (usize, usize),
367    /// First visible character (horizontal scroll).
368    pub first: usize,
369}
370
371/// Side of an item / inventory slot box in GUI pixels (vanilla's 18).
372pub const SLOT_SIZE: f32 = 18.0;
373/// A `<Screen>`'s size when it doesn't give one: the game's container screens' (176 × 166).
374pub const SCREEN_W: f32 = 176.0;
375pub const SCREEN_H: f32 = 166.0;
376/// Focus indicator color when `focus_color` is 0 (amber).
377pub const DEFAULT_FOCUS_COLOR: Color = Color(0xFF_FFE040);
378
379/// A text box's default left/right padding.
380pub(crate) const TEXT_BOX_PAD_X: f32 = 4.0;
381/// Placeholder text color of a text box.
382pub const PLACEHOLDER_COLOR: Color = Color(0xFF_707070);
383/// Border of an unfocused text box (vanilla `EditBox`).
384pub const TEXT_BOX_BORDER: Color = Color(0xFF_A0A0A0);
385/// A text box's text, as vanilla's `EditBox` draws it.
386pub const TEXT_BOX_TEXT: Color = Color(0xFF_E0E0E0);
387/// Border of a focused text box unless `focus_color` is set.
388pub const TEXT_BOX_FOCUS_BORDER: Color = Color(0xFF_FFFFFF);
389/// Selection highlight behind selected text.
390pub const SELECTION_COLOR: Color = Color(0xFF_2850A0);
391
392/// WinForms-style dock — which edge(s) the widget attaches to inside its parent.
393///
394/// - `None`   — normal flex positioning (default)
395/// - `Fill`   — stretch to fill all remaining space (both axes)
396/// - `Left`   — full height, natural width, hugs left edge; other children flow right
397/// - `Right`  — full height, natural width, hugs right edge
398/// - `Top`    — full width, natural height, hugs top edge; other children flow down
399/// - `Bottom` — full width, natural height, hugs bottom edge
400#[derive(Debug, Clone, Copy, PartialEq, Default)]
401pub enum Dock {
402    #[default]
403    None,
404    Fill,
405    Left,
406    Right,
407    Top,
408    Bottom,
409}
410
411/// How a focused widget shows its focus indicator.
412#[derive(Debug, Clone, Copy, PartialEq, Default)]
413pub enum FocusStyle {
414    /// 1px outline using `focus_color` (default, amber).
415    #[default]
416    Outline,
417    /// Solid background fill using `focus_color`.
418    Fill,
419    /// No visual indicator.
420    None,
421}
422
423/// A size value — either a fixed pixel amount, a CSS-style percentage of the
424/// parent's own resolved content size (0.0–100.0), or `Auto` (size to
425/// content/available space, this crate's original behaviour).
426#[derive(Debug, Clone, Copy, PartialEq, Default)]
427pub enum Units {
428    #[default]
429    Auto,
430    Px(f32),
431    /// Percent of the parent's resolved content size along the same axis.
432    Percent(f32),
433}
434
435impl From<f32> for Units {
436    /// `0.0` maps to `Auto` (matches this crate's original `0.0 = auto` convention,
437    /// so every existing `.w(18.0)`/`.h(0.0)` call keeps meaning exactly what it did).
438    fn from(v: f32) -> Self {
439        if v <= 0.0 { Units::Auto } else { Units::Px(v) }
440    }
441}
442
443/// A whole, in [`Units::Percent`].
444const PERCENT: f32 = 100.0;
445/// A widget's least size when its style gives none.
446const MIN_SIZE: f32 = 4.0;
447/// Text's color when its style gives none.
448pub const DEFAULT_TEXT_COLOR: Color = Color(0xFF_CCCCAA);
449
450impl Units {
451    /// Resolve against `avail` (the parent's resolved content size on this
452    /// axis). `None` for `Auto` — caller falls back to its own auto-sizing.
453    pub fn resolve(self, avail: f32) -> Option<f32> {
454        match self {
455            Units::Auto => None,
456            Units::Px(v) => Some(v),
457            Units::Percent(p) => Some(avail * p / PERCENT),
458        }
459    }
460
461    pub fn is_auto(self) -> bool {
462        matches!(self, Units::Auto)
463    }
464}
465
466/// Visual and layout style for a widget.
467#[derive(Debug, Clone)]
468pub struct Style {
469    pub w: Units, // explicit width; Auto = size to content/available space
470    pub h: Units, // explicit height; Auto = size to content/available space
471    pub min_w: f32,
472    pub min_h: f32,
473    pub flex: f32,        // grow factor inside flex container (main axis)
474    pub dock: Dock,       // WinForms-style edge attachment
475    pub gap: f32,         // spacing between children
476    pub pad: [f32; 4],    // top, right, bottom, left — space INSIDE the border
477    pub margin: [f32; 4], // top, right, bottom, left — space OUTSIDE the border
478    /// The grid cell(s) it occupies inside a [`grid`] — see [`Cell`].
479    pub cell: Cell,
480    /// Horizontal placement in its slot (grid cell, or a Column's width) —
481    /// see [`HAlign`]. `None` = the parent's default.
482    pub halign: Option<HAlign>,
483    /// Vertical placement in its slot (grid cell, or a Row's height) — see
484    /// [`VAlign`]. `None` = the parent's default.
485    pub valign: Option<VAlign>,
486    pub bg: Color, // background color; transparent = none
487    /// Background image, drawn over `bg` and stretched to the box — a Yog
488    /// image source (`sprite:button`, `sprite:panel`, `mymod:gui/frame`);
489    /// sprites are nine-sliced, so frames keep their edges. Empty = none.
490    pub bg_image: String,
491    pub color: Color, // text color
492    pub align: Align,
493    pub font_scale: f32,
494    pub text_shadow: bool, // MC drop-shadow behind text (HUD default; books disable it)
495    pub focus_style: FocusStyle,
496    pub focus_color: Color, // transparent = DEFAULT_FOCUS_COLOR
497    /// Labels/buttons: never word-wrap, even if narrower than the text.
498    /// For single-line titles/headers that must stay on one line — matches
499    /// Patchouli, where such text overflows its box horizontally rather than
500    /// wrapping onto a second line.
501    pub no_wrap: bool,
502    /// Absolute position `(x, y)` inside the parent's content box (inside its
503    /// padding) — WPF `Canvas` / WinForms `Location` style. A positioned
504    /// widget is taken out of the parent's flex flow: it takes no main-axis
505    /// space, ignores `flex`/`dock`, and is drawn in tree order (later
506    /// siblings on top). `None` (default) = normal flex positioning.
507    pub pos: Option<(f32, f32)>,
508    /// Labels/buttons: a single line (implies `no_wrap`) cut with `...` when
509    /// wider than the box, instead of overflowing it.
510    pub ellipsis: bool,
511    /// Containers: children are drawn (and hit) only inside this widget's
512    /// rect — anything scrolled or overflowing past it is cut off.
513    pub clip: bool,
514    /// Containers: vertical scroll offset of the children in GUI pixels
515    /// (0 = top). Layout clamps it to the content — the effective value is
516    /// [`LayoutNode::scroll_y`](crate::LayoutNode::scroll_y).
517    pub scroll_y: f32,
518    /// Containers: reserve a strip on the right edge and draw a vertical
519    /// scrollbar there whenever the content overflows (see
520    /// [`ScrollState`](crate::ScrollState) for driving it).
521    pub scrollbar: bool,
522    /// Font id for text — empty (default) = the game's default font
523    /// (`yog:font/default`); a Yog font such as `mymod:fancy`, or another
524    /// game font by Yog's alias: `yog:font/uniform`, `yog:font/alt`,
525    /// `yog:font/illageralt`.
526    /// See [`crate::text`] for how non-default fonts are measured.
527    pub font: String,
528}
529
530impl Default for Style {
531    fn default() -> Self {
532        Self {
533            w: Units::Auto,
534            h: Units::Auto,
535            min_w: MIN_SIZE,
536            min_h: MIN_SIZE,
537            flex: 0.0,
538            dock: Dock::None,
539            gap: 2.0,
540            pad: [0.0; 4],
541            margin: [0.0; 4],
542            cell: Cell::default(),
543            halign: None,
544            valign: None,
545            bg: Color::TRANSPARENT,
546            bg_image: String::new(),
547            color: DEFAULT_TEXT_COLOR,
548            align: Align::Start,
549            font_scale: 1.0,
550            text_shadow: true,
551            focus_style: FocusStyle::default(),
552            focus_color: Color::TRANSPARENT,
553            no_wrap: false,
554            pos: None,
555            ellipsis: false,
556            clip: false,
557            scroll_y: 0.0,
558            scrollbar: false,
559            font: String::new(),
560        }
561    }
562}