summaryrefslogtreecommitdiffstats
path: root/gui/src
diff options
context:
space:
mode:
authorsalmonik <salmonik@tuta.io>2026-11-01 19:58:59 +0100
committersalmonik <salmonik@tuta.io>2026-11-01 19:58:59 +0100
commit10ff558853f549d3f3c7c8432488bfad9da6973a (patch)
tree271b05fead384e2d91e5c8332fea9d00b540f0cc /gui/src
parentv4.0.7: finish the Phase 1 boundary with a command shell (diff)
downloadzagan-bet-10ff558853f549d3f3c7c8432488bfad9da6973a.tar.gz
zagan-bet-10ff558853f549d3f3c7c8432488bfad9da6973a.zip
v4.1.0: add the egui workspace member and the roulette style spike
Phase 3 of the GUI plan. gui/ is a separate package that depends on the core with default features off, so egui never enters the TUI or server build graph. It carries the theme tokens and two embedded fonts (Open Sans OFL, Hack MIT), paints the felt, chips, cursor and wheel from RouletteView, and composes the persistent header with the betting sidebar. The reference screen runs on demo state: arrow keys move the shared Cursor, clicks place chips, and SPIN animates a 2.4 s wheel that settles with the winning pocket under the pointer. No network until Phase 4. Verified x86_64-pc-windows-gnu cross-compile. Measured on Linux at 1080p with llvmpipe: 6.0 MB binary (4.7 MB on Windows), 52 ms to window, 137 MB idle RSS, 0% idle CPU.
Diffstat (limited to '')
-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}