diff --git a/Cargo.lock b/Cargo.lock index 70f8608..afc8bb7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -465,6 +465,12 @@ dependencies = [ "tracing", ] +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + [[package]] name = "bit-set" version = "0.10.0" @@ -631,6 +637,12 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + [[package]] name = "combine" version = "4.6.8" @@ -717,6 +729,27 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" +[[package]] +name = "dirs" +version = "7.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d57d423b3c82e89b9a24ca3091fee61f456a26edbd28d26c65906f4bc1dcd8f" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + [[package]] name = "dispatch" version = "0.2.0" @@ -962,9 +995,13 @@ name = "foreversight" version = "0.1.0" dependencies = [ "anyhow", + "dirs", + "serde", + "serde_json", "shikra", "shikra_headless", "tokio", + "ureq", ] [[package]] @@ -1047,6 +1084,17 @@ dependencies = [ "windows-link", ] +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + [[package]] name = "getrandom" version = "0.3.4" @@ -1070,6 +1118,16 @@ dependencies = [ "r-efi 6.0.0", ] +[[package]] +name = "gif" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" +dependencies = [ + "color_quant", + "weezl", +] + [[package]] name = "gl_generator" version = "0.14.0" @@ -1390,6 +1448,8 @@ checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" dependencies = [ "bytemuck", "byteorder-lite", + "color_quant", + "gif", "moxcms", "num-traits", "png", @@ -2157,6 +2217,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "orbclient" version = "0.3.55" @@ -2561,6 +2627,16 @@ dependencies = [ "bitflags 2.13.1", ] +[[package]] +name = "redox_users" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60dc65c0ff1a7ae1294b0c67b9f14baf70b644404010370171787bfac1038fc0" +dependencies = [ + "libredox", + "thiserror 2.0.21", +] + [[package]] name = "relative-path" version = "2.0.1" @@ -2576,6 +2652,20 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "roxmltree" version = "0.21.1" @@ -2655,6 +2745,41 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.23.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.23" @@ -2961,6 +3086,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "swash" version = "0.2.10" @@ -3285,6 +3416,47 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a7ac20be9b7726e0bbdbf974c059676d9acb1cd414961f570a4e8231cacd7fc" +dependencies = [ + "base64", + "flate2", + "log", + "percent-encoding", + "rustls", + "rustls-pki-types", + "ureq-proto", + "utf8-zero", + "webpki-roots", +] + +[[package]] +name = "ureq-proto" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f86fd172ccca569e458f61b6bdd6220965a9ef36e672a6852953b51a0e1583be" +dependencies = [ + "base64", + "http", + "httparse", + "log", +] + +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -3518,6 +3690,21 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + [[package]] name = "wgpu" version = "30.0.1" @@ -4304,6 +4491,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + [[package]] name = "zerotrie" version = "0.2.5" diff --git a/crates/foreversight/Cargo.toml b/crates/foreversight/Cargo.toml index e3d3ee4..519ec55 100644 --- a/crates/foreversight/Cargo.toml +++ b/crates/foreversight/Cargo.toml @@ -5,6 +5,13 @@ edition = "2024" [dependencies] anyhow = "1" +dirs = "7" +serde = { version = "1", features = ["derive"] } +serde_json = "1" shikra = { path = "../shikra" } shikra_headless = { path = "../shikra_headless" } tokio = { version = "1", features = ["time"] } +ureq = "3" + +[dev-dependencies] +shikra = { path = "../shikra", features = ["test-support"] } diff --git a/crates/foreversight/README.md b/crates/foreversight/README.md index f239b76..9336333 100644 --- a/crates/foreversight/README.md +++ b/crates/foreversight/README.md @@ -42,6 +42,20 @@ To try it without WoW, use the sample data: cargo run -p foreversight -- crates/foreversight/fixtures/Sample.lua ``` +## Icons + +Item and coin icons come from Wowhead: an item's icon name from its tooltip +data (Forever's database, or Classic's for items Forever's doesn't have yet), +and the image from Wowhead's CDN, `wow.zamimg.com`. They're cached in +`~/.cache/foreversight` (or the platform's cache directory), so each is only +downloaded once, and at most four are downloaded at a time. Delete the +directory to download them again. Icons that fail to load show an empty frame, +and coins fall back to colored discs. + +These endpoints are undocumented, and we haven't confirmed that Wowhead's terms +allow using them this way. `IconSource` in `src/icons.rs` keeps the source +swappable. + ## The data format The addon writes `ForeversightDB` in the format described at the top of @@ -78,6 +92,9 @@ nix shell nixpkgs#lua5_1 -c lua generate.lua > Sample.lua they all link to the same Wowhead page. Their links should have the suffix ID after the item ID, but we haven't seen a real one from Forever yet, or checked whether Wowhead's Forever database takes `?rand=`. +- Is using Wowhead's tooltip data and image CDN for icons OK under its terms? + Blizzard's Game Data API has official item media, but needs API + credentials, and may not cover Forever. - Equipment seems not to be loaded at `PLAYER_LOGIN` or `PLAYER_ENTERING_WORLD`, so the addon also records it on `UNIT_INVENTORY_CHANGED`. If equipment is still missing, diff --git a/crates/foreversight/src/app_view.rs b/crates/foreversight/src/app_view.rs index 2e5278b..8987c84 100644 --- a/crates/foreversight/src/app_view.rs +++ b/crates/foreversight/src/app_view.rs @@ -1,18 +1,24 @@ +use std::collections::HashMap; use std::path::PathBuf; use std::time::Duration; use anyhow::Result; use shikra::accesskit::Live; use shikra::{ - Context, Div, FocusHandle, InteractiveElement, IntoElement, Key, KeyDownEvent, ParentElement, - Render, Rgba, Role, ScrollHandle, Styled, Task, Window, div, point, rgb, text_input, + Context, Div, Entity, FocusHandle, Image, InteractiveElement, IntoElement, Key, KeyDownEvent, + ParentElement, Render, Rgba, Role, ScrollHandle, Styled, Task, Window, div, img, point, rgb, + text_input, }; use shikra_headless::{Button, Link}; +use crate::icons::{Icon, IconSource, Icons}; use crate::inventory::{Holding, Inventory, Match}; -use crate::model::{Character, Quality}; +use crate::model::{Character, Coin, Quality}; use crate::sources::{Loaded, Sources}; +/// The coin icons that have loaded. +type CoinIcons = HashMap; + /// How many search results to show. Every row is laid out and painted on /// every frame, since there are no virtualized lists yet, so thousands would /// be slow. @@ -65,6 +71,7 @@ pub struct Foreversight { /// The key of the character to show items for, or all characters if /// `None`. character: Option, + icons: Entity, _load: Task<()>, _watch: Task<()>, } @@ -79,7 +86,12 @@ enum State { } impl Foreversight { - pub fn new(sources: Sources, window: &mut Window, cx: &mut Context) -> Self { + pub fn new( + sources: Sources, + icon_source: impl IconSource, + window: &mut Window, + cx: &mut Context, + ) -> Self { let search = cx.focus_handle(); window.focus(&search); let mut this = Self { @@ -89,6 +101,7 @@ impl Foreversight { search, results_scroll: ScrollHandle::new(), character: None, + icons: cx.new(|_| Icons::new(icon_source)), _load: Task::ready(()), _watch: Task::ready(()), }; @@ -216,6 +229,12 @@ impl Foreversight { selected: Option, cx: &mut Context, ) -> Div { + let coins = self.icons.update(cx, |icons, cx| { + Coin::ALL + .into_iter() + .filter_map(|coin| Some((coin, icons.get(Icon::Coin(coin), cx)?))) + .collect::() + }); let entry = |id: String, is_selected: bool| { Button::root() .id(id) @@ -262,6 +281,7 @@ impl Foreversight { .iter() .map(|character| character.money) .sum(), + &coins, )), ); @@ -277,7 +297,7 @@ impl Foreversight { .on_click(cx.listener(move |this, _, _, cx| { this.select_character(Some(key.clone()), cx) })) - .child(character_summary(character)) + .child(character_summary(character, &coins)) }); div() @@ -358,7 +378,13 @@ impl Foreversight { let rows = matches .iter() .take(MAX_RESULTS) - .map(|item| result_row(item, inventory, selected.is_none())); + .map(|item| { + let icon = self + .icons + .update(cx, |icons, cx| icons.get(Icon::Item(item.item.item_id), cx)); + result_row(item, icon, inventory, selected.is_none()) + }) + .collect::>(); div() .style(|s| s.flex_1().flex().flex_col().gap_4().p_6().min_w_0()) @@ -389,7 +415,12 @@ impl Foreversight { } } -fn result_row(item: &Match, inventory: &Inventory, show_characters: bool) -> Div { +fn result_row( + item: &Match, + icon: Option, + inventory: &Inventory, + show_characters: bool, +) -> Div { let quality = quality_color(item.item.quality); let where_ = holdings_by_character(&item.holdings) .into_iter() @@ -419,6 +450,7 @@ fn result_row(item: &Match, inventory: &Inventory, show_characters: bool) -> Div .bg(colors::surface()) }) .role(Role::ListItem) + .child(item_icon(icon, quality)) .child( div() .style(|s| s.flex_1().flex().flex_col().gap_0p5()) @@ -459,6 +491,20 @@ fn result_row(item: &Match, inventory: &Inventory, show_characters: bool) -> Div ) } +/// An item's icon, framed in its quality's color like in the game. Empty +/// until the icon loads. +fn item_icon(icon: Option, quality: Rgba) -> Div { + div() + .style(move |s| { + s.size_9() + .flex_none() + .border_1() + .border_color(quality.opacity(0.6)) + .bg(colors::background()) + }) + .children(icon.map(|image| img(image).style(|s| s.size_full()))) +} + /// Groups holdings by character, keeping their order. fn holdings_by_character(holdings: &[Holding]) -> Vec<(usize, Vec)> { let mut groups: Vec<(usize, Vec)> = Vec::new(); @@ -471,7 +517,7 @@ fn holdings_by_character(holdings: &[Holding]) -> Vec<(usize, Vec)> { groups } -fn character_summary(character: &Character) -> Div { +fn character_summary(character: &Character, coins: &CoinIcons) -> Div { let class = class_color(&character.class); let bank_not_seen = character.bank_updated_at.is_none(); div() @@ -505,7 +551,7 @@ fn character_summary(character: &Character) -> Div { character.level, character.race, character.class_name, )) .child("·") - .child(money(character.money)), + .child(money(character.money, coins)), ) .children(bank_not_seen.then(|| { div() @@ -547,31 +593,14 @@ fn character_count(count: usize) -> String { format!("{count} {noun}") } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum Coin { - Gold, - Silver, - Copper, -} - -impl Coin { - fn name(self) -> &'static str { - match self { - Self::Gold => "gold", - Self::Silver => "silver", - Self::Copper => "copper", - } - } - - /// The coin's face and rim, like the game's coin icons. - fn colors(self) -> (Rgba, Rgba) { - let (face, rim) = match self { - Self::Gold => (0xf2c12e, 0x9c6b0c), - Self::Silver => (0xc9ccd1, 0x6f747c), - Self::Copper => (0xd0804a, 0x7f4420), - }; - (rgb(face), rgb(rim)) - } +/// A coin's face and rim, like the game's coin icons, for until they load. +fn coin_colors(coin: Coin) -> (Rgba, Rgba) { + let (face, rim) = match coin { + Coin::Gold => (0xf2c12e, 0x9c6b0c), + Coin::Silver => (0xc9ccd1, 0x6f747c), + Coin::Copper => (0xd0804a, 0x7f4420), + }; + (rgb(face), rgb(rim)) } /// Splits copper into gold, silver, and copper, leaving out leading @@ -591,7 +620,7 @@ fn money_parts(copper: u64) -> Vec<(u64, Coin)> { } /// An amount of money, with a coin after each denomination. -fn money(copper: u64) -> Div { +fn money(copper: u64, coins: &CoinIcons) -> Div { let parts = money_parts(copper); let label = parts .iter() @@ -604,19 +633,27 @@ fn money(copper: u64) -> Div { .role(Role::Label) .label(label) .children(parts.into_iter().map(|(amount, coin)| { - let (face, rim) = coin.colors(); + let icon = match coins.get(&coin) { + Some(image) => img(image.clone()).into_any_element(), + None => { + let (face, rim) = coin_colors(coin); + div() + .style(move |s| { + s.size_2p5() + .flex_none() + .rounded_full() + .bg(face) + .border_1() + .border_color(rim) + }) + .into_any_element() + } + }; div() .style(|s| s.flex().items_center().gap_0p5()) .accessibility(|node| node.set_hidden()) .child(amount.to_string()) - .child(div().style(move |s| { - s.size_2p5() - .flex_none() - .rounded_full() - .bg(face) - .border_1() - .border_color(rim) - })) + .child(icon) })) } diff --git a/crates/foreversight/src/icons.rs b/crates/foreversight/src/icons.rs new file mode 100644 index 0000000..97d4ec2 --- /dev/null +++ b/crates/foreversight/src/icons.rs @@ -0,0 +1,319 @@ +//! Icons for items and coins, loaded in the background and cached on disk. +//! +//! They come from Wowhead, which has the game's icons. Item icons are looked up +//! by name in Wowhead's tooltip data for Forever (or Classic, for items Forever +//! doesn't have yet), then downloaded from its image CDN. Both are cached, so +//! each is only downloaded once. + +use std::collections::{HashMap, HashSet, VecDeque}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::Duration; + +use anyhow::{Context as _, Result, bail}; +use serde::Deserialize; +use shikra::{Context, Image}; + +use crate::model::Coin; + +/// How many icons to load at once, to go easy on Wowhead. +const MAX_CONCURRENT_LOADS: usize = 4; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Icon { + Item(u32), + Coin(Coin), +} + +/// Where icons come from. +pub trait IconSource: Send + Sync + 'static { + /// Loads and decodes an icon. Called on a background thread, so it can + /// block. + fn load(&self, icon: Icon) -> Result; +} + +/// The icons that have been asked for, loading them as they're asked for. +/// +/// Reading an icon while rendering redraws once it loads. +pub struct Icons { + source: Arc, + icons: HashMap>, + queue: VecDeque, + loading: HashSet, + warned: bool, +} + +impl Icons { + pub fn new(source: impl IconSource) -> Self { + Self { + source: Arc::new(source), + icons: HashMap::default(), + queue: VecDeque::new(), + loading: HashSet::default(), + warned: false, + } + } + + /// Returns the icon if it's loaded, or starts loading it if it hasn't + /// been asked for yet. Icons that fail to load aren't tried again. + pub fn get(&mut self, icon: Icon, cx: &mut Context) -> Option { + if let Some(image) = self.icons.get(&icon) { + return image.clone(); + } + if !self.loading.contains(&icon) && !self.queue.contains(&icon) { + self.queue.push_back(icon); + self.load_queued(cx); + } + None + } + + fn load_queued(&mut self, cx: &mut Context) { + while self.loading.len() < MAX_CONCURRENT_LOADS + && let Some(icon) = self.queue.pop_front() + { + self.loading.insert(icon); + let source = self.source.clone(); + let loading = cx + .background_executor() + .spawn_blocking(move || source.load(icon)); + cx.spawn(async move |this, cx| { + let result = loading.await; + let _ = this.update(cx, |this, cx| this.loaded(icon, result, cx)); + }) + .detach(); + } + } + + fn loaded(&mut self, icon: Icon, result: Result, cx: &mut Context) { + self.loading.remove(&icon); + let image = match result { + Ok(image) => Some(image), + Err(error) => { + // Only once, since when one fails, like when we're offline, + // the rest likely will too. + if !self.warned { + eprintln!("foreversight: couldn't load the icon for {icon:?}: {error:#}"); + self.warned = true; + } + None + } + }; + self.icons.insert(icon, image); + self.load_queued(cx); + cx.notify(); + } +} + +/// Icons from Wowhead, cached in a directory. +pub struct Wowhead { + cache: PathBuf, + agent: ureq::Agent, +} + +impl Wowhead { + /// Caches icons in the user's cache directory. + pub fn new() -> Result { + let cache = dirs::cache_dir() + .context("couldn't find a cache directory")? + .join("foreversight"); + Ok(Self::with_cache(cache)) + } + + pub fn with_cache(cache: PathBuf) -> Self { + let agent = ureq::Agent::config_builder() + .user_agent(concat!("Foreversight/", env!("CARGO_PKG_VERSION"))) + .timeout_global(Some(Duration::from_secs(30))) + .build() + .into(); + Self { cache, agent } + } + + /// The path of an icon on Wowhead's image CDN. + fn image_path(&self, icon: Icon) -> Result { + Ok(match icon { + Icon::Item(item_id) => { + let name = self.item_icon_name(item_id)?; + format!("images/wow/icons/large/{name}.jpg") + } + Icon::Coin(coin) => format!("images/icons/money-{}.gif", coin.name()), + }) + } + + /// The name of an item's icon, like `inv_misc_rune_01`. + fn item_icon_name(&self, item_id: u32) -> Result { + let path = self.cache.join(format!("wowhead/item-icons/{item_id}")); + let name = cached(&path, || { + let tooltip = |database| { + self.get(&format!( + "https://nether.wowhead.com/{database}/tooltip/item/{item_id}" + )) + }; + // Wowhead's database for Forever leaves out items it doesn't have + // yet, like raid loot from later phases, so fall back to Classic's, + // which has the same icons. + let tooltip = match tooltip("forever") { + Err(error) if is_not_found(&error) => tooltip("classic")?, + result => result?, + }; + Ok(icon_name_from_tooltip(&tooltip)?.into_bytes()) + })?; + String::from_utf8(name).context("cached icon name isn't UTF-8") + } + + fn get(&self, url: &str) -> Result> { + let mut response = self + .agent + .get(url) + .call() + .with_context(|| format!("couldn't get {url}"))?; + response + .body_mut() + .read_to_vec() + .with_context(|| format!("couldn't read {url}")) + } +} + +impl IconSource for Wowhead { + fn load(&self, icon: Icon) -> Result { + let path = self.image_path(icon)?; + let bytes = cached(&self.cache.join("zamimg").join(&path), || { + self.get(&format!("https://wow.zamimg.com/{path}")) + })?; + Image::decode(&bytes).with_context(|| format!("couldn't decode {path}")) + } +} + +fn is_not_found(error: &anyhow::Error) -> bool { + matches!( + error.downcast_ref::(), + Some(ureq::Error::StatusCode(404)) + ) +} + +/// Reads `path`, or if it doesn't exist, fetches it and writes it there. +fn cached(path: &Path, fetch: impl FnOnce() -> Result>) -> Result> { + if let Ok(bytes) = fs::read(path) { + return Ok(bytes); + } + let bytes = fetch()?; + // Write it next to where it goes, then move it into place, so a + // half-written file is never read. + let directory = path.parent().context("cache path has no parent")?; + fs::create_dir_all(directory) + .with_context(|| format!("couldn't create {}", directory.display()))?; + let temporary = path.with_extension(format!("{}.tmp", std::process::id())); + fs::write(&temporary, &bytes) + .and_then(|()| fs::rename(&temporary, path)) + .with_context(|| format!("couldn't write {}", path.display()))?; + Ok(bytes) +} + +/// Gets the icon name from an item's tooltip data from Wowhead. +fn icon_name_from_tooltip(json: &[u8]) -> Result { + #[derive(Deserialize)] + struct Tooltip { + icon: String, + } + + let Tooltip { icon } = serde_json::from_slice(json).context("unexpected tooltip data")?; + // It goes into paths and URLs. + let is_valid = !icon.is_empty() + && icon + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-'); + if !is_valid { + bail!("unexpected icon name {icon:?}"); + } + Ok(icon.to_lowercase()) +} + +#[cfg(test)] +mod tests { + use std::sync::Mutex; + + use shikra::test::TestApp; + + use super::*; + + /// Loads 1×1 images, recording what it's asked for. + #[derive(Clone, Default)] + struct FakeSource(Arc>>); + + impl IconSource for FakeSource { + fn load(&self, icon: Icon) -> Result { + self.0.lock().unwrap().push(icon); + match icon { + Icon::Item(0) => bail!("no such item"), + _ => Ok(Image::from_rgba(1, 1, vec![255; 4])), + } + } + } + + #[test] + fn loads_icons_once() { + let app = TestApp::new(); + let source = FakeSource::default(); + let icons = app.update(|cx| cx.new(|_| Icons::new(source.clone()))); + let get = |icon| app.update(|cx| icons.update(cx, |icons, cx| icons.get(icon, cx))); + + let requested = [Icon::Item(6948), Icon::Coin(Coin::Gold), Icon::Item(0)]; + for icon in requested { + assert!(get(icon).is_none()); + // Asking again while it loads doesn't load it again. + assert!(get(icon).is_none()); + } + app.run_until(|cx| icons.read(cx).icons.len() == requested.len()); + + assert!(get(Icon::Item(6948)).is_some()); + assert!(get(Icon::Coin(Coin::Gold)).is_some()); + // Failures aren't retried. + assert!(get(Icon::Item(0)).is_none()); + let mut loaded = source.0.lock().unwrap().clone(); + loaded.sort_by_key(|icon| format!("{icon:?}")); + assert_eq!( + loaded, + [Icon::Coin(Coin::Gold), Icon::Item(0), Icon::Item(6948)] + ); + } + + #[test] + fn limits_concurrent_loads() { + let app = TestApp::new(); + let icons = app.update(|cx| cx.new(|_| Icons::new(FakeSource::default()))); + let count = MAX_CONCURRENT_LOADS as u32 + 3; + app.update(|cx| { + icons.update(cx, |icons, cx| { + for item_id in 1..=count { + icons.get(Icon::Item(item_id), cx); + } + assert_eq!(icons.loading.len(), MAX_CONCURRENT_LOADS); + assert_eq!(icons.queue.len(), 3); + }) + }); + app.run_until(|cx| icons.read(cx).icons.len() == count as usize); + } + + #[test] + fn reads_icon_names_from_tooltips() { + let tooltip = br#"{"name":"Hearthstone","quality":1,"icon":"inv_misc_rune_01","tooltip":"
"}"#; + assert_eq!(icon_name_from_tooltip(tooltip).unwrap(), "inv_misc_rune_01"); + assert!(icon_name_from_tooltip(br#"{"error":"ID is out of range"}"#).is_err()); + assert!(icon_name_from_tooltip(br#"{"icon":"../../etc/passwd"}"#).is_err()); + } + + #[test] + fn caches_what_it_fetches() { + let directory = + std::env::temp_dir().join(format!("foreversight-cache-test-{}", std::process::id())); + let path = directory.join("a/b/icon.jpg"); + + let fetched = cached(&path, || Ok(b"icon".to_vec())).unwrap(); + assert_eq!(fetched, b"icon"); + let fetched = cached(&path, || panic!("fetched again")).unwrap(); + assert_eq!(fetched, b"icon"); + assert!(cached(&directory.join("missing"), || bail!("offline")).is_err()); + + fs::remove_dir_all(directory).unwrap(); + } +} diff --git a/crates/foreversight/src/main.rs b/crates/foreversight/src/main.rs index dcf0204..a7d9065 100644 --- a/crates/foreversight/src/main.rs +++ b/crates/foreversight/src/main.rs @@ -12,6 +12,7 @@ //! `FOREVERSIGHT_WOW_DIR`. mod app_view; +mod icons; mod inventory; mod lua; mod model; @@ -22,6 +23,7 @@ use std::path::PathBuf; use shikra::{Application, WindowOptions, size}; use crate::app_view::Foreversight; +use crate::icons::Wowhead; use crate::sources::Sources; fn main() { @@ -32,6 +34,10 @@ fn main() { paths.push(directory.into()); } let sources = Sources::new(paths); + let icons = Wowhead::new().unwrap_or_else(|error| { + eprintln!("foreversight: {error:#}; caching icons in the temporary directory"); + Wowhead::with_cache(std::env::temp_dir().join("foreversight")) + }); Application::new().run(move |cx| { cx.open_window( @@ -39,7 +45,7 @@ fn main() { title: "Foreversight".into(), size: size(1100., 760.), }, - move |window, cx| cx.new(|cx| Foreversight::new(sources, window, cx)), + move |window, cx| cx.new(|cx| Foreversight::new(sources, icons, window, cx)), ); }); } diff --git a/crates/foreversight/src/model.rs b/crates/foreversight/src/model.rs index 11614f0..31bcadb 100644 --- a/crates/foreversight/src/model.rs +++ b/crates/foreversight/src/model.rs @@ -79,6 +79,26 @@ impl Location { } } +/// A denomination of money. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Coin { + Gold, + Silver, + Copper, +} + +impl Coin { + pub const ALL: [Coin; 3] = [Coin::Gold, Coin::Silver, Coin::Copper]; + + pub fn name(self) -> &'static str { + match self { + Self::Gold => "gold", + Self::Silver => "silver", + Self::Copper => "copper", + } + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum Quality { Poor, diff --git a/crates/shikra/Cargo.toml b/crates/shikra/Cargo.toml index 1a41394..4910a1f 100644 --- a/crates/shikra/Cargo.toml +++ b/crates/shikra/Cargo.toml @@ -10,7 +10,7 @@ default = ["complex-scripts", "image-decoding"] complex-scripts = ["parley/complex-scripts"] # Running apps without a platform, for tests. See `shikra::test`. test-support = [] -# Decoding PNG and JPEG images with `Image::decode`. +# Decoding PNG, JPEG, and GIF images with `Image::decode`. image-decoding = ["dep:image"] [dependencies] @@ -26,7 +26,7 @@ etagere = "0.3" # Load fontconfig at runtime, like winit and wgpu do with their system libraries. fontique = { version = "0.11", features = ["fontconfig-dlopen"] } icu_properties = "2" -image = { version = "0.25", default-features = false, features = ["png", "jpeg"], optional = true } +image = { version = "0.25", default-features = false, features = ["gif", "jpeg", "png"], optional = true } parley = "0.11" pastey = "0.2" pollster = "1" diff --git a/crates/shikra/TODO.md b/crates/shikra/TODO.md index 7a48d35..9636868 100644 --- a/crates/shikra/TODO.md +++ b/crates/shikra/TODO.md @@ -27,8 +27,8 @@ end. - Images are filtered without mipmaps, so shrinking one a lot aliases. - `img` only stretches images to fill it, with no equivalent of CSS `object-fit`, and ignores rounded corners. There's no image opacity. -- `Image::decode` only handles PNG and JPEG, and nothing loads images from - paths or URLs, or animates them. Decoding off the main thread is up to the +- `Image::decode` only handles PNG, JPEG, and GIF, and nothing loads images + from paths or URLs, or animates them. Decoding off the main thread is up to the caller. - No element opacity, transforms, transitions, or animations. diff --git a/crates/shikra/src/image.rs b/crates/shikra/src/image.rs index f5dd78a..9d98d77 100644 --- a/crates/shikra/src/image.rs +++ b/crates/shikra/src/image.rs @@ -60,7 +60,8 @@ impl Image { })) } - /// Decodes a PNG or JPEG image. + /// Decodes a PNG, JPEG, or GIF image. Only the first frame of an animated + /// GIF is decoded. /// /// Requires the `image-decoding` feature, which is on by default. #[cfg(feature = "image-decoding")] diff --git a/crates/shikra/src/test.rs b/crates/shikra/src/test.rs index ea9c13a..a4c1925 100644 --- a/crates/shikra/src/test.rs +++ b/crates/shikra/src/test.rs @@ -11,6 +11,7 @@ use std::cell::RefCell; use std::rc::Rc; +use std::time::{Duration, Instant}; use accesskit::TreeUpdate; @@ -24,8 +25,8 @@ use crate::{ /// An app without an event loop or platform windows. pub struct TestApp { pub(crate) app: Rc>, - _foreground_queue: ForegroundQueue, - _runtime: tokio::runtime::Runtime, + foreground_queue: ForegroundQueue, + runtime: tokio::runtime::Runtime, } #[cfg(test)] @@ -51,8 +52,8 @@ impl TestApp { ); Self { app, - _foreground_queue: foreground_queue, - _runtime: runtime, + foreground_queue, + runtime, } } @@ -65,6 +66,30 @@ impl TestApp { result } + /// Runs tasks, on the main thread and in the background, until `condition` + /// holds, redrawing windows as the event loop would. + /// + /// Panics if `condition` doesn't hold within 10 seconds. + pub fn run_until(&self, mut condition: impl FnMut(&App) -> bool) { + let deadline = Instant::now() + Duration::from_secs(10); + loop { + // Tasks on the main thread update the app, so it can't be borrowed + // while they run. + self.foreground_queue.run_pending(); + let mut app = self.app.borrow_mut(); + settle(&mut app); + if condition(&app) { + return; + } + drop(app); + + assert!(Instant::now() < deadline, "timed out waiting for tasks"); + // Give background tasks a chance to run. + self.runtime.block_on(tokio::task::yield_now()); + std::thread::sleep(Duration::from_millis(1)); + } + } + /// The URLs opened with [`App::open_url`] since this was last called. They /// aren't actually opened. pub fn take_opened_urls(&self) -> Vec { @@ -231,3 +256,31 @@ fn settle(app: &mut App) { } panic!("windows kept asking to be redrawn"); } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn runs_tasks_until_the_condition_holds() { + let app = TestApp::new(); + let (value, _task) = app.update(|cx| { + let value = cx.new(|_| None); + let computing = cx.background_executor().spawn_blocking(|| 6 * 7); + let task = cx.spawn({ + let value = value.downgrade(); + async move |cx| { + let answer = computing.await; + value.update(cx, |value, cx| { + *value = Some(answer); + cx.notify(); + }) + } + }); + (value, task) + }); + + app.run_until(|cx| value.read(cx).is_some()); + app.update(|cx| assert_eq!(*value.read(cx), Some(42))); + } +}