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