nandi/jolt-nativepublic Fork 0
7d907d008b66abc03da83dc36411cf13f3085a35
Commits
Clone
git clone https://git.rickub.com/nandi/jolt-native.git
git clone ssh://git@rickub.com/nandi/jolt-native.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Run the formatter over the tree 3e8c6f0 · on 7d907d008b66abc03da83dc36411cf13f3085a35 · nandi · 13d ago
theme.rs · 638 lines · 20.2 KBRust Blame HistoryRaw
  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
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
//! Palette, metrics, egui Visuals/Style, and widget helpers.

use egui::{
    pos2,
    style::{ScrollStyle, WidgetVisuals},
    Button, Color32, CornerRadius, CursorIcon, FontId, Frame, Label, Margin, Response, RichText,
    Sense, Shadow, Shape, Stroke, StrokeKind, Style, TextBuffer, TextEdit, TextWrapMode, Ui, Vec2,
    Visuals, WidgetInfo, WidgetType,
};

use crate::install_text_styles;

/// Light vs dark shell.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Mode {
    #[default]
    Dark,
    Light,
}

/// Semantic colors.
#[derive(Debug, Clone)]
pub struct Palette {
    pub window_bg: Color32,
    pub view_bg: Color32,
    pub headerbar_bg: Color32,
    pub card_bg: Color32,
    pub popover_bg: Color32,
    pub shade: Color32,

    pub accent: Color32,
    pub accent_fg: Color32,
    pub accent_hover: Color32,
    pub accent_active: Color32,

    pub destructive: Color32,
    pub success: Color32,
    pub warning: Color32,

    pub text: Color32,
    pub text_secondary: Color32,
    pub text_disabled: Color32,

    pub border: Color32,
    pub border_soft: Color32,

    pub button_bg: Color32,
    pub button_hover: Color32,
    pub button_active: Color32,
    pub button_fg: Color32,
}

impl Palette {
    pub fn dark() -> Self {
        Self {
            window_bg: rgb(0x24, 0x24, 0x24),
            view_bg: rgb(0x1e, 0x1e, 0x1e),
            headerbar_bg: rgb(0x30, 0x30, 0x30),
            card_bg: rgb(0x30, 0x30, 0x30),
            popover_bg: rgb(0x38, 0x38, 0x38),
            shade: Color32::from_black_alpha(80),

            accent: rgb(0x35, 0x84, 0xe4),
            accent_fg: Color32::WHITE,
            accent_hover: rgb(0x4a, 0x93, 0xe7),
            accent_active: rgb(0x1c, 0x71, 0xd8),

            destructive: rgb(0xc0, 0x1c, 0x28),
            success: rgb(0x2e, 0xc2, 0x7e),
            warning: rgb(0xe5, 0xa5, 0x0a),

            text: rgb(0xff, 0xff, 0xff),
            text_secondary: rgb(0x9a, 0x99, 0x96),
            text_disabled: rgb(0x5e, 0x5c, 0x64),

            border: rgb(0x5e, 0x5c, 0x64),
            border_soft: rgb(0x3d, 0x38, 0x46),

            button_bg: rgb(0x3d, 0x3d, 0x3d),
            button_hover: rgb(0x4a, 0x4a, 0x4a),
            button_active: rgb(0x35, 0x35, 0x35),
            button_fg: rgb(0xff, 0xff, 0xff),
        }
    }

    pub fn light() -> Self {
        Self {
            window_bg: rgb(0xfa, 0xfa, 0xfa),
            view_bg: rgb(0xff, 0xff, 0xff),
            headerbar_bg: rgb(0xff, 0xff, 0xff),
            card_bg: rgb(0xff, 0xff, 0xff),
            popover_bg: rgb(0xff, 0xff, 0xff),
            shade: Color32::from_black_alpha(40),

            accent: rgb(0x35, 0x84, 0xe4),
            accent_fg: Color32::WHITE,
            accent_hover: rgb(0x4a, 0x93, 0xe7),
            accent_active: rgb(0x1c, 0x71, 0xd8),

            destructive: rgb(0xc0, 0x1c, 0x28),
            success: rgb(0x26, 0xa2, 0x69),
            warning: rgb(0xe5, 0xa5, 0x0a),

            text: rgb(0x24, 0x1f, 0x31),
            text_secondary: rgb(0x5e, 0x5c, 0x64),
            text_disabled: rgb(0x9a, 0x99, 0x96),

            border: rgb(0xcd, 0xcd, 0xcd),
            border_soft: rgb(0xe0, 0xe0, 0xe0),

            button_bg: rgb(0xe0, 0xe0, 0xe0),
            button_hover: rgb(0xd0, 0xd0, 0xd0),
            button_active: rgb(0xc0, 0xc0, 0xc0),
            button_fg: rgb(0x24, 0x1f, 0x31),
        }
    }
}

