Skip to main content

steel_core/inventory/
click.rs

1//! Validated container-click input.
2//!
3//! [`SContainerClick`](steel_protocol::packets::game::SContainerClick) carries
4//! three raw fields (`slot_num: i16`, `button: i8`, [`ClickType`]) whose
5//! meanings depend on each other: `-999` means "outside the window", the swap
6//! button is a hotbar index or `40` for the offhand, and a drag click
7//! bit-packs its phase and kind into `button`. [`Click::parse`] decodes and
8//! validates all of that once at the packet boundary. Every slot index inside
9//! a [`Click`] is in range for the menu it was parsed against, so the click
10//! handlers in [`Menu`](crate::inventory::Menu) start at their actual logic
11//! instead of re-validating raw integers.
12
13use steel_protocol::packets::game::ClickType;
14use steel_registry::item_stack::ItemStack;
15
16use crate::inventory::menu::FillDirection;
17
18/// Raw slot value sent when the player clicks outside the window.
19pub const SLOT_CLICKED_OUTSIDE: i16 = -999;
20
21/// Player-inventory index of the offhand slot.
22const OFFHAND_INVENTORY_SLOT: usize = 40;
23
24/// A container click, validated from the raw protocol fields.
25///
26/// Produced by [`Click::parse`]; every `slot` is guaranteed in range for the
27/// menu the click was parsed against.
28#[derive(Clone, Copy, Debug, PartialEq, Eq)]
29pub enum Click {
30    /// Left/right click on a slot.
31    Pickup {
32        /// The clicked slot.
33        slot: usize,
34        /// Which mouse button was used.
35        button: MouseButton,
36    },
37    /// Left/right click outside the window, dropping the carried stack
38    /// (`slot_num == -999` on the wire).
39    DropCarried {
40        /// Left drops the whole stack, right drops a single item.
41        button: MouseButton,
42    },
43    /// Shift-click.
44    QuickMove {
45        /// The clicked slot.
46        slot: usize,
47    },
48    /// Number key 1-9 or the offhand key, swapping a menu slot with a player
49    /// inventory slot.
50    Swap {
51        /// The clicked menu slot.
52        slot: usize,
53        /// The player inventory slot to swap with.
54        with: SwapTarget,
55    },
56    /// Middle-click copy (creative only).
57    Clone {
58        /// The clicked slot.
59        slot: usize,
60    },
61    /// Drop key: Q (single item) or Ctrl+Q (whole stack).
62    Throw {
63        /// The clicked slot.
64        slot: usize,
65        /// True for Ctrl+Q (drop the whole stack, repeating while the slot
66        /// refills with the same item).
67        whole_stack: bool,
68    },
69    /// Double-click, collecting matching stacks into the cursor.
70    PickupAll {
71        /// The double-clicked slot.
72        slot: usize,
73        /// Which end of the menu to start collecting from.
74        direction: FillDirection,
75    },
76    /// One phase of a drag (paint) operation.
77    QuickCraft(QuickCraft),
78}
79
80impl Click {
81    /// The slot this click targets, or `None` for clicks outside the window
82    /// and drag phases.
83    #[must_use]
84    pub const fn slot(&self) -> Option<usize> {
85        match self {
86            Click::Pickup { slot, .. }
87            | Click::QuickMove { slot }
88            | Click::Swap { slot, .. }
89            | Click::Clone { slot }
90            | Click::Throw { slot, .. }
91            | Click::PickupAll { slot, .. } => Some(*slot),
92            Click::DropCarried { .. } | Click::QuickCraft(_) => None,
93        }
94    }
95
96    /// Returns whether every index encoded in this click satisfies the
97    /// invariants normally established by [`Click::parse`].
98    #[must_use]
99    pub const fn is_valid_for(&self, slot_count: usize) -> bool {
100        match self {
101            Click::Pickup { slot, .. }
102            | Click::QuickMove { slot }
103            | Click::Clone { slot }
104            | Click::Throw { slot, .. }
105            | Click::PickupAll { slot, .. }
106            | Click::QuickCraft(QuickCraft::AddSlot { slot }) => *slot < slot_count,
107            Click::Swap { slot, with } => {
108                *slot < slot_count
109                    && match with {
110                        SwapTarget::Hotbar(index) => *index < 9,
111                        SwapTarget::Offhand => true,
112                    }
113            }
114            Click::DropCarried { .. }
115            | Click::QuickCraft(QuickCraft::Start { .. } | QuickCraft::End) => true,
116        }
117    }
118}
119
120/// A mouse button, decoded from the raw `button` field.
121#[derive(Clone, Copy, Debug, PartialEq, Eq)]
122pub enum MouseButton {
123    /// The primary (left) button.
124    Left,
125    /// The secondary (right) button.
126    Right,
127}
128
129/// What a menu's [`on_slot_clicked`](crate::inventory::menu::MenuKind::on_slot_clicked)
130/// hook decided about a click.
131#[derive(Clone, Copy, Debug, PartialEq, Eq)]
132pub enum ClickOutcome {
133    /// The menu handled the click itself; skip the default pickup/swap/move
134    /// behavior. This is the "button" case — Bukkit's `event.setCancelled(true)`.
135    Consume,
136    /// The menu did not handle the click; run the default behavior.
137    Fallthrough,
138}
139
140/// The player-inventory slot a [`Click::Swap`] exchanges with.
141#[derive(Clone, Copy, Debug, PartialEq, Eq)]
142pub enum SwapTarget {
143    /// A hotbar slot (`0..=8`, the number keys).
144    Hotbar(u8),
145    /// The offhand slot (the swap-hands key, `40` on the wire).
146    Offhand,
147}
148
149impl SwapTarget {
150    /// The player inventory index this target maps to.
151    #[must_use]
152    pub const fn inventory_slot(self) -> usize {
153        match self {
154            Self::Hotbar(index) => index as usize,
155            Self::Offhand => OFFHAND_INVENTORY_SLOT,
156        }
157    }
158}
159
160/// One phase of a drag operation, decoded from the bit-packed `button` field
161/// (phase in bits 0-1, kind in bits 2-3).
162#[derive(Clone, Copy, Debug, PartialEq, Eq)]
163pub enum QuickCraft {
164    /// Begin a drag; requires the state machine to be idle.
165    Start {
166        /// Which kind of drag is starting.
167        kind: DragKind,
168    },
169    /// Add a slot to the active drag.
170    AddSlot {
171        /// The slot under the cursor.
172        slot: usize,
173    },
174    /// Finish the drag and distribute the carried items.
175    End,
176}
177
178/// The kind of drag being performed, named after the initiating button.
179#[derive(Clone, Copy, Debug, PartialEq, Eq)]
180pub enum DragKind {
181    /// Left-button drag: distribute the carried stack evenly.
182    Left,
183    /// Right-button drag: place one item per slot.
184    Right,
185    /// Middle-button drag (creative only): place a full stack per slot.
186    Clone,
187}
188
189impl DragKind {
190    /// How many items this drag places into a single slot when distributing
191    /// `carried` over `slot_count` slots.
192    #[must_use]
193    #[expect(
194        clippy::cast_precision_loss,
195        clippy::cast_possible_truncation,
196        reason = "stack counts are far below f32 precision limits; matches vanilla's int division"
197    )]
198    pub fn place_count(self, slot_count: usize, carried: &ItemStack) -> i32 {
199        match self {
200            Self::Left => (carried.count as f32 / slot_count as f32).floor() as i32,
201            Self::Right => 1,
202            Self::Clone => carried.max_stack_size(),
203        }
204    }
205}
206
207/// Checks if an item can be quick-placed into a slot.
208/// If `ignore_size` is true, doesn't check if the combined count would exceed max stack size.
209#[must_use]
210pub fn can_item_quick_replace(
211    slot_item: &ItemStack,
212    carried: &ItemStack,
213    ignore_size: bool,
214) -> bool {
215    let slot_is_empty = slot_item.is_empty();
216    if slot_is_empty {
217        return true;
218    }
219    if !ItemStack::is_same_item_same_components(carried, slot_item) {
220        return false;
221    }
222    let combined = slot_item.count + if ignore_size { 0 } else { carried.count };
223    combined <= carried.max_stack_size()
224}
225
226impl Click {
227    /// Parses the raw fields of a container-click packet against a menu with
228    /// `slot_count` slots.
229    ///
230    /// Returns `None` for malformed input — an out-of-range slot, an invalid
231    /// swap button, an unknown mouse button, or an invalid drag encoding.
232    /// Callers should ignore the click (vanilla's behavior for packets its
233    /// clients never send) but may still want to resync the client.
234    #[must_use]
235    pub fn parse(
236        slot_num: i16,
237        button: i8,
238        click_type: ClickType,
239        slot_count: usize,
240    ) -> Option<Self> {
241        if slot_num >= 0 && usize::try_from(slot_num).ok()? >= slot_count {
242            return None;
243        }
244
245        // In-range slot index, or None for -999/-1/garbage.
246        let slot = || usize::try_from(slot_num).ok().filter(|&i| i < slot_count);
247        let mouse_button = || match button {
248            0 => Some(MouseButton::Left),
249            1 => Some(MouseButton::Right),
250            _ => None,
251        };
252
253        match click_type {
254            ClickType::Pickup => {
255                let button = mouse_button()?;
256                if slot_num == SLOT_CLICKED_OUTSIDE {
257                    Some(Self::DropCarried { button })
258                } else {
259                    Some(Self::Pickup {
260                        slot: slot()?,
261                        button,
262                    })
263                }
264            }
265            ClickType::QuickMove => match button {
266                0 | 1 => Some(Self::QuickMove { slot: slot()? }),
267                _ => None,
268            },
269            ClickType::Swap => {
270                let with = match button {
271                    0..=8 => SwapTarget::Hotbar(button as u8),
272                    40 => SwapTarget::Offhand,
273                    _ => return None,
274                };
275                Some(Self::Swap {
276                    slot: slot()?,
277                    with,
278                })
279            }
280            ClickType::Clone => Some(Self::Clone { slot: slot()? }),
281            ClickType::Throw => Some(Self::Throw {
282                slot: slot()?,
283                whole_stack: match button {
284                    0 => false,
285                    1 => true,
286                    _ => return None,
287                },
288            }),
289            ClickType::PickupAll => Some(Self::PickupAll {
290                slot: slot()?,
291                direction: match button {
292                    0 => FillDirection::Forward,
293                    1 => FillDirection::Backward,
294                    _ => return None,
295                },
296            }),
297            ClickType::QuickCraft => {
298                // Phase is in bits 0-1 and kind in bits 2-3. Vanilla reads
299                // kind only for Start; AddSlot and End use the stored kind.
300                match button & 3 {
301                    0 => {
302                        let kind = match (button >> 2) & 3 {
303                            0 => DragKind::Left,
304                            1 => DragKind::Right,
305                            2 => DragKind::Clone,
306                            _ => return None,
307                        };
308                        Some(Self::QuickCraft(QuickCraft::Start { kind }))
309                    }
310                    1 => Some(Self::QuickCraft(QuickCraft::AddSlot { slot: slot()? })),
311                    2 => Some(Self::QuickCraft(QuickCraft::End)),
312                    _ => None,
313                }
314            }
315        }
316    }
317}
318
319#[cfg(test)]
320mod tests {
321    use super::*;
322
323    const SLOTS: usize = 46;
324
325    #[test]
326    fn pickup_slot_and_outside() {
327        assert_eq!(
328            Click::parse(5, 0, ClickType::Pickup, SLOTS),
329            Some(Click::Pickup {
330                slot: 5,
331                button: MouseButton::Left
332            })
333        );
334        assert_eq!(
335            Click::parse(SLOT_CLICKED_OUTSIDE, 1, ClickType::Pickup, SLOTS),
336            Some(Click::DropCarried {
337                button: MouseButton::Right
338            })
339        );
340        // Unknown mouse button.
341        assert_eq!(Click::parse(5, 2, ClickType::Pickup, SLOTS), None);
342    }
343
344    #[test]
345    fn out_of_range_slots_rejected() {
346        assert_eq!(Click::parse(-1, 0, ClickType::Pickup, SLOTS), None);
347        assert_eq!(Click::parse(46, 0, ClickType::QuickMove, SLOTS), None);
348        assert_eq!(Click::parse(-999, 0, ClickType::Throw, SLOTS), None);
349        // Last valid index is fine.
350        assert_eq!(
351            Click::parse(45, 0, ClickType::QuickMove, SLOTS),
352            Some(Click::QuickMove { slot: 45 })
353        );
354        assert_eq!(
355            Click::parse(45, 1, ClickType::QuickMove, SLOTS),
356            Some(Click::QuickMove { slot: 45 })
357        );
358        assert_eq!(Click::parse(45, 2, ClickType::QuickMove, SLOTS), None);
359    }
360
361    #[test]
362    fn swap_targets() {
363        assert_eq!(
364            Click::parse(3, 8, ClickType::Swap, SLOTS),
365            Some(Click::Swap {
366                slot: 3,
367                with: SwapTarget::Hotbar(8)
368            })
369        );
370        assert_eq!(
371            Click::parse(3, 40, ClickType::Swap, SLOTS),
372            Some(Click::Swap {
373                slot: 3,
374                with: SwapTarget::Offhand
375            })
376        );
377        assert_eq!(SwapTarget::Hotbar(4).inventory_slot(), 4);
378        assert_eq!(SwapTarget::Offhand.inventory_slot(), 40);
379        // 9 and negatives are not valid swap buttons.
380        assert_eq!(Click::parse(3, 9, ClickType::Swap, SLOTS), None);
381        assert_eq!(Click::parse(3, -1, ClickType::Swap, SLOTS), None);
382    }
383
384    #[test]
385    fn throw_and_pickup_all_buttons() {
386        assert_eq!(
387            Click::parse(7, 1, ClickType::Throw, SLOTS),
388            Some(Click::Throw {
389                slot: 7,
390                whole_stack: true
391            })
392        );
393        assert_eq!(Click::parse(7, 2, ClickType::Throw, SLOTS), None);
394        assert_eq!(
395            Click::parse(7, 0, ClickType::PickupAll, SLOTS),
396            Some(Click::PickupAll {
397                slot: 7,
398                direction: FillDirection::Forward
399            })
400        );
401        assert_eq!(
402            Click::parse(7, 1, ClickType::PickupAll, SLOTS),
403            Some(Click::PickupAll {
404                slot: 7,
405                direction: FillDirection::Backward
406            })
407        );
408    }
409
410    #[test]
411    fn quickcraft_encoding() {
412        // Phase in the low bits, kind in bits 2-3.
413        assert_eq!(
414            Click::parse(-999, 0, ClickType::QuickCraft, SLOTS),
415            Some(Click::QuickCraft(QuickCraft::Start {
416                kind: DragKind::Left
417            }))
418        );
419        assert_eq!(
420            Click::parse(10, (1 << 2) | 1, ClickType::QuickCraft, SLOTS),
421            Some(Click::QuickCraft(QuickCraft::AddSlot { slot: 10 }))
422        );
423        assert_eq!(
424            Click::parse(-999, (2 << 2) | 2, ClickType::QuickCraft, SLOTS),
425            Some(Click::QuickCraft(QuickCraft::End))
426        );
427        // Kind is validated only for Start. AddSlot and End ignore those bits.
428        assert_eq!(
429            Click::parse(-999, 3 << 2, ClickType::QuickCraft, SLOTS),
430            None
431        );
432        assert_eq!(
433            Click::parse(10, (3 << 2) | 1, ClickType::QuickCraft, SLOTS),
434            Some(Click::QuickCraft(QuickCraft::AddSlot { slot: 10 }))
435        );
436        assert_eq!(
437            Click::parse(-999, (3 << 2) | 2, ClickType::QuickCraft, SLOTS),
438            Some(Click::QuickCraft(QuickCraft::End))
439        );
440
441        // Phase 3 is invalid. Every nonnegative packet slot must be in range,
442        // including the otherwise slotless Start and End phases.
443        assert_eq!(Click::parse(-999, 3, ClickType::QuickCraft, SLOTS), None);
444        assert_eq!(
445            Click::parse(100, (1 << 2) | 1, ClickType::QuickCraft, SLOTS),
446            None
447        );
448        assert_eq!(Click::parse(100, 0, ClickType::QuickCraft, SLOTS), None);
449        assert_eq!(Click::parse(100, 2, ClickType::QuickCraft, SLOTS), None);
450    }
451
452    #[test]
453    fn programmatic_click_validation_covers_every_public_index() {
454        assert!(
455            !Click::Pickup {
456                slot: SLOTS,
457                button: MouseButton::Left,
458            }
459            .is_valid_for(SLOTS)
460        );
461        assert!(!Click::QuickCraft(QuickCraft::AddSlot { slot: SLOTS }).is_valid_for(SLOTS));
462        assert!(
463            !Click::Swap {
464                slot: 0,
465                with: SwapTarget::Hotbar(9),
466            }
467            .is_valid_for(SLOTS)
468        );
469
470        assert!(
471            Click::DropCarried {
472                button: MouseButton::Right,
473            }
474            .is_valid_for(SLOTS)
475        );
476        assert!(
477            Click::Swap {
478                slot: SLOTS - 1,
479                with: SwapTarget::Offhand,
480            }
481            .is_valid_for(SLOTS)
482        );
483    }
484}