fluor 0.0.2

First-principles GUI compositor library: center-origin RU coordinates, harmonic-mean span scaling, CPU softbuffer rendering, ARM-first.
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
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
//! Pill-shaped button widget. Visually a Textbox stripped of the editing apparatus: same squircle silhouette, same two-tone AA edge, same focus-glow, same hover tint via the host's overlay-deltas pipe; just a static centred label, no cursor / scroll / selection / keystroke routing. Adapted from `widgets::textbox` so the pill-cache + text-cache-clipped-by-`AlphaMask` pattern is identical and the two widgets read as a coherent family.
//!
//! **Action model.** Button doesn't carry a callback or an action enum — it owns a click counter. App polls [`Button::take_click`] each frame; `true` means the button fired since last poll. That's the smallest stateful API that decouples "the widget knows it was clicked" from "the app knows what to do about it" without dragging closures + lifetime juggling into the widget. For richer action dispatch (per-button intent codes, multi-target routing), the app builds a `HashMap<HitId, Action>` keyed by `button.hit_id()` and matches at the dispatch layer.

use crate::canvas::PixelRect;
use crate::coord::Coord;
use crate::paint::{self, Clip, HitId};
use crate::region::Region;
use crate::text::TextRenderer;
use crate::theme;
use crate::widgets::textbox::{blit_cache_to_target, region_to_pixelrect};
use alloc::string::String;
use alloc::vec::Vec;

pub struct Button {
    /// Allocated at construction; stamped into the host hit map at every opaque pill pixel.
    hit_id: HitId,
    /// Label text — single line, no editing. Mutate via [`Self::set_label`] so caches invalidate.
    label: String,
    font: &'static str,

    /// Stroke thickness in RU (× `font_size`). `0.0` → 1 px minimum via the `+ 1` idiom in render. Matches Textbox's stroke convention so a Button and a Textbox at the same `stroke_ru` render with identical edge weight.
    pub stroke_ru: f32,
    pub center_x: Coord,
    pub center_y: Coord,
    pub width: Coord,
    pub height: Coord,
    pub font_size: Coord,

    /// `true` while the button is the focused widget (Tab / click). Drives the glow and the active fill colour.
    focused: bool,
    /// `true` while the cursor is over the button. Drives the hover fill colour via the host's overlay-delta pipe (Button doesn't bake hover into its own cache — same pattern as Textbox).
    hovered: bool,

    /// Number of times [`Click::on_click`] has fired since construction. Monotonic. Consumers compare against [`Self::last_seen_click_counter`] via [`Self::take_click`] to know "has this button fired since I last looked."
    click_counter: u32,
    last_seen_click_counter: u32,

    // --- Caches (same layering as Textbox: pill_cache holds squircle bg + AA edges, text_cache holds the label glyphs clipped to the inner-pill silhouette, both blit topmost-first into target via under()) ---
    pill_cache: Vec<u32>,
    pill_cache_w: usize,
    pill_cache_h: usize,
    pill_cache_dirty: bool,
    text_cache: Vec<u32>,
    text_cache_w: usize,
    text_cache_h: usize,
    text_cache_dirty: bool,
    inner_pill_mask: Vec<u8>,

    // --- Damage protocol ---
    last_painted_bbox: Option<PixelRect>,
    last_painted_focused: bool,
    last_painted_hovered: bool,
}

impl Button {
    /// `hit_counter` is the app's monotonic [`HitId`] allocator (see [`crate::host::widget::next_id`]). Each Button claims one ID at construction.
    pub fn new(
        hit_counter: &mut HitId,
        center_x: Coord,
        center_y: Coord,
        width: Coord,
        height: Coord,
        font_size: Coord,
        label: impl Into<String>,
    ) -> Self {
        Self {
            hit_id: crate::host::widget::next_id(hit_counter),
            label: label.into(),
            font: "Open Sans",
            stroke_ru: 0.0,
            center_x,
            center_y,
            width,
            height,
            font_size,
            focused: false,
            hovered: false,
            click_counter: 0,
            last_seen_click_counter: 0,
            pill_cache: Vec::new(),
            pill_cache_w: 0,
            pill_cache_h: 0,
            pill_cache_dirty: true,
            text_cache: Vec::new(),
            text_cache_w: 0,
            text_cache_h: 0,
            text_cache_dirty: true,
            inner_pill_mask: Vec::new(),
            last_painted_bbox: None,
            last_painted_focused: false,
            last_painted_hovered: false,
        }
    }

