Guide intro + KeyEvent

Also fixed virtual window's refresh handling.
This commit is contained in:
Jonathan Johnson 2024-01-06 14:46:48 -08:00
parent 31de0bc458
commit cda13c42a2
No known key found for this signature in database
GPG key ID: A66D6A34D6620579
15 changed files with 317 additions and 44 deletions

View file

@ -43,6 +43,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
`Label::text` is now named `display` and `Label::new()` now accepts an
`IntoReadOnly<T>` instead of `IntoValue<String>`.
- `Dynamic<Children>::wrap` has been renamed to `into_wrap` for consistency.
- Cushy now has its own `KeyEvent` type, as winit's has private fields. This
prevented simulating input in a `VirtualWindow`.
### Fixed

View file

@ -0,0 +1,16 @@
// ANCHOR: example
use cushy::Run;
fn main() -> cushy::Result {
"Hello, World!".run()
}
// ANCHOR_END: example
#[test]
fn book() {
fn hello_world() -> impl MakeWidget {
"Hello, World!"
}
guide_examples::book_example!(hello_world).untested_still_frame();
}

View file

@ -0,0 +1,49 @@
// ANCHOR: example
use cushy::value::{Dynamic, Source};
use cushy::widget::MakeWidget;
use cushy::widgets::input::{Input, InputValue};
use cushy::Run;
fn main() -> cushy::Result {
// Create storage for user to enter a name.
let name: Dynamic<String> = Dynamic::default();
// Create our label by using `map_each` to format the name, first checking
// if it is empty.
let greeting: Dynamic<String> = name.map_each(|name| {
let name = if name.is_empty() { "World" } else { name };
format!("Hello, {name}!")
});
// Create the input widget with a placeholder.
let name_input: Input = name.into_input().placeholder("Name");
// Stack our widgets as rows, and run the app.
name_input.and(greeting).into_rows().run()
}
// ANCHOR_END: example
#[test]
fn book() {
use std::time::Duration;
fn intro() -> impl MakeWidget {
let subject: Dynamic<String> = Dynamic::default();
let greeting: Dynamic<String> = subject.map_each(|subject| {
let subject = if subject.is_empty() { "World" } else { subject };
format!("Hello, {subject}!")
});
let name_input: Input = subject.into_input().placeholder("Name");
name_input.and(greeting).into_rows()
}
guide_examples::book_example!(intro).animated(|animation| {
animation.wait_for(Duration::from_millis(1_000)).unwrap();
animation
.animate_text_input("Ferris 🦀", Duration::from_secs(1))
.unwrap();
animation.wait_for(Duration::from_millis(1_000)).unwrap();
});
}

View file