/// Spacing scale (HIG-ish: 6 / 12 / 18 / 24).
#[derive(Debug, Clone)]
pub struct Spacing {
    pub xs: f32,
    pub sm: f32,
    pub md: f32,
    pub lg: f32,
    pub xl: f32,
    pub page: f32,
    pub control_height: f32,
    /// Inner horizontal padding for text fields (egui default is only 4).
    pub field_pad_x: f32,
    /// Inner vertical padding for text fields (egui default is only 2).
    pub field_pad_y: f32,
    /// Gap between a form label and its control.
    pub field_gap: f32,
    pub radius_sm: f32,
    pub radius_md: f32,
    pub radius_lg: f32,
}

impl Default for Spacing {
    fn default() -> Self {
        Self {
            xs: 4.0,
            sm: 6.0,
            md: 12.0,
            lg: 18.0,
            xl: 24.0,
            page: 16.0,
            control_height: 34.0,
            field_pad_x: 12.0,
            field_pad_y: 8.0,
            field_gap: 12.0,
            radius_sm: 6.0,
            radius_md: 9.0,
            radius_lg: 12.0,
        }
    }
}

/// Type sizes (UI sans scale).
#[derive(Debug, Clone)]
pub struct TypeScale {
    pub title: f32,
    pub title_2: f32,
    pub title_3: f32,
    pub body: f32,
    pub caption: f32,
}

impl Default for TypeScale {
    fn default() -> Self {
        Self {
            title: 20.0,
            title_2: 16.0,
            title_3: 14.0,
            body: 14.0,
            caption: 12.0,
        }
    }
}

/// Full theme bundle.
#[derive(Debug, Clone)]
pub struct Theme {
    pub mode: Mode,
    pub palette: Palette,
    pub spacing: Spacing,
    pub type_scale: TypeScale,
}

impl Theme {
    pub fn dark() -> Self {
        Self {
            mode: Mode::Dark,
            palette: Palette::dark(),
            spacing: Spacing::default(),
            type_scale: TypeScale::default(),
        }
    }

    pub fn light() -> Self {
        Self {
            mode: Mode::Light,
            palette: Palette::light(),
            spacing: Spacing::default(),
            type_scale: TypeScale::default(),
        }
    }

    pub fn visuals(&self) -> Visuals {
        let p = &self.palette;
        let mut v = match self.mode {
            Mode::Dark => Visuals::dark(),
            Mode::Light => Visuals::light(),
        };

        v.dark_mode = self.mode == Mode::Dark;
        v.override_text_color = Some(p.text);
        v.hyperlink_color = p.accent;
        v.warn_fg_color = p.warning;
        v.error_fg_color = p.destructive;
        v.extreme_bg_color = p.view_bg;
        v.faint_bg_color = p.card_bg;
        v.code_bg_color = p.popover_bg;
        v.window_fill = p.window_bg;
        v.panel_fill = p.window_bg;
        v.window_stroke = Stroke::new(1.0_f32, p.border_soft);
        v.window_corner_radius = CornerRadius::same(self.spacing.radius_md as u8);
        v.window_shadow = Shadow {
            offset: [0, 4],
            blur: 16,
            spread: 0,
            color: p.shade,
        };
        v.popup_shadow = Shadow {
            offset: [0, 2],
            blur: 8,
            spread: 0,
            color: p.shade,
        };
        v.selection.bg_fill = p.accent.gamma_multiply(0.35);
        v.selection.stroke = Stroke::new(1.0_f32, p.accent);
        v.widgets = widget_visuals(p, &self.spacing);
        // Browser-style click affordance: buttons (and apps that opt in) show a
        // pointing-hand cursor on hover. egui defaults this to None (native toolkit
        // style); Vidya prefers the clickable cue on desktop.
        v.interact_cursor = Some(CursorIcon::PointingHand);
        v
    }

