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