    pub fn hit_id(&self) -> HitId {
        self.hit_id
    }
    pub fn is_focused(&self) -> bool {
        self.focused
    }
    pub fn is_hovered(&self) -> bool {
        self.hovered
    }
    pub fn label(&self) -> &str {
        &self.label
    }

    /// Set the focused state. Idempotent. Side-effects nothing else — the painter consults `focused` directly to decide whether to draw the glow + active fill.
    pub fn set_focused(&mut self, focused: bool) {
        if focused != self.focused {
            self.focused = focused;
        }
    }

    pub fn set_hovered(&mut self, hovered: bool) {
        if hovered != self.hovered {
            self.hovered = hovered;
        }
    }

    /// Replace the label text. Marks both caches dirty so the next render re-rasterizes glyphs (text_cache) — the pill silhouette doesn't depend on label content so technically only text_cache needs it, but a label swap typically goes with a re-layout, and marking pill dirty too is cheap insurance.
    pub fn set_label(&mut self, label: impl Into<String>) {
        let new = label.into();
        if new != self.label {
            self.label = new;
            self.text_cache_dirty = true;
        }
    }

    /// Reposition the pill. No scroll state to reconcile (Button has no horizontal scroll) so just dirty the caches and store.
    pub fn set_rect(&mut self, center_x: Coord, center_y: Coord, width: Coord, height: Coord) {
        if self.center_x != center_x
            || self.center_y != center_y
            || self.width != width
            || self.height != height
        {
            self.pill_cache_dirty = true;
            self.text_cache_dirty = true;
        }
        self.center_x = center_x;
        self.center_y = center_y;
        self.width = width;
        self.height = height;
    }

    pub fn set_font_size(&mut self, font_size: Coord) {
        if self.font_size != font_size {
            self.pill_cache_dirty = true;
            self.text_cache_dirty = true;
        }
        self.font_size = font_size;
    }

    /// Returns `true` if the button has been clicked since the last call to `take_click`. The internal counter is monotonic; this call advances the consumer's read pointer to the current value. Multiple clicks between polls coalesce into a single `true` — buttons aren't a rate-limited event source, they're "did the user activate this thing recently."
    pub fn take_click(&mut self) -> bool {
        if self.click_counter != self.last_seen_click_counter {
            self.last_seen_click_counter = self.click_counter;
            true
        } else {
            false
        }
    }

    /// Raw counter — total clicks since construction. Public for apps that want to display a counter or detect rapid clicks via deltas.
    pub fn click_count(&self) -> u32 {
        self.click_counter
    }

    /// Mark the click counter advanced. Called from the Click trait impl; pub(crate) so internal consumers can fire it without going through the trait if they need to (e.g. a hypothetical keyboard accelerator outside Key::on_key).
    pub(crate) fn fire(&mut self) {
        self.click_counter = self.click_counter.wrapping_add(1);
    }

    /// Symmetric inner padding inside the pill, in pixels. Same model as Textbox so a Button and Textbox at the same `font_size` have visually identical pill margins.
    pub fn padding(&self) -> Coord {
        self.font_size * 0.4
    }

    pub fn bbox(&self) -> Region {
        Region::new(
            self.center_x - self.width * 0.5,
            self.center_y - self.height * 0.5,
            self.width,
            self.height,
        )
    }

    /// Glow envelope — wider than `bbox` because the focus glow rays extend past the pill. Used by `damage_rect` on focus-on/off transitions so the host's damage clip covers the area the glow will paint into. Pad sizes come from [`paint::ray_reach_px`], the canonical "how many pixels does this seed decay across at this factor" helper — same numbers the rasterizer actually walks — so the bbox contains exactly what gets painted, with no early cutoff and no over-clearing.
    pub fn glow_bbox(&self) -> Region {
        let horiz_factor = glow_factor_256(self.font_size, 1.5);
        let vert_factor = glow_factor_256(self.font_size, 0.75);
        let horiz_pad = paint::ray_reach_px(0x80, horiz_factor) as f32;
        let vert_pad = paint::ray_reach_px(0x40, vert_factor) as f32;
        Region::new(
            self.center_x - self.width * 0.5 - horiz_pad,
            self.center_y - self.height * 0.5 - vert_pad,
            self.width + 2.0 * horiz_pad,
            self.height + 2.0 * vert_pad,
        )
    }

