nandi/jolt-nativepublic Fork 0
8cf1b3347ef6fa83297b6866dfd47d2728d4e339
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.

theme.rs · 646 lines · 20.4 KBRust Blame HistoryRaw
Bring vidya in cfd3e36 nandi 19d ago1//! Palette, metrics, egui Visuals/Style, and widget helpers.
2
3use egui::{
4 pos2,
5 style::{ScrollStyle, WidgetVisuals},
6 Button, Color32, CornerRadius, CursorIcon, FontId, Frame, Label, Margin, Response, RichText,
7 Sense, Shadow, Shape, Stroke, StrokeKind, Style, TextBuffer, TextEdit, TextWrapMode, Ui, Vec2,
8 Visuals, WidgetInfo, WidgetType,
9};
10
11use crate::install_text_styles;
12
13/// Light vs dark shell.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
15pub enum Mode {
16 #[default]
17 Dark,
18 Light,
19}
20
21/// Semantic colors.
22#[derive(Debug, Clone)]
23pub struct Palette {
24 pub window_bg: Color32,
25 pub view_bg: Color32,
26 pub headerbar_bg: Color32,
27 pub card_bg: Color32,
28 pub popover_bg: Color32,
29 pub shade: Color32,
30
31 pub accent: Color32,
32 pub accent_fg: Color32,
33 pub accent_hover: Color32,
34 pub accent_active: Color32,
35
36 pub destructive: Color32,
37 pub success: Color32,
38 pub warning: Color32,
39
40 pub text: Color32,
41 pub text_secondary: Color32,
42 pub text_disabled: Color32,
43
44 pub border: Color32,
45 pub border_soft: Color32,
46
47 pub button_bg: Color32,
48 pub button_hover: Color32,
49 pub button_active: Color32,
50 pub button_fg: Color32,
51}
52
53impl Palette {
54 pub fn dark() -> Self {
55 Self {
56 window_bg: rgb(0x24, 0x24, 0x24),
57 view_bg: rgb(0x1e, 0x1e, 0x1e),
58 headerbar_bg: rgb(0x30, 0x30, 0x30),
59 card_bg: rgb(0x30, 0x30, 0x30),
60 popover_bg: rgb(0x38, 0x38, 0x38),
61 shade: Color32::from_black_alpha(80),
62
63 accent: rgb(0x35, 0x84, 0xe4),
64 accent_fg: Color32::WHITE,
65 accent_hover: rgb(0x4a, 0x93, 0xe7),
66 accent_active: rgb(0x1c, 0x71, 0xd8),
67
68 destructive: rgb(0xc0, 0x1c, 0x28),
69 success: rgb(0x2e, 0xc2, 0x7e),
70 warning: rgb(0xe5, 0xa5, 0x0a),
71
72 text: rgb(0xff, 0xff, 0xff),
73 text_secondary: rgb(0x9a, 0x99, 0x96),
74 text_disabled: rgb(0x5e, 0x5c, 0x64),
75
76 border: rgb(0x5e, 0x5c, 0x64),
77 border_soft: rgb(0x3d, 0x38, 0x46),
78
79 button_bg: rgb(0x3d, 0x3d, 0x3d),
80 button_hover: rgb(0x4a, 0x4a, 0x4a),
81 button_active: rgb(0x35, 0x35, 0x35),
82 button_fg: rgb(0xff, 0xff, 0xff),
83 }
84 }
85
86 pub fn light() -> Self {
87 Self {
88 window_bg: rgb(0xfa, 0xfa, 0xfa),
89 view_bg: rgb(0xff, 0xff, 0xff),
90 headerbar_bg: rgb(0xff, 0xff, 0xff),
91 card_bg: rgb(0xff, 0xff, 0xff),
92 popover_bg: rgb(0xff, 0xff, 0xff),
93 shade: Color32::from_black_alpha(40),
94
95 accent: rgb(0x35, 0x84, 0xe4),
96 accent_fg: Color32::WHITE,
97 accent_hover: rgb(0x4a, 0x93, 0xe7),
98 accent_active: rgb(0x1c, 0x71, 0xd8),
99
100 destructive: rgb(0xc0, 0x1c, 0x28),
101 success: rgb(0x26, 0xa2, 0x69),
102 warning: rgb(0xe5, 0xa5, 0x0a),
103
104 text: rgb(0x24, 0x1f, 0x31),
105 text_secondary: rgb(0x5e, 0x5c, 0x64),
106 text_disabled: rgb(0x9a, 0x99, 0x96),
107
108 border: rgb(0xcd, 0xcd, 0xcd),
109 border_soft: rgb(0xe0, 0xe0, 0xe0),
110
111 button_bg: rgb(0xe0, 0xe0, 0xe0),
112 button_hover: rgb(0xd0, 0xd0, 0xd0),
113 button_active: rgb(0xc0, 0xc0, 0xc0),
114 button_fg: rgb(0x24, 0x1f, 0x31),
115 }
116 }
117}
118
119/// Spacing scale (HIG-ish: 6 / 12 / 18 / 24).
120#[derive(Debug, Clone)]
121pub struct Spacing {
122 pub xs: f32,
123 pub sm: f32,
124 pub md: f32,
125 pub lg: f32,
126 pub xl: f32,
127 pub page: f32,
128 pub control_height: f32,
129 /// Inner horizontal padding for text fields (egui default is only 4).
130 pub field_pad_x: f32,
131 /// Inner vertical padding for text fields (egui default is only 2).
132 pub field_pad_y: f32,
133 /// Gap between a form label and its control.
134 pub field_gap: f32,
135 pub radius_sm: f32,
136 pub radius_md: f32,
137 pub radius_lg: f32,
138}
139
140impl Default for Spacing {
141 fn default() -> Self {
142 Self {
143 xs: 4.0,
144 sm: 6.0,
145 md: 12.0,
146 lg: 18.0,
147 xl: 24.0,
148 page: 16.0,
149 control_height: 34.0,
150 field_pad_x: 12.0,
151 field_pad_y: 8.0,
152 field_gap: 12.0,
153 radius_sm: 6.0,
154 radius_md: 9.0,
155 radius_lg: 12.0,
156 }
157 }
158}
159
160/// Type sizes (UI sans scale).
161#[derive(Debug, Clone)]
162pub struct TypeScale {
163 pub title: f32,
164 pub title_2: f32,
165 pub title_3: f32,
166 pub body: f32,
167 pub caption: f32,
168}
169
170impl Default for TypeScale {
171 fn default() -> Self {
172 Self {
173 title: 20.0,
174 title_2: 16.0,
175 title_3: 14.0,
176 body: 14.0,
177 caption: 12.0,
178 }
179 }
180}
181
182/// Full theme bundle.
183#[derive(Debug, Clone)]
184pub struct Theme {
185 pub mode: Mode,
186 pub palette: Palette,
187 pub spacing: Spacing,
188 pub type_scale: TypeScale,
189}
190
191impl Theme {
192 pub fn dark() -> Self {
193 Self {
194 mode: Mode::Dark,
195 palette: Palette::dark(),
196 spacing: Spacing::default(),
197 type_scale: TypeScale::default(),
198 }
199 }
200
201 pub fn light() -> Self {
202 Self {
203 mode: Mode::Light,
204 palette: Palette::light(),
205 spacing: Spacing::default(),
206 type_scale: TypeScale::default(),
207 }
208 }
209
210 pub fn visuals(&self) -> Visuals {
211 let p = &self.palette;
212 let mut v = match self.mode {
213 Mode::Dark => Visuals::dark(),
214 Mode::Light => Visuals::light(),
215 };
216
217 v.dark_mode = self.mode == Mode::Dark;
218 v.override_text_color = Some(p.text);
219 v.hyperlink_color = p.accent;
220 v.warn_fg_color = p.warning;
221 v.error_fg_color = p.destructive;
222 v.extreme_bg_color = p.view_bg;
223 v.faint_bg_color = p.card_bg;
224 v.code_bg_color = p.popover_bg;
225 v.window_fill = p.window_bg;
226 v.panel_fill = p.window_bg;
227 v.window_stroke = Stroke::new(1.0_f32, p.border_soft);
228 v.window_corner_radius = CornerRadius::same(self.spacing.radius_md as u8);
229 v.window_shadow = Shadow {
230 offset: [0, 4],
231 blur: 16,
232 spread: 0,
233 color: p.shade,
234 };
235 v.popup_shadow = Shadow {
236 offset: [0, 2],
237 blur: 8,
238 spread: 0,
239 color: p.shade,
240 };
241 v.selection.bg_fill = p.accent.gamma_multiply(0.35);
242 v.selection.stroke = Stroke::new(1.0_f32, p.accent);
243 v.widgets = widget_visuals(p, &self.spacing);
244 // Browser-style click affordance: buttons (and apps that opt in) show a
245 // pointing-hand cursor on hover. egui defaults this to None (native toolkit
246 // style); Vidya prefers the clickable cue on desktop.
247 v.interact_cursor = Some(CursorIcon::PointingHand);
248 v
249 }
250
251 pub fn style(&self) -> Style {
252 let mut style = Style {
253 visuals: self.visuals(),
254 ..Style::default()
255 };
256 install_text_styles(&mut style, &self.type_scale);
257
258 let sp = &self.spacing;
259 style.spacing.item_spacing = Vec2::new(sp.sm, sp.sm);
260 style.spacing.button_padding = Vec2::new(sp.md, sp.sm);
261 style.spacing.indent = sp.lg;
262 style.spacing.window_margin = Margin::same(sp.page as i8);
263 style.spacing.menu_margin = Margin::same(sp.sm as i8);
264 style.spacing.interact_size = Vec2::new(sp.control_height * 2.5, sp.control_height);
265 // Native checkbox / radio icons (prefer `checkbox()` for the themed control).
266 style.spacing.icon_width = 18.0;
267 style.spacing.icon_width_inner = 12.0;
268 style.spacing.icon_spacing = sp.sm + 2.0;
269 // Solid gutters: floating bars sit on top of content (egui default). HIG-ish
270 // scroll areas reserve space so lists/text never sit under the thumb.
271 style.spacing.scroll = ScrollStyle::solid();
272 // Prefer wrapping at the parent edge over extending (overflow) or hard
273 // truncate. Pair with layout helpers (`fit_width`, `card`, `hflow`) so
274 // rows actually receive a finite residual width.
275 style.wrap_mode = Some(TextWrapMode::Wrap);
276 // Default TextEdit desired width: fill parent residual (egui still
277 // clamps to available_width). Avoid the stock 280px “always this wide”
278 // intrinsic that pushes horizontal rows past the app view.
279 style.spacing.text_edit_width = 10_000.0;
280 style.interaction.tooltip_delay = 0.4;
281 style.animation_time = 0.12;
282 style
283 }
284
285 pub fn header_frame(&self) -> Frame {
286 Frame::new()
287 .fill(self.palette.headerbar_bg)
288 .inner_margin(Margin::symmetric(
289 self.spacing.page as i8,
290 self.spacing.md as i8,
291 ))
292 .stroke(Stroke::new(1.0_f32, self.palette.border_soft))
293 }
294
295 pub fn card_frame(&self) -> Frame {
296 Frame::new()
297 .fill(self.palette.card_bg)
298 .corner_radius(self.spacing.radius_lg)
299 .inner_margin(Margin::same(self.spacing.md as i8))
300 .stroke(Stroke::new(1.0_f32, self.palette.border_soft))
301 }
302
303 pub fn page_frame(&self) -> Frame {
304 Frame::new()
305 .fill(self.palette.window_bg)
306 .inner_margin(Margin::same(self.spacing.page as i8))
307 }
308
309 /// Inner padding for [`TextEdit`] / form fields (HIG-ish; roomier than egui’s 4×2 default).
310 pub fn text_edit_margin(&self) -> Margin {
311 Margin::symmetric(
312 self.spacing.field_pad_x as i8,
313 self.spacing.field_pad_y as i8,
314 )
315 }
316}
317
318fn widget_visuals(p: &Palette, sp: &Spacing) -> egui::style::Widgets {
319 let r = CornerRadius::same(sp.radius_md as u8);
320 let soft = Stroke::new(1.0_f32, p.border_soft);
321
322 egui::style::Widgets {
323 noninteractive: WidgetVisuals {
324 bg_fill: p.card_bg,
325 weak_bg_fill: p.view_bg,
326 bg_stroke: soft,
327 corner_radius: r,
328 fg_stroke: Stroke::new(1.0_f32, p.text_secondary),
329 expansion: 0.0,
330 },
331 inactive: WidgetVisuals {
332 bg_fill: p.button_bg,
333 weak_bg_fill: p.button_bg,
334 bg_stroke: Stroke::new(1.0_f32, p.border_soft),
335 corner_radius: r,
336 fg_stroke: Stroke::new(1.0_f32, p.button_fg),
337 expansion: 0.0,
338 },
339 hovered: WidgetVisuals {
340 bg_fill: p.button_hover,
341 weak_bg_fill: p.button_hover,
342 bg_stroke: Stroke::new(1.0_f32, p.border),
343 corner_radius: r,
344 fg_stroke: Stroke::new(1.0_f32, p.text),
345 expansion: 0.0,
346 },
347 active: WidgetVisuals {
348 bg_fill: p.button_active,
349 weak_bg_fill: p.button_active,
350 bg_stroke: Stroke::new(1.0_f32, p.accent),
351 corner_radius: r,
352 fg_stroke: Stroke::new(1.0_f32, p.text),
353 expansion: 0.0,
354 },
355 open: WidgetVisuals {
356 bg_fill: p.popover_bg,
357 weak_bg_fill: p.popover_bg,
358 bg_stroke: Stroke::new(1.0_f32, p.border),
359 corner_radius: r,
360 fg_stroke: Stroke::new(1.0_f32, p.text),
361 expansion: 0.0,
362 },
363 }
364}
365
366/// Square tool button with a stroke/emoji [`crate::Icon`] (e.g. copy, plus).
367///
368/// Prefer this over text labels in tight rows — text wraps under
369/// [`TextWrapMode::Wrap`] and looks broken at small widths.
370pub fn icon_button(ui: &mut Ui, theme: &Theme, icon: crate::Icon, tip: &str) -> Response {
371 use crate::paint_icon_in;
372 let p = &theme.palette;
373 let size = theme.spacing.control_height;
374 let (rect, mut response) = ui.allocate_exact_size(Vec2::splat(size), Sense::click());
375 response = response.on_hover_text(tip);
376 if let Some(cursor) = ui.visuals().interact_cursor {
377 response = response.on_hover_cursor(cursor);
378 }
379
380 if ui.is_rect_visible(rect) {
381 let hovered = response.hovered() && ui.is_enabled();
382 let active = response.is_pointer_button_down_on();
383 let fill = if active {
384 p.button_active
385 } else if hovered {
386 p.button_hover
387 } else {
388 p.button_bg
389 };
390 let border = if hovered {
391 Stroke::new(1.0_f32, p.border)
392 } else {
393 Stroke::new(1.0_f32, p.border_soft)
394 };
395 ui.painter().rect(
396 rect,
397 theme.spacing.radius_md,
398 fill,
399 border,
400 StrokeKind::Inside,
401 );
402 let icon_size = size * 0.48;
403 let icon_rect = egui::Rect::from_center_size(rect.center(), Vec2::splat(icon_size));
404 paint_icon_in(ui, icon_rect, icon, p.button_fg);
405 }
406
407 response
408}
409
410/// Primary / suggested action (accent filled).
411pub fn primary_button(ui: &mut Ui, theme: &Theme, label: &str) -> Response {
412 let p = &theme.palette;
413 let text = RichText::new(label)
414 .size(theme.type_scale.body)
415 .color(p.accent_fg);
416 let btn = Button::new(text)
417 .fill(p.accent)
418 .stroke(Stroke::NONE)
419 .corner_radius(theme.spacing.radius_md)
420 .min_size(Vec2::new(0.0, theme.spacing.control_height));
421 ui.add(btn)
422}
423
424/// Regular flat button.
425pub fn button(ui: &mut Ui, theme: &Theme, label: &str) -> Response {
426 let p = &theme.palette;
427 let text = RichText::new(label)
428 .size(theme.type_scale.body)
429 .color(p.button_fg);
430 let btn = Button::new(text)
431 .fill(p.button_bg)
432 .stroke(Stroke::new(1.0_f32, p.border_soft))
433 .corner_radius(theme.spacing.radius_md)
434 .min_size(Vec2::new(0.0, theme.spacing.control_height));
435 ui.add(btn)
436}
437
438/// Destructive action.
439pub fn destructive_button(ui: &mut Ui, theme: &Theme, label: &str) -> Response {
440 let p = &theme.palette;
441 let text = RichText::new(label)
442 .size(theme.type_scale.body)
443 .color(Color32::WHITE);
444 let btn = Button::new(text)
445 .fill(p.destructive)
446 .stroke(Stroke::NONE)
447 .corner_radius(theme.spacing.radius_md)
448 .min_size(Vec2::new(0.0, theme.spacing.control_height));
449 ui.add(btn)
450}
451
452/// Accent-filled checkbox with a drawn checkmark (stock egui looks janky here).
453///
454/// Unchecked: soft fill + border. Checked: solid accent tile + white stroke check.
455pub fn checkbox(ui: &mut Ui, theme: &Theme, checked: &mut bool, text: &str) -> Response {
456 let p = &theme.palette;
457 let sp = &theme.spacing;
458
459 // Slightly larger than stock 16px so the check has room to breathe.
460 let box_size = 20.0_f32;
461 let gap = sp.sm + 4.0;
462
463 let galley = ui.fonts(|f| {
464 f.layout_no_wrap(
465 text.to_owned(),
466 FontId::proportional(theme.type_scale.body),
467 p.text,
468 )
469 });
470
471 let height = box_size
472 .max(galley.size().y + 4.0)
473 .max(sp.control_height * 0.85);
474 let width = box_size + gap + galley.size().x;
475 let (rect, mut response) = ui.allocate_exact_size(Vec2::new(width, height), Sense::click());
476
477 if response.clicked() {
478 *checked = !*checked;
479 response.mark_changed();
480 }
481 response.widget_info(|| {
482 WidgetInfo::selected(WidgetType::Checkbox, ui.is_enabled(), *checked, text)
483 });
484 // Custom widget — egui only applies `interact_cursor` on stock `Button`.
485 if let Some(cursor) = ui.visuals().interact_cursor {
486 response = response.on_hover_cursor(cursor);
487 }
488
489 if ui.is_rect_visible(rect) {
490 let painter = ui.painter();
491 let box_rect = egui::Rect::from_center_size(
492 pos2(rect.left() + box_size * 0.5, rect.center().y),
493 Vec2::splat(box_size),
494 );
495 // Squarer than widget radius_md (which nearly circles a 20px tile).
496 let radius = CornerRadius::same((sp.radius_sm * 0.85) as u8);
497
498 let hovered = response.hovered() && ui.is_enabled();
499 let (fill, border) = if *checked {
500 let fill = if hovered { p.accent_hover } else { p.accent };
501 (fill, Stroke::new(1.0_f32, fill))
502 } else if hovered {
503 (p.button_hover, Stroke::new(1.5_f32, p.border))
504 } else {
505 (p.button_bg, Stroke::new(1.25_f32, p.border_soft))
506 };
507
508 painter.rect(box_rect, radius, fill, border, StrokeKind::Inside);
509
510 if *checked {
511 // Refined check proportions (short leg → long arm), not stock’s steep V.
512 let s = box_size;
513 let o = box_rect.min;
514 let a = pos2(o.x + s * 0.22, o.y + s * 0.52);
515 let b = pos2(o.x + s * 0.42, o.y + s * 0.70);
516 let c = pos2(o.x + s * 0.78, o.y + s * 0.30);
517 // Slightly thicker tip stroke reads cleaner at small sizes.
518 painter.add(Shape::line(
519 vec![a, b, c],
520 Stroke::new(2.15_f32, p.accent_fg),
521 ));
522 }
523
524 let text_pos = pos2(
525 rect.left() + box_size + gap,
526 rect.center().y - 0.5 * galley.size().y,
527 );
528 painter.galley(text_pos, galley, p.text);
529 }
530
531 response
532}
533
534/// Live / offline status mark drawn with shapes.
535///
536/// **Do not** use Unicode `●` / `○` in labels on Android: egui’s default fonts
537/// often lack those glyphs and render a hollow box (“tofu”). This paints a real
538/// circle instead — filled when `live`, stroked when offline.
539pub fn status_dot(ui: &mut Ui, theme: &Theme, live: bool) -> Response {
540 let p = &theme.palette;
541 // Match body line height so the dot sits cleanly next to `body` text.
542 let line = theme.type_scale.body;
543 let diameter = (line * 0.55).clamp(7.0, 12.0);
544 let pad = 2.0;
545 let (rect, response) =
546 ui.allocate_exact_size(Vec2::new(diameter + pad * 2.0, line), Sense::hover());
547
548 if ui.is_rect_visible(rect) {
549 let center = pos2(rect.center().x, rect.center().y);
550 let radius = diameter * 0.5;
551 let color = if live { p.success } else { p.text_secondary };
552 let painter = ui.painter();
553 if live {
554 painter.circle_filled(center, radius, color);
555 } else {
556 painter.circle_stroke(center, radius, Stroke::new(1.5_f32, color));
557 }
558 }
559
560 response
561}
562
Let a label wrap where the layout wraps a20c5b9 nandi 19d ago563/// The text styles.
564///
565/// None of them sets a wrap mode: a `Label` left alone takes the layout's,
566/// which wraps in a column and extends in a row laid out without wrapping.
567/// Forcing `.wrap()` instead made a label break its own text whenever a row
568/// squeezed it — a status reading "joine/d", an unread count split down two
569/// lines — for want of the width it asked for.
Bring vidya in cfd3e36 nandi 19d ago570pub fn title(ui: &mut Ui, theme: &Theme, text: &str) {
571 ui.add(
572 Label::new(
573 RichText::new(text)
574 .size(theme.type_scale.title)
575 .strong()
576 .color(theme.palette.text),
Let a label wrap where the layout wraps a20c5b9 nandi 19d ago577 ),
Bring vidya in cfd3e36 nandi 19d ago578 );
579}
580
581pub fn title_2(ui: &mut Ui, theme: &Theme, text: &str) {
582 ui.add(
583 Label::new(
584 RichText::new(text)
585 .size(theme.type_scale.title_2)
586 .strong()
587 .color(theme.palette.text),
Let a label wrap where the layout wraps a20c5b9 nandi 19d ago588 ),
Bring vidya in cfd3e36 nandi 19d ago589 );
590}
591
592pub fn body(ui: &mut Ui, theme: &Theme, text: &str) {
593 ui.add(
594 Label::new(
595 RichText::new(text)
596 .size(theme.type_scale.body)
597 .color(theme.palette.text),
Let a label wrap where the layout wraps a20c5b9 nandi 19d ago598 ),
Bring vidya in cfd3e36 nandi 19d ago599 );
600}
601
602pub fn dim_label(ui: &mut Ui, theme: &Theme, text: &str) {
603 ui.add(
604 Label::new(
605 RichText::new(text)
606 .size(theme.type_scale.caption)
607 .color(theme.palette.text_secondary),
Let a label wrap where the layout wraps a20c5b9 nandi 19d ago608 ),
Bring vidya in cfd3e36 nandi 19d ago609 );
610}
611
612/// Single-line text field with theme field padding.
613///
614/// Desired width tracks the parent residual so the field fills its slot
615/// instead of requesting a fixed 280px and clipping siblings off-screen.
616pub fn text_field_singleline(ui: &mut Ui, theme: &Theme, text: &mut dyn TextBuffer) -> Response {
617 let w = ui.available_width().max(1.0);
618 ui.add(
619 TextEdit::singleline(text)
620 .margin(theme.text_edit_margin())
621 .desired_width(w)
622 .min_size(Vec2::new(0.0, theme.spacing.control_height)),
623 )
624}
625
626/// Multi-line text field with theme field padding.
627///
628/// Fills parent width (clamped by available space) so it stays inside the view.
629pub fn text_field_multiline(
630 ui: &mut Ui,
631 theme: &Theme,
632 text: &mut dyn TextBuffer,
633 rows: usize,
634) -> Response {
635 let w = ui.available_width().max(1.0);
636 ui.add(
637 TextEdit::multiline(text)
638 .margin(theme.text_edit_margin())
639 .desired_width(w)
640 .desired_rows(rows),
641 )
642}
643
644fn rgb(r: u8, g: u8, b: u8) -> Color32 {
645 Color32::from_rgb(r, g, b)
646}