1use steel_protocol::packets::game::ClickType;
14use steel_registry::item_stack::ItemStack;
15
16use crate::inventory::menu::FillDirection;
17
18pub const SLOT_CLICKED_OUTSIDE: i16 = -999;
20
21const OFFHAND_INVENTORY_SLOT: usize = 40;
23
24#[derive(Clone, Copy, Debug, PartialEq, Eq)]
29pub enum Click {
30 Pickup {
32 slot: usize,
34 button: MouseButton,
36 },
37 DropCarried {
40 button: MouseButton,
42 },
43 QuickMove {
45 slot: usize,
47 },
48 Swap {
51 slot: usize,
53 with: SwapTarget,
55 },
56 Clone {
58 slot: usize,
60 },
61 Throw {
63 slot: usize,
65 whole_stack: bool,
68 },
69 PickupAll {
71 slot: usize,
73 direction: FillDirection,
75 },
76 QuickCraft(QuickCraft),
78}
79
80impl Click {
81 #[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 #[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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
122pub enum MouseButton {
123 Left,
125 Right,
127}
128
129#[derive(Clone, Copy, Debug, PartialEq, Eq)]
132pub enum ClickOutcome {
133 Consume,
136 Fallthrough,
138}
139
140#[derive(Clone, Copy, Debug, PartialEq, Eq)]
142pub enum SwapTarget {
143 Hotbar(u8),
145 Offhand,
147}
148
149impl SwapTarget {
150 #[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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
163pub enum QuickCraft {
164 Start {
166 kind: DragKind,
168 },
169 AddSlot {
171 slot: usize,
173 },
174 End,
176}
177
178#[derive(Clone, Copy, Debug, PartialEq, Eq)]
180pub enum DragKind {
181 Left,
183 Right,
185 Clone,
187}
188
189impl DragKind {
190 #[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#[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 #[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 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 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 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 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 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 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 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 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}