//! Creative inventory (CreativeModeInventoryScreen): vanilla's tabs and their //! contents as built by the game's own display generators //! (`creative-inventory.json`), a 9x5 scrolling grid and the search tab. use crate::{Result, items::ItemStack, ui::EditBox}; use serde_json::Value; use std::{ collections::HashMap, fs, path::Path, sync::{Arc, Mutex}, }; #[derive(Clone, Debug)] pub struct Tab { pub id: String, pub title: String, pub icon: String, pub items: Vec, pub top: bool, pub column: u32, pub search: bool, pub inventory: bool, } /// What the client's creative search trees (SessionSearchTrees) index for one /// stack of the search tab: its creative tooltip lines and its item tags. #[derive(Clone, Debug, Default)] struct SearchEntry { /// The item id's namespace and path, lower-cased. namespace: String, path: String, /// Tooltip lines, lower-cased; empty when the resources predate them. lines: Vec, /// Item tags as (namespace, path), lower-cased. tags: Vec<(String, String)>, } pub struct Creative { pub tabs: Vec, pub selected: usize, /// Scroll position 0..1 (scrollOffs). pub scroll: f32, /// The scrollbar is held (scrolling): dragging moves it. pub scrolling: bool, /// CreativeModeInventoryScreen's search box: an unbordered `EditBox` of 50. pub search: EditBox, /// The search tab's stacks, in its display order. search_items: Vec, search_index: Vec, /// ItemPickerMenu.items: what the grid shows, filled when the tab or the /// search changes (selectTab, refreshSearchResults), not on every frame. shown: Mutex>, } /// The grid's stacks for one tab, search text and language. struct Shown { tab: usize, search: String, /// A display name, so a language change refills the search results. language: String, items: Arc<[ItemStack]>, } /// Display names from the pinned en_us language file. #[derive(Default)] pub struct Names { map: HashMap, } impl Names { pub fn load(root: &Path) -> Self { let read = |namespace: &str| { fs::read(root.join(format!("assets/{namespace}/lang/en_us.json"))) .ok() .and_then(|b| serde_json::from_slice::>(&b).ok()) .unwrap_or_default() }; let mut map = read("minecraft"); // Content mods' names (docs/modpacks.md); none in a vanilla root. for namespace in crate::mod_content::namespaces(root) { map.extend(read(&namespace)); } Self { map } } pub fn item(&self, id: &str) -> String { let (namespace, path) = id.split_once(':').unwrap_or(("minecraft", id)); self.map .get(&format!("item.{namespace}.{path}")) .or_else(|| self.map.get(&format!("block.{namespace}.{path}"))) .cloned() .unwrap_or_else(|| path.replace('_', " ")) } /// An item's display name in the selected language: `item.*` then /// `block.*`, then the pinned English map (`Names::item`). pub fn display_item(&self, id: &str) -> String { let (namespace, path) = id.split_once(':').unwrap_or(("minecraft", id)); if let Some(lang) = crate::lang::current() { for prefix in ["item", "block"] { if let Some(name) = lang.get(&format!("{prefix}.{namespace}.{path}")) { return name.to_owned(); } } } self.item(id) } /// A translation key's text in the selected language, else the pinned /// English map (`HoverName` and tooltip keys such as potion names). pub fn display_get(&self, key: &str) -> Option { crate::lang::current() .and_then(|lang| lang.get(key).map(str::to_owned)) .or_else(|| self.get(key).map(str::to_owned)) } pub fn get(&self, key: &str) -> Option<&str> { self.map.get(key).map(String::as_str) } } /// `CreativeModeTabs`' key for an exported tab id (`itemGroup.*`); the /// export's `title` is the English fallback. pub fn item_group_key(id: &str) -> Option<&'static str> { Some(match id.trim_start_matches("minecraft:") { "building_blocks" => "itemGroup.buildingBlocks", "colored_blocks" => "itemGroup.coloredBlocks", "natural_blocks" => "itemGroup.natural", "functional_blocks" => "itemGroup.functional", "redstone_blocks" => "itemGroup.redstone", "hotbar" => "itemGroup.hotbar", "search" => "itemGroup.search", "tools_and_utilities" => "itemGroup.tools", "combat" => "itemGroup.combat", "food_and_drinks" => "itemGroup.foodAndDrink", "ingredients" => "itemGroup.ingredients", "spawn_eggs" => "itemGroup.spawnEggs", "op_blocks" => "itemGroup.op", "inventory" => "itemGroup.inventory", _ => return None, }) } impl Creative { pub const WIDTH: f32 = 195.0; pub const HEIGHT: f32 = 136.0; pub const TRASH_POSITION: (f32, f32) = (173.0, 112.0); /// Visible player slots in the creative inventory tab (game slot ids). pub fn inventory_slots() -> impl Iterator { (0..36).chain(500..505) } /// CreativeModeInventoryScreen.selectTab positions its InventoryMenu wrappers. pub fn inventory_slot_position(slot: usize) -> Option<(f32, f32)> { match slot { 0..9 => Some((9.0 + slot as f32 * 18.0, 112.0)), 9..36 => Some(( 9.0 + ((slot - 9) % 9) as f32 * 18.0, 54.0 + ((slot - 9) / 9) as f32 * 18.0, )), 500..504 => { let position = slot - 500; Some(( 54.0 + (position / 2) as f32 * 54.0, 6.0 + (position % 2) as f32 * 27.0, )) } 504 => Some((35.0, 20.0)), _ => None, } } /// Game slot id of an item tab's first display slot (ItemPickerMenu's /// `CustomCreativeSlot`s, 9 by 5); its hotbar row keeps ids 0..9. pub const DISPLAY: usize = 900; /// Visible slots of an item tab (game slot ids). pub fn tab_slots() -> impl Iterator { (Self::DISPLAY..Self::DISPLAY + 45).chain(0..9) } /// ItemPickerMenu's slot positions. pub fn tab_slot_position(slot: usize) -> Option<(f32, f32)> { match slot { 0..9 => Some((9.0 + slot as f32 * 18.0, 112.0)), _ if (Self::DISPLAY..Self::DISPLAY + 45).contains(&slot) => { let i = slot - Self::DISPLAY; Some((9.0 + (i % 9) as f32 * 18.0, 18.0 + (i / 9) as f32 * 18.0)) } _ => None, } } /// The scrollbar track (insideScrollbar), relative to the screen origin. pub const SCROLL_X: f32 = 175.0; pub const SCROLL_Y: f32 = 18.0; pub const SCROLL_WIDTH: f32 = 14.0; pub const SCROLL_HEIGHT: f32 = 112.0; /// `creative-inventory.json` (VanillaCreativeTabsExporter); resources /// prepared before it fall back to `creative-tabs.json`'s item ids. pub fn load(root: &Path) -> Result { let path = root.join("creative-inventory.json"); if path.exists() { Self::from_json(&serde_json::from_slice(&fs::read(path)?)?) } else { Self::from_legacy(&serde_json::from_slice(&fs::read( root.join("creative-tabs.json"), )?)?) } } pub fn from_json(raw: &Value) -> Result { let mut tabs = Vec::new(); let mut search_items = Vec::new(); let mut search_index = Vec::new(); for t in raw["tabs"].as_array().into_iter().flatten() { let mut items = Vec::new(); for v in t["items"].as_array().into_iter().flatten() { let snbt = v.as_str().unwrap_or("-"); if let Some(stack) = crate::item_data::parse_stack(snbt)? { items.push(stack); } } let kind = t["type"].as_str().unwrap_or(""); if kind == "SEARCH" { let strings = |v: &Value| -> Vec { v.as_array() .into_iter() .flatten() .filter_map(|s| s.as_str().map(java_lower)) .collect() }; for (i, stack) in items.iter().enumerate() { let (namespace, path) = split_id(&java_lower(&stack.item)); search_index.push(SearchEntry { namespace, path, lines: strings(&t["lines"][i]), tags: strings(&t["tags"][i]).iter().map(|t| split_id(t)).collect(), }); } search_items = items.clone(); } if kind == "HOTBAR" || t["visible"] == false { continue; } let icon = t["icon"] .as_str() .and_then(|s| crate::item_data::parse_stack(s).ok().flatten()); tabs.push(Tab { id: t["id"].as_str().unwrap_or("").to_owned(), title: t["title"].as_str().unwrap_or("").to_owned(), icon: icon.map_or_else(|| "minecraft:stone".to_owned(), |s| s.item.to_string()), items, top: t["row"] == "TOP", column: t["column"].as_u64().unwrap_or(0) as u32, search: kind == "SEARCH", inventory: kind == "INVENTORY", }); } Ok(Self::new(tabs, search_items, search_index)) } /// The old export: item ids per tab, no components, tooltips or tags. fn from_legacy(raw: &Value) -> Result { let mut tabs = Vec::new(); let mut search_items: Vec = Vec::new(); for t in raw.as_array().into_iter().flatten() { let items: Vec = t["items"] .as_array() .into_iter() .flatten() .filter_map(|v| v.as_str().map(|id| ItemStack::new(id, 1))) .collect(); if t["id"] == "minecraft:hotbar" || t["id"] == "minecraft:op_blocks" { continue; } for i in &items { if !search_items.iter().any(|s| s.item == i.item) { search_items.push(i.clone()); } } let kind = t["type"].as_str().unwrap_or(""); tabs.push(Tab { id: t["id"].as_str().unwrap_or("").to_owned(), title: t["title"].as_str().unwrap_or("").to_owned(), icon: t["icon"].as_str().unwrap_or("minecraft:stone").to_owned(), items, top: t["row"] == "TOP", column: t["column"].as_u64().unwrap_or(0) as u32, search: kind == "SEARCH", inventory: kind == "INVENTORY", }); } let search_index = search_items .iter() .map(|s| { let (namespace, path) = split_id(&java_lower(&s.item)); SearchEntry { namespace, path, ..Default::default() } }) .collect(); Ok(Self::new(tabs, search_items, search_index)) } fn new(tabs: Vec, search_items: Vec, search_index: Vec) -> Self { Self { tabs, selected: 0, scroll: 0.0, scrolling: false, search: { let mut search = EditBox::new(0.0, 0.0, 80.0, 9.0); search.bordered = false; search.max_length = 50; search.text_color = u32::MAX; search }, search_items, search_index, shown: Mutex::new(None), } } /// Where a stack comes in the creative tabs (the search tab lists them /// all, in tab order): the same stack's place, else its item's first. /// None for items the tabs don't show. pub fn order_of(&self, stack: &ItemStack) -> Option { let items = &self.search_items; items .iter() .position(|s| s.same_item_same_components(stack)) .or_else(|| items.iter().position(|s| s.item == stack.item)) } /// The tabs without their stacks: what the screen draws of them each frame. pub fn tab_headers(&self) -> Vec { self.tabs .iter() .map(|t| Tab { id: t.id.clone(), title: t.title.clone(), icon: t.icon.clone(), items: Vec::new(), top: t.top, column: t.column, search: t.search, inventory: t.inventory, }) .collect() } pub fn tab(&self) -> &Tab { &self.tabs[self.selected.min(self.tabs.len() - 1)] } /// selectTab: a different tab empties the search box; the scrollbar goes to the top. pub fn select(&mut self, tab: usize) { if tab != self.selected { self.search.set_value(""); } self.selected = tab; self.scroll = 0.0; if self.tab().search { self.search.focused = true; self.search.reset_blink(); } } /// The search tab's stacks. pub fn search_items(&self) -> &[ItemStack] { &self.search_items } /// Opening the screen: vanilla rebuilds CreativeModeInventoryScreen, whose /// init() makes a fresh search box and a zeroed scrollbar; only the tab /// persists (its static selectedTab). selectTab on the reopened screen /// takes the oldTab != tab branch, so it empties and refocuses the box. pub fn reopen(&mut self) { self.search.set_value(""); self.release(); self.scroll = 0.0; self.search.focused = self.tab().search; if self.search.focused { self.search.reset_blink(); } } /// Indices into `search_items` that CreativeModeInventoryScreen.refreshSearchResults /// shows for `term`: the whole tab when empty, the tag tree after `#`, else /// the name tree, both over vanilla's suffix arrays (substring matches, /// results in the tab's order). pub fn search_results(&self, term: &str, names: &Names) -> Vec { let all = 0..self.search_index.len(); if term.is_empty() { return all.collect(); } if let Some(tag) = term.strip_prefix('#') { // IdSearchTree over the stacks' tags. let text = java_lower(tag); return all .filter(|&i| { let tags = &self.search_index[i].tags; match text.split_once(':') { None => any(tags.iter().map(|t| t.1.as_str()), &text), Some((ns, path)) => { let (ns, path) = (java_trim(ns), java_trim(path)); any(tags.iter().map(|t| t.0.as_str()), ns) && any(tags.iter().map(|t| t.1.as_str()), path) } } }) .collect(); } // FullTextSearchTree: tooltip lines, and the item id after a colon. // The export's lines are English (they come from the vanilla jars); // the hover name in the selected language is matched too, so a // localized search finds the item. It is the whole name (a tipped // arrow's potion, the ominous banner), as the first line is. let text = java_lower(term); let localized = |i: usize| { java_lower(&crate::item_tooltip::name_line( &self.search_items[i], &|k| names.display_get(k), )) }; all.filter(|&i| { let e = &self.search_index[i]; let fallback; let lines: &[String] = if e.lines.is_empty() { fallback = [java_lower(&names.item(&self.search_items[i].item))]; &fallback } else { &e.lines }; match text.split_once(':') { None => { any(lines.iter().map(String::as_str), &text) || localized(i).contains(&text) } Some((ns, path)) => { let (ns, path) = (java_trim(ns), java_trim(path)); e.namespace.contains(ns) && (e.path.contains(path) || any(lines.iter().map(String::as_str), path) || localized(i).contains(path)) } } }) .collect() } /// Items shown by the selected tab (the search tab filters by `search`), /// worked out again only when the tab, the search or the language changed. pub fn items(&self, names: &Names) -> Arc<[ItemStack]> { let tab = self.selected.min(self.tabs.len() - 1); let search = if self.tab().search { self.search.value.as_str() } else { "" }; let language = names.display_get("itemGroup.search").unwrap_or_default(); let mut shown = self.shown.lock().unwrap_or_else(|e| e.into_inner()); if let Some(s) = shown.as_ref() && s.tab == tab && s.search == search && s.language == language { return s.items.clone(); } let items: Arc<[ItemStack]> = if self.tab().search { self.search_results(search, names) .into_iter() .map(|i| self.search_items[i].clone()) .collect() } else { self.tab().items.as_slice().into() }; *shown = Some(Shown { tab, search: search.to_owned(), language, items: items.clone(), }); items } /// Drop the shown items, so the next `items` works them out again: a /// reload can rename items without changing the Search label. pub fn forget_items(&self) { *self.shown.lock().unwrap_or_else(|e| e.into_inner()) = None; } pub fn rows(&self, names: &Names) -> usize { self.items(names).len().div_ceil(9) } /// ItemPickerMenu.calculateRowCount: rows beyond the five shown. fn hidden_rows(&self, names: &Names) -> i32 { self.rows(names) as i32 - 5 } /// First visible grid row for the scroll position (getRowIndexForScroll). pub fn first_row(&self, names: &Names) -> usize { ((self.scroll * self.hidden_rows(names) as f32 + 0.5) as i32).max(0) as usize } /// CreativeModeInventoryScreen.canScroll: more than five rows, on a tab with a scrollbar. pub fn can_scroll(&self, names: &Names) -> bool { !self.tab().inventory && self.items(names).len() > 45 } /// insideScrollbar, for a point relative to the screen origin. pub fn inside_scrollbar(&self, x: f32, y: f32) -> bool { x >= Self::SCROLL_X && y >= Self::SCROLL_Y && x < Self::SCROLL_X + Self::SCROLL_WIDTH && y < Self::SCROLL_Y + Self::SCROLL_HEIGHT && !self.tab().inventory } /// mouseClicked on the scrollbar: it is held only if the tab can scroll /// (the thumb doesn't move until the mouse does). pub fn press_scrollbar(&mut self, x: f32, y: f32, names: &Names) -> bool { if !self.inside_scrollbar(x, y) { return false; } self.scrolling = self.can_scroll(names); true } /// mouseDragged while the scrollbar is held, at a cursor height relative to /// the screen origin, wherever the cursor is (even off the track or the window). pub fn drag_scrollbar(&mut self, y: f32) -> bool { if !self.scrolling { return false; } let (top, bottom) = (Self::SCROLL_Y, Self::SCROLL_Y + Self::SCROLL_HEIGHT); self.scroll = ((y - top - 7.5) / (bottom - top - 15.0)).clamp(0.0, 1.0); true } /// mouseReleased. pub fn release(&mut self) { self.scrolling = false; } /// mouseScrolled: ItemPickerMenu.subtractInputFromScroll. pub fn wheel(&mut self, amount: f64, names: &Names) { if !self.can_scroll(names) { return; } let rows = self.hidden_rows(names) as f64; self.scroll = (self.scroll - (amount / rows) as f32).clamp(0.0, 1.0); } /// The scroller sprite's offset from the top of the track. pub fn scroller_offset(&self) -> f32 { ((Self::SCROLL_HEIGHT - 17.0) * self.scroll) as i32 as f32 } /// Tab button position relative to the screen origin (CreativeModeInventoryScreen.getTabX/Y). pub fn tab_position(tab: &Tab) -> (f32, f32) { let mut x = tab.column as f32 * 27.0; if tab.column >= 5 { // Right-aligned tabs (search, inventory). x = Self::WIDTH - 27.0 * (7 - tab.column) as f32 + 1.0; } let y = if tab.top { -28.0 } else { Self::HEIGHT - 4.0 }; (x, y) } } /// Whether a suffix array of `strings` has a suffix starting with `text`: /// some string contains it (none for no strings, even when `text` is empty). fn any<'a>(strings: impl IntoIterator, text: &str) -> bool { strings.into_iter().any(|s| s.contains(text)) } /// String.toLowerCase(Locale.ROOT). fn java_lower(s: &str) -> String { s.to_lowercase() } /// String.trim: strips code points up to U+0020. fn java_trim(s: &str) -> &str { s.trim_matches(|c: char| c <= ' ') } /// Identifier.parse's namespace and path (default namespace `minecraft`). fn split_id(id: &str) -> (String, String) { match id.split_once(':') { Some((ns, path)) => (ns.to_owned(), path.to_owned()), None => ("minecraft".to_owned(), id.to_owned()), } } /// The slot under the cursor, for the search tab's keys. #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub enum Hover { Nothing, /// A display slot (`isCreativeSlot`), showing an item or past the end. Display { filled: bool, }, /// Any other slot: the hotbar row, or the inventory tab's slots. Slot, } /// `CreativeModeInventoryScreen.keyPressed` on the search tab: whether a /// key is a hotbar swap on the hovered slot instead of text for the search /// box. `numeric` is `getNumericKeyValue().isPresent()` (the digit and keypad /// keys); `hotbar_key` is a `keyHotbarSlots` key. Over an empty display slot /// the key types (`doQuickSwap` is false), and `checkHotbarKeyPressed` needs /// a hovered slot and an empty cursor. pub fn search_key_swaps(numeric: bool, hotbar_key: bool, hover: Hover, carrying: bool) -> bool { let quick_swap = hover != Hover::Display { filled: false }; quick_swap && numeric && hotbar_key && !carrying && hover != Hover::Nothing } /// What a click on an item tab's display slot does besides changing the /// carried stack. #[derive(Clone, PartialEq, Debug)] pub enum DisplayClick { Nothing, /// SWAP: the hotbar slot gets a full stack of the item. Hotbar(usize, ItemStack), /// SWAP with button 40 (Key F): the off-hand gets a full stack of the item. Offhand(ItemStack), /// THROW: a copy of the item is dropped. Drop(ItemStack), } /// `CreativeModeInventoryScreen.slotClicked` on a display slot /// (`slot.container == CONTAINER`): `shown` is the slot's item (None past /// the tab's end) and `max` gives an item's max stack size. The tab's items /// are never changed: clicks make copies. pub fn click_display( carried: &mut Option, shown: Option<&ItemStack>, max: impl Fn(&ItemStack) -> u32, button: i32, input: crate::menu::Input, ) -> DisplayClick { use crate::menu::Input; let copy = |s: &ItemStack, count: u32| ItemStack { count, ..s.clone() }; let quick_key = input == Input::QuickMove; match input { Input::Swap => { return match shown { Some(s) if button == 40 => DisplayClick::Offhand(copy(s, max(s))), Some(s) if (0..9).contains(&button) => { DisplayClick::Hotbar(button as usize, copy(s, max(s))) } Some(_) | None => DisplayClick::Nothing, }; } Input::Clone => { if carried.is_none() && let Some(s) = shown { *carried = Some(copy(s, max(s))); } return DisplayClick::Nothing; } Input::Throw => { return match shown { Some(s) => DisplayClick::Drop(copy(s, if button == 0 { 1 } else { max(s) })), None => DisplayClick::Nothing, }; } _ => {} } match (carried.as_mut(), shown) { (Some(c), Some(s)) if c.item == s.item && c.extra == s.extra && c.damage == s.damage => { // isSameItemSameComponents: grow, fill or shrink what is carried. if button == 0 { if quick_key { c.count = max(c); } else if c.count < max(c) { c.count += 1; } } else { c.count -= 1; } } (None, Some(s)) => { *carried = Some(copy(s, if quick_key { max(s) } else { s.count })); } (Some(c), _) => { if button == 0 { *carried = None; } else { c.count -= 1; } } (None, None) => {} } if carried.as_ref().is_some_and(|c| c.count == 0) { *carried = None; } DisplayClick::Nothing } #[cfg(test)] mod tests { use super::*; fn stack(item: &str, count: u32) -> ItemStack { ItemStack { item: format!("minecraft:{item}").into(), count, damage: 0, extra: None, } } /// One click on a display slot showing one stone (max 64; pearls 16). fn display( carried: Option, button: i32, input: crate::menu::Input, ) -> (Option, DisplayClick) { let mut carried = carried; let max = |s: &ItemStack| if s.item.ends_with("pearl") { 16 } else { 64 }; let effect = click_display(&mut carried, Some(&stack("stone", 1)), max, button, input); (carried, effect) } #[test] fn search_tab_number_keys_swap_over_a_slot_before_typing() { let filled = Hover::Display { filled: true }; let empty = Hover::Display { filled: false }; // Over an item, the hotbar row or another slot: the hotbar swap. assert!(search_key_swaps(true, true, filled, false)); assert!(search_key_swaps(true, true, Hover::Slot, false)); // Over an empty display slot, nothing or with a carried stack: text. assert!(!search_key_swaps(true, true, empty, false)); assert!(!search_key_swaps(true, true, Hover::Nothing, false)); assert!(!search_key_swaps(true, true, filled, true)); // Letters always type; so do the digits no hotbar key is bound to. assert!(!search_key_swaps(false, false, filled, false)); assert!(!search_key_swaps(true, false, filled, false)); } #[test] fn display_slot_clicks_follow_slot_clicked() { use crate::menu::Input::*; // An empty hand takes the shown stack, or a full one with Shift. assert_eq!(display(None, 0, Pickup).0, Some(stack("stone", 1))); assert_eq!(display(None, 1, Pickup).0, Some(stack("stone", 1))); assert_eq!(display(None, 0, QuickMove).0, Some(stack("stone", 64))); // The same item grows by one (left), fills (Shift) or shrinks (right). assert_eq!( display(Some(stack("stone", 5)), 0, Pickup).0, Some(stack("stone", 6)) ); assert_eq!( display(Some(stack("stone", 64)), 0, Pickup).0, Some(stack("stone", 64)) ); assert_eq!( display(Some(stack("stone", 5)), 0, QuickMove).0, Some(stack("stone", 64)) ); assert_eq!( display(Some(stack("stone", 5)), 1, Pickup).0, Some(stack("stone", 4)) ); assert_eq!(display(Some(stack("stone", 1)), 1, QuickMove).0, None); // Another item is deleted (left) or loses one (right). assert_eq!(display(Some(stack("ender_pearl", 9)), 0, Pickup).0, None); assert_eq!(display(Some(stack("ender_pearl", 9)), 0, QuickMove).0, None); assert_eq!( display(Some(stack("ender_pearl", 9)), 1, Pickup).0, Some(stack("ender_pearl", 8)) ); // Number keys fill a hotbar slot, F fills off-hand, Q drops copies, pick block clones. assert_eq!( display(None, 3, Swap).1, DisplayClick::Hotbar(3, stack("stone", 64)) ); assert_eq!( display(None, 40, Swap).1, DisplayClick::Offhand(stack("stone", 64)) ); assert_eq!( display(None, 0, Throw).1, DisplayClick::Drop(stack("stone", 1)) ); assert_eq!( display(None, 1, Throw).1, DisplayClick::Drop(stack("stone", 64)) ); assert_eq!(display(None, 2, Clone).0, Some(stack("stone", 64))); assert_eq!( display(Some(stack("dirt", 2)), 2, Clone).0, Some(stack("dirt", 2)) ); } #[test] fn an_empty_display_slot_deletes_or_shrinks_the_carried_stack() { use crate::menu::Input::Pickup; let mut carried = Some(stack("dirt", 3)); click_display(&mut carried, None, |_| 64, 1, Pickup); assert_eq!(carried, Some(stack("dirt", 2))); click_display(&mut carried, None, |_| 64, 0, Pickup); assert_eq!(carried, None); } fn creative(items: usize) -> Creative { let mut stacks: Vec = ["anvil", "crafting_table", "wooden_hoe"] .iter() .map(|i| format!("{{count:1,id:\"minecraft:{i}\"}}")) .collect(); stacks.extend((0..items).map(|i| format!("{{count:1,id:\"minecraft:item_{i}\"}}"))); let mut lines = vec![ vec!["Anvil"], vec!["Crafting Table"], vec!["Wooden Hoe", "When in Main Hand:", "-3 Attack Speed"], ]; lines.extend(vec![vec!["Thing"]; items]); let mut tags = vec![ vec!["minecraft:anvil"], vec![], vec!["minecraft:hoes", "minecraft:enchantable/mining"], ]; tags.extend(vec![vec![]; items]); let raw = serde_json::json!({"schema": 2, "tabs": [ {"id": "minecraft:functional_blocks", "type": "CATEGORY", "row": "TOP", "column": 3, "title": "Functional Blocks", "icon": "{count:1,id:\"minecraft:oak_sign\"}", "visible": true, "items": ["{count:1,id:\"minecraft:anvil\"}", "{count:1,id:\"minecraft:crafting_table\"}"]}, {"id": "minecraft:op_blocks", "type": "CATEGORY", "row": "BOTTOM", "column": 5, "title": "Operator Utilities", "icon": "{count:1,id:\"minecraft:command_block\"}", "visible": false, "items": []}, {"id": "minecraft:search", "type": "SEARCH", "row": "TOP", "column": 6, "title": "Search Items", "icon": "{count:1,id:\"minecraft:compass\"}", "visible": true, "items": stacks, "lines": lines, "tags": tags}, {"id": "minecraft:inventory", "type": "INVENTORY", "row": "BOTTOM", "column": 6, "title": "Survival Inventory", "icon": "{count:1,id:\"minecraft:chest\"}", "visible": true, "items": []} ]}); Creative::from_json(&raw).unwrap() } #[test] fn inventory_tab_slot_layout_matches_vanilla() { assert_eq!(Creative::inventory_slots().count(), 41); for (slot, expected) in [ (0, (9.0, 112.0)), (8, (153.0, 112.0)), (9, (9.0, 54.0)), (35, (153.0, 90.0)), (500, (54.0, 6.0)), (501, (54.0, 33.0)), (502, (108.0, 6.0)), (503, (108.0, 33.0)), (504, (35.0, 20.0)), ] { assert_eq!(Creative::inventory_slot_position(slot), Some(expected)); } assert_eq!(Creative::inventory_slot_position(100), None); assert_eq!(Creative::TRASH_POSITION, (173.0, 112.0)); let mut c = creative(90); c.select(2); assert!(c.tab().inventory); assert!(!c.can_scroll(&Names { map: HashMap::new() })); } #[test] fn inventory_tab_can_return_to_category_and_search() { let mut c = creative(90); for (selected, inventory, search) in [ (2, true, false), (0, false, false), (1, false, true), (2, true, false), ] { c.search.set_value("anvil"); c.scroll = 0.5; c.select(selected); assert_eq!(c.selected, selected); assert_eq!(c.tab().inventory, inventory); assert_eq!(c.tab().search, search); assert!(c.search.value.is_empty()); assert_eq!(c.scroll, 0.0); } } #[test] fn hidden_tabs_are_skipped() { let c = creative(0); let ids: Vec<&str> = c.tabs.iter().map(|t| t.id.as_str()).collect(); assert_eq!( ids, [ "minecraft:functional_blocks", "minecraft:search", "minecraft:inventory" ] ); assert_eq!(c.tabs[0].items[0].item.as_ref(), "minecraft:anvil"); let mut c = c; c.select(1); c.search.set_value("anvil"); c.scroll = 0.5; c.select(1); assert_eq!((c.search.value.as_str(), c.scroll), ("anvil", 0.0)); c.select(0); assert_eq!(c.search.value, ""); } #[test] fn search_matches_vanilla_trees() { let c = creative(0); let names = Names { map: HashMap::new(), }; let q = |s: &str| c.search_results(s, &names); assert_eq!(q(""), [0, 1, 2]); assert_eq!(q("ANVIL"), [0]); assert_eq!(q("table"), [1]); assert_eq!(q("a"), [0, 1, 2]); // Tooltip lines, not just names; no trimming without a colon. assert_eq!(q("attack speed"), [2]); assert_eq!(q(" anvil"), Vec::::new()); // A colon searches the id: namespace, then the path or a tooltip line. assert_eq!(q("minecraft:crafting_t"), [1]); assert_eq!(q(" minecraft : hoe "), [2]); assert_eq!(q("mine:anvil"), [0]); assert_eq!(q("other:anvil"), Vec::::new()); assert_eq!(q(":"), [0, 1, 2]); // Tags after "#": only stacks with tags. assert_eq!(q("#"), [0, 2]); assert_eq!(q("#hoe"), [2]); assert_eq!(q("#minecraft:enchantable/"), [2]); assert_eq!(q("#craft"), Vec::::new()); } #[test] fn search_matches_the_display_name_in_the_selected_language() { let c = creative(0); let names = Names { map: HashMap::from([ ("item.minecraft.anvil".to_owned(), "铁砧".to_owned()), ("item.minecraft.wooden_hoe".to_owned(), "木锄".to_owned()), ]), }; let q = |s: &str| c.search_results(s, &names); assert_eq!(q("铁砧"), [0]); assert_eq!(q("木锄"), [2]); // The English tooltip lines the export indexed still match. assert_eq!(q("anvil"), [0]); // A colon's path searches the display name too. assert_eq!(q("minecraft:铁砧"), [0]); } #[test] fn scrollbar_drag_follows_the_mouse_while_held() { let mut c = creative(90); let names = Names { map: HashMap::new(), }; c.selected = 1; // Pressing the track holds the scrollbar without moving it. assert!(c.press_scrollbar(180.0, 60.0, &names)); assert!(c.scrolling); assert_eq!(c.scroll, 0.0); // Dragging maps the cursor height onto the track (minus half the thumb). assert!(c.drag_scrollbar(18.0 + 7.5 + 48.5)); assert!((c.scroll - 0.5).abs() < 1e-6); // The cursor may leave the track, the panel or the window: it keeps scrolling. c.drag_scrollbar(400.0); assert_eq!(c.scroll, 1.0); c.drag_scrollbar(-50.0); assert_eq!(c.scroll, 0.0); c.drag_scrollbar(18.0 + 7.5 + 97.0 * 0.25); assert!((c.scroll - 0.25).abs() < 1e-6); let rows = (93usize.div_ceil(9) - 5) as f32; assert_eq!(c.first_row(&names), (0.25 * rows + 0.5) as usize); // Releasing stops it. c.release(); assert!(!c.drag_scrollbar(400.0)); assert!((c.scroll - 0.25).abs() < 1e-6); // Off the track, or with too few items, it isn't held. assert!(!c.press_scrollbar(170.0, 60.0, &names)); assert!(!c.press_scrollbar(180.0, 130.0, &names)); c.selected = 0; assert!(c.press_scrollbar(180.0, 60.0, &names)); assert!(!c.scrolling); } #[test] fn creative_tab_titles_have_vanilla_keys() { assert_eq!( item_group_key("minecraft:building_blocks"), Some("itemGroup.buildingBlocks") ); assert_eq!( item_group_key("minecraft:food_and_drinks"), Some("itemGroup.foodAndDrink") ); assert_eq!(item_group_key("minecraft:hotbar"), Some("itemGroup.hotbar")); assert_eq!(item_group_key("minecraft:nonsense"), None); } #[test] fn wheel_moves_one_row_per_notch() { let mut c = creative(90); let names = Names { map: HashMap::new(), }; c.selected = 1; let rows = (93usize.div_ceil(9) - 5) as f32; c.wheel(-1.0, &names); assert!((c.scroll - 1.0 / rows).abs() < 1e-6); assert_eq!(c.first_row(&names), 1); c.wheel(5.0, &names); assert_eq!(c.scroll, 0.0); assert_eq!(c.scroller_offset(), 0.0); c.scroll = 1.0; assert_eq!(c.scroller_offset(), 95.0); } #[test] fn search_edits_at_its_cursor() { use winit::keyboard::KeyCode; let mut c = creative(0); c.select(1); c.search.insert_text("hello world"); for _ in 0..5 { assert!(c.search.key(KeyCode::ArrowLeft, false, false)); } c.search.insert_text("X"); assert_eq!(c.search.value, "hello Xworld"); c.search.key(KeyCode::Home, false, true); c.search.insert_text("Y"); assert_eq!(c.search.value, "Yworld"); // Ctrl+Left jumps a word; one step right and Backspace deletes before the cursor. c.search.key(KeyCode::ArrowLeft, true, false); c.search.key(KeyCode::ArrowRight, false, false); c.search.key(KeyCode::Backspace, false, false); assert_eq!(c.search.value, "world"); } }