vertra 0.3.0

A cross-platform graphics editor built with Rust and WebAssembly.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
//! Individual screen-space text label representation.

/// Horizontal anchor mode for a text label.
///
/// Controls how the window resize handler repositions the label's `x`
/// coordinate when the viewport dimensions change.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum HorizontalAlignment {
    /// Fixed pixel offset from the **left** edge (default).
    #[default]
    Left,
    /// Always horizontally **centred** in the viewport.
    Center,
    /// Fixed pixel offset from the **right** edge.
    Right,
    /// **Freely positioned**, no edge anchoring.
    ///
    /// `label.x` is treated as an absolute pixel coordinate at all times.
    /// On window resize the position scales proportionally with the viewport
    /// width so the label stays at the same relative position.
    /// Editor drags are preserved across re-bakes because `resolve_screen_x`
    /// returns `label.x` directly instead of recovering a stored margin.
    Free,
}

impl HorizontalAlignment {
    /// Encode as the `u8` tag used in the VTR binary format.
    pub(crate) fn to_u8(self) -> u8 {
        match self { Self::Left => 0, Self::Center => 1, Self::Right => 2, Self::Free => 3 }
    }
    /// Decode from the VTR binary tag (unknown -> `Left`).
    pub(crate) fn from_u8(v: u8) -> Self {
        match v { 1 => Self::Center, 2 => Self::Right, 3 => Self::Free, _ => Self::Left }
    }
}

/// Vertical anchor mode for a text label.
///
/// Controls how the window resize handler and initial placement set the
/// label's `y` screen coordinate when the viewport dimensions change.
///
/// `x` is always handled according to [`HorizontalAlignment`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum VerticalAlignment {
    /// Fixed pixel offset from the **top** edge (default).
    #[default]
    Top,
    /// Always vertically **centred** in the viewport.
    Middle,
    /// Fixed pixel offset from the **bottom** edge.
    Bottom,
    /// **Freely positioned**, no edge anchoring.
    ///
    /// `label.y` is treated as an absolute pixel coordinate at all times.
    /// On window resize the position scales proportionally with the viewport
    /// height so the label stays at the same relative vertical position.
    /// Editor drags are preserved across re-bakes because `resolve_screen_y`
    /// returns `label.y` directly instead of recovering a stored margin.
    Free,
}

impl VerticalAlignment {
    /// Encode as the `u8` tag used in the VTR binary format.
    pub(crate) fn to_u8(self) -> u8 {
        match self { Self::Top => 0, Self::Middle => 1, Self::Bottom => 2, Self::Free => 3 }
    }
    /// Decode from the VTR binary tag (unknown -> `Top`).
    pub(crate) fn from_u8(v: u8) -> Self {
        match v { 1 => Self::Middle, 2 => Self::Bottom, 3 => Self::Free, _ => Self::Top }
    }
}

/// Well-known font string IDs loaded by the `default-fonts` feature.
///
/// | Variant | String ID | File                  | Font         |
/// |---------|-----------|-----------------------|--------------|
/// | `Sans`  | `"sans"`  | `src/fonts/sans.ttf`  | Google Sans  |
/// | `Serif` | `"serif"` | `src/fonts/serif.ttf` | Roboto Serif |
/// | `Mono`  | `"mono"`  | `src/fonts/mono.ttf`  | Roboto Mono  |
///
/// Use [`DefaultFont::id`] to obtain the string ID, e.g.:
/// ```rust,ignore
/// builder.with_font(DefaultFont::Sans.id())
/// ```
#[cfg(feature = "default-fonts")]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DefaultFont {
    /// The sans-serif face (string id `"sans"`).
    Sans,
    /// The serif face (string id `"serif"`).
    Serif,
    /// The monospace face (string id `"mono"`).
    Mono,
}

#[cfg(feature = "default-fonts")]
impl DefaultFont {
    /// Returns the string font ID for this variant.
    pub fn id(self) -> &'static str {
        match self {
            Self::Sans  => "sans",
            Self::Serif => "serif",
            Self::Mono  => "mono",
        }
    }
}