    /// Damage region. Returns `None` if nothing has changed since the last paint (host can persist scratch). Returns `Some(rect)` covering whichever of (current bbox, prior bbox, glow envelope) need a fresh paint. Glow envelope enters damage only on focus on / off transitions, matching Textbox's per-keystroke optimisation that keeps steady-state damage tight to the bare pill.
    pub fn damage_rect(&self, viewport_w: usize, viewport_h: usize) -> Option<PixelRect> {
        let focus_changed = self.focused != self.last_painted_focused;
        let hover_changed = self.hovered != self.last_painted_hovered;
        let pill_dirty = self.pill_cache_dirty || self.text_cache_dirty;
        if !pill_dirty && !focus_changed && !hover_changed && self.last_painted_bbox.is_some() {
            return None;
        }
        let mut combined: Option<PixelRect> = None;
        if let Some(prev) = self.last_painted_bbox {
            combined = Some(prev);
        }
        let current = if focus_changed {
            region_to_pixelrect(self.glow_bbox(), viewport_w, viewport_h)
        } else {
            region_to_pixelrect(self.bbox(), viewport_w, viewport_h)
        };
        combined = Some(combined.map_or(current, |c| c.union(current)));
        combined
    }

    /// Paint the button into `canvas` at its viewport-space `center_*` / `width` / `height`, stamping `hit_id` into `hit_map` at every opaque pill pixel. Same three-step blit as Textbox: text_cache (glyphs) → pill_cache (squircle bg + AA edges) → focus glow on top. No selection rect, no blinkey — just label + pill + optional glow.
    pub fn render_content_into(
        &mut self,
        canvas: &mut crate::canvas::Canvas,
        offset_x: Coord,
        offset_y: Coord,
        text: &mut TextRenderer,
        clip: Option<Clip>,
        hit_map: Option<&mut [HitId]>,
        hit_id: HitId,
    ) {
        let pill_x_target = (self.center_x - self.width * 0.5 - offset_x) as isize;
        let pill_y_target = (self.center_y - self.height * 0.5 - offset_y) as isize;
        let pill_w = self.width as isize;
        let pill_h = self.height as isize;
        if pill_w <= 0 || pill_h <= 0 {
            return;
        }
        let cw = pill_w as usize;
        let ch = pill_h as usize;
        // Fractional squirdleyness — slots between an ellipse (2) and a diamond (1). `1.5` reads as a noticeably-rounder, slightly-faceted pill: distinctly more curved than the textbox's `3.0` "slightly squared" but not as soft as a full ellipse. Routes through paint's `_f` (powf) variant; the textbox / chrome keep the integer (powi) fast path. Adjustable per-instance via this constant; future API could expose it as a Button field if more shapes are desired.
        let squirdleyness = 1.75;
        let stroke_px = (self.stroke_ru * self.font_size) as isize + 1;

        // --- Pill cache rasterize: squircle fill + AA edges, gated by pill_cache_dirty. Identical to Textbox's pill rasterize so the two widgets share the visual family. ---
        if self.pill_cache_dirty {
            paint::RASTERIZE_OPS.fetch_add(1, core::sync::atomic::Ordering::Relaxed);
            self.pill_cache.clear();
            self.pill_cache.resize(cw * ch, 0);
            self.pill_cache_w = cw;
            self.pill_cache_h = ch;
            let inner_x = stroke_px;
            let inner_y = stroke_px;
            let inner_w = (pill_w - 2 * stroke_px).max(0);
            let inner_h = (pill_h - 2 * stroke_px).max(0);
            let mut cache_damage = crate::canvas::Damage::new();
            {
                let mut cache_canvas =
                    crate::canvas::Canvas::new(&mut self.pill_cache, cw, ch, &mut cache_damage);
                if inner_w > 0 && inner_h > 0 {
                    paint::draw_squircle_pill_f(
                        &mut cache_canvas,
                        inner_x,
                        inner_y,
                        inner_w,
                        inner_h,
                        theme::TEXTBOX_FILL,
                        squirdleyness,
                    );
                }
            }
            self.inner_pill_mask.clear();
            self.inner_pill_mask.resize(cw * ch, 0);
            for i in 0..(cw * ch) {
                self.inner_pill_mask[i] = ((self.pill_cache[i] >> 24) & 0xFF) as u8;
            }
            {
                let mut cache_canvas =
                    crate::canvas::Canvas::new(&mut self.pill_cache, cw, ch, &mut cache_damage);
                // Bevel direction is INVERTED relative to Textbox: shadow on the top/left (where light would normally hit a sunken edge), light on the bottom/right. Reads visually as "raised" — the button protrudes toward the viewer — whereas a textbox with the canonical orientation reads as "inset / carved into the surface." Single argument swap, zero extra render cost, classic UI lighting convention preserved.
                paint::draw_squircle_pill_two_tone_f(
                    &mut cache_canvas,
                    0,
                    0,
                    pill_w,
                    pill_h,
                    theme::TEXTBOX_SHADOW_EDGE,
                    theme::TEXTBOX_LIGHT_EDGE,
                    squirdleyness,
                    None,
                    0,
                );
            }
            self.pill_cache_dirty = false;
        }

        // --- Text cache: centred label clipped by inner-pill mask. No scroll, no selection — anchor at pill centre, draw_text_left_u32 with x = centre − text_width / 2. ---
        if self.text_cache_dirty {
            paint::RASTERIZE_OPS.fetch_add(1, core::sync::atomic::Ordering::Relaxed);
            self.text_cache.clear();
            self.text_cache.resize(cw * ch, 0);
            self.text_cache_w = cw;
            self.text_cache_h = ch;
            if !self.label.is_empty() && self.font_size > 0.0 {
                let tw = text.measure_text_width(&self.label, self.font_size, 400, self.font);
                let local_text_left = pill_w as Coord * 0.5 - tw * 0.5;
                let local_y_center = pill_h as Coord * 0.5;
                let mut text_damage = crate::canvas::Damage::new();
                let mut text_canvas =
                    crate::canvas::Canvas::new(&mut self.text_cache, cw, ch, &mut text_damage);
                let mask_buffer = paint::AlphaMask::new(&self.inner_pill_mask, cw, ch);
                text.draw_text_left_u32(
                    &mut text_canvas,
                    &self.label,
                    local_text_left,
                    local_y_center,
                    self.font_size,
                    400,
                    theme::TEXTBOX_TEXT,
                    self.font,
                    None,
                    Some(&mask_buffer),
                    None,
                );
            }
            self.text_cache_dirty = false;
        }

        // --- Composition: text (topmost) → pill (bottom, stamps hit_map) ---
        blit_cache_to_target(
            &self.text_cache,
            self.text_cache_w,
            self.text_cache_h,
            pill_x_target,
            pill_y_target,
            canvas,
            None,
            0,
            clip,
        );
        blit_cache_to_target(
            &self.pill_cache,
            self.pill_cache_w,
            self.pill_cache_h,
            pill_x_target,
            pill_y_target,
            canvas,
            hit_map,
            hit_id,
            clip,
        );

        // --- Focus glow on target, paints fresh each frame. Same four-direction RU-invariant decay as Textbox. ---
        if self.focused {
            let horiz_factor = glow_factor_256(self.font_size, 1.5);
            let vert_factor = glow_factor_256(self.font_size, 0.75);
            paint::apply_textbox_glow_right(
                canvas,
                pill_x_target,
                pill_y_target,
                pill_w,
                pill_h,
                theme::GLOW_DARK,
                0x80,
                horiz_factor,
                clip,
            );
            paint::apply_textbox_glow_left(
                canvas,
                pill_x_target,
                pill_y_target,
                pill_w,
                pill_h,
                theme::GLOW_DARK,
                0x80,
                horiz_factor,
                clip,
            );
            paint::apply_textbox_glow_top(
                canvas,
                pill_x_target,
                pill_y_target,
                pill_w,
                pill_h,
                theme::GLOW_DARK,
                0x40,
                vert_factor,
                clip,
            );
            paint::apply_textbox_glow_bottom(
                canvas,
                pill_x_target,
                pill_y_target,
                pill_w,
                pill_h,
                theme::GLOW_DARK,
                0x40,
                vert_factor,
                clip,
            );
        }

        // Record what we painted so next frame's damage_rect can union prev∪current.
        let viewport_w = canvas.width;
        let viewport_h = canvas.height;
        self.last_painted_bbox = Some(region_to_pixelrect(self.bbox(), viewport_w, viewport_h));
        self.last_painted_focused = self.focused;
        self.last_painted_hovered = self.hovered;
    }
}

