veloren_voxygen/hud/
social.rs

1use super::{
2    Show, TEXT_COLOR, TEXT_COLOR_3, UI_HIGHLIGHT_0, UI_MAIN,
3    img_ids::{Imgs, ImgsRot},
4};
5use crate::{
6    GlobalState,
7    ui::{ImageFrame, Tooltip, TooltipManager, Tooltipable, fonts::Fonts},
8};
9use client::{self, Client};
10use common::{comp::group, resources::BattleMode, uid::Uid};
11use conrod_core::{
12    Color, Colorable, Labelable, Positionable, Sizeable, Widget, WidgetCommon, color,
13    widget::{self, Button, Image, Rectangle, Scrollbar, Text, TextEdit},
14    widget_ids,
15};
16use i18n::Localization;
17use itertools::Itertools;
18use std::time::Instant;
19
20widget_ids! {
21    pub struct Ids {
22        frame,
23        close,
24        title_align,
25        title,
26        bg,
27        icon,
28        scrollbar,
29        online_align,
30        player_names[],
31        player_pvp_icons[],
32        player_rows[],
33        player_mod_badges[],
34        online_txt,
35        online_no,
36        invite_button,
37        player_search_icon,
38        player_search_input,
39        player_search_input_bg,
40        player_search_input_overlay,
41        pvp_button_on,
42        pvp_button_off,
43    }
44}
45
46pub struct State {
47    ids: Ids,
48    // Holds the time when selection is made since this selection can be overridden
49    // by selecting an entity in-game
50    selected_uid: Option<(Uid, Instant)>,
51}
52
53#[derive(WidgetCommon)]
54pub struct Social<'a> {
55    show: &'a Show,
56    client: &'a Client,
57    imgs: &'a Imgs,
58    fonts: &'a Fonts,
59    localized_strings: &'a Localization,
60    selected_entity: Option<(specs::Entity, Instant)>,
61    rot_imgs: &'a ImgsRot,
62    tooltip_manager: &'a mut TooltipManager,
63    global_state: &'a GlobalState,
64
65    #[conrod(common_builder)]
66    common: widget::CommonBuilder,
67}
68
69impl<'a> Social<'a> {
70    pub fn new(
71        show: &'a Show,
72        client: &'a Client,
73        imgs: &'a Imgs,
74        fonts: &'a Fonts,
75        localized_strings: &'a Localization,
76        selected_entity: Option<(specs::Entity, Instant)>,
77        rot_imgs: &'a ImgsRot,
78        tooltip_manager: &'a mut TooltipManager,
79        global_state: &'a GlobalState,
80    ) -> Self {
81        Self {
82            show,
83            client,
84            imgs,
85            rot_imgs,
86            fonts,
87            localized_strings,
88            tooltip_manager,
89            selected_entity,
90            common: widget::CommonBuilder::default(),
91            global_state,
92        }
93    }
94}
95
96pub enum Event {
97    Close,
98    Invite(Uid),
99    Focus(widget::Id),
100    SearchPlayers(Option<String>),
101    SetBattleMode(BattleMode),
102}
103
104impl Widget for Social<'_> {
105    type Event = Vec<Event>;
106    type State = State;
107    type Style = ();
108
109    fn init_state(&self, id_gen: widget::id::Generator) -> Self::State {
110        Self::State {
111            ids: Ids::new(id_gen),
112            selected_uid: None,
113        }
114    }
115
116    fn style(&self) -> Self::Style {}
117
118    fn update(self, args: widget::UpdateArgs<Self>) -> Self::Event {
119        common_base::prof_span!("Social::update");
120        let battle_mode = self.client.get_battle_mode();
121        let widget::UpdateArgs { state, ui, .. } = args;
122        let mut events = Vec::new();
123        let button_tooltip = Tooltip::new({
124            // Edge images [t, b, r, l]
125            // Corner images [tr, tl, br, bl]
126            let edge = &self.rot_imgs.tt_side;
127            let corner = &self.rot_imgs.tt_corner;
128            ImageFrame::new(
129                [edge.cw180, edge.none, edge.cw270, edge.cw90],
130                [corner.none, corner.cw270, corner.cw90, corner.cw180],
131                Color::Rgba(0.08, 0.07, 0.04, 1.0),
132                5.0,
133            )
134        })
135        .title_font_size(self.fonts.cyri.scale(15))
136        .parent(ui.window)
137        .desc_font_size(self.fonts.cyri.scale(12))
138        .font_id(self.fonts.cyri.conrod_id)
139        .desc_text_color(TEXT_COLOR);
140
141        // Window BG
142        Image::new(self.imgs.social_bg_on)
143            .bottom_left_with_margins_on(ui.window, 308.0, 25.0)
144            .color(Some(UI_MAIN))
145            .w_h(310.0, 460.0)
146            .set(state.ids.bg, ui);
147        // Window frame
148        Image::new(self.imgs.social_frame_on)
149            .middle_of(state.ids.bg)
150            .color(Some(UI_HIGHLIGHT_0))
151            .w_h(310.0, 460.0)
152            .set(state.ids.frame, ui);
153
154        // Icon
155        Image::new(self.imgs.social)
156            .w_h(30.0, 30.0)
157            .top_left_with_margins_on(state.ids.frame, 6.0, 6.0)
158            .set(state.ids.icon, ui);
159        // X-Button
160        if Button::image(self.imgs.close_button)
161            .w_h(24.0, 25.0)
162            .hover_image(self.imgs.close_button_hover)
163            .press_image(self.imgs.close_button_press)
164            .top_right_with_margins_on(state.ids.frame, 0.0, 0.0)
165            .set(state.ids.close, ui)
166            .was_clicked()
167        {
168            events.push(Event::Close);
169        }
170
171        // Title
172        Rectangle::fill_with([212.0, 42.0], color::TRANSPARENT)
173            .top_left_with_margins_on(state.ids.frame, 2.0, 44.0)
174            .set(state.ids.title_align, ui);
175        Text::new(&self.localized_strings.get_msg("hud-social"))
176            .middle_of(state.ids.title_align)
177            .font_id(self.fonts.cyri.conrod_id)
178            .font_size(self.fonts.cyri.scale(20))
179            .color(TEXT_COLOR)
180            .set(state.ids.title, ui);
181
182        let players = self
183            .client
184            .player_list()
185            .iter()
186            .filter(|(_, p)| p.is_online);
187        let player_count = players.clone().count();
188
189        // Content Alignment
190        Rectangle::fill_with([310.0, 346.0], color::TRANSPARENT)
191            .mid_top_with_margin_on(state.ids.frame, 74.0)
192            .scroll_kids_vertically()
193            .set(state.ids.online_align, ui);
194        Scrollbar::y_axis(state.ids.online_align)
195            .thickness(4.0)
196            .color(Color::Rgba(0.79, 1.09, 1.09, 0.0))
197            .set(state.ids.scrollbar, ui);
198
199        // Online Text
200        Text::new(&self.localized_strings.get_msg("hud-social-online"))
201            .bottom_left_with_margins_on(state.ids.frame, 18.0, 10.0)
202            .font_id(self.fonts.cyri.conrod_id)
203            .font_size(self.fonts.cyri.scale(14))
204            .color(TEXT_COLOR)
205            .set(state.ids.online_txt, ui);
206        Text::new(&player_count.to_string())
207            .right_from(state.ids.online_txt, 5.0)
208            .font_id(self.fonts.cyri.conrod_id)
209            .font_size(self.fonts.cyri.scale(14))
210            .color(TEXT_COLOR)
211            .set(state.ids.online_no, ui);
212        // Adjust widget_id struct vec length to player count
213        if state.ids.player_names.len() < player_count {
214            state.update(|s| {
215                s.ids
216                    .player_rows
217                    .resize(player_count, &mut ui.widget_id_generator());
218                s.ids
219                    .player_names
220                    .resize(player_count, &mut ui.widget_id_generator());
221                s.ids
222                    .player_pvp_icons
223                    .resize(player_count, &mut ui.widget_id_generator());
224                s.ids
225                    .player_mod_badges
226                    .resize(player_count, &mut ui.widget_id_generator());
227            })
228        };
229
230        // Filter out yourself from the online list and perform search
231        let my_uid = self.client.uid();
232        let mut player_list = players
233            .filter(|(uid, _)| Some(**uid) != my_uid)
234            .filter(|(_, player)| {
235                self.show
236                    .social_search_key
237                    .as_ref()
238                    .map(|search_key| {
239                        search_key
240                            .to_lowercase()
241                            .split_whitespace()
242                            .all(|substring| {
243                                let player_alias = &player.player_alias.to_lowercase();
244                                let character_name = player.character.as_ref().map(|character| {
245                                    self.localized_strings
246                                        .get_content(&character.name)
247                                        .to_lowercase()
248                                });
249                                player_alias.contains(substring)
250                                    || character_name
251                                        .map(|cn| cn.contains(substring))
252                                        .unwrap_or(false)
253                            })
254                    })
255                    .unwrap_or(true)
256            })
257            .collect_vec();
258        player_list.sort_by_key(|(_, player)| {
259            // hoist `localized` up to manually extend the lifetime
260            let localized;
261            let name = if let Some(character) = player.character.as_ref() {
262                localized = self.localized_strings.get_content(&character.name);
263                &localized
264            } else {
265                &player.player_alias
266            };
267            name.to_lowercase()
268        });
269        for (i, (&uid, player_info)) in player_list.into_iter().enumerate() {
270            let hide_username = true;
271            let selected = state.selected_uid.is_some_and(|u| u.0 == uid);
272            let alias = &player_info.player_alias;
273            let name_text = match &player_info.character {
274                Some(character) => {
275                    if hide_username {
276                        self.localized_strings.get_content(&character.name)
277                    } else {
278                        format!(
279                            "[{}] {}",
280                            alias,
281                            self.localized_strings.get_content(&character.name)
282                        )
283                    }
284                },
285                None => format!(
286                    "{} [{}]",
287                    alias,
288                    self.localized_strings.get_msg("hud-group-in_menu")
289                ), // character select or spectating
290            };
291            let name_text_length_limited = if name_text.chars().count() > 29 {
292                format!("{}...", name_text.chars().take(26).collect::<String>())
293            } else {
294                name_text
295            };
296            let acc_name_txt = format!(
297                "{}: {}",
298                &self.localized_strings.get_msg("hud-social-account"),
299                alias
300            );
301            // Player name widget
302            let button = Button::image(if !selected {
303                self.imgs.nothing
304            } else {
305                self.imgs.selection
306            })
307            .hover_image(if selected {
308                self.imgs.selection
309            } else {
310                self.imgs.selection_hover
311            })
312            .press_image(if selected {
313                self.imgs.selection
314            } else {
315                self.imgs.selection_press
316            })
317            .w_h(256.0, 20.0)
318            .image_color(color::rgba(1.0, 0.82, 0.27, 1.0));
319            let button = if i == 0 {
320                button.mid_top_with_margin_on(state.ids.online_align, 1.0)
321            } else {
322                button.down_from(state.ids.player_names[i - 1], 1.0)
323            };
324            if button
325                .label(&name_text_length_limited)
326                .label_font_size(self.fonts.cyri.scale(14))
327                .label_y(conrod_core::position::Relative::Scalar(1.0))
328                .label_font_id(self.fonts.cyri.conrod_id)
329                .label_color(TEXT_COLOR)
330                .depth(1.0)
331                .with_tooltip(
332                    self.tooltip_manager,
333                    &acc_name_txt,
334                    "",
335                    &button_tooltip,
336                    TEXT_COLOR,
337                )
338                .set(state.ids.player_names[i], ui)
339                .was_clicked()
340            {
341                state.update(|s| s.selected_uid = Some((uid, Instant::now())));
342            }
343
344            // Player name row background
345            if i % 2 != 0 {
346                Rectangle::fill_with(
347                    [300.0, 20.0],
348                    color::rgba(
349                        1.0,
350                        1.0,
351                        1.0,
352                        self.global_state.settings.interface.row_background_opacity,
353                    ),
354                )
355                .middle_of(state.ids.player_names[i])
356                .depth(2.0)
357                .set(state.ids.player_rows[i], ui);
358            }
359
360            // Moderator Badge
361            if player_info.is_moderator {
362                Image::new(self.imgs.chat_moderator_badge)
363                    .w_h(20.0, 20.0)
364                    .right_from(state.ids.player_names[i], 0.0)
365                    .with_tooltip(
366                        self.tooltip_manager,
367                        "",
368                        "This player is a moderator.",
369                        &button_tooltip,
370                        TEXT_COLOR,
371                    )
372                    .set(state.ids.player_mod_badges[i], ui);
373            }
374
375            // PvP Icon
376            if player_info
377                .character
378                .as_ref()
379                .is_some_and(|character_info| matches!(character_info.battle_mode, BattleMode::PvP))
380            {
381                Image::new(self.imgs.player_pvp)
382                    .w_h(20.0, 20.0)
383                    .left_from(state.ids.player_names[i], 0.0)
384                    .with_tooltip(
385                        self.tooltip_manager,
386                        "",
387                        "This player has PvP enabled.",
388                        &button_tooltip,
389                        TEXT_COLOR,
390                    )
391                    .set(state.ids.player_pvp_icons[i], ui);
392            }
393        }
394
395        // Invite Button
396        let is_leader_or_not_in_group = self
397            .client
398            .group_info()
399            .is_none_or(|(_, l_uid)| self.client.uid() == Some(l_uid));
400
401        let current_members = self
402            .client
403            .group_members()
404            .iter()
405            .filter(|(_, role)| matches!(role, group::Role::Member))
406            .count()
407            + 1;
408        let current_invites = self.client.pending_invites().len();
409        let max_members = self.client.max_group_size() as usize;
410        let group_not_full = current_members + current_invites < max_members;
411        let selected_to_invite = (is_leader_or_not_in_group && group_not_full)
412            .then(|| {
413                state
414                    .selected_uid
415                    .as_ref()
416                    .map(|(s, _)| *s)
417                    .filter(|selected| {
418                        self.client
419                            .player_list()
420                            .get(selected)
421                            .is_some_and(|selected_player| {
422                                selected_player.is_online && selected_player.character.is_some()
423                            })
424                    })
425                    .or_else(|| {
426                        self.selected_entity
427                            .and_then(|s| self.client.state().read_component_copied(s.0))
428                    })
429                    .filter(|selected| {
430                        // Prevent inviting entities already in the same group
431                        !self.client.group_members().contains_key(selected)
432                    })
433            })
434            .flatten();
435
436        let invite_text = self.localized_strings.get_msg("hud-group-invite");
437        let invite_button = Button::image(self.imgs.button)
438            .w_h(106.0, 26.0)
439            .left_from(
440                match battle_mode {
441                    BattleMode::PvE => state.ids.pvp_button_off,
442                    BattleMode::PvP => state.ids.pvp_button_on,
443                },
444                5.0,
445            )
446            .hover_image(if selected_to_invite.is_some() {
447                self.imgs.button_hover
448            } else {
449                self.imgs.button
450            })
451            .press_image(if selected_to_invite.is_some() {
452                self.imgs.button_press
453            } else {
454                self.imgs.button
455            })
456            .label(&invite_text)
457            .label_y(conrod_core::position::Relative::Scalar(3.0))
458            .label_color(if selected_to_invite.is_some() {
459                TEXT_COLOR
460            } else {
461                TEXT_COLOR_3
462            })
463            .image_color(if selected_to_invite.is_some() {
464                TEXT_COLOR
465            } else {
466                TEXT_COLOR_3
467            })
468            .label_font_size(self.fonts.cyri.scale(15))
469            .label_font_id(self.fonts.cyri.conrod_id);
470
471        if if self.client.group_info().is_some() {
472            let tooltip_txt = format!(
473                "{}/{} {}",
474                current_members + current_invites,
475                max_members,
476                &self.localized_strings.get_msg("hud-group-members")
477            );
478            invite_button
479                .with_tooltip(
480                    self.tooltip_manager,
481                    &tooltip_txt,
482                    "",
483                    &button_tooltip,
484                    TEXT_COLOR,
485                )
486                .set(state.ids.invite_button, ui)
487        } else {
488            invite_button.set(state.ids.invite_button, ui)
489        }
490        .was_clicked()
491            && let Some(uid) = selected_to_invite
492        {
493            events.push(Event::Invite(uid));
494            state.update(|s| {
495                s.selected_uid = None;
496            });
497        }
498
499        // Player Search
500        if Button::image(self.imgs.search_btn)
501            .top_left_with_margins_on(state.ids.frame, 54.0, 9.0)
502            .hover_image(self.imgs.search_btn_hover)
503            .press_image(self.imgs.search_btn_press)
504            .w_h(16.0, 16.0)
505            .set(state.ids.player_search_icon, ui)
506            .was_clicked()
507        {
508            events.push(Event::Focus(state.ids.player_search_input));
509        }
510        Rectangle::fill([248.0, 20.0])
511            .top_left_with_margins_on(state.ids.player_search_icon, -2.0, 18.0)
512            .hsla(0.0, 0.0, 0.0, 0.7)
513            .depth(1.0)
514            .parent(state.ids.bg)
515            .set(state.ids.player_search_input_bg, ui);
516        if let Some(string) =
517            TextEdit::new(self.show.social_search_key.as_deref().unwrap_or_default())
518                .top_left_with_margins_on(state.ids.player_search_icon, -1.0, 22.0)
519                .w_h(215.0, 20.0)
520                .font_id(self.fonts.cyri.conrod_id)
521                .font_size(self.fonts.cyri.scale(14))
522                .color(TEXT_COLOR)
523                .set(state.ids.player_search_input, ui)
524        {
525            events.push(Event::SearchPlayers(Some(string)));
526        }
527        Rectangle::fill_with([266.0, 20.0], color::TRANSPARENT)
528            .top_left_with_margins_on(state.ids.player_search_icon, -1.0, 0.0)
529            .graphics_for(state.ids.player_search_icon)
530            .set(state.ids.player_search_input_overlay, ui);
531
532        let pvp_tooltip = Tooltip::new({
533            let edge = &self.rot_imgs.tt_side;
534            let corner = &self.rot_imgs.tt_corner;
535            ImageFrame::new(
536                [edge.cw180, edge.none, edge.cw270, edge.cw90],
537                [corner.none, corner.cw270, corner.cw90, corner.cw180],
538                Color::Rgba(0.08, 0.07, 0.04, 1.0),
539                5.0,
540            )
541        })
542        .title_font_size(self.fonts.cyri.scale(15))
543        .parent(ui.window)
544        .desc_font_size(self.fonts.cyri.scale(12))
545        .font_id(self.fonts.cyri.conrod_id)
546        .desc_text_color(TEXT_COLOR);
547
548        // PvP Toggle Button
549        match battle_mode {
550            BattleMode::PvE => {
551                if Button::image(self.imgs.pvp_off)
552                    .w_h(26.0, 26.0)
553                    .bottom_right_with_margins_on(state.ids.frame, 9.0, 7.0)
554                    .with_tooltip(
555                        self.tooltip_manager,
556                        "",
557                        "PvP is off. Click to enable PvP.",
558                        &pvp_tooltip,
559                        TEXT_COLOR,
560                    )
561                    .set(state.ids.pvp_button_off, ui)
562                    .was_clicked()
563                {
564                    events.push(Event::SetBattleMode(BattleMode::PvP))
565                };
566            },
567            BattleMode::PvP => {
568                if Button::image(self.imgs.pvp_on)
569                    .w_h(26.0, 26.0)
570                    .bottom_right_with_margins_on(state.ids.frame, 9.0, 7.0)
571                    .with_tooltip(
572                        self.tooltip_manager,
573                        "",
574                        "PvP is on. Click to disable PvP.",
575                        &pvp_tooltip,
576                        TEXT_COLOR,
577                    )
578                    .set(state.ids.pvp_button_on, ui)
579                    .was_clicked()
580                {
581                    events.push(Event::SetBattleMode(BattleMode::PvE))
582                }
583            },
584        }
585
586        events
587    }
588}