Skip to main content

fyrox_ui/
bbcode.rs

1use std::{
2    convert::Infallible,
3    fmt::{Debug, Display, Write},
4    ops::Range,
5    str::FromStr,
6};
7
8use crate::{
9    brush::Brush,
10    font::FontResource,
11    formatted_text::{FormattedTextBuilder, Run, RunSet},
12};
13
14/// A BBCode parser that is specially designed for the formatting options
15/// available to [`FormattedText`](crate::formatted_text::FormattedText).
16/// The available tags are:
17/// * `[b]` **bold text** `[/b]`
18/// * `[i]` *italic text* `[/i]`
19/// * `[color=red]` red text `[/color]` (can be shortened to `[c=red]`... `[/c]`, and can use hex color as in `[color=#FF0000]`)
20/// * `[size=24]` large text `[/size]` (can be shortened to `[s=24]` ... `[/s]`)
21/// * `[shadow]` shadowed text `[/shadow]` (can be shortened to `[sh]` ... `[/sh]` and can change shadow color with `[shadow=blue]`)
22/// * `[br]` for a line break.
23#[derive(Debug, Clone)]
24pub struct BBCode {
25    /// The plain text without tags.
26    pub text: String,
27    /// The tags that were removed from the text.
28    pub tags: Box<[BBTag]>,
29}
30
31#[derive(Clone, Eq, PartialEq)]
32pub struct BBTag {
33    /// The position of the tag in the plain text, with 0 being the beginning of the text.
34    /// The position is relative to the text *without* tags, so for example, if the code text were:
35    /// `"Here is [b]bold text[/b]."` then the plain text would be:
36    /// `"Here is bold text."` and the tags would be at positions 8 and 17.
37    pub position: usize,
38    /// The content of the tag.
39    pub data: BBTagData,
40}
41
42impl std::ops::Deref for BBTag {
43    type Target = BBTagData;
44
45    fn deref(&self) -> &Self::Target {
46        &self.data
47    }
48}
49
50impl Debug for BBTag {
51    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
52        Display::fmt(&self, f)
53    }
54}
55
56impl Display for BBTag {
57    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
58        write!(f, "{}@{}", self.data, self.position)
59    }
60}
61
62/// The content of a BBCode tag.
63#[derive(Debug, Clone, Eq, PartialEq)]
64pub struct BBTagData {
65    /// The text at the beginning of the tag, not including an initial /, upto and not including an = if present.
66    /// For example, if the tag were `[size=24]` then the label would be "size".
67    /// If the tag were `[/size]` then the label would be "size".
68    pub label: String,
69    /// The text that follows the = in the tag.
70    pub argument: Option<String>,
71    /// True if the tag starts with a / to indicate that it is the end of some span.
72    pub is_close: bool,
73}
74
75impl BBTagData {
76    pub fn open(label: String, argument: Option<String>) -> Self {
77        Self {
78            is_close: false,
79            label,
80            argument,
81        }
82    }
83    pub fn close(label: String, argument: Option<String>) -> Self {
84        Self {
85            is_close: true,
86            label,
87            argument,
88        }
89    }
90}
91
92impl Display for BBTagData {
93    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
94        f.write_char('[')?;
95        if self.is_close {
96            f.write_char('/')?;
97        }
98        f.write_str(&self.label)?;
99        if let Some(arg) = &self.argument {
100            f.write_char('=')?;
101            f.write_str(arg)?;
102        }
103        f.write_char(']')
104    }
105}
106
107impl FromStr for BBTagData {
108    type Err = Infallible;
109    fn from_str(source: &str) -> Result<Self, Infallible> {
110        let mut source = source.as_bytes();
111        let mut is_close = false;
112        if let Some((b'/', rest)) = source.split_first() {
113            is_close = true;
114            source = rest;
115        }
116        if let Some(equals_pos) = source.iter().position(|c| *c == b'=') {
117            let (label, argument) = source.split_at(equals_pos);
118            let label = label.trim_ascii();
119            let argument = argument[1..].trim_ascii();
120            Ok(Self {
121                is_close,
122                label: std::str::from_utf8(label).unwrap().to_string(),
123                argument: Some(std::str::from_utf8(argument).unwrap().to_string()),
124            })
125        } else {
126            Ok(Self {
127                is_close,
128                label: std::str::from_utf8(source.trim_ascii())
129                    .unwrap()
130                    .to_string(),
131                argument: None,
132            })
133        }
134    }
135}
136
137impl FromStr for BBCode {
138    type Err = Infallible;
139    fn from_str(source: &str) -> Result<Self, Infallible> {
140        let mut source = source.as_bytes();
141        let mut text = Vec::new();
142        let mut tags = Vec::new();
143        while !source.is_empty() {
144            match source[0] {
145                b'[' => {
146                    source = &source[1..];
147                    if let Some(end_pos) = source.iter().position(|c| *c == b']') {
148                        let content = std::str::from_utf8(&source[0..end_pos]).unwrap();
149                        source = &source[end_pos + 1..];
150                        let data: BBTagData = content.parse()?;
151                        if !data.is_close && data.argument.is_none() && data.label == "br" {
152                            text.push(b'\n');
153                        } else {
154                            tags.push(BBTag {
155                                position: text.len(),
156                                data,
157                            });
158                        }
159                    } else {
160                        source = &[];
161                    }
162                }
163                c => {
164                    text.push(c);
165                    source = &source[1..];
166                }
167            }
168        }
169        Ok(Self {
170            text: std::str::from_utf8(&text).unwrap().to_string(),
171            tags: tags.into_boxed_slice(),
172        })
173    }
174}
175
176fn find_close<'a, I: Iterator<Item = &'a BBTag>>(label: &str, iter: I) -> Option<&'a BBTag> {
177    let mut nesting_level = 0;
178    for tag in iter {
179        if tag.is_close {
180            if nesting_level == 0 {
181                return (tag.label == label).then_some(tag);
182            } else {
183                nesting_level -= 1;
184            }
185        } else {
186            nesting_level += 1;
187        }
188    }
189    None
190}
191
192fn find_font_run(runs: &mut [Run], pos: u32) -> Option<&mut Run> {
193    runs.iter_mut()
194        .rev()
195        .find(|r| r.range.contains(&pos) && r.font().is_some())
196}
197
198fn update_font(
199    runs: &mut RunSet,
200    range: Range<u32>,
201    new_font: Option<&FontResource>,
202    other_font: Option<&FontResource>,
203    bold_italic: Option<&FontResource>,
204) {
205    if let Some(run) = find_font_run(runs, range.start) {
206        if other_font == run.font() {
207            if let Some(bold_italic) = bold_italic {
208                if range == run.range {
209                    *run = Run::new(range).with_font(bold_italic.clone());
210                } else {
211                    runs.push(Run::new(range).with_font(bold_italic.clone()));
212                }
213            }
214        }
215    } else if let Some(new_font) = new_font {
216        runs.push(Run::new(range).with_font(new_font.clone()));
217    }
218}
219
220fn apply_tag(
221    runs: &mut RunSet,
222    label: &str,
223    argument: Option<&str>,
224    range: Range<u32>,
225    font: &FontResource,
226) {
227    match (label, argument) {
228        ("i", None) => {
229            if font.is_ok() {
230                let font = font.data_ref();
231                update_font(
232                    runs,
233                    range,
234                    font.italic.as_ref(),
235                    font.bold.as_ref(),
236                    font.bold_italic.as_ref(),
237                );
238            }
239        }
240        ("b", None) => {
241            if font.is_ok() {
242                let font = font.data_ref();
243                update_font(
244                    runs,
245                    range,
246                    font.bold.as_ref(),
247                    font.italic.as_ref(),
248                    font.bold_italic.as_ref(),
249                );
250            }
251        }
252        ("size" | "s", Some(size)) => {
253            if let Ok(size) = size.parse() {
254                runs.push(Run::new(range).with_size(size));
255            }
256        }
257        ("color" | "c", Some(color)) => {
258            if let Ok(color) = color.parse() {
259                runs.push(Run::new(range).with_brush(Brush::Solid(color)));
260            }
261        }
262        ("shadow" | "sh", color) => {
263            let mut run = Run::new(range).with_shadow(true);
264            if let Some(color) = color.and_then(|c| c.parse().ok()) {
265                run = run.with_shadow_brush(Brush::Solid(color));
266            }
267            runs.push(run);
268        }
269        _ => (),
270    }
271}
272
273impl BBCode {
274    pub fn build_formatted_text(self, font: FontResource) -> FormattedTextBuilder {
275        let runs = self.build_runs(&font);
276        FormattedTextBuilder::new(font)
277            .with_text(self.text)
278            .with_runs(runs)
279    }
280    pub fn build_runs(&self, font: &FontResource) -> RunSet {
281        let mut runs = RunSet::default();
282        let mut iter = self.tags.iter();
283        while let Some(tag) = iter.next() {
284            if tag.is_close {
285                continue;
286            }
287            if let Some(close) = find_close(&tag.label, iter.clone()) {
288                let start_pos = self.text[0..tag.position].chars().count() as u32;
289                let end_pos = self.text[0..close.position].chars().count() as u32;
290                apply_tag(
291                    &mut runs,
292                    &tag.label,
293                    tag.argument.as_deref(),
294                    start_pos..end_pos,
295                    font,
296                );
297            }
298        }
299        runs
300    }
301}
302
303#[cfg(test)]
304mod test {
305    use fyrox_core::color::Color;
306    use fyrox_resource::untyped::ResourceKind;
307    use uuid::Uuid;
308
309    use crate::font::{Font, BUILT_IN_FONT};
310
311    use super::*;
312    #[test]
313    fn test_built_in_font() {
314        let font = BUILT_IN_FONT.resource();
315        assert!(font.data_ref().bold.is_some());
316        assert!(font.data_ref().italic.is_some());
317        assert!(font.data_ref().bold_italic.is_some());
318    }
319    #[test]
320    fn test_example() {
321        let code: BBCode = "Here is [b]bold text[/b].".parse().unwrap();
322        assert_eq!(&code.text, "Here is bold text.");
323        assert_eq!(
324            *code.tags,
325            *&[
326                BBTag {
327                    position: 8,
328                    data: BBTagData::open("b".into(), None)
329                },
330                BBTag {
331                    position: 17,
332                    data: BBTagData::close("b".into(), None)
333                }
334            ]
335        );
336    }
337    #[test]
338    fn test_example2() {
339        let code: BBCode = "Here is [size = 24]big text[/ size= x ].".parse().unwrap();
340        assert_eq!(&code.text, "Here is big text.");
341        assert_eq!(
342            *code.tags,
343            *&[
344                BBTag {
345                    position: 8,
346                    data: BBTagData::open("size".into(), Some("24".into()))
347                },
348                BBTag {
349                    position: 16,
350                    data: BBTagData::close("size".into(), Some("x".into()))
351                }
352            ]
353        );
354    }
355    #[test]
356    fn test_formatted() {
357        let bold = FontResource::new_ok(Uuid::new_v4(), ResourceKind::Embedded, Font::default());
358        let italic = FontResource::new_ok(Uuid::new_v4(), ResourceKind::Embedded, Font::default());
359        let bold_italic =
360            FontResource::new_ok(Uuid::new_v4(), ResourceKind::Embedded, Font::default());
361        let font = FontResource::new_ok(
362            Uuid::new_v4(),
363            ResourceKind::Embedded,
364            Font {
365                bold: Some(bold.clone()),
366                italic: Some(italic.clone()),
367                bold_italic: Some(bold_italic.clone()),
368                ..Font::default()
369            },
370        );
371        let code: BBCode = "Here is [size=24]big text[/size].".parse().unwrap();
372        let text = code.build_formatted_text(font.clone()).build();
373        assert_eq!(**text.runs(), *&[Run::new(8..16).with_size(24.0)]);
374        let code: BBCode = "Here is [shadow]big text[/shadow].".parse().unwrap();
375        let text = code.build_formatted_text(font.clone()).build();
376        assert_eq!(**text.runs(), *&[Run::new(8..16).with_shadow(true)]);
377        let code: BBCode = "Here is [sh][s=24]big text[/s][/sh].".parse().unwrap();
378        let text = code.build_formatted_text(font.clone()).build();
379        assert_eq!(
380            **text.runs(),
381            *&[Run::new(8..16).with_shadow(true).with_size(24.0)]
382        );
383        let code: BBCode = "Here is [color=green]big text[/color].".parse().unwrap();
384        let text = code.build_formatted_text(font.clone()).build();
385        assert_eq!(
386            **text.runs(),
387            *&[Run::new(8..16).with_brush(Brush::Solid(Color::GREEN))]
388        );
389        let code: BBCode = "Here is [c=#010203]big text[/c].".parse().unwrap();
390        let text = code.build_formatted_text(font.clone()).build();
391        assert_eq!(
392            **text.runs(),
393            *&[Run::new(8..16).with_brush(Brush::Solid(Color::opaque(1, 2, 3)))]
394        );
395        let code: BBCode = "Here is [i]big text[/i].".parse().unwrap();
396        let text = code.build_formatted_text(font.clone()).build();
397        assert_eq!(**text.runs(), *&[Run::new(8..16).with_font(italic.clone())]);
398        let code: BBCode = "Here is [b]big text[/b].".parse().unwrap();
399        let text = code.build_formatted_text(font.clone()).build();
400        assert_eq!(**text.runs(), *&[Run::new(8..16).with_font(bold.clone())]);
401        let code: BBCode = "Here is [b][i]big text[/i][/b].".parse().unwrap();
402        let text = code.build_formatted_text(font.clone()).build();
403        assert_eq!(
404            **text.runs(),
405            *&[Run::new(8..16).with_font(bold_italic.clone())]
406        );
407        let code: BBCode = "Here is [i]big [b]text[/b]!!![/i]".parse().unwrap();
408        let text = code.build_formatted_text(font.clone()).build();
409        assert_eq!(
410            **text.runs(),
411            *&[
412                Run::new(8..19).with_font(italic.clone()),
413                Run::new(12..16).with_font(bold_italic.clone())
414            ]
415        );
416    }
417    #[test]
418    fn test_nesting() {
419        let font = FontResource::new_ok(Uuid::new_v4(), ResourceKind::Embedded, Font::default());
420        let code: BBCode = "Here is [s=24]big [s=3]small[/s] text[/s]."
421            .parse()
422            .unwrap();
423        let text = code.build_formatted_text(font.clone()).build();
424        assert_eq!(
425            **text.runs(),
426            *&[
427                Run::new(8..22).with_size(24.0),
428                Run::new(12..17).with_size(3.0)
429            ]
430        );
431    }
432}