Skip to main content

kvas_engine/
canvas.rs

1//! Immediate-mode game interface for scripts: every frame a script describes what is on screen
2//! (panels, text in any font, images, buttons, sliders, toggles) and the shell paints it over the
3//! game. Clicks and slider changes come back on the next frame, so menus are plain `if`s:
4//!
5//! ```
6//! use kvas_engine::canvas::{Canvas, Align};
7//! let ui = Canvas::default();
8//! ui.panel([0.0, 0.0, 1280.0, 720.0], [0.0, 0.0, 0.0, 0.6], 0.0);
9//! ui.text("LAST SERVICE", [640.0, 200.0], 64.0, [1.0; 4], "fonts/terminal.ttf", Align::Center);
10//! if ui.button("new", [540.0, 360.0, 200.0, 44.0], "NEW SHIFT") { /* start */ }
11//! assert_eq!(ui.commands().len(), 3);
12//! ```
13//!
14//! Coordinates are in a virtual screen 720 units high whose width follows the window's aspect
15//! ratio (`ctx.ui_width()`); `[x, y, w, h]` rectangles start at the top-left corner.
16
17use serde::{Deserialize, Serialize};
18use std::cell::{Cell, RefCell};
19
20/// Height of the virtual interface screen.
21pub const UI_HEIGHT: f32 = 720.0;
22
23/// Horizontal text alignment.
24#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
25pub enum Align {
26    /// The x position is where the text starts.
27    #[default]
28    Left,
29    /// The x position is the middle of the text.
30    Center,
31    /// The x position is where the text ends.
32    Right,
33}
34
35/// One drawing command of the interface (fields as in the matching `Canvas` methods).
36#[allow(missing_docs)]
37#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
38pub enum UiCmd {
39    /// Filled rounded rectangle, optional outline.
40    Panel { rect: [f32; 4], color: [f32; 4], radius: f32, outline: [f32; 4] },
41    /// Text at a point (the point is the top of the line; alignment around x).
42    Text { pos: [f32; 2], text: String, size: f32, color: [f32; 4], font: String, align: Align, wrap: f32 },
43    /// An image from the assets, tinted.
44    Image { rect: [f32; 4], path: String, tint: [f32; 4] },
45    /// Clickable button.
46    Button { id: String, rect: [f32; 4], text: String, style: ButtonStyle },
47    /// Horizontal slider for a 0..1 value.
48    Slider { id: String, rect: [f32; 4], value: f32, style: ButtonStyle },
49}
50
51/// Look of buttons and sliders.
52#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
53pub struct ButtonStyle {
54    /// Font asset (`.ttf` / `.otf`), empty = the default font.
55    pub font: String,
56    /// Text size in interface units.
57    pub size: f32,
58    /// Text colour.
59    pub text: [f32; 4],
60    /// Background colour.
61    pub background: [f32; 4],
62    /// Background while the pointer is over it (also the slider fill).
63    pub hover: [f32; 4],
64    /// Outline colour (alpha 0 = none).
65    pub outline: [f32; 4],
66    /// Corner radius.
67    pub radius: f32,
68    /// Text alignment inside the button.
69    pub align: Align,
70}
71
72impl Default for ButtonStyle {
73    fn default() -> Self {
74        Self { font: String::new(), size: 22.0, text: [0.92, 0.9, 0.85, 1.0], background: [0.0, 0.0, 0.0, 0.0], hover: [0.85, 0.12, 0.1, 0.35], outline: [0.0; 4], radius: 4.0, align: Align::Center }
75    }
76}
77
78/// The interface of one frame plus what the player did with the previous one.
79#[derive(Default, Clone)]
80pub struct Canvas {
81    cmds: RefCell<Vec<UiCmd>>,
82    clicked: RefCell<Vec<String>>,
83    hovered: RefCell<Vec<String>>,
84    /// Slider values changed by the player: (id, new value).
85    changed: RefCell<Vec<(String, f32)>>,
86    width: Cell<f32>,
87    style: RefCell<ButtonStyle>,
88    /// Mouse position in interface units (when the cursor is free).
89    mouse: Cell<[f32; 2]>,
90    /// True while the shell shows any button, so the host can free the mouse.
91    wants_mouse: Cell<bool>,
92}
93
94impl Canvas {
95    /// Width of the virtual screen (720 high, width follows the window).
96    pub fn width(&self) -> f32 {
97        let w = self.width.get();
98        if w > 0.0 { w } else { UI_HEIGHT * 16.0 / 9.0 }
99    }
100    /// Sets the look of the following buttons and sliders.
101    pub fn set_style(&self, style: ButtonStyle) {
102        *self.style.borrow_mut() = style;
103    }
104    /// The current button style.
105    pub fn style(&self) -> ButtonStyle {
106        self.style.borrow().clone()
107    }
108    /// A filled panel.
109    pub fn panel(&self, rect: [f32; 4], color: [f32; 4], radius: f32) {
110        self.cmds.borrow_mut().push(UiCmd::Panel { rect, color, radius, outline: [0.0; 4] });
111    }
112    /// A panel with an outline.
113    pub fn frame(&self, rect: [f32; 4], color: [f32; 4], outline: [f32; 4], radius: f32) {
114        self.cmds.borrow_mut().push(UiCmd::Panel { rect, color, radius, outline });
115    }
116    /// One line (or a wrapped paragraph when `wrap` > 0 is given via [`Self::paragraph`]) of text.
117    pub fn text(&self, text: &str, pos: [f32; 2], size: f32, color: [f32; 4], font: &str, align: Align) {
118        self.cmds.borrow_mut().push(UiCmd::Text { pos, text: text.to_string(), size, color, font: font.to_string(), align, wrap: 0.0 });
119    }
120    /// Text wrapped to `width` units.
121    pub fn paragraph(&self, text: &str, pos: [f32; 2], width: f32, size: f32, color: [f32; 4], font: &str) {
122        self.cmds.borrow_mut().push(UiCmd::Text { pos, text: text.to_string(), size, color, font: font.to_string(), align: Align::Left, wrap: width });
123    }
124    /// An image (PNG/JPG from the assets).
125    pub fn image(&self, path: &str, rect: [f32; 4], tint: [f32; 4]) {
126        self.cmds.borrow_mut().push(UiCmd::Image { rect, path: path.to_string(), tint });
127    }
128    /// A button; returns true on the frame after it was clicked.
129    pub fn button(&self, id: &str, rect: [f32; 4], text: &str) -> bool {
130        let style = self.style.borrow().clone();
131        self.cmds.borrow_mut().push(UiCmd::Button { id: id.to_string(), rect, text: text.to_string(), style });
132        self.wants_mouse.set(true);
133        self.clicked.borrow().iter().any(|c| c == id)
134    }
135    /// Whether the pointer was over the button last frame.
136    pub fn hovered(&self, id: &str) -> bool {
137        self.hovered.borrow().iter().any(|c| c == id)
138    }
139    /// A slider showing `value` (0..1); returns the value after the player's change.
140    pub fn slider(&self, id: &str, rect: [f32; 4], value: f32) -> f32 {
141        let style = self.style.borrow().clone();
142        let v = self.changed.borrow().iter().rev().find(|(i, _)| i == id).map_or(value, |(_, v)| *v);
143        self.cmds.borrow_mut().push(UiCmd::Slider { id: id.to_string(), rect, value: v.clamp(0.0, 1.0), style });
144        self.wants_mouse.set(true);
145        v.clamp(0.0, 1.0)
146    }
147    /// A toggle drawn as a button with ON / OFF; returns the new state.
148    pub fn toggle(&self, id: &str, rect: [f32; 4], label: &str, on: bool) -> bool {
149        let clicked = self.button(id, rect, &format!("{label}: {}", if on { "ON" } else { "OFF" }));
150        on ^ clicked
151    }
152    /// Mouse position in interface units.
153    pub fn mouse(&self) -> [f32; 2] {
154        self.mouse.get()
155    }
156
157    // ---- host side
158    /// Commands of the current frame.
159    pub fn commands(&self) -> Vec<UiCmd> {
160        self.cmds.borrow().clone()
161    }
162    /// Whether any interactive element is on screen (the host then shows the cursor).
163    pub fn wants_mouse(&self) -> bool {
164        self.wants_mouse.get()
165    }
166    /// Starts a new frame of commands (called by the runtime before scripts update).
167    pub fn begin(&self) {
168        self.cmds.borrow_mut().clear();
169        self.wants_mouse.set(false);
170    }
171    /// What the player did with the painted frame (called by the shell after painting).
172    pub fn feedback(&self, clicked: Vec<String>, hovered: Vec<String>, changed: Vec<(String, f32)>, width: f32, mouse: [f32; 2]) {
173        *self.clicked.borrow_mut() = clicked;
174        *self.hovered.borrow_mut() = hovered;
175        *self.changed.borrow_mut() = changed;
176        self.width.set(width);
177        self.mouse.set(mouse);
178    }
179}
180
181#[cfg(test)]
182mod tests {
183    use super::*;
184
185    #[test]
186    fn buttons_report_clicks_one_frame_later() {
187        let c = Canvas::default();
188        assert!(!c.button("a", [0.0; 4], "A"));
189        c.feedback(vec!["a".into()], vec!["a".into()], vec![("vol".into(), 0.3)], 1280.0, [1.0, 2.0]);
190        c.begin();
191        assert!(c.button("a", [0.0; 4], "A"));
192        assert!(c.hovered("a"));
193        assert_eq!(c.slider("vol", [0.0; 4], 0.8), 0.3);
194        assert!(c.toggle("t", [0.0; 4], "VHS", true));
195        assert_eq!(c.width(), 1280.0);
196        assert!(c.wants_mouse());
197    }
198}