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