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";
190
191const fn region(texture: &'static str, u: f32, v: f32, w: f32, h: f32) -> Option<LegacyRegion> {
193 region_of(texture, LEGACY_TEX_SIZE, u, v, w, h)
194}
195
196const fn region_of(
197 texture: &'static str,
198 size: f32,
199 u: f32,
200 v: f32,
201 w: f32,
202 h: f32,
203) -> Option<LegacyRegion> {
204 Some(LegacyRegion {
205 texture,
206 u,
207 v,
208 w,
209 h,
210 tex_w: size,
211 tex_h: size,
212 })
213}
214
215#[allow(clippy::too_many_arguments)]
216const fn sprite_def(
217 name: &'static str,
218 description: &'static str,
219 w: f32,
220 h: f32,
221 atlas: &'static str,
222 legacy: Option<LegacyRegion>,
223 border: f32,
224) -> Sprite {
225 Sprite {
226 name,
227 description,
228 w,
229 h,
230 atlas: Some(atlas),
231 legacy,
232 border,
233 }
234}
235
236pub const SPRITES: &[Sprite] = &[
240 sprite_def(
241 "button",
242 "Button background",
243 200.0,
244 20.0,
245 "widget/button",
246 region(WIDGETS, 0.0, 66.0, 200.0, 20.0),
247 3.0,
248 ),
249 sprite_def(
250 "button_hover",
251 "Button background, hovered/focused",
252 200.0,
253 20.0,
254 "widget/button_highlighted",
255 region(WIDGETS, 0.0, 86.0, 200.0, 20.0),
256 3.0,
257 ),
258 sprite_def(
259 "button_disabled",
260 "Button background, disabled",
261 200.0,
262 20.0,
263 "widget/button_disabled",
264 region(WIDGETS, 0.0, 46.0, 200.0, 20.0),
265 3.0,
266 ),
267 Sprite {
268 name: "panel",
269 description: "Window background (vanilla's grey frame)",
270 w: 248.0,
271 h: 166.0,
272 atlas: None,
273 legacy: region(WINDOW, 0.0, 0.0, 248.0, 166.0),
274 border: 4.0,
275 },
276 Sprite {
277 name: "slot",
278 description: "Inventory slot (18×18)",
279 w: 18.0,
280 h: 18.0,
281 atlas: None,
282 legacy: None,
283 border: 1.0,
284 },
285 sprite_def(
286 "scrollbar",
287 "Scrollbar knob (creative inventory)",
288 12.0,
289 15.0,
290 "container/creative_inventory/scroller",
291 region(TABS, 232.0, 0.0, 12.0, 15.0),
292 0.0,
293 ),
294 sprite_def(
295 "scrollbar_disabled",
296 "Scrollbar knob, disabled",
297 12.0,
298 15.0,
299 "container/creative_inventory/scroller_disabled",
300 region(TABS, 244.0, 0.0, 12.0, 15.0),
301 0.0,
302 ),
303 sprite_def(
304 "checkbox",
305 "Checkbox, unchecked",
306 20.0,
307 20.0,
308 "widget/checkbox",
309 region_of(CHECKBOX, 64.0, 0.0, 0.0, 20.0, 20.0),
310 0.0,
311 ),
312 sprite_def(
313 "checkbox_checked",
314 "Checkbox, checked",
315 20.0,
316 20.0,
317 "widget/checkbox_selected",
318 region_of(CHECKBOX, 64.0, 0.0, 20.0, 20.0, 20.0),
319 0.0,
320 ),
321 sprite_def(
322 "hotbar",
323 "Hotbar",
324 182.0,
325 22.0,
326 "hud/hotbar",
327 region(WIDGETS, 0.0, 0.0, 182.0, 22.0),
328 0.0,
329 ),
330 sprite_def(
331 "hotbar_selection",
332 "Hotbar selection frame",
333 24.0,
334 23.0,
335 "hud/hotbar_selection",
336 region(WIDGETS, 0.0, 22.0, 24.0, 24.0),
337 0.0,
338 ),
339 sprite_def(
340 "crosshair",
341 "Crosshair",
342 15.0,
343 15.0,
344 "hud/crosshair",
345 region(ICONS, 0.0, 0.0, 15.0, 15.0),
346 0.0,
347 ),
348 sprite_def(
349 "xp_bar",
350 "Experience bar, empty",
351 182.0,
352 5.0,
353 "hud/experience_bar_background",
354 region(ICONS, 0.0, 64.0, 182.0, 5.0),
355 0.0,
356 ),
357 sprite_def(
358 "xp_bar_progress",
359 "Experience bar, full",
360 182.0,
361 5.0,
362 "hud/experience_bar_progress",
363 region(ICONS, 0.0, 69.0, 182.0, 5.0),
364 0.0,
365 ),
366 sprite_def(
367 "icon/heart",
368 "Heart",
369 9.0,
370 9.0,
371 "hud/heart/full",
372 region(ICONS, 52.0, 0.0, 9.0, 9.0),
373 0.0,
374 ),
375 sprite_def(
376 "icon/heart_half",
377 "Half heart",
378 9.0,
379 9.0,
380 "hud/heart/half",
381 region(ICONS, 61.0, 0.0, 9.0, 9.0),
382 0.0,
383 ),
384 sprite_def(
385 "icon/heart_empty",
386 "Heart container (empty)",
387 9.0,
388 9.0,
389 "hud/heart/container",
390 region(ICONS, 16.0, 0.0, 9.0, 9.0),
391 0.0,
392 ),
393 sprite_def(
394 "icon/food",
395 "Hunger shank",
396 9.0,
397 9.0,
398 "hud/food_full",
399 region(ICONS, 52.0, 27.0, 9.0, 9.0),
400 0.0,
401 ),
402 sprite_def(
403 "icon/food_half",
404 "Half hunger shank",
405 9.0,
406 9.0,
407 "hud/food_half",
408 region(ICONS, 61.0, 27.0, 9.0, 9.0),
409 0.0,
410 ),
411 sprite_def(
412 "icon/food_empty",
413 "Hunger shank, empty",
414 9.0,
415 9.0,
416 "hud/food_empty",
417 region(ICONS, 16.0, 27.0, 9.0, 9.0),
418 0.0,
419 ),
420 sprite_def(
421 "icon/armor",
422 "Armor",
423 9.0,
424 9.0,
425 "hud/armor_full",
426 region(ICONS, 34.0, 9.0, 9.0, 9.0),
427 0.0,
428 ),
429 sprite_def(
430 "icon/armor_half",
431 "Half armor",
432 9.0,
433 9.0,
434 "hud/armor_half",
435 region(ICONS, 25.0, 9.0, 9.0, 9.0),
436 0.0,
437 ),
438 sprite_def(
439 "icon/armor_empty",
440 "Armor, empty",
441 9.0,
442 9.0,
443 "hud/armor_empty",
444 region(ICONS, 16.0, 9.0, 9.0, 9.0),
445 0.0,
446 ),
447 sprite_def(
448 "icon/air",
449 "Air bubble",
450 9.0,
451 9.0,
452 "hud/air",
453 region(ICONS, 16.0, 18.0, 9.0, 9.0),
454 0.0,
455 ),
456 sprite_def(
457 "icon/air_bursting",
458 "Air bubble, bursting",
459 9.0,
460 9.0,
461 "hud/air_bursting",
462 region(ICONS, 25.0, 18.0, 9.0, 9.0),
463 0.0,
464 ),
465];
466
467#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
469#[repr(u8)]
470pub enum Stretch {
471 None = 0,
473 Fill = 1,
475 #[default]
477 Uniform = 2,
478 UniformToFill = 3,
480}
481
482pub fn fit(
487 stretch: Stretch,
488 nat_w: f32,
489 nat_h: f32,
490 x: f32,
491 y: f32,
492 w: f32,
493 h: f32,
494) -> ([f32; 4], [f32; 4]) {
495 if nat_w <= 0.0 || nat_h <= 0.0 || w <= 0.0 || h <= 0.0 {
496 return ([x, y, 0.0, 0.0], [0.0, 0.0, 1.0, 1.0]);
497 }
498 let scale = match stretch {
499 Stretch::Fill => return ([x, y, w, h], [0.0, 0.0, 1.0, 1.0]),
500 Stretch::None => 1.0,
501 Stretch::Uniform => (w / nat_w).min(h / nat_h),
502 Stretch::UniformToFill => (w / nat_w).max(h / nat_h),
503 };
504 let (dw, dh) = (nat_w * scale, nat_h * scale);
505 let axis = |pos: f32, size: f32, d: f32| -> (f32, f32, f32, f32) {
507 if d <= size {
508 ((pos + (size - d) / 2.0).floor(), d, 0.0, 1.0)
509 } else {
510 let cut = (d - size) / 2.0 / d;
511 (pos, size, cut, 1.0 - cut)
512 }
513 };
514 let (dx, dw, u0, u1) = axis(x, w, dw);
515 let (dy, dh, v0, v1) = axis(y, h, dh);
516 ([dx, dy, dw, dh], [u0, v0, u1, v1])
517}
518
519#[cfg(test)]
520mod tests {
521 use super::*;
522
523 #[test]
524 fn parses_sources() {
525 assert_eq!(
526 ImageSource::parse("item:apple"),
527 Some(ImageSource::Item("minecraft:apple".into()))
528 );
529 assert_eq!(
530 ImageSource::parse("item:mymod:wand"),
531 Some(ImageSource::Item("mymod:wand".into()))
532 );
533 assert_eq!(
534 ImageSource::parse("block:stone"),
535 Some(ImageSource::Block("minecraft:stone".into()))
536 );
537 assert_eq!(
538 ImageSource::parse("sprite:button"),
539 Some(ImageSource::Sprite("button".into()))
540 );
541 assert_eq!(
542 ImageSource::parse("mymod:gui/logo"),
543 Some(ImageSource::Texture("mymod:textures/gui/logo.png".into()))
544 );
545 assert_eq!(
546 ImageSource::parse("minecraft:textures/item/apple.png"),
547 Some(ImageSource::Texture(
548 "minecraft:textures/item/apple.png".into()
549 ))
550 );
551 assert_eq!(
552 ImageSource::parse("mod:gui/logo").map(|s| s.with_mod_id("mymod")),
553 Some(ImageSource::Texture("mymod:textures/gui/logo.png".into()))
554 );
555 assert_eq!(
556 ImageSource::parse("mymod:gui/logo.svg"),
557 Some(ImageSource::Texture("mymod:textures/gui/logo.svg".into()))
558 );
559 assert_eq!(
560 ImageSource::parse("mymod:textures/gui/logo.svg"),
561 Some(ImageSource::Texture("mymod:textures/gui/logo.svg".into()))
562 );
563 assert_eq!(ImageSource::parse(""), None);
564 assert_eq!(ImageSource::parse("apple"), None);
565 assert_eq!(ImageSource::parse("item:"), None);
566 }
567
568 #[test]
569 fn suggests_aliases_for_raw_paths() {
570 assert_eq!(
571 suggest_alias("minecraft:textures/item/apple.png").as_deref(),
572 Some("item:apple")
573 );
574 assert_eq!(
575 suggest_alias("mymod:textures/block/ore.png").as_deref(),
576 Some("block:mymod:ore")
577 );
578 assert_eq!(
579 suggest_alias("minecraft:textures/gui/widgets.png").as_deref(),
580 Some("minecraft:gui/widgets")
581 );
582 assert_eq!(
583 suggest_alias("minecraft:textures/gui/sprites/widget/button.png").as_deref(),
584 Some("sprite:button")
585 );
586 assert_eq!(
587 suggest_alias("mymod:textures/gui/logo.png").as_deref(),
588 Some("mymod:gui/logo")
589 );
590 assert_eq!(suggest_alias("item:apple"), None);
591 }
592
593 #[test]
594 fn sprite_names_are_unique() {
595 for (i, s) in SPRITES.iter().enumerate() {
596 assert!(
597 SPRITES[i + 1..].iter().all(|o| o.name != s.name),
598 "{}",
599 s.name
600 );
601 }
602 assert!(sprite("slot").unwrap().is_procedural());
603 assert_eq!(
604 sprite("hotbar_selection").unwrap().size(false),
605 (24.0, 24.0)
606 );
607 assert_eq!(sprite("hotbar_selection").unwrap().size(true), (24.0, 23.0));
608 }
609
610 #[test]
611 fn fits_like_wpf() {
612 let (d, s) = fit(Stretch::Uniform, 16.0, 16.0, 0.0, 0.0, 156.0, 29.0);
614 assert_eq!(d, [63.0, 0.0, 29.0, 29.0]);
615 assert_eq!(s, [0.0, 0.0, 1.0, 1.0]);
616 assert_eq!(
617 fit(Stretch::Fill, 16.0, 16.0, 1.0, 2.0, 156.0, 29.0).0,
618 [1.0, 2.0, 156.0, 29.0]
619 );
620 assert_eq!(
621 fit(Stretch::None, 16.0, 16.0, 0.0, 0.0, 156.0, 29.0).0,
622 [70.0, 6.0, 16.0, 16.0]
623 );
624 let (d, s) = fit(Stretch::UniformToFill, 16.0, 16.0, 0.0, 0.0, 156.0, 29.0);
625 assert_eq!(d, [0.0, 0.0, 156.0, 29.0]);
626 assert!((s[1] - (156.0 - 29.0) / 2.0 / 156.0).abs() < 1e-5, "{s:?}");
627 let (d, s) = fit(Stretch::None, 40.0, 10.0, 0.0, 0.0, 20.0, 10.0);
629 assert_eq!(d, [0.0, 0.0, 20.0, 10.0]);
630 assert_eq!(s, [0.25, 0.0, 0.75, 1.0]);
631 }
632}