widgetry/
text.rs

1use std::collections::hash_map::DefaultHasher;
2use std::fmt::Write;
3use std::hash::Hasher;
4
5use usvg::TreeParsing;
6use usvg_text_layout::TreeTextToPath;
7
8use geom::{PolyLine, Polygon};
9
10use crate::assets::Assets;
11use crate::{
12    svg, Color, DeferDraw, EventCtx, GeomBatch, JustDraw, MultiKey, ScreenDims, Style, Widget,
13};
14
15// Same as body()
16pub const DEFAULT_FONT: Font = Font::OverpassRegular;
17pub const DEFAULT_FONT_SIZE: usize = 21;
18
19pub const SCALE_LINE_HEIGHT: f64 = 1.2;
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
22pub enum Font {
23    BungeeInlineRegular,
24    BungeeRegular,
25    OverpassBold,
26    OverpassRegular,
27    OverpassSemiBold,
28    OverpassMonoBold,
29}
30
31impl Font {
32    pub fn family(self) -> &'static str {
33        match self {
34            Font::BungeeInlineRegular => "Bungee Inline",
35            Font::BungeeRegular => "Bungee",
36            Font::OverpassBold => "Overpass",
37            Font::OverpassRegular => "Overpass",
38            Font::OverpassSemiBold => "Overpass",
39            Font::OverpassMonoBold => "Overpass Mono",
40        }
41    }
42}
43
44#[derive(Debug, Clone)]
45pub struct TextSpan {
46    text: String,
47    fg_color: Option<Color>,
48    outline_color: Option<Color>,
49    size: usize,
50    font: Font,
51    underlined: bool,
52}
53
54impl<AsStrRef: AsRef<str>> From<AsStrRef> for TextSpan {
55    fn from(line: AsStrRef) -> Self {
56        Line(line.as_ref())
57    }
58}
59
60impl TextSpan {
61    pub fn fg(mut self, color: Color) -> TextSpan {
62        assert_eq!(self.fg_color, None);
63        self.fg_color = Some(color);
64        self
65    }
66
67    pub fn maybe_fg(mut self, color: Option<Color>) -> TextSpan {
68        assert_eq!(self.fg_color, None);
69        self.fg_color = color;
70        self
71    }
72
73    pub fn fg_color_for_style(&self, style: &Style) -> Color {
74        self.fg_color.unwrap_or(style.text_primary_color)
75    }
76
77    pub fn outlined(mut self, color: Color) -> TextSpan {
78        assert_eq!(self.outline_color, None);
79        self.outline_color = Some(color);
80        self
81    }
82
83    pub fn into_widget(self, ctx: &EventCtx) -> Widget {
84        Text::from(self).into_widget(ctx)
85    }
86    pub fn batch(self, ctx: &EventCtx) -> Widget {
87        Text::from(self).batch(ctx)
88    }
89
90    // Yuwen's new styles, defined in Figma. Should document them in Github better.
91
92    pub fn display_title(mut self) -> TextSpan {
93        self.font = Font::BungeeInlineRegular;
94        self.size = 64;
95        self
96    }
97    pub fn big_heading_styled(mut self) -> TextSpan {
98        self.font = Font::BungeeRegular;
99        self.size = 32;
100        self
101    }
102    pub fn big_heading_plain(mut self) -> TextSpan {
103        self.font = Font::OverpassBold;
104        self.size = 32;
105        self
106    }
107    pub fn small_heading(mut self) -> TextSpan {
108        self.font = Font::OverpassSemiBold;
109        self.size = 26;
110        self
111    }
112    // The default
113    pub fn body(mut self) -> TextSpan {
114        self.font = Font::OverpassRegular;
115        self.size = 21;
116        self
117    }
118    pub fn bold_body(mut self) -> TextSpan {
119        self.font = Font::OverpassBold;
120        self.size = 21;
121        self
122    }
123    pub fn secondary(mut self) -> TextSpan {
124        self.font = Font::OverpassRegular;
125        self.size = 21;
126        // TODO This should be per-theme
127        self.fg_color = Some(Color::hex("#A3A3A3"));
128        self
129    }
130    pub fn small(mut self) -> TextSpan {
131        self.font = Font::OverpassRegular;
132        self.size = 16;
133        self
134    }
135    pub fn big_monospaced(mut self) -> TextSpan {
136        self.font = Font::OverpassMonoBold;
137        self.size = 32;
138        self
139    }
140    pub fn small_monospaced(mut self) -> TextSpan {
141        self.font = Font::OverpassMonoBold;
142        self.size = 16;
143        self
144    }
145
146    pub fn underlined(mut self) -> TextSpan {
147        self.underlined = true;
148        self
149    }
150
151    pub fn size(mut self, size: usize) -> TextSpan {
152        self.size = size;
153        self
154    }
155
156    pub fn font(mut self, font: Font) -> TextSpan {
157        self.font = font;
158        self
159    }
160}
161
162// TODO What's the better way of doing this? Also "Line" is a bit of a misnomer
163#[allow(non_snake_case)]
164pub fn Line<S: Into<String>>(text: S) -> TextSpan {
165    TextSpan {
166        text: text.into(),
167        fg_color: None,
168        outline_color: None,
169        size: DEFAULT_FONT_SIZE,
170        font: DEFAULT_FONT,
171        underlined: false,
172    }
173}
174
175#[derive(Debug, Clone)]
176pub struct Text {
177    // The bg_color will cover the entire block, but some lines can have extra highlighting.
178    lines: Vec<(Option<Color>, Vec<TextSpan>)>,
179    // TODO Stop using this as much as possible.
180    bg_color: Option<Color>,
181}
182
183impl From<TextSpan> for Text {
184    fn from(line: TextSpan) -> Text {
185        let mut txt = Text::new();
186        txt.add_line(line);
187        txt
188    }
189}
190
191impl<AsStrRef: AsRef<str>> From<AsStrRef> for Text {
192    fn from(line: AsStrRef) -> Text {
193        let mut txt = Text::new();
194        txt.add_line(Line(line.as_ref()));
195        txt
196    }
197}
198
199impl Text {
200    pub fn new() -> Text {
201        Text {
202            lines: Vec::new(),
203            bg_color: None,
204        }
205    }
206
207    pub fn from_all(lines: Vec<TextSpan>) -> Text {
208        let mut txt = Text::new();
209        for l in lines {
210            txt.append(l);
211        }
212        txt
213    }
214
215    pub fn from_multiline(lines: Vec<impl Into<TextSpan>>) -> Text {
216        let mut txt = Text::new();
217        for l in lines {
218            txt.add_line(l.into());
219        }
220        txt
221    }
222
223    pub fn bg(mut self, bg: Color) -> Text {
224        assert!(self.bg_color.is_none());
225        self.bg_color = Some(bg);
226        self
227    }
228
229    // TODO Not exactly sure this is the right place for this, but better than code duplication
230    pub fn tooltip<MK: Into<Option<MultiKey>>>(ctx: &EventCtx, hotkey: MK, action: &str) -> Text {
231        if let Some(ref key) = hotkey.into() {
232            Text::from_all(vec![
233                Line(key.describe())
234                    .fg(ctx.style().text_hotkey_color)
235                    .small(),
236                Line(format!(" - {}", action)).small(),
237            ])
238        } else {
239            Text::from(Line(action).small())
240        }
241    }
242
243    pub fn change_fg(mut self, fg: Color) -> Text {
244        for (_, spans) in self.lines.iter_mut() {
245            for span in spans {
246                span.fg_color = Some(fg);
247            }
248        }
249        self
250    }
251
252    pub fn default_fg(mut self, fg: Color) -> Text {
253        for (_, spans) in self.lines.iter_mut() {
254            for span in spans {
255                if span.fg_color.is_none() {
256                    span.fg_color = Some(fg);
257                }
258            }
259        }
260        self
261    }
262
263    pub fn add_line(&mut self, line: impl Into<TextSpan>) {
264        self.lines.push((None, vec![line.into()]));
265    }
266
267    // TODO Just one user...
268    pub(crate) fn highlight_last_line(&mut self, highlight: Color) {
269        self.lines.last_mut().unwrap().0 = Some(highlight);
270    }
271
272    pub fn append(&mut self, line: TextSpan) {
273        if self.lines.is_empty() {
274            self.add_line(line);
275            return;
276        }
277
278        self.lines.last_mut().unwrap().1.push(line);
279    }
280
281    pub fn add_appended(&mut self, lines: Vec<TextSpan>) {
282        for (idx, l) in lines.into_iter().enumerate() {
283            if idx == 0 {
284                self.add_line(l);
285            } else {
286                self.append(l);
287            }
288        }
289    }
290
291    pub fn append_all(&mut self, lines: Vec<TextSpan>) {
292        for l in lines {
293            self.append(l);
294        }
295    }
296
297    pub fn remove_colors_from_last_line(&mut self) {
298        let (_, spans) = self.lines.last_mut().unwrap();
299        for span in spans {
300            span.fg_color = None;
301            span.outline_color = None;
302        }
303    }
304
305    pub fn is_empty(&self) -> bool {
306        self.lines.is_empty()
307    }
308
309    pub fn extend(&mut self, other: Text) {
310        self.lines.extend(other.lines);
311    }
312
313    pub(crate) fn dims(self, assets: &Assets) -> ScreenDims {
314        self.render(assets).get_dims()
315    }
316
317    pub fn rendered_width<A: AsRef<Assets>>(self, assets: &A) -> f64 {
318        self.dims(assets.as_ref()).width
319    }
320
321    /// Render the text, without any autocropping. You can pass in an `EventCtx` or `GfxCtx`.
322    pub fn render<A: AsRef<Assets>>(self, assets: &A) -> GeomBatch {
323        let assets: &Assets = assets.as_ref();
324        self.inner_render(assets, svg::HIGH_QUALITY)
325    }
326
327    pub(crate) fn inner_render(self, assets: &Assets, tolerance: f32) -> GeomBatch {
328        let hash_key = self.hash_key();
329        if let Some(batch) = assets.get_cached_text(&hash_key) {
330            return batch;
331        }
332
333        let mut output_batch = GeomBatch::new();
334        let mut master_batch = GeomBatch::new();
335
336        let mut y = 0.0;
337        let mut max_width = 0.0_f64;
338        // TODO Can we make usvg do the work of layouting multiple lines too?
339        // https://www.oreilly.com/library/view/svg-text-layout/9781491933817/ch04.html
340        for (line_color, line) in self.lines {
341            // In case size changes mid-line, take the max of every span.
342            // (f64 isn't Ord, so no max(), so do this manually.)
343            let mut line_height = 0.0_f64;
344            for span in &line {
345                line_height = line_height.max(assets.line_height(span.font, span.size));
346            }
347
348            let line_batch = render_line(line, tolerance, assets);
349            let line_dims = if line_batch.is_empty() {
350                ScreenDims::new(0.0, line_height)
351            } else {
352                // Also lie a little about width to make things look reasonable. TODO Probably
353                // should tune based on font size.
354                ScreenDims::new(line_batch.get_dims().width + 5.0, line_height)
355            };
356
357            if let Some(c) = line_color {
358                master_batch.push(
359                    c,
360                    Polygon::rectangle(line_dims.width, line_dims.height).translate(0.0, y),
361                );
362            }
363
364            y += line_dims.height;
365
366            // Add all of the padding at the bottom of the line.
367            let offset = line_height / SCALE_LINE_HEIGHT * 0.2;
368            master_batch.append(line_batch.translate(0.0, y - offset));
369
370            max_width = max_width.max(line_dims.width);
371        }
372
373        if let Some(c) = self.bg_color {
374            output_batch.push(c, Polygon::rectangle(max_width, y));
375        }
376        output_batch.append(master_batch);
377        output_batch.autocrop_dims = false;
378
379        assets.cache_text(hash_key, output_batch.clone());
380        output_batch
381    }
382
383    /// Render the text, autocropping blank space out of the result. You can pass in an `EventCtx`
384    /// or `GfxCtx`.
385    pub fn render_autocropped<A: AsRef<Assets>>(self, assets: &A) -> GeomBatch {
386        let mut batch = self.render(assets);
387        batch.autocrop_dims = true;
388        batch.autocrop()
389    }
390
391    fn hash_key(&self) -> String {
392        let mut hasher = DefaultHasher::new();
393        hasher.write(format!("{:?}", self).as_ref());
394        format!("{:x}", hasher.finish())
395    }
396
397    pub fn into_widget(self, ctx: &EventCtx) -> Widget {
398        JustDraw::wrap(ctx, self.render(ctx))
399    }
400    pub fn batch(self, ctx: &EventCtx) -> Widget {
401        DeferDraw::new_widget(self.render(ctx))
402    }
403
404    pub fn wrap_to_pct(self, ctx: &EventCtx, pct: usize) -> Text {
405        self.wrap_to_pixels(ctx, (pct as f64) / 100.0 * ctx.canvas.window_width)
406    }
407
408    pub fn wrap_to_pixels(self, ctx: &EventCtx, limit: f64) -> Text {
409        self.inner_wrap_to_pixels(limit, &ctx.prerender.assets)
410    }
411
412    pub(crate) fn inner_wrap_to_pixels(mut self, limit: f64, assets: &Assets) -> Text {
413        let mut lines = Vec::new();
414        for (bg, spans) in self.lines.drain(..) {
415            // First optimistically assume everything just fits.
416            if render_line(spans.clone(), svg::LOW_QUALITY, assets)
417                .get_dims()
418                .width
419                < limit
420            {
421                lines.push((bg, spans));
422                continue;
423            }
424
425            // Greedy approach, fit as many words on a line as possible. Don't do all of that
426            // hyphenation nonsense.
427            let mut width_left = limit;
428            let mut current_line = Vec::new();
429            for span in spans {
430                let mut current_span = span.clone();
431                current_span.text = String::new();
432                for word in span.text.split_whitespace() {
433                    let width = render_line(
434                        vec![TextSpan {
435                            text: word.to_string(),
436                            size: span.size,
437                            font: span.font,
438                            fg_color: span.fg_color,
439                            outline_color: span.outline_color,
440                            underlined: span.underlined,
441                        }],
442                        svg::LOW_QUALITY,
443                        assets,
444                    )
445                    .get_dims()
446                    .width;
447                    if width_left > width {
448                        current_span.text.push(' ');
449                        current_span.text.push_str(word);
450                        width_left -= width;
451                    } else {
452                        current_line.push(current_span);
453                        lines.push((bg, current_line.drain(..).collect()));
454
455                        current_span = span.clone();
456                        current_span.text = word.to_string();
457                        width_left = limit;
458                    }
459                }
460                if !current_span.text.is_empty() {
461                    current_line.push(current_span);
462                }
463            }
464            if !current_line.is_empty() {
465                lines.push((bg, current_line));
466            }
467        }
468        self.lines = lines;
469        self
470    }
471}
472
473fn render_line(spans: Vec<TextSpan>, tolerance: f32, assets: &Assets) -> GeomBatch {
474    // Just set a sufficiently large view box
475    let mut svg = r##"<svg width="9999" height="9999" viewBox="0 0 9999 9999" xmlns="http://www.w3.org/2000/svg">"##.to_string();
476
477    write!(&mut svg, r##"<text x="0" y="0" xml:space="preserve">"##,).unwrap();
478
479    let mut contents = String::new();
480    for span in spans {
481        let fg_color = span.fg_color_for_style(&assets.style.borrow());
482        write!(
483            &mut contents,
484            r##"<tspan font-size="{}" font-family="{}" {} fill="{}" fill-opacity="{}" {}{}>{}</tspan>"##,
485            span.size,
486            span.font.family(),
487            match span.font {
488                Font::OverpassBold => "font-weight=\"bold\"",
489                Font::OverpassSemiBold => "font-weight=\"600\"",
490                _ => "",
491            },
492            fg_color.as_hex(),
493            fg_color.a,
494            if span.underlined {
495                "text-decoration=\"underline\""
496            } else {
497                ""
498            },
499            if let Some(c) = span.outline_color {
500                format!("stroke=\"{}\"", c.as_hex())
501            } else {
502                String::new()
503            },
504            htmlescape::encode_minimal(&span.text)
505        )
506        .unwrap();
507    }
508    write!(&mut svg, "{}</text></svg>", contents).unwrap();
509
510    let mut svg_tree = match usvg::Tree::from_str(&svg, &usvg::Options::default()) {
511        Ok(t) => t,
512        Err(err) => panic!("render_line({}): {}", contents, err),
513    };
514    svg_tree.convert_text(&assets.fontdb.borrow());
515    let mut batch = GeomBatch::new();
516    match crate::svg::add_svg_inner(&mut batch, svg_tree, tolerance) {
517        Ok(_) => batch,
518        Err(err) => {
519            error!("render_line({}): {}", contents, err);
520            // We'll just wind up with a blank line
521            batch
522        }
523    }
524}
525
526pub trait TextExt {
527    fn text_widget(self, ctx: &EventCtx) -> Widget;
528    fn batch_text(self, ctx: &EventCtx) -> Widget;
529}
530
531impl TextExt for &str {
532    fn text_widget(self, ctx: &EventCtx) -> Widget {
533        Line(self).into_widget(ctx)
534    }
535    fn batch_text(self, ctx: &EventCtx) -> Widget {
536        Line(self).batch(ctx)
537    }
538}
539
540impl TextExt for String {
541    fn text_widget(self, ctx: &EventCtx) -> Widget {
542        Line(self).into_widget(ctx)
543    }
544    fn batch_text(self, ctx: &EventCtx) -> Widget {
545        Line(self).batch(ctx)
546    }
547}
548
549impl TextSpan {
550    // TODO Copies from render_line a fair amount
551    pub fn render_curvey<A: AsRef<Assets>>(
552        self,
553        assets: &A,
554        path: &PolyLine,
555        scale: f64,
556    ) -> GeomBatch {
557        let assets = assets.as_ref();
558        let tolerance = svg::HIGH_QUALITY;
559        let mut stroke_parameters = String::new();
560
561        if let Some(c) = self.outline_color {
562            stroke_parameters = format!("stroke=\"{}\" stroke-width=\".1\"", c.as_hex());
563        };
564
565        // Just set a sufficiently large view box
566        let mut svg = r##"<svg width="9999" height="9999" viewBox="0 0 9999 9999" xmlns="http://www.w3.org/2000/svg">"##.to_string();
567
568        write!(
569            &mut svg,
570            r##"<path id="txtpath" fill="none" stroke="none" d=""##
571        )
572        .unwrap();
573        write!(
574            &mut svg,
575            "M {} {}",
576            path.points()[0].x(),
577            path.points()[0].y()
578        )
579        .unwrap();
580        for pt in path.points().iter().skip(1) {
581            write!(&mut svg, " L {} {}", pt.x(), pt.y()).unwrap();
582        }
583        write!(&mut svg, "\" />").unwrap();
584        // We need to subtract and account for the length of the text
585        let start_offset = (path.length().inner_meters()
586            - scale * Text::from(&self.text).rendered_width(&assets))
587            / 2.0;
588
589        let fg_color = self.fg_color_for_style(&assets.style.borrow());
590
591        write!(
592            &mut svg,
593            r##"<text xml:space="preserve" font-size="{}" font-family="{}" {} fill="{}" fill-opacity="{}" startOffset="{}" {}>"##,
594            // This is seemingly the easiest way to do this. We could .scale() the whole batch
595            // after, but then we have to re-translate it to the proper spot
596            (self.size as f64) * scale,
597            self.font.family(),
598            match self.font {
599                Font::OverpassBold => "font-weight=\"bold\"",
600                Font::OverpassSemiBold => "font-weight=\"600\"",
601                _ => "",
602            },
603            fg_color.as_hex(),
604            fg_color.a,
605            start_offset,
606            stroke_parameters,
607        )
608            .unwrap();
609
610        write!(
611            &mut svg,
612            r##"<textPath href="#txtpath">{}</textPath></text></svg>"##,
613            htmlescape::encode_minimal(&self.text)
614        )
615        .unwrap();
616
617        let mut svg_tree = match usvg::Tree::from_str(&svg, &usvg::Options::default()) {
618            Ok(t) => t,
619            Err(err) => panic!("curvey({}): {}", self.text, err),
620        };
621        svg_tree.convert_text(&assets.fontdb.borrow());
622        let mut batch = GeomBatch::new();
623        match crate::svg::add_svg_inner(&mut batch, svg_tree, tolerance) {
624            Ok(_) => batch,
625            Err(err) => {
626                error!("render_curvey({}): {}", self.text, err);
627                batch
628            }
629        }
630    }
631}