@ -5,13 +5,49 @@ use cushy::figures::units::Px;
use cushy::figures::Size;
use cushy::widget::MakeWidget;
use cushy::widgets::container::ContainerShadow;
use cushy::window::{Rgba8, VirtualRecorder, VirtualRecorderBuilder};
use cushy::window::{AnimationRecorder, Rgba8, VirtualRecorder, VirtualRecorderBuilder};
pub struct BookExample {
pub struct BookExampleBuilder {
name: &'static str,
recorder: VirtualRecorderBuilder<Rgba8>,
}
impl BookExampleBuilder {
pub fn finish(self) -> BookExample {
let mut recorder = self.recorder.finish().expect("error creating recorder");
recorder.window.set_focused(true);
BookExample {
name: self.name,
recorder,
}
}
pub fn untested_still_frame(self) {
self.finish().untested_still_frame()
}
pub fn prepare_with<Prepare>(self, prepare: Prepare) -> BookExample
where
Prepare: FnOnce(&mut VirtualRecorder<Rgba8>),
{
self.finish().prepare_with(prepare)
}
pub fn still_frame<Test>(self, test: Test)
where
Test: FnOnce(&mut VirtualRecorder<Rgba8>),
{
self.finish().still_frame(test);
}
pub fn animated<Test>(self, test: Test)
where
Test: FnOnce(&mut AnimationRecorder<'_, Rgba8>),
{
self.finish().animated(test);
}
}
fn target_dir() -> PathBuf {
let target_dir = std::env::current_dir()
.expect("missing current dir")
@ -27,9 +63,14 @@ fn target_dir() -> PathBuf {
target_dir
}
pub struct BookExample {
name: &'static str,
recorder: VirtualRecorder<Rgba8>,
}
impl BookExample {
pub fn new(name: &'static str, interface: impl MakeWidget) -> Self {
Self {
pub fn build(name: &'static str, interface: impl MakeWidget) -> BookExampleBuilder {
BookExampleBuilder {
name,
recorder: interface
.contain()
@ -42,17 +83,31 @@ impl BookExample {
}
}
pub fn still_frame<Test>(self, test: Test)
pub fn untested_still_frame(self) {
self.still_frame(|_| {});
}
pub fn prepare_with<Prepare>(mut self, prepare: Prepare) -> Self
where
Prepare: FnOnce(&mut VirtualRecorder<Rgba8>),
{
prepare(&mut self.recorder);
self
}
pub fn still_frame<Test>(mut self, test: Test)
where
Test: FnOnce(&mut VirtualRecorder<Rgba8>),
{
let mut recorder = self.recorder.finish().unwrap();
let capture = std::env::var("CAPTURE").is_ok();
let errored = std::panic::catch_unwind(AssertUnwindSafe(|| test(&mut recorder))).is_err();
let errored =
std::panic::catch_unwind(AssertUnwindSafe(|| test(&mut self.recorder))).is_err();
if errored || capture {
let path = target_dir().join(format!("{}.png", self.name));
recorder.image().save(&path).expect("error saving file");
self.recorder
.image()
.save(&path)
.expect("error saving file");
println!("Wrote {}", path.display());
if errored {
@ -61,16 +116,28 @@ impl BookExample {
}
}
// pub fn animated<Test>(self, test: Test)
// where
// Test: FnOnce(&mut AnimationRecorder<'_, Rgb8>),
// {
// }
pub fn animated<Test>(mut self, test: Test)
where
Test: FnOnce(&mut AnimationRecorder<'_, Rgba8>),
{
let mut animation = self.recorder.record_animated_png(60);
let capture = std::env::var("CAPTURE").is_ok();
let errored = std::panic::catch_unwind(AssertUnwindSafe(|| test(&mut animation))).is_err();
if errored || capture {
let path = target_dir().join(format!("{}.png", self.name));
animation.write_to(&path).expect("error saving file");
println!("Wrote {}", path.display());
if errored {
std::process::exit(-1);
}
}
}
}
#[macro_export]
macro_rules! book_example {
($name:ident) => {
guide_examples::BookExample::new(stringify!($name), $name())
guide_examples::BookExample::build(stringify!($name), $name())
};
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

View file

@ -7,6 +7,54 @@ This is a user's guide for [Cushy][cushy], a [Rust][rust] GUI crate. The
functionality quickly. This guide is aimed to providing an example-rich
walkthrough of how to use and extend Cushy.
## A "Hello, World" Example
Here's the simplest "Hello, World" example:
```rust,no_run,no_playground
{{#include ../guide-examples/examples/hello-world.rs:example}}
```
When run, the app just displays the text as one would hope:
![Hello World Example](./examples/hello_world.png)
That was a little too easy. Let's take it a step further by letting a user type
in their name and have a label display "Hello, {name}!":
```rust,no_run,no_playground
{{#include ../guide-examples/examples/intro.rs:example}}
```
This app looks like this when executed:
![Hello Ferris Example](./examples/intro.png)
In this example, both `name` and `greeting` are [`Dynamic<String>`s][dynamic]. A
`Dynamic<T>` is an `Arc<Mutex<T>>`-like type that is able to invoke a set of
callbacks when its contents is changed. This simple feature is the core of
Cushy's reactive data model.
Each time `name` is changed, the `map_each` closure will be executed and
`greeting` will be updated with the result. Now that we have the individual
pieces of data our user interface is going to work with, we can start assembling
the interface.
First, we create `name_input` by converting the `Dynamic<String>` into a text
[`Input<String>`][input]. Since `Dynamic<String>` can be used as a
[`Label`][label], all that's left is laying out our two widgets.
To layout `name_input` and `greeting`, we use a [`Stack`][stack] to lay out the
widgets as rows.
Don't worry if this example seems a bit magical or confusing as to how it works.
Cushy can feel magical to use. But, it should never be a mystery. The goal of
this guide is to try and explain how and why Cushy works the way it does.
[cushy]: <https://github.com/khonsulabs/cushy>
[rust]: <https://rust-lang.org/>
[docs]: <{{docs}}>
[dynamic]: <{{docs}}/value/struct.Dynamic.html>
[input]: <{{docs}}/widgets/input/struct.Input.html>
[label]: <{{docs}}/widgets/label/struct.Label.html>
[stack]: <{{docs}}/widgets/stack/struct.Stack.html>

View file

@ -4,7 +4,7 @@ use std::ops::{Deref, DerefMut};
use figures::units::{Lp, Px, UPx};
use figures::{IntoSigned, Point, Px2D, Rect, Round, ScreenScale, Size, Zero};
use kludgine::app::winit::event::{Ime, KeyEvent, MouseButton, MouseScrollDelta, TouchPhase};
use kludgine::app::winit::event::{Ime, MouseButton, MouseScrollDelta, TouchPhase};
use kludgine::app::winit::window::CursorIcon;
use kludgine::shapes::{Shape, StrokeOptions};
use kludgine::{Color, Kludgine, KludgineId};
@ -19,7 +19,7 @@ use crate::styles::{ComponentDefinition, Styles, Theme, ThemePair};
use crate::tree::Tree;
use crate::value::{IntoValue, Source, Value};
use crate::widget::{EventHandling, MountedWidget, RootBehavior, WidgetId, WidgetInstance};
use crate::window::{CursorState, DeviceId, PlatformWindow, ThemeMode};
use crate::window::{CursorState, DeviceId, KeyEvent, PlatformWindow, ThemeMode};
use crate::ConstraintLimit;
/// A context to an event function.

View file

@ -6,13 +6,14 @@ use ahash::AHashSet;
use figures::units::Px;
use figures::Point;
use intentional::Assert;
use kludgine::app::winit::event::{ElementState, KeyEvent, MouseButton};
use kludgine::app::winit::event::{ElementState, MouseButton};
use kludgine::app::winit::keyboard::Key;
use crate::context::WidgetContext;
use crate::utils::IgnorePoison;
use crate::value::{Destination, Dynamic};
use crate::widget::{EventHandling, HANDLED, IGNORED};
use crate::window::KeyEvent;
/// A fixed-rate callback that provides access to tracked input on its
/// associated widget.

View file

@ -12,7 +12,7 @@ use alot::LotId;
use figures::units::{Px, UPx};
use figures::{IntoSigned, IntoUnsigned, Point, Rect, Size};
use intentional::Assert;
use kludgine::app::winit::event::{Ime, KeyEvent, MouseButton, MouseScrollDelta, TouchPhase};
use kludgine::app::winit::event::{Ime, MouseButton, MouseScrollDelta, TouchPhase};
use kludgine::app::winit::window::CursorIcon;
use kludgine::Color;
@ -45,8 +45,8 @@ use crate::widgets::{
};
use crate::window::sealed::WindowCommand;
use crate::window::{
CushyWindowBuilder, DeviceId, Rgb8, RunningWindow, ThemeMode, VirtualRecorderBuilder, Window,
WindowBehavior, WindowHandle, WindowLocal,
CushyWindowBuilder, DeviceId, KeyEvent, Rgb8, RunningWindow, ThemeMode, VirtualRecorderBuilder,
Window, WindowBehavior, WindowHandle, WindowLocal,
};
use crate::ConstraintLimit;

View file

@ -2,7 +2,7 @@ use std::fmt::Debug;
use figures::units::Px;
use figures::{Point, Size};
use kludgine::app::winit::event::{Ime, KeyEvent, MouseButton, MouseScrollDelta, TouchPhase};
use kludgine::app::winit::event::{Ime, MouseButton, MouseScrollDelta, TouchPhase};
use kludgine::app::winit::window::CursorIcon;
use kludgine::Color;
@ -11,7 +11,7 @@ use crate::styles::VisualOrder;
use crate::value::{IntoValue, Value};
use crate::widget::{EventHandling, MakeWidget, WidgetRef, WrappedLayout, WrapperWidget, IGNORED};
use crate::widgets::Space;
use crate::window::DeviceId;
use crate::window::{DeviceId, KeyEvent};
use crate::ConstraintLimit;
/// A callback-based custom widget.

View file

@ -13,7 +13,7 @@ use figures::{
Abs, FloatConversion, IntoSigned, IntoUnsigned, Point, Rect, Round, ScreenScale, Size,
};
use intentional::Cast;
use kludgine::app::winit::event::{ElementState, Ime, KeyEvent};
use kludgine::app::winit::event::{ElementState, Ime};
use kludgine::app::winit::keyboard::{Key, NamedKey};
use kludgine::app::winit::window::{CursorIcon, ImePurpose};
use kludgine::shapes::{Shape, StrokeOptions};
@ -27,13 +27,14 @@ use crate::styles::components::{HighlightColor, IntrinsicPadding, OutlineColor,
use crate::utils::ModifiersExt;
use crate::value::{Destination, Dynamic, Generation, IntoDynamic, IntoValue, Source, Value};
use crate::widget::{Callback, EventHandling, Widget, HANDLED, IGNORED};
use crate::window::KeyEvent;
use crate::{ConstraintLimit, Lazy};
const CURSOR_BLINK_DURATION: Duration = Duration::from_millis(500);
/// A text input widget.
#[must_use]
pub struct Input<Storage> {
pub struct Input<Storage = String> {
/// The value of this widget.
pub value: Dynamic<Storage>,
/// The placeholder text to display when no value is present.
@ -1149,8 +1150,6 @@ where
);
}
context.redraw_in(cursor_state.remaining_until_blink);
} else {
context.redraw_when_changed(context.window().focused());
}
}
@ -1190,7 +1189,7 @@ where
fn keyboard_input(
&mut self,
_device_id: crate::window::DeviceId,
input: kludgine::app::winit::event::KeyEvent,
input: KeyEvent,
_is_synthetic: bool,
context: &mut EventContext<'_>,
) -> EventHandling {

View file

@ -21,7 +21,7 @@ use crate::styles::components::{
use crate::styles::{Dimension, HorizontalOrder, VerticalOrder, VisualOrder};
use crate::value::{Destination, Dynamic, IntoDynamic, IntoValue, Source, Value};
use crate::widget::{EventHandling, Widget, HANDLED, IGNORED};
use crate::window::DeviceId;
use crate::window::{DeviceId, KeyEvent};
use crate::ConstraintLimit;
/// A widget that allows sliding between two values.
@ -673,7 +673,7 @@ where
fn keyboard_input(
&mut self,
_device_id: DeviceId,
input: kludgine::app::winit::event::KeyEvent,
input: KeyEvent,
_is_synthetic: bool,
_context: &mut EventContext<'_>,
) -> EventHandling {

View file

@ -3,7 +3,7 @@ use std::fmt::Debug;
use figures::units::{Px, UPx};
use figures::{Point, Size};
use intentional::Cast;
use kludgine::app::winit::event::{ElementState, KeyEvent, MouseScrollDelta, TouchPhase};
use kludgine::app::winit::event::{ElementState, MouseScrollDelta, TouchPhase};
use kludgine::app::winit::window::CursorIcon;
use kludgine::tilemap;
use kludgine::tilemap::TileMapFocus;
@ -12,7 +12,7 @@ use crate::context::{EventContext, GraphicsContext, LayoutContext, Trackable};
use crate::tick::Tick;
use crate::value::{Dynamic, IntoValue, Value};
use crate::widget::{EventHandling, Widget, HANDLED, IGNORED};
use crate::window::DeviceId;
use crate::window::{DeviceId, KeyEvent};
use crate::ConstraintLimit;
/// A layered tile-based 2d game surface.

View file

@ -24,9 +24,11 @@ use image::{DynamicImage, RgbImage, RgbaImage};
use intentional::{Assert, Cast};
use kludgine::app::winit::dpi::{PhysicalPosition, PhysicalSize};
use kludgine::app::winit::event::{
ElementState, Ime, KeyEvent, Modifiers, MouseButton, MouseScrollDelta, TouchPhase,
ElementState, Ime, Modifiers, MouseButton, MouseScrollDelta, TouchPhase,
};
use kludgine::app::winit::keyboard::{
Key, KeyCode, KeyLocation, NamedKey, NativeKeyCode, PhysicalKey, SmolStr,
};
use kludgine::app::winit::keyboard::{Key, NamedKey};
use kludgine::app::winit::window::{self, CursorIcon};
use kludgine::app::{winit, WindowBehavior as _};
use kludgine::cosmic_text::{fontdb, Family, FamilyOwned};
@ -35,6 +37,7 @@ use kludgine::shapes::Shape;
use kludgine::wgpu::{self, CompositeAlphaMode, COPY_BYTES_PER_ROW_ALIGNMENT};
use kludgine::{Color, DrawableExt, Kludgine, KludgineId, Origin, Texture};
use tracing::Level;
use unicode_segmentation::UnicodeSegmentation;
use crate::animation::{
AnimationTarget, Easing, LinearInterpolate, PercentBetween, Spawn, ZeroToOne,
@ -1298,6 +1301,14 @@ where
self.root.invalidate();
}
pub fn set_focused(&mut self, focused: bool) {
self.focused.set(focused);
}
pub fn set_occluded(&mut self, occluded: bool) {
self.occluded.set(occluded);
}
pub fn keyboard_input<W>(
&mut self,
window: W,
@ -1674,7 +1685,7 @@ where
window: kludgine::app::Window<'_, WindowCommand>,
_kludgine: &mut Kludgine,
) {
self.focused.set(window.focused());
self.set_focused(window.focused());
}
fn occlusion_changed(
@ -1682,7 +1693,7 @@ where
window: kludgine::app::Window<'_, WindowCommand>,
_kludgine: &mut Kludgine,
) {
self.occluded.set(window.ocluded());
self.set_occluded(window.ocluded());
}
fn render<'pass>(
@ -1778,10 +1789,16 @@ where
window: kludgine::app::Window<'_, WindowCommand>,
kludgine: &mut Kludgine,
device_id: winit::event::DeviceId,
input: KeyEvent,
input: winit::event::KeyEvent,
is_synthetic: bool,
) {
self.keyboard_input(window, kludgine, device_id.into(), input, is_synthetic);
self.keyboard_input(
window,
kludgine,
device_id.into(),
input.into(),
is_synthetic,
);
}
fn mouse_wheel(
@ -2570,6 +2587,14 @@ impl CushyWindow {
kludgine::Graphics::new(&mut self.kludgine, device, queue)
}
pub fn set_focused(&mut self, focused: bool) {
self.window.set_focused(focused);
}
pub fn set_occluded(&mut self, occluded: bool) {
self.window.set_occluded(occluded);
}
/// Requests that the window close.
///
/// Returns true if the request should be honored.
@ -2699,6 +2724,7 @@ impl VirtualWindow {
.map(|i| now.duration_since(i))
.unwrap_or_default();
self.last_rendered_at = Some(now);
self.state.dynamic.redraw_target.set(RedrawTarget::Never);
self.cushy.prepare(&mut self.state, device, queue);
}
@ -2726,7 +2752,6 @@ impl VirtualWindow {
queue: &wgpu::Queue,
additional_drawing: Option<&Drawing>,
) -> Option<wgpu::SubmissionIndex> {
self.state.dynamic.redraw_target.set(RedrawTarget::Never);
self.cushy
.render_with(pass, device, queue, additional_drawing)
}
@ -2739,7 +2764,6 @@ impl VirtualWindow {
device: &wgpu::Device,
queue: &wgpu::Queue,
) -> Option<wgpu::SubmissionIndex> {
self.state.dynamic.redraw_target.set(RedrawTarget::Never);
self.cushy.render_into(texture, load_op, device, queue)
}
@ -2766,6 +2790,14 @@ impl VirtualWindow {
}
}
pub fn set_focused(&mut self, focused: bool) {
self.cushy.set_focused(focused);
}
pub fn set_occluded(&mut self, occluded: bool) {
self.cushy.set_occluded(occluded);
}
/// Returns true if this window should no longer be open.
#[must_use]
pub fn closed(&self) -> bool {
@ -3363,6 +3395,40 @@ where
self.wait_for(over)
}
pub fn animate_text_input(
&mut self,
text: &str,
duration: Duration,
) -> Result<(), VirtualRecorderError> {
let graphemes = text.graphemes(true).count();
let delay_per_event =
Duration::from_nanos(duration.as_nanos().cast::<u64>() / graphemes.cast::<u64>() / 2);
for grapheme in text.graphemes(true) {
let grapheme = SmolStr::new(grapheme);
let mut event = KeyEvent {
physical_key: PhysicalKey::Unidentified(NativeKeyCode::Xkb(0)),
logical_key: Key::Character(grapheme.clone()),
text: Some(SmolStr::new(grapheme)),
location: KeyLocation::Standard,
state: ElementState::Pressed,
repeat: false,
};
let _handled =
self.recorder
.window
.keyboard_input(DeviceId::Virtual(0), event.clone(), true);
self.wait_for(delay_per_event)?;
event.state = ElementState::Released;
let _handled = self
.recorder
.window
.keyboard_input(DeviceId::Virtual(0), event, true);
self.wait_for(delay_per_event)?;
}
Ok(())
}
/// Waits for `duration`, rendering frames as needed.
pub fn wait_for(&mut self, duration: Duration) -> Result<(), VirtualRecorderError> {
self.wait_until(Instant::now() + duration)
@ -3391,7 +3457,7 @@ where
RedrawTarget::At(instant) => now.min(instant),
};
if final_frame || next_frame == now {
if final_frame || next_frame <= now {
// Try to reuse an existing capture instead of forcing an
// allocation.
if let Ok(capture) = assembler.resuable_captures.try_recv() {
@ -3399,6 +3465,7 @@ where
}
let elapsed = now.saturating_duration_since(last_frame);
last_frame = now;
println!("Redrawing");
self.recorder.redraw();
let capture = self.recorder.capture.take().assert("always present");
if assembler.sender.send((capture, elapsed)).is_err() {
@ -3447,8 +3514,7 @@ where
let mut current_frame_delay = Duration::ZERO;
let mut writer = encoder.write_header()?;
for frame in &frames {
writer.write_image_data(&frame.data)?;
if current_frame_delay != frame.duration {
if current_frame_delay != frame.duration && frames.len() > 1 {
current_frame_delay = frame.duration;
// This has a limitation that a single frame can't be longer
// than ~6.5 seconds, but it ensures frame timing is more
@ -3458,9 +3524,10 @@ where
10_000,
)?;
}
writer.write_image_data(&frame.data)?;
}
writer.finish()?;
writer.finish().unwrap();
file.sync_all()?;
@ -3604,3 +3671,27 @@ impl FrameAssembler {
let _result = result.send(Ok(assembled));
}
}
/// Describes a keyboard input targeting a window.
#[derive(Debug, Clone, Eq, PartialEq, Hash)]
pub struct KeyEvent {
pub physical_key: PhysicalKey,
pub logical_key: Key,
pub text: Option<SmolStr>,
pub location: KeyLocation,
pub state: ElementState,
pub repeat: bool,
}
impl From<winit::event::KeyEvent> for KeyEvent {
fn from(event: winit::event::KeyEvent) -> Self {
Self {
physical_key: event.physical_key,
logical_key: event.logical_key,
text: event.text,
location: event.location,
state: event.state,
repeat: event.repeat,
}
}
}