1use yog_graphics::{Rect, Vec2};
24
25#[derive(Debug, Clone, PartialEq, Eq)]
27pub enum ImageSource {
28 Item(String),
30 Block(String),
32 Sprite(String),
35 ModPath(String),
38 Texture(String),
40}
41
42const DEFAULT_NS: &str = "minecraft";
44const LEGACY_TEX_SIZE: f32 = 256.0;
46
47pub 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
57pub 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 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 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
102pub 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 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#[derive(Debug, Clone, Copy, PartialEq)]
135pub struct LegacyRegion {
136 pub texture: &'static str,
138 pub region: Rect,
140 pub texture_size: Vec2,
142}
143
144#[derive(Debug, Clone, Copy, PartialEq)]
146pub struct Sprite {
147 pub name: &'static str,
149 pub description: &'static str,
151 pub size: Vec2,
153 pub atlas: Option<&'static str>,
156 pub legacy: Option<LegacyRegion>,
158 pub border: f32,
160}
161
162impl Sprite {
163 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 pub fn is_procedural(&self) -> bool {
173 self.atlas.is_none() && self.legacy.is_none()
174 }
175}
176
177pub 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";
186const WINDOW: &str = "minecraft:textures/gui/demo_background.png";
189const CRAFTING_TABLE: &str = "minecraft:textures/gui/container/crafting_table.png";
191
192const 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
228pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
467#[repr(u8)]
468pub enum Stretch {
469 None = 0,
471 Fill = 1,
473 #[default]
475 Uniform = 2,
476 UniformToFill = 3,
478}
479
480pub 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 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 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 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}