From 64ffef3d853abd882b306051994663b0d0fc4278 Mon Sep 17 00:00:00 2001 From: Purdze Date: Wed, 1 Jul 2026 18:46:02 +0100 Subject: [PATCH 1/9] Add cursor-carried item to creative inventory clicks --- pomme-client/src/app/phases/in_game.rs | 8 +-- pomme-client/src/ui/common.rs | 38 ++++++---- pomme-client/src/ui/creative_inventory.rs | 86 ++++++++++++++++++----- 3 files changed, 98 insertions(+), 34 deletions(-) diff --git a/pomme-client/src/app/phases/in_game.rs b/pomme-client/src/app/phases/in_game.rs index 11a70c04..3c65744a 100644 --- a/pomme-client/src/app/phases/in_game.rs +++ b/pomme-client/src/app/phases/in_game.rs @@ -1124,7 +1124,6 @@ pub fn update_game( let scroll_delta = core.input.consume_menu_scroll(); let typed = core.input.drain_typed_chars(); let backspace = core.input.backspace_pressed(); - let selected_hotbar = core.input.selected_slot(); let action = crate::ui::creative_inventory::build_creative_inventory( &mut elements, &mut game.creative_state, @@ -1136,7 +1135,6 @@ pub fn update_game( &typed, backspace, &game.player.inventory, - selected_hotbar, gs, game.advanced_item_tooltips, core.input.left_held(), @@ -1146,7 +1144,7 @@ pub fn update_game( crate::ui::creative_inventory::CreativeAction::Close => { close_inventory = true; } - crate::ui::creative_inventory::CreativeAction::Place(item, slot_num) => { + crate::ui::creative_inventory::CreativeAction::SetSlot(slot_num, item) => { use azalea_protocol::packets::game::s_set_creative_mode_slot::ServerboundSetCreativeModeSlot; if game.player.game_mode == 1 { connection @@ -1154,9 +1152,11 @@ pub fn update_game( .send(ServerboundGamePacket::SetCreativeModeSlot( ServerboundSetCreativeModeSlot { slot_num, - item_stack: item, + item_stack: item.clone(), }, )); + // Optimistic local update; the server echoes via ContainerSetSlot. + game.player.inventory.set_slot(slot_num as usize, item); } } crate::ui::creative_inventory::CreativeAction::None => {} diff --git a/pomme-client/src/ui/common.rs b/pomme-client/src/ui/common.rs index 1bea5dd0..a4489b79 100644 --- a/pomme-client/src/ui/common.rs +++ b/pomme-client/src/ui/common.rs @@ -1,4 +1,4 @@ -use azalea_inventory::ItemStack; +use azalea_inventory::{ItemStack, ItemStackData}; use crate::benchmark::UploadStatus; use crate::player::inventory::item_resource_name; @@ -243,19 +243,7 @@ pub fn push_slot( }); } } - ItemStack::Present(data) => { - elements.push(MenuElement::ItemIcon { - x, - y, - w: size, - h: size, - item_name: item_resource_name(data.kind), - tint: WHITE, - }); - if data.count > 1 { - push_item_count(elements, x, y, size, scale, data.count); - } - } + ItemStack::Present(data) => push_item_icon(elements, x, y, size, scale, data), } if hovered { elements.push(highlight(SpriteId::SlotHighlightFront)); @@ -263,6 +251,28 @@ pub fn push_slot( hovered } +/// Draws an item icon (and its stack count when > 1) at the given position. +pub fn push_item_icon( + elements: &mut Vec, + x: f32, + y: f32, + size: f32, + scale: f32, + data: &ItemStackData, +) { + elements.push(MenuElement::ItemIcon { + x, + y, + w: size, + h: size, + item_name: item_resource_name(data.kind), + tint: WHITE, + }); + if data.count > 1 { + push_item_count(elements, x, y, size, scale, data.count); + } +} + #[allow(clippy::too_many_arguments)] pub fn push_button( elements: &mut Vec, diff --git a/pomme-client/src/ui/creative_inventory.rs b/pomme-client/src/ui/creative_inventory.rs index e967c03f..77dc968f 100644 --- a/pomme-client/src/ui/creative_inventory.rs +++ b/pomme-client/src/ui/creative_inventory.rs @@ -251,6 +251,8 @@ pub struct CreativeState { pub tab: CreativeTab, pub scroll: f32, pub search: String, + /// Client-side carried stack (the item riding the cursor). Creative-only. + pub cursor_item: ItemStack, cursor_blink: Instant, scroll_dragging: bool, } @@ -261,6 +263,7 @@ impl CreativeState { tab: CreativeTab::BuildingBlocks, scroll: 0.0, search: String::new(), + cursor_item: ItemStack::Empty, cursor_blink: Instant::now(), scroll_dragging: false, } @@ -280,7 +283,8 @@ impl Default for CreativeState { pub enum CreativeAction { None, Close, - Place(ItemStack, u16), + /// Set a real player-inventory slot to the given stack (creative set-slot). + SetSlot(u16, ItemStack), } #[allow(clippy::too_many_arguments)] @@ -295,7 +299,6 @@ pub fn build_creative_inventory( typed_chars: &[char], backspace: bool, inventory: &Inventory, - selected_hotbar: u8, gs: f32, advanced_tooltips: bool, mouse_held: bool, @@ -368,7 +371,11 @@ pub fn build_creative_inventory( if state.tab.is_inventory_tab() { if let Some(slot) = draw_inventory_layout(elements, ox, oy, scale, inventory, &tt) { - action = CreativeAction::Place(ItemStack::Empty, slot); + if slot == SLOT_TRASH { + state.cursor_item = ItemStack::Empty; + } else { + action = apply_slot_click(state, inventory, slot); + } } } else { let items = visible_items(state); @@ -423,12 +430,13 @@ pub fn build_creative_inventory( let hovered = push_slot(elements, slot_x, slot_y, size, scale, cursor, &item, None); if hovered { push_item_tooltip(elements, &item, &tt); - if grid_clicked - && scrollable - && let ItemStack::Present(data) = item - { - let slot_num = 36 + selected_hotbar as u16; - action = CreativeAction::Place(ItemStack::Present(data), slot_num); + if grid_clicked { + if matches!(state.cursor_item, ItemStack::Present(_)) { + // Carrying an item: clicking the creative list discards it. + state.cursor_item = ItemStack::Empty; + } else if matches!(item, ItemStack::Present(_)) { + state.cursor_item = item.clone(); + } } } } @@ -437,7 +445,7 @@ pub fn build_creative_inventory( if let Some(slot) = draw_player_hotbar(elements, ox, oy, scale, inventory, &tt) && matches!(action, CreativeAction::None) { - action = CreativeAction::Place(ItemStack::Empty, slot); + action = apply_slot_click(state, inventory, slot); } if scrollable { @@ -449,7 +457,25 @@ pub fn build_creative_inventory( let outside = !hit_test(cursor, [ox, oy, inv_w, inv_h]); if clicked && outside && tab_hit.is_none() && matches!(action, CreativeAction::None) { - action = CreativeAction::Close; + if matches!(state.cursor_item, ItemStack::Present(_)) { + // TODO: drop the carried item into the world; for now just discard it. + state.cursor_item = ItemStack::Empty; + } else { + action = CreativeAction::Close; + } + } + + // Draw the carried item on top of everything, centred on the cursor. + if let ItemStack::Present(data) = &state.cursor_item { + let size = SLOT_SIZE * scale; + common::push_item_icon( + elements, + cursor.0 - size / 2.0, + cursor.1 - size / 2.0, + size, + scale, + data, + ); } action @@ -794,7 +820,8 @@ fn push_tab_tooltip( } } -/// Returns the slot number when a present item is clicked. +/// Returns the slot number when the slot is clicked (empty or not), so a +/// carried item can be placed into an empty slot. #[allow(clippy::too_many_arguments)] fn slot_with_tooltip( elements: &mut Vec, @@ -811,7 +838,6 @@ fn slot_with_tooltip( if hovered { push_item_tooltip(elements, item, tt); if tt.clicked - && matches!(item, ItemStack::Present(_)) && let Some(slot) = slot_num { return Some(slot); @@ -820,11 +846,38 @@ fn slot_with_tooltip( None } +/// Left-click on a real player-inventory slot: pick up, place, or swap the +/// carried item, updating the cursor stack and emitting the set-slot action. +fn apply_slot_click( + state: &mut CreativeState, + inventory: &Inventory, + slot_num: u16, +) -> CreativeAction { + let slot_item = inventory.slot(slot_num as usize).clone(); + match std::mem::replace(&mut state.cursor_item, ItemStack::Empty) { + ItemStack::Empty => { + if matches!(slot_item, ItemStack::Present(_)) { + state.cursor_item = slot_item; + CreativeAction::SetSlot(slot_num, ItemStack::Empty) + } else { + CreativeAction::None + } + } + // TODO: merge stacks when the carried and slot items are the same. + carried => { + state.cursor_item = slot_item; + CreativeAction::SetSlot(slot_num, carried) + } + } +} + // Vanilla `PlayerInventory` slot indices. const SLOT_ARMOR_BASE: u16 = 5; const SLOT_MAIN_BASE: u16 = 9; const SLOT_HOTBAR_BASE: u16 = 36; const SLOT_OFFHAND: u16 = 45; +/// Sentinel for the inventory-tab trash slot (not a real inventory index). +const SLOT_TRASH: u16 = u16::MAX; fn draw_player_hotbar( elements: &mut Vec, @@ -939,16 +992,17 @@ fn draw_inventory_layout( clicked_slot = clicked_slot.or(draw_player_hotbar(elements, ox, oy, scale, inventory, tt)); let (trash_x, trash_y) = slot_xy(ox, oy, scale, INV_TRASH_X, INV_TRASH_Y); - push_slot( + clicked_slot = clicked_slot.or(slot_with_tooltip( elements, trash_x, trash_y, size, scale, - tt.cursor, &ItemStack::Empty, None, - ); + tt, + Some(SLOT_TRASH), + )); clicked_slot } From 26b95489cdfd7363c542bc97e7592d3c53681cd4 Mon Sep 17 00:00:00 2001 From: Purdze Date: Wed, 1 Jul 2026 18:48:31 +0100 Subject: [PATCH 2/9] Hide creative hover tooltip while carrying an item --- pomme-client/src/ui/creative_inventory.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/pomme-client/src/ui/creative_inventory.rs b/pomme-client/src/ui/creative_inventory.rs index 77dc968f..faaa7730 100644 --- a/pomme-client/src/ui/creative_inventory.rs +++ b/pomme-client/src/ui/creative_inventory.rs @@ -367,6 +367,7 @@ pub fn build_creative_inventory( gs, advanced: advanced_tooltips, clicked, + carrying: matches!(state.cursor_item, ItemStack::Present(_)), }; if state.tab.is_inventory_tab() { @@ -581,6 +582,7 @@ struct TooltipCtx { gs: f32, advanced: bool, clicked: bool, + carrying: bool, } const fn rgb(hex: u32) -> [f32; 4] { @@ -776,6 +778,9 @@ fn build_item_tooltip_lines(data: &ItemStackData, advanced: bool) -> Vec, item: &ItemStack, tt: &TooltipCtx) { + if tt.carrying { + return; + } if let ItemStack::Present(data) = item { elements.push(MenuElement::TooltipLines { x: tt.cursor.0, @@ -795,6 +800,9 @@ fn push_tab_tooltip( scale: f32, tt: &TooltipCtx, ) { + if tt.carrying { + return; + } let inset_w = 21.0 * scale; let inset_h = 27.0 * scale; for &tab in TABS.iter() { From be0043e3ce80241b17632798dfb089374d1022ca Mon Sep 17 00:00:00 2001 From: Purdze Date: Wed, 1 Jul 2026 18:55:24 +0100 Subject: [PATCH 3/9] Grow carried creative stack on repeated same-item click --- pomme-client/src/ui/creative_inventory.rs | 31 ++++++++++++++++++----- 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/pomme-client/src/ui/creative_inventory.rs b/pomme-client/src/ui/creative_inventory.rs index faaa7730..4ac3afe8 100644 --- a/pomme-client/src/ui/creative_inventory.rs +++ b/pomme-client/src/ui/creative_inventory.rs @@ -2,7 +2,7 @@ use std::collections::HashMap; use std::sync::OnceLock; use std::time::Instant; -use azalea_inventory::components::{Damage, Enchantments, MaxDamage, Rarity}; +use azalea_inventory::components::{Damage, Enchantments, MaxDamage, MaxStackSize, Rarity}; use azalea_inventory::default_components::get_default_component; use azalea_inventory::{ItemStack, ItemStackData}; use azalea_registry::builtin::{DataComponentKind, ItemKind}; @@ -432,11 +432,24 @@ pub fn build_creative_inventory( if hovered { push_item_tooltip(elements, &item, &tt); if grid_clicked { - if matches!(state.cursor_item, ItemStack::Present(_)) { - // Carrying an item: clicking the creative list discards it. - state.cursor_item = ItemStack::Empty; - } else if matches!(item, ItemStack::Present(_)) { - state.cursor_item = item.clone(); + match std::mem::replace(&mut state.cursor_item, ItemStack::Empty) { + // Same item: grow the carried stack up to its max and keep it. + // A different item or an empty cell leaves the cursor empty (discard). + ItemStack::Present(mut carried) => { + if let ItemStack::Present(clicked) = &item + && carried.is_same_item_and_components(clicked) + { + if carried.count < max_stack_size(carried.kind) { + carried.count += 1; + } + state.cursor_item = ItemStack::Present(carried); + } + } + ItemStack::Empty => { + if let ItemStack::Present(data) = &item { + state.cursor_item = ItemStack::Present(data.clone()); + } + } } } } @@ -602,6 +615,12 @@ const RARITY_UNCOMMON: [f32; 4] = rgb(0xFFFF55); const RARITY_RARE: [f32; 4] = rgb(0x55FFFF); const RARITY_EPIC: [f32; 4] = rgb(0xFF55FF); +fn max_stack_size(kind: ItemKind) -> i32 { + get_default_component::(kind) + .map(|m| m.count) + .unwrap_or(64) +} + fn rarity_color(kind: ItemKind) -> [f32; 4] { match get_default_component::(kind) { Some(Rarity::Uncommon) => RARITY_UNCOMMON, From ca7f19d98e95e063eda72ad334b556d0b3a6c669 Mon Sep 17 00:00:00 2001 From: Purdze Date: Wed, 1 Jul 2026 18:57:25 +0100 Subject: [PATCH 4/9] Use arrow cursor for in-game screens --- pomme-client/src/app/core.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/pomme-client/src/app/core.rs b/pomme-client/src/app/core.rs index 370c2e8f..db862ab6 100644 --- a/pomme-client/src/app/core.rs +++ b/pomme-client/src/app/core.rs @@ -229,6 +229,7 @@ impl AppCore { } pub fn apply_cursor_grab(&self, window: &Window, game: Option<&mut GameState>) { + let in_game = game.is_some(); let captured = game.is_some_and(|g| g.input_live() && !g.dead && self.input.is_cursor_captured()); if captured { @@ -239,6 +240,11 @@ impl AppCore { } else { let _ = window.set_cursor_grab(CursorGrabMode::None); window.set_cursor_visible(true); + if in_game { + // In-game screens (inventory, chat, pause) use the plain arrow like + // vanilla, not the pointer the branded menu may have left set. + window.set_cursor(winit::window::CursorIcon::Default); + } } } From 221b47148e87e57ff1e91a1405294e05cfb4f9e0 Mon Sep 17 00:00:00 2001 From: Purdze Date: Wed, 1 Jul 2026 19:01:33 +0100 Subject: [PATCH 5/9] Middle-click to clone a full stack in creative --- pomme-client/src/app/input.rs | 16 ++++++++++++++++ pomme-client/src/app/phases/in_game.rs | 2 ++ pomme-client/src/ui/creative_inventory.rs | 10 +++++++++- 3 files changed, 27 insertions(+), 1 deletion(-) diff --git a/pomme-client/src/app/input.rs b/pomme-client/src/app/input.rs index e911512e..867ed1b5 100644 --- a/pomme-client/src/app/input.rs +++ b/pomme-client/src/app/input.rs @@ -40,6 +40,7 @@ pub struct InputState { selected_slot: u8, left_click: ClickState, right_click: ClickState, + middle_click: ClickState, cursor_pos: (f32, f32), cursor_moved: bool, typed_chars: Vec, @@ -115,6 +116,7 @@ impl InputState { selected_slot: 0, left_click: ClickState::default(), right_click: ClickState::default(), + middle_click: ClickState::default(), cursor_pos: (0.0, 0.0), cursor_moved: false, typed_chars: Vec::new(), @@ -386,6 +388,8 @@ impl InputState { self.left_click.just_released = false; self.right_click.just_pressed = false; self.right_click.just_released = false; + self.middle_click.just_pressed = false; + self.middle_click.just_released = false; self.cursor_moved = false; } @@ -622,6 +626,14 @@ impl InputState { self.recent_actions.insert(Action::Use, false); } } + MouseButton::Middle => { + self.middle_click.held = was_pressed; + if was_pressed { + self.middle_click.just_pressed = true; + } else { + self.middle_click.just_released = true; + } + } _ => (), } } @@ -638,6 +650,10 @@ impl InputState { self.right_click.held } + pub fn middle_just_pressed(&self) -> bool { + self.middle_click.just_pressed + } + pub fn on_cursor_moved(&mut self, x: f32, y: f32) { self.cursor_pos = (x, y); self.cursor_moved = true; diff --git a/pomme-client/src/app/phases/in_game.rs b/pomme-client/src/app/phases/in_game.rs index 3c65744a..9b1890be 100644 --- a/pomme-client/src/app/phases/in_game.rs +++ b/pomme-client/src/app/phases/in_game.rs @@ -1121,6 +1121,7 @@ pub fn update_game( if game.creative_inventory_open { let cursor = core.input.cursor_pos(); let clicked = core.input.left_just_pressed(); + let middle_clicked = core.input.middle_just_pressed(); let scroll_delta = core.input.consume_menu_scroll(); let typed = core.input.drain_typed_chars(); let backspace = core.input.backspace_pressed(); @@ -1131,6 +1132,7 @@ pub fn update_game( sh, cursor, clicked, + middle_clicked, scroll_delta, &typed, backspace, diff --git a/pomme-client/src/ui/creative_inventory.rs b/pomme-client/src/ui/creative_inventory.rs index 4ac3afe8..8ccb9edf 100644 --- a/pomme-client/src/ui/creative_inventory.rs +++ b/pomme-client/src/ui/creative_inventory.rs @@ -295,6 +295,7 @@ pub fn build_creative_inventory( screen_h: f32, cursor: (f32, f32), clicked: bool, + middle_clicked: bool, scroll_delta: f32, typed_chars: &[char], backspace: bool, @@ -431,7 +432,14 @@ pub fn build_creative_inventory( let hovered = push_slot(elements, slot_x, slot_y, size, scale, cursor, &item, None); if hovered { push_item_tooltip(elements, &item, &tt); - if grid_clicked { + if middle_clicked + && matches!(state.cursor_item, ItemStack::Empty) + && let ItemStack::Present(data) = &item + { + let mut cloned = data.clone(); + cloned.count = max_stack_size(cloned.kind); + state.cursor_item = ItemStack::Present(cloned); + } else if grid_clicked { match std::mem::replace(&mut state.cursor_item, ItemStack::Empty) { // Same item: grow the carried stack up to its max and keep it. // A different item or an empty cell leaves the cursor empty (discard). From 4158b6d550acbe041f28dcce8d2e000c9dda0164 Mon Sep 17 00:00:00 2001 From: Purdze Date: Wed, 1 Jul 2026 19:13:40 +0100 Subject: [PATCH 6/9] Right-click place-one/take-half in creative inventory --- pomme-client/src/app/input.rs | 4 + pomme-client/src/app/phases/in_game.rs | 2 + pomme-client/src/ui/creative_inventory.rs | 120 +++++++++++++++++++--- 3 files changed, 109 insertions(+), 17 deletions(-) diff --git a/pomme-client/src/app/input.rs b/pomme-client/src/app/input.rs index 867ed1b5..d55ebc55 100644 --- a/pomme-client/src/app/input.rs +++ b/pomme-client/src/app/input.rs @@ -654,6 +654,10 @@ impl InputState { self.middle_click.just_pressed } + pub fn right_just_pressed(&self) -> bool { + self.right_click.just_pressed + } + pub fn on_cursor_moved(&mut self, x: f32, y: f32) { self.cursor_pos = (x, y); self.cursor_moved = true; diff --git a/pomme-client/src/app/phases/in_game.rs b/pomme-client/src/app/phases/in_game.rs index 9b1890be..57047b65 100644 --- a/pomme-client/src/app/phases/in_game.rs +++ b/pomme-client/src/app/phases/in_game.rs @@ -1122,6 +1122,7 @@ pub fn update_game( let cursor = core.input.cursor_pos(); let clicked = core.input.left_just_pressed(); let middle_clicked = core.input.middle_just_pressed(); + let right_clicked = core.input.right_just_pressed(); let scroll_delta = core.input.consume_menu_scroll(); let typed = core.input.drain_typed_chars(); let backspace = core.input.backspace_pressed(); @@ -1133,6 +1134,7 @@ pub fn update_game( cursor, clicked, middle_clicked, + right_clicked, scroll_delta, &typed, backspace, diff --git a/pomme-client/src/ui/creative_inventory.rs b/pomme-client/src/ui/creative_inventory.rs index 8ccb9edf..29fde140 100644 --- a/pomme-client/src/ui/creative_inventory.rs +++ b/pomme-client/src/ui/creative_inventory.rs @@ -296,6 +296,7 @@ pub fn build_creative_inventory( cursor: (f32, f32), clicked: bool, middle_clicked: bool, + right_clicked: bool, scroll_delta: f32, typed_chars: &[char], backspace: bool, @@ -368,15 +369,16 @@ pub fn build_creative_inventory( gs, advanced: advanced_tooltips, clicked, + right_clicked, carrying: matches!(state.cursor_item, ItemStack::Present(_)), }; if state.tab.is_inventory_tab() { - if let Some(slot) = draw_inventory_layout(elements, ox, oy, scale, inventory, &tt) { + if let Some((slot, kind)) = draw_inventory_layout(elements, ox, oy, scale, inventory, &tt) { if slot == SLOT_TRASH { state.cursor_item = ItemStack::Empty; } else { - action = apply_slot_click(state, inventory, slot); + action = apply_slot_action(state, inventory, slot, kind); } } } else { @@ -439,14 +441,21 @@ pub fn build_creative_inventory( let mut cloned = data.clone(); cloned.count = max_stack_size(cloned.kind); state.cursor_item = ItemStack::Present(cloned); - } else if grid_clicked { + } else if grid_clicked || right_clicked { match std::mem::replace(&mut state.cursor_item, ItemStack::Empty) { - // Same item: grow the carried stack up to its max and keep it. - // A different item or an empty cell leaves the cursor empty (discard). ItemStack::Present(mut carried) => { - if let ItemStack::Present(clicked) = &item + if right_clicked { + // Drop one from the carried stack. + carried.count -= 1; + if carried.count > 0 { + state.cursor_item = ItemStack::Present(carried); + } + } else if let ItemStack::Present(clicked) = &item && carried.is_same_item_and_components(clicked) { + // Left-click the same item: grow the carried stack up to its + // max. A different item or empty cell discards it (stays + // empty). if carried.count < max_stack_size(carried.kind) { carried.count += 1; } @@ -464,10 +473,10 @@ pub fn build_creative_inventory( } } - if let Some(slot) = draw_player_hotbar(elements, ox, oy, scale, inventory, &tt) + if let Some((slot, kind)) = draw_player_hotbar(elements, ox, oy, scale, inventory, &tt) && matches!(action, CreativeAction::None) { - action = apply_slot_click(state, inventory, slot); + action = apply_slot_action(state, inventory, slot, kind); } if scrollable { @@ -603,9 +612,16 @@ struct TooltipCtx { gs: f32, advanced: bool, clicked: bool, + right_clicked: bool, carrying: bool, } +#[derive(Clone, Copy)] +enum ClickKind { + Left, + Right, +} + const fn rgb(hex: u32) -> [f32; 4] { [ ((hex >> 16) & 0xff) as f32 / 255.0, @@ -855,8 +871,8 @@ fn push_tab_tooltip( } } -/// Returns the slot number when the slot is clicked (empty or not), so a -/// carried item can be placed into an empty slot. +/// Returns the clicked slot and which button, whether the slot is empty or not, +/// so a carried item can be placed into an empty slot. #[allow(clippy::too_many_arguments)] fn slot_with_tooltip( elements: &mut Vec, @@ -868,14 +884,17 @@ fn slot_with_tooltip( empty_sprite: Option, tt: &TooltipCtx, slot_num: Option, -) -> Option { +) -> Option<(u16, ClickKind)> { let hovered = push_slot(elements, x, y, size, scale, tt.cursor, item, empty_sprite); if hovered { push_item_tooltip(elements, item, tt); - if tt.clicked - && let Some(slot) = slot_num - { - return Some(slot); + if let Some(slot) = slot_num { + if tt.clicked { + return Some((slot, ClickKind::Left)); + } + if tt.right_clicked { + return Some((slot, ClickKind::Right)); + } } } None @@ -906,6 +925,73 @@ fn apply_slot_click( } } +/// Right-click on a real player-inventory slot: take half with an empty cursor, +/// else place one (or swap on a different item). +fn apply_slot_right_click( + state: &mut CreativeState, + inventory: &Inventory, + slot_num: u16, +) -> CreativeAction { + let slot_item = inventory.slot(slot_num as usize).clone(); + match std::mem::replace(&mut state.cursor_item, ItemStack::Empty) { + ItemStack::Empty => { + let ItemStack::Present(mut data) = slot_item else { + return CreativeAction::None; + }; + let take = (data.count + 1) / 2; + data.count -= take; + let mut taken = data.clone(); + taken.count = take; + state.cursor_item = ItemStack::Present(taken); + let remainder = if data.count > 0 { + ItemStack::Present(data) + } else { + ItemStack::Empty + }; + CreativeAction::SetSlot(slot_num, remainder) + } + ItemStack::Present(mut carried) => { + let placed = match slot_item { + ItemStack::Present(mut existing) => { + if !carried.is_same_item_and_components(&existing) { + // Different item: swap the whole stacks. + state.cursor_item = ItemStack::Present(existing); + return CreativeAction::SetSlot(slot_num, ItemStack::Present(carried)); + } + if existing.count >= max_stack_size(existing.kind) { + state.cursor_item = ItemStack::Present(carried); + return CreativeAction::None; + } + existing.count += 1; + existing + } + ItemStack::Empty => { + let mut one = carried.clone(); + one.count = 1; + one + } + }; + carried.count -= 1; + if carried.count > 0 { + state.cursor_item = ItemStack::Present(carried); + } + CreativeAction::SetSlot(slot_num, ItemStack::Present(placed)) + } + } +} + +fn apply_slot_action( + state: &mut CreativeState, + inventory: &Inventory, + slot_num: u16, + kind: ClickKind, +) -> CreativeAction { + match kind { + ClickKind::Left => apply_slot_click(state, inventory, slot_num), + ClickKind::Right => apply_slot_right_click(state, inventory, slot_num), + } +} + // Vanilla `PlayerInventory` slot indices. const SLOT_ARMOR_BASE: u16 = 5; const SLOT_MAIN_BASE: u16 = 9; @@ -921,7 +1007,7 @@ fn draw_player_hotbar( scale: f32, inventory: &Inventory, tt: &TooltipCtx, -) -> Option { +) -> Option<(u16, ClickKind)> { let size = SLOT_SIZE * scale; let hotbar = inventory.hotbar_slots(); let mut clicked_slot = None; @@ -956,7 +1042,7 @@ fn draw_inventory_layout( scale: f32, inventory: &Inventory, tt: &TooltipCtx, -) -> Option { +) -> Option<(u16, ClickKind)> { let size = SLOT_SIZE * scale; let mut clicked_slot = None; From e89929f36a5891a7437976ac61e5ec029ef95538 Mon Sep 17 00:00:00 2001 From: Purdze Date: Wed, 1 Jul 2026 19:33:12 +0100 Subject: [PATCH 7/9] Click-drag distribute across creative slots with live preview --- pomme-client/src/app/phases/in_game.rs | 33 ++- pomme-client/src/ui/creative_inventory.rs | 299 ++++++++++++++-------- 2 files changed, 214 insertions(+), 118 deletions(-) diff --git a/pomme-client/src/app/phases/in_game.rs b/pomme-client/src/app/phases/in_game.rs index 57047b65..2815a546 100644 --- a/pomme-client/src/app/phases/in_game.rs +++ b/pomme-client/src/app/phases/in_game.rs @@ -1142,25 +1142,34 @@ pub fn update_game( gs, game.advanced_item_tooltips, core.input.left_held(), + core.input.right_held(), &|t, s| gfx.renderer.menu_text_width(t, s), ); + use azalea_protocol::packets::game::s_set_creative_mode_slot::ServerboundSetCreativeModeSlot; + let mut set_creative_slot = |slot_num: u16, item: azalea_inventory::ItemStack| { + if game.player.game_mode == 1 { + connection + .packet_tx + .send(ServerboundGamePacket::SetCreativeModeSlot( + ServerboundSetCreativeModeSlot { + slot_num, + item_stack: item.clone(), + }, + )); + // Optimistic local update; the server echoes via ContainerSetSlot. + game.player.inventory.set_slot(slot_num as usize, item); + } + }; match action { crate::ui::creative_inventory::CreativeAction::Close => { close_inventory = true; } crate::ui::creative_inventory::CreativeAction::SetSlot(slot_num, item) => { - use azalea_protocol::packets::game::s_set_creative_mode_slot::ServerboundSetCreativeModeSlot; - if game.player.game_mode == 1 { - connection - .packet_tx - .send(ServerboundGamePacket::SetCreativeModeSlot( - ServerboundSetCreativeModeSlot { - slot_num, - item_stack: item.clone(), - }, - )); - // Optimistic local update; the server echoes via ContainerSetSlot. - game.player.inventory.set_slot(slot_num as usize, item); + set_creative_slot(slot_num, item); + } + crate::ui::creative_inventory::CreativeAction::SetSlots(items) => { + for (slot_num, item) in items { + set_creative_slot(slot_num, item); } } crate::ui::creative_inventory::CreativeAction::None => {} diff --git a/pomme-client/src/ui/creative_inventory.rs b/pomme-client/src/ui/creative_inventory.rs index 29fde140..206071f3 100644 --- a/pomme-client/src/ui/creative_inventory.rs +++ b/pomme-client/src/ui/creative_inventory.rs @@ -253,10 +253,17 @@ pub struct CreativeState { pub search: String, /// Client-side carried stack (the item riding the cursor). Creative-only. pub cursor_item: ItemStack, + /// Active click-drag distribution, if a button is held across slots. + drag: Option, cursor_blink: Instant, scroll_dragging: bool, } +struct DragState { + button: ClickKind, + slots: Vec, +} + impl CreativeState { pub fn new() -> Self { Self { @@ -264,6 +271,7 @@ impl CreativeState { scroll: 0.0, search: String::new(), cursor_item: ItemStack::Empty, + drag: None, cursor_blink: Instant::now(), scroll_dragging: false, } @@ -285,6 +293,8 @@ pub enum CreativeAction { Close, /// Set a real player-inventory slot to the given stack (creative set-slot). SetSlot(u16, ItemStack), + /// Set several slots at once (drag distribution). + SetSlots(Vec<(u16, ItemStack)>), } #[allow(clippy::too_many_arguments)] @@ -304,6 +314,7 @@ pub fn build_creative_inventory( gs: f32, advanced_tooltips: bool, mouse_held: bool, + right_held: bool, text_width_fn: &dyn Fn(&str, f32) -> f32, ) -> CreativeAction { if state.tab.captures_typing() { @@ -373,14 +384,20 @@ pub fn build_creative_inventory( carrying: matches!(state.cursor_item, ItemStack::Present(_)), }; - if state.tab.is_inventory_tab() { - if let Some((slot, kind)) = draw_inventory_layout(elements, ox, oy, scale, inventory, &tt) { - if slot == SLOT_TRASH { - state.cursor_item = ItemStack::Empty; - } else { - action = apply_slot_action(state, inventory, slot, kind); - } + let drag_preview: Option = match (&state.drag, &state.cursor_item) { + (Some(drag), ItemStack::Present(carried)) => { + Some(compute_drag_preview(carried, drag, inventory)) } + _ => None, + }; + let empty_preview = HashMap::new(); + let preview_map = drag_preview + .as_ref() + .map(|p| &p.slots) + .unwrap_or(&empty_preview); + + let real_hit: Option = if state.tab.is_inventory_tab() { + draw_inventory_layout(elements, ox, oy, scale, inventory, &tt, preview_map) } else { let items = visible_items(state); let scrollable = state.tab.scrollable(); @@ -438,9 +455,7 @@ pub fn build_creative_inventory( && matches!(state.cursor_item, ItemStack::Empty) && let ItemStack::Present(data) = &item { - let mut cloned = data.clone(); - cloned.count = max_stack_size(cloned.kind); - state.cursor_item = ItemStack::Present(cloned); + state.cursor_item = stack_with_count(data, max_stack_size(data.kind)); } else if grid_clicked || right_clicked { match std::mem::replace(&mut state.cursor_item, ItemStack::Empty) { ItemStack::Present(mut carried) => { @@ -473,15 +488,67 @@ pub fn build_creative_inventory( } } - if let Some((slot, kind)) = draw_player_hotbar(elements, ox, oy, scale, inventory, &tt) - && matches!(action, CreativeAction::None) - { - action = apply_slot_action(state, inventory, slot, kind); - } + let hit = draw_player_hotbar(elements, ox, oy, scale, inventory, &tt, preview_map); if scrollable { draw_scrollbar(elements, ox, oy, scale, state.scroll, max_scroll_rows == 0); } + hit + }; + + // Real-slot clicks and click-drag distribution. + if let Some(button) = state.drag.as_ref().map(|d| d.button) { + let held = match button { + ClickKind::Left => mouse_held, + ClickKind::Right => right_held, + }; + if held { + // Extend the drag onto a newly hovered, eligible slot. + if let Some(hit) = &real_hit + && hit.slot != SLOT_TRASH + && let ItemStack::Present(carried) = state.cursor_item.clone() + { + let drag = state.drag.as_mut().unwrap(); + if !drag.slots.contains(&hit.slot) + && carried.count as usize > drag.slots.len() + && drag_slot_eligible(&carried, inventory, hit.slot) + { + drag.slots.push(hit.slot); + } + } + } else { + // Released: commit the split and keep the remainder on the cursor. + if let Some(preview) = &drag_preview { + if let ItemStack::Present(carried) = state.cursor_item.clone() { + state.cursor_item = stack_with_count(&carried, preview.remainder); + } + let items: Vec<(u16, ItemStack)> = + preview.slots.iter().map(|(k, v)| (*k, v.clone())).collect(); + if !items.is_empty() { + action = CreativeAction::SetSlots(items); + } + } + state.drag = None; + } + } else if let Some(hit) = &real_hit + && let Some(kind) = hit.click + { + if hit.slot == SLOT_TRASH { + state.cursor_item = ItemStack::Empty; + } else if let ItemStack::Present(carried) = state.cursor_item.clone() { + // Carrying: start a drag on an eligible slot, else swap immediately. + if drag_slot_eligible(&carried, inventory, hit.slot) { + state.drag = Some(DragState { + button: kind, + slots: vec![hit.slot], + }); + } else { + action = apply_slot_action(state, inventory, hit.slot, kind); + } + } else { + // Empty cursor: pick up / take half immediately. + action = apply_slot_action(state, inventory, hit.slot, kind); + } } push_tab_tooltip(elements, ox, oy, scale, &tt); @@ -496,9 +563,14 @@ pub fn build_creative_inventory( } } - // Draw the carried item on top of everything, centred on the cursor. - if let ItemStack::Present(data) = &state.cursor_item { - let size = SLOT_SIZE * scale; + // Draw the carried item on the cursor; while dragging, show the remainder. + let cursor_stack = match (state.drag.is_some(), &state.cursor_item, &drag_preview) { + (true, ItemStack::Present(carried), Some(preview)) => { + stack_with_count(carried, preview.remainder) + } + _ => state.cursor_item.clone(), + }; + if let ItemStack::Present(data) = &cursor_stack { common::push_item_icon( elements, cursor.0 - size / 2.0, @@ -622,6 +694,19 @@ enum ClickKind { Right, } +/// A hovered real slot, plus which button (if any) was just pressed on it. +struct SlotHit { + slot: u16, + click: Option, +} + +/// Pending click-drag distribution: the stack each covered slot would receive +/// and the count left on the cursor. +struct DragPreview { + slots: HashMap, + remainder: i32, +} + const fn rgb(hex: u32) -> [f32; 4] { [ ((hex >> 16) & 0xff) as f32 / 255.0, @@ -871,8 +956,8 @@ fn push_tab_tooltip( } } -/// Returns the clicked slot and which button, whether the slot is empty or not, -/// so a carried item can be placed into an empty slot. +/// Reports the hovered slot (and which button, if any, was just pressed) so the +/// caller can drive clicks and drag accumulation. Returns for empty slots too. #[allow(clippy::too_many_arguments)] fn slot_with_tooltip( elements: &mut Vec, @@ -884,40 +969,48 @@ fn slot_with_tooltip( empty_sprite: Option, tt: &TooltipCtx, slot_num: Option, -) -> Option<(u16, ClickKind)> { +) -> Option { let hovered = push_slot(elements, x, y, size, scale, tt.cursor, item, empty_sprite); if hovered { push_item_tooltip(elements, item, tt); if let Some(slot) = slot_num { - if tt.clicked { - return Some((slot, ClickKind::Left)); - } - if tt.right_clicked { - return Some((slot, ClickKind::Right)); - } + let click = if tt.clicked { + Some(ClickKind::Left) + } else if tt.right_clicked { + Some(ClickKind::Right) + } else { + None + }; + return Some(SlotHit { slot, click }); } } None } -/// Left-click on a real player-inventory slot: pick up, place, or swap the -/// carried item, updating the cursor stack and emitting the set-slot action. -fn apply_slot_click( +/// A single (non-drag) click on a real player-inventory slot. With an empty +/// cursor it picks up the slot (left = whole, right = half); while carrying it +/// swaps — reachable only for a different item, since empty/same-item slots go +/// through the drag path. +fn apply_slot_action( state: &mut CreativeState, inventory: &Inventory, slot_num: u16, + kind: ClickKind, ) -> CreativeAction { let slot_item = inventory.slot(slot_num as usize).clone(); match std::mem::replace(&mut state.cursor_item, ItemStack::Empty) { - ItemStack::Empty => { - if matches!(slot_item, ItemStack::Present(_)) { + ItemStack::Empty => match (&slot_item, kind) { + (ItemStack::Empty, _) => CreativeAction::None, + (ItemStack::Present(_), ClickKind::Left) => { state.cursor_item = slot_item; CreativeAction::SetSlot(slot_num, ItemStack::Empty) - } else { - CreativeAction::None } - } - // TODO: merge stacks when the carried and slot items are the same. + (ItemStack::Present(data), ClickKind::Right) => { + let take = (data.count + 1) / 2; + state.cursor_item = stack_with_count(data, take); + CreativeAction::SetSlot(slot_num, stack_with_count(data, data.count - take)) + } + }, carried => { state.cursor_item = slot_item; CreativeAction::SetSlot(slot_num, carried) @@ -925,71 +1018,52 @@ fn apply_slot_click( } } -/// Right-click on a real player-inventory slot: take half with an empty cursor, -/// else place one (or swap on a different item). -fn apply_slot_right_click( - state: &mut CreativeState, - inventory: &Inventory, - slot_num: u16, -) -> CreativeAction { - let slot_item = inventory.slot(slot_num as usize).clone(); - match std::mem::replace(&mut state.cursor_item, ItemStack::Empty) { - ItemStack::Empty => { - let ItemStack::Present(mut data) = slot_item else { - return CreativeAction::None; - }; - let take = (data.count + 1) / 2; - data.count -= take; - let mut taken = data.clone(); - taken.count = take; - state.cursor_item = ItemStack::Present(taken); - let remainder = if data.count > 0 { - ItemStack::Present(data) - } else { - ItemStack::Empty - }; - CreativeAction::SetSlot(slot_num, remainder) - } - ItemStack::Present(mut carried) => { - let placed = match slot_item { - ItemStack::Present(mut existing) => { - if !carried.is_same_item_and_components(&existing) { - // Different item: swap the whole stacks. - state.cursor_item = ItemStack::Present(existing); - return CreativeAction::SetSlot(slot_num, ItemStack::Present(carried)); - } - if existing.count >= max_stack_size(existing.kind) { - state.cursor_item = ItemStack::Present(carried); - return CreativeAction::None; - } - existing.count += 1; - existing - } - ItemStack::Empty => { - let mut one = carried.clone(); - one.count = 1; - one - } - }; - carried.count -= 1; - if carried.count > 0 { - state.cursor_item = ItemStack::Present(carried); - } - CreativeAction::SetSlot(slot_num, ItemStack::Present(placed)) - } +/// A copy of `item` at `count`, or `Empty` when `count` drops to zero or below. +fn stack_with_count(item: &ItemStackData, count: i32) -> ItemStack { + if count > 0 { + let mut s = item.clone(); + s.count = count; + ItemStack::Present(s) + } else { + ItemStack::Empty } } -fn apply_slot_action( - state: &mut CreativeState, +/// A drag can cover a slot only if it's empty or holds the same item as the +/// carried stack. +fn drag_slot_eligible(carried: &ItemStackData, inventory: &Inventory, slot_num: u16) -> bool { + match inventory.slot(slot_num as usize) { + ItemStack::Empty => true, + ItemStack::Present(existing) => carried.is_same_item_and_components(existing), + } +} + +/// Distribute the carried stack across the dragged slots: left splits it +/// evenly, right places one each. Returns each slot's resulting stack and the +/// remainder. +fn compute_drag_preview( + carried: &ItemStackData, + drag: &DragState, inventory: &Inventory, - slot_num: u16, - kind: ClickKind, -) -> CreativeAction { - match kind { - ClickKind::Left => apply_slot_click(state, inventory, slot_num), - ClickKind::Right => apply_slot_right_click(state, inventory, slot_num), +) -> DragPreview { + let n = drag.slots.len() as i32; + let place = match drag.button { + ClickKind::Left => carried.count / n.max(1), + ClickKind::Right => 1, + }; + let max = max_stack_size(carried.kind); + let mut remainder = carried.count; + let mut slots = HashMap::new(); + for &slot in &drag.slots { + let existing = match inventory.slot(slot as usize) { + ItemStack::Present(d) if carried.is_same_item_and_components(d) => d.count, + _ => 0, + }; + let new_count = (place + existing).min(max); + remainder -= new_count - existing; + slots.insert(slot, stack_with_count(carried, new_count)); } + DragPreview { slots, remainder } } // Vanilla `PlayerInventory` slot indices. @@ -1007,7 +1081,8 @@ fn draw_player_hotbar( scale: f32, inventory: &Inventory, tt: &TooltipCtx, -) -> Option<(u16, ClickKind)> { + preview: &HashMap, +) -> Option { let size = SLOT_SIZE * scale; let hotbar = inventory.hotbar_slots(); let mut clicked_slot = None; @@ -1019,7 +1094,8 @@ fn draw_player_hotbar( GRID_ORIGIN_X + col as f32 * SLOT_STRIDE, HOTBAR_Y, ); - let item = item_or_empty(hotbar, col); + let slot_num = SLOT_HOTBAR_BASE + col as u16; + let item = slot_display(preview, slot_num, item_or_empty(hotbar, col)); clicked_slot = clicked_slot.or(slot_with_tooltip( elements, x, @@ -1029,12 +1105,17 @@ fn draw_player_hotbar( &item, None, tt, - Some(SLOT_HOTBAR_BASE + col as u16), + Some(slot_num), )); } clicked_slot } +/// Overrides a slot's drawn stack with its drag preview, when one is pending. +fn slot_display(preview: &HashMap, slot_num: u16, actual: ItemStack) -> ItemStack { + preview.get(&slot_num).cloned().unwrap_or(actual) +} + fn draw_inventory_layout( elements: &mut Vec, ox: f32, @@ -1042,7 +1123,8 @@ fn draw_inventory_layout( scale: f32, inventory: &Inventory, tt: &TooltipCtx, -) -> Option<(u16, ClickKind)> { + preview: &HashMap, +) -> Option { let size = SLOT_SIZE * scale; let mut clicked_slot = None; @@ -1057,7 +1139,8 @@ fn draw_inventory_layout( INV_ARMOR_X + col * INV_ARMOR_COL_STRIDE, INV_ARMOR_Y + row * INV_ARMOR_ROW_STRIDE, ); - let item = item_or_empty(armor, i); + let slot_num = SLOT_ARMOR_BASE + i as u16; + let item = slot_display(preview, slot_num, item_or_empty(armor, i)); clicked_slot = clicked_slot.or(slot_with_tooltip( elements, x, @@ -1067,18 +1150,19 @@ fn draw_inventory_layout( &item, None, tt, - Some(SLOT_ARMOR_BASE + i as u16), + Some(slot_num), )); } let (x, y) = slot_xy(ox, oy, scale, INV_OFFHAND_X, INV_OFFHAND_Y); + let offhand = slot_display(preview, SLOT_OFFHAND, inventory.offhand().clone()); clicked_slot = clicked_slot.or(slot_with_tooltip( elements, x, y, size, scale, - inventory.offhand(), + &offhand, None, tt, Some(SLOT_OFFHAND), @@ -1095,7 +1179,8 @@ fn draw_inventory_layout( GRID_ORIGIN_X + col as f32 * SLOT_STRIDE, INV_MAIN_Y + row as f32 * SLOT_STRIDE, ); - let item = item_or_empty(main, idx); + let slot_num = SLOT_MAIN_BASE + idx as u16; + let item = slot_display(preview, slot_num, item_or_empty(main, idx)); clicked_slot = clicked_slot.or(slot_with_tooltip( elements, x, @@ -1105,12 +1190,14 @@ fn draw_inventory_layout( &item, None, tt, - Some(SLOT_MAIN_BASE + idx as u16), + Some(slot_num), )); } } - clicked_slot = clicked_slot.or(draw_player_hotbar(elements, ox, oy, scale, inventory, tt)); + clicked_slot = clicked_slot.or(draw_player_hotbar( + elements, ox, oy, scale, inventory, tt, preview, + )); let (trash_x, trash_y) = slot_xy(ox, oy, scale, INV_TRASH_X, INV_TRASH_Y); clicked_slot = clicked_slot.or(slot_with_tooltip( From 2ae7b648b55870e8257f7d722e351f00fb6e387a Mon Sep 17 00:00:00 2001 From: Purdze Date: Wed, 1 Jul 2026 19:44:19 +0100 Subject: [PATCH 8/9] Double-click to gather matching items in creative --- pomme-client/src/ui/creative_inventory.rs | 79 ++++++++++++++++++++--- 1 file changed, 69 insertions(+), 10 deletions(-) diff --git a/pomme-client/src/ui/creative_inventory.rs b/pomme-client/src/ui/creative_inventory.rs index 206071f3..08867629 100644 --- a/pomme-client/src/ui/creative_inventory.rs +++ b/pomme-client/src/ui/creative_inventory.rs @@ -255,6 +255,8 @@ pub struct CreativeState { pub cursor_item: ItemStack, /// Active click-drag distribution, if a button is held across slots. drag: Option, + /// Last left-click (slot, time) for double-click detection. + last_left_click: Option<(u16, Instant)>, cursor_blink: Instant, scroll_dragging: bool, } @@ -272,6 +274,7 @@ impl CreativeState { search: String::new(), cursor_item: ItemStack::Empty, drag: None, + last_left_click: None, cursor_blink: Instant::now(), scroll_dragging: false, } @@ -533,21 +536,34 @@ pub fn build_creative_inventory( } else if let Some(hit) = &real_hit && let Some(kind) = hit.click { + let double = matches!(kind, ClickKind::Left) && is_double_click(state, hit.slot); if hit.slot == SLOT_TRASH { state.cursor_item = ItemStack::Empty; - } else if let ItemStack::Present(carried) = state.cursor_item.clone() { - // Carrying: start a drag on an eligible slot, else swap immediately. - if drag_slot_eligible(&carried, inventory, hit.slot) { - state.drag = Some(DragState { - button: kind, - slots: vec![hit.slot], - }); + } else if double && matches!(state.cursor_item, ItemStack::Present(_)) { + // Double-click: gather matching items into the cursor. + let items = double_click_gather(state, inventory); + if !items.is_empty() { + action = CreativeAction::SetSlots(items); + } + state.last_left_click = None; + } else { + if matches!(kind, ClickKind::Left) { + state.last_left_click = Some((hit.slot, Instant::now())); + } + if let ItemStack::Present(carried) = state.cursor_item.clone() { + // Carrying: start a drag on an eligible slot, else swap immediately. + if drag_slot_eligible(&carried, inventory, hit.slot) { + state.drag = Some(DragState { + button: kind, + slots: vec![hit.slot], + }); + } else { + action = apply_slot_action(state, inventory, hit.slot, kind); + } } else { + // Empty cursor: pick up / take half immediately. action = apply_slot_action(state, inventory, hit.slot, kind); } - } else { - // Empty cursor: pick up / take half immediately. - action = apply_slot_action(state, inventory, hit.slot, kind); } } @@ -1029,6 +1045,49 @@ fn stack_with_count(item: &ItemStackData, count: i32) -> ItemStack { } } +const DOUBLE_CLICK_MS: u128 = 250; + +fn is_double_click(state: &CreativeState, slot: u16) -> bool { + matches!(state.last_left_click, Some((s, t)) if s == slot && t.elapsed().as_millis() <= DOUBLE_CLICK_MS) +} + +/// Double-click gather: fill the carried stack to its max by pulling matching +/// items from the real player inventory (partial stacks first, then full), +/// matching vanilla `PICKUP_ALL`. Returns the drained slots. +fn double_click_gather(state: &mut CreativeState, inventory: &Inventory) -> Vec<(u16, ItemStack)> { + let ItemStack::Present(mut carried) = state.cursor_item.clone() else { + return Vec::new(); + }; + let max = max_stack_size(carried.kind); + let mut changed: HashMap = HashMap::new(); + for pass in 0..2 { + for slot in SLOT_MAIN_BASE..=SLOT_OFFHAND { + if carried.count >= max { + break; + } + let existing = match changed.get(&slot) { + Some(ItemStack::Present(d)) => d.clone(), + Some(ItemStack::Empty) => continue, + None => match inventory.slot(slot as usize) { + ItemStack::Present(d) => d.clone(), + ItemStack::Empty => continue, + }, + }; + if !carried.is_same_item_and_components(&existing) { + continue; + } + if pass == 0 && existing.count >= max_stack_size(existing.kind) { + continue; + } + let take = (max - carried.count).min(existing.count); + carried.count += take; + changed.insert(slot, stack_with_count(&existing, existing.count - take)); + } + } + state.cursor_item = ItemStack::Present(carried); + changed.into_iter().collect() +} + /// A drag can cover a slot only if it's empty or holds the same item as the /// carried stack. fn drag_slot_eligible(carried: &ItemStackData, inventory: &Inventory, slot_num: u16) -> bool { From edf355e0922310ff273707a93eddf555473365c3 Mon Sep 17 00:00:00 2001 From: Purdze Date: Thu, 2 Jul 2026 19:28:54 +0100 Subject: [PATCH 9/9] cleanup --- pomme-client/src/app/core.rs | 10 +-- pomme-client/src/app/input.rs | 2 +- pomme-client/src/app/mod.rs | 5 +- pomme-client/src/app/phases/in_game.rs | 7 +- pomme-client/src/ui/creative_inventory.rs | 98 ++++++++++++++--------- 5 files changed, 73 insertions(+), 49 deletions(-) diff --git a/pomme-client/src/app/core.rs b/pomme-client/src/app/core.rs index db862ab6..735959f7 100644 --- a/pomme-client/src/app/core.rs +++ b/pomme-client/src/app/core.rs @@ -229,7 +229,6 @@ impl AppCore { } pub fn apply_cursor_grab(&self, window: &Window, game: Option<&mut GameState>) { - let in_game = game.is_some(); let captured = game.is_some_and(|g| g.input_live() && !g.dead && self.input.is_cursor_captured()); if captured { @@ -240,11 +239,6 @@ impl AppCore { } else { let _ = window.set_cursor_grab(CursorGrabMode::None); window.set_cursor_visible(true); - if in_game { - // In-game screens (inventory, chat, pause) use the plain arrow like - // vanilla, not the pointer the branded menu may have left set. - window.set_cursor(winit::window::CursorIcon::Default); - } } } @@ -631,12 +625,12 @@ impl AppCore { } 3 => { game.inventory_open = false; - game.creative_inventory_open = false; + game.close_creative_inventory(); self.apply_cursor_grab(window, Some(game)); } _ => { game.inventory_open = true; - game.creative_inventory_open = false; + game.close_creative_inventory(); } } } diff --git a/pomme-client/src/app/input.rs b/pomme-client/src/app/input.rs index d55ebc55..b9ffd685 100644 --- a/pomme-client/src/app/input.rs +++ b/pomme-client/src/app/input.rs @@ -158,7 +158,7 @@ impl InputState { { if self.action_just_pressed(Action::ToggleInventory) { if game.creative_inventory_open { - game.creative_inventory_open = false; + game.close_creative_inventory(); should_apply_cursor_grab = true; } else if !game.paused && !game.dead diff --git a/pomme-client/src/app/mod.rs b/pomme-client/src/app/mod.rs index 2dee27d2..07808974 100644 --- a/pomme-client/src/app/mod.rs +++ b/pomme-client/src/app/mod.rs @@ -353,7 +353,7 @@ impl ApplicationHandler for App { } else if game.creative_inventory_open { match code { KeyCode::Escape => { - game.creative_inventory_open = false; + game.close_creative_inventory(); self.core .input .clear_action(crate::app::input::Action::OpenMenu); @@ -560,6 +560,9 @@ impl ApplicationHandler for App { if let Some(p) = &mut core.presence { p.playing_multiplayer(&core.version); } + // In-game screens use the plain arrow like vanilla, not + // the pointer the branded menu may have left set. + gfx.window.set_cursor(winit::window::CursorIcon::Default); core.apply_cursor_grab(&gfx.window, Some(&mut game)); AppPhase::InGame { diff --git a/pomme-client/src/app/phases/in_game.rs b/pomme-client/src/app/phases/in_game.rs index 2815a546..3c12d78b 100644 --- a/pomme-client/src/app/phases/in_game.rs +++ b/pomme-client/src/app/phases/in_game.rs @@ -218,6 +218,11 @@ impl GameState { self.inventory_open || self.creative_inventory_open } + pub fn close_creative_inventory(&mut self) { + self.creative_inventory_open = false; + self.creative_state.reset_interaction(); + } + /// No menu (pause, inventory, chat) is capturing input. pub fn input_live(&self) -> bool { !self.paused @@ -1399,7 +1404,7 @@ pub fn update_game( if close_inventory { game.inventory_open = false; - game.creative_inventory_open = false; + game.close_creative_inventory(); core.apply_cursor_grab(&gfx.window, Some(game)); } diff --git a/pomme-client/src/ui/creative_inventory.rs b/pomme-client/src/ui/creative_inventory.rs index 08867629..93bd1a19 100644 --- a/pomme-client/src/ui/creative_inventory.rs +++ b/pomme-client/src/ui/creative_inventory.rs @@ -2,8 +2,11 @@ use std::collections::HashMap; use std::sync::OnceLock; use std::time::Instant; -use azalea_inventory::components::{Damage, Enchantments, MaxDamage, MaxStackSize, Rarity}; +use azalea_inventory::components::{ + Damage, Enchantments, EquipmentSlot, Equippable, MaxDamage, Rarity, +}; use azalea_inventory::default_components::get_default_component; +use azalea_inventory::item::MaxStackSizeExt; use azalea_inventory::{ItemStack, ItemStackData}; use azalea_registry::builtin::{DataComponentKind, ItemKind}; @@ -283,6 +286,15 @@ impl CreativeState { fn reset_blink(&mut self) { self.cursor_blink = Instant::now(); } + + /// Discards the carried stack and any pending drag/double-click state. + /// Must run whenever the screen closes, or a stale drag would re-commit on + /// reopen. + pub fn reset_interaction(&mut self) { + self.cursor_item = ItemStack::Empty; + self.drag = None; + self.last_left_click = None; + } } impl Default for CreativeState { @@ -316,7 +328,7 @@ pub fn build_creative_inventory( inventory: &Inventory, gs: f32, advanced_tooltips: bool, - mouse_held: bool, + left_held: bool, right_held: bool, text_width_fn: &dyn Fn(&str, f32) -> f32, ) -> CreativeAction { @@ -384,7 +396,7 @@ pub fn build_creative_inventory( advanced: advanced_tooltips, clicked, right_clicked, - carrying: matches!(state.cursor_item, ItemStack::Present(_)), + carrying: state.cursor_item.is_present(), }; let drag_preview: Option = match (&state.drag, &state.cursor_item) { @@ -417,7 +429,7 @@ pub fn build_creative_inventory( let step = 1.0 / max_scroll_rows as f32; state.scroll = (state.scroll - scroll_delta.signum() * step).clamp(0.0, 1.0); } - if update_scroll_drag(state, ox, oy, scale, cursor, clicked, mouse_held) { + if update_scroll_drag(state, ox, oy, scale, cursor, clicked, left_held) { grid_clicked = false; } } else { @@ -455,15 +467,14 @@ pub fn build_creative_inventory( if hovered { push_item_tooltip(elements, &item, &tt); if middle_clicked - && matches!(state.cursor_item, ItemStack::Empty) + && state.cursor_item.is_empty() && let ItemStack::Present(data) = &item { - state.cursor_item = stack_with_count(data, max_stack_size(data.kind)); + state.cursor_item = stack_with_count(data, data.kind.max_stack_size()); } else if grid_clicked || right_clicked { match std::mem::replace(&mut state.cursor_item, ItemStack::Empty) { ItemStack::Present(mut carried) => { if right_clicked { - // Drop one from the carried stack. carried.count -= 1; if carried.count > 0 { state.cursor_item = ItemStack::Present(carried); @@ -474,7 +485,7 @@ pub fn build_creative_inventory( // Left-click the same item: grow the carried stack up to its // max. A different item or empty cell discards it (stays // empty). - if carried.count < max_stack_size(carried.kind) { + if carried.count < carried.kind.max_stack_size() { carried.count += 1; } state.cursor_item = ItemStack::Present(carried); @@ -500,24 +511,21 @@ pub fn build_creative_inventory( }; // Real-slot clicks and click-drag distribution. - if let Some(button) = state.drag.as_ref().map(|d| d.button) { - let held = match button { - ClickKind::Left => mouse_held, + if let Some(drag) = &mut state.drag { + let held = match drag.button { + ClickKind::Left => left_held, ClickKind::Right => right_held, }; if held { // Extend the drag onto a newly hovered, eligible slot. if let Some(hit) = &real_hit && hit.slot != SLOT_TRASH - && let ItemStack::Present(carried) = state.cursor_item.clone() + && let ItemStack::Present(carried) = &state.cursor_item + && !drag.slots.contains(&hit.slot) + && carried.count as usize > drag.slots.len() + && drag_slot_eligible(carried, inventory, hit.slot) { - let drag = state.drag.as_mut().unwrap(); - if !drag.slots.contains(&hit.slot) - && carried.count as usize > drag.slots.len() - && drag_slot_eligible(&carried, inventory, hit.slot) - { - drag.slots.push(hit.slot); - } + drag.slots.push(hit.slot); } } else { // Released: commit the split and keep the remainder on the cursor. @@ -539,8 +547,7 @@ pub fn build_creative_inventory( let double = matches!(kind, ClickKind::Left) && is_double_click(state, hit.slot); if hit.slot == SLOT_TRASH { state.cursor_item = ItemStack::Empty; - } else if double && matches!(state.cursor_item, ItemStack::Present(_)) { - // Double-click: gather matching items into the cursor. + } else if double && state.cursor_item.is_present() { let items = double_click_gather(state, inventory); if !items.is_empty() { action = CreativeAction::SetSlots(items); @@ -550,9 +557,9 @@ pub fn build_creative_inventory( if matches!(kind, ClickKind::Left) { state.last_left_click = Some((hit.slot, Instant::now())); } - if let ItemStack::Present(carried) = state.cursor_item.clone() { + if let ItemStack::Present(carried) = &state.cursor_item { // Carrying: start a drag on an eligible slot, else swap immediately. - if drag_slot_eligible(&carried, inventory, hit.slot) { + if drag_slot_eligible(carried, inventory, hit.slot) { state.drag = Some(DragState { button: kind, slots: vec![hit.slot], @@ -571,7 +578,7 @@ pub fn build_creative_inventory( let outside = !hit_test(cursor, [ox, oy, inv_w, inv_h]); if clicked && outside && tab_hit.is_none() && matches!(action, CreativeAction::None) { - if matches!(state.cursor_item, ItemStack::Present(_)) { + if state.cursor_item.is_present() { // TODO: drop the carried item into the world; for now just discard it. state.cursor_item = ItemStack::Empty; } else { @@ -740,12 +747,6 @@ const RARITY_UNCOMMON: [f32; 4] = rgb(0xFFFF55); const RARITY_RARE: [f32; 4] = rgb(0x55FFFF); const RARITY_EPIC: [f32; 4] = rgb(0xFF55FF); -fn max_stack_size(kind: ItemKind) -> i32 { - get_default_component::(kind) - .map(|m| m.count) - .unwrap_or(64) -} - fn rarity_color(kind: ItemKind) -> [f32; 4] { match get_default_component::(kind) { Some(Rarity::Uncommon) => RARITY_UNCOMMON, @@ -1006,7 +1007,7 @@ fn slot_with_tooltip( /// A single (non-drag) click on a real player-inventory slot. With an empty /// cursor it picks up the slot (left = whole, right = half); while carrying it /// swaps — reachable only for a different item, since empty/same-item slots go -/// through the drag path. +/// through the drag path — unless the slot rejects the item (armor). fn apply_slot_action( state: &mut CreativeState, inventory: &Inventory, @@ -1027,9 +1028,13 @@ fn apply_slot_action( CreativeAction::SetSlot(slot_num, stack_with_count(data, data.count - take)) } }, - carried => { + ItemStack::Present(carried) => { + if !may_place(slot_num, &carried) { + state.cursor_item = ItemStack::Present(carried); + return CreativeAction::None; + } state.cursor_item = slot_item; - CreativeAction::SetSlot(slot_num, carried) + CreativeAction::SetSlot(slot_num, ItemStack::Present(carried)) } } } @@ -1058,7 +1063,7 @@ fn double_click_gather(state: &mut CreativeState, inventory: &Inventory) -> Vec< let ItemStack::Present(mut carried) = state.cursor_item.clone() else { return Vec::new(); }; - let max = max_stack_size(carried.kind); + let max = carried.kind.max_stack_size(); let mut changed: HashMap = HashMap::new(); for pass in 0..2 { for slot in SLOT_MAIN_BASE..=SLOT_OFFHAND { @@ -1076,7 +1081,7 @@ fn double_click_gather(state: &mut CreativeState, inventory: &Inventory) -> Vec< if !carried.is_same_item_and_components(&existing) { continue; } - if pass == 0 && existing.count >= max_stack_size(existing.kind) { + if pass == 0 && existing.count >= existing.kind.max_stack_size() { continue; } let take = (max - carried.count).min(existing.count); @@ -1088,9 +1093,26 @@ fn double_click_gather(state: &mut CreativeState, inventory: &Inventory) -> Vec< changed.into_iter().collect() } -/// A drag can cover a slot only if it's empty or holds the same item as the -/// carried stack. +/// Vanilla `ArmorSlot.mayPlace`: armor slots only accept items whose +/// Equippable component targets that slot; anything goes elsewhere. +fn may_place(slot_num: u16, item: &ItemStackData) -> bool { + // Vanilla InventoryMenu.SLOT_IDS: armor menu slots 5..=8 are head..feet. + let required = match slot_num { + 5 => EquipmentSlot::Head, + 6 => EquipmentSlot::Chest, + 7 => EquipmentSlot::Legs, + 8 => EquipmentSlot::Feet, + _ => return true, + }; + get_default_component::(item.kind).is_some_and(|e| e.slot == required) +} + +/// A drag can cover a slot only if the item may go there and the slot is empty +/// or holds the same item as the carried stack. fn drag_slot_eligible(carried: &ItemStackData, inventory: &Inventory, slot_num: u16) -> bool { + if !may_place(slot_num, carried) { + return false; + } match inventory.slot(slot_num as usize) { ItemStack::Empty => true, ItemStack::Present(existing) => carried.is_same_item_and_components(existing), @@ -1110,7 +1132,7 @@ fn compute_drag_preview( ClickKind::Left => carried.count / n.max(1), ClickKind::Right => 1, }; - let max = max_stack_size(carried.kind); + let max = carried.kind.max_stack_size(); let mut remainder = carried.count; let mut slots = HashMap::new(); for &slot in &drag.slots {