/// Same RU-invariant glow factor as Textbox's [`Textbox::glow_factor_256`]; duplicated here so Button doesn't pull a `pub(crate)` from Textbox just for one tiny formula. Returns a factor in `[96, 254]` where smaller = steeper decay = shorter visual reach; matches the chrome shadow's `factor_256` math so glows around buttons and textboxes share the same look.
fn glow_factor_256(font_size: f32, radius_scale: f32) -> u32 {
    let target_radius = (font_size * radius_scale).max(8.0);
    let drop = (1240.0 / target_radius) as u32;
    (256u32.saturating_sub(drop)).clamp(96, 254)
}

#[cfg(feature = "host-winit")]
mod widget_impls {
    //! [`crate::host::widget`] capability traits for [`Button`]. Mirrors `textbox::widget_impls`: Click increments the action counter, Focus / Hover route through Button's setters, Key activates on Enter / Space.

    use super::Button;
    use crate::coord::Coord;
    use crate::host::widget::{Click, Focus, Hover, Key, PaintCtx, Widget};
    use crate::paint::HitId;
    use crate::text::TextRenderer;
    use winit::event::KeyEvent;
    use winit::keyboard::{Key as WKey, ModifiersState, NamedKey};

    impl Widget for Button {
        fn id(&self) -> HitId {
            self.hit_id()
        }
        fn paint(&mut self, _ctx: &mut PaintCtx<'_, '_>) {
            // No-op for the same reason Textbox's is: panes drives the actual render via [`Button::render_content_into`] with its ad-hoc parameter list. The trait makes Button a participant in dispatch (click / focus / hover / key) without forcing every consumer onto PaintCtx today.
        }
        fn click(&mut self) -> Option<&mut dyn Click> {
            Some(self)
        }
        fn key(&mut self) -> Option<&mut dyn Key> {
            Some(self)
        }
        fn focus(&mut self) -> Option<&mut dyn Focus> {
            Some(self)
        }
        fn hover(&mut self) -> Option<&mut dyn Hover> {
            Some(self)
        }
    }

