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