summaryrefslogtreecommitdiffstats
path: root/gui/src
diff options
context:
space:
mode:
Diffstat (limited to 'gui/src')
-rw-r--r--gui/src/app.rs587
-rw-r--r--gui/src/lib.rs9
-rw-r--r--gui/src/main.rs25
-rw-r--r--gui/src/roulette.rs522
-rw-r--r--gui/src/theme.rs211
5 files changed, 1354 insertions, 0 deletions
diff --git a/gui/src/app.rs b/gui/src/app.rs
new file mode 100644
index 0000000..fde117a
--- /dev/null
+++ b/gui/src/app.rs
@@ -0,0 +1,587 @@
1//! The desktop shell: header, roulette table, and betting sidebar.
2//!
3//! Phase 3 is a style spike. The table is painted from a demo `RouletteView`
4//! built with the core's own types, but no command is sent yet: Phase 4 wires
5//! the transport and replaces the demo state with `ClientCore`.
6
7use std::f32::consts::TAU;
8
9use eframe::egui::{
10 self, Align, Button, CentralPanel, Color32, Context, Frame, Key, Layout, Margin, Panel,
11 RichText, Sense, Stroke, Ui, Vec2, vec2,
12};
13use zagan_bet::client::games::roulette::{Cursor, Direction};
14use zagan_bet::client::view::{RoulettePhase, RouletteView};
15use zagan_bet::roulette::{BetTarget, Bets, Outside, WHEEL, color as wheel_color};
16use zagan_bet::version::VERSION;
17
18use crate::{roulette, theme};
19
20const HEADER_HEIGHT: f32 = 64.0;
21const SIDEBAR_WIDTH: f32 = 336.0;
22const SPIN_SECONDS: f64 = 2.4;
23/// Demo winners, cycled: a red, a zero, a red, a black, a black, a black.
24const DEMO_TARGETS: [u8; 6] = [17, 0, 32, 26, 8, 11];
25const CHIP_SIZES: [u64; 4] = [10, 25, 50, 100];
26
27pub struct ZaganApp {
28 demo: Demo,
29}
30
31impl ZaganApp {
32 pub fn new(cc: &eframe::CreationContext<'_>) -> Self {
33 theme::install(&cc.egui_ctx);
34 Self { demo: Demo::new() }
35 }
36}
37
38impl eframe::App for ZaganApp {
39 fn ui(&mut self, ui: &mut Ui, _frame: &mut eframe::Frame) {
40 self.demo.tick(ui.ctx());
41 self.demo.keys(ui.ctx());
42
43 Panel::top("header")
44 .exact_size(HEADER_HEIGHT)
45 .frame(
46 Frame::new()
47 .fill(theme::SURFACE)
48 .inner_margin(Margin::symmetric(theme::SPACE_5 as i8, 0)),
49 )
50 .show(ui, |ui| header(ui, &self.demo));
51
52 Panel::right("sidebar")
53 .exact_size(SIDEBAR_WIDTH)
54 .frame(
55 Frame::new()
56 .fill(theme::SURFACE)
57 .inner_margin(Margin::symmetric(theme::SPACE_5 as i8, 20)),
58 )
59 .show(ui, |ui| {
60 egui::ScrollArea::vertical()
61 .auto_shrink([false, false])
62 .show(ui, |ui| sidebar(ui, &mut self.demo));
63 });
64
65 CentralPanel::default()
66 .frame(
67 Frame::new()
68 .fill(theme::BG)
69 .inner_margin(Margin::same(theme::SPACE_5 as i8)),
70 )
71 .show(ui, |ui| table(ui, &mut self.demo));
72 }
73}
74
75// ---------------------------------------------------------------------------
76// Header
77// ---------------------------------------------------------------------------
78
79fn header(ui: &mut Ui, demo: &Demo) {
80 ui.with_layout(Layout::left_to_right(Align::Center), |ui| {
81 let (mark, _) = ui.allocate_exact_size(Vec2::splat(10.0), Sense::hover());
82 ui.painter().rect_filled(mark, theme::RADIUS_SM, theme::WIN);
83 ui.add_space(theme::SPACE_2);
84 ui.label(
85 RichText::new("ZAGAN")
86 .font(theme::mono(18.0))
87 .color(theme::TEXT),
88 );
89 ui.label(
90 RichText::new("BET")
91 .font(theme::mono(18.0))
92 .color(theme::WIN),
93 );
94 ui.add_space(theme::SPACE_3);
95 pill(ui, &format!("v{VERSION}"), theme::TEXT_FAINT);
96 ui.add_space(theme::SPACE_1);
97 pill(ui, "PREVIEW", theme::GOLD);
98 ui.add_space(theme::SPACE_5);
99 connection_pill(ui, demo);
100
101 ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
102 stat_block(
103 ui,
104 150.0,
105 "Balance",
106 &theme::money(demo.balance),
107 theme::mono(20.0),
108 theme::TEXT,
109 None,
110 );
111 ui.add_space(theme::SPACE_5);
112 stat_block(
113 ui,
114 190.0,
115 "Account",
116 demo.user,
117 theme::sans(theme::FONT_BODY),
118 theme::TEXT,
119 Some(demo.account),
120 );
121 });
122 });
123}
124
125fn pill(ui: &mut Ui, text: &str, color: Color32) {
126 Frame::new()
127 .fill(theme::SURFACE_RAISED)
128 .corner_radius(theme::RADIUS_SM)
129 .inner_margin(Margin::symmetric(theme::SPACE_2 as i8, 3))
130 .show(ui, |ui| {
131 ui.label(RichText::new(text).font(theme::mono(10.0)).color(color));
132 });
133}
134
135fn connection_pill(ui: &mut Ui, demo: &Demo) {
136 Frame::new()
137 .fill(theme::SURFACE_RAISED)
138 .corner_radius(theme::RADIUS_SM)
139 .inner_margin(Margin::symmetric(theme::SPACE_3 as i8, 5))
140 .show(ui, |ui| {
141 ui.horizontal(|ui| {
142 let (dot, _) = ui.allocate_exact_size(Vec2::splat(8.0), Sense::hover());
143 ui.painter().circle_filled(dot.center(), 3.0, theme::WIN);
144 ui.label(
145 RichText::new("ONLINE")
146 .font(theme::mono(10.0))
147 .color(theme::WIN),
148 );
149 ui.label(
150 RichText::new(demo.host)
151 .font(theme::mono(12.0))
152 .color(theme::TEXT_DIM),
153 );
154 ui.label(
155 RichText::new(format!("{} ms", demo.ping_ms))
156 .font(theme::mono(12.0))
157 .color(theme::TEXT_FAINT),
158 );
159 });
160 });
161}
162
163/// A label over a value, right-aligned in a fixed slot so the header's right
164/// edge stays put as the numbers change.
165fn stat_block(
166 ui: &mut Ui,
167 width: f32,
168 label: &str,
169 value: &str,
170 value_font: egui::FontId,
171 value_color: Color32,
172 detail: Option<&str>,
173) {
174 ui.allocate_ui_with_layout(vec2(width, 44.0), Layout::top_down(Align::Max), |ui| {
175 ui.spacing_mut().item_spacing.y = 0.0;
176 ui.label(theme::label(label));
177 ui.label(RichText::new(value).font(value_font).color(value_color));
178 if let Some(detail) = detail {
179 ui.label(
180 RichText::new(detail)
181 .font(theme::mono(10.0))
182 .color(theme::TEXT_FAINT),
183 );
184 }
185 });
186}
187
188// ---------------------------------------------------------------------------
189// Sidebar
190// ---------------------------------------------------------------------------
191
192fn sidebar(ui: &mut Ui, demo: &mut Demo) {
193 ui.label(
194 RichText::new("Roulette")
195 .font(theme::sans(22.0))
196 .color(theme::TEXT),
197 );
198 ui.label(
199 RichText::new("European · single zero")
200 .font(theme::sans(12.0))
201 .color(theme::TEXT_DIM),
202 );
203
204 ui.add_space(theme::SPACE_4);
205 let view = demo.view();
206 let wheel_size = ui.available_width().min(210.0);
207 ui.horizontal(|ui| {
208 let pad = ((ui.available_width() - wheel_size) * 0.5).max(0.0);
209 ui.add_space(pad);
210 roulette::wheel(ui, wheel_size, &view, demo.rotation);
211 });
212 ui.add_space(theme::SPACE_1);
213 result_line(ui, demo);
214
215 ui.add_space(theme::SPACE_3);
216 ui.separator();
217 ui.add_space(theme::SPACE_3);
218
219 ui.label(theme::label("Chip"));
220 ui.add_space(theme::SPACE_2);
221 ui.horizontal(|ui| {
222 ui.spacing_mut().item_spacing.x = theme::SPACE_2;
223 for amount in CHIP_SIZES {
224 if chip_button(ui, amount, demo.chip == amount).clicked() {
225 demo.chip = amount;
226 }
227 }
228 });
229
230 ui.add_space(theme::SPACE_3);
231 ui.label(theme::label("Bets"));
232 ui.add_space(theme::SPACE_1);
233 if demo.bets.chips.is_empty() {
234 ui.label(
235 RichText::new("No chips on the felt")
236 .font(theme::sans(12.0))
237 .color(theme::TEXT_FAINT),
238 );
239 } else {
240 for chip in &demo.bets.chips {
241 bet_row(ui, &roulette::target_name(&chip.target), chip.amount);
242 }
243 }
244
245 ui.add_space(theme::SPACE_3);
246 ui.separator();
247 ui.add_space(theme::SPACE_2);
248 bet_row(ui, "Total", demo.bets.total());
249
250 ui.add_space(theme::SPACE_3);
251 let spinning = demo.phase == RoulettePhase::Spinning;
252 let spin = Button::new(
253 RichText::new("SPIN")
254 .font(theme::mono(theme::FONT_MONO))
255 .color(theme::BG),
256 )
257 .fill(if spinning {
258 theme::WIN.gamma_multiply(0.45)
259 } else {
260 theme::WIN
261 })
262 .corner_radius(theme::RADIUS_MD)
263 .min_size(vec2(ui.available_width(), 44.0));
264 if ui.add_enabled(!spinning, spin).clicked() {
265 demo.start_spin(ui.ctx());
266 }
267
268 ui.add_space(theme::SPACE_2);
269 let clear = Button::new(
270 RichText::new("Clear bets")
271 .font(theme::sans(12.0))
272 .color(theme::TEXT_DIM),
273 )
274 .frame(false);
275 if ui
276 .add_enabled(!demo.bets.is_empty() && !spinning, clear)
277 .clicked()
278 {
279 demo.bets = Bets::default();
280 }
281}
282
283fn result_line(ui: &mut Ui, demo: &Demo) {
284 ui.horizontal(|ui| {
285 match (demo.phase, demo.number) {
286 (RoulettePhase::Result, Some(number)) => {
287 ui.label(theme::label("Result"));
288 ui.label(
289 RichText::new(number.to_string())
290 .font(theme::mono(15.0))
291 .color(color_of(number)),
292 );
293 ui.label(
294 RichText::new(wheel_color(number).to_uppercase())
295 .font(theme::mono(12.0))
296 .color(theme::TEXT_DIM),
297 );
298 }
299 (RoulettePhase::Spinning, _) => {
300 ui.label(
301 RichText::new("SPINNING…")
302 .font(theme::mono(12.0))
303 .color(theme::GOLD),
304 );
305 }
306 _ => {
307 ui.label(
308 RichText::new("Place your bets")
309 .font(theme::sans(12.0))
310 .color(theme::TEXT_DIM),
311 );
312 }
313 }
314 ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
315 ui.label(
316 RichText::new(format!("{} chips", demo.bets.chips.len()))
317 .font(theme::mono(10.0))
318 .color(theme::TEXT_FAINT),
319 );
320 });
321 });
322}
323
324fn bet_row(ui: &mut Ui, name: &str, amount: u64) {
325 ui.horizontal(|ui| {
326 ui.label(
327 RichText::new(name)
328 .font(theme::sans(13.0))
329 .color(theme::TEXT_DIM),
330 );
331 ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
332 ui.label(
333 RichText::new(theme::money(amount as i64))
334 .font(theme::mono(13.0))
335 .color(theme::GOLD),
336 );
337 });
338 });
339}
340
341fn chip_button(ui: &mut Ui, amount: u64, selected: bool) -> egui::Response {
342 let (rect, response) = ui.allocate_exact_size(Vec2::splat(46.0), Sense::click());
343 let center = rect.center();
344 let radius = 20.0;
345 let painter = ui.painter();
346 if selected || response.hovered() {
347 painter.circle_stroke(
348 center,
349 radius + 3.0,
350 Stroke::new(if selected { 2.0 } else { 1.0 }, theme::ACCENT),
351 );
352 }
353 roulette::paint_chip(painter, center, radius, amount);
354 response
355}
356
357fn color_of(number: u8) -> Color32 {
358 match wheel_color(number) {
359 "red" => theme::ROULETTE_RED,
360 "green" => theme::WIN,
361 _ => theme::TEXT,
362 }
363}
364
365// ---------------------------------------------------------------------------
366// Table
367// ---------------------------------------------------------------------------
368
369fn table(ui: &mut Ui, demo: &mut Demo) {
370 ui.horizontal(|ui| {
371 ui.label(
372 RichText::new("Table 01")
373 .font(theme::sans(15.0))
374 .color(theme::TEXT),
375 );
376 ui.label(
377 RichText::new("European · single zero")
378 .font(theme::sans(12.0))
379 .color(theme::TEXT_DIM),
380 );
381 ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
382 ui.label(
383 RichText::new("Limits 10 – 500 Kr")
384 .font(theme::mono(12.0))
385 .color(theme::TEXT_FAINT),
386 );
387 });
388 });
389 ui.add_space(theme::SPACE_4);
390
391 let view = demo.view();
392 let strip_height = 104.0;
393 let available = ui.available_size();
394 let size = roulette::size_for(vec2(available.x, (available.y - strip_height).max(220.0)));
395 let side_pad = ((available.x - size.x) * 0.5).max(0.0);
396 let top_pad = ((available.y - size.y - strip_height) * 0.5).max(0.0);
397 let chip = demo.chip;
398 ui.add_space(top_pad);
399 ui.horizontal(|ui| {
400 ui.add_space(side_pad);
401 if let Some(target) = roulette::felt(ui, size, &view) {
402 demo.bets.add(target, chip);
403 }
404 });
405
406 ui.add_space(theme::SPACE_4);
407 ui.horizontal(|ui| {
408 ui.label(theme::label("Last"));
409 if demo.last.is_empty() {
410 ui.label(
411 RichText::new("No spins yet")
412 .font(theme::sans(12.0))
413 .color(theme::TEXT_FAINT),
414 );
415 }
416 for number in &demo.last {
417 let (dot, _) = ui.allocate_exact_size(Vec2::splat(26.0), Sense::hover());
418 roulette::paint_result_dot(ui.painter(), dot.center(), 11.0, *number);
419 ui.add_space(theme::SPACE_1);
420 }
421 });
422 ui.add_space(theme::SPACE_2);
423 ui.label(
424 RichText::new(
425 "Click a pocket to place a chip · arrow keys move the cursor · space spins",
426 )
427 .font(theme::sans(12.0))
428 .color(theme::TEXT_FAINT),
429 );
430}
431
432// ---------------------------------------------------------------------------
433// Demo state
434// ---------------------------------------------------------------------------
435
436struct Spin {
437 started: f64,
438 from: f32,
439 delta: f32,
440 target: u8,
441}
442
443struct Demo {
444 bets: Bets,
445 cursor: Cursor,
446 chip: u64,
447 phase: RoulettePhase,
448 number: Option<u8>,
449 rotation: f32,
450 spin: Option<Spin>,
451 spins: usize,
452 last: Vec<u8>,
453 balance: i64,
454 host: &'static str,
455 ping_ms: u32,
456 user: &'static str,
457 account: &'static str,
458}
459
460impl Demo {
461 fn new() -> Self {
462 let mut bets = Bets::default();
463 bets.add(BetTarget::Straight { number: 17 }, 50);
464 bets.add(
465 BetTarget::Corner {
466 numbers: [17, 18, 20, 21],
467 },
468 25,
469 );
470 bets.add(BetTarget::Outside { kind: Outside::Red }, 25);
471
472 Self {
473 bets,
474 // A split with no chip on it, so the fine lattice cursor shows.
475 cursor: Cursor::Lattice { x: 18, y: 3 },
476 chip: 25,
477 phase: RoulettePhase::Placing,
478 number: None,
479 rotation: 0.0,
480 spin: None,
481 spins: 0,
482 last: Vec::new(),
483 balance: 1000,
484 host: "127.0.0.1:11525",
485 ping_ms: 41,
486 user: "alice",
487 account: "#4161673306",
488 }
489 }
490
491 fn view(&self) -> RouletteView {
492 RouletteView {
493 bet_amount: 10,
494 balance: Some(self.balance),
495 chip: self.chip,
496 bets: self.bets.clone(),
497 cursor: self.cursor,
498 phase: self.phase,
499 number: self.number,
500 color: self.number.map(|number| wheel_color(number).to_string()),
501 winnings: None,
502 error: None,
503 }
504 }
505
506 fn tick(&mut self, ctx: &Context) {
507 let now = ctx.input(|input| input.time);
508 let mut finished = None;
509 if let Some(spin) = &self.spin {
510 let t = (((now - spin.started) / SPIN_SECONDS) as f32).clamp(0.0, 1.0);
511 let eased = 1.0 - (1.0 - t).powi(3);
512 self.rotation = spin.from + spin.delta * eased;
513 if t >= 1.0 {
514 finished = Some(spin.target);
515 }
516 ctx.request_repaint();
517 }
518 if let Some(number) = finished {
519 self.spin = None;
520 self.phase = RoulettePhase::Result;
521 self.number = Some(number);
522 self.last.insert(0, number);
523 self.last.truncate(12);
524 }
525 }
526
527 fn keys(&mut self, ctx: &Context) {
528 if self.phase == RoulettePhase::Spinning {
529 return;
530 }
531 let (left, right, up, down) = ctx.input(|input| {
532 (
533 input.key_pressed(Key::ArrowLeft),
534 input.key_pressed(Key::ArrowRight),
535 input.key_pressed(Key::ArrowUp),
536 input.key_pressed(Key::ArrowDown),
537 )
538 });
539 for (pressed, direction) in [
540 (left, Direction::Left),
541 (right, Direction::Right),
542 (up, Direction::Up),
543 (down, Direction::Down),
544 ] {
545 if pressed {
546 self.cursor = self.cursor.moved(direction);
547 }
548 }
549 if ctx.memory(|memory| memory.focused().is_none()) {
550 if ctx.input(|input| input.key_pressed(Key::Enter)) {
551 if let Some(target) = self.cursor.target() {
552 let chip = self.chip;
553 self.bets.add(target, chip);
554 }
555 }
556 if ctx.input(|input| input.key_pressed(Key::Space)) {
557 self.start_spin(ctx);
558 }
559 }
560 }
561
562 fn start_spin(&mut self, ctx: &Context) {
563 if self.phase == RoulettePhase::Spinning {
564 return;
565 }
566 let target = DEMO_TARGETS[self.spins % DEMO_TARGETS.len()];
567 self.spins += 1;
568
569 let segment = TAU / WHEEL.len() as f32;
570 let index = WHEEL
571 .iter()
572 .position(|number| *number == target)
573 .unwrap_or(0) as f32;
574 let desired = -index * segment;
575 let delta = (desired - self.rotation).rem_euclid(TAU) + 3.0 * TAU;
576
577 self.spin = Some(Spin {
578 started: ctx.input(|input| input.time),
579 from: self.rotation,
580 delta,
581 target,
582 });
583 self.phase = RoulettePhase::Spinning;
584 self.number = None;
585 ctx.request_repaint();
586 }
587}
diff --git a/gui/src/lib.rs b/gui/src/lib.rs
new file mode 100644
index 0000000..e0f5bc9
--- /dev/null
+++ b/gui/src/lib.rs
@@ -0,0 +1,9 @@
1//! Desktop frontend for zagan-bet.
2//!
3//! The window is a thin shell over the shared `ClientCore` (Phase 4). This
4//! crate holds only presentation: the theme, the roulette table, and the app
5//! layout. It never enters the TUI or server build graph.
6
7pub mod app;
8pub mod roulette;
9pub mod theme;
diff --git a/gui/src/main.rs b/gui/src/main.rs
new file mode 100644
index 0000000..cf1c67d
--- /dev/null
+++ b/gui/src/main.rs
@@ -0,0 +1,25 @@
1//! Desktop frontend for zagan-bet.
2//!
3//! Phase 3 of `docs/GUI-PLAN.md`: the style spike. It owns the window and the
4//! roulette reference screen; the shared core and transport are wired in
5//! Phase 4.
6
7use zagan_bet_gui::app::ZaganApp;
8
9fn main() -> eframe::Result {
10 let options = eframe::NativeOptions {
11 viewport: eframe::egui::ViewportBuilder::default()
12 .with_title("Zagan-Bet")
13 .with_app_id("zagan-bet")
14 .with_inner_size([1440.0, 900.0])
15 .with_min_inner_size([1100.0, 720.0]),
16 renderer: eframe::Renderer::Glow,
17 ..Default::default()
18 };
19
20 eframe::run_native(
21 "Zagan-Bet",
22 options,
23 Box::new(|cc| Ok(Box::new(ZaganApp::new(cc)))),
24 )
25}
diff --git a/gui/src/roulette.rs b/gui/src/roulette.rs
new file mode 100644
index 0000000..22c46d6
--- /dev/null
+++ b/gui/src/roulette.rs
@@ -0,0 +1,522 @@
1//! The roulette table and wheel, painted from `RouletteView`.
2//!
3//! Geometry is the GUI's business: the core only says which number or region a
4//! chip or the cursor is on. Everything here is derived from that, so the two
5//! frontends can lay the same table out differently.
6
7use std::f32::consts::{FRAC_PI_2, TAU};
8
9use eframe::egui::{
10 Align2, Color32, CornerRadius, Painter, Pos2, Rect, Sense, Shape, Stroke, StrokeKind, Ui, Vec2,
11 pos2, vec2,
12};
13use zagan_bet::client::games::roulette::Cursor;
14use zagan_bet::client::view::{RoulettePhase, RouletteView};
15use zagan_bet::roulette::{BetTarget, Outside, WHEEL, color as wheel_color, is_red};
16
17use crate::theme;
18
19const COLUMNS: usize = 12;
20const ROWS: usize = 3;
21const OUTSIDE_ORDER: [Outside; 6] = [
22 Outside::Low,
23 Outside::Even,
24 Outside::Red,
25 Outside::Black,
26 Outside::Odd,
27 Outside::High,
28];
29
30const DOZEN_ROW: f32 = 0.72;
31const COLUMN_ROW: f32 = 0.55;
32const OUTSIDE_ROW: f32 = 0.62;
33const HEIGHT_UNITS: f32 = ROWS as f32 + DOZEN_ROW + COLUMN_ROW + OUTSIDE_ROW;
34const BOARD_PAD: f32 = 6.0;
35
36/// Largest table that fits in `available`, including its felt border.
37pub fn size_for(available: Vec2) -> Vec2 {
38 let inner = (available - Vec2::splat(BOARD_PAD * 2.0)).max(Vec2::ZERO);
39 let cell = (inner.x / (COLUMNS as f32 + 1.0))
40 .min(inner.y / HEIGHT_UNITS)
41 .max(1.0);
42 vec2((COLUMNS as f32 + 1.0) * cell, HEIGHT_UNITS * cell) + Vec2::splat(BOARD_PAD * 2.0)
43}
44
45/// The table painted at `size`; returns the target clicked this frame.
46pub fn felt(ui: &mut Ui, size: Vec2, view: &RouletteView) -> Option<BetTarget> {
47 let (rect, response) = ui.allocate_exact_size(size, Sense::click());
48 let felt = Felt::new(rect);
49 let hovered = response.hover_pos().and_then(|pos| felt.hit(pos));
50 let painter = ui.painter();
51
52 painter.rect_filled(rect, theme::RADIUS_LG, theme::FELT_DARK);
53
54 for number in 0..=36u8 {
55 let highlighted = hovered == Some(BetTarget::Straight { number });
56 felt.paint_number(painter, number, highlighted);
57 }
58 for index in 0..3 {
59 felt.paint_dozen(
60 painter,
61 index,
62 hovered == Some(BetTarget::Dozen { index: index as u8 }),
63 );
64 }
65 for index in 0..3 {
66 felt.paint_column(
67 painter,
68 index,
69 hovered == Some(BetTarget::Column { index: index as u8 }),
70 );
71 }
72 for (index, kind) in OUTSIDE_ORDER.iter().enumerate() {
73 felt.paint_outside(
74 painter,
75 index,
76 *kind,
77 hovered == Some(BetTarget::Outside { kind: *kind }),
78 );
79 }
80
81 if view.phase == RoulettePhase::Result {
82 if let Some(number) = view.number {
83 painter.rect_stroke(
84 felt.number_rect(number).shrink(2.0),
85 theme::RADIUS_SM,
86 Stroke::new(2.0, theme::GOLD),
87 StrokeKind::Inside,
88 );
89 }
90 }
91
92 felt.paint_cursor(painter, &view.cursor);
93
94 for chip in &view.bets.chips {
95 if let Some(center) = felt.anchor(&chip.target) {
96 paint_chip(painter, center, felt.cell * 0.26, chip.amount);
97 }
98 }
99
100 if response.clicked() { hovered } else { None }
101}
102
103struct Felt {
104 cell: f32,
105 origin: Pos2,
106}
107
108impl Felt {
109 fn new(board: Rect) -> Self {
110 let inner = board.shrink(BOARD_PAD);
111 Self {
112 cell: inner.width() / (COLUMNS as f32 + 1.0),
113 origin: inner.min,
114 }
115 }
116
117 fn number_rect(&self, number: u8) -> Rect {
118 debug_assert!(number <= 36);
119 if number == 0 {
120 return Rect::from_min_size(self.origin, vec2(self.cell, self.cell * ROWS as f32));
121 }
122 let column = ((number - 1) / 3) as f32;
123 let row = (2 - (number - 1) % 3) as f32;
124 Rect::from_min_size(
125 self.origin + vec2(self.cell * (1.0 + column), self.cell * row),
126 Vec2::splat(self.cell),
127 )
128 }
129
130 fn dozen_rect(&self, index: usize) -> Rect {
131 let y = self.origin.y + self.cell * ROWS as f32;
132 Rect::from_min_size(
133 pos2(self.origin.x + self.cell * (1.0 + 4.0 * index as f32), y),
134 vec2(self.cell * 4.0, self.cell * DOZEN_ROW),
135 )
136 }
137
138 fn column_rect(&self, index: usize) -> Rect {
139 let y = self.origin.y + self.cell * (ROWS as f32 + DOZEN_ROW);
140 Rect::from_min_size(
141 pos2(self.origin.x + self.cell * (1.0 + index as f32), y),
142 vec2(self.cell, self.cell * COLUMN_ROW),
143 )
144 }
145
146 fn outside_rect(&self, index: usize) -> Rect {
147 let y = self.origin.y + self.cell * (ROWS as f32 + DOZEN_ROW + COLUMN_ROW);
148 Rect::from_min_size(
149 pos2(self.origin.x + self.cell * (1.0 + 2.0 * index as f32), y),
150 vec2(self.cell * 2.0, self.cell * OUTSIDE_ROW),
151 )
152 }
153
154 /// Where a chip sits for this target.
155 fn anchor(&self, target: &BetTarget) -> Option<Pos2> {
156 Some(match target {
157 BetTarget::Straight { number } => self.number_rect(*number).center(),
158 BetTarget::Split { a, b } => {
159 let a = self.number_rect(*a).center();
160 let b = self.number_rect(*b).center();
161 pos2((a.x + b.x) * 0.5, (a.y + b.y) * 0.5)
162 }
163 BetTarget::Corner { numbers } => {
164 let mut sum = Vec2::ZERO;
165 for number in numbers {
166 sum += self.number_rect(*number).center().to_vec2();
167 }
168 (sum / numbers.len() as f32).to_pos2()
169 }
170 BetTarget::Dozen { index } => self.dozen_rect(*index as usize).center(),
171 BetTarget::Column { index } => self.column_rect(*index as usize).center(),
172 BetTarget::Outside { kind } => {
173 let index = OUTSIDE_ORDER
174 .iter()
175 .position(|candidate| candidate == kind)?;
176 self.outside_rect(index).center()
177 }
178 })
179 }
180
181 /// The fine-grid point the cursor occupies over the numbers.
182 fn lattice(&self, x: usize, y: usize) -> Pos2 {
183 pos2(
184 self.origin.x + self.cell * (1.0 + x as f32 * 0.5),
185 self.origin.y + self.cell * (y as f32 * 0.5),
186 )
187 }
188
189 fn hit(&self, pos: Pos2) -> Option<BetTarget> {
190 for number in 0..=36u8 {
191 if self.number_rect(number).contains(pos) {
192 return Some(BetTarget::Straight { number });
193 }
194 }
195 for index in 0..3usize {
196 if self.dozen_rect(index).contains(pos) {
197 return Some(BetTarget::Dozen { index: index as u8 });
198 }
199 if self.column_rect(index).contains(pos) {
200 return Some(BetTarget::Column { index: index as u8 });
201 }
202 }
203 for (index, kind) in OUTSIDE_ORDER.iter().enumerate() {
204 if self.outside_rect(index).contains(pos) {
205 return Some(BetTarget::Outside { kind: *kind });
206 }
207 }
208 None
209 }
210
211 fn paint_number(&self, painter: &Painter, number: u8, hovered: bool) {
212 let rect = self.number_rect(number);
213 let fill = if number == 0 {
214 theme::ROULETTE_ZERO
215 } else if is_red(number) {
216 theme::ROULETTE_RED
217 } else {
218 theme::ROULETTE_BLACK
219 };
220 painter.rect_filled(rect, CornerRadius::ZERO, fill);
221 painter.rect_stroke(
222 rect,
223 CornerRadius::ZERO,
224 Stroke::new(1.0, theme::FELT_LINE),
225 StrokeKind::Inside,
226 );
227 if hovered {
228 painter.rect_filled(
229 rect,
230 CornerRadius::ZERO,
231 Color32::WHITE.gamma_multiply(0.10),
232 );
233 }
234 painter.text(
235 rect.center(),
236 Align2::CENTER_CENTER,
237 number,
238 theme::mono(self.number_font()),
239 theme::FELT_TEXT,
240 );
241 }
242
243 fn paint_dozen(&self, painter: &Painter, index: usize, hovered: bool) {
244 let rect = self.dozen_rect(index);
245 self.paint_region(painter, rect, hovered);
246 let label = match index {
247 0 => "1st 12",
248 1 => "2nd 12",
249 _ => "3rd 12",
250 };
251 painter.text(
252 rect.center(),
253 Align2::CENTER_CENTER,
254 label,
255 theme::sans(self.label_font()),
256 theme::FELT_TEXT,
257 );
258 }
259
260 fn paint_column(&self, painter: &Painter, index: usize, hovered: bool) {
261 let rect = self.column_rect(index);
262 self.paint_region(painter, rect, hovered);
263 painter.text(
264 rect.center(),
265 Align2::CENTER_CENTER,
266 "2:1",
267 theme::sans(self.label_font()),
268 theme::FELT_TEXT,
269 );
270 }
271
272 fn paint_outside(&self, painter: &Painter, index: usize, kind: Outside, hovered: bool) {
273 let rect = self.outside_rect(index);
274 self.paint_region(painter, rect, hovered);
275 let color = match kind {
276 Outside::Red => theme::ROULETTE_RED,
277 Outside::Black => theme::TEXT_DIM,
278 _ => theme::FELT_TEXT,
279 };
280 painter.text(
281 rect.center(),
282 Align2::CENTER_CENTER,
283 kind.label(),
284 theme::sans(self.label_font()),
285 color,
286 );
287 }
288
289 fn paint_region(&self, painter: &Painter, rect: Rect, hovered: bool) {
290 painter.rect_filled(rect, CornerRadius::ZERO, theme::FELT);
291 painter.rect_stroke(
292 rect,
293 CornerRadius::ZERO,
294 Stroke::new(1.0, theme::FELT_LINE),
295 StrokeKind::Inside,
296 );
297 if hovered {
298 painter.rect_filled(
299 rect,
300 CornerRadius::ZERO,
301 Color32::WHITE.gamma_multiply(0.10),
302 );
303 }
304 }
305
306 fn paint_cursor(&self, painter: &Painter, cursor: &Cursor) {
307 let stroke = Stroke::new(2.0, theme::ACCENT);
308 match cursor {
309 Cursor::Zero => outline(painter, self.number_rect(0), stroke),
310 Cursor::Lattice { x, y } => {
311 for number in cursor.numbers() {
312 painter.rect_filled(
313 self.number_rect(number),
314 CornerRadius::ZERO,
315 theme::ACCENT.gamma_multiply(0.14),
316 );
317 }
318 let point = self.lattice(*x, *y);
319 painter.circle_filled(point, self.cell * 0.05, theme::ACCENT);
320 painter.circle_stroke(point, self.cell * 0.14, stroke);
321 }
322 Cursor::Dozen(index) => outline(painter, self.dozen_rect(*index), stroke),
323 Cursor::Column(index) => outline(painter, self.column_rect(*index), stroke),
324 Cursor::Outside(index) => outline(painter, self.outside_rect(*index), stroke),
325 }
326 }
327
328 fn number_font(&self) -> f32 {
329 (self.cell * 0.40).clamp(12.0, 22.0)
330 }
331
332 fn label_font(&self) -> f32 {
333 (self.cell * 0.24).clamp(10.0, 15.0)
334 }
335}
336
337fn outline(painter: &Painter, rect: Rect, stroke: Stroke) {
338 painter.rect_stroke(
339 rect.shrink(1.0),
340 theme::RADIUS_SM,
341 stroke,
342 StrokeKind::Inside,
343 );
344}
345
346/// A single chip, also used by the sidebar's chip tray.
347pub fn paint_chip(painter: &Painter, center: Pos2, radius: f32, amount: u64) {
348 let fill = chip_color(amount);
349 painter.circle_filled(center, radius, fill);
350 painter.circle_stroke(
351 center,
352 radius - radius * 0.18,
353 Stroke::new(radius * 0.10, Color32::WHITE.gamma_multiply(0.55)),
354 );
355 let text_color = if amount <= 10 {
356 theme::BG
357 } else {
358 Color32::WHITE
359 };
360 painter.text(
361 center,
362 Align2::CENTER_CENTER,
363 amount,
364 theme::mono(radius * 0.85),
365 text_color,
366 );
367}
368
369/// Colour per denomination, as a chip tray would be stocked.
370pub fn chip_color(amount: u64) -> Color32 {
371 match amount {
372 0..=10 => Color32::from_rgb(0xd9, 0xd3, 0xc7),
373 11..=25 => Color32::from_rgb(0x3f, 0x7f, 0xd1),
374 26..=50 => Color32::from_rgb(0xc9, 0x4b, 0x4b),
375 _ => Color32::from_rgb(0x3a, 0x9d, 0x5d),
376 }
377}
378
379fn segment_color(number: u8) -> Color32 {
380 match wheel_color(number) {
381 "red" => theme::ROULETTE_RED,
382 "green" => theme::ROULETTE_ZERO,
383 _ => Color32::from_rgb(0x2b, 0x35, 0x3f),
384 }
385}
386
387/// Number currently under the pointer for this wheel rotation.
388pub fn number_under(rotation: f32) -> u8 {
389 let segment = TAU / WHEEL.len() as f32;
390 let index = (-rotation / segment).round() as i64;
391 WHEEL[index.rem_euclid(WHEEL.len() as i64) as usize]
392}
393
394/// The wheel dial: 37 coloured pockets, a fixed pointer, and the number under
395/// it. `rotation` is in radians.
396pub fn wheel(ui: &mut Ui, diameter: f32, view: &RouletteView, rotation: f32) {
397 let (rect, _) = ui.allocate_exact_size(Vec2::splat(diameter), Sense::hover());
398 let painter = ui.painter();
399 let center = rect.center();
400 let radius = diameter * 0.5;
401
402 painter.circle_filled(center, radius, theme::SURFACE_RAISED);
403 painter.circle_stroke(center, radius - 1.0, Stroke::new(1.0, theme::LINE));
404
405 let segment = TAU / WHEEL.len() as f32;
406 let ring = radius * 0.80;
407 let dot = (radius * 0.072).max(2.0);
408 painter.circle_stroke(
409 center,
410 ring,
411 Stroke::new(dot * 2.0, Color32::from_rgb(0x1f, 0x28, 0x31)),
412 );
413 for (index, number) in WHEEL.iter().enumerate() {
414 let angle = -FRAC_PI_2 + rotation + index as f32 * segment;
415 let pocket = center + vec2(angle.cos(), angle.sin()) * ring;
416 let winning = view.phase == RoulettePhase::Result && view.number == Some(*number);
417 let stroke = if winning {
418 Stroke::new(2.0, theme::GOLD)
419 } else {
420 Stroke::new(1.0, Color32::from_rgb(0x3a, 0x46, 0x52))
421 };
422 painter.circle(
423 pocket,
424 if winning { dot * 1.25 } else { dot },
425 segment_color(*number),
426 stroke,
427 );
428 }
429
430 // A fixed pointer at the top, marking the winning pocket.
431 let tip = center + vec2(0.0, -radius + dot * 0.30);
432 painter.add(Shape::convex_polygon(
433 vec![
434 tip,
435 tip + vec2(-dot * 0.9, -dot * 1.4),
436 tip + vec2(dot * 0.9, -dot * 1.4),
437 ],
438 theme::TEXT,
439 Stroke::NONE,
440 ));
441
442 let inner = radius * 0.52;
443 painter.circle_filled(center, inner, theme::SURFACE);
444 painter.circle_stroke(center, inner, Stroke::new(1.0, theme::LINE));
445
446 let displayed = match view.phase {
447 RoulettePhase::Result => view.number,
448 RoulettePhase::Spinning => Some(number_under(rotation)),
449 RoulettePhase::Placing => None,
450 };
451 match displayed {
452 Some(number) => {
453 let color = match wheel_color(number) {
454 "red" => theme::ROULETTE_RED,
455 "green" => theme::WIN,
456 _ => theme::TEXT,
457 };
458 painter.text(
459 center,
460 Align2::CENTER_CENTER,
461 number,
462 theme::mono(inner * 0.85),
463 color,
464 );
465 }
466 None => {
467 painter.text(
468 center,
469 Align2::CENTER_CENTER,
470 "—",
471 theme::mono(inner * 0.55),
472 theme::TEXT_FAINT,
473 );
474 }
475 }
476}
477
478/// "Straight 17", "Corner 17/18/20/21", "Red".
479pub fn target_name(target: &BetTarget) -> String {
480 match target {
481 BetTarget::Straight { number } => format!("Straight {number}"),
482 BetTarget::Split { a, b } => format!("Split {a}/{b}"),
483 BetTarget::Corner { numbers } => format!(
484 "Corner {}/{}/{}/{}",
485 numbers[0], numbers[1], numbers[2], numbers[3]
486 ),
487 BetTarget::Dozen { index } => match index {
488 0 => "1st 12".to_string(),
489 1 => "2nd 12".to_string(),
490 _ => "3rd 12".to_string(),
491 },
492 BetTarget::Column { index } => format!("Column {}", index + 1),
493 BetTarget::Outside { kind } => title_case(kind.label()),
494 }
495}
496
497fn title_case(text: &str) -> String {
498 let mut characters = text.chars();
499 match characters.next() {
500 Some(first) => {
501 first.to_uppercase().collect::<String>() + &characters.as_str().to_lowercase()
502 }
503 None => String::new(),
504 }
505}
506
507/// Small coloured dot for a result strip.
508pub fn paint_result_dot(painter: &Painter, center: Pos2, radius: f32, number: u8) {
509 let color = match wheel_color(number) {
510 "red" => theme::ROULETTE_RED,
511 "green" => theme::ROULETTE_ZERO,
512 _ => theme::ROULETTE_BLACK,
513 };
514 painter.circle_filled(center, radius, color);
515 painter.text(
516 center,
517 Align2::CENTER_CENTER,
518 number,
519 theme::mono(radius * 0.95),
520 theme::FELT_TEXT,
521 );
522}
diff --git a/gui/src/theme.rs b/gui/src/theme.rs
new file mode 100644
index 0000000..9270aa6
--- /dev/null
+++ b/gui/src/theme.rs
@@ -0,0 +1,211 @@
1//! Look and feel for the desktop frontend.
2//!
3//! The palette is derived from the terminal client's ANSI scheme so the two
4//! frontends read as the same product: near-black ground, low-contrast panels,
5//! cyan for focus, and the felt green reserved for the roulette table.
6//!
7//! egui's defaults are a debug tool's palette. Every colour, size and corner
8//! used by the app comes from this module; widgets never pick their own.
9
10use std::sync::Arc;
11
12use eframe::egui::style::WidgetVisuals;
13use eframe::egui::{
14 self, Color32, Context, CornerRadius, FontData, FontDefinitions, FontFamily, FontId, Margin,
15 RichText, Stroke, TextStyle, Theme, Visuals,
16};
17
18// ---------------------------------------------------------------------------
19// Colour tokens
20// ---------------------------------------------------------------------------
21
22/// Window ground. Everything else sits above this.
23pub const BG: Color32 = Color32::from_rgb(0x0a, 0x0e, 0x12);
24/// Cards, header, sidebar.
25pub const SURFACE: Color32 = Color32::from_rgb(0x11, 0x17, 0x1d);
26/// Inputs, pills, raised rows.
27pub const SURFACE_RAISED: Color32 = Color32::from_rgb(0x17, 0x1f, 0x27);
28/// Hover state.
29pub const SURFACE_HOVER: Color32 = Color32::from_rgb(0x1d, 0x27, 0x31);
30/// Hairlines and table borders.
31pub const LINE: Color32 = Color32::from_rgb(0x24, 0x2e, 0x38);
32/// Primary text.
33pub const TEXT: Color32 = Color32::from_rgb(0xd9, 0xe2, 0xea);
34/// Labels and secondary text.
35pub const TEXT_DIM: Color32 = Color32::from_rgb(0x8a, 0x98, 0xa6);
36/// Disabled text, captions.
37pub const TEXT_FAINT: Color32 = Color32::from_rgb(0x5b, 0x67, 0x73);
38/// Focus, cursor, selection.
39pub const ACCENT: Color32 = Color32::from_rgb(0x4f, 0xb8, 0xc9);
40/// Wins, "online", the spin button.
41pub const WIN: Color32 = Color32::from_rgb(0x4e, 0xc2, 0x7b);
42/// Losses and errors.
43pub const LOSS: Color32 = Color32::from_rgb(0xe0, 0x5a, 0x5a);
44/// Chips and pending bets.
45pub const GOLD: Color32 = Color32::from_rgb(0xe0, 0xb6, 0x4a);
46
47/// Roulette felt. Never used outside the table.
48pub const FELT: Color32 = Color32::from_rgb(0x0d, 0x5c, 0x39);
49pub const FELT_DARK: Color32 = Color32::from_rgb(0x09, 0x47, 0x2c);
50pub const FELT_LINE: Color32 = Color32::from_rgb(0x1f, 0x7d, 0x52);
51pub const FELT_TEXT: Color32 = Color32::from_rgb(0xec, 0xf7, 0xf0);
52pub const ROULETTE_RED: Color32 = Color32::from_rgb(0xb0, 0x31, 0x3a);
53pub const ROULETTE_BLACK: Color32 = Color32::from_rgb(0x15, 0x1b, 0x21);
54pub const ROULETTE_ZERO: Color32 = Color32::from_rgb(0x10, 0x6b, 0x45);
55
56// ---------------------------------------------------------------------------
57// Spacing, radius, type scale
58// ---------------------------------------------------------------------------
59
60pub const SPACE_1: f32 = 4.0;
61pub const SPACE_2: f32 = 8.0;
62pub const SPACE_3: f32 = 12.0;
63pub const SPACE_4: f32 = 16.0;
64pub const SPACE_5: f32 = 24.0;
65
66pub const RADIUS_SM: CornerRadius = CornerRadius::same(4);
67pub const RADIUS_MD: CornerRadius = CornerRadius::same(8);
68pub const RADIUS_LG: CornerRadius = CornerRadius::same(12);
69
70pub const FONT_BODY: f32 = 14.0;
71pub const FONT_SMALL: f32 = 12.0;
72pub const FONT_LABEL: f32 = 11.0;
73pub const FONT_TITLE: f32 = 24.0;
74pub const FONT_MONO: f32 = 16.0;
75
76// ---------------------------------------------------------------------------
77// Fonts
78// ---------------------------------------------------------------------------
79
80/// Open Sans, SIL OFL 1.1. See `assets/fonts/OFL-OpenSans.txt`.
81pub const FONT_SANS_BYTES: &[u8] = include_bytes!("../assets/fonts/OpenSans-Regular.ttf");
82/// Hack, MIT. See `assets/fonts/LICENSE-Hack.md`.
83pub const FONT_MONO_BYTES: &[u8] = include_bytes!("../assets/fonts/Hack-Regular.ttf");
84
85pub fn sans(size: f32) -> FontId {
86 FontId::new(size, FontFamily::Proportional)
87}
88
89pub fn mono(size: f32) -> FontId {
90 FontId::new(size, FontFamily::Monospace)
91}
92
93/// A small uppercase label, the terminal's dim section headers.
94pub fn label(text: &str) -> RichText {
95 RichText::new(text.to_uppercase())
96 .font(mono(FONT_LABEL))
97 .color(TEXT_DIM)
98}
99
100/// Group an amount in threes: 1234567 -> "1 234 567".
101pub fn group_digits(amount: i64) -> String {
102 let negative = amount < 0;
103 let digits = amount.unsigned_abs().to_string();
104 let mut grouped = String::with_capacity(digits.len() + digits.len() / 3);
105 for (index, character) in digits.chars().enumerate() {
106 if index > 0 && (digits.len() - index) % 3 == 0 {
107 grouped.push(' ');
108 }
109 grouped.push(character);
110 }
111 if negative {
112 format!("-{grouped}")
113 } else {
114 grouped
115 }
116}
117
118/// `1000` -> `"1 000 Kr"`.
119pub fn money(amount: i64) -> String {
120 format!("{} Kr", group_digits(amount))
121}
122
123// ---------------------------------------------------------------------------
124// Installation
125// ---------------------------------------------------------------------------
126
127/// Install the embedded fonts and the dark style. Call once, from the app
128/// constructor, before the first frame.
129pub fn install(ctx: &Context) {
130 let mut fonts = FontDefinitions::default();
131 fonts.font_data.insert(
132 "zagan-sans".to_owned(),
133 Arc::new(FontData::from_static(FONT_SANS_BYTES)),
134 );
135 fonts.font_data.insert(
136 "zagan-mono".to_owned(),
137 Arc::new(FontData::from_static(FONT_MONO_BYTES)),
138 );
139 fonts
140 .families
141 .get_mut(&FontFamily::Proportional)
142 .expect("proportional family exists")
143 .insert(0, "zagan-sans".to_owned());
144 fonts
145 .families
146 .get_mut(&FontFamily::Monospace)
147 .expect("monospace family exists")
148 .insert(0, "zagan-mono".to_owned());
149 ctx.set_fonts(fonts);
150
151 ctx.set_theme(Theme::Dark);
152 let mut style = (*ctx.global_style()).clone();
153 style.text_styles = [
154 (TextStyle::Heading, sans(FONT_TITLE)),
155 (TextStyle::Body, sans(FONT_BODY)),
156 (TextStyle::Button, sans(FONT_BODY)),
157 (TextStyle::Small, sans(FONT_SMALL)),
158 (TextStyle::Monospace, mono(FONT_MONO)),
159 ]
160 .into();
161 style.spacing.item_spacing = egui::vec2(SPACE_2, SPACE_2);
162 style.spacing.button_padding = egui::vec2(SPACE_3, SPACE_2);
163 style.spacing.interact_size = egui::vec2(36.0, 32.0);
164 style.spacing.window_margin = Margin::same(SPACE_3 as i8);
165 style.visuals = visuals();
166 ctx.set_global_style(style);
167}
168
169fn widget(
170 bg_fill: Color32,
171 bg_stroke: Color32,
172 fg: Color32,
173 corner_radius: CornerRadius,
174) -> WidgetVisuals {
175 WidgetVisuals {
176 bg_fill,
177 weak_bg_fill: bg_fill,
178 bg_stroke: Stroke::new(1.0, bg_stroke),
179 corner_radius,
180 fg_stroke: Stroke::new(1.0, fg),
181 expansion: 0.0,
182 }
183}
184
185fn visuals() -> Visuals {
186 let mut visuals = Visuals::dark();
187 visuals.override_text_color = Some(TEXT);
188 visuals.panel_fill = BG;
189 visuals.window_fill = SURFACE;
190 visuals.window_stroke = Stroke::new(1.0, LINE);
191 visuals.window_corner_radius = RADIUS_LG;
192 visuals.extreme_bg_color = BG;
193 visuals.faint_bg_color = SURFACE_RAISED;
194 visuals.code_bg_color = SURFACE_RAISED;
195 visuals.hyperlink_color = ACCENT;
196 visuals.warn_fg_color = GOLD;
197 visuals.error_fg_color = LOSS;
198 visuals.selection.bg_fill = ACCENT.gamma_multiply(0.30);
199 visuals.selection.stroke = Stroke::new(1.0, ACCENT);
200 visuals.widgets.noninteractive = widget(SURFACE, LINE, TEXT, RADIUS_MD);
201 visuals.widgets.inactive = widget(SURFACE_RAISED, LINE, TEXT, RADIUS_MD);
202 visuals.widgets.hovered = widget(SURFACE_HOVER, ACCENT.gamma_multiply(0.75), TEXT, RADIUS_MD);
203 visuals.widgets.active = widget(
204 ACCENT.gamma_multiply(0.25),
205 ACCENT,
206 Color32::WHITE,
207 RADIUS_MD,
208 );
209 visuals.widgets.open = visuals.widgets.active;
210 visuals
211}