Skip to main content

steel_core/inventory/menu/
mod.rs

1//! Contains the Menu API
2
3mod behavior;
4mod builder;
5mod grid;
6mod kind;
7pub mod kinds;
8mod layout;
9
10use crate::inventory::container::Container as _;
11pub use behavior::MenuBehavior;
12pub use builder::{
13    ContainerSlots, DataSlot, FakeResultRemainderPolicy, FillDirection, IntoSections, MenuBuilder,
14    PlayerInventorySections, Section, SectionKind, SectionSource, SlotFactory,
15};
16pub use grid::{ColSpan, GridPlacer, PlacementBuilder, Rect, Region, RowSpan, SpanBounds};
17pub use kind::MenuKind;
18pub(crate) use layout::MenuLayout;
19#[cfg(test)]
20use steel_utils::locks::Shared;
21
22use std::fmt;
23use std::mem;
24
25use steel_registry::{item_stack::ItemStack, menu_type::MenuTypeRef};
26use steel_utils::{Downcast as _, types::GameType};
27
28use crate::inventory::container::CraftingContainer;
29use crate::inventory::menu::kinds::InventoryKind;
30use crate::{
31    inventory::lock::{ContainerId, ContainerLockGuard, ContainerRef},
32    player::Player,
33};
34
35use crate::inventory::click::{Click, ClickOutcome, SwapTarget, can_item_quick_replace};
36
37/// A menu opened by a player: the shared click machinery plus one
38/// [`MenuKind`].
39///
40/// The single concrete menu type. It owns the [`MenuBehavior`], the
41/// `MenuLayout`, and a boxed [`MenuKind`]. Click handlers are inherent methods.
42pub struct Menu {
43    behavior: MenuBehavior,
44    layout: MenuLayout,
45    kind: Box<dyn MenuKind>,
46    overrides_player_slots: bool,
47}
48
49impl fmt::Debug for Menu {
50    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
51        f.debug_struct("Menu")
52            .field("behavior", &self.behavior)
53            .field("kind", &self.kind.downcast_type_key())
54            .finish_non_exhaustive()
55    }
56}
57
58impl Menu {
59    /// Assembles a menu from its parts.
60    pub(super) fn from_parts(
61        behavior: MenuBehavior,
62        layout: MenuLayout,
63        kind: Box<dyn MenuKind>,
64        overrides_player_slots: bool,
65    ) -> Self {
66        Self {
67            behavior,
68            layout,
69            kind,
70            overrides_player_slots,
71        }
72    }
73
74    /// Returns a reference to the shared menu behavior.
75    #[must_use]
76    pub const fn behavior(&self) -> &MenuBehavior {
77        &self.behavior
78    }
79
80    /// Returns a mutable reference to the shared menu behavior.
81    pub const fn behavior_mut(&mut self) -> &mut MenuBehavior {
82        &mut self.behavior
83    }
84
85    /// Returns a reference to this menu's kind.
86    #[must_use]
87    pub fn kind(&self) -> &dyn MenuKind {
88        self.kind.as_ref()
89    }
90
91    /// Returns a mutable reference to this menu's kind.
92    pub fn kind_mut(&mut self) -> &mut dyn MenuKind {
93        self.kind.as_mut()
94    }
95
96    /// The container ID for this menu (0 for the player inventory).
97    #[must_use]
98    pub const fn container_id(&self) -> u8 {
99        self.behavior.container_id()
100    }
101
102    /// The menu type for the open-screen packet, or `None` for the player's own
103    /// inventory.
104    #[must_use]
105    pub const fn menu_type(&self) -> Option<MenuTypeRef> {
106        self.behavior.menu_type()
107    }
108
109    /// Returns whether this menu paints over the client's standard player slots.
110    #[must_use]
111    pub const fn overrides_player_slots(&self) -> bool {
112        self.overrides_player_slots
113    }
114
115    /// Returns true if this menu is still valid for the player.
116    #[must_use]
117    pub fn still_valid(&self, player: &Player) -> bool {
118        self.kind.still_valid(&self.behavior, player)
119    }
120
121    /// Returns true if the item can be taken from the slot during pickup all.
122    #[must_use]
123    pub fn can_take_item_for_pick_all(&self, carried: &ItemStack, slot_index: usize) -> bool {
124        self.kind.can_take_item_for_pick_all(carried, slot_index)
125    }
126
127    /// Called when the menu is closed. Hands the carried item and input sections
128    /// back to the player, then runs the kind's cleanup. Items drop into the
129    /// world if the player can't take them (see
130    /// [`Player::returns_menu_items_to_inventory`]).
131    pub fn removed(&mut self, player: &Player) {
132        let return_to_inventory = player.returns_menu_items_to_inventory();
133
134        let carried = mem::take(self.behavior.carried_mut());
135        if !carried.is_empty() {
136            if return_to_inventory {
137                player.add_item_or_drop(carried);
138            } else {
139                let _ = player.drop_item(carried, false, false);
140            }
141        }
142        self.layout
143            .return_drained_items(&self.behavior, player, return_to_inventory);
144
145        let Self { behavior, kind, .. } = self;
146        kind.removed(behavior, player);
147    }
148
149    /// Applies a client rename to the menu's kind. A no-op for kinds without a
150    /// rename input.
151    pub fn set_item_name(&mut self, name: impl Into<String>, player: &Player) {
152        let Self { behavior, kind, .. } = self;
153        kind.on_rename(behavior, name.into(), player);
154    }
155
156    /// Clears or counts crafting-grid items in the base inventory menu,
157    /// returning the number cleared or counted. Returns 0 for any other menu.
158    pub(crate) fn clear_or_count_crafting_items(
159        &mut self,
160        predicate: &dyn Fn(&ItemStack) -> bool,
161        amount_to_remove: i32,
162        counting_only: bool,
163    ) -> i32 {
164        let Some(kind) = self.kind.downcast_ref::<InventoryKind>() else {
165            return 0;
166        };
167        let crafting_id = kind.crafting_id();
168        let mut guard = self.behavior.lock_all_containers();
169        let Some(crafting) = guard.get_typed_mut::<CraftingContainer>(crafting_id) else {
170            return 0;
171        };
172
173        crafting.clear_or_count_matching_items(predicate, amount_to_remove, counting_only)
174    }
175
176    /// A shared handle to the base inventory menu's 2x2 crafting grid, or `None`
177    /// for any other menu.
178    #[cfg(test)]
179    pub(crate) fn crafting_container(&self) -> Option<Shared<CraftingContainer>> {
180        let kind = self.kind.downcast_ref::<InventoryKind>()?;
181        Some(kind.crafting_container())
182    }
183
184    /// Recomputes the base inventory menu's crafting result. A no-op for any
185    /// other menu.
186    pub(crate) fn update_crafting_result(&mut self) {
187        let Some(kind) = self.kind.downcast_mut::<InventoryKind>() else {
188            return;
189        };
190        let mut guard = self.behavior.lock_all_containers();
191        kind.update_result(&mut guard);
192    }
193
194    /// Recomputes recipe-driven slots after a change (delegates to the kind).
195    fn slots_changed(&mut self, guard: &mut ContainerLockGuard, player: &Player) {
196        let Self { behavior, kind, .. } = self;
197        kind.slots_changed(behavior, guard, player);
198    }
199
200    /// Runs the kind's `on_open` hook, after contents are built but before they
201    /// are sent to the client.
202    pub fn on_open(&mut self, player: &Player) {
203        let mut guard = self.behavior().lock_all_containers();
204        let Self { behavior, kind, .. } = self;
205        kind.on_open(behavior, &mut guard, player);
206    }
207
208    /// Runs the kind's `on_tick` hook. Called once per server tick while open.
209    pub fn on_tick(&mut self, player: &Player) {
210        let mut guard = self.behavior().lock_all_containers();
211        let Self { behavior, kind, .. } = self;
212        kind.on_tick(behavior, &mut guard, player);
213    }
214
215    /// Shift-click (quick move) for a slot: the kind's override if any, else the
216    /// declarative route table. Returns the item originally in the slot, or
217    /// empty if nothing moved.
218    fn quick_move_stack(
219        &mut self,
220        guard: &mut ContainerLockGuard,
221        slot_index: usize,
222        player: &Player,
223    ) -> ItemStack {
224        let Self {
225            behavior,
226            layout,
227            kind,
228            ..
229        } = self;
230        if let Some(result) = kind.quick_move(behavior, guard, slot_index, player) {
231            result
232        } else {
233            layout.quick_move(behavior, guard, slot_index, player)
234        }
235    }
236
237    /// Handles a click action in this menu. Packet clicks are validated via
238    /// [`Click::parse`]; invalid programmatically constructed clicks are ignored.
239    ///
240    /// TODO: Add `tryItemClickBehaviorOverride` for bundle item support.
241    pub fn clicked(&mut self, click: Click, player: &Player) {
242        if !click.is_valid_for(self.behavior().slot_count()) {
243            log::debug!(
244                "Ignoring programmatic container click that violates parsed-click invariants: \
245                 {click:?}"
246            );
247            return;
248        }
249
250        let has_infinite_materials = player.game_mode() == GameType::Creative;
251        if let Click::QuickCraft(action) = click {
252            let outcome = {
253                let mut guard = self.behavior().lock_all_containers();
254                let Self { behavior, kind, .. } = self;
255                kind.on_drag(behavior, &mut guard, action, player)
256            };
257            if outcome == ClickOutcome::Consume {
258                self.behavior_mut().reset_quick_craft();
259            } else {
260                let Self { behavior, kind, .. } = self;
261                behavior.do_quick_craft(action, has_infinite_materials, player, &|slot| {
262                    kind.can_drag_to(slot)
263                });
264            }
265        } else {
266            // Any non-quickcraft click resets an in-progress quickcraft.
267            if self.behavior().quickcraft().is_some() {
268                self.behavior_mut().reset_quick_craft();
269            }
270
271            // Menu-defined click hook. A consumed click skips default handling.
272            // The guard is dropped before the default arms re-lock the same containers.
273            let outcome = {
274                let mut guard = self.behavior().lock_all_containers();
275                let Self { behavior, kind, .. } = self;
276                kind.on_slot_clicked(behavior, &mut guard, click, player)
277            };
278
279            if outcome == ClickOutcome::Fallthrough {
280                match click {
281                    Click::Pickup { slot, button } => {
282                        self.behavior_mut().do_pickup(slot, button, player);
283                    }
284                    Click::DropCarried { button } => {
285                        self.behavior_mut().drop_carried(button, player);
286                    }
287                    Click::QuickMove { slot } => {
288                        self.do_quick_move(slot, player);
289                    }
290                    Click::Swap { slot, with } => {
291                        self.do_swap(slot, with, player);
292                    }
293                    Click::Clone { slot } => {
294                        self.behavior_mut().do_clone(slot, has_infinite_materials);
295                    }
296                    Click::Throw { slot, whole_stack } => {
297                        self.behavior_mut().do_throw(slot, whole_stack, player);
298                    }
299                    Click::PickupAll { slot, direction } => {
300                        self.do_pickup_all(slot, direction, player);
301                    }
302                    Click::QuickCraft(_) => unreachable!(),
303                }
304            }
305        }
306        // Recompute recipe-driven slots after the click. A QuickCraft drag has
307        // no slot on its end phase, so recompute on any non-empty menu.
308        let should_recompute = match click {
309            Click::DropCarried { .. } => false,
310            Click::QuickCraft(_) => !self.behavior().slots().is_empty(),
311            _ => true,
312        };
313        if should_recompute {
314            let mut guard = self.behavior().lock_all_containers();
315            self.slots_changed(&mut guard, player);
316        }
317    }
318
319    /// Handles quick move (shift-click).
320    fn do_quick_move(&mut self, slot_index: usize, player: &Player) {
321        let mut guard = self.behavior().lock_all_containers();
322
323        if !self.behavior().slots()[slot_index].may_pickup(&guard, player) {
324            return;
325        }
326
327        let initial_item = self.behavior().slots()[slot_index].get_item(&guard).clone();
328        if initial_item.is_empty() {
329            return;
330        }
331
332        // Loop while the slot still holds the same item type.
333        let mut result = self.quick_move_stack(&mut guard, slot_index, player);
334
335        while !result.is_empty() {
336            let current_item = self.behavior().slots()[slot_index].get_item(&guard).clone();
337            if !ItemStack::is_same_item(&current_item, &result) {
338                break;
339            }
340            result = self.quick_move_stack(&mut guard, slot_index, player);
341        }
342    }
343
344    /// Handles swap (number keys for a hotbar slot, or swap-hands for the
345    /// offhand).
346    fn do_swap(&mut self, slot_index: usize, with: SwapTarget, player: &Player) {
347        let player_inventory = ContainerRef::from(player.inventory.clone());
348        let player_inv_id = ContainerId::from_arc(&player.inventory);
349        let mut guard = self.behavior().lock_all_containers_with(player_inventory);
350
351        let behavior = self.behavior();
352        let target_slot = &behavior.slots()[slot_index];
353        let inventory_slot = with.inventory_slot();
354
355        let target_item = target_slot.get_item(&guard).clone();
356        let Some(inventory) = guard.get(player_inv_id) else {
357            unreachable!("the explicitly locked player inventory must be present");
358        };
359        let source_item = inventory.get_item(inventory_slot).clone();
360
361        if source_item.is_empty() && target_item.is_empty() {
362            return;
363        }
364
365        if source_item.is_empty() {
366            // Move target -> inventory.
367            if target_slot.may_pickup(&guard, player) {
368                let Some(inventory) = guard.get_mut(player_inv_id) else {
369                    unreachable!("the explicitly locked player inventory must be present");
370                };
371                inventory.set_item(inventory_slot, target_item.clone());
372                target_slot.set_by_player(&mut guard, ItemStack::empty(), &target_item);
373                if let Some(remainder) = target_slot.on_take(&mut guard, &target_item, player) {
374                    player.add_item_or_drop_with_guard(&mut guard, remainder);
375                }
376            }
377        } else if target_item.is_empty() {
378            // Move inventory -> target.
379            if target_slot.may_place(&source_item) {
380                let max_size = target_slot.get_max_stack_size_for_item(&guard, &source_item);
381                if source_item.count > max_size {
382                    let Some(inv) = guard.get_mut(player_inv_id) else {
383                        unreachable!("the explicitly locked player inventory must be present");
384                    };
385                    let to_place = inv.get_item_mut(inventory_slot).split(max_size);
386                    target_slot.set_by_player(&mut guard, to_place, &ItemStack::empty());
387                } else {
388                    let Some(inventory) = guard.get_mut(player_inv_id) else {
389                        unreachable!("the explicitly locked player inventory must be present");
390                    };
391                    inventory.set_item(inventory_slot, ItemStack::empty());
392                    target_slot.set_by_player(&mut guard, source_item, &ItemStack::empty());
393                }
394            }
395        } else {
396            // Swap target <-> inventory.
397            if target_slot.may_pickup(&guard, player) && target_slot.may_place(&source_item) {
398                let max_size = target_slot.get_max_stack_size_for_item(&guard, &source_item);
399                if source_item.count > max_size {
400                    // Source too big: place a partial stack, return target to inventory.
401                    let Some(inv) = guard.get_mut(player_inv_id) else {
402                        unreachable!("the explicitly locked player inventory must be present");
403                    };
404                    let to_place = inv.get_item_mut(inventory_slot).split(max_size);
405                    target_slot.set_by_player(&mut guard, to_place, &target_item);
406                    if let Some(remainder) = target_slot.on_take(&mut guard, &target_item, player) {
407                        player.add_item_or_drop_with_guard(&mut guard, remainder);
408                    }
409                    let mut displaced = target_item;
410                    let Some(inventory) = guard.get_mut(player_inv_id) else {
411                        unreachable!("the explicitly locked player inventory must be present");
412                    };
413                    let added = inventory.add(&mut displaced);
414                    // Vanilla's Inventory::add consumes uninserted stacks in creative mode.
415                    if !added && !player.has_infinite_materials() {
416                        let _ = guard.run_unlocked(|| player.drop_item(displaced, false, true));
417                    }
418                } else {
419                    let Some(inventory) = guard.get_mut(player_inv_id) else {
420                        unreachable!("the explicitly locked player inventory must be present");
421                    };
422                    inventory.set_item(inventory_slot, target_item.clone());
423                    target_slot.set_by_player(&mut guard, source_item, &target_item);
424                    if let Some(remainder) = target_slot.on_take(&mut guard, &target_item, player) {
425                        player.add_item_or_drop_with_guard(&mut guard, remainder);
426                    }
427                }
428            }
429        }
430    }
431
432    /// Handles pickup all (double-click): collects matching items from all slots
433    /// into the carried stack.
434    fn do_pickup_all(&mut self, slot_index: usize, direction: FillDirection, player: &Player) {
435        let mut guard = self.behavior().lock_all_containers();
436
437        let behavior = self.behavior();
438        let slot = &behavior.slots()[slot_index];
439        let slot_has_item = !slot.get_item(&guard).is_empty();
440        let slot_may_pickup = slot.may_pickup(&guard, player);
441
442        if behavior.carried().is_empty() || (slot_has_item && slot_may_pickup) {
443            return;
444        }
445
446        let max_stack = behavior.carried().max_stack_size();
447        let carried_item = behavior.carried().clone();
448        let slot_count = behavior.slots().len();
449
450        let (start, step): (i32, i32) = match direction {
451            FillDirection::Forward => (0, 1),
452            FillDirection::Backward => (slot_count as i32 - 1, -1),
453        };
454
455        // First pass collects non-full stacks, second pass the full ones.
456        for pass in 0..2 {
457            let mut i = start;
458            while i >= 0 && i < slot_count as i32 && self.behavior().carried().count < max_stack {
459                let target_slot = &self.behavior().slots()[i as usize];
460                let target_item = target_slot.get_item(&guard).clone();
461
462                if !target_item.is_empty()
463                    && can_item_quick_replace(&target_item, &carried_item, true)
464                    && target_slot.may_pickup(&guard, player)
465                    && self.can_take_item_for_pick_all(&carried_item, i as usize)
466                {
467                    // First pass skips full stacks, second pass includes them.
468                    if pass != 0 || target_item.count != target_item.max_stack_size() {
469                        let can_take = max_stack - self.behavior().carried().count;
470                        let to_take = target_item.count.min(can_take);
471                        let removed = target_slot.safe_take(&mut guard, to_take, can_take, player);
472                        self.behavior_mut()
473                            .carried_mut()
474                            .grow(removed.count.min(can_take));
475                    }
476                }
477
478                i += step;
479            }
480        }
481    }
482}
483
484#[cfg(test)]
485mod tests;