    pub fn style(&self) -> Style {
        let mut style = Style {
            visuals: self.visuals(),
            ..Style::default()
        };
        install_text_styles(&mut style, &self.type_scale);

        let sp = &self.spacing;
        style.spacing.item_spacing = Vec2::new(sp.sm, sp.sm);
        style.spacing.button_padding = Vec2::new(sp.md, sp.sm);
        style.spacing.indent = sp.lg;
        style.spacing.window_margin = Margin::same(sp.page as i8);
        style.spacing.menu_margin = Margin::same(sp.sm as i8);
        style.spacing.interact_size = Vec2::new(sp.control_height * 2.5, sp.control_height);
        // Native checkbox / radio icons (prefer `checkbox()` for the themed control).
        style.spacing.icon_width = 18.0;
        style.spacing.icon_width_inner = 12.0;
        style.spacing.icon_spacing = sp.sm + 2.0;
        // Solid gutters: floating bars sit on top of content (egui default). HIG-ish
        // scroll areas reserve space so lists/text never sit under the thumb.
        style.spacing.scroll = ScrollStyle::solid();
        // Prefer wrapping at the parent edge over extending (overflow) or hard
        // truncate. Pair with layout helpers (`fit_width`, `card`, `hflow`) so
        // rows actually receive a finite residual width.
        style.wrap_mode = Some(TextWrapMode::Wrap);
        // Default TextEdit desired width: fill parent residual (egui still
        // clamps to available_width). Avoid the stock 280px “always this wide”
        // intrinsic that pushes horizontal rows past the app view.
        style.spacing.text_edit_width = 10_000.0;
        style.interaction.tooltip_delay = 0.4;
        style.animation_time = 0.12;
        style
    }

    pub fn header_frame(&self) -> Frame {
        Frame::new()
            .fill(self.palette.headerbar_bg)
            .inner_margin(Margin::symmetric(
                self.spacing.page as i8,
                self.spacing.md as i8,
            ))
            .stroke(Stroke::new(1.0_f32, self.palette.border_soft))
    }

    pub fn card_frame(&self) -> Frame {
        Frame::new()
            .fill(self.palette.card_bg)
            .corner_radius(self.spacing.radius_lg)
            .inner_margin(Margin::same(self.spacing.md as i8))
            .stroke(Stroke::new(1.0_f32, self.palette.border_soft))
    }

    pub fn page_frame(&self) -> Frame {
        Frame::new()
            .fill(self.palette.window_bg)
            .inner_margin(Margin::same(self.spacing.page as i8))
    }

    /// Inner padding for [`TextEdit`] / form fields (HIG-ish; roomier than egui’s 4×2 default).
    pub fn text_edit_margin(&self) -> Margin {
        Margin::symmetric(
            self.spacing.field_pad_x as i8,
            self.spacing.field_pad_y as i8,
        )
    }
}

fn widget_visuals(p: &Palette, sp: &Spacing) -> egui::style::Widgets {
    let r = CornerRadius::same(sp.radius_md as u8);
    let soft = Stroke::new(1.0_f32, p.border_soft);

    egui::style::Widgets {
        noninteractive: WidgetVisuals {
            bg_fill: p.card_bg,
            weak_bg_fill: p.view_bg,
            bg_stroke: soft,
            corner_radius: r,
            fg_stroke: Stroke::new(1.0_f32, p.text_secondary),
            expansion: 0.0,
        },
        inactive: WidgetVisuals {
            bg_fill: p.button_bg,
            weak_bg_fill: p.button_bg,
            bg_stroke: Stroke::new(1.0_f32, p.border_soft),
            corner_radius: r,
            fg_stroke: Stroke::new(1.0_f32, p.button_fg),
            expansion: 0.0,
        },
        hovered: WidgetVisuals {
            bg_fill: p.button_hover,
            weak_bg_fill: p.button_hover,
            bg_stroke: Stroke::new(1.0_f32, p.border),
            corner_radius: r,
            fg_stroke: Stroke::new(1.0_f32, p.text),
            expansion: 0.0,
        },
        active: WidgetVisuals {
            bg_fill: p.button_active,
            weak_bg_fill: p.button_active,
            bg_stroke: Stroke::new(1.0_f32, p.accent),
            corner_radius: r,
            fg_stroke: Stroke::new(1.0_f32, p.text),
            expansion: 0.0,
        },
        open: WidgetVisuals {
            bg_fill: p.popover_bg,
            weak_bg_fill: p.popover_bg,
            bg_stroke: Stroke::new(1.0_f32, p.border),
            corner_radius: r,
            fg_stroke: Stroke::new(1.0_f32, p.text),
            expansion: 0.0,
        },
    }
}

