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