/// A single screen-space text label.
///
/// Positions are in **pixel coordinates** from the top-left corner of the
/// viewport.  Obtain one via [`crate::text_overlay::TextOverlay::add_label`] + [`TextLabelBuilder::build`]
/// and read it back through [`TextLabelHandle::label`].
#[derive(Debug, Clone)]
pub struct TextLabel {
    /// Unique identifier.
    pub id: usize,
    /// The string to display.
    pub text: String,
    /// Horizontal pixel position (absolute screen x after first bake;
    /// equals `margin_x` for Left-aligned labels).
    pub x: f32,
    /// Vertical pixel position (absolute screen y after first bake;
    /// equals `margin_y` for Top-aligned labels).
    pub y: f32,
    /// Font size in pixels.
    pub font_size: f32,
    /// RGBA colour in `[0.0, 1.0]` linear space.
    pub color: [f32; 4],
    /// Whether this label is rendered this frame.
    pub visible: bool,
    /// String ID of the font to use (see [`crate::text_overlay::TextOverlay::add_font`]).
    /// An empty string means "use the first loaded font".
    pub font_id: String,
    /// Drawing order: lower values are rendered first (further back).
    /// Defaults to the insertion order index so that labels added later
    /// appear on top.
    pub zindex: i32,
    /// Horizontal alignment / resize anchor for window resize handling.
    pub horizontal_alignment: HorizontalAlignment,
    /// Vertical alignment / resize anchor for window resize handling.
    pub vertical_alignment: VerticalAlignment,
    /// Set whenever a property changes so the GPU texture is re-uploaded.
    pub dirty: bool,
    /// Set when only the position (x/y) changed — rebuilds the vertex buffer
    /// but skips the expensive CPU rasterization and GPU texture re-upload.
    pub position_dirty: bool,
    /// Actual pixel width of the last rasterized bitmap (0 until first render).
    pub rasterized_w: u32,
    /// Actual pixel height of the last rasterized bitmap (0 until first render).
    pub rasterized_h: u32,
    /// The `font_size` value that was in effect when the bitmap was last
    /// rasterised.  Used to scale the on-screen quad during a resize drag
    /// without re-rasterising (draft mode).  0.0 means "not yet rasterised".
    pub rasterized_font_size: f32,
    /// Viewport width (pixels) at the time the bitmap was last rasterised.
    /// 0.0 until the first render.
    pub rasterized_vp_w: f32,
    /// Viewport height (pixels) at the time the bitmap was last rasterised.
    /// 0.0 until the first render.
    pub rasterized_vp_h: f32,
    /// The **semantic** x margin from [`TextLabelBuilder::at`].
    ///
    /// * `Left`   - pixels from the left edge (equals `x` at all times).
    /// * `Center` - ignored by the renderer.
    /// * `Right`  - pixels from the **right** edge; never changes on window resize.
    ///
    /// `resolve_screen_x` uses this value directly, so Right-aligned labels
    /// always stay exactly `margin_x` pixels from the right edge regardless of
    /// viewport size or font-size changes.
    pub margin_x: f32,
    /// The **semantic** y margin from [`TextLabelBuilder::at`].
    ///
    /// * `Top`    - pixels from the top edge (equals `y` at all times).
    /// * `Middle` - ignored by the renderer.
    /// * `Bottom` - pixels from the **bottom** edge; never changes on window resize.
    pub margin_y: f32,
}

/// Fluent builder for creating a new [`TextLabel`].
///
/// Returned by [`crate::text_overlay::TextOverlay::add_label`].
/// Call [`build`](Self::build) to insert the label and receive a
/// [`TextLabelHandle`].
pub struct TextLabelBuilder<'a> {
    pub(crate) overlay:             &'a mut crate::text_overlay::TextOverlay,
    pub(crate) text:                String,
    pub(crate) x:                   f32,
    pub(crate) y:                   f32,
    pub(crate) font_size:           f32,
    pub(crate) color:               [f32; 4],
    pub(crate) font_id:             String,
    pub(crate) visible:             bool,
    pub(crate) zindex:              Option<i32>,
    pub(crate) horizontal_alignment: HorizontalAlignment,
    pub(crate) vertical_alignment:  VerticalAlignment,
}

impl<'a> TextLabelBuilder<'a> {
    /// Set the pixel position `(x, y)` from the top-left corner.
    pub fn at(mut self, x: f32, y: f32) -> Self {
        self.x = x; self.y = y; self
    }

    /// Set the RGBA colour `[r, g, b, a]` in `[0.0, 1.0]`.
    pub fn with_color(mut self, color: [f32; 4]) -> Self {
        self.color = color; self
    }

    /// Set the font size in pixels.
    pub fn with_font_size(mut self, size: f32) -> Self {
        self.font_size = size; self
    }

    /// Choose a font by its string ID (see [`crate::text_overlay::TextOverlay::add_font`]).
    ///
    /// An empty string or no call to `with_font` uses the first loaded font.
    pub fn with_font(mut self, font_id: impl Into<String>) -> Self {
        self.font_id = font_id.into(); self
    }

    /// Override the draw order.  Lower values render first (further back).
    /// When not called, defaults to insertion order.
    pub fn with_zindex(mut self, z: i32) -> Self {
        self.zindex = Some(z); self
    }

    /// Set the horizontal alignment / resize anchor.
    pub fn with_horizontal_alignment(mut self, alignment: HorizontalAlignment) -> Self {
        self.horizontal_alignment = alignment;
        self
    }

