Skip to main content

yog_gfx/
image.rs

1//! Yog image sources — the names every Yog API that shows a picture takes
2//! instead of raw Minecraft resource paths, so mods don't depend on the game
3//! version, the loader or where Mojang keeps a texture this release.
4//!
5//! | Source | Draws |
6//! |---|---|
7//! | `item:<id>` | an item's icon through the item renderer (3D block items, modded items), e.g. `item:apple`, `item:mymod:wand` |
8//! | `block:<id>` | a block's item form, e.g. `block:crafting_table` |
9//! | `sprite:<name>` | a vanilla GUI sprite by Yog's stable name (see [`SPRITES`]), e.g. `sprite:button`, `sprite:icon/heart` |
10//! | `mod:<path>` | the mod's own image `assets/<mod id>/textures/<path>.png` (`.yui` files; `yog build` fills in the mod id) |
11//! | `<ns>:<path>` | `assets/<ns>/textures/<path>.png` — another mod's (or your own) image, e.g. `mymod:gui/logo` |
12//! | `<ns>:textures/<path>.png` | a raw resource id — what `mod:`/`<ns>:<path>` resolve to; `.yui` files reject it in favour of the alias ([`suggest_alias`]) |
13//!
14//! Ids without a namespace are `minecraft:`. The host resolves a source when
15//! it draws it ([`Draw2D::image`](crate::draw2d::Draw2D::image)) — sprites
16//! map to the GUI atlas on Minecraft 1.20.2+ and to `textures/gui/*.png`
17//! regions on 1.20.1 — so the same mod binary draws right on every version.
18//! This module is the shared definition (the runtime and Yog-IDLE's designer
19//! resolve with it); mods normally only pass strings.
20
21/// A parsed image source — see the [module docs](self).
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub enum ImageSource {
24    /// `item:<id>` — a namespaced item id.
25    Item(String),
26    /// `block:<id>` — a namespaced block id (drawn as its item).
27    Block(String),
28    /// `sprite:<name>` — a [`SPRITES`] name, or a raw GUI-atlas sprite id
29    /// when it has a namespace (`sprite:minecraft:widget/button`, 1.20.2+).
30    Sprite(String),
31    /// `mod:<path>` — the calling mod's own texture; needs the mod id
32    /// (see [`ImageSource::with_mod_id`]).
33    ModPath(String),
34    /// A texture resource id, `<ns>:textures/<path>.png`.
35    Texture(String),
36}
37
38/// The namespace of ids that don't name one.
39const DEFAULT_NS: &str = "minecraft";
40/// Side of the square 1.20.1 GUI textures most legacy regions live in.
41const LEGACY_TEX_SIZE: f32 = 256.0;
42
43/// `apple` → `minecraft:apple`; namespaced ids are kept.
44pub fn qualify(id: &str) -> String {
45    let id = id.trim();
46    if id.contains(':') {
47        id.to_string()
48    } else {
49        format!("{DEFAULT_NS}:{id}")
50    }
51}
52
53/// `<ns>` + `<path>` → the texture resource id: `gui/logo` →
54/// `<ns>:textures/gui/logo.png`; paths already under `textures/` (or
55/// ending in `.png`) are kept as they are.
56pub fn texture_id(ns: &str, path: &str) -> String {
57    let path = path.trim_start_matches('/');
58    if path.starts_with("textures/") || path.ends_with(".png") {
59        let png = if path.ends_with(".png") { "" } else { ".png" };
60        format!("{ns}:{path}{png}")
61    } else {
62        format!("{ns}:textures/{path}.png")
63    }
64}
65
66impl ImageSource {
67    /// Parses a source string; `None` for an empty or malformed one.
68    pub fn parse(source: &str) -> Option<ImageSource> {
69        let s = source.trim();
70        let (scheme, rest) = s.split_once(':')?;
71        if rest.is_empty() {
72            return None;
73        }
74        Some(match scheme {
75            "item" => ImageSource::Item(qualify(rest)),
76            "block" => ImageSource::Block(qualify(rest)),
77            "sprite" => ImageSource::Sprite(rest.to_string()),
78            "mod" => ImageSource::ModPath(rest.to_string()),
79            ns if !ns.is_empty() => ImageSource::Texture(texture_id(ns, rest)),
80            _ => return None,
81        })
82    }
83
84    /// Resolves `mod:<path>` against `mod_id` (others are unchanged).
85    pub fn with_mod_id(self, mod_id: &str) -> ImageSource {
86        match self {
87            ImageSource::ModPath(path) => ImageSource::Texture(texture_id(mod_id, &path)),
88            other => other,
89        }
90    }
91}
92
93/// `<ns>:textures/<path>.png` → the Yog source a `.yui` file or a mod should
94/// use instead: `item:` for item textures, `block:` for block textures, a
95/// `sprite:` with the same image, or the short `<ns>:<path>` form. `None`
96/// when `id` isn't a raw texture id.
97pub fn suggest_alias(id: &str) -> Option<String> {
98    let (ns, path) = id.trim().split_once(':')?;
99    let path = path.strip_prefix("textures/")?;
100    let stem = path.strip_suffix(".png").unwrap_or(path);
101    // `minecraft:` is the default namespace of every alias.
102    let short = |id: &str| -> String {
103        if ns == DEFAULT_NS {
104            id.to_string()
105        } else {
106            format!("{ns}:{id}")
107        }
108    };
109    if let Some(item) = stem.strip_prefix("item/") {
110        return Some(format!("item:{}", short(item)));
111    }
112    if let Some(block) = stem.strip_prefix("block/") {
113        return Some(format!("block:{}", short(block)));
114    }
115    if ns == DEFAULT_NS
116        && let Some(atlas_id) = stem.strip_prefix("gui/sprites/")
117        && let Some(sprite) = SPRITES.iter().find(|s| s.atlas == Some(atlas_id))
118    {
119        return Some(format!("sprite:{}", sprite.name));
120    }
121    Some(format!("{ns}:{stem}"))
122}
123
124/// A region of a texture on Minecraft 1.20.1 (before the GUI sprite atlas).
125#[derive(Debug, Clone, Copy, PartialEq)]
126pub struct LegacyRegion {
127    /// Texture resource id.
128    pub texture: &'static str,
129    pub u: f32,
130    pub v: f32,
131    pub w: f32,
132    pub h: f32,
133    /// Full texture size.
134    pub tex_w: f32,
135    pub tex_h: f32,
136}
137
138/// One entry of Yog's GUI sprite table.
139#[derive(Debug, Clone, Copy, PartialEq)]
140pub struct Sprite {
141    /// The stable Yog name (`sprite:<name>`).
142    pub name: &'static str,
143    /// What it is, for editors' pickers.
144    pub description: &'static str,
145    /// Natural size in GUI pixels (1.20.2+; 1.20.1 uses the legacy region's).
146    pub w: f32,
147    pub h: f32,
148    /// GUI-atlas sprite id on Minecraft 1.20.2+ (`minecraft:<id>`, i.e.
149    /// `textures/gui/sprites/<id>.png`).
150    pub atlas: Option<&'static str>,
151    /// Where it lives on 1.20.1.
152    pub legacy: Option<LegacyRegion>,
153    /// Nine-slice border in pixels when stretched (0 = scale the whole image).
154    pub border: f32,
155}
156
157impl Sprite {
158    /// Natural size on a host with (`true`) or without the GUI sprite atlas.
159    pub fn size(&self, atlas: bool) -> (f32, f32) {
160        match (atlas, self.legacy) {
161            (false, Some(l)) => (l.w, l.h),
162            _ => (self.w, self.h),
163        }
164    }
165
166    /// Drawn by Yog itself (no texture on any version) — the inventory slot.
167    pub fn is_procedural(&self) -> bool {
168        self.atlas.is_none() && self.legacy.is_none()
169    }
170}
171
172/// Looks up a [`SPRITES`] entry by its Yog name.
173pub fn sprite(name: &str) -> Option<&'static Sprite> {
174    SPRITES.iter().find(|s| s.name == name.trim())
175}
176
177const WIDGETS: &str = "minecraft:textures/gui/widgets.png";
178const ICONS: &str = "minecraft:textures/gui/icons.png";
179const TABS: &str = "minecraft:textures/gui/container/creative_inventory/tabs.png";
180const CHECKBOX: &str = "minecraft:textures/gui/checkbox.png";
181/// The demo screen's window — vanilla's plain grey window frame, the same
182/// texture on every version.
183const WINDOW: &str = "minecraft:textures/gui/demo_background.png";
184
185/// A region of a [`LEGACY_TEX_SIZE`]-square texture.
186const fn region(texture: &'static str, u: f32, v: f32, w: f32, h: f32) -> Option<LegacyRegion> {
187    region_of(texture, LEGACY_TEX_SIZE, u, v, w, h)
188}
189
190const fn region_of(
191    texture: &'static str,
192    size: f32,
193    u: f32,
194    v: f32,
195    w: f32,
196    h: f32,
197) -> Option<LegacyRegion> {
198    Some(LegacyRegion {
199        texture,
200        u,
201        v,
202        w,
203        h,
204        tex_w: size,
205        tex_h: size,
206    })
207}
208
209#[allow(clippy::too_many_arguments)]
210const fn sprite_def(
211    name: &'static str,
212    description: &'static str,
213    w: f32,
214    h: f32,
215    atlas: &'static str,
216    legacy: Option<LegacyRegion>,
217    border: f32,
218) -> Sprite {
219    Sprite {
220        name,
221        description,
222        w,
223        h,
224        atlas: Some(atlas),
225        legacy,
226        border,
227    }
228}
229
230/// Yog's GUI sprite names. Adding one: pick a stable name, its 1.20.2+
231/// atlas id (`assets/minecraft/textures/gui/sprites/<id>.png` in the client
232/// jar) and its 1.20.1 region (`textures/gui/*.png`, in pixels).
233pub const SPRITES: &[Sprite] = &[
234    sprite_def(
235        "button",
236        "Button background",
237        200.0,
238        20.0,
239        "widget/button",
240        region(WIDGETS, 0.0, 66.0, 200.0, 20.0),
241        3.0,
242    ),
243    sprite_def(
244        "button_hover",
245        "Button background, hovered/focused",
246        200.0,
247        20.0,
248        "widget/button_highlighted",
249        region(WIDGETS, 0.0, 86.0, 200.0, 20.0),
250        3.0,
251    ),
252    sprite_def(
253        "button_disabled",
254        "Button background, disabled",
255        200.0,
256        20.0,
257        "widget/button_disabled",
258        region(WIDGETS, 0.0, 46.0, 200.0, 20.0),
259        3.0,
260    ),
261    Sprite {
262        name: "panel",
263        description: "Window background (vanilla's grey frame)",
264        w: 248.0,
265        h: 166.0,
266        atlas: None,
267        legacy: region(WINDOW, 0.0, 0.0, 248.0, 166.0),
268        border: 4.0,
269    },
270    Sprite {
271        name: "slot",
272        description: "Inventory slot (18×18)",
273        w: 18.0,
274        h: 18.0,
275        atlas: None,
276        legacy: None,
277        border: 1.0,
278    },
279    sprite_def(
280        "scrollbar",
281        "Scrollbar knob (creative inventory)",
282        12.0,
283        15.0,
284        "container/creative_inventory/scroller",
285        region(TABS, 232.0, 0.0, 12.0, 15.0),
286        0.0,
287    ),
288    sprite_def(
289        "scrollbar_disabled",
290        "Scrollbar knob, disabled",
291        12.0,
292        15.0,
293        "container/creative_inventory/scroller_disabled",
294        region(TABS, 244.0, 0.0, 12.0, 15.0),
295        0.0,
296    ),
297    sprite_def(
298        "checkbox",
299        "Checkbox, unchecked",
300        20.0,
301        20.0,
302        "widget/checkbox",
303        region_of(CHECKBOX, 64.0, 0.0, 0.0, 20.0, 20.0),
304        0.0,
305    ),
306    sprite_def(
307        "checkbox_checked",
308        "Checkbox, checked",
309        20.0,
310        20.0,
311        "widget/checkbox_selected",
312        region_of(CHECKBOX, 64.0, 0.0, 20.0, 20.0, 20.0),
313        0.0,
314    ),
315    sprite_def(
316        "hotbar",
317        "Hotbar",
318        182.0,
319        22.0,
320        "hud/hotbar",
321        region(WIDGETS, 0.0, 0.0, 182.0, 22.0),
322        0.0,
323    ),
324    sprite_def(
325        "hotbar_selection",
326        "Hotbar selection frame",
327        24.0,
328        23.0,
329        "hud/hotbar_selection",
330        region(WIDGETS, 0.0, 22.0, 24.0, 24.0),
331        0.0,
332    ),
333    sprite_def(
334        "crosshair",
335        "Crosshair",
336        15.0,
337        15.0,
338        "hud/crosshair",
339        region(ICONS, 0.0, 0.0, 15.0, 15.0),
340        0.0,
341    ),
342    sprite_def(
343        "xp_bar",
344        "Experience bar, empty",
345        182.0,
346        5.0,
347        "hud/experience_bar_background",
348        region(ICONS, 0.0, 64.0, 182.0, 5.0),
349        0.0,
350    ),
351    sprite_def(
352        "xp_bar_progress",
353        "Experience bar, full",
354        182.0,
355        5.0,
356        "hud/experience_bar_progress",
357        region(ICONS, 0.0, 69.0, 182.0, 5.0),
358        0.0,
359    ),
360    sprite_def(
361        "icon/heart",
362        "Heart",
363        9.0,
364        9.0,
365        "hud/heart/full",
366        region(ICONS, 52.0, 0.0, 9.0, 9.0),
367        0.0,
368    ),
369    sprite_def(
370        "icon/heart_half",
371        "Half heart",
372        9.0,
373        9.0,
374        "hud/heart/half",
375        region(ICONS, 61.0, 0.0, 9.0, 9.0),
376        0.0,
377    ),
378    sprite_def(
379        "icon/heart_empty",
380        "Heart container (empty)",
381        9.0,
382        9.0,
383        "hud/heart/container",
384        region(ICONS, 16.0, 0.0, 9.0, 9.0),
385        0.0,
386    ),
387    sprite_def(
388        "icon/food",
389        "Hunger shank",
390        9.0,
391        9.0,
392        "hud/food_full",
393        region(ICONS, 52.0, 27.0, 9.0, 9.0),
394        0.0,
395    ),
396    sprite_def(
397        "icon/food_half",
398        "Half hunger shank",
399        9.0,
400        9.0,
401        "hud/food_half",
402        region(ICONS, 61.0, 27.0, 9.0, 9.0),
403        0.0,
404    ),
405    sprite_def(
406        "icon/food_empty",
407        "Hunger shank, empty",
408        9.0,
409        9.0,
410        "hud/food_empty",
411        region(ICONS, 16.0, 27.0, 9.0, 9.0),
412        0.0,
413    ),
414    sprite_def(
415        "icon/armor",
416        "Armor",
417        9.0,
418        9.0,
419        "hud/armor_full",
420        region(ICONS, 34.0, 9.0, 9.0, 9.0),
421        0.0,
422    ),
423    sprite_def(
424        "icon/armor_half",
425        "Half armor",
426        9.0,
427        9.0,
428        "hud/armor_half",
429        region(ICONS, 25.0, 9.0, 9.0, 9.0),
430        0.0,
431    ),
432    sprite_def(
433        "icon/armor_empty",
434        "Armor, empty",
435        9.0,
436        9.0,
437        "hud/armor_empty",
438        region(ICONS, 16.0, 9.0, 9.0, 9.0),
439        0.0,
440    ),
441    sprite_def(
442        "icon/air",
443        "Air bubble",
444        9.0,
445        9.0,
446        "hud/air",
447        region(ICONS, 16.0, 18.0, 9.0, 9.0),
448        0.0,
449    ),
450    sprite_def(
451        "icon/air_bursting",
452        "Air bubble, bursting",
453        9.0,
454        9.0,
455        "hud/air_bursting",
456        region(ICONS, 25.0, 18.0, 9.0, 9.0),
457        0.0,
458    ),
459];
460
461/// How an image fills the box it's given — WPF's `Stretch`.
462#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
463#[repr(u8)]
464pub enum Stretch {
465    /// Natural size, centered; cut to the box when larger.
466    None = 0,
467    /// The whole box (aspect ratio not kept).
468    Fill = 1,
469    /// As large as fits, aspect ratio kept, centered.
470    #[default]
471    Uniform = 2,
472    /// Covers the box, aspect ratio kept, centered; the overflow is cut.
473    UniformToFill = 3,
474}
475
476/// Where a `nat_w × nat_h` image goes in the box `(x, y, w, h)` under
477/// `stretch`: `(dest, src_fraction)` — the destination rect and the part of
478/// the image shown, as fractions `(u0, v0, u1, v1)` of the image (less than
479/// the whole for `None`/`UniformToFill` images bigger than the box).
480pub fn fit(
481    stretch: Stretch,
482    nat_w: f32,
483    nat_h: f32,
484    x: f32,
485    y: f32,
486    w: f32,
487    h: f32,
488) -> ([f32; 4], [f32; 4]) {
489    if nat_w <= 0.0 || nat_h <= 0.0 || w <= 0.0 || h <= 0.0 {
490        return ([x, y, 0.0, 0.0], [0.0, 0.0, 1.0, 1.0]);
491    }
492    let scale = match stretch {
493        Stretch::Fill => return ([x, y, w, h], [0.0, 0.0, 1.0, 1.0]),
494        Stretch::None => 1.0,
495        Stretch::Uniform => (w / nat_w).min(h / nat_h),
496        Stretch::UniformToFill => (w / nat_w).max(h / nat_h),
497    };
498    let (dw, dh) = (nat_w * scale, nat_h * scale);
499    // Per axis: centered when it fits, else cut evenly on both sides.
500    let axis = |pos: f32, size: f32, d: f32| -> (f32, f32, f32, f32) {
501        if d <= size {
502            ((pos + (size - d) / 2.0).floor(), d, 0.0, 1.0)
503        } else {
504            let cut = (d - size) / 2.0 / d;
505            (pos, size, cut, 1.0 - cut)
506        }
507    };
508    let (dx, dw, u0, u1) = axis(x, w, dw);
509    let (dy, dh, v0, v1) = axis(y, h, dh);
510    ([dx, dy, dw, dh], [u0, v0, u1, v1])
511}
512
513#[cfg(test)]
514mod tests {
515    use super::*;
516
517    #[test]
518    fn parses_sources() {
519        assert_eq!(
520            ImageSource::parse("item:apple"),
521            Some(ImageSource::Item("minecraft:apple".into()))
522        );
523        assert_eq!(
524            ImageSource::parse("item:mymod:wand"),
525            Some(ImageSource::Item("mymod:wand".into()))
526        );
527        assert_eq!(
528            ImageSource::parse("block:stone"),
529            Some(ImageSource::Block("minecraft:stone".into()))
530        );
531        assert_eq!(
532            ImageSource::parse("sprite:button"),
533            Some(ImageSource::Sprite("button".into()))
534        );
535        assert_eq!(
536            ImageSource::parse("mymod:gui/logo"),
537            Some(ImageSource::Texture("mymod:textures/gui/logo.png".into()))
538        );
539        assert_eq!(
540            ImageSource::parse("minecraft:textures/item/apple.png"),
541            Some(ImageSource::Texture(
542                "minecraft:textures/item/apple.png".into()
543            ))
544        );
545        assert_eq!(
546            ImageSource::parse("mod:gui/logo").map(|s| s.with_mod_id("mymod")),
547            Some(ImageSource::Texture("mymod:textures/gui/logo.png".into()))
548        );
549        assert_eq!(ImageSource::parse(""), None);
550        assert_eq!(ImageSource::parse("apple"), None);
551        assert_eq!(ImageSource::parse("item:"), None);
552    }
553
554    #[test]
555    fn suggests_aliases_for_raw_paths() {
556        assert_eq!(
557            suggest_alias("minecraft:textures/item/apple.png").as_deref(),
558            Some("item:apple")
559        );
560        assert_eq!(
561            suggest_alias("mymod:textures/block/ore.png").as_deref(),
562            Some("block:mymod:ore")
563        );
564        assert_eq!(
565            suggest_alias("minecraft:textures/gui/widgets.png").as_deref(),
566            Some("minecraft:gui/widgets")
567        );
568        assert_eq!(
569            suggest_alias("minecraft:textures/gui/sprites/widget/button.png").as_deref(),
570            Some("sprite:button")
571        );
572        assert_eq!(
573            suggest_alias("mymod:textures/gui/logo.png").as_deref(),
574            Some("mymod:gui/logo")
575        );
576        assert_eq!(suggest_alias("item:apple"), None);
577    }
578
579    #[test]
580    fn sprite_names_are_unique() {
581        for (i, s) in SPRITES.iter().enumerate() {
582            assert!(
583                SPRITES[i + 1..].iter().all(|o| o.name != s.name),
584                "{}",
585                s.name
586            );
587        }
588        assert!(sprite("slot").unwrap().is_procedural());
589        assert_eq!(
590            sprite("hotbar_selection").unwrap().size(false),
591            (24.0, 24.0)
592        );
593        assert_eq!(sprite("hotbar_selection").unwrap().size(true), (24.0, 23.0));
594    }
595
596    #[test]
597    fn fits_like_wpf() {
598        // 16×16 into 156×29.
599        let (d, s) = fit(Stretch::Uniform, 16.0, 16.0, 0.0, 0.0, 156.0, 29.0);
600        assert_eq!(d, [63.0, 0.0, 29.0, 29.0]);
601        assert_eq!(s, [0.0, 0.0, 1.0, 1.0]);
602        assert_eq!(
603            fit(Stretch::Fill, 16.0, 16.0, 1.0, 2.0, 156.0, 29.0).0,
604            [1.0, 2.0, 156.0, 29.0]
605        );
606        assert_eq!(
607            fit(Stretch::None, 16.0, 16.0, 0.0, 0.0, 156.0, 29.0).0,
608            [70.0, 6.0, 16.0, 16.0]
609        );
610        let (d, s) = fit(Stretch::UniformToFill, 16.0, 16.0, 0.0, 0.0, 156.0, 29.0);
611        assert_eq!(d, [0.0, 0.0, 156.0, 29.0]);
612        assert!((s[1] - (156.0 - 29.0) / 2.0 / 156.0).abs() < 1e-5, "{s:?}");
613        // None, bigger than the box: cut to it.
614        let (d, s) = fit(Stretch::None, 40.0, 10.0, 0.0, 0.0, 20.0, 10.0);
615        assert_eq!(d, [0.0, 0.0, 20.0, 10.0]);
616        assert_eq!(s, [0.25, 0.0, 0.75, 1.0]);
617    }
618}