From a409bed7c4c3983d85fe63cc49c8890bde854a56 Mon Sep 17 00:00:00 2001 From: meetsu <96637888+klNuno@users.noreply.github.com> Date: Tue, 18 Aug 2026 22:57:37 +0200 Subject: [PATCH 1/5] feat(tui): a lever at the bottom of the menu, for anyone who cannot decide The three questions the picker asks are three reels of a slot machine, stopping left to right because that is the order they depend on each other in: the provider reel is filled from the harness that just landed, and the model reel turns on placeholders until the catalogue lookup for that provider answers. The wait for the network became the animation rather than a spinner in front of it. The lucky line is a button, so the menu now enables mouse capture, and the draw records where the line ended up: the footer sits under a body whose height is only known once the frame is laid out, and a click knows a row and a column and nothing else. Ctrl+L pulls the same lever, because a plain letter is typed into the model filter. Nothing launches on its own: the payout screen shows the three answers and waits for enter, esc puts the rolls back in the menu. --- src/main.rs | 1 + src/slots.rs | 429 +++++++++++++++++++++++++++++++++++++++++++++++++++ src/tui.rs | 302 +++++++++++++++++++++++++++++++++++- 3 files changed, 724 insertions(+), 8 deletions(-) create mode 100644 src/slots.rs diff --git a/src/main.rs b/src/main.rs index c10c9b3..f0b164c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -13,6 +13,7 @@ mod launch; mod paths; mod prompts; mod secrets; +mod slots; mod state; mod tui; mod update; diff --git a/src/slots.rs b/src/slots.rs new file mode 100644 index 0000000..6668a23 --- /dev/null +++ b/src/slots.rs @@ -0,0 +1,429 @@ +//! The lever nobody asked for. +//! +//! Three reels, one per question the picker asks: harness, then provider, then model. They +//! stop left to right because that is the order the answers depend on each other in, so the +//! machine is not only decoration: a provider reel cannot hold anything until the harness +//! reel has landed, and the model reel spins on whatever the catalogue lookup is still +//! fetching behind it. +//! +//! This module owns the pixels and the dice. The state machine that turns a landing into a +//! selection lives with the picker, which is the only thing allowed to touch its rows. + +use ratatui::prelude::*; +use ratatui::widgets::Paragraph; +use std::time::{SystemTime, UNIX_EPOCH}; + +/// splitmix64 seeded off the clock. A menu does not need a crypto source to pick a row, and +/// a dependency for three `%` operations is a dependency to audit for ever. +pub struct Rng(u64); + +impl Rng { + pub fn new() -> Rng { + let seed = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos() as u64) + .unwrap_or(0x9E37_79B9_7F4A_7C15); + Rng(seed ^ 0xD1B5_4A32_D192_ED03) + } + + fn next_u64(&mut self) -> u64 { + self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15); + let mut z = self.0; + z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + z ^ (z >> 31) + } + + /// A row index in `0..n`, and 0 for an empty list rather than a panic: an empty reel is + /// a menu with nothing to offer, which the caller already has to handle. + pub fn below(&mut self, n: usize) -> usize { + match n { + 0 => 0, + n => (self.next_u64() % n as u64) as usize, + } + } +} + +/// One column of the machine. `items` is what it can land on, `pos` where it is right now. +pub struct Reel { + pub title: &'static str, + pub items: Vec, + pub pos: usize, + pub stopped: bool, +} + +impl Reel { + /// A reel whose question has not been answered yet: it still turns, on nothing. + pub fn teaser(title: &'static str) -> Reel { + Reel { + title, + items: ["? ? ?", "$ $ $", "7 7 7", "* * *"] + .iter() + .map(|s| s.to_string()) + .collect(), + pos: 0, + stopped: false, + } + } + + pub fn load(&mut self, items: Vec) { + self.items = match items.is_empty() { + true => vec!["nothing".to_string()], + false => items, + }; + self.pos = 0; + } + + fn at(&self, offset: isize) -> &str { + if self.items.is_empty() { + return ""; + } + let n = self.items.len() as isize; + let i = (self.pos as isize + offset).rem_euclid(n) as usize; + &self.items[i] + } +} + +/// What the machine is doing this frame. Drawing reads it, nothing else does. +pub struct View<'a> { + pub reels: &'a [Reel; 3], + /// Frame counter. Drives the marquee, the blinking and the jackpot colours, so the + /// animation needs no clock of its own. + pub tick: usize, + /// 0 up, 1 halfway, 2 pulled. + pub lever: u8, + pub jackpot: bool, + pub status: String, +} + +/// Wide enough for a model id carrying a suffix, which is what the third reel lands on. +const CELL: usize = 18; +const INNER: usize = CELL * 3 + 4; + +/// The casino palette, cycled by the tick so the frame never sits still. +const LIGHTS: [Color; 4] = [ + Color::LightRed, + Color::LightYellow, + Color::LightMagenta, + Color::LightCyan, +]; + +pub fn draw(f: &mut Frame, area: Rect, v: &View) { + let lines = lines(v); + let width = (INNER as u16 + 2 + 6).min(area.width); + let height = (lines.len() as u16).min(area.height); + // Centred, and clamped rather than skipped on a small terminal: a machine with its top + // row cut off is still playable, a machine that refuses to draw is a broken easter egg. + let box_area = Rect { + x: area.x + (area.width.saturating_sub(width)) / 2, + y: area.y + (area.height.saturating_sub(height)) / 2, + width, + height, + }; + f.render_widget(Paragraph::new(lines), box_area); +} + +fn lines(v: &View) -> Vec> { + let frame = match v.jackpot { + // The whole cabinet joins in once it has paid out. + true => LIGHTS[(v.tick / 2) % LIGHTS.len()], + false => Color::LightMagenta, + }; + let mut out = Vec::with_capacity(16); + + out.push(row( + 0, + vec![Span::styled( + format!("╔{}╗", "═".repeat(INNER)), + Style::new().fg(frame), + )], + v, + )); + out.push(row( + 1, + vec![wall(frame), marquee(v.tick, frame), wall(frame)], + v, + )); + out.push(row( + 2, + vec![ + wall(frame), + Span::styled( + centred("F A S T P I C K S L O T S", INNER), + Style::new() + .fg(Color::LightYellow) + .add_modifier(Modifier::BOLD), + ), + wall(frame), + ], + v, + )); + out.push(row( + 3, + vec![Span::styled( + format!("╠{}╣", "═".repeat(INNER)), + Style::new().fg(frame), + )], + v, + )); + + // The three questions, over the reel that answers them. + let mut titles = String::from(" "); + for r in v.reels { + titles.push_str(¢red(r.title, CELL)); + titles.push(' '); + } + out.push(row( + 4, + vec![ + wall(frame), + Span::styled(pad(&titles, INNER), Style::new().fg(Color::DarkGray)), + wall(frame), + ], + v, + )); + + out.push(row(5, edge('┌', '┐', frame), v)); + out.push(row(6, band(v, -1), v)); + out.push(row(7, band(v, 0), v)); + out.push(row(8, band(v, 1), v)); + out.push(row(9, edge('└', '┘', frame), v)); + + out.push(row(10, vec![wall(frame), blank(), wall(frame)], v)); + out.push(row( + 11, + vec![ + wall(frame), + Span::styled( + pad(&format!(" {}", v.status), INNER), + match v.jackpot { + true => Style::new() + .fg(Color::LightGreen) + .add_modifier(Modifier::BOLD), + false => Style::new().fg(Color::Gray), + }, + ), + wall(frame), + ], + v, + )); + out.push(row( + 12, + vec![wall(frame), marquee(v.tick + 2, frame), wall(frame)], + v, + )); + out.push(row( + 13, + vec![Span::styled( + format!("╚{}╝", "═".repeat(INNER)), + Style::new().fg(frame), + )], + v, + )); + out +} + +/// One line of the cabinet, with the lever drawn in the margin beside it. +fn row(index: usize, mut spans: Vec>, v: &View) -> Line<'static> { + spans.push(lever(index, v)); + Line::from(spans) +} + +fn wall(c: Color) -> Span<'static> { + Span::styled("║", Style::new().fg(c)) +} + +fn blank() -> Span<'static> { + Span::raw(" ".repeat(INNER)) +} + +fn edge(left: char, right: char, c: Color) -> Vec> { + let mut s = String::from(" "); + for _ in 0..3 { + s.push(left); + s.push_str(&"─".repeat(CELL - 2)); + s.push(right); + s.push(' '); + } + vec![ + wall(c), + Span::styled(pad(&s, INNER), Style::new().fg(Color::DarkGray)), + wall(c), + ] +} + +/// A horizontal slice through all three reels: the row above the window, the lit one, the +/// row below. Three visible symbols per reel is what makes a spinning column read as a +/// spinning column and not as a label changing at random. +fn band(v: &View, offset: isize) -> Vec> { + let lit = offset == 0; + let frame = match v.jackpot { + true => LIGHTS[(v.tick / 2) % LIGHTS.len()], + false => Color::LightMagenta, + }; + // The payline, marked in the margin the other two bands leave blank rather than in a + // column of its own: the result of a pull is the middle row and nothing else. + let arrow = |c: &'static str| match lit { + true => Span::styled( + c, + Style::new() + .fg(Color::LightYellow) + .add_modifier(Modifier::BOLD), + ), + false => Span::raw(" "), + }; + let mut spans = vec![wall(frame), arrow("▶")]; + for r in v.reels { + spans.push(Span::styled("│", Style::new().fg(Color::DarkGray))); + let text = fit(r.at(offset), CELL - 2); + let style = match (lit, r.stopped, v.jackpot) { + // Landed, and the machine has paid: the answer flashes. + (true, true, true) => Style::new() + .fg(LIGHTS[(v.tick / 2) % LIGHTS.len()]) + .add_modifier(Modifier::BOLD | Modifier::REVERSED), + (true, true, false) => Style::new() + .fg(Color::LightYellow) + .add_modifier(Modifier::BOLD), + (true, false, _) => Style::new().fg(Color::White), + _ => Style::new().fg(Color::DarkGray), + }; + spans.push(Span::styled(text, style)); + spans.push(Span::styled("│", Style::new().fg(Color::DarkGray))); + spans.push(Span::raw(" ")); + } + spans.pop(); + spans.push(arrow("◀")); + spans.push(wall(frame)); + spans +} + +/// The chase lights, one cell shifted per frame. +fn marquee(tick: usize, frame: Color) -> Span<'static> { + let mut s = String::with_capacity(INNER); + for i in 0..INNER { + s.push(match (i + tick) % 4 { + 0 => '*', + 2 => '.', + _ => ' ', + }); + } + Span::styled(s, Style::new().fg(frame)) +} + +/// The handle, six rows tall in the right margin, the ball riding down as it is pulled. +fn lever(index: usize, v: &View) -> Span<'static> { + let top = 5usize; + let ball = top + v.lever as usize; + if index < top || index > top + 4 { + return Span::raw(""); + } + let (art, colour) = match index { + i if i == ball => (" (O) ", Color::LightRed), + i if i > top + 3 => (" [=] ", Color::DarkGray), + i if i > ball => (" | ", Color::Gray), + _ => (" ", Color::Reset), + }; + Span::styled( + art.to_string(), + Style::new().fg(colour).add_modifier(match index == ball { + true => Modifier::BOLD, + false => Modifier::empty(), + }), + ) +} + +/// Trimmed to the cell, ellipsis included, so a long model id cannot push the cabinet open. +fn fit(text: &str, width: usize) -> String { + let n = text.chars().count(); + if n > width { + let mut s: String = text.chars().take(width.saturating_sub(1)).collect(); + s.push('~'); + return s; + } + let left = (width - n) / 2; + format!( + "{}{}{}", + " ".repeat(left), + text, + " ".repeat(width - n - left) + ) +} + +fn centred(text: &str, width: usize) -> String { + fit(text, width) +} + +fn pad(text: &str, width: usize) -> String { + let n = text.chars().count(); + match n >= width { + true => text.chars().take(width).collect(), + false => format!("{text}{}", " ".repeat(width - n)), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use ratatui::backend::TestBackend; + + fn reel(items: &[&str]) -> Reel { + Reel { + title: "harness", + items: items.iter().map(|s| s.to_string()).collect(), + pos: 0, + stopped: true, + } + } + + #[test] + fn every_line_is_the_same_width() { + let reels = [ + reel(&["claude code"]), + reel(&["a provider with a very long name"]), + reel(&["gpt-5"]), + ]; + let v = View { + reels: &reels, + tick: 3, + lever: 2, + jackpot: true, + status: "JACKPOT".into(), + }; + let widths: Vec = lines(&v).iter().map(|l| l.width()).collect(); + // The lever margin is empty on the rows it does not reach, so the cabinet itself is + // what has to line up: every row is the frame plus, at most, the handle. + for w in &widths { + assert!( + *w == INNER + 2 || *w == INNER + 8, + "a row came out {w} wide, cabinet is {}", + INNER + 2 + ); + } + } + + /// A terminal smaller than the cabinet must still render something rather than panic. + #[test] + fn draws_into_a_short_terminal() { + let reels = [reel(&["x"]), reel(&["y"]), reel(&["z"])]; + let v = View { + reels: &reels, + tick: 0, + lever: 0, + jackpot: false, + status: String::new(), + }; + let mut terminal = Terminal::new(TestBackend::new(20, 5)).unwrap(); + terminal + .draw(|f| draw(f, f.area(), &v)) + .expect("a small terminal must not stop the machine"); + } + + #[test] + fn below_stays_in_range() { + let mut rng = Rng::new(); + for _ in 0..200 { + assert!(rng.below(7) < 7); + } + assert_eq!(rng.below(0), 0); + } +} diff --git a/src/tui.rs b/src/tui.rs index a21315c..9c80a02 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -4,7 +4,9 @@ //! menu stays usable while the network is slow or dead. No screen blocks on a socket. use anyhow::Result; -use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers}; +use crossterm::event::{ + self, Event, KeyCode, KeyEventKind, KeyModifiers, MouseButton, MouseEventKind, +}; use ratatui::prelude::*; use ratatui::widgets::{List, ListItem, ListState, Paragraph, Wrap}; use ratatui::DefaultTerminal; @@ -16,6 +18,7 @@ use std::time::{Duration, Instant}; use crate::catalog::{self, Listed, Source}; use crate::config::{Config, Model}; use crate::prompts::{self, PromptFile}; +use crate::slots::{self, Reel, Rng, View}; #[derive(Clone, Copy, PartialEq)] pub enum Screen { @@ -120,6 +123,11 @@ pub struct App<'a> { /// `ratatui::init()` is wiped by the switch to the alternate screen a moment later. notice: Option, + /// Where the lucky line ended up last frame: row, first column, last column. Written + /// by the draw and read by a click, because the footer sits under a body whose height + /// is only known once the screen has been laid out. + lucky_zone: std::cell::Cell<(u16, u16, u16)>, + /// A newer release, if the last check found one. Read once here rather than per frame, /// which also means a check that lands during this run shows up on the next one. That /// is the right side to err on for a line nobody asked for. @@ -170,6 +178,7 @@ impl<'a> App<'a> { opt_row: 0, unsupported: Vec::new(), notice: None, + lucky_zone: std::cell::Cell::new((0, 0, 0)), update_available: crate::update::pending(), }; app.rebuild_providers(); @@ -523,6 +532,211 @@ impl<'a> App<'a> { pub fn set_screen(&mut self, s: Screen) { self.screen = s; } + + /// Whether a click landed on the lucky line, using where the last frame put it. + fn on_lucky(&self, column: u16, row: u16) -> bool { + let (y, x0, x1) = self.lucky_zone.get(); + row == y && column >= x0 && column < x1 + } +} + +/// What a pull of the lever ended in. +enum Roll { + /// The machine paid and the user took it. Boxed because this arm is a whole selection + /// and the other two are nothing, so the enum would be that size everywhere. + Launch(Box), + /// Back to the menu, with whatever the reels landed on already selected. + Back, + Quit, +} + +/// One pull of the lever. +/// +/// The reels stop left to right because each answer narrows the next: the provider reel is +/// filled from the harness that just landed, and the model reel turns on nothing until the +/// catalogue lookup for that provider comes back. So the wait for the network is the +/// animation rather than a spinner in front of it. +fn play_slots(terminal: &mut DefaultTerminal, app: &mut App) -> Result { + const FRAME: Duration = Duration::from_millis(45); + /// Long enough that a reel reads as spinning rather than as a value being replaced. + const MIN_SPIN: Duration = Duration::from_millis(900); + /// The same ceiling the command-line path puts on a catalogue lookup. A reel that turns + /// for ever is a menu with no way out. + const PATIENCE: Duration = Duration::from_secs(20); + + if app.harness_rows.is_empty() { + app.notice = Some("nothing installed to gamble on".into()); + return Ok(Roll::Back); + } + + let mut rng = Rng::new(); + let mut reels = [ + Reel::teaser("harness"), + Reel::teaser("provider"), + Reel::teaser("model"), + ]; + reels[0].load( + app.harness_rows + .iter() + .filter_map(|&i| app.cfg.harnesses.get(i)) + .map(|h| h.name.clone()) + .collect(), + ); + + // 0 to 2 while the handle comes down, then one stage per reel, then the payout. + let mut stage: usize = 0; + let mut tick: usize = 0; + let mut lever: u8 = 0; + let mut began = Instant::now(); + let mut target: Option = None; + let mut status = String::from("pull!"); + // The model reel is filled once, mid-spin, when the catalogue lands. A count would not + // do: a provider serving three models is indistinguishable from the placeholders. + let mut model_reel_filled = false; + + loop { + app.poll_models(); + + // The model reel has nothing to turn on until the provider has answered, so it + // keeps its placeholders and the lookup fills it in mid-spin. + if stage == 3 && !model_reel_filled && !app.loading() { + if app.visible_models.is_empty() { + app.notice = Some("the machine came up empty, that provider listed nothing".into()); + return Ok(Roll::Back); + } + reels[2].load( + app.visible_models + .iter() + .filter_map(|&i| app.models.get(i)) + .map(|l| l.model.display().to_string()) + .collect(), + ); + model_reel_filled = true; + began = Instant::now(); + target = None; + status = "model...".into(); + } + + let jackpot = stage == 4; + terminal.draw(|f| { + slots::draw( + f, + f.area(), + &View { + reels: &reels, + tick, + lever, + jackpot, + status: status.clone(), + }, + ) + })?; + + if event::poll(FRAME)? { + match event::read()? { + Event::Key(k) if k.kind == KeyEventKind::Press => { + if k.modifiers.contains(KeyModifiers::CONTROL) && k.code == KeyCode::Char('c') { + return Ok(Roll::Quit); + } + match k.code { + KeyCode::Char('q') => return Ok(Roll::Quit), + KeyCode::Esc => { + app.notice = Some("no bet taken".into()); + return Ok(Roll::Back); + } + // Only pays once every reel has landed, so an early Enter is the + // impatience it looks like and not a launch of half a choice. + KeyCode::Enter if jackpot => { + return Ok(match app.picked() { + Some(p) => Roll::Launch(Box::new(p)), + None => Roll::Back, + }) + } + _ => {} + } + } + _ => {} + } + } + tick = tick.wrapping_add(1); + + match stage { + // The handle coming down. Three positions, then the first reel goes. + 0 => { + lever = (began.elapsed().as_millis() / 110).min(2) as u8; + if began.elapsed() > Duration::from_millis(360) { + stage = 1; + began = Instant::now(); + status = "harness...".into(); + } + } + 1..=3 => { + let i = stage - 1; + let waiting = stage == 3 && app.loading(); + if waiting { + status = format!( + "asking {} what it serves...", + app.provider().map(|p| p.name.as_str()).unwrap_or("") + ); + if began.elapsed() > PATIENCE { + app.notice = + Some("the catalogue never answered, so nothing was rolled".into()); + return Ok(Roll::Back); + } + } + + let len = reels[i].items.len().max(1); + reels[i].pos = (reels[i].pos + 1) % len; + if target.is_none() && !waiting { + target = Some(rng.below(len)); + } + + let landed = target == Some(reels[i].pos) && began.elapsed() >= MIN_SPIN; + if landed && !waiting { + reels[i].stopped = true; + let row = reels[i].pos; + target = None; + began = Instant::now(); + match i { + 0 => { + app.harness_row = row; + app.provider_row = 0; + app.rebuild_providers(); + if app.provider_rows.is_empty() { + app.notice = + Some("that harness has no provider to gamble on".into()); + return Ok(Roll::Back); + } + reels[1].load( + app.provider_rows + .iter() + .filter_map(|&p| app.cfg.providers.get(p)) + .map(|p| p.name.clone()) + .collect(), + ); + status = "provider...".into(); + } + 1 => { + app.provider_row = row; + // `--key` narrowed an answer the user gave; the machine is + // answering for them, so every key of the site is in play. + app.only_key = None; + app.set_screen(Screen::Model); + app.load_models(false); + status = "model...".into(); + } + _ => { + app.model_idx = row; + app.ensure_options(); + status = "JACKPOT enter launch esc back".into(); + } + } + stage += 1; + } + } + _ => {} + } + } } /// Runs the picker. `Ok(None)` means the user quit without choosing. @@ -594,7 +808,14 @@ pub fn run(cfg: &Config, start: &Start) -> Result> { crate::update::check_in_background(); let mut terminal = ratatui::init(); + // Only for the lucky line, which is a button and so has to be clickable. Failing to + // enable it is not a reason to refuse the menu: every key still works, the button just + // needs its shortcut. + let mouse = crossterm::execute!(std::io::stdout(), event::EnableMouseCapture).is_ok(); let result = event_loop(&mut terminal, &mut app, pending_model.as_deref()); + if mouse { + let _ = crossterm::execute!(std::io::stdout(), event::DisableMouseCapture); + } ratatui::restore(); result } @@ -650,18 +871,36 @@ fn event_loop( continue; } - let Event::Key(key) = event::read()? else { - continue; + let key = match event::read()? { + Event::Key(k) if k.kind == KeyEventKind::Press => k, + // The lucky line is the only thing on screen a click means anything on. + Event::Mouse(m) + if m.kind == MouseEventKind::Down(MouseButton::Left) + && app.on_lucky(m.column, m.row) => + { + match play_slots(terminal, app)? { + Roll::Launch(p) => return Ok(Some(*p)), + Roll::Quit => return Ok(None), + Roll::Back => continue, + } + } + _ => continue, }; - if key.kind != KeyEventKind::Press { - continue; - } // Read once, then gone: it explains the screen the user just landed on, and past // that it would sit on top of the catalogue line for the rest of the session. app.notice = None; if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') { return Ok(None); } + // The same lever, for anyone who does not have a mouse in a terminal. Ctrl rather + // than a bare letter: the model screen types every plain key into its filter. + if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('l') { + match play_slots(terminal, app)? { + Roll::Launch(p) => return Ok(Some(*p)), + Roll::Quit => return Ok(None), + Roll::Back => continue, + } + } match app.screen { Screen::Harness => match key.code { @@ -830,8 +1069,9 @@ fn draw(f: &mut Frame, app: &App) { Some(p) => height_of(p, right_width), None => 0, }); - // title, blank, body, blank, status, help, and the update line when there is one. - let footer_height = if app.update_available.is_some() { 3 } else { 2 }; + // title, blank, body, blank, status, help, the lucky line, and the update line when + // there is one. + let footer_height = if app.update_available.is_some() { 4 } else { 3 }; let room = area.height.saturating_sub(3 + footer_height as u16).max(1); let height = wanted.clamp(1, room); @@ -891,6 +1131,14 @@ fn draw(f: &mut Frame, app: &App) { )), footer_line(app), ]; + lines.push(lucky_line()); + // The line is a button, so where it landed has to survive the frame: a click knows + // a row and a column, and nothing else on screen can tell it what sits there. + app.lucky_zone.set(( + footer.y + lines.len() as u16 - 1, + footer.x, + footer.x + LUCKY.chars().count() as u16, + )); if let Some(v) = &app.update_available { lines.push(Line::from(Span::styled( format!( @@ -1102,6 +1350,23 @@ fn footer_line(app: &App) -> Line<'static> { ]) } +/// The clickable part of the lucky line. Its width is the hit box, so the two must stay +/// the same string. +const LUCKY: &str = "777 I'm feeling lucky"; + +fn lucky_line() -> Line<'static> { + Line::from(vec![ + Span::styled( + "777", + Style::new() + .fg(Color::LightYellow) + .add_modifier(Modifier::BOLD), + ), + Span::styled(" I'm feeling lucky", Style::new().fg(Color::LightMagenta)), + dim(" click it, or ctrl+l".to_string()), + ]) +} + fn help(app: &App) -> &'static str { match (app.screen, app.options_open) { (Screen::Harness, _) => "up/down move right next q quit", @@ -1562,6 +1827,27 @@ mod tests { } } + /// The lever is a button, and a button whose hit box has drifted off the line it draws + /// is a button that does nothing on click with nothing on screen to say why. + #[test] + fn the_lucky_line_is_where_a_click_looks_for_it() { + let cfg = cfg(); + let app = App::new(&cfg, &Start::default()); + let lines = render_lines(&app); + let row = lines + .iter() + .position(|l| l.contains("I'm feeling lucky")) + .expect("the lucky line must be drawn"); + + let (y, x0, x1) = app.lucky_zone.get(); + assert_eq!(y as usize, row); + assert!(app.on_lucky(x0, y)); + assert!(app.on_lucky(x1 - 1, y)); + // Past the label, and one row off, are both misses. + assert!(!app.on_lucky(x1, y)); + assert!(!app.on_lucky(x0, y + 1)); + } + #[test] fn renders_every_screen() { let cfg = cfg(); From e90037b66932391e069a96917cdb7ebfcd06ed23 Mon Sep 17 00:00:00 2001 From: meetsu <96637888+klNuno@users.noreply.github.com> Date: Tue, 18 Aug 2026 23:16:11 +0200 Subject: [PATCH 2/5] feat(tui): a bigger cabinet, and a handle that waits to be pulled --- src/slots.rs | 244 ++++++++++++++++++++++++++++++++++++--------------- src/tui.rs | 108 +++++++++++++++++------ 2 files changed, 253 insertions(+), 99 deletions(-) diff --git a/src/slots.rs b/src/slots.rs index 6668a23..470ae88 100644 --- a/src/slots.rs +++ b/src/slots.rs @@ -6,6 +6,9 @@ //! reel has landed, and the model reel spins on whatever the catalogue lookup is still //! fetching behind it. //! +//! Nothing turns on its own. The cabinet comes up idle and waits for the handle to be +//! pulled, because a machine that starts spinning by itself is a loading screen. +//! //! This module owns the pixels and the dice. The state machine that turns a landing into a //! selection lives with the picker, which is the only thing allowed to touch its rows. @@ -57,7 +60,7 @@ impl Reel { pub fn teaser(title: &'static str) -> Reel { Reel { title, - items: ["? ? ?", "$ $ $", "7 7 7", "* * *"] + items: ["? ? ?", "$ $ $", "7 7 7", "* * *", "B A R", "- - -"] .iter() .map(|s| s.to_string()) .collect(), @@ -90,15 +93,29 @@ pub struct View<'a> { /// Frame counter. Drives the marquee, the blinking and the jackpot colours, so the /// animation needs no clock of its own. pub tick: usize, - /// 0 up, 1 halfway, 2 pulled. + /// 0 up, `LEVER_THROW` fully pulled. pub lever: u8, + /// Waiting for a pull. The handle pulses and the banner says how to reach it. + pub idle: bool, pub jackpot: bool, pub status: String, } /// Wide enough for a model id carrying a suffix, which is what the third reel lands on. -const CELL: usize = 18; +const CELL: usize = 22; const INNER: usize = CELL * 3 + 4; +/// The right-hand margin the handle lives in, and the click target the picker reads back. +const MARGIN: usize = 7; +/// Rows of drum above and below the payline. Five symbols per column read as something +/// turning; three read as a label being replaced. +const REACH: isize = 2; + +/// Rows the handle occupies, counted from the top of the cabinet. The base takes the last +/// two, so the ball rides the rod above it. +const LEVER_TOP: usize = 5; +const LEVER_BASE: usize = 13; +/// How far down the ball travels on a pull. +pub const LEVER_THROW: u8 = 3; /// The casino palette, cycled by the tick so the frame never sits still. const LIGHTS: [Color; 4] = [ @@ -108,9 +125,11 @@ const LIGHTS: [Color; 4] = [ Color::LightCyan, ]; -pub fn draw(f: &mut Frame, area: Rect, v: &View) { +/// Draws the cabinet centred in `area` and answers with the handle's rectangle on screen, +/// which is the only part of it a click means anything on. +pub fn draw(f: &mut Frame, area: Rect, v: &View) -> Rect { let lines = lines(v); - let width = (INNER as u16 + 2 + 6).min(area.width); + let width = (INNER as u16 + 2 + MARGIN as u16).min(area.width); let height = (lines.len() as u16).min(area.height); // Centred, and clamped rather than skipped on a small terminal: a machine with its top // row cut off is still playable, a machine that refuses to draw is a broken easter egg. @@ -121,18 +140,30 @@ pub fn draw(f: &mut Frame, area: Rect, v: &View) { height, }; f.render_widget(Paragraph::new(lines), box_area); + + // The whole margin, not the three cells the ball sits on: a handle you have to hit + // exactly is a handle nobody pulls twice. + let handle = Rect { + x: box_area.x + INNER as u16 + 2, + y: box_area.y + LEVER_TOP as u16, + width: MARGIN as u16, + height: (LEVER_BASE - LEVER_TOP + 1) as u16, + }; + // On a terminal too narrow for the margin the handle is simply not on screen, and an + // empty rectangle is what says so: `intersection` keeps the off-screen corner when it + // has nothing to keep, which would leave a click target hanging past the last column. + match handle.intersection(box_area) { + r if r.width == 0 || r.height == 0 => Rect::default(), + r => r, + } } fn lines(v: &View) -> Vec> { - let frame = match v.jackpot { - // The whole cabinet joins in once it has paid out. - true => LIGHTS[(v.tick / 2) % LIGHTS.len()], - false => Color::LightMagenta, - }; - let mut out = Vec::with_capacity(16); + let frame = frame_colour(v); + let mut out: Vec> = Vec::with_capacity(20); out.push(row( - 0, + out.len(), vec![Span::styled( format!("╔{}╗", "═".repeat(INNER)), Style::new().fg(frame), @@ -140,12 +171,12 @@ fn lines(v: &View) -> Vec> { v, )); out.push(row( - 1, + out.len(), vec![wall(frame), marquee(v.tick, frame), wall(frame)], v, )); out.push(row( - 2, + out.len(), vec![ wall(frame), Span::styled( @@ -158,8 +189,9 @@ fn lines(v: &View) -> Vec> { ], v, )); + out.push(row(out.len(), vec![wall(frame), banner(v), wall(frame)], v)); out.push(row( - 3, + out.len(), vec![Span::styled( format!("╠{}╣", "═".repeat(INNER)), Style::new().fg(frame), @@ -174,7 +206,7 @@ fn lines(v: &View) -> Vec> { titles.push(' '); } out.push(row( - 4, + out.len(), vec![ wall(frame), Span::styled(pad(&titles, INNER), Style::new().fg(Color::DarkGray)), @@ -183,15 +215,15 @@ fn lines(v: &View) -> Vec> { v, )); - out.push(row(5, edge('┌', '┐', frame), v)); - out.push(row(6, band(v, -1), v)); - out.push(row(7, band(v, 0), v)); - out.push(row(8, band(v, 1), v)); - out.push(row(9, edge('└', '┘', frame), v)); + out.push(row(out.len(), edge('┌', '┐', frame), v)); + for offset in -REACH..=REACH { + out.push(row(out.len(), band(v, offset), v)); + } + out.push(row(out.len(), edge('└', '┘', frame), v)); - out.push(row(10, vec![wall(frame), blank(), wall(frame)], v)); + out.push(row(out.len(), vec![wall(frame), blank(), wall(frame)], v)); out.push(row( - 11, + out.len(), vec![ wall(frame), Span::styled( @@ -208,12 +240,12 @@ fn lines(v: &View) -> Vec> { v, )); out.push(row( - 12, - vec![wall(frame), marquee(v.tick + 2, frame), wall(frame)], + out.len(), + vec![wall(frame), marquee(v.tick + 3, frame), wall(frame)], v, )); out.push(row( - 13, + out.len(), vec![Span::styled( format!("╚{}╝", "═".repeat(INNER)), Style::new().fg(frame), @@ -223,6 +255,41 @@ fn lines(v: &View) -> Vec> { out } +fn frame_colour(v: &View) -> Color { + match v.jackpot { + // The whole cabinet joins in once it has paid out. + true => LIGHTS[(v.tick / 2) % LIGHTS.len()], + false => Color::LightMagenta, + } +} + +/// The line under the sign: what the machine wants from you, or what it has just done. +fn banner(v: &View) -> Span<'static> { + let (text, style) = match (v.jackpot, v.idle) { + (true, _) => ( + "* J A C K P O T *", + Style::new() + .fg(LIGHTS[(v.tick / 2) % LIGHTS.len()]) + .add_modifier(Modifier::BOLD | Modifier::REVERSED), + ), + (false, true) => ( + "insert coin >> pull the handle", + // Blinks slower than the marquee, which is what makes the eye go to it. + match (v.tick / 6) % 2 { + 0 => Style::new() + .fg(Color::LightCyan) + .add_modifier(Modifier::BOLD), + _ => Style::new().fg(Color::DarkGray), + }, + ), + (false, false) => ( + "no refunds -- the house picks", + Style::new().fg(Color::DarkGray), + ), + }; + Span::styled(centred(text, INNER), style) +} + /// One line of the cabinet, with the lever drawn in the margin beside it. fn row(index: usize, mut spans: Vec>, v: &View) -> Line<'static> { spans.push(lever(index, v)); @@ -252,17 +319,13 @@ fn edge(left: char, right: char, c: Color) -> Vec> { ] } -/// A horizontal slice through all three reels: the row above the window, the lit one, the -/// row below. Three visible symbols per reel is what makes a spinning column read as a -/// spinning column and not as a label changing at random. +/// A horizontal slice through all three reels: the payline in the middle, two rows of drum +/// above and below it, fading with distance so the column reads as curved. fn band(v: &View, offset: isize) -> Vec> { let lit = offset == 0; - let frame = match v.jackpot { - true => LIGHTS[(v.tick / 2) % LIGHTS.len()], - false => Color::LightMagenta, - }; - // The payline, marked in the margin the other two bands leave blank rather than in a - // column of its own: the result of a pull is the middle row and nothing else. + let frame = frame_colour(v); + // The payline, marked in the margin the other bands leave blank rather than in a column + // of its own: the result of a pull is the middle row and nothing else. let arrow = |c: &'static str| match lit { true => Span::styled( c, @@ -284,8 +347,12 @@ fn band(v: &View, offset: isize) -> Vec> { (true, true, false) => Style::new() .fg(Color::LightYellow) .add_modifier(Modifier::BOLD), - (true, false, _) => Style::new().fg(Color::White), - _ => Style::new().fg(Color::DarkGray), + (true, false, _) => Style::new().fg(Color::White).add_modifier(Modifier::BOLD), + // Off the payline, and the further off the fainter: the drum falls away. + _ => match offset.abs() { + 1 => Style::new().fg(Color::Gray), + _ => Style::new().fg(Color::DarkGray).add_modifier(Modifier::DIM), + }, }; spans.push(Span::styled(text, style)); spans.push(Span::styled("│", Style::new().fg(Color::DarkGray))); @@ -301,35 +368,44 @@ fn band(v: &View, offset: isize) -> Vec> { fn marquee(tick: usize, frame: Color) -> Span<'static> { let mut s = String::with_capacity(INNER); for i in 0..INNER { - s.push(match (i + tick) % 4 { + s.push(match (i + tick) % 6 { 0 => '*', - 2 => '.', + 3 => '.', _ => ' ', }); } Span::styled(s, Style::new().fg(frame)) } -/// The handle, six rows tall in the right margin, the ball riding down as it is pulled. +/// The handle, in the right margin, the ball riding down the rod as it is pulled. Every row +/// of the margin is drawn, blank ones included, so the click target is a solid block. fn lever(index: usize, v: &View) -> Span<'static> { - let top = 5usize; - let ball = top + v.lever as usize; - if index < top || index > top + 4 { + if !(LEVER_TOP..=LEVER_BASE).contains(&index) { return Span::raw(""); } - let (art, colour) = match index { - i if i == ball => (" (O) ", Color::LightRed), - i if i > top + 3 => (" [=] ", Color::DarkGray), - i if i > ball => (" | ", Color::Gray), - _ => (" ", Color::Reset), + let ball = LEVER_TOP + v.lever as usize; + let (art, style) = match index { + i if i == LEVER_BASE => ( + " ▐███▌ ", + Style::new() + .fg(Color::DarkGray) + .add_modifier(Modifier::BOLD), + ), + i if i == LEVER_BASE - 1 => (" ▄▄▄ ", Style::new().fg(Color::DarkGray)), + i if i == ball => ( + " (O) ", + Style::new() + .fg(match v.idle && (v.tick / 5) % 2 == 1 { + // Idle, so it pulses: the one thing on screen asking to be touched. + true => Color::LightYellow, + false => Color::LightRed, + }) + .add_modifier(Modifier::BOLD), + ), + i if i > ball => (" ║ ", Style::new().fg(Color::Gray)), + _ => (" ", Style::new()), }; - Span::styled( - art.to_string(), - Style::new().fg(colour).add_modifier(match index == ball { - true => Modifier::BOLD, - false => Modifier::empty(), - }), - ) + Span::styled(art.to_string(), style) } /// Trimmed to the cell, ellipsis included, so a long model id cannot push the cabinet open. @@ -375,6 +451,17 @@ mod tests { } } + fn view(reels: &[Reel; 3], idle: bool, jackpot: bool) -> View<'_> { + View { + reels, + tick: 3, + lever: 2, + idle, + jackpot, + status: "JACKPOT".into(), + } + } + #[test] fn every_line_is_the_same_width() { let reels = [ @@ -382,39 +469,52 @@ mod tests { reel(&["a provider with a very long name"]), reel(&["gpt-5"]), ]; - let v = View { - reels: &reels, - tick: 3, - lever: 2, - jackpot: true, - status: "JACKPOT".into(), - }; - let widths: Vec = lines(&v).iter().map(|l| l.width()).collect(); + let widths: Vec = lines(&view(&reels, false, true)) + .iter() + .map(|l| l.width()) + .collect(); // The lever margin is empty on the rows it does not reach, so the cabinet itself is // what has to line up: every row is the frame plus, at most, the handle. for w in &widths { assert!( - *w == INNER + 2 || *w == INNER + 8, + *w == INNER + 2 || *w == INNER + 2 + MARGIN, "a row came out {w} wide, cabinet is {}", INNER + 2 ); } } + /// The handle has to fill the rectangle the click handler is handed, at every throw. + #[test] + fn the_handle_covers_its_whole_click_target() { + let reels = [reel(&["x"]), reel(&["y"]), reel(&["z"])]; + for throw in 0..=LEVER_THROW { + let mut v = view(&reels, true, false); + v.lever = throw; + for index in LEVER_TOP..=LEVER_BASE { + assert_eq!( + lever(index, &v).content.chars().count(), + MARGIN, + "row {index} of the handle is not the width of the target" + ); + } + } + } + /// A terminal smaller than the cabinet must still render something rather than panic. #[test] fn draws_into_a_short_terminal() { let reels = [reel(&["x"]), reel(&["y"]), reel(&["z"])]; - let v = View { - reels: &reels, - tick: 0, - lever: 0, - jackpot: false, - status: String::new(), - }; + let v = view(&reels, true, false); let mut terminal = Terminal::new(TestBackend::new(20, 5)).unwrap(); terminal - .draw(|f| draw(f, f.area(), &v)) + .draw(|f| { + let handle = draw(f, f.area(), &v); + // Clamped into the frame, so a click is never tested against a rectangle + // hanging off the screen. + assert!(handle.right() <= f.area().right()); + assert!(handle.bottom() <= f.area().bottom()); + }) .expect("a small terminal must not stop the machine"); } diff --git a/src/tui.rs b/src/tui.rs index 9c80a02..2ef3df4 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -550,12 +550,15 @@ enum Roll { Quit, } -/// One pull of the lever. +/// One visit to the machine. /// -/// The reels stop left to right because each answer narrows the next: the provider reel is -/// filled from the harness that just landed, and the model reel turns on nothing until the -/// catalogue lookup for that provider comes back. So the wait for the network is the -/// animation rather than a spinner in front of it. +/// Nothing turns until the handle is pulled, with space or a click on it: the picker is +/// about to answer three questions for the user, so it waits to be told to. +/// +/// The reels then stop left to right because each answer narrows the next: the provider +/// reel is filled from the harness that just landed, and the model reel turns on nothing +/// until the catalogue lookup for that provider comes back. So the wait for the network is +/// the animation rather than a spinner in front of it. fn play_slots(terminal: &mut DefaultTerminal, app: &mut App) -> Result { const FRAME: Duration = Duration::from_millis(45); /// Long enough that a reel reads as spinning rather than as a value being replaced. @@ -563,6 +566,13 @@ fn play_slots(terminal: &mut DefaultTerminal, app: &mut App) -> Result { /// The same ceiling the command-line path puts on a catalogue lookup. A reel that turns /// for ever is a menu with no way out. const PATIENCE: Duration = Duration::from_secs(20); + /// Milliseconds the ball spends on each row of its throw. + const THROW_STEP: u128 = 90; + /// Waiting to be played, the handle coming down, one stage per reel, then the payout. + const IDLE: usize = 0; + const PULL: usize = 1; + const FIRST_REEL: usize = 2; + const PAID: usize = 5; if app.harness_rows.is_empty() { app.notice = Some("nothing installed to gamble on".into()); @@ -570,36 +580,40 @@ fn play_slots(terminal: &mut DefaultTerminal, app: &mut App) -> Result { } let mut rng = Rng::new(); + // Kept, because a second pull rebuilds the reels from scratch and the harness list is + // the one thing no landing can change. + let harnesses: Vec = app + .harness_rows + .iter() + .filter_map(|&i| app.cfg.harnesses.get(i)) + .map(|h| h.name.clone()) + .collect(); let mut reels = [ Reel::teaser("harness"), Reel::teaser("provider"), Reel::teaser("model"), ]; - reels[0].load( - app.harness_rows - .iter() - .filter_map(|&i| app.cfg.harnesses.get(i)) - .map(|h| h.name.clone()) - .collect(), - ); + reels[0].load(harnesses.clone()); - // 0 to 2 while the handle comes down, then one stage per reel, then the payout. - let mut stage: usize = 0; + let mut stage: usize = IDLE; let mut tick: usize = 0; let mut lever: u8 = 0; let mut began = Instant::now(); let mut target: Option = None; - let mut status = String::from("pull!"); + let mut status = String::from("pull the handle space, or click it"); // The model reel is filled once, mid-spin, when the catalogue lands. A count would not // do: a provider serving three models is indistinguishable from the placeholders. let mut model_reel_filled = false; + // Where the last frame put the handle. Only the draw knows, since the cabinet is + // centred in whatever the terminal happens to be. + let mut handle = Rect::default(); loop { app.poll_models(); // The model reel has nothing to turn on until the provider has answered, so it // keeps its placeholders and the lookup fills it in mid-spin. - if stage == 3 && !model_reel_filled && !app.loading() { + if stage == FIRST_REEL + 2 && !model_reel_filled && !app.loading() { if app.visible_models.is_empty() { app.notice = Some("the machine came up empty, that provider listed nothing".into()); return Ok(Roll::Back); @@ -617,21 +631,24 @@ fn play_slots(terminal: &mut DefaultTerminal, app: &mut App) -> Result { status = "model...".into(); } - let jackpot = stage == 4; + let jackpot = stage == PAID; + let idle = stage == IDLE; terminal.draw(|f| { - slots::draw( + handle = slots::draw( f, f.area(), &View { reels: &reels, tick, lever, + idle, jackpot, status: status.clone(), }, ) })?; + let mut pulled = false; if event::poll(FRAME)? { match event::read()? { Event::Key(k) if k.kind == KeyEventKind::Press => { @@ -644,6 +661,7 @@ fn play_slots(terminal: &mut DefaultTerminal, app: &mut App) -> Result { app.notice = Some("no bet taken".into()); return Ok(Roll::Back); } + KeyCode::Char(' ') => pulled = true, // Only pays once every reel has landed, so an early Enter is the // impatience it looks like and not a launch of half a choice. KeyCode::Enter if jackpot => { @@ -655,24 +673,60 @@ fn play_slots(terminal: &mut DefaultTerminal, app: &mut App) -> Result { _ => {} } } + Event::Mouse(m) if m.kind == MouseEventKind::Down(MouseButton::Left) => { + pulled = handle.width > 0 + && m.column >= handle.x + && m.column < handle.x + handle.width + && m.row >= handle.y + && m.row < handle.y + handle.height; + } _ => {} } } + + // A pull on a machine that has already paid is a re-roll: the reels go back to + // their placeholders and everything it decided is up for grabs again. + if pulled && (stage == IDLE || stage == PAID) { + reels = [ + Reel::teaser("harness"), + Reel::teaser("provider"), + Reel::teaser("model"), + ]; + reels[0].load(harnesses.clone()); + model_reel_filled = false; + target = None; + lever = 0; + stage = PULL; + began = Instant::now(); + status = String::new(); + } + tick = tick.wrapping_add(1); match stage { - // The handle coming down. Three positions, then the first reel goes. - 0 => { - lever = (began.elapsed().as_millis() / 110).min(2) as u8; - if began.elapsed() > Duration::from_millis(360) { - stage = 1; + // Loose drums, drifting. Enough movement to say the thing is switched on, slow + // enough that nobody mistakes it for a roll already under way. + IDLE => { + if tick.is_multiple_of(6) { + for r in reels.iter_mut() { + let len = r.items.len().max(1); + r.pos = (r.pos + 1) % len; + } + } + } + // The handle coming down, then the first reel goes. + PULL => { + lever = (began.elapsed().as_millis() / THROW_STEP).min(slots::LEVER_THROW as u128) + as u8; + if began.elapsed().as_millis() > THROW_STEP * (slots::LEVER_THROW as u128 + 1) { + stage = FIRST_REEL; began = Instant::now(); status = "harness...".into(); } } - 1..=3 => { - let i = stage - 1; - let waiting = stage == 3 && app.loading(); + s if (FIRST_REEL..PAID).contains(&s) => { + let i = stage - FIRST_REEL; + let waiting = i == 2 && app.loading(); if waiting { status = format!( "asking {} what it serves...", @@ -728,7 +782,7 @@ fn play_slots(terminal: &mut DefaultTerminal, app: &mut App) -> Result { _ => { app.model_idx = row; app.ensure_options(); - status = "JACKPOT enter launch esc back".into(); + status = "JACKPOT enter launch space reroll esc back".into(); } } stage += 1; From 7d9d9ea99fae8330c343548a6717caa7c909b83d Mon Sep 17 00:00:00 2001 From: meetsu <96637888+klNuno@users.noreply.github.com> Date: Tue, 18 Aug 2026 23:25:32 +0200 Subject: [PATCH 3/5] feat(tui): a cabinet measured against the terminal it opens in slots.rs sized every column from a 22-wide const, which stumped a long model id on a wide screen and overflowed a narrow one. every size now comes from Geo::fit(area): wider terminal, wider reels up to MAX_CELL; taller terminal, deeper drums. under the width that keeps a reel legible the handle margin goes rather than the reels, and the banner names the space bar instead. --- src/slots.rs | 318 ++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 226 insertions(+), 92 deletions(-) diff --git a/src/slots.rs b/src/slots.rs index 470ae88..3cd7d66 100644 --- a/src/slots.rs +++ b/src/slots.rs @@ -9,6 +9,10 @@ //! Nothing turns on its own. The cabinet comes up idle and waits for the handle to be //! pulled, because a machine that starts spinning by itself is a loading screen. //! +//! Nothing here has a fixed size either. Provider and model ids run long, so the cabinet is +//! measured against the terminal every frame and takes all the room it is given, down to a +//! window that cannot hold the handle at all. +//! //! This module owns the pixels and the dice. The state machine that turns a landing into a //! selection lives with the picker, which is the only thing allowed to touch its rows. @@ -101,20 +105,26 @@ pub struct View<'a> { pub status: String, } -/// Wide enough for a model id carrying a suffix, which is what the third reel lands on. -const CELL: usize = 22; -const INNER: usize = CELL * 3 + 4; +/// Narrowest a reel can be and still say anything: `douane` and a short model id fit, a long +/// one is trimmed. Below this the cabinet stops shrinking and lets the terminal cut it off, +/// since a column three characters wide answers no question. +const MIN_CELL: usize = 10; +/// Widest a reel gets. Past this the ids are long since fully readable and the machine is +/// only stretching, which reads as a bug rather than a bigger prize. +const MAX_CELL: usize = 34; /// The right-hand margin the handle lives in, and the click target the picker reads back. const MARGIN: usize = 7; -/// Rows of drum above and below the payline. Five symbols per column read as something -/// turning; three read as a label being replaced. -const REACH: isize = 2; - -/// Rows the handle occupies, counted from the top of the cabinet. The base takes the last -/// two, so the ball rides the rod above it. +/// Columns the cabinet spends on something other than the reels: two walls, and the four +/// spaces framing and separating the three columns. +const GUTTERS: usize = 6; +/// Rows the cabinet spends on something other than the drum: sign, banner, titles, edges, +/// status, marquees, borders, plus the payline itself. +const CHROME: usize = 13; + +/// Row the handle starts on. Level with the reel titles, so it stands beside the drum. const LEVER_TOP: usize = 5; -const LEVER_BASE: usize = 13; -/// How far down the ball travels on a pull. +/// How far down the ball travels on a pull. Small enough that the shortest cabinet still has +/// rod above its plinth to run it down. pub const LEVER_THROW: u8 = 3; /// The casino palette, cycled by the tick so the frame never sits still. @@ -125,11 +135,56 @@ const LIGHTS: [Color; 4] = [ Color::LightCyan, ]; +/// The cabinet measured against the terminal it has to live in. Everything drawn reads its +/// sizes from here rather than from a constant, which is what lets a wide window show a full +/// model id instead of a stump. +pub struct Geo { + /// Width of one reel column, borders included. + pub cell: usize, + /// Width between the two walls. + pub inner: usize, + /// Rows of drum above and below the payline. + pub reach: isize, + /// Width of the handle margin, and 0 when the terminal cannot spare it: on a narrow + /// window the reels are worth more than the lever, which the space bar replaces. + pub margin: usize, + lever_base: usize, +} + +impl Geo { + pub fn fit(area: Rect) -> Geo { + let w = area.width as usize; + // The handle is the first thing dropped, and only once keeping it would squeeze the + // reels under the point where an id is legible. + let margin = match w.saturating_sub(GUTTERS + MARGIN) / 3 >= MIN_CELL { + true => MARGIN, + false => 0, + }; + let cell = (w.saturating_sub(GUTTERS + margin) / 3).clamp(MIN_CELL, MAX_CELL); + // A taller terminal buys deeper drums, which is the one part of the machine that + // reads better big: more symbols in flight, more of a spin. + let reach = ((area.height as isize - CHROME as isize) / 2).clamp(1, 4); + Geo { + cell, + inner: cell * 3 + 4, + reach, + margin, + lever_base: (2 * reach as usize) + 9, + } + } + + /// Total rows, which is also what the caller has to have to see the whole cabinet. + fn height(&self) -> usize { + CHROME + 2 * self.reach as usize + } +} + /// Draws the cabinet centred in `area` and answers with the handle's rectangle on screen, /// which is the only part of it a click means anything on. pub fn draw(f: &mut Frame, area: Rect, v: &View) -> Rect { - let lines = lines(v); - let width = (INNER as u16 + 2 + MARGIN as u16).min(area.width); + let g = Geo::fit(area); + let lines = lines(v, &g); + let width = (g.inner as u16 + 2 + g.margin as u16).min(area.width); let height = (lines.len() as u16).min(area.height); // Centred, and clamped rather than skipped on a small terminal: a machine with its top // row cut off is still playable, a machine that refuses to draw is a broken easter egg. @@ -141,46 +196,51 @@ pub fn draw(f: &mut Frame, area: Rect, v: &View) -> Rect { }; f.render_widget(Paragraph::new(lines), box_area); + if g.margin == 0 { + return Rect::default(); + } // The whole margin, not the three cells the ball sits on: a handle you have to hit // exactly is a handle nobody pulls twice. let handle = Rect { - x: box_area.x + INNER as u16 + 2, + x: box_area.x + g.inner as u16 + 2, y: box_area.y + LEVER_TOP as u16, - width: MARGIN as u16, - height: (LEVER_BASE - LEVER_TOP + 1) as u16, + width: g.margin as u16, + height: (g.lever_base - LEVER_TOP + 1) as u16, }; - // On a terminal too narrow for the margin the handle is simply not on screen, and an - // empty rectangle is what says so: `intersection` keeps the off-screen corner when it - // has nothing to keep, which would leave a click target hanging past the last column. + // On a terminal too short for the handle it is simply not on screen, and an empty + // rectangle is what says so: `intersection` keeps the off-screen corner when it has + // nothing to keep, which would leave a click target hanging past the last row. match handle.intersection(box_area) { r if r.width == 0 || r.height == 0 => Rect::default(), r => r, } } -fn lines(v: &View) -> Vec> { +fn lines(v: &View, g: &Geo) -> Vec> { let frame = frame_colour(v); - let mut out: Vec> = Vec::with_capacity(20); + let mut out: Vec> = Vec::with_capacity(g.height()); out.push(row( out.len(), vec![Span::styled( - format!("╔{}╗", "═".repeat(INNER)), + format!("╔{}╗", "═".repeat(g.inner)), Style::new().fg(frame), )], v, + g, )); out.push(row( out.len(), - vec![wall(frame), marquee(v.tick, frame), wall(frame)], + vec![wall(frame), marquee(v.tick, frame, g), wall(frame)], v, + g, )); out.push(row( out.len(), vec![ wall(frame), Span::styled( - centred("F A S T P I C K S L O T S", INNER), + centred("F A S T P I C K S L O T S", g.inner), Style::new() .fg(Color::LightYellow) .add_modifier(Modifier::BOLD), @@ -188,46 +248,59 @@ fn lines(v: &View) -> Vec> { wall(frame), ], v, + g, + )); + out.push(row( + out.len(), + vec![wall(frame), banner(v, g), wall(frame)], + v, + g, )); - out.push(row(out.len(), vec![wall(frame), banner(v), wall(frame)], v)); out.push(row( out.len(), vec![Span::styled( - format!("╠{}╣", "═".repeat(INNER)), + format!("╠{}╣", "═".repeat(g.inner)), Style::new().fg(frame), )], v, + g, )); // The three questions, over the reel that answers them. let mut titles = String::from(" "); for r in v.reels { - titles.push_str(¢red(r.title, CELL)); + titles.push_str(¢red(r.title, g.cell)); titles.push(' '); } out.push(row( out.len(), vec![ wall(frame), - Span::styled(pad(&titles, INNER), Style::new().fg(Color::DarkGray)), + Span::styled(pad(&titles, g.inner), Style::new().fg(Color::DarkGray)), wall(frame), ], v, + g, )); - out.push(row(out.len(), edge('┌', '┐', frame), v)); - for offset in -REACH..=REACH { - out.push(row(out.len(), band(v, offset), v)); + out.push(row(out.len(), edge('┌', '┐', frame, g), v, g)); + for offset in -g.reach..=g.reach { + out.push(row(out.len(), band(v, offset, g), v, g)); } - out.push(row(out.len(), edge('└', '┘', frame), v)); + out.push(row(out.len(), edge('└', '┘', frame, g), v, g)); - out.push(row(out.len(), vec![wall(frame), blank(), wall(frame)], v)); + out.push(row( + out.len(), + vec![wall(frame), blank(g), wall(frame)], + v, + g, + )); out.push(row( out.len(), vec![ wall(frame), Span::styled( - pad(&format!(" {}", v.status), INNER), + pad(&format!(" {}", v.status), g.inner), match v.jackpot { true => Style::new() .fg(Color::LightGreen) @@ -238,19 +311,22 @@ fn lines(v: &View) -> Vec> { wall(frame), ], v, + g, )); out.push(row( out.len(), - vec![wall(frame), marquee(v.tick + 3, frame), wall(frame)], + vec![wall(frame), marquee(v.tick + 3, frame, g), wall(frame)], v, + g, )); out.push(row( out.len(), vec![Span::styled( - format!("╚{}╝", "═".repeat(INNER)), + format!("╚{}╝", "═".repeat(g.inner)), Style::new().fg(frame), )], v, + g, )); out } @@ -264,7 +340,7 @@ fn frame_colour(v: &View) -> Color { } /// The line under the sign: what the machine wants from you, or what it has just done. -fn banner(v: &View) -> Span<'static> { +fn banner(v: &View, g: &Geo) -> Span<'static> { let (text, style) = match (v.jackpot, v.idle) { (true, _) => ( "* J A C K P O T *", @@ -273,7 +349,12 @@ fn banner(v: &View) -> Span<'static> { .add_modifier(Modifier::BOLD | Modifier::REVERSED), ), (false, true) => ( - "insert coin >> pull the handle", + // Without a margin there is no handle to point at, so the invitation names the + // key that is left. + match g.margin { + 0 => "insert coin >> press space", + _ => "insert coin >> pull the handle", + }, // Blinks slower than the marquee, which is what makes the eye go to it. match (v.tick / 6) % 2 { 0 => Style::new() @@ -287,12 +368,12 @@ fn banner(v: &View) -> Span<'static> { Style::new().fg(Color::DarkGray), ), }; - Span::styled(centred(text, INNER), style) + Span::styled(centred(text, g.inner), style) } /// One line of the cabinet, with the lever drawn in the margin beside it. -fn row(index: usize, mut spans: Vec>, v: &View) -> Line<'static> { - spans.push(lever(index, v)); +fn row(index: usize, mut spans: Vec>, v: &View, g: &Geo) -> Line<'static> { + spans.push(lever(index, v, g)); Line::from(spans) } @@ -300,28 +381,28 @@ fn wall(c: Color) -> Span<'static> { Span::styled("║", Style::new().fg(c)) } -fn blank() -> Span<'static> { - Span::raw(" ".repeat(INNER)) +fn blank(g: &Geo) -> Span<'static> { + Span::raw(" ".repeat(g.inner)) } -fn edge(left: char, right: char, c: Color) -> Vec> { +fn edge(left: char, right: char, c: Color, g: &Geo) -> Vec> { let mut s = String::from(" "); for _ in 0..3 { s.push(left); - s.push_str(&"─".repeat(CELL - 2)); + s.push_str(&"─".repeat(g.cell - 2)); s.push(right); s.push(' '); } vec![ wall(c), - Span::styled(pad(&s, INNER), Style::new().fg(Color::DarkGray)), + Span::styled(pad(&s, g.inner), Style::new().fg(Color::DarkGray)), wall(c), ] } -/// A horizontal slice through all three reels: the payline in the middle, two rows of drum -/// above and below it, fading with distance so the column reads as curved. -fn band(v: &View, offset: isize) -> Vec> { +/// A horizontal slice through all three reels: the payline in the middle, drum above and +/// below it, fading with distance so the column reads as curved. +fn band(v: &View, offset: isize, g: &Geo) -> Vec> { let lit = offset == 0; let frame = frame_colour(v); // The payline, marked in the margin the other bands leave blank rather than in a column @@ -338,7 +419,7 @@ fn band(v: &View, offset: isize) -> Vec> { let mut spans = vec![wall(frame), arrow("▶")]; for r in v.reels { spans.push(Span::styled("│", Style::new().fg(Color::DarkGray))); - let text = fit(r.at(offset), CELL - 2); + let text = fit(r.at(offset), g.cell - 2); let style = match (lit, r.stopped, v.jackpot) { // Landed, and the machine has paid: the answer flashes. (true, true, true) => Style::new() @@ -365,9 +446,9 @@ fn band(v: &View, offset: isize) -> Vec> { } /// The chase lights, one cell shifted per frame. -fn marquee(tick: usize, frame: Color) -> Span<'static> { - let mut s = String::with_capacity(INNER); - for i in 0..INNER { +fn marquee(tick: usize, frame: Color, g: &Geo) -> Span<'static> { + let mut s = String::with_capacity(g.inner); + for i in 0..g.inner { s.push(match (i + tick) % 6 { 0 => '*', 3 => '.', @@ -379,19 +460,19 @@ fn marquee(tick: usize, frame: Color) -> Span<'static> { /// The handle, in the right margin, the ball riding down the rod as it is pulled. Every row /// of the margin is drawn, blank ones included, so the click target is a solid block. -fn lever(index: usize, v: &View) -> Span<'static> { - if !(LEVER_TOP..=LEVER_BASE).contains(&index) { +fn lever(index: usize, v: &View, g: &Geo) -> Span<'static> { + if g.margin == 0 || !(LEVER_TOP..=g.lever_base).contains(&index) { return Span::raw(""); } let ball = LEVER_TOP + v.lever as usize; let (art, style) = match index { - i if i == LEVER_BASE => ( + i if i == g.lever_base => ( " ▐███▌ ", Style::new() .fg(Color::DarkGray) .add_modifier(Modifier::BOLD), ), - i if i == LEVER_BASE - 1 => (" ▄▄▄ ", Style::new().fg(Color::DarkGray)), + i if i == g.lever_base - 1 => (" ▄▄▄ ", Style::new().fg(Color::DarkGray)), i if i == ball => ( " (O) ", Style::new() @@ -462,60 +543,113 @@ mod tests { } } + fn geo(width: u16, height: u16) -> Geo { + Geo::fit(Rect::new(0, 0, width, height)) + } + + /// Every terminal the machine can be opened in, from a phone-sized pane to a wall. + fn sizes() -> Vec<(u16, u16)> { + vec![ + (20, 5), + (40, 12), + (60, 18), + (80, 24), + (100, 24), + (120, 40), + (200, 60), + ] + } + #[test] fn every_line_is_the_same_width() { let reels = [ reel(&["claude code"]), - reel(&["a provider with a very long name"]), - reel(&["gpt-5"]), + reel(&["a provider with a name that runs off the end of the cabinet"]), + reel(&["cx-gpt-5-6-terra-with-a-long-suffix"]), ]; - let widths: Vec = lines(&view(&reels, false, true)) - .iter() - .map(|l| l.width()) - .collect(); - // The lever margin is empty on the rows it does not reach, so the cabinet itself is - // what has to line up: every row is the frame plus, at most, the handle. - for w in &widths { - assert!( - *w == INNER + 2 || *w == INNER + 2 + MARGIN, - "a row came out {w} wide, cabinet is {}", - INNER + 2 - ); + for (w, h) in sizes() { + let g = geo(w, h); + // The lever margin is empty on the rows it does not reach, so the cabinet itself + // is what has to line up: every row is the frame plus, at most, the handle. + for line in lines(&view(&reels, false, true), &g) { + let width = line.width(); + assert!( + width == g.inner + 2 || width == g.inner + 2 + g.margin, + "at {w}x{h} a row came out {width} wide, cabinet is {}", + g.inner + 2 + ); + } } } - /// The handle has to fill the rectangle the click handler is handed, at every throw. + /// The reels grow with the terminal, which is the whole point of measuring it. + #[test] + fn a_wider_terminal_buys_wider_reels() { + assert!(geo(200, 40).cell > geo(80, 24).cell); + assert!(geo(200, 40).reach >= geo(80, 24).reach); + assert_eq!( + geo(200, 40).cell, + MAX_CELL, + "the cabinet has to stop somewhere" + ); + // Too narrow for the handle: the reels keep the room and the space bar takes over. + assert_eq!(geo(34, 20).margin, 0); + assert!(geo(100, 24).margin > 0); + } + + /// The handle has to fill the rectangle the click handler is handed, at every throw and + /// in every cabinet tall enough to carry one. #[test] fn the_handle_covers_its_whole_click_target() { let reels = [reel(&["x"]), reel(&["y"]), reel(&["z"])]; - for throw in 0..=LEVER_THROW { - let mut v = view(&reels, true, false); - v.lever = throw; - for index in LEVER_TOP..=LEVER_BASE { - assert_eq!( - lever(index, &v).content.chars().count(), - MARGIN, - "row {index} of the handle is not the width of the target" - ); + for (w, h) in sizes() { + let g = geo(w, h); + if g.margin == 0 { + continue; + } + for throw in 0..=LEVER_THROW { + let mut v = view(&reels, true, false); + v.lever = throw; + for index in LEVER_TOP..=g.lever_base { + assert_eq!( + lever(index, &v, &g).content.chars().count(), + g.margin, + "at {w}x{h}, row {index} of the handle is not the width of the target" + ); + } } } } + /// The ball must never reach the plinth, however short the cabinet is. + #[test] + fn the_ball_stays_on_its_rod() { + for (w, h) in sizes() { + let g = geo(w, h); + assert!( + LEVER_TOP + LEVER_THROW as usize <= g.lever_base - 2, + "at {w}x{h} the handle has no rod left to run down" + ); + } + } + /// A terminal smaller than the cabinet must still render something rather than panic. #[test] - fn draws_into_a_short_terminal() { + fn draws_into_any_terminal() { let reels = [reel(&["x"]), reel(&["y"]), reel(&["z"])]; let v = view(&reels, true, false); - let mut terminal = Terminal::new(TestBackend::new(20, 5)).unwrap(); - terminal - .draw(|f| { - let handle = draw(f, f.area(), &v); - // Clamped into the frame, so a click is never tested against a rectangle - // hanging off the screen. - assert!(handle.right() <= f.area().right()); - assert!(handle.bottom() <= f.area().bottom()); - }) - .expect("a small terminal must not stop the machine"); + for (w, h) in sizes() { + let mut terminal = Terminal::new(TestBackend::new(w, h)).unwrap(); + terminal + .draw(|f| { + let handle = draw(f, f.area(), &v); + // Clamped into the frame, so a click is never tested against a rectangle + // hanging off the screen. + assert!(handle.right() <= f.area().right()); + assert!(handle.bottom() <= f.area().bottom()); + }) + .expect("a small terminal must not stop the machine"); + } } #[test] From 23b274a0109f9b38e22aece52c3c2da093bef111 Mon Sep 17 00:00:00 2001 From: meetsu <96637888+klNuno@users.noreply.github.com> Date: Tue, 18 Aug 2026 23:46:45 +0200 Subject: [PATCH 4/5] feat(tui): drums with weight, a handle with a stroke, a cabinet that fills the screen one row per frame read as a list being replaced. slots.rs now owns the spin: a fractional offset winding up to TOP_SPEED, blurring into symbols past BLUR_SPEED, easing onto its row through settle(), a back-out curve overshooting under a row. all three drums go on the pull and stop left to right, the provider reel refilled mid-spin without losing its motion. the cabinet lights from the payline outwards (View::boot), the handle is a knob on a rod in a track with a sprung return (View::lever), Geo::fit takes the whole terminal. --- src/slots.rs | 418 ++++++++++++++++++++++++++++++++++++++++----------- src/tui.rs | 296 ++++++++++++++++++++---------------- 2 files changed, 500 insertions(+), 214 deletions(-) diff --git a/src/slots.rs b/src/slots.rs index 3cd7d66..7d0f029 100644 --- a/src/slots.rs +++ b/src/slots.rs @@ -1,20 +1,20 @@ //! The lever nobody asked for. //! -//! Three reels, one per question the picker asks: harness, then provider, then model. They -//! stop left to right because that is the order the answers depend on each other in, so the -//! machine is not only decoration: a provider reel cannot hold anything until the harness -//! reel has landed, and the model reel spins on whatever the catalogue lookup is still -//! fetching behind it. +//! Three reels, one per question the picker asks: harness, then provider, then model. All +//! three go at once on a pull and stop left to right, which is both what a real cabinet does +//! and the order the answers depend on each other in: the provider reel is refilled while it +//! is still turning, from the harness that has just landed, and the model reel spins on +//! casino symbols until the catalogue lookup behind it comes back. //! -//! Nothing turns on its own. The cabinet comes up idle and waits for the handle to be -//! pulled, because a machine that starts spinning by itself is a loading screen. +//! Nothing turns on its own. The cabinet comes up dark, lights itself, and waits for the +//! handle, because a machine that starts spinning by itself is a loading screen. //! //! Nothing here has a fixed size either. Provider and model ids run long, so the cabinet is -//! measured against the terminal every frame and takes all the room it is given, down to a -//! window that cannot hold the handle at all. +//! measured against the terminal every frame and takes the whole of it. //! -//! This module owns the pixels and the dice. The state machine that turns a landing into a -//! selection lives with the picker, which is the only thing allowed to touch its rows. +//! This module owns the pixels, the dice and the physics. The state machine that turns a +//! landing into a selection lives with the picker, which is the only thing allowed to touch +//! its rows. use ratatui::prelude::*; use ratatui::widgets::Paragraph; @@ -51,12 +51,50 @@ impl Rng { } } -/// One column of the machine. `items` is what it can land on, `pos` where it is right now. +/// What a drum is doing. The picker never sets this, it pulls the lever and reads the +/// landings back, so the whole spin lives in one place. +#[derive(PartialEq, Clone, Copy)] +enum Spin { + /// Switched on, nothing at stake: barely turning. + Rest, + /// Winding up to full speed, and holding there until told where to stop. + Free, + /// Easing into the row it was given, overshooting it and settling back. + Braking, + Stopped, +} + +/// Rows a drum crosses per second at full tilt. Fast enough that the names blur into +/// symbols, slow enough that the eye still reads it as a wheel and not as noise. +const TOP_SPEED: f64 = 24.0; +/// Rows per second it drifts at while waiting to be played. +const REST_SPEED: f64 = 1.1; +/// Rows per second squared on the way up. A drum that reaches full speed instantly has no +/// weight, and weight is most of what makes a spin look real. +const SPIN_UP: f64 = 46.0; +/// Above this the drum shows casino symbols instead of names: nothing is readable at speed, +/// and pretending otherwise is what made the old machine look like a list being replaced. +const BLUR_SPEED: f64 = 9.0; +/// What a blurred drum shows. Three cells wide at most, so a narrow cabinet keeps them. +const SYMBOLS: [&str; 6] = ["7 7 7", "$ $ $", "* * *", "B A R", "- - -", "$ 7 $"]; + +/// One column of the machine. `items` is what it can land on, `offset` where it is right +/// now, in rows, fractional so the motion has somewhere to live between frames. pub struct Reel { pub title: &'static str, pub items: Vec, - pub pos: usize, - pub stopped: bool, + offset: f64, + speed: f64, + state: Spin, + /// Where the current brake started, how far it runs, and how far through it is. An eased + /// interpolation rather than a deceleration: it has to land exactly on the row the dice + /// chose, and a physical brake would need a correction at the end that reads as a stutter. + from: f64, + dist: f64, + t: f64, + dur: f64, + /// Seconds of landing flash left. The drum hitting its stop is the payout of the spin. + flash: f64, } impl Reel { @@ -64,41 +102,135 @@ impl Reel { pub fn teaser(title: &'static str) -> Reel { Reel { title, - items: ["? ? ?", "$ $ $", "7 7 7", "* * *", "B A R", "- - -"] - .iter() - .map(|s| s.to_string()) - .collect(), - pos: 0, - stopped: false, + items: SYMBOLS.iter().map(|s| s.to_string()).collect(), + offset: 0.0, + speed: 0.0, + state: Spin::Rest, + from: 0.0, + dist: 0.0, + t: 0.0, + dur: 0.0, + flash: 0.0, } } + /// New contents under a drum that may well be turning: the motion is kept and only the + /// position is brought back into range, so a reel refilled mid-spin never jumps. pub fn load(&mut self, items: Vec) { self.items = match items.is_empty() { true => vec!["nothing".to_string()], false => items, }; - self.pos = 0; + self.offset = self.offset.rem_euclid(self.items.len() as f64); + } + + /// Let go of the brake and wind up. + pub fn kick(&mut self) { + self.state = Spin::Free; + self.flash = 0.0; } - fn at(&self, offset: isize) -> &str { + /// Bring it down onto `row`, after `laps` more turns so the stop is watched rather than + /// noticed. Ignored unless the drum is actually free, which is what keeps a second call + /// from restarting a brake already under way. + pub fn brake_to(&mut self, row: usize, laps: f64) { + if self.state != Spin::Free { + return; + } + let n = self.items.len().max(1) as f64; + let ahead = (row as f64 - self.offset).rem_euclid(n); + self.from = self.offset; + self.dist = ahead + laps * n; + self.t = 0.0; + // Long enough for the eye to follow the last few rows in, and scaled by the distance + // so a long brake is not a slow one. + self.dur = (0.55 + self.dist / TOP_SPEED).min(2.2); + self.state = Spin::Braking; + } + + /// One frame of physics. True on the frame the drum comes to rest, which is the picker's + /// cue to read the row and fill the next reel. + pub fn tick(&mut self, dt: f64) -> bool { + let n = self.items.len().max(1) as f64; + self.flash = (self.flash - dt).max(0.0); + match self.state { + Spin::Rest => { + self.speed = REST_SPEED; + self.offset = (self.offset + self.speed * dt).rem_euclid(n); + false + } + Spin::Free => { + self.speed = (self.speed + SPIN_UP * dt).min(TOP_SPEED); + self.offset = (self.offset + self.speed * dt).rem_euclid(n); + false + } + Spin::Braking => { + self.t = (self.t + dt).min(self.dur); + let x = self.t / self.dur; + let before = self.offset; + self.offset = (self.from + self.dist * settle(x)).rem_euclid(n); + // Speed is what the drawing reads to decide whether the names are legible, + // so it is measured off the curve rather than carried alongside it. + self.speed = ((self.offset - before).rem_euclid(n) / dt.max(1e-6)).min(TOP_SPEED); + match self.t >= self.dur { + true => { + self.offset = self.offset.round().rem_euclid(n); + self.speed = 0.0; + self.state = Spin::Stopped; + self.flash = 0.45; + true + } + false => false, + } + } + Spin::Stopped => false, + } + } + + pub fn stopped(&self) -> bool { + self.state == Spin::Stopped + } + + /// The row on the payline, which is only meaningful once it has stopped. + pub fn row(&self) -> usize { + (self.offset.round() as isize).rem_euclid(self.items.len().max(1) as isize) as usize + } + + /// What sits `offset` rows off the payline, as the eye would see it: names when the drum + /// is slow enough to read, symbols when it is not. + fn face(&self, offset: isize) -> &str { if self.items.is_empty() { return ""; } - let n = self.items.len() as isize; - let i = (self.pos as isize + offset).rem_euclid(n) as usize; - &self.items[i] + let i = self.offset.floor() as isize + offset; + match self.speed > BLUR_SPEED { + true => SYMBOLS[i.rem_euclid(SYMBOLS.len() as isize) as usize], + false => &self.items[i.rem_euclid(self.items.len() as isize) as usize], + } } } +/// Eased landing with a bounce: fast in, then past the row and back onto it. `x` runs 0 to 1 +/// and the curve ends at exactly 1, which is what lets the drum stop on the chosen row. +fn settle(x: f64) -> f64 { + // A back-out curve. The overshoot is deliberately under a row, so the drum is seen to + // strain past its stop rather than to skip one. + const OVERSHOOT: f64 = 1.30; + let u = x - 1.0; + 1.0 + u * u * ((OVERSHOOT + 1.0) * u + OVERSHOOT) +} + /// What the machine is doing this frame. Drawing reads it, nothing else does. pub struct View<'a> { pub reels: &'a [Reel; 3], /// Frame counter. Drives the marquee, the blinking and the jackpot colours, so the /// animation needs no clock of its own. pub tick: usize, - /// 0 up, `LEVER_THROW` fully pulled. - pub lever: u8, + /// The handle, 0 up and 1 fully down. Fractional, because the pull and the slower return + /// stroke are both eased. + pub lever: f64, + /// How lit the cabinet is, 0 dark and 1 fully on. Runs once when the machine is opened. + pub boot: f64, /// Waiting for a pull. The handle pulses and the banner says how to reach it. pub idle: bool, pub jackpot: bool, @@ -109,23 +241,22 @@ pub struct View<'a> { /// one is trimmed. Below this the cabinet stops shrinking and lets the terminal cut it off, /// since a column three characters wide answers no question. const MIN_CELL: usize = 10; -/// Widest a reel gets. Past this the ids are long since fully readable and the machine is -/// only stretching, which reads as a bug rather than a bigger prize. -const MAX_CELL: usize = 34; +/// Widest a reel gets. Past a full model id and its suffix the column is only stretching, +/// and three columns that wide already fill a very large terminal. +const MAX_CELL: usize = 60; /// The right-hand margin the handle lives in, and the click target the picker reads back. -const MARGIN: usize = 7; +const MARGIN: usize = 9; /// Columns the cabinet spends on something other than the reels: two walls, and the four /// spaces framing and separating the three columns. const GUTTERS: usize = 6; /// Rows the cabinet spends on something other than the drum: sign, banner, titles, edges, /// status, marquees, borders, plus the payline itself. const CHROME: usize = 13; +/// Deepest drum. Past this the payline is so far from the edges that the eye loses it. +const MAX_REACH: isize = 16; /// Row the handle starts on. Level with the reel titles, so it stands beside the drum. const LEVER_TOP: usize = 5; -/// How far down the ball travels on a pull. Small enough that the shortest cabinet still has -/// rod above its plinth to run it down. -pub const LEVER_THROW: u8 = 3; /// The casino palette, cycled by the tick so the frame never sits still. const LIGHTS: [Color; 4] = [ @@ -163,7 +294,7 @@ impl Geo { let cell = (w.saturating_sub(GUTTERS + margin) / 3).clamp(MIN_CELL, MAX_CELL); // A taller terminal buys deeper drums, which is the one part of the machine that // reads better big: more symbols in flight, more of a spin. - let reach = ((area.height as isize - CHROME as isize) / 2).clamp(1, 4); + let reach = ((area.height as isize - CHROME as isize) / 2).clamp(1, MAX_REACH); Geo { cell, inner: cell * 3 + 4, @@ -174,9 +305,14 @@ impl Geo { } /// Total rows, which is also what the caller has to have to see the whole cabinet. - fn height(&self) -> usize { + pub fn height(&self) -> usize { CHROME + 2 * self.reach as usize } + + /// Rows the ball travels on a full pull: everything between its rest and the plinth. + fn throw(&self) -> usize { + self.lever_base.saturating_sub(LEVER_TOP + 2) + } } /// Draws the cabinet centred in `area` and answers with the handle's rectangle on screen, @@ -371,12 +507,30 @@ fn banner(v: &View, g: &Geo) -> Span<'static> { Span::styled(centred(text, g.inner), style) } -/// One line of the cabinet, with the lever drawn in the margin beside it. +/// One line of the cabinet, with the lever drawn in the margin beside it, dimmed while the +/// machine is still lighting up. fn row(index: usize, mut spans: Vec>, v: &View, g: &Geo) -> Line<'static> { spans.push(lever(index, v, g)); + if !lit(index, v, g) { + // Everything is drawn either way: the cabinet is there in the dark and the current + // reaches it, which is a machine warming up rather than a menu being assembled. + for s in spans.iter_mut() { + s.style = Style::new().fg(Color::DarkGray).add_modifier(Modifier::DIM); + } + } Line::from(spans) } +/// Power runs out from the payline, so the reels come up first and the frame last. +fn lit(index: usize, v: &View, g: &Geo) -> bool { + if v.boot >= 1.0 { + return true; + } + let middle = (g.height() / 2) as f64; + let dist = (index as f64 - middle).abs(); + dist <= v.boot * (middle + 1.0) +} + fn wall(c: Color) -> Span<'static> { Span::styled("║", Style::new().fg(c)) } @@ -419,19 +573,30 @@ fn band(v: &View, offset: isize, g: &Geo) -> Vec> { let mut spans = vec![wall(frame), arrow("▶")]; for r in v.reels { spans.push(Span::styled("│", Style::new().fg(Color::DarkGray))); - let text = fit(r.at(offset), g.cell - 2); - let style = match (lit, r.stopped, v.jackpot) { + let text = fit(r.face(offset), g.cell - 2); + let moving = r.speed > BLUR_SPEED; + let style = match (lit, r.stopped(), v.jackpot) { // Landed, and the machine has paid: the answer flashes. (true, true, true) => Style::new() .fg(LIGHTS[(v.tick / 2) % LIGHTS.len()]) .add_modifier(Modifier::BOLD | Modifier::REVERSED), + // The frames right after a drum hits its stop, so the eye is told which one just + // landed rather than having to find it. + (true, true, false) if r.flash > 0.0 => Style::new() + .fg(Color::White) + .add_modifier(Modifier::BOLD | Modifier::REVERSED), (true, true, false) => Style::new() .fg(Color::LightYellow) .add_modifier(Modifier::BOLD), - (true, false, _) => Style::new().fg(Color::White).add_modifier(Modifier::BOLD), - // Off the payline, and the further off the fainter: the drum falls away. - _ => match offset.abs() { - 1 => Style::new().fg(Color::Gray), + (true, false, _) => match moving { + true => Style::new().fg(Color::LightRed), + false => Style::new().fg(Color::White).add_modifier(Modifier::BOLD), + }, + // Off the payline, and the further off the fainter: the drum falls away. A + // moving drum fades harder, which is the smear the terminal cannot draw. + _ => match (offset.abs(), moving) { + (1, false) => Style::new().fg(Color::Gray), + (1, true) => Style::new().fg(Color::DarkGray), _ => Style::new().fg(Color::DarkGray).add_modifier(Modifier::DIM), }, }; @@ -458,33 +623,43 @@ fn marquee(tick: usize, frame: Color, g: &Geo) -> Span<'static> { Span::styled(s, Style::new().fg(frame)) } -/// The handle, in the right margin, the ball riding down the rod as it is pulled. Every row -/// of the margin is drawn, blank ones included, so the click target is a solid block. +/// The handle, in the right margin: a knob riding a rod down a track into its housing. Every +/// row of the margin is drawn, blank ones included, so the click target is a solid block. fn lever(index: usize, v: &View, g: &Geo) -> Span<'static> { if g.margin == 0 || !(LEVER_TOP..=g.lever_base).contains(&index) { return Span::raw(""); } - let ball = LEVER_TOP + v.lever as usize; + let knob = LEVER_TOP + (v.lever.clamp(0.0, 1.0) * g.throw() as f64).round() as usize; + let pulling = v.lever > 0.05; let (art, style) = match index { + // The housing the rod disappears into, bolted to the side of the cabinet. i if i == g.lever_base => ( - " ▐███▌ ", + " ▐█████▌ ", Style::new() .fg(Color::DarkGray) .add_modifier(Modifier::BOLD), ), - i if i == g.lever_base - 1 => (" ▄▄▄ ", Style::new().fg(Color::DarkGray)), - i if i == ball => ( - " (O) ", + i if i == g.lever_base - 1 => (" ▄▄▄▄▄ ", Style::new().fg(Color::Gray)), + i if i == knob => ( + " ((◉)) ", Style::new() - .fg(match v.idle && (v.tick / 5) % 2 == 1 { + .fg(match (pulling, v.idle && (v.tick / 5) % 2 == 1) { + // Under the hand, so it goes hot. + (true, _) => Color::LightYellow, // Idle, so it pulses: the one thing on screen asking to be touched. - true => Color::LightYellow, - false => Color::LightRed, + (false, true) => Color::LightRed, + (false, false) => Color::Red, }) .add_modifier(Modifier::BOLD), ), - i if i > ball => (" ║ ", Style::new().fg(Color::Gray)), - _ => (" ", Style::new()), + // The rod, thicker just under the knob so the handle has a direction. + i if i == knob + 1 => (" ┃ ", Style::new().fg(Color::White)), + i if i > knob => (" ┃ ", Style::new().fg(Color::Gray)), + // The track above it, which is what says how far the thing still has to travel. + _ => ( + " ┊ ", + Style::new().fg(Color::DarkGray).add_modifier(Modifier::DIM), + ), }; Span::styled(art.to_string(), style) } @@ -524,19 +699,17 @@ mod tests { use ratatui::backend::TestBackend; fn reel(items: &[&str]) -> Reel { - Reel { - title: "harness", - items: items.iter().map(|s| s.to_string()).collect(), - pos: 0, - stopped: true, - } + let mut r = Reel::teaser("harness"); + r.load(items.iter().map(|s| s.to_string()).collect()); + r } fn view(reels: &[Reel; 3], idle: bool, jackpot: bool) -> View<'_> { View { reels, tick: 3, - lever: 2, + lever: 0.5, + boot: 1.0, idle, jackpot, status: "JACKPOT".into(), @@ -557,6 +730,7 @@ mod tests { (100, 24), (120, 40), (200, 60), + (400, 100), ] } @@ -586,19 +760,33 @@ mod tests { #[test] fn a_wider_terminal_buys_wider_reels() { assert!(geo(200, 40).cell > geo(80, 24).cell); - assert!(geo(200, 40).reach >= geo(80, 24).reach); - assert_eq!( - geo(200, 40).cell, - MAX_CELL, - "the cabinet has to stop somewhere" - ); + assert!(geo(200, 60).reach > geo(80, 24).reach); // Too narrow for the handle: the reels keep the room and the space bar takes over. assert_eq!(geo(34, 20).margin, 0); assert!(geo(100, 24).margin > 0); } - /// The handle has to fill the rectangle the click handler is handed, at every throw and - /// in every cabinet tall enough to carry one. + /// A wide terminal has to be filled, not decorated with a small machine in the middle. + #[test] + fn a_big_terminal_is_mostly_cabinet() { + for (w, h) in [(120u16, 30u16), (160, 45), (200, 50)] { + let g = geo(w, h); + let used = g.inner + 2 + g.margin; + assert!( + used + 6 >= w as usize, + "at {w}x{h} the cabinet is {used} wide and leaves {} columns empty", + w as usize - used + ); + assert!( + g.height() * 4 >= h as usize * 3, + "at {w}x{h} the cabinet is only {} rows tall", + g.height() + ); + } + } + + /// The handle has to fill the rectangle the click handler is handed, at every point of + /// its travel and in every cabinet tall enough to carry one. #[test] fn the_handle_covers_its_whole_click_target() { let reels = [reel(&["x"]), reel(&["y"]), reel(&["z"])]; @@ -607,9 +795,9 @@ mod tests { if g.margin == 0 { continue; } - for throw in 0..=LEVER_THROW { + for step in 0..=10 { let mut v = view(&reels, true, false); - v.lever = throw; + v.lever = step as f64 / 10.0; for index in LEVER_TOP..=g.lever_base { assert_eq!( lever(index, &v, &g).content.chars().count(), @@ -621,34 +809,92 @@ mod tests { } } - /// The ball must never reach the plinth, however short the cabinet is. + /// The knob must never reach the housing, however short the cabinet is. #[test] - fn the_ball_stays_on_its_rod() { + fn the_knob_stays_on_its_rod() { for (w, h) in sizes() { let g = geo(w, h); assert!( - LEVER_TOP + LEVER_THROW as usize <= g.lever_base - 2, + g.throw() >= 1, "at {w}x{h} the handle has no rod left to run down" ); + assert!(LEVER_TOP + g.throw() <= g.lever_base - 2); } } + /// A spin has to wind up, blur, and come to rest exactly on the row it was given. + #[test] + fn a_braked_reel_lands_on_its_row() { + for row in 0..5 { + let mut r = reel(&["a", "b", "c", "d", "e"]); + r.kick(); + for _ in 0..40 { + r.tick(1.0 / 30.0); + } + assert!(r.speed > BLUR_SPEED, "the drum never reached full speed"); + r.brake_to(row, 2.0); + let mut frames = 0; + while !r.tick(1.0 / 30.0) { + frames += 1; + assert!(frames < 300, "the drum never came to rest"); + } + assert_eq!(r.row(), row); + assert!(r.stopped()); + // And it stays put once it has: a stopped drum is an answer. + let before = r.offset; + r.tick(1.0 / 30.0); + assert_eq!(r.offset, before); + } + } + + /// The landing curve overshoots, which is the bounce, but never by a whole row and it + /// always comes back to exactly where it was sent. + #[test] + fn the_landing_bounces_without_skipping_a_row() { + assert!(settle(0.0).abs() < 1e-9); + assert!((settle(1.0) - 1.0).abs() < 1e-9); + let peak = (0..=100) + .map(|i| settle(i as f64 / 100.0)) + .fold(0.0f64, f64::max); + assert!(peak > 1.0, "no overshoot, so no bounce"); + assert!(peak < 1.08, "the bounce is worth more than a whole row"); + } + + /// Refilling a turning drum keeps it turning, since the provider reel is loaded mid-spin + /// from the harness that has just landed. + #[test] + fn a_reel_refilled_mid_spin_keeps_its_motion() { + let mut r = reel(&["a", "b"]); + r.kick(); + for _ in 0..20 { + r.tick(1.0 / 30.0); + } + let speed = r.speed; + r.load(vec!["one".into(), "two".into(), "three".into()]); + assert_eq!(r.speed, speed); + assert!(r.offset < 3.0, "the position was left out of range"); + assert!(!r.stopped()); + } + /// A terminal smaller than the cabinet must still render something rather than panic. #[test] fn draws_into_any_terminal() { let reels = [reel(&["x"]), reel(&["y"]), reel(&["z"])]; - let v = view(&reels, true, false); for (w, h) in sizes() { - let mut terminal = Terminal::new(TestBackend::new(w, h)).unwrap(); - terminal - .draw(|f| { - let handle = draw(f, f.area(), &v); - // Clamped into the frame, so a click is never tested against a rectangle - // hanging off the screen. - assert!(handle.right() <= f.area().right()); - assert!(handle.bottom() <= f.area().bottom()); - }) - .expect("a small terminal must not stop the machine"); + for boot in [0.0, 0.4, 1.0] { + let mut v = view(&reels, true, false); + v.boot = boot; + let mut terminal = Terminal::new(TestBackend::new(w, h)).unwrap(); + terminal + .draw(|f| { + let handle = draw(f, f.area(), &v); + // Clamped into the frame, so a click is never tested against a + // rectangle hanging off the screen. + assert!(handle.right() <= f.area().right()); + assert!(handle.bottom() <= f.area().bottom()); + }) + .expect("a small terminal must not stop the machine"); + } } } diff --git a/src/tui.rs b/src/tui.rs index 2ef3df4..e858a08 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -555,30 +555,54 @@ enum Roll { /// Nothing turns until the handle is pulled, with space or a click on it: the picker is /// about to answer three questions for the user, so it waits to be told to. /// -/// The reels then stop left to right because each answer narrows the next: the provider -/// reel is filled from the harness that just landed, and the model reel turns on nothing -/// until the catalogue lookup for that provider comes back. So the wait for the network is -/// the animation rather than a spinner in front of it. +/// All three drums then go at once and stop left to right, because each answer narrows the +/// next: the provider reel is refilled while it is still turning, from the harness that has +/// just landed, and the model reel runs on casino symbols until the catalogue lookup for +/// that provider comes back. So the wait for the network is the animation rather than a +/// spinner in front of it. +/// +/// The loop keeps no reel state of its own beyond which drum is due to stop next: the +/// physics lives in `slots`, and what a landing means lives in `App`. fn play_slots(terminal: &mut DefaultTerminal, app: &mut App) -> Result { - const FRAME: Duration = Duration::from_millis(45); - /// Long enough that a reel reads as spinning rather than as a value being replaced. - const MIN_SPIN: Duration = Duration::from_millis(900); + /// 30 frames a second. The drums cross two dozen rows a second at full tilt, which needs + /// the frames to keep the motion continuous rather than steppy. + const FRAME: Duration = Duration::from_millis(33); /// The same ceiling the command-line path puts on a catalogue lookup. A reel that turns /// for ever is a menu with no way out. const PATIENCE: Duration = Duration::from_secs(20); - /// Milliseconds the ball spends on each row of its throw. - const THROW_STEP: u128 = 90; - /// Waiting to be played, the handle coming down, one stage per reel, then the payout. - const IDLE: usize = 0; - const PULL: usize = 1; - const FIRST_REEL: usize = 2; - const PAID: usize = 5; + /// Seconds the cabinet takes to light up when the machine is opened. + const BOOT: f64 = 0.7; + /// The pull: slammed down, held, then let back up at its own pace. + const DOWN: f64 = 0.16; + const HOLD: f64 = 0.10; + const UP: f64 = 0.55; + /// How long the first drum runs before it is allowed to brake, and the gap between one + /// drum stopping and the next being told to. + const FIRST_RUN: f64 = 1.1; + const GAP: f64 = 0.45; + /// Turns a drum still has to make once it knows where it is going, so the stop is + /// watched rather than noticed. + const LAPS: f64 = 2.0; if app.harness_rows.is_empty() { app.notice = Some("nothing installed to gamble on".into()); return Ok(Roll::Back); } + /// Where the handle is, from how long ago it was pulled: down fast under the hand, then + /// eased back up on its spring. + fn lever_at(t: f64) -> f64 { + const DOWN: f64 = 0.16; + const HOLD: f64 = 0.10; + const UP: f64 = 0.55; + match t { + t if t < DOWN => (t / DOWN).powi(2), + t if t < DOWN + HOLD => 1.0, + t if t < DOWN + HOLD + UP => (1.0 - (t - DOWN - HOLD) / UP).powi(3), + _ => 0.0, + } + } + let mut rng = Rng::new(); // Kept, because a second pull rebuilds the reels from scratch and the harness list is // the one thing no landing can change. @@ -595,14 +619,21 @@ fn play_slots(terminal: &mut DefaultTerminal, app: &mut App) -> Result { ]; reels[0].load(harnesses.clone()); - let mut stage: usize = IDLE; let mut tick: usize = 0; - let mut lever: u8 = 0; - let mut began = Instant::now(); - let mut target: Option = None; + let opened = Instant::now(); + let mut last = Instant::now(); + // None until the handle is touched, then seconds since, which drives both the handle and + // the moment the drums are let go. + let mut pulled_at: Option = None; + // The drum due to stop next, and 3 once the machine has paid. + let mut stopping: usize = 0; + let mut spinning = false; + let mut since_stop = 0.0f64; + let mut run = 0.0f64; + let mut waited = Instant::now(); let mut status = String::from("pull the handle space, or click it"); // The model reel is filled once, mid-spin, when the catalogue lands. A count would not - // do: a provider serving three models is indistinguishable from the placeholders. + // do: a provider serving three models is indistinguishable from the symbols. let mut model_reel_filled = false; // Where the last frame put the handle. Only the draw knows, since the cabinet is // centred in whatever the terminal happens to be. @@ -610,29 +641,13 @@ fn play_slots(terminal: &mut DefaultTerminal, app: &mut App) -> Result { loop { app.poll_models(); + let dt = last.elapsed().as_secs_f64().min(0.1); + last = Instant::now(); - // The model reel has nothing to turn on until the provider has answered, so it - // keeps its placeholders and the lookup fills it in mid-spin. - if stage == FIRST_REEL + 2 && !model_reel_filled && !app.loading() { - if app.visible_models.is_empty() { - app.notice = Some("the machine came up empty, that provider listed nothing".into()); - return Ok(Roll::Back); - } - reels[2].load( - app.visible_models - .iter() - .filter_map(|&i| app.models.get(i)) - .map(|l| l.model.display().to_string()) - .collect(), - ); - model_reel_filled = true; - began = Instant::now(); - target = None; - status = "model...".into(); - } - - let jackpot = stage == PAID; - let idle = stage == IDLE; + let jackpot = stopping == 3; + let idle = pulled_at.is_none(); + let lever = pulled_at.map(lever_at).unwrap_or(0.0); + let boot = (opened.elapsed().as_secs_f64() / BOOT).min(1.0); terminal.draw(|f| { handle = slots::draw( f, @@ -641,6 +656,7 @@ fn play_slots(terminal: &mut DefaultTerminal, app: &mut App) -> Result { reels: &reels, tick, lever, + boot, idle, jackpot, status: status.clone(), @@ -648,7 +664,7 @@ fn play_slots(terminal: &mut DefaultTerminal, app: &mut App) -> Result { ) })?; - let mut pulled = false; + let mut pull = false; if event::poll(FRAME)? { match event::read()? { Event::Key(k) if k.kind == KeyEventKind::Press => { @@ -661,7 +677,7 @@ fn play_slots(terminal: &mut DefaultTerminal, app: &mut App) -> Result { app.notice = Some("no bet taken".into()); return Ok(Roll::Back); } - KeyCode::Char(' ') => pulled = true, + KeyCode::Char(' ') => pull = true, // Only pays once every reel has landed, so an early Enter is the // impatience it looks like and not a launch of half a choice. KeyCode::Enter if jackpot => { @@ -674,7 +690,7 @@ fn play_slots(terminal: &mut DefaultTerminal, app: &mut App) -> Result { } } Event::Mouse(m) if m.kind == MouseEventKind::Down(MouseButton::Left) => { - pulled = handle.width > 0 + pull = handle.width > 0 && m.column >= handle.x && m.column < handle.x + handle.width && m.row >= handle.y @@ -684,9 +700,9 @@ fn play_slots(terminal: &mut DefaultTerminal, app: &mut App) -> Result { } } - // A pull on a machine that has already paid is a re-roll: the reels go back to - // their placeholders and everything it decided is up for grabs again. - if pulled && (stage == IDLE || stage == PAID) { + // A pull on a machine that has already paid is a re-roll: the drums go back to their + // symbols and everything it decided is up for grabs again. + if pull && (idle || jackpot) { reels = [ Reel::teaser("harness"), Reel::teaser("provider"), @@ -694,101 +710,125 @@ fn play_slots(terminal: &mut DefaultTerminal, app: &mut App) -> Result { ]; reels[0].load(harnesses.clone()); model_reel_filled = false; - target = None; - lever = 0; - stage = PULL; - began = Instant::now(); + stopping = 0; + spinning = false; + run = 0.0; + pulled_at = Some(0.0); status = String::new(); } tick = tick.wrapping_add(1); - - match stage { - // Loose drums, drifting. Enough movement to say the thing is switched on, slow - // enough that nobody mistakes it for a roll already under way. - IDLE => { - if tick.is_multiple_of(6) { - for r in reels.iter_mut() { - let len = r.items.len().max(1); - r.pos = (r.pos + 1) % len; - } + if let Some(t) = pulled_at.as_mut() { + *t += dt; + // The drums are let go at the bottom of the throw, not when the handle is + // touched: the machine answers the pull, it does not anticipate it. + if !spinning && *t >= DOWN && stopping < 3 { + for r in reels.iter_mut() { + r.kick(); } + spinning = true; + run = 0.0; + waited = Instant::now(); + status = "no going back".into(); + } + if *t > DOWN + HOLD + UP && !spinning { + pulled_at = None; + } + } + + // The model reel has nothing to turn on until the provider has answered, so it keeps + // its symbols and the lookup fills it in mid-spin. + if stopping == 2 && !model_reel_filled && !app.loading() { + if app.visible_models.is_empty() { + app.notice = Some("the machine came up empty, that provider listed nothing".into()); + return Ok(Roll::Back); + } + reels[2].load( + app.visible_models + .iter() + .filter_map(|&i| app.models.get(i)) + .map(|l| l.model.display().to_string()) + .collect(), + ); + model_reel_filled = true; + } + + run += dt; + since_stop += dt; + let mut landed = None; + for (i, r) in reels.iter_mut().enumerate() { + if r.tick(dt) { + landed = Some(i); } - // The handle coming down, then the first reel goes. - PULL => { - lever = (began.elapsed().as_millis() / THROW_STEP).min(slots::LEVER_THROW as u128) - as u8; - if began.elapsed().as_millis() > THROW_STEP * (slots::LEVER_THROW as u128 + 1) { - stage = FIRST_REEL; - began = Instant::now(); - status = "harness...".into(); + } + + if spinning && stopping < 3 { + // The next drum is told where to stop once it has been seen to run, and once + // whatever fills it has answered. + let ready = match stopping { + 0 => run >= FIRST_RUN, + 2 => since_stop >= GAP && model_reel_filled, + _ => since_stop >= GAP, + }; + if stopping == 2 && !model_reel_filled { + status = format!( + "asking {} what it serves...", + app.provider().map(|p| p.name.as_str()).unwrap_or("") + ); + if waited.elapsed() > PATIENCE { + app.notice = Some("the catalogue never answered, so nothing was rolled".into()); + return Ok(Roll::Back); } } - s if (FIRST_REEL..PAID).contains(&s) => { - let i = stage - FIRST_REEL; - let waiting = i == 2 && app.loading(); - if waiting { - status = format!( - "asking {} what it serves...", - app.provider().map(|p| p.name.as_str()).unwrap_or("") - ); - if began.elapsed() > PATIENCE { - app.notice = - Some("the catalogue never answered, so nothing was rolled".into()); + if ready { + let n = reels[stopping].items.len(); + let row = rng.below(n); + reels[stopping].brake_to(row, LAPS); + } + } + + // A drum coming to rest is what advances the machine: the row it stopped on is the + // answer, and filling the next reel is the question it asks. + if landed == Some(stopping) { + let row = reels[stopping].row(); + since_stop = 0.0; + match stopping { + 0 => { + app.harness_row = row; + app.provider_row = 0; + app.rebuild_providers(); + if app.provider_rows.is_empty() { + app.notice = Some("that harness has no provider to gamble on".into()); return Ok(Roll::Back); } + reels[1].load( + app.provider_rows + .iter() + .filter_map(|&p| app.cfg.providers.get(p)) + .map(|p| p.name.clone()) + .collect(), + ); + status = "provider...".into(); } - - let len = reels[i].items.len().max(1); - reels[i].pos = (reels[i].pos + 1) % len; - if target.is_none() && !waiting { - target = Some(rng.below(len)); + 1 => { + app.provider_row = row; + // `--key` narrowed an answer the user gave; the machine is answering for + // them, so every key of the site is in play. + app.only_key = None; + app.set_screen(Screen::Model); + app.load_models(false); + waited = Instant::now(); + status = "model...".into(); } - - let landed = target == Some(reels[i].pos) && began.elapsed() >= MIN_SPIN; - if landed && !waiting { - reels[i].stopped = true; - let row = reels[i].pos; - target = None; - began = Instant::now(); - match i { - 0 => { - app.harness_row = row; - app.provider_row = 0; - app.rebuild_providers(); - if app.provider_rows.is_empty() { - app.notice = - Some("that harness has no provider to gamble on".into()); - return Ok(Roll::Back); - } - reels[1].load( - app.provider_rows - .iter() - .filter_map(|&p| app.cfg.providers.get(p)) - .map(|p| p.name.clone()) - .collect(), - ); - status = "provider...".into(); - } - 1 => { - app.provider_row = row; - // `--key` narrowed an answer the user gave; the machine is - // answering for them, so every key of the site is in play. - app.only_key = None; - app.set_screen(Screen::Model); - app.load_models(false); - status = "model...".into(); - } - _ => { - app.model_idx = row; - app.ensure_options(); - status = "JACKPOT enter launch space reroll esc back".into(); - } - } - stage += 1; + _ => { + app.model_idx = row; + app.ensure_options(); + spinning = false; + pulled_at = None; + status = "JACKPOT enter launch space reroll esc back".into(); } } - _ => {} + stopping += 1; } } } From e9d7502d49e451993b76575ce9ea00a0f2405897 Mon Sep 17 00:00:00 2001 From: meetsu <96637888+klNuno@users.noreply.github.com> Date: Tue, 18 Aug 2026 23:54:15 +0200 Subject: [PATCH 5/5] fix(tui): a shorter cabinet, and reels you can still read while they run `MAX_REACH` 3 and `MAX_CELL` 30: at 140x34 the machine is nineteen rows tall and centred, with room left around it. `Reel::face` no longer swaps names for casino symbols at speed. the swap hid the only thing worth watching, the answers going past. `TOP_SPEED` drops to eleven rows a second and the payline runs cyan, white on landing. `brake_to` now arms rather than brakes: `Spin::Arming` holds full speed until the row is `BRAKE_ROWS` away, then `land` times the curve off `ENTRY_SLOPE` so it leaves at the speed the drum was turning. list length no longer changes the landing, and the brake no longer opens with a lurch. --- src/slots.rs | 135 ++++++++++++++++++++++++++++++++++----------------- src/tui.rs | 5 +- 2 files changed, 91 insertions(+), 49 deletions(-) diff --git a/src/slots.rs b/src/slots.rs index 7d0f029..608fce3 100644 --- a/src/slots.rs +++ b/src/slots.rs @@ -59,23 +59,33 @@ enum Spin { Rest, /// Winding up to full speed, and holding there until told where to stop. Free, + /// Told where to stop, still running: it holds full speed until its row comes round + /// close enough that the brake can take it without the drum lurching. + Arming, /// Easing into the row it was given, overshooting it and settling back. Braking, Stopped, } -/// Rows a drum crosses per second at full tilt. Fast enough that the names blur into -/// symbols, slow enough that the eye still reads it as a wheel and not as noise. -const TOP_SPEED: f64 = 24.0; +/// Rows a drum crosses per second at full tilt. Slow enough that a name on the payline is +/// still read as a name: a machine spinning too fast to see what is on it is a progress bar. +const TOP_SPEED: f64 = 11.0; /// Rows per second it drifts at while waiting to be played. const REST_SPEED: f64 = 1.1; /// Rows per second squared on the way up. A drum that reaches full speed instantly has no /// weight, and weight is most of what makes a spin look real. -const SPIN_UP: f64 = 46.0; -/// Above this the drum shows casino symbols instead of names: nothing is readable at speed, -/// and pretending otherwise is what made the old machine look like a list being replaced. -const BLUR_SPEED: f64 = 9.0; -/// What a blurred drum shows. Three cells wide at most, so a narrow cabinet keeps them. +const SPIN_UP: f64 = 26.0; +/// Above this the drum counts as running, which only changes how it is coloured. It never +/// changes what it shows: the whole point of turning is watching the names go by. +const BLUR_SPEED: f64 = 5.0; +/// Rows the landing runs over. Six is about a second at full tilt: long enough to follow +/// the last names in, short enough that three drums stopping is not a wait. +const BRAKE_ROWS: f64 = 6.0; +/// How much faster than its average a `settle` curve leaves, which is fixed by its shape. +/// The landing is timed against it so the drum enters the brake at the speed it was turning. +const ENTRY_SLOPE: f64 = 3.35; +/// What a reel with no answer yet turns on. Three cells wide at most, so a narrow cabinet +/// keeps them. const SYMBOLS: [&str; 6] = ["7 7 7", "$ $ $", "* * *", "B A R", "- - -", "$ 7 $"]; /// One column of the machine. `items` is what it can land on, `offset` where it is right @@ -93,6 +103,8 @@ pub struct Reel { dist: f64, t: f64, dur: f64, + /// The row it has been told to land on, once it has been told. + target: usize, /// Seconds of landing flash left. The drum hitting its stop is the payout of the spin. flash: f64, } @@ -108,6 +120,7 @@ impl Reel { state: Spin::Rest, from: 0.0, dist: 0.0, + target: 0, t: 0.0, dur: 0.0, flash: 0.0, @@ -130,21 +143,32 @@ impl Reel { self.flash = 0.0; } - /// Bring it down onto `row`, after `laps` more turns so the stop is watched rather than - /// noticed. Ignored unless the drum is actually free, which is what keeps a second call - /// from restarting a brake already under way. - pub fn brake_to(&mut self, row: usize, laps: f64) { + /// Tell it where to land. It keeps running until its row is `BRAKE_ROWS` away and only + /// then eases in, so the stop is always the same length whatever the list holds and the + /// drum never has to jump to cover the distance. Ignored unless the drum is actually + /// free, which is what keeps a second call from restarting a stop already under way. + pub fn brake_to(&mut self, row: usize) { if self.state != Spin::Free { return; } + self.target = row; + self.state = Spin::Arming; + } + + /// Start the landing from wherever the drum is now. The travel is padded with whole + /// turns until it is long enough to be seen, which matters on a three-row reel. + fn land(&mut self) { let n = self.items.len().max(1) as f64; - let ahead = (row as f64 - self.offset).rem_euclid(n); + let mut dist = (self.target as f64 - self.offset).rem_euclid(n); + while dist < BRAKE_ROWS { + dist += n; + } self.from = self.offset; - self.dist = ahead + laps * n; + self.dist = dist; self.t = 0.0; - // Long enough for the eye to follow the last few rows in, and scaled by the distance - // so a long brake is not a slow one. - self.dur = (0.55 + self.dist / TOP_SPEED).min(2.2); + // Timed so the curve leaves at the speed the drum is already turning: any shorter + // and the brake starts with a lurch, which is the one thing a heavy wheel never does. + self.dur = dist * ENTRY_SLOPE / TOP_SPEED; self.state = Spin::Braking; } @@ -164,6 +188,18 @@ impl Reel { self.offset = (self.offset + self.speed * dt).rem_euclid(n); false } + Spin::Arming => { + self.speed = (self.speed + SPIN_UP * dt).min(TOP_SPEED); + self.offset = (self.offset + self.speed * dt).rem_euclid(n); + // Only once it is at speed, otherwise a drum told where to go on its first + // frame would land before it ever looked like it was turning. + if self.speed >= TOP_SPEED + && (self.target as f64 - self.offset).rem_euclid(n) <= BRAKE_ROWS + { + self.land(); + } + false + } Spin::Braking => { self.t = (self.t + dt).min(self.dur); let x = self.t / self.dur; @@ -196,17 +232,15 @@ impl Reel { (self.offset.round() as isize).rem_euclid(self.items.len().max(1) as isize) as usize } - /// What sits `offset` rows off the payline, as the eye would see it: names when the drum - /// is slow enough to read, symbols when it is not. + /// What sits `offset` rows off the payline. Always the drum's own items, at rest and at + /// speed alike: swapping in symbols while it runs hides the one thing worth watching, + /// which is the list of answers going past. fn face(&self, offset: isize) -> &str { if self.items.is_empty() { return ""; } let i = self.offset.floor() as isize + offset; - match self.speed > BLUR_SPEED { - true => SYMBOLS[i.rem_euclid(SYMBOLS.len() as isize) as usize], - false => &self.items[i.rem_euclid(self.items.len() as isize) as usize], - } + &self.items[i.rem_euclid(self.items.len() as isize) as usize] } } @@ -215,7 +249,7 @@ impl Reel { fn settle(x: f64) -> f64 { // A back-out curve. The overshoot is deliberately under a row, so the drum is seen to // strain past its stop rather than to skip one. - const OVERSHOOT: f64 = 1.30; + const OVERSHOOT: f64 = ENTRY_SLOPE - 3.0; let u = x - 1.0; 1.0 + u * u * ((OVERSHOOT + 1.0) * u + OVERSHOOT) } @@ -241,9 +275,9 @@ pub struct View<'a> { /// one is trimmed. Below this the cabinet stops shrinking and lets the terminal cut it off, /// since a column three characters wide answers no question. const MIN_CELL: usize = 10; -/// Widest a reel gets. Past a full model id and its suffix the column is only stretching, -/// and three columns that wide already fill a very large terminal. -const MAX_CELL: usize = 60; +/// Widest a reel gets. Long model ids fit well before this; past it the column is only +/// stretching, and a cabinet wider than the eye can cross reads as a wall, not a machine. +const MAX_CELL: usize = 30; /// The right-hand margin the handle lives in, and the click target the picker reads back. const MARGIN: usize = 9; /// Columns the cabinet spends on something other than the reels: two walls, and the four @@ -252,8 +286,9 @@ const GUTTERS: usize = 6; /// Rows the cabinet spends on something other than the drum: sign, banner, titles, edges, /// status, marquees, borders, plus the payline itself. const CHROME: usize = 13; -/// Deepest drum. Past this the payline is so far from the edges that the eye loses it. -const MAX_REACH: isize = 16; +/// Deepest drum. Three rows either side of the payline is enough to read as a wheel; past +/// that the machine grows tall without showing more, and the payline drifts off centre. +const MAX_REACH: isize = 3; /// Row the handle starts on. Level with the reel titles, so it stands beside the drum. const LEVER_TOP: usize = 5; @@ -588,16 +623,18 @@ fn band(v: &View, offset: isize, g: &Geo) -> Vec> { (true, true, false) => Style::new() .fg(Color::LightYellow) .add_modifier(Modifier::BOLD), + // Running, and still to be read: bright enough that a name crossing the payline + // registers, dimmer than a landed one so a stop is unmistakable. (true, false, _) => match moving { - true => Style::new().fg(Color::LightRed), + true => Style::new() + .fg(Color::LightCyan) + .add_modifier(Modifier::BOLD), false => Style::new().fg(Color::White).add_modifier(Modifier::BOLD), }, - // Off the payline, and the further off the fainter: the drum falls away. A - // moving drum fades harder, which is the smear the terminal cannot draw. - _ => match (offset.abs(), moving) { - (1, false) => Style::new().fg(Color::Gray), - (1, true) => Style::new().fg(Color::DarkGray), - _ => Style::new().fg(Color::DarkGray).add_modifier(Modifier::DIM), + // Off the payline, and the further off the fainter: the drum falls away. + _ => match offset.abs() { + 1 => Style::new().fg(Color::Gray), + _ => Style::new().fg(Color::DarkGray), }, }; spans.push(Span::styled(text, style)); @@ -760,26 +797,34 @@ mod tests { #[test] fn a_wider_terminal_buys_wider_reels() { assert!(geo(200, 40).cell > geo(80, 24).cell); - assert!(geo(200, 60).reach > geo(80, 24).reach); + // Height is the exception: the drum stops deepening once it reads as a wheel, so a + // tall terminal keeps its room instead of the machine taking all of it. + assert_eq!(geo(200, 60).reach, geo(80, 24).reach); + assert!(geo(80, 16).reach < geo(80, 24).reach); // Too narrow for the handle: the reels keep the room and the space bar takes over. assert_eq!(geo(34, 20).margin, 0); assert!(geo(100, 24).margin > 0); } - /// A wide terminal has to be filled, not decorated with a small machine in the middle. + /// A cabinet that grows without bound stops being a machine: it has to stay a thing + /// sitting in the terminal, wide enough for the longest id and no taller than a glance. #[test] - fn a_big_terminal_is_mostly_cabinet() { + fn a_big_terminal_does_not_get_swallowed() { for (w, h) in [(120u16, 30u16), (160, 45), (200, 50)] { let g = geo(w, h); let used = g.inner + 2 + g.margin; assert!( - used + 6 >= w as usize, - "at {w}x{h} the cabinet is {used} wide and leaves {} columns empty", - w as usize - used + used <= w as usize, + "at {w}x{h} the cabinet is {used} wide and does not fit" + ); + assert!( + g.cell >= 30, + "at {w}x{h} a reel is only {} wide, too narrow for a model id", + g.cell ); assert!( - g.height() * 4 >= h as usize * 3, - "at {w}x{h} the cabinet is only {} rows tall", + g.height() * 2 <= h as usize + 8, + "at {w}x{h} the cabinet is {} rows tall, which is most of the screen", g.height() ); } @@ -832,7 +877,7 @@ mod tests { r.tick(1.0 / 30.0); } assert!(r.speed > BLUR_SPEED, "the drum never reached full speed"); - r.brake_to(row, 2.0); + r.brake_to(row); let mut frames = 0; while !r.tick(1.0 / 30.0) { frames += 1; diff --git a/src/tui.rs b/src/tui.rs index e858a08..daf4c33 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -580,9 +580,6 @@ fn play_slots(terminal: &mut DefaultTerminal, app: &mut App) -> Result { /// drum stopping and the next being told to. const FIRST_RUN: f64 = 1.1; const GAP: f64 = 0.45; - /// Turns a drum still has to make once it knows where it is going, so the stop is - /// watched rather than noticed. - const LAPS: f64 = 2.0; if app.harness_rows.is_empty() { app.notice = Some("nothing installed to gamble on".into()); @@ -783,7 +780,7 @@ fn play_slots(terminal: &mut DefaultTerminal, app: &mut App) -> Result { if ready { let n = reels[stopping].items.len(); let row = rng.below(n); - reels[stopping].brake_to(row, LAPS); + reels[stopping].brake_to(row); } }