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}