    /// Set the vertical alignment / resize anchor.
    pub fn with_vertical_alignment(mut self, alignment: VerticalAlignment) -> Self {
        self.vertical_alignment = alignment;
        self
    }

    /// Start the label hidden; call [`TextLabelHandle::show`] to reveal it.
    pub fn hidden(mut self) -> Self {
        self.visible = false; self
    }

    /// Insert the label into the overlay and return a handle.
    ///
    /// The mutable borrow of the overlay ends here, so you can use the scene
    /// freely afterwards.
    pub fn build(self) -> TextLabelHandle {
        let id = self.overlay.next_id;
        self.overlay.next_id += 1;
        let zindex = self.zindex.unwrap_or(id as i32);
        self.overlay.labels.insert(id, TextLabel {
            id,
            text:               self.text,
            x:                  self.x,
            y:                  self.y,
            font_size:          self.font_size,
            color:              self.color,
            visible:            self.visible,
            font_id:            self.font_id,
            zindex,
            horizontal_alignment: self.horizontal_alignment,
            vertical_alignment: self.vertical_alignment,
            dirty:              true,
            position_dirty:     false,
            rasterized_w:       0,
            rasterized_h:       0,
            rasterized_font_size: 0.0,
            rasterized_vp_w:    0.0,
            rasterized_vp_h:    0.0,
            margin_x:           self.x,
            margin_y:           self.y,
        });
        TextLabelHandle { id }
    }
}

/// A lightweight, copyable handle to a text label.
///
/// All mutation methods take `&mut TextOverlay` explicitly, which keeps the
/// handle lifetime-independent and allows it to be stored anywhere.
///
/// ```rust,ignore
/// let score = overlay.add_label("0").at(20.0, 20.0).build();
///
/// score.set_text(&mut overlay, "42");
/// score.set_font(&mut overlay, "mono");
/// score.remove(&mut overlay);
/// ```
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct TextLabelHandle {
    /// The numeric label ID.  You can construct a handle from a bare ID with
    /// `TextLabelHandle { id }` when crossing FFI boundaries.
    pub id: usize,
}

impl TextLabelHandle {
    /// Borrow the underlying [`TextLabel`], or `None` if already removed.
    pub fn label<'a>(&self, overlay: &'a crate::text_overlay::TextOverlay) -> Option<&'a TextLabel> {
        overlay.labels.get(&self.id)
    }

    /// Returns `true` if the label still exists in `overlay`.
    pub fn exists(&self, overlay: &crate::text_overlay::TextOverlay) -> bool {
        overlay.labels.contains_key(&self.id)
    }

    /// Replace the displayed text.  Returns `false` if the label was removed.
    pub fn set_text(&self, overlay: &mut crate::text_overlay::TextOverlay, text: impl Into<String>) -> bool {
        if let Some(l) = overlay.labels.get_mut(&self.id) {
            l.text  = text.into();
            l.dirty = true;
            true
        } else { false }
    }

    /// Move to a new pixel position.  Returns `false` if already removed.
    ///
    /// Switches both [`HorizontalAlignment`] and [`VerticalAlignment`] to
    /// [`HorizontalAlignment::Free`] / [`VerticalAlignment::Free`] so that the
    /// new position is preserved across re-bakes.  Without switching to `Free`,
    /// any non-`Free` alignment would cause `resolve_screen_x` /
    /// `resolve_screen_y` to recompute the position from the unchanged
    /// `margin_x` / `margin_y` on the next full re-bake (e.g. after `set_text`
    /// or a window resize), silently undoing the move.
    pub fn move_to(&self, overlay: &mut crate::text_overlay::TextOverlay, x: f32, y: f32) -> bool {
        if let Some(l) = overlay.labels.get_mut(&self.id) {
            l.x = x;
            l.y = y;
            l.margin_x = x;
            l.margin_y = y;
            l.horizontal_alignment = HorizontalAlignment::Free;
            l.vertical_alignment   = VerticalAlignment::Free;
            l.position_dirty = true;
            true
        } else { false }
    }

    /// Change the RGBA colour.  Returns `false` if already removed.
    pub fn set_color(&self, overlay: &mut crate::text_overlay::TextOverlay, color: [f32; 4]) -> bool {
        if let Some(l) = overlay.labels.get_mut(&self.id) {
            l.color = color; l.dirty = true; true
        } else { false }
    }

    /// Change the font size in pixels.  Returns `false` if already removed.
    pub fn set_font_size(&self, overlay: &mut crate::text_overlay::TextOverlay, size: f32) -> bool {
        if let Some(l) = overlay.labels.get_mut(&self.id) {
            l.font_size = size; l.dirty = true; true
        } else { false }
    }

    /// Switch to a different font by string ID.  Returns `false` if removed.
    pub fn set_font(&self, overlay: &mut crate::text_overlay::TextOverlay, font_id: impl Into<String>) -> bool {
        if let Some(l) = overlay.labels.get_mut(&self.id) {
            l.font_id = font_id.into(); l.dirty = true; true
        } else { false }
    }

    /// Override the draw order.  Lower values render first (further back).
    /// Returns `false` if already removed.
    pub fn set_zindex(&self, overlay: &mut crate::text_overlay::TextOverlay, z: i32) -> bool {
        if let Some(l) = overlay.labels.get_mut(&self.id) {
            l.zindex = z; true
        } else { false }
    }

    /// Set the horizontal alignment / resize anchor.  Returns `false` if removed.
    pub fn set_horizontal_alignment(&self, overlay: &mut crate::text_overlay::TextOverlay, alignment: HorizontalAlignment) -> bool {
        if let Some(l) = overlay.labels.get_mut(&self.id) {
            l.horizontal_alignment = alignment; true
        } else { false }
    }

    /// Set the vertical alignment / resize anchor.  Returns `false` if removed.
    pub fn set_vertical_alignment(&self, overlay: &mut crate::text_overlay::TextOverlay, alignment: VerticalAlignment) -> bool {
        if let Some(l) = overlay.labels.get_mut(&self.id) {
            l.vertical_alignment = alignment; true
        } else { false }
    }

    /// Make the label visible.  Returns `false` if already removed.
    pub fn show(&self, overlay: &mut crate::text_overlay::TextOverlay) -> bool {
        if let Some(l) = overlay.labels.get_mut(&self.id) {
            l.visible = true; true
        } else { false }
    }

    /// Hide the label without removing it.  Returns `false` if already removed.
    pub fn hide(&self, overlay: &mut crate::text_overlay::TextOverlay) -> bool {
        if let Some(l) = overlay.labels.get_mut(&self.id) {
            l.visible = false; true
        } else { false }
    }

    /// Remove the label from `overlay`.  Returns `false` if already removed.
    pub fn remove(&self, overlay: &mut crate::text_overlay::TextOverlay) -> bool {
        overlay.labels.remove(&self.id).is_some()
    }
}

