Skip to main content

veloren_voxygen/hud/
slot_grid.rs

1use super::{
2    TEXT_COLOR, UI_MAIN,
3    img_ids::Imgs,
4    item_imgs::ItemImgs,
5    slots::{InventorySlot, SlotManager},
6    util,
7};
8use crate::{
9    GlobalState,
10    hud::slots::SlotKind,
11    ui::{
12        ItemTooltip, ItemTooltipManager, ItemTooltipable,
13        fonts::Fonts,
14        slot::{ContentSize, SlotMaker},
15    },
16    window::MenuInput,
17};
18use client::Client;
19use common::{
20    assets::AssetExt,
21    comp::{
22        Inventory,
23        inventory::slot::Slot,
24        item::{ItemDef, ItemDesc, ItemI18n, ItemKind, Quality},
25    },
26};
27use conrod_core::{
28    Borderable, Color, Colorable, Labelable, Positionable, Sizeable, Widget, WidgetCommon,
29    builder_methods, color,
30    widget::{self, Button, Rectangle, Text},
31    widget_ids,
32};
33use i18n::Localization;
34use specs::Entity as EcsEntity;
35use std::{borrow::Borrow, sync::Arc};
36use vek::Vec2;
37
38#[derive(PartialEq)]
39pub enum TabFilters {
40    Gear,
41    //Ingredients,
42    //Food,
43    //QuestItems,
44    None,
45}
46
47pub enum SlotEvents {
48    Close,
49    ExitUp,
50    ExitLeft,
51    ExitRight,
52    ExitDown,
53    FilteredSize(usize),
54}
55
56#[derive(WidgetCommon)]
57pub struct SlotGrid<'a> {
58    #[conrod(common_builder)]
59    common: widget::CommonBuilder,
60    client: &'a Client,
61    global_state: &'a GlobalState,
62    imgs: &'a Imgs,
63    item_imgs: &'a ItemImgs,
64    fonts: &'a Fonts,
65    item_tooltip_manager: &'a mut ItemTooltipManager,
66    slot_manager: &'a mut SlotManager,
67    inventory: &'a Inventory,
68    item_tooltip: &'a ItemTooltip<'a>,
69    localized_strings: &'a Localization,
70    item_i18n: &'a ItemI18n,
71    entity: EcsEntity,
72    pulse: f32,
73    menu_events: &'a Vec<MenuInput>,
74    is_us: bool,
75    details_mode: bool,
76    show_salvage: bool,
77    columns: usize,
78    spacing: f64,
79    slot_size: f64,
80    is_focused: bool,
81    filter: TabFilters,
82}
83
84widget_ids! {
85    struct Ids {
86        item_slots[],
87        inv_slot_names[],
88        inv_slot_amounts[],
89
90        context_menu,
91        spacing_below,
92    }
93}
94
95pub struct State {
96    ids: Ids,
97
98    active_context_slot: Option<SlotKind>,
99    // TODO: switch from 2D coordinates to 1D coordinates for optimization
100    context_menu_pos: [f64; 2],
101    active_slot: [usize; 2],
102}
103
104impl<'a> SlotGrid<'a> {
105    builder_methods! {
106        pub columns { columns = usize }
107        pub spacing { spacing = f64 }
108        pub slot_size { slot_size = f64 }
109        pub is_focused { is_focused = bool }
110        pub filter { filter = TabFilters }
111    }
112
113    #[expect(clippy::too_many_arguments)]
114    pub fn new(
115        client: &'a Client,
116        global_state: &'a GlobalState,
117        imgs: &'a Imgs,
118        item_imgs: &'a ItemImgs,
119        fonts: &'a Fonts,
120        item_tooltip_manager: &'a mut ItemTooltipManager,
121        slot_manager: &'a mut SlotManager,
122        inventory: &'a Inventory,
123        item_tooltip: &'a ItemTooltip<'a>,
124        localized_strings: &'a Localization,
125        item_i18n: &'a ItemI18n,
126        entity: EcsEntity,
127        pulse: f32,
128        menu_events: &'a Vec<MenuInput>,
129        is_us: bool,
130        details_mode: bool,
131        show_salvage: bool,
132    ) -> Self {
133        SlotGrid {
134            common: widget::CommonBuilder::default(),
135            client,
136            global_state,
137            imgs,
138            item_imgs,
139            fonts,
140            item_tooltip_manager,
141            slot_manager,
142            inventory,
143            item_tooltip,
144            localized_strings,
145            item_i18n,
146            entity,
147            pulse,
148            menu_events,
149            is_us,
150            details_mode,
151            show_salvage,
152            columns: 6,
153            slot_size: 55.0,
154            spacing: 6.0,
155            is_focused: true,
156            filter: TabFilters::None,
157        }
158    }
159}
160
161impl<'a> Widget for SlotGrid<'a> {
162    type Event = Vec<SlotEvents>;
163    type State = State;
164    type Style = ();
165
166    fn init_state(&self, id_gen: widget::id::Generator) -> Self::State {
167        State {
168            ids: Ids::new(id_gen),
169            active_context_slot: None,
170            context_menu_pos: [0.0, 0.0],
171            active_slot: [0, 0],
172        }
173    }
174
175    fn style(&self) -> Self::Style {}
176
177    fn update(self, args: widget::UpdateArgs<Self>) -> Self::Event {
178        let widget::UpdateArgs { id, state, ui, .. } = args;
179
180        // Capture selected slot
181        let selected = self.slot_manager.selected();
182        if selected.is_none() {
183            state.update(|s| {
184                // If nothing is selected, the context menu should never be open
185                s.active_context_slot = None;
186            })
187        }
188
189        // Filter inventory
190        let inventory_iter = || {
191            self.inventory
192                .slots_with_id()
193                .map(|(slot, item)| (Slot::Inventory(slot), item.as_ref()))
194                .chain(
195                    self.inventory
196                        .overflow_items()
197                        .enumerate()
198                        .map(|(i, item)| (Slot::Overflow(i), Some(item))),
199                )
200        };
201        let mut items = inventory_iter()
202            .filter(|(_, items_list)| match self.filter {
203                TabFilters::Gear => {
204                    if let Some(item) = items_list {
205                        matches!(
206                            &*item.kind(),
207                            ItemKind::Tool(_)
208                                | ItemKind::ModularComponent(_)
209                                | ItemKind::Lantern(_)
210                                | ItemKind::Armor(_)
211                                | ItemKind::Glider
212                        )
213                    } else {
214                        false
215                    }
216                },
217                // The below commented code is used to filter the entire inventory done into
218                // specific categories. These additional inventory tabs were experimented
219                // with, but not included in the initial bag redesign MR
220                /*
221                TabFilters::Ingredients => {
222                    if let Some(item) = items_list {
223                        match &*item.kind() {
224                            // Allowing it because 'descriptor' isn't being used
225                            #[allow(deprecated)]
226                            ItemKind::Ingredient { descriptor: _ } => true,
227                            _ => false,
228                        }
229                    } else {
230                        false
231                    }
232                },
233                TabFilters::Food => {
234                    if let Some(item) = items_list {
235                        item.tags().contains(&ItemTag::Food)
236                            | item.tags().contains(&ItemTag::Potion)
237                    } else {
238                        false
239                    }
240                },
241                TabFilters::QuestItems => {
242                    if let Some(item) = items_list {
243                        matches!(&*item.kind(), ItemKind::Quest)
244                    } else {
245                        false
246                    }
247                },
248                */
249                TabFilters::None => true,
250            })
251            .collect::<Vec<_>>();
252        if self.details_mode && !self.is_us {
253            items.sort_by_cached_key(|(_, item)| {
254                (
255                    item.is_none(),
256                    item.as_ref().map(|i| {
257                        (
258                            std::cmp::Reverse(i.quality()),
259                            {
260                                // TODO: we do double the work here, optimize?
261                                let (name, _) =
262                                    util::item_text(i, self.localized_strings, self.item_i18n);
263                                name
264                            },
265                            i.amount(),
266                        )
267                    }),
268                )
269            });
270        }
271
272        // Add the first empty slot (if any) to the items list to help with removing
273        // gear and visualizing when more space is available
274        if self.filter != TabFilters::None
275            && let Some(empty_slot) = inventory_iter().find(|(_, item)| item.is_none())
276        {
277            items.push(empty_slot);
278        }
279
280        // Calculate formatting info
281        let total_slots = items.len();
282        let cols = if self.details_mode { 1 } else { self.columns };
283
284        let mut events = Vec::new();
285
286        // MENU INPUTS: change the slot focus
287        // Up: go up a row (no wrap)
288        // Down: go down a row (no wrap)
289        // Left: move left a column (no wrap)
290        // Right: move right a column (no wrap)
291        // LocalFocus: Change local focus
292        // Apply: select the current slot
293        // Back: close the bag menu
294        let mut clicked = false;
295        if selected.is_none() && self.is_focused {
296            for event in self.menu_events {
297                match *event {
298                    MenuInput::Up => state.update(|s| {
299                        let [x, y] = s.active_slot;
300                        if y > 0 {
301                            s.active_slot = [x, y - 1];
302                        } else {
303                            events.push(SlotEvents::ExitUp);
304                        }
305                    }),
306                    MenuInput::Down => state.update(|s| {
307                        let [x, y] = s.active_slot;
308                        if y < (total_slots / cols) {
309                            s.active_slot = [x, y + 1];
310                        } else {
311                            events.push(SlotEvents::ExitDown);
312                        }
313                    }),
314                    MenuInput::Left => state.update(|s| {
315                        let [x, y] = s.active_slot;
316                        if x > 0 {
317                            s.active_slot = [x - 1, y];
318                        } else {
319                            events.push(SlotEvents::ExitLeft);
320                        }
321                    }),
322                    MenuInput::Right => state.update(|s| {
323                        let [x, y] = s.active_slot;
324                        // Only go right if there are slots to go to
325                        if x < self.columns - 1 && (y * cols) + (x + 1) < total_slots {
326                            s.active_slot = [x + 1, y];
327                        } else {
328                            events.push(SlotEvents::ExitRight);
329                        }
330                    }),
331                    MenuInput::Apply => {
332                        clicked = true;
333                    },
334                    MenuInput::Back => {
335                        events.push(SlotEvents::Close);
336                    },
337                    _ => {},
338                }
339            }
340        }
341
342        // Create available inventory slot widgets
343        if state.ids.item_slots.len() < total_slots {
344            state.update(|s| {
345                s.ids
346                    .item_slots
347                    .resize(total_slots, &mut ui.widget_id_generator());
348                s.ids
349                    .inv_slot_names
350                    .resize(total_slots, &mut ui.widget_id_generator());
351                s.ids
352                    .inv_slot_amounts
353                    .resize(total_slots, &mut ui.widget_id_generator());
354            });
355        }
356
357        // Determine the range of inventory slots that are provided by the loadout item
358        // that the mouse is over
359        let mouseover_loadout_slots = self
360            .slot_manager
361            .mouse_over_slot
362            .and_then(|x| {
363                if let SlotKind::Equip(e) = x {
364                    self.inventory.get_slot_range_for_equip_slot(e)
365                } else {
366                    None
367                }
368            })
369            .unwrap_or(0usize..0usize);
370
371        // Display inventory contents
372        let mut slot_maker = SlotMaker {
373            empty_slot: self.imgs.inv_slot,
374            hovered_slot: self.imgs.skillbar_index,
375            filled_slot: self.imgs.inv_slot,
376            selected_slot: self.imgs.inv_slot_sel,
377            background_color: Some(UI_MAIN),
378            content_size: ContentSize {
379                width_height_ratio: 1.0,
380                max_fraction: 0.75,
381            },
382            selected_content_scale: 1.067,
383            amount_font: self.fonts.cyri.conrod_id,
384            amount_margins: Vec2::new(-4.0, 0.0),
385            amount_font_size: self.fonts.cyri.scale(12),
386            amount_text_color: TEXT_COLOR,
387            content_source: self.inventory,
388            image_source: self.item_imgs,
389            slot_manager: Some(self.slot_manager),
390            global_state: self.global_state,
391            pulse: self.pulse,
392        };
393
394        for (i, (pos, item)) in items.into_iter().enumerate() {
395            if self.details_mode && !self.is_us && item.is_none() {
396                continue;
397            }
398            let (x, y) = if self.details_mode {
399                (0, i)
400            } else {
401                (i % self.columns, i / self.columns)
402            };
403
404            // Inventory slot details
405            let x_pos = (x as f64 * (self.slot_size + self.spacing)).floor();
406            let y_pos = (y as f64 * (self.slot_size + self.spacing)).floor();
407            let inv_slot = InventorySlot {
408                slot: pos,
409                ours: self.is_us,
410                entity: self.entity,
411            };
412
413            // Check if active menu navigation hover
414            let menu_hover = state.active_slot[0] == x
415                && state.active_slot[1] == y // Is it the current slot
416                && selected.is_none() // Is the context menu not open
417                && self.is_focused; // Is focus on the inventory
418
419            let mut slot_widget = slot_maker
420                .fabricate(inv_slot, [self.slot_size as f32; 2], menu_hover, clicked)
421                .top_left_with_margins_on(
422                    id,
423                    // Decimal values might cause pixel mismatches between slots, use floor to try
424                    // to avoid this
425                    (y as f64 * (self.slot_size + self.spacing)).floor(),
426                    (x as f64 * (self.slot_size + self.spacing)).floor(),
427                );
428
429            // Highlight slots are provided by the loadout item (bag) that the mouse is over
430            if self.filter == TabFilters::None && mouseover_loadout_slots.contains(&i) {
431                slot_widget = slot_widget.with_background_color(Color::Rgba(1.0, 1.0, 1.0, 1.0));
432            }
433
434            if self.show_salvage && item.as_ref().is_some_and(|item| item.is_salvageable()) {
435                slot_widget = slot_widget.with_background_color(Color::Rgba(1.0, 1.0, 1.0, 1.0));
436            }
437
438            // Highlight in red the slots that are overflow
439            if matches!(pos, Slot::Overflow(_)) {
440                slot_widget = slot_widget.with_background_color(Color::Rgba(1.0, 0.0, 0.0, 1.0));
441            }
442
443            if let Some(item) = item {
444                let quality_col_img = match item.quality() {
445                    Quality::Low => self.imgs.inv_slot_grey,
446                    Quality::Common => self.imgs.inv_slot_common,
447                    Quality::Moderate => self.imgs.inv_slot_green,
448                    Quality::High => self.imgs.inv_slot_blue,
449                    Quality::Epic => self.imgs.inv_slot_purple,
450                    Quality::Legendary => self.imgs.inv_slot_gold,
451                    Quality::Artifact => self.imgs.inv_slot_orange,
452                    _ => self.imgs.inv_slot_red,
453                };
454
455                let prices_info = self
456                    .client
457                    .pending_trade()
458                    .as_ref()
459                    .and_then(|(_, _, prices)| prices.clone());
460
461                if self.show_salvage && item.is_salvageable() {
462                    let salvage_result: Vec<_> = item
463                        .salvage_output()
464                        .map(|(material_id, _)| Arc::<ItemDef>::load_expect_cloned(material_id))
465                        .map(|item| item as Arc<dyn ItemDesc>)
466                        .collect();
467
468                    let salvage_items = salvage_result
469                        .iter()
470                        .map(|item| item.borrow())
471                        .chain(core::iter::once(item as &dyn ItemDesc));
472
473                    slot_widget
474                        .filled_slot(quality_col_img)
475                        .with_item_tooltip(
476                            self.item_tooltip_manager,
477                            salvage_items,
478                            &prices_info,
479                            self.item_tooltip,
480                        )
481                        .set(state.ids.item_slots[i], ui);
482                } else {
483                    slot_widget
484                        .filled_slot(quality_col_img)
485                        .with_item_tooltip(
486                            self.item_tooltip_manager,
487                            core::iter::once(item as &dyn ItemDesc),
488                            &prices_info,
489                            self.item_tooltip,
490                        )
491                        .set(state.ids.item_slots[i], ui);
492                }
493                if self.details_mode {
494                    let (name, _) = util::item_text(item, self.localized_strings, self.item_i18n);
495                    // TODO: text is not aligned with list mode icons, need to fix
496                    Text::new(&name)
497                        .top_left_with_margins_on(
498                            id,
499                            0.0 + y as f64 * self.slot_size,
500                            30.0 + x as f64 * self.slot_size,
501                        )
502                        .font_id(self.fonts.cyri.conrod_id)
503                        .font_size(self.fonts.cyri.scale(14))
504                        .color(color::WHITE)
505                        .set(state.ids.inv_slot_names[i], ui);
506
507                    let col = self.columns;
508                    let size = self.columns;
509                    let space = self.spacing as usize;
510                    let current_width = ((col * size) + ((col - 1) * space)) as f64;
511                    Text::new(&format!("{}", item.amount()))
512                        .top_left_with_margins_on(
513                            id,
514                            0.0 + y as f64 * self.slot_size,
515                            current_width - 40.0_f64 * self.slot_size,
516                        )
517                        .font_id(self.fonts.cyri.conrod_id)
518                        .font_size(self.fonts.cyri.scale(14))
519                        .color(color::WHITE)
520                        .set(state.ids.inv_slot_amounts[i], ui);
521                }
522            } else {
523                slot_widget.set(state.ids.item_slots[i], ui);
524            }
525
526            // Record the position and details of any selected slot
527            if selected == Some(inv_slot.into()) {
528                state.update(|s| {
529                    s.active_context_slot = selected;
530                    let menu_width = 130.0;
531                    let offset = if x < self.columns / 2 {
532                        self.slot_size // Place to the right
533                    } else {
534                        -menu_width // Place to the left
535                    };
536                    s.context_menu_pos = [x_pos + offset, y_pos];
537                });
538            }
539        }
540
541        // Add padding beneath the last row of items to make scrolling feel more natural
542        if !state.ids.item_slots.is_empty() {
543            Rectangle::fill_with([1.0, 15.0], color::TRANSPARENT)
544                .down_from(state.ids.item_slots[state.ids.item_slots.len() - 1], 0.0)
545                .set(state.ids.spacing_below, ui);
546        }
547
548        // Not exactly an event, but I have to return the filtered list size somehow
549        events.push(SlotEvents::FilteredSize(total_slots));
550
551        // Open context menu if any slot is selected
552        if state.active_context_slot.is_some() {
553            let context_use = self.localized_strings.get_msg("hud-context-menu-use");
554            // add `Move` context action
555            // add `Split` context action
556            let context_drop = self.localized_strings.get_msg("hud-context-menu-drop");
557            let context_cancel = self.localized_strings.get_msg("hud-context-menu-cancel");
558
559            let actions = [context_use, context_drop, context_cancel];
560            // TODO: instead of storing [x,y] coordinates, consider storing the widget id
561            let [x, y] = state.context_menu_pos;
562            let total_h = (actions.len() as f64 * 25.0) + ((actions.len() as f64 + 1.0) * 2.0);
563
564            let event = ContextMenu::new(
565                self.global_state,
566                &actions,
567                self.fonts,
568                self.imgs,
569                self.menu_events,
570            )
571            .top_left_with_margins_on(id, y, x)
572            .w_h(130.0, total_h)
573            .set(state.ids.context_menu, ui);
574
575            if let Some(index) = event {
576                match index {
577                    0 => self.slot_manager.use_selected(),
578                    1 => self.slot_manager.dropped_selected(),
579                    2 => self.slot_manager.idle(),
580                    _ => self.slot_manager.idle(),
581                }
582
583                state.update(|s| s.active_context_slot = None);
584            }
585        }
586
587        events
588    }
589}
590
591#[derive(WidgetCommon)]
592struct ContextMenu<'a, T: 'a + AsRef<str>> {
593    #[conrod(common_builder)]
594    common: widget::CommonBuilder,
595    global_state: &'a GlobalState,
596    actions: &'a [T],
597    fonts: &'a Fonts,
598    imgs: &'a Imgs,
599    menu_events: &'a Vec<MenuInput>,
600}
601
602widget_ids! {
603    struct ContextMenuIds {
604        bg,
605        buttons[],
606    }
607}
608
609struct ContextState {
610    ids: ContextMenuIds,
611
612    active_slot: usize,
613}
614
615impl<'a, T: AsRef<str>> ContextMenu<'a, T> {
616    fn new(
617        global_state: &'a GlobalState,
618        actions: &'a [T],
619        fonts: &'a Fonts,
620        imgs: &'a Imgs,
621        menu_events: &'a Vec<MenuInput>,
622    ) -> Self {
623        ContextMenu {
624            common: widget::CommonBuilder::default(),
625            global_state,
626            actions,
627            fonts,
628            imgs,
629            menu_events,
630        }
631    }
632}
633
634impl<'a, T: AsRef<str>> Widget for ContextMenu<'a, T> {
635    type Event = Option<usize>;
636    type State = ContextState;
637    type Style = ();
638
639    fn init_state(&self, id_gen: widget::id::Generator) -> Self::State {
640        ContextState {
641            ids: ContextMenuIds::new(id_gen),
642            active_slot: 0,
643        }
644    }
645
646    fn style(&self) -> Self::Style {}
647
648    fn update(self, args: widget::UpdateArgs<Self>) -> Self::Event {
649        let widget::UpdateArgs {
650            id,
651            state,
652            ui,
653            rect,
654            ..
655        } = args;
656        let mut clicked_index = None;
657
658        let item_h = 25.0;
659        let spacing = 2.0;
660        let actions_len = self.actions.len();
661
662        // MENU INPUTS: navigate up and down the list
663        // Up: go up an item (wrap?)
664        // Down: go down an item (wrap?)
665        // Apply: select the current list item
666        // Back: close the context menu
667        let mut clicked = false;
668        for event in self.menu_events {
669            match *event {
670                MenuInput::Up => state.update(|s| {
671                    let y = s.active_slot;
672                    if y > 0 {
673                        s.active_slot = y - 1;
674                    }
675                }),
676                MenuInput::Down => state.update(|s| {
677                    let y = s.active_slot;
678                    if y < actions_len - 1 {
679                        s.active_slot = y + 1;
680                    }
681                }),
682                MenuInput::Apply => {
683                    clicked = true;
684                },
685                MenuInput::Back => {
686                    // Assume the last selection is `Close` for now
687                    clicked_index = Some(actions_len - 1);
688                },
689                _ => {},
690            }
691        }
692
693        // Draw background
694        Rectangle::fill_with(rect.dim(), Color::Rgba(0.2, 0.2, 0.2, 0.99))
695            .middle_of(id)
696            .set(state.ids.bg, ui);
697
698        if state.ids.buttons.len() < actions_len {
699            state.update(|s| {
700                s.ids
701                    .buttons
702                    .resize(actions_len, &mut ui.widget_id_generator());
703            });
704        }
705
706        // Position buttons
707        for (i, label) in self.actions.iter().enumerate() {
708            let btn_id = state.ids.buttons[i];
709            let active_btn = state.active_slot == i;
710            let btn = Button::image(self.imgs.nothing)
711                .color(color::BLACK)
712                .border(20.0)
713                .border_color(
714                    if active_btn && self.global_state.window.last_input_type_menu() {
715                        color::YELLOW
716                    } else {
717                        color::WHITE
718                    }
719                )
720                .label(label.as_ref())
721                .label_font_size(self.fonts.cyri.scale(12))
722                .label_font_id(self.fonts.cyri.conrod_id)
723                .label_color(
724                    if active_btn && self.global_state.window.last_input_type_menu() {
725                        color::YELLOW
726                    } else {
727                        color::WHITE
728                    }
729                )
730                .hover_image(self.imgs.selection_hover) // Puts a border around the button
731                .press_image(self.imgs.selection_press)
732                .image_color(color::rgba(1.0, 0.82, 0.27, 1.0))
733                .h(item_h)
734                .w(rect.w() - (spacing * 2.0))
735                .parent(state.ids.bg);
736
737            let placed_btn = if i == 0 {
738                btn.mid_top_with_margin_on(state.ids.bg, spacing)
739            } else {
740                btn.down_from(state.ids.buttons[i - 1], spacing)
741            };
742
743            if placed_btn.set(btn_id, ui).was_clicked() || (clicked && active_btn) {
744                clicked_index = Some(i);
745            }
746        }
747
748        clicked_index
749    }
750}