/// Square tool button with a stroke/emoji [`crate::Icon`] (e.g. copy, plus).
///
/// Prefer this over text labels in tight rows — text wraps under
/// [`TextWrapMode::Wrap`] and looks broken at small widths.
pub fn icon_button(ui: &mut Ui, theme: &Theme, icon: crate::Icon, tip: &str) -> Response {
    use crate::paint_icon_in;
    let p = &theme.palette;
    let size = theme.spacing.control_height;
    let (rect, mut response) = ui.allocate_exact_size(Vec2::splat(size), Sense::click());
    response = response.on_hover_text(tip);
    if let Some(cursor) = ui.visuals().interact_cursor {
        response = response.on_hover_cursor(cursor);
    }

    if ui.is_rect_visible(rect) {
        let hovered = response.hovered() && ui.is_enabled();
        let active = response.is_pointer_button_down_on();
        let fill = if active {
            p.button_active
        } else if hovered {
            p.button_hover
        } else {
            p.button_bg
        };
        let border = if hovered {
            Stroke::new(1.0_f32, p.border)
        } else {
            Stroke::new(1.0_f32, p.border_soft)
        };
        ui.painter().rect(
            rect,
            theme.spacing.radius_md,
            fill,
            border,
            StrokeKind::Inside,
        );
        let icon_size = size * 0.48;
        let icon_rect = egui::Rect::from_center_size(rect.center(), Vec2::splat(icon_size));
        paint_icon_in(ui, icon_rect, icon, p.button_fg);
    }

    response
}

/// Primary / suggested action (accent filled).
pub fn primary_button(ui: &mut Ui, theme: &Theme, label: &str) -> Response {
    let p = &theme.palette;
    let text = RichText::new(label)
        .size(theme.type_scale.body)
        .color(p.accent_fg);
    let btn = Button::new(text)
        .fill(p.accent)
        .stroke(Stroke::NONE)
        .corner_radius(theme.spacing.radius_md)
        .min_size(Vec2::new(0.0, theme.spacing.control_height));
    ui.add(btn)
}

/// Regular flat button.
pub fn button(ui: &mut Ui, theme: &Theme, label: &str) -> Response {
    let p = &theme.palette;
    let text = RichText::new(label)
        .size(theme.type_scale.body)
        .color(p.button_fg);
    let btn = Button::new(text)
        .fill(p.button_bg)
        .stroke(Stroke::new(1.0_f32, p.border_soft))
        .corner_radius(theme.spacing.radius_md)
        .min_size(Vec2::new(0.0, theme.spacing.control_height));
    ui.add(btn)
}

/// Destructive action.
pub fn destructive_button(ui: &mut Ui, theme: &Theme, label: &str) -> Response {
    let p = &theme.palette;
    let text = RichText::new(label)
        .size(theme.type_scale.body)
        .color(Color32::WHITE);
    let btn = Button::new(text)
        .fill(p.destructive)
        .stroke(Stroke::NONE)
        .corner_radius(theme.spacing.radius_md)
        .min_size(Vec2::new(0.0, theme.spacing.control_height));
    ui.add(btn)
}

/// Accent-filled checkbox with a drawn checkmark (stock egui looks janky here).
///
/// Unchecked: soft fill + border. Checked: solid accent tile + white stroke check.
pub fn checkbox(ui: &mut Ui, theme: &Theme, checked: &mut bool, text: &str) -> Response {
    let p = &theme.palette;
    let sp = &theme.spacing;

    // Slightly larger than stock 16px so the check has room to breathe.
    let box_size = 20.0_f32;
    let gap = sp.sm + 4.0;

    let galley = ui.fonts(|f| {
        f.layout_no_wrap(
            text.to_owned(),
            FontId::proportional(theme.type_scale.body),
            p.text,
        )
    });

    let height = box_size
        .max(galley.size().y + 4.0)
        .max(sp.control_height * 0.85);
    let width = box_size + gap + galley.size().x;
    let (rect, mut response) = ui.allocate_exact_size(Vec2::new(width, height), Sense::click());

    if response.clicked() {
        *checked = !*checked;
        response.mark_changed();
    }
    response.widget_info(|| {
        WidgetInfo::selected(WidgetType::Checkbox, ui.is_enabled(), *checked, text)
    });
    // Custom widget — egui only applies `interact_cursor` on stock `Button`.
    if let Some(cursor) = ui.visuals().interact_cursor {
        response = response.on_hover_cursor(cursor);
    }

    if ui.is_rect_visible(rect) {
        let painter = ui.painter();
        let box_rect = egui::Rect::from_center_size(
            pos2(rect.left() + box_size * 0.5, rect.center().y),
            Vec2::splat(box_size),
        );
        // Squarer than widget radius_md (which nearly circles a 20px tile).
        let radius = CornerRadius::same((sp.radius_sm * 0.85) as u8);

        let hovered = response.hovered() && ui.is_enabled();
        let (fill, border) = if *checked {
            let fill = if hovered { p.accent_hover } else { p.accent };
            (fill, Stroke::new(1.0_f32, fill))
        } else if hovered {
            (p.button_hover, Stroke::new(1.5_f32, p.border))
        } else {
            (p.button_bg, Stroke::new(1.25_f32, p.border_soft))
        };

        painter.rect(box_rect, radius, fill, border, StrokeKind::Inside);

        if *checked {
            // Refined check proportions (short leg → long arm), not stock’s steep V.
            let s = box_size;
            let o = box_rect.min;
            let a = pos2(o.x + s * 0.22, o.y + s * 0.52);
            let b = pos2(o.x + s * 0.42, o.y + s * 0.70);
            let c = pos2(o.x + s * 0.78, o.y + s * 0.30);
            // Slightly thicker tip stroke reads cleaner at small sizes.
            painter.add(Shape::line(
                vec![a, b, c],
                Stroke::new(2.15_f32, p.accent_fg),
            ));
        }

        let text_pos = pos2(
            rect.left() + box_size + gap,
            rect.center().y - 0.5 * galley.size().y,
        );
        painter.galley(text_pos, galley, p.text);
    }

    response
}

