Skip to main content

rocketsplash_rt/font/
cls_text_builder.rs

1// <FILE>crates/rocketsplash-rt/src/font/cls_text_builder.rs</FILE>
2// <DESC>Builder for configuring text rendering</DESC>
3// <VERS>VERSION: 1.1.0</VERS>
4// <WCTX>Runtime library implementation</WCTX>
5// <CLOG>Add word-level colors and safe shadow padding</CLOG>
6
7use std::io::Write;
8
9use crate::color::ColorMode;
10use crate::font::{render_text, Font, RenderOptions};
11use crate::render::{apply_color, apply_shadow, write_ansi, ColorFill, RenderBuffer};
12use crate::{Align, Color, Error, FallbackMode, GradientDirection, TextStyle};
13
14#[derive(Clone, Debug)]
15pub struct TextBuilder<'a> {
16    font: &'a Font,
17    text: String,
18    fill: Option<ColorFillInput>,
19    word_colors: Option<Vec<Color>>,
20    shadow: Option<ShadowInput>,
21    style: TextStyle,
22    spacing: i8,
23    align: Align,
24    fallback: FallbackMode,
25    color_mode: ColorMode,
26}
27
28#[derive(Clone, Debug)]
29enum ColorFillInput {
30    Solid(Color),
31    Gradient {
32        start: Color,
33        end: Color,
34        vertical: bool,
35    },
36}
37
38#[derive(Clone, Debug)]
39struct ShadowInput {
40    dx: i8,
41    dy: i8,
42    color: Color,
43}
44
45impl<'a> TextBuilder<'a> {
46    pub(crate) fn new(font: &'a Font, text: &str) -> Self {
47        Self {
48            font,
49            text: text.to_string(),
50            fill: None,
51            word_colors: None,
52            shadow: None,
53            style: TextStyle::empty(),
54            spacing: 0,
55            align: Align::Left,
56            fallback: FallbackMode::Error,
57            color_mode: ColorMode::TrueColor,
58        }
59    }
60
61    pub fn color(mut self, color: impl Into<Color>) -> Self {
62        self.fill = Some(ColorFillInput::Solid(color.into()));
63        self
64    }
65
66    pub fn gradient(mut self, start: impl Into<Color>, end: impl Into<Color>) -> Self {
67        self.fill = Some(ColorFillInput::Gradient {
68            start: start.into(),
69            end: end.into(),
70            vertical: false,
71        });
72        self
73    }
74
75    pub fn vertical_gradient(mut self, top: impl Into<Color>, bottom: impl Into<Color>) -> Self {
76        self.fill = Some(ColorFillInput::Gradient {
77            start: top.into(),
78            end: bottom.into(),
79            vertical: true,
80        });
81        self
82    }
83
84    pub fn gradient_direction(
85        mut self,
86        start: impl Into<Color>,
87        end: impl Into<Color>,
88        direction: GradientDirection,
89    ) -> Self {
90        let vertical = matches!(direction, GradientDirection::Vertical);
91        self.fill = Some(ColorFillInput::Gradient {
92            start: start.into(),
93            end: end.into(),
94            vertical,
95        });
96        self
97    }
98
99    pub fn word_colors<I, C>(mut self, colors: I) -> Self
100    where
101        I: IntoIterator<Item = C>,
102        C: Into<Color>,
103    {
104        let collected: Vec<Color> = colors.into_iter().map(Into::into).collect();
105        self.word_colors = Some(collected);
106        self
107    }
108
109    pub fn shadow(mut self, dx: i8, dy: i8, color: impl Into<Color>) -> Self {
110        self.shadow = Some(ShadowInput {
111            dx,
112            dy,
113            color: color.into(),
114        });
115        self
116    }
117
118    pub fn drop_shadow(mut self) -> Self {
119        self.shadow = Some(ShadowInput {
120            dx: 1,
121            dy: 1,
122            color: Color::rgb(0, 0, 0),
123        });
124        self
125    }
126
127    pub fn style(mut self, style: TextStyle) -> Self {
128        self.style = style;
129        self
130    }
131
132    pub fn spacing(mut self, adjust: i8) -> Self {
133        self.spacing = adjust.clamp(-10, 100);
134        self
135    }
136
137    pub fn align(mut self, align: Align) -> Self {
138        self.align = align;
139        self
140    }
141
142    pub fn fallback(mut self, mode: FallbackMode) -> Self {
143        self.fallback = mode;
144        self
145    }
146
147    pub fn color_mode(mut self, mode: ColorMode) -> Self {
148        self.color_mode = mode;
149        self
150    }
151
152    pub fn build(self) -> Result<String, Error> {
153        let buffer = self.build_buffer_ref()?;
154        let mut bytes = Vec::new();
155        write_ansi(&buffer, self.color_mode, &mut bytes)?;
156        String::from_utf8(bytes).map_err(|err| Error::InvalidFormat {
157            message: err.to_string(),
158        })
159    }
160
161    pub fn write_to<W: Write>(self, w: &mut W) -> std::io::Result<()> {
162        let buffer = self
163            .build_buffer_ref()
164            .map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidData, err))?;
165        write_ansi(&buffer, self.color_mode, w)
166    }
167
168    pub fn write_to_string(self, s: &mut String) -> Result<(), Error> {
169        let output = self.build()?;
170        s.push_str(&output);
171        Ok(())
172    }
173
174    pub fn build_buffer(self) -> Result<RenderBuffer, Error> {
175        self.build_buffer_ref()
176    }
177
178    fn build_buffer_ref(&self) -> Result<RenderBuffer, Error> {
179        let options = RenderOptions {
180            style: self.style,
181            spacing: self.spacing,
182            align: self.align,
183            fallback: self.fallback,
184        };
185        let mut buffer = if let Some(colors) = &self.word_colors {
186            build_word_color_buffer(self.font, &self.text, &options, colors)?
187        } else {
188            let mut buffer = render_text(self.font, &self.text, &options)?;
189            if let Some(fill) = self.resolve_fill()? {
190                apply_color(&mut buffer, &fill);
191            }
192            buffer
193        };
194        if let Some(shadow) = &self.shadow {
195            pad_buffer_for_shadow(&mut buffer, shadow.dx, shadow.dy);
196            let shadow_color = shadow.color.to_rgb()?;
197            apply_shadow(&mut buffer, shadow.dx, shadow.dy, shadow_color);
198        }
199        Ok(buffer)
200    }
201
202    fn resolve_fill(&self) -> Result<Option<ColorFill>, Error> {
203        match &self.fill {
204            None => Ok(None),
205            Some(ColorFillInput::Solid(color)) => Ok(Some(ColorFill::Solid(color.to_rgb()?))),
206            Some(ColorFillInput::Gradient {
207                start,
208                end,
209                vertical,
210            }) => Ok(Some(ColorFill::Gradient {
211                start: start.to_rgb()?,
212                end: end.to_rgb()?,
213                vertical: *vertical,
214            })),
215        }
216    }
217}
218
219fn pad_buffer_for_shadow(buffer: &mut RenderBuffer, dx: i8, dy: i8) {
220    if buffer.width == 0 || buffer.height == 0 {
221        return;
222    }
223    let dx = i16::from(dx);
224    let dy = i16::from(dy);
225    let pad_left = if dx < 0 { (-dx) as usize } else { 0 };
226    let pad_right = if dx > 0 { dx as usize } else { 0 };
227    let pad_top = if dy < 0 { (-dy) as usize } else { 0 };
228    let pad_bottom = if dy > 0 { dy as usize } else { 0 };
229    if pad_left == 0 && pad_right == 0 && pad_top == 0 && pad_bottom == 0 {
230        return;
231    }
232    let new_width = buffer.width.saturating_add(pad_left + pad_right);
233    let new_height = buffer.height.saturating_add(pad_top + pad_bottom);
234    let mut next = RenderBuffer::new(new_width, new_height);
235    for y in 0..buffer.height {
236        for x in 0..buffer.width {
237            let idx = y * buffer.width + x;
238            let target_x = x + pad_left;
239            let target_y = y + pad_top;
240            let target_idx = target_y * new_width + target_x;
241            if let Some(cell) = next.cells.get_mut(target_idx) {
242                *cell = buffer.cells[idx];
243            }
244        }
245    }
246    *buffer = next;
247}
248
249fn build_word_color_buffer(
250    font: &Font,
251    text: &str,
252    options: &RenderOptions,
253    colors: &[Color],
254) -> Result<RenderBuffer, Error> {
255    if text.contains('\n') {
256        return Err(Error::InvalidFormat {
257            message: "word_colors only supports single-line text".to_string(),
258        });
259    }
260    if colors.is_empty() {
261        return Err(Error::InvalidFormat {
262            message: "word_colors requires at least one color".to_string(),
263        });
264    }
265    let segments = split_segments(text);
266    let mut buffers = Vec::new();
267    let mut color_index = 0usize;
268    for segment in segments {
269        if segment.text.is_empty() {
270            continue;
271        }
272        let mut buffer = render_text(font, &segment.text, options)?;
273        if segment.is_word {
274            let color = colors
275                .get(color_index)
276                .or_else(|| colors.last())
277                .ok_or_else(|| Error::InvalidFormat {
278                    message: "word_colors requires at least one color".to_string(),
279                })?;
280            apply_color(&mut buffer, &ColorFill::Solid(color.to_rgb()?));
281            color_index = color_index.saturating_add(1);
282        }
283        buffers.push(buffer);
284    }
285    Ok(concat_buffers_with_spacing(&buffers, options.spacing))
286}
287
288#[derive(Clone, Debug)]
289struct Segment {
290    text: String,
291    is_word: bool,
292}
293
294fn split_segments(text: &str) -> Vec<Segment> {
295    let mut segments = Vec::new();
296    let mut current = String::new();
297    let mut is_word = None;
298    for ch in text.chars() {
299        let current_is_word = !ch.is_whitespace();
300        match is_word {
301            None => {
302                is_word = Some(current_is_word);
303                current.push(ch);
304            }
305            Some(active) if active == current_is_word => {
306                current.push(ch);
307            }
308            Some(active) => {
309                segments.push(Segment {
310                    text: current.clone(),
311                    is_word: active,
312                });
313                current.clear();
314                current.push(ch);
315                is_word = Some(current_is_word);
316            }
317        }
318    }
319    if let Some(active) = is_word {
320        if !current.is_empty() {
321            segments.push(Segment {
322                text: current,
323                is_word: active,
324            });
325        }
326    }
327    segments
328}
329
330fn concat_buffers_with_spacing(buffers: &[RenderBuffer], spacing: i8) -> RenderBuffer {
331    if buffers.is_empty() {
332        return RenderBuffer::new(0, 0);
333    }
334    let height = buffers.iter().map(|buf| buf.height).max().unwrap_or(0);
335    let spacing = spacing as i64;
336    let mut placements: Vec<(i64, &RenderBuffer)> = Vec::new();
337    let mut cursor = 0i64;
338    let mut min_x = 0i64;
339    let mut max_x = 0i64;
340    for (idx, buffer) in buffers.iter().enumerate() {
341        placements.push((cursor, buffer));
342        min_x = min_x.min(cursor);
343        max_x = max_x.max(cursor + buffer.width as i64);
344        cursor += buffer.width as i64;
345        if idx + 1 < buffers.len() {
346            cursor += spacing;
347        }
348    }
349    let shift = if min_x < 0 { -min_x } else { 0 };
350    let width = (max_x + shift).max(0) as usize;
351    let mut output = RenderBuffer::new(width, height);
352    for (x_offset, buffer) in placements {
353        let base_x = (x_offset + shift) as usize;
354        for y in 0..buffer.height {
355            for x in 0..buffer.width {
356                let src_idx = y * buffer.width + x;
357                let dst_idx = y * width + base_x + x;
358                if let Some(cell) = output.cells.get_mut(dst_idx) {
359                    *cell = buffer.cells[src_idx];
360                }
361            }
362        }
363    }
364    output
365}
366
367#[cfg(test)]
368mod tests {
369    use std::collections::BTreeMap;
370
371    use rocketsplash_formats::{
372        FontAtlas, FontMeta, GlyphData, GlyphVariants, RenderMode, StyleFlags, FONT_ATLAS_VERSION,
373    };
374
375    use crate::font::Font;
376
377    #[test]
378    fn drop_shadow_defaults_expand_buffer() {
379        let font = make_font();
380        let builder = font.render("A").drop_shadow();
381        let buffer = builder.build_buffer().expect("buffer builds");
382        assert_eq!(buffer.width, 2);
383        assert_eq!(buffer.height, 2);
384        let base = buffer.cell(0, 0).expect("base cell");
385        assert_eq!(base.ch, 'A');
386        let shadow = buffer.cell(1, 1).expect("shadow cell");
387        assert_eq!(shadow.ch, 'A');
388        assert!(shadow.fg.is_some());
389    }
390
391    fn make_font() -> Font {
392        let glyph = make_glyph('A');
393        let variants = GlyphVariants {
394            base: glyph,
395            bold: None,
396            italic: None,
397            bold_italic: None,
398            reverse: None,
399        };
400        let mut glyphs = BTreeMap::new();
401        glyphs.insert('A', variants);
402        let atlas = FontAtlas {
403            version: FONT_ATLAS_VERSION,
404            meta: FontMeta {
405                font_name: "Test".to_string(),
406                font_size: 12.0,
407                created_at: 0,
408                editor_version: "test".to_string(),
409            },
410            glyphs,
411            line_height: 1,
412            mode: RenderMode::Braille,
413            available_styles: StyleFlags::empty(),
414        };
415        let bytes = rmp_serde::to_vec(&atlas).expect("serialize atlas");
416        Font::from_bytes(&bytes).expect("load font")
417    }
418
419    fn make_glyph(ch: char) -> GlyphData {
420        GlyphData {
421            chars: ch.to_string(),
422            width: 1,
423            height: 1,
424            opacity: Some(vec![255]),
425        }
426    }
427}
428
429// <FILE>crates/rocketsplash-rt/src/font/cls_text_builder.rs</FILE>
430// <VERS>END OF VERSION: 1.1.0</VERS>