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}