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