From 501eecd7a52870881ee15a07f93a3d66b0bfd5d9 Mon Sep 17 00:00:00 2001 From: Jonathan Johnson Date: Fri, 3 Nov 2023 07:15:34 -0700 Subject: [PATCH] Async, better scroll, Input::on_key --- Cargo.lock | 12 ++--- Cargo.toml | 2 + examples/gameui.rs | 46 ++++++++++++++++++ src/animation.rs | 1 - src/lib.rs | 20 +++----- src/utils.rs | 15 ++++++ src/value.rs | 81 ++++++++++++++++++++++++++----- src/widgets/input.rs | 53 +++++++++++++++----- src/widgets/scroll.rs | 110 +++++++++++++++++++++++++++++++++--------- src/window.rs | 2 +- src/with_clone.rs | 2 - 11 files changed, 274 insertions(+), 70 deletions(-) create mode 100644 examples/gameui.rs diff --git a/Cargo.lock b/Cargo.lock index 685798f..f63204c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -96,7 +96,7 @@ dependencies = [ [[package]] name = "appit" version = "0.1.0" -source = "git+https://github.com/khonsulabs/appit#91c540c2a2db69eb25ea47eccb7aac1eb911933e" +source = "git+https://github.com/khonsulabs/appit#043bfe2c78524d6a06ed159289ea1cd7a62b0fec" dependencies = [ "raw-window-handle 0.5.2", "winit", @@ -847,7 +847,7 @@ checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" [[package]] name = "kludgine" version = "0.1.0" -source = "git+https://github.com/khonsulabs/kludgine#35b83e14e71a02f3cd9a96e865094f5b3ad1407c" +source = "git+https://github.com/khonsulabs/kludgine#ce69ff4ecf5995a3120d2fc56f4fa6c16381d6b5" dependencies = [ "ahash", "alot", @@ -2428,18 +2428,18 @@ checksum = "dd15f8e0dbb966fd9245e7498c7e9e5055d9e5c8b676b95bd67091cd11a1e697" [[package]] name = "zerocopy" -version = "0.7.23" +version = "0.7.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e50cbb27c30666a6108abd6bc7577556265b44f243e2be89a8bc4e07a528c107" +checksum = "092cd76b01a033a9965b9097da258689d9e17c69ded5dcf41bca001dd20ebc6d" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.7.23" +version = "0.7.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a25f293fe55f0a48e7010d65552bb63704f6ceb55a1a385da10d41d8f78e4a3d" +checksum = "a13a20a7c6a90e2034bcc65495799da92efcec6a8dd4f3fcb6f7a48988637ead" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index 9029fcb..c140e6f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,8 @@ kempt = "0.2.1" # [patch."https://github.com/khonsulabs/kludgine"] # kludgine = { path = "../kludgine2" } +# [patch."https://github.com/khonsulabs/appit"] +# appit = { path = "../appit" } # [patch."https://github.com/khonsulabs/figures"] # figures = { path = "../figures" } diff --git a/examples/gameui.rs b/examples/gameui.rs new file mode 100644 index 0000000..b905bd6 --- /dev/null +++ b/examples/gameui.rs @@ -0,0 +1,46 @@ +use gooey::value::Dynamic; +use gooey::widget::{HANDLED, IGNORED}; +use gooey::widgets::{Canvas, Expand, Input, Label, Scroll, Stack}; +use gooey::{widgets, Run}; +use kludgine::app::winit::event::ElementState; +use kludgine::app::winit::keyboard::Key; +use kludgine::figures::{Point, Rect}; +use kludgine::shapes::Shape; +use kludgine::Color; + +fn main() -> gooey::Result { + let chat_log = Dynamic::new("Chat log goes here.\n".repeat(100)); + let chat_message = Dynamic::new(String::new()); + + Expand::new(Stack::rows(widgets![ + Expand::new(Stack::columns(widgets![ + Expand::new(Scroll::vertical(Label::new(chat_log.clone()))), + Expand::weighted( + 2, + Canvas::new(|context| { + let entire_canvas = Rect::from(context.graphics.size()); + context.graphics.draw_shape( + &Shape::filled_rect(entire_canvas, Color::RED), + Point::default(), + None, + None, + ); + }) + ) + ])), + Input::new(chat_message.clone()).on_key(move |input| { + match (input.state, input.logical_key) { + (ElementState::Pressed, Key::Enter) => { + let new_message = chat_message.map_mut(|text| std::mem::take(text)); + chat_log.map_mut(|chat_log| { + chat_log.push_str(&new_message); + chat_log.push('\n'); + }); + HANDLED + } + _ => IGNORED, + } + }), + ])) + .run() +} diff --git a/src/animation.rs b/src/animation.rs index f8ee704..702881f 100644 --- a/src/animation.rs +++ b/src/animation.rs @@ -11,7 +11,6 @@ use alot::{LotId, Lots}; use kempt::Set; use kludgine::Color; -use crate::impl_all_tuples; use crate::value::Dynamic; static ANIMATIONS: Mutex = Mutex::new(Animating::new()); diff --git a/src/lib.rs b/src/lib.rs index 7a782eb..253613a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,6 +2,9 @@ #![warn(clippy::pedantic, missing_docs)] #![allow(clippy::module_name_repetitions, clippy::missing_errors_doc)] +#[macro_use] +mod utils; + pub mod animation; pub mod context; mod graphics; @@ -9,7 +12,6 @@ mod names; pub mod styles; mod tick; mod tree; -mod utils; pub mod value; pub mod widget; pub mod widgets; @@ -88,6 +90,9 @@ macro_rules! widgets { }}; } +/// Counts the number of expressions passed to it. +/// +/// This is used inside of Gooey macros to preallocate collections. #[macro_export] #[doc(hidden)] macro_rules! count { @@ -115,16 +120,3 @@ macro_rules! styles { $crate::styles!($($component => $value),*) }}; } - -#[doc(hidden)] -#[macro_export] -macro_rules! impl_all_tuples { - ($macro_name:ident) => { - $macro_name!(T0 0); - $macro_name!(T0 0, T1 1); - $macro_name!(T0 0, T1 1, T2 2); - $macro_name!(T0 0, T1 1, T2 2, T3 3); - $macro_name!(T0 0, T1 1, T2 2, T3 3, T4 4); - $macro_name!(T0 0, T1 1, T2 2, T3 3, T4 4, T5 5); - } -} diff --git a/src/utils.rs b/src/utils.rs index f309538..16d29fd 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -62,3 +62,18 @@ impl Deref for Lazy { self.once.get_or_init(self.init) } } + +/// Invokes the provided macro with a pattern that can be matched using this +/// macro_rules expression: `$($type:ident $field:tt),+`, where `$type` is an +/// identifier to use for the generic parameter and `$field` is the field index +/// inside of the tuple. +macro_rules! impl_all_tuples { + ($macro_name:ident) => { + $macro_name!(T0 0); + $macro_name!(T0 0, T1 1); + $macro_name!(T0 0, T1 1, T2 2); + $macro_name!(T0 0, T1 1, T2 2, T3 3); + $macro_name!(T0 0, T1 1, T2 2, T3 3, T4 4); + $macro_name!(T0 0, T1 1, T2 2, T3 3, T4 4, T5 5); + } +} diff --git a/src/value.rs b/src/value.rs index 17db149..2dd06fc 100644 --- a/src/value.rs +++ b/src/value.rs @@ -1,8 +1,10 @@ //! Types for storing and interacting with values in Widgets. use std::fmt::Debug; +use std::future::Future; use std::panic::AssertUnwindSafe; use std::sync::{Arc, Condvar, Mutex, MutexGuard, PoisonError}; +use std::task::{Poll, Waker}; use crate::animation::{DynamicTransition, LinearInterpolate}; use crate::context::{WidgetContext, WindowHandle}; @@ -24,6 +26,7 @@ impl Dynamic { callbacks: Vec::new(), windows: Vec::new(), readers: 0, + wakers: Vec::new(), }), sync: AssertUnwindSafe(Condvar::new()), })) @@ -39,7 +42,7 @@ impl Dynamic { /// function, all observers will be notified that the contents have been /// updated. pub fn map_mut(&self, map: impl FnOnce(&mut T) -> R) -> R { - self.0.map_mut(map) + self.0.map_mut(|value, _| map(value)) } /// Attaches `for_each` to this value so that it is invoked each time the @@ -106,7 +109,8 @@ impl Dynamic { /// the contents have been updated. #[must_use] pub fn replace(&self, new_value: T) -> T { - self.0.map_mut(|value| std::mem::replace(value, new_value)) + self.0 + .map_mut(|value, _| std::mem::replace(value, new_value)) } /// Stores `new_value` in this dynamic. Before returning from this function, @@ -115,6 +119,21 @@ impl Dynamic { let _old = self.replace(new_value); } + /// Updates this dynamic with `new_value`, but only if `new_value` is not + /// equal to the currently stored value. + pub fn update(&self, new_value: T) + where + T: Eq, + { + self.0.map_mut(|value, changed| { + if *value == new_value { + *changed = false; + } else { + *value = new_value; + } + }); + } + /// Returns a new reference-based reader for this dynamic value. #[must_use] pub fn create_ref_reader(&self) -> DynamicReader { @@ -207,21 +226,26 @@ impl DynamicData { self.state().wrapped.clone() } - #[must_use] - pub fn map_mut(&self, map: impl FnOnce(&mut T) -> R) -> R { + pub fn map_mut(&self, map: impl FnOnce(&mut T, &mut bool) -> R) -> R { let mut state = self.state(); let old = { let state = &mut *state; - let generation = state.wrapped.generation.next(); - let result = map(&mut state.wrapped.value); - state.wrapped.generation = generation; + let mut changed = true; + let result = map(&mut state.wrapped.value, &mut changed); + if changed { + state.wrapped.generation = state.wrapped.generation.next(); - for callback in &mut state.callbacks { - callback.update(&state.wrapped); - } - for window in state.windows.drain(..) { - window.redraw(); + for callback in &mut state.callbacks { + callback.update(&state.wrapped); + } + for window in state.windows.drain(..) { + window.redraw(); + } + for waker in state.wakers.drain(..) { + waker.wake(); + } } + result }; drop(state); @@ -261,6 +285,7 @@ struct State { wrapped: GenerationalValue, callbacks: Vec>>, windows: Vec, + wakers: Vec, readers: usize, } @@ -345,6 +370,14 @@ impl DynamicReader { .map_or_else(PoisonError::into_inner, |g| g); } } + + /// Suspends the current async task until the contained value has been + /// updated or there are no remaining writers for the value. + /// + /// Returns true if a newly updated value was discovered. + pub fn block_until_updated_async(&mut self) -> BlockUntilUpdatedFuture<'_, T> { + BlockUntilUpdatedFuture(self) + } } impl Clone for DynamicReader { @@ -364,6 +397,30 @@ impl Drop for DynamicReader { } } +/// Suspends the current async task until the contained value has been +/// updated or there are no remaining writers for the value. +/// +/// Yeilds true if a newly updated value was discovered. +#[derive(Debug)] +#[must_use = "futures must be .await'ed to be executed"] +pub struct BlockUntilUpdatedFuture<'a, T>(&'a mut DynamicReader); + +impl<'a, T> Future for BlockUntilUpdatedFuture<'a, T> { + type Output = bool; + + fn poll(self: std::pin::Pin<&mut Self>, cx: &mut std::task::Context<'_>) -> Poll { + let mut state = self.0.source.state(); + if state.wrapped.generation != self.0.read_generation { + return Poll::Ready(true); + } else if state.readers == Arc::strong_count(&self.0.source) { + return Poll::Ready(false); + } + + state.wakers.push(cx.waker().clone()); + Poll::Pending + } +} + #[test] fn disconnecting_reader_from_dynamic() { let value = Dynamic::new(1); diff --git a/src/widgets/input.rs b/src/widgets/input.rs index 7dfd29a..4670ceb 100644 --- a/src/widgets/input.rs +++ b/src/widgets/input.rs @@ -1,8 +1,9 @@ use std::cmp::Ordering; use std::fmt::Debug; +use std::panic::UnwindSafe; use std::time::Duration; -use kludgine::app::winit::event::Ime; +use kludgine::app::winit::event::{Ime, KeyEvent}; use kludgine::app::winit::keyboard::Key; use kludgine::cosmic_text::{Action, Attrs, Buffer, Cursor, Edit, Editor, Metrics, Shaping}; use kludgine::figures::units::Px; @@ -18,7 +19,7 @@ use crate::styles::components::{HighlightColor, LineHeight, TextColor, TextSize} use crate::styles::Styles; use crate::utils::ModifiersExt; use crate::value::{Generation, IntoValue, Value}; -use crate::widget::{EventHandling, Widget, HANDLED, IGNORED}; +use crate::widget::{Callback, EventHandling, Widget, HANDLED, IGNORED}; const CURSOR_BLINK_DURATION: Duration = Duration::from_millis(500); @@ -27,6 +28,7 @@ const CURSOR_BLINK_DURATION: Duration = Duration::from_millis(500); pub struct Input { /// The value of this widget. pub text: Value, + on_key: Option>, editor: Option, cursor_state: CursorState, } @@ -43,9 +45,22 @@ impl Input { text: initial_text.into_value(), editor: None, cursor_state: CursorState::default(), + on_key: None, } } + /// Sets the `on_key` callback. + /// + /// This function is called for every keyboard input event. If [`HANDLED`] + /// is returned, this widget will ignore the event. + pub fn on_key(mut self, on_key: F) -> Self + where + F: FnMut(KeyEvent) -> EventHandling + Send + UnwindSafe + 'static, + { + self.on_key = Some(Callback::new(on_key)); + self + } + fn editor_mut(&mut self, kludgine: &mut Kludgine, styles: &Styles) -> &mut Editor { match (&self.editor, self.text.generation()) { (Some(editor), generation) if editor.generation == generation => {} @@ -315,6 +330,10 @@ impl Widget for Input { _is_synthetic: bool, context: &mut EventContext<'_, '_>, ) -> EventHandling { + if let Some(on_key) = &mut self.on_key { + on_key.invoke(input.clone())?; + } + if !input.state.is_pressed() { return IGNORED; } @@ -322,11 +341,11 @@ impl Widget for Input { let styles = context.query_styles(&[&TextColor]); let editor = self.editor_mut(context.kludgine, &styles); - println!( - "Keyboard input: {:?}. {:?}, {:?}", - input.logical_key, input.text, input.physical_key - ); - let handled = match (input.logical_key, input.text) { + // println!( + // "Keyboard input: {:?}. {:?}, {:?}", + // input.logical_key, input.text, input.physical_key + // ); + let (text_changed, handled) = match (input.logical_key, input.text) { (key @ (Key::Backspace | Key::Delete), _) => { editor.action( context.kludgine.font_system(), @@ -336,7 +355,7 @@ impl Widget for Input { _ => unreachable!("previously matched"), }, ); - HANDLED + (true, HANDLED) } (key @ (Key::ArrowLeft | Key::ArrowDown | Key::ArrowUp | Key::ArrowRight), _) => { let modifiers = context.modifiers(); @@ -362,18 +381,30 @@ impl Widget for Input { _ => unreachable!("previously matched"), }, ); - HANDLED + (false, HANDLED) } (_, Some(text)) if !context.modifiers().state().primary() => { editor.insert_string(&text, None); - HANDLED + (true, HANDLED) } - (_, _) => IGNORED, + (_, _) => (false, IGNORED), }; if handled.is_break() { context.set_needs_redraw(); self.cursor_state.force_on(); + if text_changed { + if let Value::Dynamic(value) = &self.text { + let state = self.editor.as_mut().expect("just_used"); + value.map_mut(|text| { + text.clear(); + for line in &state.editor.buffer().lines { + text.push_str(line.text()); + } + }); + state.generation = Some(value.generation()); + } + } } handled diff --git a/src/widgets/scroll.rs b/src/widgets/scroll.rs index d087d30..532a029 100644 --- a/src/widgets/scroll.rs +++ b/src/widgets/scroll.rs @@ -48,27 +48,52 @@ impl ChildWidget { pub struct Scroll { contents: ChildWidget, content_size: Size, - scroll: Point, - max_scroll: Point, + control_size: Size, + scroll: Dynamic>, + enabled: Point, + max_scroll: Dynamic>, scrollbar_opacity: Dynamic, scrollbar_opacity_animation: AnimationHandle, } impl Scroll { /// Returns a new scroll widget containing `contents`. - pub fn new(contents: impl MakeWidget) -> Self { + fn construct(contents: impl MakeWidget, enabled: Point) -> Self { Self { contents: ChildWidget::Instance(contents.make_widget()), + enabled, content_size: Size::default(), - scroll: Point::default(), - max_scroll: Point::default(), + control_size: Size::default(), + scroll: Dynamic::new(Point::default()), + max_scroll: Dynamic::new(Point::default()), scrollbar_opacity: Dynamic::default(), scrollbar_opacity_animation: AnimationHandle::new(), } } + /// Returns a new scroll widget containing `contents` that allows scrolling + /// vertically or horizontally. + pub fn new(contents: impl MakeWidget) -> Self { + Self::construct(contents, Point::new(true, true)) + } + + /// Returns a new scroll widget that allows scrolling `contents` + /// horizontally. + pub fn horizontal(contents: impl MakeWidget) -> Self { + Self::construct(contents, Point::new(true, false)) + } + + /// Returns a new scroll widget that allows scrolling `contents` vertically. + pub fn vertical(contents: impl MakeWidget) -> Self { + Self::construct(contents, Point::new(false, true)) + } + fn constrain_scroll(&mut self) { - self.scroll = self.scroll.max(self.max_scroll).min(Point::default()); + let scroll = self.scroll.get(); + let clamped = scroll.max(self.max_scroll.get()).min(Point::default()); + if clamped != scroll { + self.scroll.set(clamped); + } } } @@ -111,29 +136,68 @@ impl Widget for Scroll { .get_or_default(&ScrollBarThickness) .into_px(context.graphics.scale()); + let mut scroll = self.scroll.get(); + let current_max_scroll = self.max_scroll.get(); + + let control_size = context.graphics.region().size; let max_extents = Size::new( - ConstraintLimit::ClippedAfter(UPx::MAX - self.scroll.x.into_unsigned()), - ConstraintLimit::ClippedAfter(UPx::MAX - self.scroll.y.into_unsigned()), + if self.enabled.x { + ConstraintLimit::ClippedAfter(UPx::MAX - scroll.x.into_unsigned()) + } else { + ConstraintLimit::Known(control_size.width.into_unsigned()) + }, + if self.enabled.y { + ConstraintLimit::ClippedAfter(UPx::MAX - scroll.y.into_unsigned()) + } else { + ConstraintLimit::Known(control_size.height.into_unsigned()) + }, ); let managed = self.contents.managed(&mut context.as_event_context()); - self.content_size = context + let new_content_size = context .for_other(&managed) .measure(max_extents) .into_signed(); - let control_size = context.graphics.region().size; + + let horizontal_bar = scrollbar_region(scroll.x, new_content_size.width, control_size.width); + let max_scroll_x = if self.enabled.x { + -horizontal_bar.amount_hidden + } else { + Px(0) + }; + + let vertical_bar = scrollbar_region(scroll.y, new_content_size.height, control_size.height); + let max_scroll_y = if self.enabled.y { + -vertical_bar.amount_hidden + } else { + Px(0) + }; + + // Preserve the current scroll if the widget has resized + if self.content_size.width != new_content_size.width + || self.control_size.width != control_size.width + { + self.content_size.width = new_content_size.width; + let scroll_pct = scroll.x.into_float() / current_max_scroll.x.into_float(); + scroll.x = max_scroll_x * scroll_pct; + } + + if self.content_size.height != new_content_size.height + || self.control_size.height != control_size.height + { + self.content_size.height = new_content_size.height; + let scroll_pct = scroll.y.into_float() / current_max_scroll.y.into_float(); + scroll.y = max_scroll_y * scroll_pct; + } + self.scroll.update(scroll); let region = Rect::new( - self.scroll, + scroll, self.content_size - .min(Size::new(Px::MAX, Px::MAX) - self.scroll.max(Point::default())), + .min(Size::new(Px::MAX, Px::MAX) - scroll.max(Point::default())), ); context.for_child(&managed, region).redraw(); - let horizontal_bar = - scrollbar_region(self.scroll.x, self.content_size.width, control_size.width); - self.max_scroll.x = -horizontal_bar.amount_hidden; - - if horizontal_bar.size > 0 { + if max_scroll_x != 0 { context.graphics.draw_shape( &Shape::filled_rect( Rect::new( @@ -148,11 +212,7 @@ impl Widget for Scroll { ); } - let vertical_bar = - scrollbar_region(self.scroll.y, self.content_size.height, control_size.height); - self.max_scroll.y = -vertical_bar.amount_hidden; - - if vertical_bar.size > 0 { + if max_scroll_y != 0 { context.graphics.draw_shape( &Shape::filled_rect( Rect::new( @@ -166,6 +226,10 @@ impl Widget for Scroll { None, ); } + + self.control_size = control_size; + self.max_scroll + .update(Point::new(max_scroll_x, max_scroll_y)); } fn measure( @@ -191,7 +255,7 @@ impl Widget for Scroll { } }; - self.scroll += amount.cast(); + self.scroll.map_mut(|scroll| *scroll += amount.cast()); context.set_needs_redraw(); // TODO make this only returned handled if we actually scrolled. diff --git a/src/window.rs b/src/window.rs index f6e35ec..8b93d59 100644 --- a/src/window.rs +++ b/src/window.rs @@ -297,7 +297,7 @@ where if !handled { match input.physical_key { KeyCode::KeyW - if window.modifiers().state().primary() && dbg!(input.state).is_pressed() => + if window.modifiers().state().primary() && input.state.is_pressed() => { if self.request_close(&mut window) { window.set_needs_redraw(); diff --git a/src/with_clone.rs b/src/with_clone.rs index 17b94ec..9050b7b 100644 --- a/src/with_clone.rs +++ b/src/with_clone.rs @@ -1,5 +1,3 @@ -use crate::impl_all_tuples; - /// Invokes a function with a clone of `self`. pub trait WithClone: Sized { /// The type that results from cloning.