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