From 7bc3ab8f009706bbe01e021af4dad60604752680 Mon Sep 17 00:00:00 2001 From: Marshall Bowers Date: Sun, 27 Sep 2026 19:25:13 -0400 Subject: [PATCH] foreversight: Use `List` to render item list --- crates/foreversight/README.md | 3 - crates/foreversight/src/app_view.rs | 123 ++++++++++++++------------- crates/foreversight/src/inventory.rs | 12 +-- 3 files changed, 69 insertions(+), 69 deletions(-) diff --git a/crates/foreversight/README.md b/crates/foreversight/README.md index 5f7ca83..fc4b8c4 100644 --- a/crates/foreversight/README.md +++ b/crates/foreversight/README.md @@ -121,9 +121,6 @@ nix shell nixpkgs#lua5_1 -c lua generate.lua > Sample.lua Gaps that Foreversight works around today, roughly in order of how much they matter here: -- **Virtualized lists**, for thousands of items once every character is - recorded. Results are capped at 500 for now, since every row is laid out - and painted each frame. - **Text truncation**, for long item names in narrow rows. - **Window-wide key listeners**, so Escape can close a tooltip opened by the mouse while focus is elsewhere, like in the search field. diff --git a/crates/foreversight/src/app_view.rs b/crates/foreversight/src/app_view.rs index 8c88513..f3e4234 100644 --- a/crates/foreversight/src/app_view.rs +++ b/crates/foreversight/src/app_view.rs @@ -1,5 +1,6 @@ use std::collections::HashMap; use std::path::PathBuf; +use std::rc::Rc; use std::sync::Arc; use std::time::{Duration, SystemTime, UNIX_EPOCH}; @@ -7,8 +8,8 @@ use anyhow::Result; use shikra::accesskit::Live; use shikra::{ Align, Context, Div, Entity, FocusHandle, Image, InteractiveElement, IntoElement, Key, - KeyDownEvent, ParentElement, Render, Rgba, Role, ScrollHandle, Side, Styled, Task, Window, div, - img, point, px, rgb, text_input, + KeyDownEvent, ListState, ParentElement, Render, Rgba, Role, Side, Styled, Task, Window, div, + img, list, px, rgb, text_input, }; use shikra_headless::{Button, Link, Tooltip, TooltipContent}; @@ -25,11 +26,6 @@ type CoinIcons = HashMap; /// since the game shows them right away. const TOOLTIP_DELAY: Duration = Duration::from_millis(200); -/// 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. -const MAX_RESULTS: usize = 500; - /// How often to check whether the SavedVariables files have changed. WoW /// writes them when you log out or reload the UI. const WATCH_INTERVAL: Duration = Duration::from_secs(2); @@ -89,8 +85,9 @@ pub struct Foreversight { query: String, /// The search field, which is focused to start with. search: FocusHandle, - /// How far the results are scrolled. - results_scroll: ScrollHandle, + /// The items matching the query, for the character if one is selected. + matches: Rc>, + results: ListState, /// The key of the character to show items for, or all characters if /// `None`. character: Option, @@ -103,7 +100,7 @@ pub struct Foreversight { enum State { Loading, Loaded { - inventory: Inventory, + inventory: Rc, files: Vec, }, Failed(String), @@ -124,7 +121,8 @@ impl Foreversight { state: State::Loading, query: String::new(), search, - results_scroll: ScrollHandle::new(), + matches: Rc::default(), + results: ListState::new(0), character: None, icons: cx.new(|_| Icons::new("icon", icon_source)), tooltips: cx.new(|_| Tooltips::new("tooltip", tooltip_source)), @@ -152,11 +150,13 @@ impl Foreversight { fn loaded(&mut self, result: Result, cx: &mut Context) { self.state = match result { Ok(loaded) => State::Loaded { - inventory: Inventory::new(loaded.characters), + inventory: Rc::new(Inventory::new(loaded.characters)), files: loaded.files, }, Err(error) => State::Failed(format!("{error:#}")), }; + // They're reloaded whenever the game writes them, so stay where you were. + self.search_again(false); cx.notify(); } @@ -184,20 +184,43 @@ impl Foreversight { fn select_character(&mut self, character: Option, cx: &mut Context) { if character != self.character { self.character = character; - self.scroll_results_to_top(); + self.search_again(true); } cx.notify(); } - /// Shows the start of the results, for when they change. - fn scroll_results_to_top(&self) { - self.results_scroll.set_offset(point(0., 0.)); + /// The index of the character to show items for, if one is selected. + fn selected_character(&self, inventory: &Inventory) -> Option { + let key = self.character.as_ref()?; + inventory + .characters + .iter() + .position(|character| character.key() == *key) + } + + /// Finds the matching items again, for when the query, the character, or + /// the items change, scrolling back to the top of the results if + /// `scroll_to_top`. + fn search_again(&mut self, scroll_to_top: bool) { + let matches = match &self.state { + State::Loaded { inventory, .. } => { + inventory.search(&self.query, self.selected_character(inventory)) + } + _ => Vec::new(), + }; + if scroll_to_top { + self.results.reset(matches.len()); + } else { + let count = self.results.item_count(); + self.results.splice(0..count, matches.len()); + } + self.matches = Rc::new(matches); } fn set_query(&mut self, query: &str, cx: &mut Context) { if query != self.query { self.query = query.to_string(); - self.scroll_results_to_top(); + self.search_again(true); cx.notify(); } } @@ -236,12 +259,7 @@ impl Render for Foreversight { } State::Loaded { inventory, .. } => { let now = now(); - let selected = self.character.as_ref().and_then(|key| { - inventory - .characters - .iter() - .position(|character| character.key() == *key) - }); + let selected = self.selected_character(inventory); root.child(self.sidebar(inventory, selected, now, cx)) .child(self.results(inventory, selected, now, cx)) } @@ -364,13 +382,11 @@ impl Foreversight { fn results( &self, - inventory: &Inventory, + inventory: &Rc, selected: Option, now: i64, cx: &mut Context, ) -> Div { - let matches = inventory.search(&self.query, selected); - let search = text_input() .value(self.query.clone()) .placeholder("Search items across all your characters") @@ -398,37 +414,37 @@ impl Foreversight { .role(Role::SearchInput) .label("Search items"); - let mut summary = match matches.len() { + let mut summary = match self.matches.len() { 1 => "1 item".to_string(), count => format!("{count} items"), }; if let Some(index) = selected { summary.push_str(&format!(" on {}", inventory.characters[index].full_name())); } - if matches.len() > MAX_RESULTS { - summary.push_str(&format!(", showing the first {MAX_RESULTS}")); - } - let coins = self.coin_icons(cx); - let rows = matches - .iter() - .take(MAX_RESULTS) - .map(|item| { - let icon = self - .icons - .update(cx, |icons, cx| icons.get(Icon::Item(item.item.item_id), cx)); + let rows = list(&self.results, { + let matches = self.matches.clone(); + let inventory = inventory.clone(); + let icons = self.icons.clone(); + let tooltips = self.tooltips.clone(); + let coins = self.coin_icons(cx); + move |ix, _, cx| { + let item = &matches[ix]; + let icon = + icons.update(cx, |icons, cx| icons.get(Icon::Item(item.item.item_id), cx)); // Loaded with the row, like its icon, so it's ready on hover. - let tooltip = self - .tooltips - .update(cx, |tooltips, cx| tooltips.get(item.item.version(), cx)); + let tooltip = + tooltips.update(cx, |tooltips, cx| tooltips.get(item.item.version(), cx)); let tooltip = ItemTooltipView { - item: item.item, + item: &item.item, tooltip, coins: &coins, }; - result_row(item, icon, &tooltip, inventory, selected.is_none(), now) - }) - .collect::>(); + result_row(item, icon, &tooltip, &inventory, selected.is_none(), now) + } + }) + // Leave room for the scrollbar, rather than covering the rows with it. + .style(|s| s.flex_1().gap_1().pr_3()); div() .style(|s| s.flex_1().flex().flex_col().gap_4().p_6().min_w_0()) @@ -440,22 +456,7 @@ impl Foreversight { .accessibility(|node| node.set_live(Live::Polite)) .child(summary), ) - .child( - div() - .style(|s| { - // Leave room for the scrollbar, rather than covering - // the rows with it. - s.flex_1() - .flex() - .flex_col() - .gap_1() - .pr_3() - .overflow_y_scroll() - }) - .track_scroll(&self.results_scroll) - .role(Role::List) - .children(rows), - ) + .child(rows) } } diff --git a/crates/foreversight/src/inventory.rs b/crates/foreversight/src/inventory.rs index 140e0c0..6d20a3d 100644 --- a/crates/foreversight/src/inventory.rs +++ b/crates/foreversight/src/inventory.rs @@ -1,13 +1,14 @@ //! Every item across every character, grouped so they can be searched. use std::collections::BTreeMap; +use std::rc::Rc; use crate::model::{Character, Location, Quality}; use crate::tooltip::ItemVersion; pub struct Inventory { pub characters: Vec, - items: Vec, + items: Vec>, } /// All the stacks of one item, across characters. @@ -53,8 +54,8 @@ pub struct Holding { } /// An item that matched a search, with only the holdings that matched. -pub struct Match<'a> { - pub item: &'a Item, +pub struct Match { + pub item: Rc, pub holdings: Vec, pub total: u32, } @@ -99,13 +100,14 @@ impl Inventory { } let mut items: Vec<_> = items.into_values().collect(); items.sort_by(|a, b| a.name.cmp(&b.name).then(a.item_id.cmp(&b.item_id))); + let items = items.into_iter().map(Rc::new).collect(); Self { characters, items } } /// Finds the items whose names contain every word of `query`, ignoring /// case, and that `character` has, if one is given. - pub fn search(&self, query: &str, character: Option) -> Vec> { + pub fn search(&self, query: &str, character: Option) -> Vec { let query = query.to_lowercase(); let terms: Vec<_> = query.split_whitespace().collect(); self.items @@ -122,7 +124,7 @@ impl Inventory { return None; } Some(Match { - item, + item: item.clone(), total: holdings.iter().map(|holding| holding.count).sum(), holdings, }) -- knotmirror 0.0.1