1use std::sync::LazyLock;
24
25use yog_graphics::{Rect, Vec2};
26
27#[derive(Debug, Clone, PartialEq, Eq)]
29pub enum ImageSource {
30 Item(String),
32 Block(String),
34 Sprite(String),
37 ModPath(String),
40 Texture(String),
42}
43
44const LEGACY_TEX_SIZE: f32 = 256.0;
46
47pub fn texture_id(ns: &str, path: &str) -> String {
52 use yog_ids::res::{PNG, SVG, TEXTURES};
53 let path = path.trim_start_matches(yog_ids::sep::PATH);
54 if path.ends_with(SVG) {
55 let path = path.strip_prefix(TEXTURES).unwrap_or(path);
56 return yog_ids::join(ns, &format!("{TEXTURES}{path}"));
57 }
58 if path.starts_with(TEXTURES) || path.ends_with(PNG) {
59 let png = if path.ends_with(PNG) { "" } else { PNG };
60 yog_ids::join(ns, &format!("{path}{png}"))
61 } else {
62 yog_ids::join(ns, &format!("{TEXTURES}{path}{PNG}"))
63 }
64}
65
66pub fn short_source(texture: &str) -> String {
69 let (ns, path) = yog_ids::split(texture.trim());
70 let path = path.strip_prefix(yog_ids::res::TEXTURES).unwrap_or(path);
71 yog_ids::join(ns, path.strip_suffix(yog_ids::res::PNG).unwrap_or(path))
72}
73
74pub use yog_ids::image_scheme as scheme;
76
77impl std::fmt::Display for ImageSource {
80 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
81 match self {
82 ImageSource::Item(id) => write!(f, "{}:{id}", scheme::ITEM),
83 ImageSource::Block(id) => write!(f, "{}:{id}", scheme::BLOCK),
84 ImageSource::Sprite(name) => write!(f, "{}:{name}", scheme::SPRITE),
85 ImageSource::ModPath(path) => write!(f, "{}:{path}", scheme::MOD),
86 ImageSource::Texture(id) => f.write_str(id),
87 }
88 }
89}
90
91impl ImageSource {
92 pub fn parse(source: &str) -> Option<ImageSource> {
94 let s = source.trim();
95 let (scheme, rest) = s.split_once(yog_ids::sep::NAMESPACE)?;
96 if rest.is_empty() {
97 return None;
98 }
99 Some(match scheme {
100 scheme::ITEM => ImageSource::Item(yog_ids::qualify(rest.trim())),
101 scheme::BLOCK => ImageSource::Block(yog_ids::qualify(rest.trim())),
102 scheme::SPRITE => ImageSource::Sprite(rest.to_string()),
103 scheme::MOD => ImageSource::ModPath(rest.to_string()),
104 ns if !ns.is_empty() => ImageSource::Texture(texture_id(ns, rest)),
105 _ => return None,
106 })
107 }
108
109 pub fn with_mod_id(self, mod_id: &str) -> ImageSource {
111 match self {
112 ImageSource::ModPath(path) => ImageSource::Texture(texture_id(mod_id, &path)),
113 other => other,
114 }
115 }
116}
117
118pub fn suggest_alias(id: &str) -> Option<String> {
123 let (ns, path) = id.trim().split_once(yog_ids::sep::NAMESPACE)?;
124 let path = path.strip_prefix(yog_ids::res::TEXTURES)?;
125 let stem = path.strip_suffix(yog_ids::res::PNG).unwrap_or(path);
126 let short = |id: &str| -> String {
128 if ns == yog_ids::ns::MINECRAFT {
129 id.to_string()
130 } else {
131 yog_ids::join(ns, id)
132 }
133 };
134 if let Some(item) = stem.strip_prefix(yog_ids::res::ITEM_DIR) {
135 return Some(ImageSource::Item(short(item)).to_string());
136 }
137 if let Some(block) = stem.strip_prefix(yog_ids::res::BLOCK_DIR) {
138 return Some(ImageSource::Block(short(block)).to_string());
139 }
140 if ns == yog_ids::ns::MINECRAFT
141 && let Some(atlas_id) = stem.strip_prefix(yog_ids::res::GUI_SPRITES)
142 && let Some(sprite) = sprite_by_atlas(atlas_id)
143 {
144 return Some(ImageSource::Sprite(sprite.name.to_string()).to_string());
145 }
146 Some(yog_ids::join(ns, stem))
147}
148
149#[derive(Debug, Clone, PartialEq)]
151pub struct LegacyRegion {
152 pub texture: String,
154 pub region: Rect,
156 pub texture_size: Vec2,
158}
159
160#[derive(Debug, Clone, PartialEq)]
162pub struct Sprite {
163 pub name: String,
165 pub description: String,
167 pub size: Vec2,
169 pub atlas: Option<String>,
172 pub legacy: Option<LegacyRegion>,
174 pub border: f32,
176}
177
178impl Sprite {
179 pub fn size(&self, atlas: bool) -> Vec2 {
181 match (atlas, &self.legacy) {
182 (false, Some(l)) => Vec2::new(l.region.w, l.region.h),
183 _ => self.size,
184 }
185 }
186
187 pub fn is_procedural(&self) -> bool {
189 self.atlas.is_none() && self.legacy.is_none()
190 }
191}
192
193pub fn sprite(name: &str) -> Option<&'static Sprite> {
195 SPRITES.iter().find(|s| s.name == name.trim())
196}
197
198pub fn sprite_by_atlas(atlas: &str) -> Option<&'static Sprite> {
200 SPRITES.iter().find(|s| s.atlas.as_deref() == Some(atlas))
201}
202
203pub fn atlas_texture(ns: &str, atlas: &str) -> String {
206 yog_ids::join(ns, &yog_ids::texture_file(&format!("{}{atlas}", yog_ids::res::GUI_SPRITES)))
207}
208
209#[derive(yog_data::Schema)]
211struct SpriteDef {
212 name: String,
213 description: String,
214 size: [f32; 2],
215 atlas: Option<String>,
216 legacy: Option<LegacyDef>,
217 #[data(default)]
218 border: f32,
219}
220
221#[derive(yog_data::Schema)]
222struct LegacyDef {
223 texture: String,
224 region: [f32; 4],
225 texture_size: Option<f32>,
227}
228
229pub static SPRITES: LazyLock<Vec<Sprite>> = LazyLock::new(|| {
231 yog_data::from_text::<Vec<SpriteDef>>(include_str!("../assets/sprites.ydata"))
232 .unwrap_or_else(|e| panic!("yog-gfx's sprites: {e}"))
233 .value
234 .into_iter()
235 .map(|d| Sprite {
236 name: d.name,
237 description: d.description,
238 size: Vec2::new(d.size[0], d.size[1]),
239 atlas: d.atlas,
240 legacy: d.legacy.map(|l| {
241 let [u, v, w, h] = l.region;
242 let side = l.texture_size.unwrap_or(LEGACY_TEX_SIZE);
243 LegacyRegion { texture: l.texture, region: Rect::new(u, v, w, h), texture_size: Vec2::new(side, side) }
244 }),
245 border: d.border,
246 })
247 .collect()
248});
249
250#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
252#[repr(u8)]
253pub enum Stretch {
254 None = 0,
256 Fill = 1,
258 #[default]
260 Uniform = 2,
261 UniformToFill = 3,
263}
264
265pub fn fit(stretch: Stretch, natural: Vec2, bx: Rect) -> (Rect, Rect) {
270 let whole = Rect::new(0.0, 0.0, 1.0, 1.0);
271 if natural.x <= 0.0 || natural.y <= 0.0 || bx.w <= 0.0 || bx.h <= 0.0 {
272 return (Rect::new(bx.x, bx.y, 0.0, 0.0), whole);
273 }
274 let scale = match stretch {
275 Stretch::Fill => return (bx, whole),
276 Stretch::None => 1.0,
277 Stretch::Uniform => (bx.w / natural.x).min(bx.h / natural.y),
278 Stretch::UniformToFill => (bx.w / natural.x).max(bx.h / natural.y),
279 };
280 let d = natural * scale;
281 let axis = |pos: f32, size: f32, d: f32| -> (f32, f32, f32, f32) {
283 if d <= size {
284 ((pos + (size - d) / 2.0).floor(), d, 0.0, 1.0)
285 } else {
286 let cut = (d - size) / 2.0 / d;
287 (pos, size, cut, 1.0 - 2.0 * cut)
288 }
289 };
290 let (dx, dw, u, uw) = axis(bx.x, bx.w, d.x);
291 let (dy, dh, v, vh) = axis(bx.y, bx.h, d.y);
292 (Rect::new(dx, dy, dw, dh), Rect::new(u, v, uw, vh))
293}
294
295#[cfg(test)]
296mod tests {
297 use super::*;
298
299 #[test]
300 fn parses_sources() {
301 assert_eq!(
302 ImageSource::parse("item:apple"),
303 Some(ImageSource::Item("minecraft:apple".into()))
304 );
305 assert_eq!(
306 ImageSource::parse("item:mymod:wand"),
307 Some(ImageSource::Item("mymod:wand".into()))
308 );
309 assert_eq!(
310 ImageSource::parse("block:stone"),
311 Some(ImageSource::Block("minecraft:stone".into()))
312 );
313 assert_eq!(
314 ImageSource::parse("sprite:button"),
315 Some(ImageSource::Sprite("button".into()))
316 );
317 assert_eq!(
318 ImageSource::parse("mymod:gui/logo"),
319 Some(ImageSource::Texture("mymod:textures/gui/logo.png".into()))
320 );
321 assert_eq!(
322 ImageSource::parse("minecraft:textures/item/apple.png"),
323 Some(ImageSource::Texture(
324 "minecraft:textures/item/apple.png".into()
325 ))
326 );
327 assert_eq!(
328 ImageSource::parse("mod:gui/logo").map(|s| s.with_mod_id("mymod")),
329 Some(ImageSource::Texture("mymod:textures/gui/logo.png".into()))
330 );
331 assert_eq!(
332 ImageSource::parse("mymod:gui/logo.svg"),
333 Some(ImageSource::Texture("mymod:textures/gui/logo.svg".into()))
334 );
335 assert_eq!(
336 ImageSource::parse("mymod:textures/gui/logo.svg"),
337 Some(ImageSource::Texture("mymod:textures/gui/logo.svg".into()))
338 );
339 assert_eq!(ImageSource::parse(""), None);
340 assert_eq!(ImageSource::parse("apple"), None);
341 assert_eq!(ImageSource::parse("item:"), None);
342 }
343
344 #[test]
345 fn suggests_aliases_for_raw_paths() {
346 assert_eq!(
347 suggest_alias("minecraft:textures/item/apple.png").as_deref(),
348 Some("item:apple")
349 );
350 assert_eq!(
351 suggest_alias("mymod:textures/block/ore.png").as_deref(),
352 Some("block:mymod:ore")
353 );
354 assert_eq!(
355 suggest_alias("minecraft:textures/gui/widgets.png").as_deref(),
356 Some("minecraft:gui/widgets")
357 );
358 assert_eq!(
359 suggest_alias("minecraft:textures/gui/sprites/widget/button.png").as_deref(),
360 Some("sprite:button")
361 );
362 assert_eq!(
363 suggest_alias("mymod:textures/gui/logo.png").as_deref(),
364 Some("mymod:gui/logo")
365 );
366 assert_eq!(suggest_alias("item:apple"), None);
367 }
368
369 #[test]
370 fn sprite_names_are_unique() {
371 for (i, s) in SPRITES.iter().enumerate() {
372 assert!(
373 SPRITES[i + 1..].iter().all(|o| o.name != s.name),
374 "{}",
375 s.name
376 );
377 }
378 assert!(sprite("slot").unwrap().is_procedural());
379 assert_eq!(
380 sprite("hotbar_selection").unwrap().size(false),
381 Vec2::new(24.0, 24.0)
382 );
383 assert_eq!(sprite("hotbar_selection").unwrap().size(true), Vec2::new(24.0, 23.0));
384 }
385
386 #[test]
387 fn fits_like_wpf() {
388 let icon = Vec2::new(16.0, 16.0);
389 let bx = Rect::new(0.0, 0.0, 156.0, 29.0);
390 let whole = Rect::new(0.0, 0.0, 1.0, 1.0);
391 assert_eq!(fit(Stretch::Uniform, icon, bx), (Rect::new(63.0, 0.0, 29.0, 29.0), whole));
393 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));
394 assert_eq!(fit(Stretch::None, icon, bx).0, Rect::new(70.0, 6.0, 16.0, 16.0));
395 let (d, s) = fit(Stretch::UniformToFill, icon, bx);
396 assert_eq!(d, bx);
397 assert!((s.y - (156.0 - 29.0) / 2.0 / 156.0).abs() < 1e-5, "{s:?}");
398 let (d, s) = fit(Stretch::None, Vec2::new(40.0, 10.0), Rect::new(0.0, 0.0, 20.0, 10.0));
400 assert_eq!(d, Rect::new(0.0, 0.0, 20.0, 10.0));
401 assert_eq!(s, Rect::new(0.25, 0.0, 0.5, 1.0));
402 }
403}