pub(crate) fn rasterize_text(
    font: &fontdue::Font,
    text: &str,
    font_size: f32,
    color: [f32; 4],
) -> (Vec<u8>, u32, u32) {
    if text.is_empty() {
        return (vec![0u8; 4], 1, 1);
    }

    let mut glyphs: Vec<(fontdue::Metrics, Vec<u8>)> = Vec::with_capacity(text.len());
    let mut total_advance       = 0.0f32;
    let mut max_above_baseline: i32 = 0;
    let mut min_below_baseline: i32 = 0;

    for ch in text.chars() {
        let (metrics, bitmap) = font.rasterize(ch, font_size);
        total_advance += metrics.advance_width;
        let above = metrics.ymin + metrics.height as i32;
        if above         > max_above_baseline { max_above_baseline = above; }
        if metrics.ymin  < min_below_baseline { min_below_baseline = metrics.ymin; }
        glyphs.push((metrics, bitmap));
    }

    let text_height = ((max_above_baseline - min_below_baseline) as u32).max(1) + 2;
    // Use ceil so fractional sub-pixel advances don't clip the last glyph.
    let text_width  = (total_advance.ceil() as u32).max(1) + 2;

    let mut pixels   = vec![0u8; (text_width * text_height * 4) as usize];
    let r            = (color[0] * 255.0) as u8;
    let g            = (color[1] * 255.0) as u8;
    let b            = (color[2] * 255.0) as u8;
    let base_alpha   = color[3];
    let baseline_y   = max_above_baseline as i32 + 1;
    // Use a float cursor to accumulate sub-pixel advances accurately.
    let mut cursor_x = 1.0f32;

    for (metrics, bitmap) in &glyphs {
        for gy in 0..metrics.height {
            for gx in 0..metrics.width {
                let alpha_byte = bitmap[gy * metrics.width + gx];
                if alpha_byte == 0 { continue; }

                // fontdue bitmap row 0 = top of glyph bounding box.
                let px = cursor_x as i32 + gx as i32 + metrics.xmin;
                let py = baseline_y - metrics.ymin - metrics.height as i32 + 1 + gy as i32;

                if px >= 0 && px < text_width as i32 && py >= 0 && py < text_height as i32 {
                    let idx        = ((py as u32 * text_width + px as u32) * 4) as usize;
                    pixels[idx]     = r;
                    pixels[idx + 1] = g;
                    pixels[idx + 2] = b;
                    pixels[idx + 3] = ((alpha_byte as f32 / 255.0) * base_alpha * 255.0) as u8;
                }
            }
        }
        cursor_x += metrics.advance_width;
    }

    (pixels, text_width, text_height)
}