/// Live / offline status mark drawn with shapes.
///
/// **Do not** use Unicode `●` / `○` in labels on Android: egui’s default fonts
/// often lack those glyphs and render a hollow box (“tofu”). This paints a real
/// circle instead — filled when `live`, stroked when offline.
pub fn status_dot(ui: &mut Ui, theme: &Theme, live: bool) -> Response {
    let p = &theme.palette;
    // Match body line height so the dot sits cleanly next to `body` text.
    let line = theme.type_scale.body;
    let diameter = (line * 0.55).clamp(7.0, 12.0);
    let pad = 2.0;
    let (rect, response) =
        ui.allocate_exact_size(Vec2::new(diameter + pad * 2.0, line), Sense::hover());

    if ui.is_rect_visible(rect) {
        let center = pos2(rect.center().x, rect.center().y);
        let radius = diameter * 0.5;
        let color = if live { p.success } else { p.text_secondary };
        let painter = ui.painter();
        if live {
            painter.circle_filled(center, radius, color);
        } else {
            painter.circle_stroke(center, radius, Stroke::new(1.5_f32, color));
        }
    }

    response
}

/// The text styles.
///
/// None of them sets a wrap mode: a `Label` left alone takes the layout's,
/// which wraps in a column and extends in a row laid out without wrapping.
/// Forcing `.wrap()` instead made a label break its own text whenever a row
/// squeezed it — a status reading "joine/d", an unread count split down two
/// lines — for want of the width it asked for.
pub fn title(ui: &mut Ui, theme: &Theme, text: &str) {
    ui.add(Label::new(
        RichText::new(text)
            .size(theme.type_scale.title)
            .strong()
            .color(theme.palette.text),
    ));
}

pub fn title_2(ui: &mut Ui, theme: &Theme, text: &str) {
    ui.add(Label::new(
        RichText::new(text)
            .size(theme.type_scale.title_2)
            .strong()
            .color(theme.palette.text),
    ));
}

pub fn body(ui: &mut Ui, theme: &Theme, text: &str) {
    ui.add(Label::new(
        RichText::new(text)
            .size(theme.type_scale.body)
            .color(theme.palette.text),
    ));
}

pub fn dim_label(ui: &mut Ui, theme: &Theme, text: &str) {
    ui.add(Label::new(
        RichText::new(text)
            .size(theme.type_scale.caption)
            .color(theme.palette.text_secondary),
    ));
}

/// Single-line text field with theme field padding.
///
/// Desired width tracks the parent residual so the field fills its slot
/// instead of requesting a fixed 280px and clipping siblings off-screen.
pub fn text_field_singleline(ui: &mut Ui, theme: &Theme, text: &mut dyn TextBuffer) -> Response {
    let w = ui.available_width().max(1.0);
    ui.add(
        TextEdit::singleline(text)
            .margin(theme.text_edit_margin())
            .desired_width(w)
            .min_size(Vec2::new(0.0, theme.spacing.control_height)),
    )
}

/// Multi-line text field with theme field padding.
///
/// Fills parent width (clamped by available space) so it stays inside the view.
pub fn text_field_multiline(
    ui: &mut Ui,
    theme: &Theme,
    text: &mut dyn TextBuffer,
    rows: usize,
) -> Response {
    let w = ui.available_width().max(1.0);
    ui.add(
        TextEdit::multiline(text)
            .margin(theme.text_edit_margin())
            .desired_width(w)
            .desired_rows(rows),
    )
}

fn rgb(r: u8, g: u8, b: u8) -> Color32 {
    Color32::from_rgb(r, g, b)
}