    impl Click for Button {
        fn on_click(
            &mut self,
            _x: Coord,
            _y: Coord,
            _mods: ModifiersState,
        ) -> crate::host::app::EventResponse {
            self.fire();
            crate::host::app::EventResponse::Handled
        }
    }

    impl Key for Button {
        fn on_key(
            &mut self,
            kev: &KeyEvent,
            _mods: ModifiersState,
            _text: &mut TextRenderer,
        ) -> crate::host::app::EventResponse {
            if kev.state != winit::event::ElementState::Pressed {
                return crate::host::app::EventResponse::Pass;
            }
            match &kev.logical_key {
                WKey::Named(NamedKey::Enter) | WKey::Named(NamedKey::Space) => {
                    self.fire();
                    crate::host::app::EventResponse::Handled
                }
                _ => crate::host::app::EventResponse::Pass,
            }
        }
    }

    impl Focus for Button {
        fn set_focused(&mut self, focused: bool) {
            Button::set_focused(self, focused);
        }
        fn focus_bbox(&self) -> Option<crate::canvas::PixelRect> {
            let r = self.bbox();
            let x0 = r.x.max(0.0) as usize;
            let y0 = r.y.max(0.0) as usize;
            let x1 = (r.x + r.w).max(0.0) as usize;
            let y1 = (r.y + r.h).max(0.0) as usize;
            Some(crate::canvas::PixelRect::new(x0, y0, x1, y1))
        }
    }

    impl Hover for Button {
        fn set_hovered(&mut self, hovered: bool) {
            Button::set_hovered(self, hovered);
        }
        fn tint_delta(&self) -> u32 {
            // Same focus = hover convention as [`crate::widgets::textbox::Textbox`]. Both widgets read from the same theme family so a Button beside a Textbox tints identically.
            if self.is_focused() || self.is_hovered() {
                crate::paint::wrap_sub_rgb(
                    crate::theme::TEXTBOX_HOVER,
                    crate::theme::TEXTBOX_FILL,
                )
            } else {
                0
            }
        }
    }
}