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