1#[derive(Debug, Clone, PartialEq, Eq)]
24pub enum ImageSource {
25 Item(String),
27 Block(String),
29 Sprite(String),
32 ModPath(String),
35 Texture(String),
37}
38
39const DEFAULT_NS: &str = "minecraft";
41const LEGACY_TEX_SIZE: f32 = 256.0;
43
44pub 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
54pub 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 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 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
99pub 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 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#[derive(Debug, Clone, Copy, PartialEq)]
132pub struct LegacyRegion {
133 pub texture: &'static str,
135 pub u: f32,
136 pub v: f32,
137 pub w: f32,
138 pub h: f32,
139 pub tex_w: f32,
141 pub tex_h: f32,
142}
143
144#[derive(Debug, Clone, Copy, PartialEq)]
146pub struct Sprite {
147 pub name: &'static str,
149 pub description: &'static str,
151 pub w: f32,
153 pub h: f32,
154 pub atlas: Option<&'static str>,
157 pub legacy: Option<LegacyRegion>,
159 pub border: f32,
161}
162
163impl Sprite {
164 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 pub fn is_procedural(&self) -> bool {
174 self.atlas.is_none() && self.legacy.is_none()
175 }
176}
177
178pub 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";
187const WINDOW: &str = "minecraft:textures/gui/demo_background.png";
190const CRAFTING_TABLE: &str = "minecraft:textures/gui/container/crafting_table.png";
192
193const 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
238pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
480#[repr(u8)]
481pub enum Stretch {
482 None = 0,
484 Fill = 1,
486 #[default]
488 Uniform = 2,
489 UniformToFill = 3,
491}
492
493pub 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 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 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 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}