veloren_voxygen/hud/
chat.rs

1use super::{
2    ChatTab, ERROR_COLOR, FACTION_COLOR, GROUP_COLOR, INFO_COLOR, KILL_COLOR, OFFLINE_COLOR,
3    ONLINE_COLOR, REGION_COLOR, SAY_COLOR, TELL_COLOR, TEXT_COLOR, WORLD_COLOR, img_ids::Imgs,
4};
5use crate::{
6    GlobalState,
7    cmd::complete,
8    settings::chat::MAX_CHAT_TABS,
9    ui::{
10        Scale,
11        fonts::{Font, Fonts},
12    },
13};
14use client::Client;
15use common::comp::{ChatMode, ChatMsg, ChatType, group::Role};
16use conrod_core::{
17    Color, Colorable, Labelable, Positionable, Sizeable, Ui, UiCell, Widget, WidgetCommon, color,
18    input::Key,
19    position::Dimension,
20    text::{
21        self,
22        cursor::{self, Index},
23    },
24    widget::{self, Button, Id, Image, Line, List, Rectangle, Text, TextEdit},
25    widget_ids,
26};
27use i18n::Localization;
28use i18n_helpers::localize_chat_message;
29use std::collections::{HashSet, VecDeque};
30use vek::{Vec2, approx::AbsDiffEq};
31
32widget_ids! {
33    struct Ids {
34        draggable_area,
35        message_box,
36        message_box_bg,
37        chat_input,
38        chat_input_bg,
39        chat_input_icon,
40        chat_input_border_up,
41        chat_input_border_down,
42        chat_input_border_left,
43        chat_input_border_right,
44        chat_arrow,
45        chat_icon_align,
46        chat_icons[],
47        chat_badges[],
48
49        chat_tab_align,
50        chat_tab_all,
51        chat_tab_selected,
52        chat_tabs[],
53        chat_tab_tooltip_bg,
54        chat_tab_tooltip_text,
55    }
56}
57/*#[const_tweaker::tweak(min = 0.0, max = 60.0, step = 1.0)]
58const X: f64 = 18.0;*/
59
60pub const MAX_MESSAGES: usize = 100;
61
62const CHAT_ICON_WIDTH: f64 = 16.0;
63const CHAT_MARGIN_THICKNESS: f64 = 2.0;
64const CHAT_ICON_HEIGHT: f64 = 16.0;
65const MIN_DIMENSION: Vec2<f64> = Vec2::new(400.0, 150.0);
66const MAX_DIMENSION: Vec2<f64> = Vec2::new(650.0, 500.0);
67
68const CHAT_TAB_HEIGHT: f64 = 20.0;
69const CHAT_TAB_ALL_WIDTH: f64 = 40.0;
70
71#[derive(WidgetCommon)]
72pub struct Chat<'a> {
73    pulse: f32,
74    new_messages: &'a mut VecDeque<ChatMsg>,
75    client: &'a Client,
76    force_input: Option<String>,
77    force_cursor: Option<Index>,
78    force_completions: Option<Vec<String>>,
79
80    global_state: &'a GlobalState,
81    imgs: &'a Imgs,
82    fonts: &'a Fonts,
83
84    #[conrod(common_builder)]
85    common: widget::CommonBuilder,
86
87    // TODO: add an option to adjust this
88    history_max: usize,
89    scale: Scale,
90
91    localized_strings: &'a Localization,
92    clear_messages: bool,
93}
94
95impl<'a> Chat<'a> {
96    pub fn new(
97        new_messages: &'a mut VecDeque<ChatMsg>,
98        client: &'a Client,
99        global_state: &'a GlobalState,
100        pulse: f32,
101        imgs: &'a Imgs,
102        fonts: &'a Fonts,
103        localized_strings: &'a Localization,
104        scale: Scale,
105        clear_messages: bool,
106    ) -> Self {
107        Self {
108            pulse,
109            new_messages,
110            client,
111            force_input: None,
112            force_cursor: None,
113            force_completions: None,
114            imgs,
115            fonts,
116            global_state,
117            common: widget::CommonBuilder::default(),
118            history_max: 32,
119            localized_strings,
120            scale,
121            clear_messages,
122        }
123    }
124
125    pub fn prepare_tab_completion(mut self, input: String) -> Self {
126        self.force_completions = if let Some(index) = input.find('\t') {
127            Some(complete(
128                &input[..index],
129                self.client,
130                &self.global_state.settings.chat.chat_cmd_prefix.to_string(),
131            ))
132        } else {
133            None
134        };
135        self
136    }
137
138    pub fn input(mut self, input: String) -> Self {
139        self.force_input = Some(input);
140        self
141    }
142
143    pub fn cursor_pos(mut self, index: Index) -> Self {
144        self.force_cursor = Some(index);
145        self
146    }
147
148    pub fn scrolled_to_bottom(state: &State, ui: &UiCell) -> bool {
149        // Might be more efficient to cache result and update it when a scroll event has
150        // occurred instead of every frame.
151        if let Some(scroll) = ui
152            .widget_graph()
153            .widget(state.ids.message_box)
154            .and_then(|widget| widget.maybe_y_scroll_state)
155        {
156            scroll.offset + 50.0 >= scroll.offset_bounds.start
157        } else {
158            false
159        }
160    }
161}
162
163struct InputState {
164    message: String,
165    mode: ChatMode,
166}
167
168pub struct State {
169    messages: VecDeque<ChatMsg>,
170    input: InputState,
171    ids: Ids,
172    history: VecDeque<String>,
173    // Index into the history Vec, history_pos == 0 is history not in use
174    // otherwise index is history_pos -1
175    history_pos: usize,
176    completions: Vec<String>,
177    // Index into the completion Vec
178    completions_index: Option<usize>,
179    // At which character is tab completion happening
180    completion_cursor: Option<usize>,
181    // last time mouse has been hovered
182    tabs_last_hover_pulse: Option<f32>,
183    // last chat_tab (used to see if chat tab has been changed)
184    prev_chat_tab: Option<ChatTab>,
185    //whether or not a scroll action is queued
186    scroll_next: bool,
187}
188
189pub enum Event {
190    TabCompletionStart(String),
191    SendMessage(String),
192    SendCommand(String, Vec<String>),
193    Focus(Id),
194    ChangeChatTab(Option<usize>),
195    ShowChatTabSettings(usize),
196    ResizeChat(Vec2<f64>),
197    MoveChat(Vec2<f64>),
198    DisableForceChat,
199}
200
201impl Widget for Chat<'_> {
202    type Event = Vec<Event>;
203    type State = State;
204    type Style = ();
205
206    fn init_state(&self, id_gen: widget::id::Generator) -> Self::State {
207        State {
208            input: InputState {
209                message: "".to_owned(),
210                mode: ChatMode::default(),
211            },
212            messages: VecDeque::new(),
213            history: VecDeque::new(),
214            history_pos: 0,
215            completions: Vec::new(),
216            completions_index: None,
217            completion_cursor: None,
218            ids: Ids::new(id_gen),
219            tabs_last_hover_pulse: None,
220            prev_chat_tab: None,
221            scroll_next: false,
222        }
223    }
224
225    fn style(&self) -> Self::Style {}
226
227    fn update(self, args: widget::UpdateArgs<Self>) -> Self::Event {
228        fn adjust_border_opacity(color: Color, opacity: f32) -> Color {
229            match color {
230                Color::Rgba(r, g, b, a) => Color::Rgba(r, g, b, (a + opacity) / 2.0),
231                _ => panic!("Color input should be Rgba, instead found: {:?}", color),
232            }
233        }
234        common_base::prof_span!("Chat::update");
235
236        let widget::UpdateArgs { id, state, ui, .. } = args;
237
238        let mut events = Vec::new();
239
240        let chat_settings = &self.global_state.settings.chat;
241        let force_chat = !(&self.global_state.settings.interface.toggle_chat);
242        let chat_tabs = &chat_settings.chat_tabs;
243        let current_chat_tab = chat_settings.chat_tab_index.and_then(|i| chat_tabs.get(i));
244        let chat_size = Vec2::new(chat_settings.chat_size_x, chat_settings.chat_size_y);
245        let chat_pos = Vec2::new(chat_settings.chat_pos_x, chat_settings.chat_pos_y);
246        let chat_box_input_width = chat_size.x - CHAT_ICON_WIDTH - 12.0;
247
248        if self.clear_messages {
249            state.update(|s| s.messages.clear());
250        }
251
252        // Empty old messages
253        state.update(|s| {
254            while s.messages.len() > MAX_MESSAGES {
255                s.messages.pop_front();
256            }
257        });
258
259        let chat_in_screen_upper = chat_pos.y > self.global_state.window.logical_size().y / 2.0;
260
261        let pos_delta: Vec2<f64> = ui
262            .widget_input(state.ids.draggable_area)
263            .drags()
264            .left()
265            .map(|drag| Vec2::<f64>::from(drag.delta_xy))
266            .sum();
267        let new_pos = (chat_pos + pos_delta).map(|e| e.max(0.)).map2(
268            self.scale
269                .scale_point(self.global_state.window.logical_size())
270                - Vec2::unit_y() * CHAT_TAB_HEIGHT
271                - chat_size,
272            |e, bounds| e.min(bounds),
273        );
274        if new_pos.abs_diff_ne(&chat_pos, f64::EPSILON) {
275            events.push(Event::MoveChat(new_pos));
276        }
277        let size_delta: Vec2<f64> = ui
278            .widget_input(state.ids.draggable_area)
279            .drags()
280            .right()
281            .map(|drag| Vec2::<f64>::from(drag.delta_xy))
282            .sum();
283        let new_size = (chat_size + size_delta)
284            .map3(
285                self.scale.scale_point(MIN_DIMENSION),
286                self.scale.scale_point(MAX_DIMENSION),
287                |sz, min, max| sz.clamp(min, max),
288            )
289            .map2(
290                self.scale
291                    .scale_point(self.global_state.window.logical_size())
292                    - Vec2::unit_y() * CHAT_TAB_HEIGHT
293                    - new_pos,
294                |e, bounds| e.min(bounds),
295            );
296        if new_size.abs_diff_ne(&chat_size, f64::EPSILON) {
297            events.push(Event::ResizeChat(new_size));
298        }
299
300        // Maintain scrolling //
301        if !self.new_messages.is_empty() {
302            for message in self.new_messages.iter() {
303                // Log the output of commands since the ingame terminal doesn't support copying
304                // the output to the clipboard
305                if let ChatType::CommandInfo = &message.chat_type {
306                    tracing::info!("Chat command info: {:?}", message.content());
307                }
308            }
309            //new messages - update chat w/ them & scroll down if at bottom of chat
310            state.update(|s| s.messages.extend(self.new_messages.drain(..)));
311            // Prevent automatic scroll upon new messages if not already scrolled to bottom
312            if Self::scrolled_to_bottom(state, ui) {
313                ui.scroll_widget(state.ids.message_box, [0.0, f64::MAX]);
314            }
315        }
316
317        // Trigger scroll event queued from previous frame
318        if state.scroll_next {
319            ui.scroll_widget(state.ids.message_box, [0.0, f64::MAX]);
320            state.update(|s| s.scroll_next = false);
321        }
322
323        // Queue scroll event if switching from a different tab
324        if current_chat_tab != state.prev_chat_tab.as_ref() {
325            state.update(|s| s.prev_chat_tab = current_chat_tab.cloned());
326            state.update(|s| s.scroll_next = true); //make scroll happen only once any filters to the messages have already been applied
327        }
328
329        if let Some(comps) = &self.force_completions {
330            state.update(|s| s.completions.clone_from(comps));
331        }
332
333        let mut force_cursor = self.force_cursor;
334
335        // If up or down are pressed: move through history
336        // If any key other than up, down, or tab is pressed: stop completion.
337        let (history_dir, tab_dir, stop_tab_completion) =
338            ui.widget_input(state.ids.chat_input).presses().key().fold(
339                (0isize, 0isize, false),
340                |(n, m, tc), key_press| match key_press.key {
341                    Key::Up => (n + 1, m - 1, tc),
342                    Key::Down => (n - 1, m + 1, tc),
343                    Key::Tab => (n, m + 1, tc),
344                    _ => (n, m, true),
345                },
346            );
347
348        // Handle tab completion
349        let request_tab_completions = if stop_tab_completion {
350            // End tab completion
351            state.update(|s| {
352                if s.completion_cursor.is_some() {
353                    s.completion_cursor = None;
354                }
355                s.completions_index = None;
356            });
357            false
358        } else if let Some(cursor) = state.completion_cursor {
359            // Cycle through tab completions of the current word
360            if state.input.message.contains('\t') {
361                state.update(|s| s.input.message.retain(|c| c != '\t'));
362                //tab_dir + 1
363            }
364            if !state.completions.is_empty() && (tab_dir != 0 || state.completions_index.is_none())
365            {
366                state.update(|s| {
367                    let len = s.completions.len();
368                    s.completions_index = Some(
369                        (s.completions_index.unwrap_or(0) + (tab_dir + len as isize) as usize)
370                            % len,
371                    );
372                    if let Some(replacement) = &s.completions.get(s.completions_index.unwrap()) {
373                        let (completed, offset) =
374                            do_tab_completion(cursor, &s.input.message, replacement);
375                        force_cursor = cursor_offset_to_index(
376                            offset,
377                            &completed,
378                            ui,
379                            self.fonts,
380                            chat_box_input_width,
381                        );
382                        s.input.message = completed;
383                    }
384                });
385            }
386            false
387        } else if let Some(cursor) = state.input.message.find('\t') {
388            // Begin tab completion
389            state.update(|s| s.completion_cursor = Some(cursor));
390            true
391        } else {
392            // Not tab completing
393            false
394        };
395
396        // Move through history
397        if history_dir != 0 && state.completion_cursor.is_none() {
398            state.update(|s| {
399                if history_dir > 0 {
400                    if s.history_pos < s.history.len() {
401                        s.history_pos += 1;
402                    }
403                } else if s.history_pos > 0 {
404                    s.history_pos -= 1;
405                }
406                if let Some(before) = s.history.iter().nth_back(s.history.len() - s.history_pos) {
407                    s.input.message.clone_from(before);
408                    force_cursor = cursor_offset_to_index(
409                        s.input.message.len(),
410                        &s.input.message,
411                        ui,
412                        self.fonts,
413                        chat_box_input_width,
414                    );
415                } else {
416                    s.input.message.clear();
417                }
418            });
419        }
420
421        let keyboard_capturer = ui.global_input().current.widget_capturing_keyboard;
422
423        if let Some(input) = &self.force_input {
424            state.update(|s| s.input.message = input.to_string());
425        }
426
427        let input_focused =
428            keyboard_capturer == Some(state.ids.chat_input) || keyboard_capturer == Some(id);
429
430        // Only show if it has the keyboard captured.
431        // Chat input uses a rectangle as its background.
432        if input_focused {
433            // Shallow comparison of ChatMode.
434            let discrim = std::mem::discriminant;
435            if discrim(&state.input.mode) != discrim(&self.client.chat_mode) {
436                state.update(|s| {
437                    s.input.mode = self.client.chat_mode.clone();
438                });
439            }
440
441            let (color, icon) = render_chat_mode(&state.input.mode, self.imgs);
442            Image::new(icon)
443                .w_h(CHAT_ICON_WIDTH, CHAT_ICON_HEIGHT)
444                .top_left_with_margin_on(state.ids.chat_input_bg, 2.0)
445                .set(state.ids.chat_input_icon, ui);
446
447            // Any changes to this TextEdit's width and font size must be reflected in
448            // `cursor_offset_to_index` below.
449            let mut text_edit = TextEdit::new(&state.input.message)
450                .w(chat_box_input_width)
451                .restrict_to_height(false)
452                .color(color)
453                .line_spacing(2.0)
454                .font_size(self.fonts.opensans.scale(15))
455                .font_id(self.fonts.opensans.conrod_id);
456
457            if let Some(pos) = force_cursor {
458                text_edit = text_edit.cursor_pos(pos);
459            }
460
461            let y = match text_edit.get_y_dimension(ui) {
462                Dimension::Absolute(y) => y + 6.0,
463                _ => 0.0,
464            };
465            Rectangle::fill([chat_size.x, y])
466                .rgba(0.0, 0.0, 0.0, chat_settings.chat_opacity)
467                .w(chat_size.x)
468                .and(|r| {
469                    if chat_in_screen_upper {
470                        r.down_from(state.ids.message_box_bg, CHAT_MARGIN_THICKNESS / 2.0)
471                    } else {
472                        r.bottom_left_with_margins_on(ui.window, chat_pos.y, chat_pos.x)
473                    }
474                })
475                .set(state.ids.chat_input_bg, ui);
476
477            //border around focused chat window
478            let border_color = adjust_border_opacity(color, chat_settings.chat_opacity);
479            //top line
480            Line::centred([0.0, 0.0], [chat_size.x, 0.0])
481                .color(border_color)
482                .thickness(CHAT_MARGIN_THICKNESS)
483                .top_left_of(state.ids.chat_input_bg)
484                .set(state.ids.chat_input_border_up, ui);
485            //bottom line
486            Line::centred([0.0, 0.0], [chat_size.x, 0.0])
487                .color(border_color)
488                .thickness(CHAT_MARGIN_THICKNESS)
489                .bottom_left_of(state.ids.chat_input_bg)
490                .set(state.ids.chat_input_border_down, ui);
491            //left line
492            Line::centred([0.0, 0.0], [0.0, y])
493                .color(border_color)
494                .thickness(CHAT_MARGIN_THICKNESS)
495                .bottom_left_of(state.ids.chat_input_bg)
496                .set(state.ids.chat_input_border_left, ui);
497            //right line
498            Line::centred([0.0, 0.0], [0.0, y])
499                .color(border_color)
500                .thickness(CHAT_MARGIN_THICKNESS)
501                .bottom_right_of(state.ids.chat_input_bg)
502                .set(state.ids.chat_input_border_right, ui);
503
504            if let Some(mut input) = text_edit
505                .right_from(state.ids.chat_input_icon, 1.0)
506                .set(state.ids.chat_input, ui)
507            {
508                input.retain(|c| c != '\n');
509                state.update(|s| s.input.message = input);
510            }
511        }
512
513        // Message box
514        Rectangle::fill([chat_size.x, chat_size.y])
515            .rgba(0.0, 0.0, 0.0, chat_settings.chat_opacity)
516            .and(|r| {
517                if input_focused && !chat_in_screen_upper {
518                    r.up_from(
519                        state.ids.chat_input_border_up,
520                        0.0 + CHAT_MARGIN_THICKNESS / 2.0,
521                    )
522                } else {
523                    r.bottom_left_with_margins_on(ui.window, chat_pos.y, chat_pos.x)
524                }
525            })
526            .crop_kids()
527            .set(state.ids.message_box_bg, ui);
528        if state.ids.chat_icons.len() < state.messages.len() {
529            state.update(|s| {
530                s.ids
531                    .chat_icons
532                    .resize(s.messages.len(), &mut ui.widget_id_generator())
533            });
534        }
535        let group_members = self
536            .client
537            .group_members()
538            .iter()
539            .filter_map(|(u, r)| match r {
540                Role::Member => Some(u),
541                Role::Pet => None,
542            })
543            .collect::<HashSet<_>>();
544        let show_char_name = chat_settings.chat_character_name;
545        let messages = &state
546            .messages
547            .iter()
548            .filter(|m| {
549                if let Some(chat_tab) = current_chat_tab {
550                    chat_tab.filter.satisfies(m, &group_members)
551                } else {
552                    true
553                }
554            })
555            .map(|m| {
556                let is_moderator = m
557                    .chat_type
558                    .uid()
559                    .and_then(|uid| {
560                        self.client
561                            .lookup_msg_context(m)
562                            .player_info
563                            .get(&uid)
564                            .map(|i| i.is_moderator)
565                    })
566                    .unwrap_or(false);
567                let (chat_type, text) = localize_chat_message(
568                    m.clone(),
569                    |msg| self.client.lookup_msg_context(msg),
570                    self.localized_strings,
571                    show_char_name,
572                );
573                (is_moderator, chat_type, text)
574            })
575            .collect::<Vec<_>>();
576        let n_badges = messages.iter().filter(|t| t.0).count();
577        if state.ids.chat_badges.len() < n_badges {
578            state.update(|s| {
579                s.ids
580                    .chat_badges
581                    .resize(n_badges, &mut ui.widget_id_generator())
582            })
583        }
584        Rectangle::fill_with([CHAT_ICON_WIDTH, chat_size.y], color::TRANSPARENT)
585            .top_left_with_margins_on(state.ids.message_box_bg, 0.0, 0.0)
586            .crop_kids()
587            .set(state.ids.chat_icon_align, ui);
588        let (mut items, _) = List::flow_down(messages.len() + 1)
589            .top_left_with_margins_on(state.ids.message_box_bg, 0.0, CHAT_ICON_WIDTH)
590            .w_h(chat_size.x - CHAT_ICON_WIDTH, chat_size.y)
591            .scroll_kids_vertically()
592            .set(state.ids.message_box, ui);
593
594        let mut badge_id = 0;
595        while let Some(item) = items.next(ui) {
596            /// Calculate the width of the group text or faction name
597            fn group_width(chat_type: &ChatType<String>, ui: &Ui, font: &Font) -> Option<f64> {
598                // This is a temporary solution on a best effort basis
599                // This needs to be reworked in the long run
600                let text = match chat_type {
601                    ChatType::Group(_, desc) => desc.as_str(),
602                    ChatType::Faction(_, desc) => desc.as_str(),
603                    _ => return None,
604                };
605                let bracket_width = Text::new("() ")
606                    .font_size(font.scale(15))
607                    .font_id(font.conrod_id)
608                    .get_w(ui)?;
609                Text::new(text)
610                    .font_size(font.scale(15))
611                    .font_id(font.conrod_id)
612                    .get_w(ui)
613                    .map(|v| bracket_width + v)
614            }
615            // This would be easier if conrod used the v-metrics from rusttype.
616            if item.i < messages.len() {
617                let (is_moderator, chat_type, text) = &messages[item.i];
618                let (color, icon) = render_chat_line(chat_type, self.imgs);
619                // For each ChatType needing localization get/set matching pre-formatted
620                // localized string. This string will be formatted with the data
621                // provided in ChatType in the client/src/mod.rs
622                // fn format_message called below
623
624                let text = Text::new(text)
625                    .font_size(self.fonts.opensans.scale(15))
626                    .font_id(self.fonts.opensans.conrod_id)
627                    .w(chat_size.x - CHAT_ICON_WIDTH - 1.0)
628                    .wrap_by_word()
629                    .color(color)
630                    .line_spacing(2.0);
631
632                // Add space between messages.
633                let y = match text.get_y_dimension(ui) {
634                    Dimension::Absolute(y) => y + 2.0,
635                    _ => 0.0,
636                };
637                item.set(text.h(y), ui);
638
639                // If the user is a moderator display a moderator icon with their alias.
640                if *is_moderator {
641                    let group_width =
642                        group_width(chat_type, ui, &self.fonts.opensans).unwrap_or(0.0);
643                    Image::new(self.imgs.chat_moderator_badge)
644                        .w_h(CHAT_ICON_WIDTH, CHAT_ICON_HEIGHT)
645                        .top_left_with_margins_on(item.widget_id, 2.0, 7.0 + group_width)
646                        .parent(state.ids.message_box_bg)
647                        .set(state.ids.chat_badges[badge_id], ui);
648
649                    badge_id += 1;
650                }
651
652                let icon_id = state.ids.chat_icons[item.i];
653                Image::new(icon)
654                    .w_h(CHAT_ICON_WIDTH, CHAT_ICON_HEIGHT)
655                    .top_left_with_margins_on(item.widget_id, 2.0, -CHAT_ICON_WIDTH)
656                    .parent(state.ids.chat_icon_align)
657                    .set(icon_id, ui);
658            } else {
659                // Spacer at bottom of the last message so that it is not cut off.
660                // Needs to be larger than the space above.
661                item.set(
662                    Text::new("")
663                        .font_size(self.fonts.opensans.scale(6))
664                        .font_id(self.fonts.opensans.conrod_id)
665                        .w(chat_size.x),
666                    ui,
667                );
668            };
669        }
670
671        //Chat tabs
672        if ui
673            .rect_of(state.ids.message_box_bg)
674            .is_some_and(|r| r.is_over(ui.global_input().current.mouse.xy))
675        {
676            state.update(|s| s.tabs_last_hover_pulse = Some(self.pulse));
677        }
678
679        if let Some(time_since_hover) = state
680            .tabs_last_hover_pulse
681            .map(|t| self.pulse - t)
682            .filter(|t| t <= &1.5)
683        {
684            let alpha = 1.0 - (time_since_hover / 1.5).powi(4);
685            let shading = color::rgba(1.0, 0.82, 0.27, chat_settings.chat_opacity * alpha);
686
687            Rectangle::fill([chat_size.x, CHAT_TAB_HEIGHT])
688                .rgba(0.0, 0.0, 0.0, chat_settings.chat_opacity * alpha)
689                .up_from(state.ids.message_box_bg, 0.0)
690                .set(state.ids.chat_tab_align, ui);
691            if ui
692                .rect_of(state.ids.chat_tab_align)
693                .is_some_and(|r| r.is_over(ui.global_input().current.mouse.xy))
694            {
695                state.update(|s| s.tabs_last_hover_pulse = Some(self.pulse));
696            }
697
698            if Button::image(if chat_settings.chat_tab_index.is_none() {
699                self.imgs.selection
700            } else {
701                self.imgs.nothing
702            })
703            .top_left_with_margins_on(state.ids.chat_tab_align, 0.0, 0.0)
704            .w_h(CHAT_TAB_ALL_WIDTH, CHAT_TAB_HEIGHT)
705            .hover_image(self.imgs.selection_hover)
706            .hover_image(self.imgs.selection_press)
707            .image_color(shading)
708            .label(&self.localized_strings.get_msg("hud-chat-all"))
709            .label_font_size(self.fonts.cyri.scale(14))
710            .label_font_id(self.fonts.cyri.conrod_id)
711            .label_color(TEXT_COLOR.alpha(alpha))
712            .set(state.ids.chat_tab_all, ui)
713            .was_clicked()
714            {
715                events.push(Event::ChangeChatTab(None));
716            }
717
718            let chat_tab_width = (chat_size.x - CHAT_TAB_ALL_WIDTH) / (MAX_CHAT_TABS as f64);
719
720            if state.ids.chat_tabs.len() < chat_tabs.len() {
721                state.update(|s| {
722                    s.ids
723                        .chat_tabs
724                        .resize(chat_tabs.len(), &mut ui.widget_id_generator())
725                });
726            }
727            for (i, chat_tab) in chat_tabs.iter().enumerate() {
728                if Button::image(if chat_settings.chat_tab_index == Some(i) {
729                    self.imgs.selection
730                } else {
731                    self.imgs.nothing
732                })
733                .w_h(chat_tab_width, CHAT_TAB_HEIGHT)
734                .hover_image(self.imgs.selection_hover)
735                .press_image(self.imgs.selection_press)
736                .image_color(shading)
737                .label(chat_tab.label.as_str())
738                .label_font_size(self.fonts.cyri.scale(14))
739                .label_font_id(self.fonts.cyri.conrod_id)
740                .label_color(TEXT_COLOR.alpha(alpha))
741                .right_from(
742                    if i == 0 {
743                        state.ids.chat_tab_all
744                    } else {
745                        state.ids.chat_tabs[i - 1]
746                    },
747                    0.0,
748                )
749                .set(state.ids.chat_tabs[i], ui)
750                .was_clicked()
751                {
752                    events.push(Event::ChangeChatTab(Some(i)));
753                }
754
755                if ui
756                    .widget_input(state.ids.chat_tabs[i])
757                    .mouse()
758                    .is_some_and(|m| m.is_over())
759                {
760                    Rectangle::fill([120.0, 20.0])
761                        .rgba(0.0, 0.0, 0.0, 0.9)
762                        .top_left_with_margins_on(state.ids.chat_tabs[i], -20.0, 5.0)
763                        .parent(id)
764                        .set(state.ids.chat_tab_tooltip_bg, ui);
765
766                    Text::new(
767                        &self
768                            .localized_strings
769                            .get_msg("hud-chat-chat_tab_hover_tooltip"),
770                    )
771                    .mid_top_with_margin_on(state.ids.chat_tab_tooltip_bg, 3.0)
772                    .font_size(self.fonts.cyri.scale(10))
773                    .font_id(self.fonts.cyri.conrod_id)
774                    .color(TEXT_COLOR)
775                    .set(state.ids.chat_tab_tooltip_text, ui);
776                }
777
778                if ui
779                    .widget_input(state.ids.chat_tabs[i])
780                    .clicks()
781                    .right()
782                    .next()
783                    .is_some()
784                {
785                    events.push(Event::ShowChatTabSettings(i));
786                }
787            }
788        }
789
790        // Chat Arrow
791        // Check if already at bottom.
792        if !Self::scrolled_to_bottom(state, ui)
793            && Button::image(self.imgs.chat_arrow)
794                .w_h(20.0, 20.0)
795                .hover_image(self.imgs.chat_arrow_mo)
796                .press_image(self.imgs.chat_arrow_press)
797                .top_right_with_margins_on(state.ids.message_box_bg, 0.0, -22.0)
798                .parent(id)
799                .set(state.ids.chat_arrow, ui)
800                .was_clicked()
801        {
802            ui.scroll_widget(state.ids.message_box, [0.0, f64::MAX]);
803        }
804
805        // We've started a new tab completion. Populate tab completion suggestions.
806        if request_tab_completions {
807            events.push(Event::TabCompletionStart(state.input.message.to_string()));
808        // If the chat widget is focused, return a focus event to pass the focus
809        // to the input box.
810        } else if keyboard_capturer == Some(id) {
811            events.push(Event::Focus(state.ids.chat_input));
812        }
813        // If either Return or Enter is pressed and the input box is not empty, send the current
814        // message.
815        else if ui
816            .widget_input(state.ids.chat_input)
817            .presses()
818            .key()
819            .any(|key_press| {
820                let has_message = !state.input.message.is_empty();
821                let pressed = matches!(key_press.key, Key::Return | Key::NumPadEnter);
822                if pressed {
823                    // If chat was hidden, scroll to bottom the next time it is opened
824                    state.update(|s| s.scroll_next |= force_chat);
825                    events.push(Event::DisableForceChat);
826                }
827                has_message && pressed
828            })
829        {
830            let msg = state.input.message.clone();
831            state.update(|s| {
832                s.input.message.clear();
833                // Update the history
834                // Don't add if this is identical to the last message in the history
835                s.history_pos = 0;
836                if s.history.front() != Some(&msg) {
837                    s.history.push_front(msg.clone());
838                    s.history.truncate(self.history_max);
839                }
840            });
841            if let Some(msg) = msg.strip_prefix(chat_settings.chat_cmd_prefix) {
842                match parse_cmd(msg) {
843                    Ok((name, args)) => events.push(Event::SendCommand(name, args)),
844                    // TODO: Localise
845                    Err(err) => self
846                        .new_messages
847                        .push_back(ChatType::CommandError.into_plain_msg(err)),
848                }
849            } else {
850                events.push(Event::SendMessage(msg));
851            }
852        }
853
854        Rectangle::fill_with([chat_size.x, chat_size.y], color::TRANSPARENT)
855            .and(|r| {
856                if input_focused {
857                    r.up_from(state.ids.chat_input_border_up, CHAT_MARGIN_THICKNESS / 2.0)
858                } else {
859                    r.bottom_left_with_margins_on(ui.window, chat_pos.y, chat_pos.x)
860                }
861            })
862            .set(state.ids.draggable_area, ui);
863        events
864    }
865}
866
867fn do_tab_completion(cursor: usize, input: &str, word: &str) -> (String, usize) {
868    let mut pre_ws = None;
869    let mut post_ws = None;
870    let mut in_quotation = false;
871    for (char_i, (byte_i, c)) in input.char_indices().enumerate() {
872        if c == '"' {
873            in_quotation = !in_quotation;
874        } else if !in_quotation && c.is_whitespace() && c != '\t' {
875            if char_i < cursor {
876                pre_ws = Some(byte_i);
877            } else {
878                post_ws = Some(byte_i);
879                break;
880            }
881        }
882    }
883
884    match (pre_ws, post_ws) {
885        (None, None) => (word.to_string(), word.chars().count()),
886        (None, Some(i)) => (
887            format!("{}{}", word, input.split_at(i).1),
888            word.chars().count(),
889        ),
890        (Some(i), None) => {
891            let l_split = input.split_at(i).0;
892            let completed = format!("{} {}", l_split, word);
893            (
894                completed,
895                l_split.chars().count() + 1 + word.chars().count(),
896            )
897        },
898        (Some(i), Some(j)) => {
899            let l_split = input.split_at(i).0;
900            let r_split = input.split_at(j).1;
901            let completed = format!("{} {}{}", l_split, word, r_split);
902            (
903                completed,
904                l_split.chars().count() + 1 + word.chars().count(),
905            )
906        },
907    }
908}
909
910fn cursor_offset_to_index(
911    offset: usize,
912    text: &str,
913    ui: &Ui,
914    fonts: &Fonts,
915    input_width: f64,
916) -> Option<Index> {
917    // This moves the cursor to the given offset. Conrod is a pain.
918    //
919    // Width and font must match that of the chat TextEdit
920    let font = ui.fonts.get(fonts.opensans.conrod_id)?;
921    let font_size = fonts.opensans.scale(15);
922    let infos = text::line::infos(text, font, font_size).wrap_by_whitespace(input_width);
923
924    cursor::index_before_char(infos, offset)
925}
926
927/// Get the color and icon for a client's ChatMode.
928fn render_chat_mode(chat_mode: &ChatMode, imgs: &Imgs) -> (Color, conrod_core::image::Id) {
929    match chat_mode {
930        ChatMode::World => (WORLD_COLOR, imgs.chat_world_small),
931        ChatMode::Say => (SAY_COLOR, imgs.chat_say_small),
932        ChatMode::Region => (REGION_COLOR, imgs.chat_region_small),
933        ChatMode::Faction(_) => (FACTION_COLOR, imgs.chat_faction_small),
934        ChatMode::Group => (GROUP_COLOR, imgs.chat_group_small),
935        ChatMode::Tell(_) => (TELL_COLOR, imgs.chat_tell_small),
936    }
937}
938
939/// Get the color and icon for the current line in the chat box
940fn render_chat_line(chat_type: &ChatType<String>, imgs: &Imgs) -> (Color, conrod_core::image::Id) {
941    match chat_type {
942        ChatType::Online(_) => (ONLINE_COLOR, imgs.chat_online_small),
943        ChatType::Offline(_) => (OFFLINE_COLOR, imgs.chat_offline_small),
944        ChatType::CommandError => (ERROR_COLOR, imgs.chat_command_error_small),
945        ChatType::CommandInfo => (INFO_COLOR, imgs.chat_command_info_small),
946        ChatType::GroupMeta(_) => (GROUP_COLOR, imgs.chat_group_small),
947        ChatType::FactionMeta(_) => (FACTION_COLOR, imgs.chat_faction_small),
948        ChatType::Kill(_, _) => (KILL_COLOR, imgs.chat_kill_small),
949        ChatType::Tell(_from, _to) => (TELL_COLOR, imgs.chat_tell_small),
950        ChatType::Say(_uid) => (SAY_COLOR, imgs.chat_say_small),
951        ChatType::Group(_uid, _s) => (GROUP_COLOR, imgs.chat_group_small),
952        ChatType::Faction(_uid, _s) => (FACTION_COLOR, imgs.chat_faction_small),
953        ChatType::Region(_uid) => (REGION_COLOR, imgs.chat_region_small),
954        ChatType::World(_uid) => (WORLD_COLOR, imgs.chat_world_small),
955        ChatType::Npc(_uid) => panic!("NPCs can't talk!"), // Should be filtered by hud/mod.rs
956        ChatType::NpcSay(_uid) => (SAY_COLOR, imgs.chat_say_small),
957        ChatType::NpcTell(_from, _to) => (TELL_COLOR, imgs.chat_tell_small),
958        ChatType::Meta => (INFO_COLOR, imgs.chat_command_info_small),
959    }
960}
961
962fn parse_cmd(msg: &str) -> Result<(String, Vec<String>), String> {
963    use chumsky::prelude::*;
964
965    let escape = just::<_, _, Simple<char>>('\\').ignore_then(
966        just('\\')
967            .or(just('/'))
968            .or(just('"'))
969            .or(just('b').to('\x08'))
970            .or(just('f').to('\x0C'))
971            .or(just('n').to('\n'))
972            .or(just('r').to('\r'))
973            .or(just('t').to('\t')),
974    );
975
976    let string = just('"')
977        .ignore_then(filter(|c| *c != '\\' && *c != '"').or(escape).repeated())
978        .then_ignore(just('"'))
979        .labelled("quoted argument");
980
981    let arg = string
982        .or(filter(|c: &char| !c.is_whitespace() && *c != '"')
983            .repeated()
984            .at_least(1)
985            .labelled("argument"))
986        .collect::<String>();
987
988    let cmd = text::ident()
989        .then(arg.padded().repeated())
990        .then_ignore(end());
991
992    cmd.parse(msg).map_err(|errs| {
993        errs.into_iter()
994            .map(|err| err.to_string())
995            .collect::<Vec<_>>()
996            .join(", ")
997    })
998}
999
1000#[cfg(test)]
1001mod tests {
1002    use super::*;
1003
1004    #[test]
1005    fn parse_cmds() {
1006        let expected: Result<(String, Vec<String>), String> = Ok(("help".to_string(), vec![]));
1007        assert_eq!(parse_cmd(r"help"), expected);
1008
1009        let expected: Result<(String, Vec<String>), String> = Ok(("say".to_string(), vec![
1010            "foo".to_string(),
1011            "bar".to_string(),
1012        ]));
1013        assert_eq!(parse_cmd(r"say foo bar"), expected);
1014        assert_eq!(parse_cmd(r#"say "foo" "bar""#), expected);
1015
1016        let expected: Result<(String, Vec<String>), String> =
1017            Ok(("say".to_string(), vec!["Hello World".to_string()]));
1018        assert_eq!(parse_cmd(r#"say "Hello World""#), expected);
1019
1020        // Note: \n in the expected gets expanded by rust to a newline character, that's
1021        // why we must not use a raw string in the expected
1022        let expected: Result<(String, Vec<String>), String> =
1023            Ok(("say".to_string(), vec!["Hello\nWorld".to_string()]));
1024        assert_eq!(parse_cmd(r#"say "Hello\nWorld""#), expected);
1025    }
1026}