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