diff --git a/CHANGELOG.md b/CHANGELOG.md index 61bac5f..87e00af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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` instead of `IntoValue`. - `Dynamic::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 diff --git a/guide/guide-examples/examples/hello-world.rs b/guide/guide-examples/examples/hello-world.rs new file mode 100644 index 0000000..fd91cc0 --- /dev/null +++ b/guide/guide-examples/examples/hello-world.rs @@ -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(); +} diff --git a/guide/guide-examples/examples/intro.rs b/guide/guide-examples/examples/intro.rs new file mode 100644 index 0000000..e990df2 --- /dev/null +++ b/guide/guide-examples/examples/intro.rs @@ -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 = Dynamic::default(); + + // Create our label by using `map_each` to format the name, first checking + // if it is empty. + let greeting: Dynamic = 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 = Dynamic::default(); + let greeting: Dynamic = 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(); + }); +} diff --git a/guide/guide-examples/src/lib.rs b/guide/guide-examples/src/lib.rs index 40f2bcb..0e9e8fc 100644 --- a/guide/guide-examples/src/lib.rs +++ b/guide/guide-examples/src/lib.rs @@ -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, } +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(self, prepare: Prepare) -> BookExample + where + Prepare: FnOnce(&mut VirtualRecorder), + { + self.finish().prepare_with(prepare) + } + + pub fn still_frame(self, test: Test) + where + Test: FnOnce(&mut VirtualRecorder), + { + self.finish().still_frame(test); + } + + pub fn animated(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, +} + 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(self, test: Test) + pub fn untested_still_frame(self) { + self.still_frame(|_| {}); + } + + pub fn prepare_with(mut self, prepare: Prepare) -> Self + where + Prepare: FnOnce(&mut VirtualRecorder), + { + prepare(&mut self.recorder); + self + } + + pub fn still_frame(mut self, test: Test) where Test: FnOnce(&mut VirtualRecorder), { - 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(self, test: Test) - // where - // Test: FnOnce(&mut AnimationRecorder<'_, Rgb8>), - // { - // } + pub fn animated(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()) }; } diff --git a/guide/src/examples/hello_world.png b/guide/src/examples/hello_world.png new file mode 100644 index 0000000..76c2c79 Binary files /dev/null and b/guide/src/examples/hello_world.png differ diff --git a/guide/src/examples/intro.png b/guide/src/examples/intro.png new file mode 100644 index 0000000..4733a26 Binary files /dev/null and b/guide/src/examples/intro.png differ diff --git a/guide/src/intro.md b/guide/src/intro.md index fcde68f..e5cb461 100644 --- a/guide/src/intro.md +++ b/guide/src/intro.md @@ -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`s][dynamic]. A +`Dynamic` is an `Arc>`-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` into a text +[`Input`][input]. Since `Dynamic` 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]: [rust]: [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> diff --git a/src/context.rs b/src/context.rs index 56b215a..ccc8747 100644 --- a/src/context.rs +++ b/src/context.rs @@ -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. diff --git a/src/tick.rs b/src/tick.rs index b1dfe70..6120618 100644 --- a/src/tick.rs +++ b/src/tick.rs @@ -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. diff --git a/src/widget.rs b/src/widget.rs index 25857bf..d30de58 100644 --- a/src/widget.rs +++ b/src/widget.rs @@ -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; diff --git a/src/widgets/custom.rs b/src/widgets/custom.rs index 8220346..cddac27 100644 --- a/src/widgets/custom.rs +++ b/src/widgets/custom.rs @@ -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. diff --git a/src/widgets/input.rs b/src/widgets/input.rs index 0aec7fe..caf0d43 100644 --- a/src/widgets/input.rs +++ b/src/widgets/input.rs @@ -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 { +pub struct Input { /// The value of this widget. pub value: Dynamic, /// 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 { diff --git a/src/widgets/slider.rs b/src/widgets/slider.rs index 4987564..9a74cc8 100644 --- a/src/widgets/slider.rs +++ b/src/widgets/slider.rs @@ -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 { diff --git a/src/widgets/tilemap.rs b/src/widgets/tilemap.rs index b976470..80430e5 100644 --- a/src/widgets/tilemap.rs +++ b/src/widgets/tilemap.rs @@ -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. diff --git a/src/window.rs b/src/window.rs index 7865e9c..bf32c39 100644 --- a/src/window.rs +++ b/src/window.rs @@ -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( &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 { - 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 { - 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::() / graphemes.cast::() / 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, + pub location: KeyLocation, + pub state: ElementState, + pub repeat: bool, +} + +impl From 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, + } + } +}