Skip to main content

veloren_voxygen/ui/widgets/
rich_text.rs

1use crate::hud::{controller_icons as icon_utils, img_ids::Imgs};
2use conrod_core::{
3    Color, FontSize, Positionable, Sizeable, Ui, Widget, WidgetCommon, builder_methods, image,
4    position::Dimension, text, widget, widget_ids,
5};
6use regex::Regex;
7use std::sync::LazyLock;
8
9// represents a piece of the rich text flow
10enum TextSegment<'a> {
11    Text(&'a str),
12    Image(image::Id), // font size is [w, h]
13    Newline,
14}
15
16pub struct State {
17    ids: Ids,
18}
19
20widget_ids! {
21    struct Ids {
22        text_ids[],
23        image_ids[],
24    }
25}
26
27/// a widget for rendering text with inline images/icons
28///
29/// `RichText` automatically parses strings for tags (e.g., `:south:`) and
30/// replaces them with the corresponding icons
31#[derive(WidgetCommon)]
32pub struct RichText<'a> {
33    #[conrod(common_builder)]
34    common: widget::CommonBuilder,
35    segments: Vec<TextSegment<'a>>,
36    text_segments: usize,
37    image_segments: usize,
38    style: widget::text::Style,
39    font_id: Option<text::font::Id>,
40    line_spacing: f64,
41}
42
43impl<'a> RichText<'a> {
44    builder_methods! {
45        pub color { style.color = Some(Color) }
46        pub font_size { style.font_size = Some(FontSize) }
47        pub font_id { font_id = Some(text::font::Id) }
48        pub line_spacing { line_spacing = f64 }
49        pub justify { style.justify = Some(text::Justify) }
50    }
51
52    /// creates a new `RichText` widget
53    ///
54    /// # arguments
55    /// * `string` - the text to display. Use tags like `:name:` to insert
56    ///   images
57    /// * `imgs` - Imgs object to fetch conrod image ids
58    pub fn new(string: &'a str, imgs: &'a Imgs) -> Self {
59        let (segments, text_segments, image_segments) = Self::parse(string, imgs);
60
61        RichText {
62            common: widget::CommonBuilder::default(),
63            segments,
64            text_segments,
65            image_segments,
66            style: widget::text::Style::default(),
67            font_id: None,
68            line_spacing: 5.0,
69        }
70    }
71
72    // do a forward pass through the input to pre-process it
73    // TODO: auto add a "\n" character if input goes passed max defined width
74    fn parse(input: &'a str, imgs: &Imgs) -> (Vec<TextSegment<'a>>, usize, usize) {
75        // a magical incantation that splits strings by double colons (e.g., ":icon:")
76        static RE: LazyLock<Regex> =
77            LazyLock::new(|| Regex::new(r":(?P<tag>[^:\s]+):").expect("Invalid Regex"));
78        let mut segments = Vec::new();
79        let mut text_segments = 0;
80        let mut image_segments = 0;
81        let mut last_end = 0;
82
83        for check in RE.captures_iter(input) {
84            let whole = check.get(0).unwrap();
85            let tag = &check["tag"];
86
87            // push the text before the icon
88            if whole.start() > last_end {
89                let text_strip = &input[last_end..whole.start()];
90                // handle newlines
91                for (i, line) in text_strip.split('\n').enumerate() {
92                    if i > 0 {
93                        segments.push(TextSegment::Newline);
94                    }
95                    if !line.is_empty() {
96                        segments.push(TextSegment::Text(line));
97                        text_segments += 1;
98                    }
99                }
100            }
101
102            // add icon to output
103            let img_id = icon_utils::get_controller_icon_id_from_string(tag, imgs);
104
105            segments.push(TextSegment::Image(img_id));
106            image_segments += 1;
107
108            last_end = whole.end();
109        }
110
111        // push trailing text
112        if last_end < input.len() {
113            let trailing = &input[last_end..];
114            for (i, line) in trailing.split('\n').enumerate() {
115                if i > 0 {
116                    segments.push(TextSegment::Newline);
117                }
118                if !line.is_empty() {
119                    segments.push(TextSegment::Text(line));
120                    text_segments += 1;
121                }
122            }
123        }
124
125        (segments, text_segments, image_segments)
126    }
127
128    // helper to calculate the total bounding box of all segments.
129    fn calculate_dimensions(&self, ui: &Ui) -> [f64; 2] {
130        let font_id = self.font_id.or(ui.theme.font_id).expect("No font provided");
131        let font = ui.fonts.get(font_id).expect("Font not found");
132        let font_size = self.style.font_size(&ui.theme);
133        let line_height = font_size as f64 + self.line_spacing;
134
135        let mut max_w: f64 = 0.0;
136        let mut current_w: f64 = 0.0;
137        let mut total_h: f64 = line_height;
138
139        for segment in &self.segments {
140            match segment {
141                TextSegment::Text(s) => {
142                    current_w += text::line::width(s, font, font_size);
143                },
144                TextSegment::Image(_) => {
145                    current_w += font_size as f64;
146                },
147                TextSegment::Newline => {
148                    max_w = max_w.max(current_w);
149                    current_w = 0.0;
150                    total_h += line_height;
151                },
152            }
153        }
154        [max_w.max(current_w), total_h]
155    }
156}
157
158impl<'a> Widget for RichText<'a> {
159    type Event = ();
160    type State = State;
161    type Style = ();
162
163    fn init_state(&self, id_gen: widget::id::Generator) -> Self::State {
164        State {
165            ids: Ids::new(id_gen),
166        }
167    }
168
169    fn style(&self) -> Self::Style {}
170
171    fn default_x_dimension(&self, ui: &Ui) -> Dimension {
172        let [w, _h] = self.calculate_dimensions(ui);
173        Dimension::Absolute(w)
174    }
175
176    fn default_y_dimension(&self, ui: &Ui) -> Dimension {
177        let [_w, h] = self.calculate_dimensions(ui);
178        Dimension::Absolute(h)
179    }
180
181    fn update(self, args: widget::UpdateArgs<Self>) -> Self::Event {
182        let widget::UpdateArgs {
183            id,
184            state,
185            ui,
186            rect,
187            ..
188        } = args;
189
190        let font_size = self.style.font_size(ui.theme());
191        let line_height = font_size as f64 + self.line_spacing;
192        let font_id = self.font_id.or(ui.theme.font_id).expect("No font provided");
193
194        let available_width = rect.w();
195        let justify = self.style.justify(ui.theme());
196
197        // calculate the line widths on a line-by-line basis for proper text alignment
198        let line_widths: Vec<f64> = {
199            let font = ui.fonts.get(font_id).expect("Font not found"); // borrows ui
200            let mut current_width = 0.0;
201            let mut widths = Vec::new();
202
203            for segment in &self.segments {
204                match segment {
205                    TextSegment::Text(s) => {
206                        current_width += text::line::width(s, font, font_size);
207                    },
208                    TextSegment::Image(_) => {
209                        current_width += font_size as f64;
210                    },
211                    TextSegment::Newline => {
212                        widths.push(current_width);
213                        current_width = 0.0;
214                    },
215                }
216            }
217
218            widths.push(current_width);
219            widths
220        };
221
222        // dynamically update number of widgets and use the appropriate conrod primitive
223        state.update(|s| {
224            s.ids
225                .text_ids
226                .resize(self.text_segments, &mut ui.widget_id_generator());
227            s.ids
228                .image_ids
229                .resize(self.image_segments, &mut ui.widget_id_generator());
230        });
231
232        let mut y_cursor = 0.0;
233        let mut text_idx = 0;
234        let mut image_idx = 0;
235        let mut line_idx = 0;
236
237        let mut x_cursor = match justify {
238            text::Justify::Left => 0.0,
239            text::Justify::Right => available_width - line_widths[line_idx],
240            text::Justify::Center => (available_width - line_widths[line_idx]) / 2.0,
241        };
242
243        for segment in &self.segments {
244            match segment {
245                TextSegment::Text(string) => {
246                    if string.is_empty() {
247                        continue;
248                    }
249
250                    let text_width = {
251                        let font = ui.fonts.get(font_id).expect("Font not found");
252                        text::line::width(string, font, font_size)
253                    };
254
255                    widget::Text::new(string)
256                        .with_style(self.style)
257                        .font_id(font_id)
258                        .parent(id)
259                        .graphics_for(id)
260                        .top_left_with_margins_on(id, y_cursor, x_cursor)
261                        .set(state.ids.text_ids[text_idx], ui);
262
263                    x_cursor += text_width;
264                    text_idx += 1;
265                },
266                TextSegment::Image(image_id) => {
267                    let image_size = font_size as f64;
268                    // should this not be hardcoded to 1.5? It seems to properly align images
269                    // regardless of font_size
270                    let v_offset = 1.5;
271
272                    // not sure if I like coloring icons with text, but I'll leave it for now
273                    // opacity value is important though
274                    let color = self.style.color.unwrap_or(Color::Rgba(1.0, 1.0, 1.0, 1.0));
275                    widget::Image::new(*image_id)
276                        .wh([image_size, image_size])
277                        .parent(id)
278                        .graphics_for(id)
279                        .color(Some(color))
280                        .top_left_with_margins_on(id, y_cursor + v_offset, x_cursor)
281                        .set(state.ids.image_ids[image_idx], ui);
282
283                    x_cursor += image_size;
284                    image_idx += 1;
285                },
286                TextSegment::Newline => {
287                    line_idx += 1;
288                    x_cursor = match justify {
289                        text::Justify::Left => 0.0,
290                        text::Justify::Right => available_width - line_widths[line_idx],
291                        text::Justify::Center => (available_width - line_widths[line_idx]) / 2.0,
292                    };
293                    y_cursor += line_height;
294                },
295            }
296        }
297    }
298}