From a04619a2792a088fec49c6d82eedc12a0fd3137a Mon Sep 17 00:00:00 2001 From: Jonathan Johnson Date: Tue, 14 Nov 2023 07:36:22 -0800 Subject: [PATCH 01/24] Layout caching, Lerp underflow fix, label fix --- Cargo.lock | 10 +-- examples/containers.rs | 2 +- src/animation.rs | 25 +++--- src/context.rs | 68 +++++++++++++--- src/styles.rs | 89 ++++++++++++++++++-- src/tick.rs | 12 +-- src/tree.rs | 170 +++++++++++++++++++++++++-------------- src/utils.rs | 15 +++- src/value.rs | 118 ++++++++++++++++++--------- src/widget.rs | 26 +++--- src/widgets/container.rs | 8 +- src/widgets/input.rs | 2 +- src/widgets/label.rs | 19 +++-- src/widgets/slider.rs | 2 +- src/widgets/stack.rs | 40 ++++----- src/window.rs | 18 +++-- 16 files changed, 440 insertions(+), 184 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 12ee340..6fff768 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -969,7 +969,7 @@ checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" [[package]] name = "kludgine" version = "0.1.0" -source = "git+https://github.com/khonsulabs/kludgine#a26299823498dccbbbb3c28abc820b660fcc1289" +source = "git+https://github.com/khonsulabs/kludgine#09790aafb5a9c3b0da034387adead9960eb06bc7" dependencies = [ "ahash", "alot", @@ -1807,9 +1807,9 @@ checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" [[package]] name = "rustix" -version = "0.38.21" +version = "0.38.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b426b0506e5d50a7d8dafcf2e81471400deb602392c7dd110815afb4eaf02a3" +checksum = "ffb93593068e9babdad10e4fce47dc9b3ac25315a72a59766ffd9e9a71996a04" dependencies = [ "bitflags 2.4.1", "errno", @@ -2031,9 +2031,9 @@ dependencies = [ [[package]] name = "termcolor" -version = "1.3.0" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6093bad37da69aab9d123a8091e4be0aa4a03e4d601ec641c327398315f62b64" +checksum = "ff1bc3d3f05aff0403e8ac0d92ced918ec05b666a43f83297ccef5bea8a3d449" dependencies = [ "winapi-util", ] diff --git a/examples/containers.rs b/examples/containers.rs index 2f2c363..3a703e0 100644 --- a/examples/containers.rs +++ b/examples/containers.rs @@ -6,7 +6,7 @@ use gooey::Run; fn main() -> gooey::Result { let theme_mode = Dynamic::default(); - set_of_containers(1, theme_mode.clone()) + set_of_containers(3, theme_mode.clone()) .centered() .into_window() .with_theme_mode(theme_mode) diff --git a/src/animation.rs b/src/animation.rs index ba99798..7a55f08 100644 --- a/src/animation.rs +++ b/src/animation.rs @@ -43,7 +43,7 @@ use std::fmt::{Debug, Display}; use std::ops::{ControlFlow, Deref, Div, Mul}; use std::panic::{RefUnwindSafe, UnwindSafe}; use std::str::FromStr; -use std::sync::{Arc, Condvar, Mutex, MutexGuard, OnceLock, PoisonError}; +use std::sync::{Arc, Condvar, Mutex, MutexGuard, OnceLock}; use std::thread; use std::time::{Duration, Instant}; @@ -54,7 +54,8 @@ use kludgine::figures::Ranged; use kludgine::Color; use crate::animation::easings::Linear; -use crate::styles::Component; +use crate::styles::{Component, RequireInvalidation}; +use crate::utils::IgnorePoison; use crate::value::Dynamic; static ANIMATIONS: Mutex = Mutex::new(Animating::new()); @@ -65,9 +66,7 @@ fn thread_state() -> MutexGuard<'static, Animating> { THREAD.get_or_init(|| { thread::spawn(animation_thread); }); - ANIMATIONS - .lock() - .map_or_else(PoisonError::into_inner, |g| g) + ANIMATIONS.lock().ignore_poison() } fn animation_thread() { @@ -75,9 +74,7 @@ fn animation_thread() { loop { if state.running.is_empty() { state.last_updated = None; - state = NEW_ANIMATIONS - .wait(state) - .map_or_else(PoisonError::into_inner, |g| g); + state = NEW_ANIMATIONS.wait(state).ignore_poison(); } else { let start = Instant::now(); let last_tick = state.last_updated.unwrap_or(start); @@ -641,9 +638,9 @@ macro_rules! impl_lerp_for_uint { fn lerp(&self, target: &Self, percent: f32) -> Self { let percent = $float::from(percent); if let Some(delta) = target.checked_sub(*self) { - *self + (delta as $float * percent).round() as $type + self.saturating_add((delta as $float * percent).round() as $type) } else { - *self - ((*self - *target) as $float * percent).round() as $type + self.saturating_sub(((*self - *target) as $float * percent).round() as $type) } } } @@ -701,8 +698,10 @@ impl PercentBetween for bool { fn integer_lerps() { #[track_caller] fn test_lerps(a: &T, b: &T, mid: &T) { + assert_eq!(&b.lerp(a, 1.), a); assert_eq!(&a.lerp(b, 1.), b); assert_eq!(&a.lerp(b, 0.), a); + assert_eq!(&b.lerp(a, 0.), b); assert_eq!(&a.lerp(b, 0.5), mid); } @@ -1012,6 +1011,12 @@ impl TryFrom for EasingFunction { } } +impl RequireInvalidation for EasingFunction { + fn requires_invalidation(&self) -> bool { + false + } +} + /// Performs easing for value interpolation. pub trait Easing: Debug + Send + Sync + RefUnwindSafe + UnwindSafe + 'static { /// Eases a value ranging between zero and one. The resulting value does not diff --git a/src/context.rs b/src/context.rs index f0555db..e487a18 100644 --- a/src/context.rs +++ b/src/context.rs @@ -1,9 +1,11 @@ //! Types that provide access to the Gooey runtime. use std::borrow::Cow; +use std::hash::Hash; use std::ops::{Deref, DerefMut}; use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::Arc; +use std::sync::{Arc, Mutex, MutexGuard}; +use kempt::Set; use kludgine::app::winit::event::{ DeviceId, Ime, KeyEvent, MouseButton, MouseScrollDelta, TouchPhase, }; @@ -15,6 +17,7 @@ use kludgine::{Color, Kludgine}; use crate::graphics::Graphics; use crate::styles::components::{HighlightColor, WidgetBackground}; use crate::styles::{ComponentDefinition, Styles, Theme, ThemePair, VisualOrder}; +use crate::utils::IgnorePoison; use crate::value::{Dynamic, IntoValue, Value}; use crate::widget::{EventHandling, ManagedWidget, WidgetId, WidgetInstance, WidgetRef}; use crate::window::sealed::WindowCommand; @@ -572,14 +575,23 @@ impl<'context, 'window, 'clip, 'gfx, 'pass> LayoutContext<'context, 'window, 'cl /// context's widget and returns the result. pub fn layout(&mut self, available_space: Size) -> Size { if self.persist_layout { - self.graphics.current_node.reset_child_layouts(); + if let Some(cached) = self.graphics.current_node.begin_layout(available_space) { + return cached; + } } - self.graphics + let result = self + .graphics .current_node .clone() .lock() .as_widget() - .layout(available_space, self) + .layout(available_space, self); + if self.persist_layout { + self.graphics + .current_node + .persist_layout(available_space, result); + } + result } /// Sets the layout for `child` to `layout`. @@ -664,7 +676,7 @@ impl<'window> AsEventContext<'window> for GraphicsContext<'_, 'window, '_, '_, ' /// specific widget. pub struct WidgetContext<'context, 'window> { current_node: ManagedWidget, - redraw_status: &'context RedrawStatus, + redraw_status: &'context InvalidationStatus, window: &'context mut RunningWindow<'window>, theme: Cow<'context, ThemePair>, pending_state: PendingState<'context>, @@ -675,7 +687,7 @@ pub struct WidgetContext<'context, 'window> { impl<'context, 'window> WidgetContext<'context, 'window> { pub(crate) fn new( current_node: ManagedWidget, - redraw_status: &'context RedrawStatus, + redraw_status: &'context InvalidationStatus, theme: &'context ThemePair, window: &'context mut RunningWindow<'window>, theme_mode: ThemeMode, @@ -755,6 +767,11 @@ impl<'context, 'window> WidgetContext<'context, 'window> { value.redraw_when_changed(self.handle()); } + /// Ensures that this widget will be redrawn when `value` has been updated. + pub fn invalidate_when_changed(&self, value: &Dynamic) { + value.invalidate_when_changed(self.handle(), self.current_node.id()); + } + /// Returns the last layout of this widget. #[must_use] pub fn last_layout(&self) -> Option> { @@ -963,7 +980,24 @@ impl<'context, 'window> WidgetContext<'context, 'window> { pub(crate) struct WindowHandle { kludgine: kludgine::app::WindowHandle, - redraw_status: RedrawStatus, + redraw_status: InvalidationStatus, +} + +impl Eq for WindowHandle {} + +impl PartialEq for WindowHandle { + fn eq(&self, other: &Self) -> bool { + Arc::ptr_eq( + &self.redraw_status.invalidated, + &other.redraw_status.invalidated, + ) + } +} + +impl Hash for WindowHandle { + fn hash(&self, state: &mut H) { + Arc::as_ptr(&self.redraw_status.invalidated).hash(state); + } } impl WindowHandle { @@ -972,6 +1006,12 @@ impl WindowHandle { let _result = self.kludgine.send(WindowCommand::Redraw); } } + + pub fn invalidate(&self, widget: WidgetId) { + if self.redraw_status.invalidate(widget) { + self.redraw(); + } + } } impl dyn AsEventContext<'_> {} @@ -1036,11 +1076,12 @@ impl DerefMut for PendingState<'_> { } #[derive(Default, Clone)] -pub(crate) struct RedrawStatus { +pub(crate) struct InvalidationStatus { refresh_sent: Arc, + invalidated: Arc>>, } -impl RedrawStatus { +impl InvalidationStatus { pub fn should_send_refresh(&self) -> bool { self.refresh_sent .compare_exchange(false, true, Ordering::Release, Ordering::Acquire) @@ -1050,6 +1091,15 @@ impl RedrawStatus { pub fn refresh_received(&self) { self.refresh_sent.store(false, Ordering::Release); } + + pub fn invalidate(&self, widget: WidgetId) -> bool { + let mut invalidated = self.invalidated.lock().ignore_poison(); + invalidated.insert(widget) + } + + pub fn invalidations(&self) -> MutexGuard<'_, Set> { + self.invalidated.lock().ignore_poison() + } } /// A type chat can convert to a [`ManagedWidget`] through a [`WidgetContext`]. diff --git a/src/styles.rs b/src/styles.rs index 650e9d6..4446f97 100644 --- a/src/styles.rs +++ b/src/styles.rs @@ -86,8 +86,17 @@ impl Styles { self.0 .get(&name) .and_then(|component| { - component.redraw_when_changed(context); - ::try_from_component(component.get()).ok() + match ::try_from_component(component.get()) { + Ok(value) => { + if value.requires_invalidation() { + component.invalidate_when_changed(context); + } else { + component.redraw_when_changed(context); + } + Some(value) + } + Err(_) => None, + } }) .unwrap_or_else(|| component.default_value(context)) } @@ -219,6 +228,12 @@ impl TryFrom for Color { } } +impl RequireInvalidation for Color { + fn requires_invalidation(&self) -> bool { + false + } +} + impl From for Component { fn from(value: Dimension) -> Self { Self::Dimension(value) @@ -236,6 +251,12 @@ impl TryFrom for Dimension { } } +impl RequireInvalidation for Dimension { + fn requires_invalidation(&self) -> bool { + true + } +} + impl From for Component { fn from(value: Px) -> Self { Self::from(Dimension::from(value)) @@ -253,6 +274,12 @@ impl TryFrom for Px { } } +impl RequireInvalidation for Px { + fn requires_invalidation(&self) -> bool { + true + } +} + impl From for Component { fn from(value: Lp) -> Self { Self::from(Dimension::from(value)) @@ -270,6 +297,12 @@ impl TryFrom for Lp { } } +impl RequireInvalidation for Lp { + fn requires_invalidation(&self) -> bool { + true + } +} + /// A 1-dimensional measurement that may be automatically calculated. #[derive(Debug, Clone, Copy)] pub enum FlexibleDimension { @@ -563,6 +596,12 @@ impl TryFrom for DimensionRange { } } +impl RequireInvalidation for DimensionRange { + fn requires_invalidation(&self) -> bool { + true + } +} + /// A custom component value. #[derive(Debug, Clone)] pub struct CustomComponent(Arc); @@ -571,7 +610,7 @@ impl CustomComponent { /// Wraps an arbitrary value so that it can be used as a [`Component`]. pub fn new(value: T) -> Self where - T: RefUnwindSafe + UnwindSafe + Debug + Send + Sync + 'static, + T: RequireInvalidation + RefUnwindSafe + UnwindSafe + Debug + Send + Sync + 'static, { Self(Arc::new(value)) } @@ -587,6 +626,12 @@ impl CustomComponent { } } +impl RequireInvalidation for CustomComponent { + fn requires_invalidation(&self) -> bool { + self.0.requires_invalidation() + } +} + impl ComponentType for CustomComponent { fn into_component(self) -> Component { Component::Custom(self) @@ -600,13 +645,13 @@ impl ComponentType for CustomComponent { } } -trait AnyComponent: Send + Sync + RefUnwindSafe + UnwindSafe + Debug { +trait AnyComponent: RequireInvalidation + Send + Sync + RefUnwindSafe + UnwindSafe + Debug { fn as_any(&self) -> &dyn Any; } impl AnyComponent for T where - T: RefUnwindSafe + UnwindSafe + Debug + Send + Sync + 'static, + T: RequireInvalidation + RefUnwindSafe + UnwindSafe + Debug + Send + Sync + 'static, { fn as_any(&self) -> &dyn Any { self @@ -654,8 +699,20 @@ pub trait ComponentDefinition: NamedComponent { fn default_value(&self, context: &WidgetContext<'_, '_>) -> Self::ComponentType; } +/// Describes whether a type should invalidate a widget. +pub trait RequireInvalidation { + /// Gooey tracks two different states: + /// + /// - Whether to repaint the window + /// - Whether to relayout a widget + /// + /// If a value change of `self` may require a relayout, this should return + /// true. + fn requires_invalidation(&self) -> bool; +} + /// A type that can be converted to and from [`Component`]. -pub trait ComponentType: Sized { +pub trait ComponentType: RequireInvalidation + Sized { /// Returns this type, wrapped in a [`Component`]. fn into_component(self) -> Component; /// Attempts to extract this type from `component`. If `component` does not @@ -665,7 +722,7 @@ pub trait ComponentType: Sized { impl ComponentType for T where - T: Into + TryFrom, + T: RequireInvalidation + Into + TryFrom, { fn into_component(self) -> Component { self.into() @@ -1411,6 +1468,12 @@ impl TryFrom for VisualOrder { } } +impl RequireInvalidation for VisualOrder { + fn requires_invalidation(&self) -> bool { + true + } +} + /// A horizontal direction. #[derive(Debug, Copy, Clone, Eq, PartialEq)] pub enum HorizontalOrder { @@ -1514,6 +1577,12 @@ impl TryFrom for FocusableWidgets { } } +impl RequireInvalidation for FocusableWidgets { + fn requires_invalidation(&self) -> bool { + false + } +} + /// A description of the level of depth a /// [`Container`](crate::widgets::Container) is nested at. #[derive(Default, Debug, Clone, Copy, Eq, PartialEq, Ord, PartialOrd)] @@ -1563,6 +1632,12 @@ impl TryFrom for ContainerLevel { } } +impl RequireInvalidation for ContainerLevel { + fn requires_invalidation(&self) -> bool { + true + } +} + /// A builder of [`ColorScheme`]s. #[derive(Clone, Copy, Debug)] pub struct ColorSchemeBuilder { diff --git a/src/tick.rs b/src/tick.rs index d57ed02..ad3f6c7 100644 --- a/src/tick.rs +++ b/src/tick.rs @@ -1,12 +1,13 @@ use std::collections::HashSet; use std::sync::atomic::{AtomicUsize, Ordering}; -use std::sync::{Arc, Condvar, Mutex, MutexGuard, PoisonError}; +use std::sync::{Arc, Condvar, Mutex, MutexGuard}; use std::time::{Duration, Instant}; use kludgine::app::winit::event::KeyEvent; use kludgine::app::winit::keyboard::Key; use crate::context::WidgetContext; +use crate::utils::IgnorePoison; use crate::value::Dynamic; use crate::widget::{EventHandling, HANDLED, IGNORED}; @@ -123,9 +124,7 @@ struct TickData { impl TickData { fn state(&self) -> MutexGuard<'_, TickState> { - self.state - .lock() - .map_or_else(PoisonError::into_inner, |g| g) + self.state.lock().ignore_poison() } } @@ -173,10 +172,7 @@ where while state.keep_running { let current_frame = data.rendered_frame.load(Ordering::Acquire); if state.frame == current_frame { - state = data - .sync - .wait(state) - .map_or_else(PoisonError::into_inner, |g| g); + state = data.sync.wait(state).ignore_poison(); } else { break; } diff --git a/src/tree.rs b/src/tree.rs index 58c5e71..927e69b 100644 --- a/src/tree.rs +++ b/src/tree.rs @@ -1,15 +1,17 @@ use std::mem; -use std::sync::{Arc, Mutex, PoisonError}; +use std::sync::{Arc, Mutex}; use ahash::AHashMap; use alot::{LotId, Lots}; -use kludgine::figures::units::Px; -use kludgine::figures::{Point, Rect}; +use kludgine::figures::units::{Px, UPx}; +use kludgine::figures::{Point, Rect, Size}; use crate::styles::{Styles, ThemePair, VisualOrder}; +use crate::utils::IgnorePoison; use crate::value::Value; use crate::widget::{ManagedWidget, WidgetId, WidgetInstance}; use crate::window::ThemeMode; +use crate::ConstraintLimit; #[derive(Clone, Default)] pub struct Tree { @@ -22,7 +24,7 @@ impl Tree { widget: WidgetInstance, parent: Option<&ManagedWidget>, ) -> ManagedWidget { - let mut data = self.data.lock().map_or_else(PoisonError::into_inner, |g| g); + let mut data = self.data.lock().ignore_poison(); let id = widget.id(); let (effective_styles, parent_id) = if let Some(parent) = parent { ( @@ -36,6 +38,7 @@ impl Tree { widget: widget.clone(), children: Vec::new(), parent: parent_id, + last_layout_query: None, layout: None, associated_styles: None, effective_styles, @@ -68,7 +71,7 @@ impl Tree { } pub fn remove_child(&self, child: &ManagedWidget, parent: &ManagedWidget) { - let mut data = self.data.lock().map_or_else(PoisonError::into_inner, |g| g); + let mut data = self.data.lock().ignore_poison(); data.remove_child(child.node_id, parent.node_id); if child.widget.is_default() { @@ -80,7 +83,7 @@ impl Tree { } pub(crate) fn set_layout(&self, widget: LotId, rect: Rect) { - let mut data = self.data.lock().map_or_else(PoisonError::into_inner, |g| g); + let mut data = self.data.lock().ignore_poison(); let node = &mut data.nodes[widget]; node.layout = Some(rect); @@ -98,26 +101,62 @@ impl Tree { } pub(crate) fn layout(&self, widget: LotId) -> Option> { - let data = self.data.lock().map_or_else(PoisonError::into_inner, |g| g); + let data = self.data.lock().ignore_poison(); data.nodes.get(widget).and_then(|widget| widget.layout) } - pub(crate) fn reset_render_order(&self) { - let mut data = self.data.lock().map_or_else(PoisonError::into_inner, |g| g); + pub(crate) fn new_frame(&self, invalidations: impl IntoIterator) { + let mut data = self.data.lock().ignore_poison(); data.render_order.clear(); + + for id in invalidations { + let Some(id) = data.nodes_by_id.get(&id).copied() else { + continue; + }; + + data.invalidate(id, true); + } } pub(crate) fn note_widget_rendered(&self, widget: LotId) { - let mut data = self.data.lock().map_or_else(PoisonError::into_inner, |g| g); + let mut data = self.data.lock().ignore_poison(); data.render_order.push(widget); } - pub(crate) fn reset_child_layouts(&self, parent: LotId) { - let mut data = self.data.lock().map_or_else(PoisonError::into_inner, |g| g); - let children = data.nodes[parent].children.clone(); - for child in children { - data.nodes.get_mut(child).expect("missing widget").layout = None; + pub(crate) fn begin_layout( + &self, + parent: LotId, + constraints: Size, + ) -> Option> { + let mut data = self.data.lock().ignore_poison(); + + let node = &mut data.nodes[parent]; + if let Some(cached_layout) = &node.last_layout_query { + if constraints.width.max() < cached_layout.constraints.width.max() + && constraints.height.max() < cached_layout.constraints.height.max() + { + return Some(cached_layout.size); + } + + node.last_layout_query = None; } + + let children = node.children.clone(); + for child in children { + data.invalidate(child, false); + } + + None + } + + pub(crate) fn persist_layout( + &self, + id: LotId, + constraints: Size, + size: Size, + ) { + let mut data = self.data.lock().ignore_poison(); + data.nodes[id].last_layout_query = Some(CachedLayoutQuery { constraints, size }); } pub(crate) fn visually_ordered_children( @@ -125,7 +164,7 @@ impl Tree { parent: LotId, order: VisualOrder, ) -> Vec { - let data = self.data.lock().map_or_else(PoisonError::into_inner, |g| g); + let data = self.data.lock().ignore_poison(); let node = &data.nodes[parent]; let mut unordered = node.children.clone(); let mut ordered = Vec::::with_capacity(unordered.len()); @@ -182,12 +221,12 @@ impl Tree { } pub(crate) fn effective_styles(&self, id: LotId) -> Styles { - let data = self.data.lock().map_or_else(PoisonError::into_inner, |g| g); + let data = self.data.lock().ignore_poison(); data.nodes[id].effective_styles.clone() } pub(crate) fn hover(&self, new_hover: Option<&ManagedWidget>) -> HoverResults { - let mut data = self.data.lock().map_or_else(PoisonError::into_inner, |g| g); + let mut data = self.data.lock().ignore_poison(); let hovered = new_hover .map(|new_hover| data.widget_hierarchy(new_hover.node_id, self)) .unwrap_or_default(); @@ -209,7 +248,7 @@ impl Tree { } pub fn focus(&self, new_focus: Option<&ManagedWidget>) -> Result, ()> { - let mut data = self.data.lock().map_or_else(PoisonError::into_inner, |g| g); + let mut data = self.data.lock().ignore_poison(); data.update_tracked_widget(new_focus, self, |data| &mut data.focus) } @@ -217,54 +256,38 @@ impl Tree { &self, new_active: Option<&ManagedWidget>, ) -> Result, ()> { - let mut data = self.data.lock().map_or_else(PoisonError::into_inner, |g| g); + let mut data = self.data.lock().ignore_poison(); data.update_tracked_widget(new_active, self, |data| &mut data.active) } pub fn widget(&self, id: WidgetId) -> Option { - let data = self.data.lock().map_or_else(PoisonError::into_inner, |g| g); + let data = self.data.lock().ignore_poison(); data.widget_from_id(id, self) } pub(crate) fn widget_from_node(&self, id: LotId) -> Option { - let data = self.data.lock().map_or_else(PoisonError::into_inner, |g| g); + let data = self.data.lock().ignore_poison(); data.widget_from_node(id, self) } pub(crate) fn active_widget(&self) -> Option { - self.data - .lock() - .map_or_else(PoisonError::into_inner, |g| g) - .active + self.data.lock().ignore_poison().active } pub(crate) fn hovered_widget(&self) -> Option { - self.data - .lock() - .map_or_else(PoisonError::into_inner, |g| g) - .hover + self.data.lock().ignore_poison().hover } pub(crate) fn default_widget(&self) -> Option { - self.data - .lock() - .map_or_else(PoisonError::into_inner, |g| g) - .defaults - .last() - .copied() + self.data.lock().ignore_poison().defaults.last().copied() } pub(crate) fn escape_widget(&self) -> Option { - self.data - .lock() - .map_or_else(PoisonError::into_inner, |g| g) - .escapes - .last() - .copied() + self.data.lock().ignore_poison().escapes.last().copied() } pub(crate) fn is_hovered(&self, id: LotId) -> bool { - let data = self.data.lock().map_or_else(PoisonError::into_inner, |g| g); + let data = self.data.lock().ignore_poison(); let mut search = data.hover; while let Some(hovered) = search { if hovered == id { @@ -277,14 +300,11 @@ impl Tree { } pub(crate) fn focused_widget(&self) -> Option { - self.data - .lock() - .map_or_else(PoisonError::into_inner, |g| g) - .focus + self.data.lock().ignore_poison().focus } pub(crate) fn widgets_at_point(&self, point: Point) -> Vec { - let data = self.data.lock().map_or_else(PoisonError::into_inner, |g| g); + let data = self.data.lock().ignore_poison(); let mut hits = Vec::new(); for id in data.render_order.iter().rev() { if let Some(last_rendered) = data.nodes.get(*id).and_then(|widget| widget.layout) { @@ -297,22 +317,22 @@ impl Tree { } pub(crate) fn parent(&self, id: LotId) -> Option { - let data = self.data.lock().map_or_else(PoisonError::into_inner, |g| g); + let data = self.data.lock().ignore_poison(); data.nodes.get(id).expect("missing widget").parent } pub(crate) fn attach_styles(&self, id: LotId, styles: Value) { - let mut data = self.data.lock().map_or_else(PoisonError::into_inner, |g| g); + let mut data = self.data.lock().ignore_poison(); data.attach_styles(id, styles); } pub(crate) fn attach_theme(&self, id: LotId, theme: Value) { - let mut data = self.data.lock().map_or_else(PoisonError::into_inner, |g| g); + let mut data = self.data.lock().ignore_poison(); data.nodes.get_mut(id).expect("missing widget").theme = Some(theme); } pub(crate) fn attach_theme_mode(&self, id: LotId, theme: Value) { - let mut data = self.data.lock().map_or_else(PoisonError::into_inner, |g| g); + let mut data = self.data.lock().ignore_poison(); data.nodes.get_mut(id).expect("missing widget").theme_mode = Some(theme); } @@ -320,7 +340,7 @@ impl Tree { &self, id: LotId, ) -> (Styles, Option>, Option>) { - let data = self.data.lock().map_or_else(PoisonError::into_inner, |g| g); + let data = self.data.lock().ignore_poison(); let node = data.nodes.get(id).expect("missing widget"); ( node.effective_styles.clone(), @@ -328,6 +348,13 @@ impl Tree { node.theme_mode.clone(), ) } + + pub fn invalidate(&self, id: LotId, include_hierarchy: bool) { + self.data + .lock() + .ignore_poison() + .invalidate(id, include_hierarchy); + } } pub(crate) struct HoverResults { @@ -453,17 +480,31 @@ impl TreeData { (None, _) => Ok(None), } } + + fn invalidate(&mut self, id: LotId, include_hierarchy: bool) { + let mut node = &mut self.nodes[id]; + while node.layout.is_some() { + node.layout = None; + node.last_layout_query = None; + + let (true, Some(parent)) = (include_hierarchy, node.parent) else { + break; + }; + node = &mut self.nodes[parent]; + } + } } -pub struct Node { - pub widget: WidgetInstance, - pub children: Vec, - pub parent: Option, - pub layout: Option>, - pub associated_styles: Option>, - pub effective_styles: Styles, - pub theme: Option>, - pub theme_mode: Option>, +struct Node { + widget: WidgetInstance, + children: Vec, + parent: Option, + layout: Option>, + last_layout_query: Option, + associated_styles: Option>, + effective_styles: Styles, + theme: Option>, + theme_mode: Option>, } impl Node { @@ -475,3 +516,8 @@ impl Node { effective_styles } } + +struct CachedLayoutQuery { + constraints: Size, + size: Size, +} diff --git a/src/utils.rs b/src/utils.rs index 8811dcc..228efa7 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,5 +1,5 @@ use std::ops::Deref; -use std::sync::OnceLock; +use std::sync::{OnceLock, PoisonError}; use kludgine::app::winit::event::Modifiers; use kludgine::app::winit::keyboard::ModifiersState; @@ -129,3 +129,16 @@ impl Deref for Lazy { self.once.get_or_init(self.init) } } + +pub trait IgnorePoison { + type Unwrapped; + fn ignore_poison(self) -> Self::Unwrapped; +} + +impl IgnorePoison for Result> { + type Unwrapped = T; + + fn ignore_poison(self) -> Self::Unwrapped { + self.map_or_else(PoisonError::into_inner, |g| g) + } +} diff --git a/src/value.rs b/src/value.rs index 3a08a6a..710a84b 100644 --- a/src/value.rs +++ b/src/value.rs @@ -5,15 +5,17 @@ use std::fmt::{Debug, Display}; use std::future::Future; use std::ops::{Deref, DerefMut}; use std::panic::AssertUnwindSafe; -use std::sync::{Arc, Condvar, Mutex, MutexGuard, PoisonError, TryLockError}; +use std::sync::{Arc, Condvar, Mutex, MutexGuard, TryLockError}; use std::task::{Poll, Waker}; use std::thread::ThreadId; +use ahash::AHashSet; use intentional::Assert; use crate::animation::{DynamicTransition, LinearInterpolate}; use crate::context::{WidgetContext, WindowHandle}; -use crate::utils::WithClone; +use crate::utils::{IgnorePoison, WithClone}; +use crate::widget::WidgetId; /// An instance of a value that provides APIs to observe and react to its /// contents. @@ -30,9 +32,10 @@ impl Dynamic { generation: Generation::default(), }, callbacks: Vec::new(), - windows: Vec::new(), + windows: AHashSet::new(), readers: 0, wakers: Vec::new(), + widgets: AHashSet::new(), }), during_callback_state: Mutex::default(), sync: AssertUnwindSafe(Condvar::new()), @@ -158,6 +161,10 @@ impl Dynamic { self.0.redraw_when_changed(window); } + pub(crate) fn invalidate_when_changed(&self, window: WindowHandle, widget: WidgetId) { + self.0.invalidate_when_changed(window, widget); + } + /// Returns a clone of the currently contained value. /// /// # Panics @@ -181,7 +188,7 @@ impl Dynamic { /// This function panics if this value is already locked by the current /// thread. #[must_use] - pub fn get_tracked(&self, context: &WidgetContext<'_, '_>) -> T + pub fn get_tracking_refresh(&self, context: &WidgetContext<'_, '_>) -> T where T: Clone, { @@ -189,6 +196,23 @@ impl Dynamic { self.get() } + /// Returns a clone of the currently contained value. + /// + /// `context` will be invalidated when the value is updated. + /// + /// # Panics + /// + /// This function panics if this value is already locked by the current + /// thread. + #[must_use] + pub fn get_tracking_invalidate(&self, context: &WidgetContext<'_, '_>) -> T + where + T: Clone, + { + context.invalidate_when_changed(self); + self.get() + } + /// Returns the currently stored value, replacing the current contents with /// `T::default()`. /// @@ -409,11 +433,7 @@ struct DynamicMutexGuard<'a, T> { impl<'a, T> Drop for DynamicMutexGuard<'a, T> { fn drop(&mut self) { - let mut during_state = self - .dynamic - .during_callback_state - .lock() - .map_or_else(PoisonError::into_inner, |g| g); + let mut during_state = self.dynamic.during_callback_state.lock().ignore_poison(); *during_state = None; drop(during_state); self.dynamic.sync.notify_all(); @@ -450,10 +470,7 @@ struct DynamicData { impl DynamicData { fn state(&self) -> Result, DeadlockError> { - let mut during_sync = self - .during_callback_state - .lock() - .map_or_else(PoisonError::into_inner, |g| g); + let mut during_sync = self.during_callback_state.lock().ignore_poison(); let current_thread_id = std::thread::current().id(); let guard = loop { @@ -466,10 +483,7 @@ impl DynamicData { return Err(DeadlockError) } Some(_) => { - during_sync = self - .sync - .wait(during_sync) - .map_or_else(PoisonError::into_inner, |g| g); + during_sync = self.sync.wait(during_sync).ignore_poison(); } None => break, } @@ -487,7 +501,12 @@ impl DynamicData { pub fn redraw_when_changed(&self, window: WindowHandle) { let mut state = self.state().expect("deadlocked"); - state.windows.push(window); + state.windows.insert(window); + } + + pub fn invalidate_when_changed(&self, window: WindowHandle, widget: WidgetId) { + let mut state = self.state().expect("deadlocked"); + state.widgets.insert((window, widget)); } pub fn get(&self) -> Result, DeadlockError> @@ -579,7 +598,8 @@ impl Display for DeadlockError { struct State { wrapped: GenerationalValue, callbacks: Vec>>, - windows: Vec, + windows: AHashSet, + widgets: AHashSet<(WindowHandle, WidgetId)>, wakers: Vec, readers: usize, } @@ -591,7 +611,10 @@ impl State { for callback in &mut self.callbacks { callback.update(&self.wrapped); } - for window in self.windows.drain(..) { + for (window, widget) in self.widgets.drain() { + window.invalidate(widget); + } + for window in self.windows.drain() { window.redraw(); } for waker in self.wakers.drain(..) { @@ -726,11 +749,7 @@ impl DynamicReader { /// This function panics if this value is already locked by the current /// thread. pub fn block_until_updated(&mut self) -> bool { - let mut deadlock_state = self - .source - .during_callback_state - .lock() - .map_or_else(PoisonError::into_inner, |g| g); + let mut deadlock_state = self.source.during_callback_state.lock().ignore_poison(); assert!( deadlock_state .as_ref() @@ -739,11 +758,7 @@ impl DynamicReader { "deadlocked" ); loop { - let state = self - .source - .state - .lock() - .map_or_else(PoisonError::into_inner, |g| g); + let state = self.source.state.lock().ignore_poison(); if state.wrapped.generation != self.read_generation { return true; } else if state.readers == Arc::strong_count(&self.source) { @@ -752,11 +767,7 @@ impl DynamicReader { drop(state); // Wait for a notification of a change, which is synch - deadlock_state = self - .source - .sync - .wait(deadlock_state) - .map_or_else(PoisonError::into_inner, |g| g); + deadlock_state = self.source.sync.wait(deadlock_state).ignore_poison(); } } @@ -936,7 +947,11 @@ impl Value { /// /// If `self` is a dynamic, `context` will be invalidated when the value is /// updated. - pub fn map_tracked(&self, context: &WidgetContext<'_, '_>, map: impl FnOnce(&T) -> R) -> R { + pub fn map_tracking_redraw( + &self, + context: &WidgetContext<'_, '_>, + map: impl FnOnce(&T) -> R, + ) -> R { match self { Value::Constant(value) => map(value), Value::Dynamic(dynamic) => { @@ -946,6 +961,24 @@ impl Value { } } + /// Maps the current contents to `map` and returns the result. + /// + /// If `self` is a dynamic, `context` will be invalidated when the value is + /// updated. + pub fn map_tracking_invalidate( + &self, + context: &WidgetContext<'_, '_>, + map: impl FnOnce(&T) -> R, + ) -> R { + match self { + Value::Constant(value) => map(value), + Value::Dynamic(dynamic) => { + context.invalidate_when_changed(dynamic); + dynamic.map_ref(map) + } + } + } + /// Maps the current contents with exclusive access and returns the result. pub fn map_mut(&mut self, map: impl FnOnce(&mut T) -> R) -> R { match self { @@ -984,7 +1017,7 @@ impl Value { where T: Clone, { - self.map_tracked(context, Clone::clone) + self.map_tracking_redraw(context, Clone::clone) } /// Returns the current generation of the data stored, if the contained @@ -1004,6 +1037,15 @@ impl Value { context.redraw_when_changed(dynamic); } } + + /// Marks the widget for redraw when this value is updated. + /// + /// This function has no effect if the value is constant. + pub fn invalidate_when_changed(&self, context: &WidgetContext<'_, '_>) { + if let Value::Dynamic(dynamic) = self { + context.invalidate_when_changed(dynamic); + } + } } impl Clone for Value where @@ -1155,7 +1197,7 @@ macro_rules! impl_tuple_for_each { move |$var: &$type| { $(let $rvar = $rvar.lock();)+ let mut for_each = - for_each.lock().map_or_else(PoisonError::into_inner, |g| g); + for_each.lock().ignore_poison(); (for_each)(($(&$avar,)+)); } })); diff --git a/src/widget.rs b/src/widget.rs index 506e338..bd1c4ff 100644 --- a/src/widget.rs +++ b/src/widget.rs @@ -6,7 +6,7 @@ use std::fmt::Debug; use std::ops::{ControlFlow, Deref, DerefMut}; use std::panic::UnwindSafe; use std::sync::atomic::{self, AtomicU64}; -use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; +use std::sync::{Arc, Mutex, MutexGuard}; use alot::LotId; use kludgine::app::winit::event::{ @@ -22,6 +22,7 @@ use crate::styles::{ ThemePair, VisualOrder, }; use crate::tree::Tree; +use crate::utils::IgnorePoison; use crate::value::{IntoValue, Value}; use crate::widgets::{Align, Container, Expand, Resize, Scroll, Stack, Style}; use crate::window::{RunningWindow, ThemeMode, Window, WindowBehavior}; @@ -908,13 +909,9 @@ impl WidgetInstance { /// Locks the widget for exclusive access. Locking widgets should only be /// done for brief moments of time when you are certain no deadlocks can /// occur due to other widget locks being held. + #[must_use] pub fn lock(&self) -> WidgetGuard<'_> { - WidgetGuard( - self.data - .widget - .lock() - .map_or_else(PoisonError::into_inner, |g| g), - ) + WidgetGuard(self.data.widget.lock().ignore_poison()) } /// Runs this widget instance as an application. @@ -1041,6 +1038,11 @@ impl ManagedWidget { self.widget.lock() } + /// Invalidates this widget. + pub fn invalidate(&self) { + self.tree.invalidate(self.node_id, false); + } + pub(crate) fn set_layout(&self, rect: Rect) { self.tree.set_layout(self.node_id, rect); } @@ -1130,8 +1132,12 @@ impl ManagedWidget { self.tree.overriden_theme(self.node_id) } - pub(crate) fn reset_child_layouts(&self) { - self.tree.reset_child_layouts(self.node_id); + pub(crate) fn begin_layout(&self, constraints: Size) -> Option> { + self.tree.begin_layout(self.node_id, constraints) + } + + pub(crate) fn persist_layout(&self, constraints: Size, size: Size) { + self.tree.persist_layout(self.node_id, constraints, size); } pub(crate) fn visually_ordered_children(&self, order: VisualOrder) -> Vec { @@ -1343,7 +1349,7 @@ impl AsRef for WidgetRef { /// /// Each [`WidgetInstance`] is guaranteed to have a unique [`WidgetId`] across /// the lifetime of an application. -#[derive(Clone, Copy, Eq, PartialEq, Debug, Hash)] +#[derive(Clone, Copy, Eq, PartialEq, Debug, Hash, Ord, PartialOrd)] pub struct WidgetId(u64); impl WidgetId { diff --git a/src/widgets/container.rs b/src/widgets/container.rs index 34eb3ce..3b22687 100644 --- a/src/widgets/container.rs +++ b/src/widgets/container.rs @@ -6,7 +6,7 @@ use kludgine::Color; use crate::context::{GraphicsContext, LayoutContext, WidgetContext}; use crate::styles::components::{IntrinsicPadding, SurfaceColor}; -use crate::styles::{Component, ContainerLevel, Dimension, Edges, Styles}; +use crate::styles::{Component, ContainerLevel, Dimension, Edges, RequireInvalidation, Styles}; use crate::value::{IntoValue, Value}; use crate::widget::{MakeWidget, WidgetRef, WrappedLayout, WrapperWidget}; use crate::ConstraintLimit; @@ -250,6 +250,12 @@ impl From for Component { } } +impl RequireInvalidation for EffectiveBackground { + fn requires_invalidation(&self) -> bool { + false + } +} + define_components! { Container { /// The container background behind the current widget. diff --git a/src/widgets/input.rs b/src/widgets/input.rs index e15ec30..4b74396 100644 --- a/src/widgets/input.rs +++ b/src/widgets/input.rs @@ -85,7 +85,7 @@ impl Input { context.get(&LineHeight).into_px(scale).into_float(), ), ); - self.text.map(|text| { + self.text.map_tracking_invalidate(context, |text| { buffer.set_text( kludgine.font_system(), text, diff --git a/src/widgets/label.rs b/src/widgets/label.rs index 266c9cd..0a573f0 100644 --- a/src/widgets/label.rs +++ b/src/widgets/label.rs @@ -7,7 +7,7 @@ use kludgine::Color; use crate::context::{GraphicsContext, LayoutContext}; use crate::styles::components::{IntrinsicPadding, TextColor}; -use crate::value::{Dynamic, IntoValue, Value}; +use crate::value::{Dynamic, Generation, IntoValue, Value}; use crate::widget::{MakeWidget, Widget, WidgetInstance}; use crate::ConstraintLimit; @@ -16,7 +16,7 @@ use crate::ConstraintLimit; pub struct Label { /// The contents of the label. pub text: Value, - prepared_text: Option<(MeasuredText, Px, Color)>, + prepared_text: Option<(MeasuredText, Option, Px, Color)>, } impl Label { @@ -34,29 +34,34 @@ impl Label { color: Color, width: Px, ) -> &MeasuredText { + let check_generation = self.text.generation(); match &self.prepared_text { - Some((_, prepared_width, prepared_color)) - if *prepared_color == color && *prepared_width == width => {} + Some((prepared, prepared_generation, prepared_width, prepared_color)) + if *prepared_generation == check_generation + && *prepared_color == color + && (*prepared_width == width + || (*prepared_width < width + && prepared.line_height == prepared.size.height)) => {} _ => { let measured = self.text.map(|text| { context .gfx .measure_text(Text::new(text, color).wrap_at(width)) }); - self.prepared_text = Some((measured, width, color)); + self.prepared_text = Some((measured, check_generation, width, color)); } } self.prepared_text .as_ref() - .map(|(prepared, _, _)| prepared) + .map(|(prepared, _, _, _)| prepared) .expect("always initialized") } } impl Widget for Label { fn redraw(&mut self, context: &mut GraphicsContext<'_, '_, '_, '_, '_>) { - self.text.redraw_when_changed(context); + self.text.invalidate_when_changed(context); let size = context.gfx.region().size; let center = Point::from(size) / 2; diff --git a/src/widgets/slider.rs b/src/widgets/slider.rs index bbc076d..6dd210c 100644 --- a/src/widgets/slider.rs +++ b/src/widgets/slider.rs @@ -176,7 +176,7 @@ where let half_knob = knob_size / 2; - let mut value = self.value.get_tracked(context); + let mut value = self.value.get_tracking_refresh(context); let min = self.minimum.get_tracked(context); let mut max = self.maximum.get_tracked(context); diff --git a/src/widgets/stack.rs b/src/widgets/stack.rs index 118cdb4..4eb0172 100644 --- a/src/widgets/stack.rs +++ b/src/widgets/stack.rs @@ -402,15 +402,17 @@ impl Layout { let mut remaining = available_space.saturating_sub(allocated_space); // Measure the children that fit their content + self.other = UPx(0); for &id in &self.measured { let index = self.children.index_of_id(id).expect("child not found"); - let (measured, _) = self.orientation.split_size(measure( + let (measured, other) = self.orientation.split_size(measure( index, self.orientation .make_size(ConstraintLimit::ClippedAfter(remaining), other_constraint), false, )); self.layouts[index].size = measured; + self.other = self.other.max(other); remaining = remaining.saturating_sub(measured); } @@ -435,24 +437,23 @@ impl Layout { self.layouts[index].size = size; } - } - // Now that we know the constrained sizes, we can measure the children - // to get the other measurement using the constrainted measurement. - self.other = UPx(0); - let mut offset = UPx(0); - for index in 0..self.children.len() { - self.layouts[index].offset = offset; - offset += self.layouts[index].size; - let (_, measured) = self.orientation.split_size(measure( - index, - self.orientation.make_size( - ConstraintLimit::Known(self.layouts[index].size.into_px(scale).into_unsigned()), - other_constraint, - ), - false, - )); - self.other = self.other.max(measured); + // Now that we know the constrained sizes, we can measure the children + // to get the other measurement using the constrainted measurement. + for (id, _) in &self.fractional { + let index = self.children.index_of_id(*id).expect("child not found"); + let (_, measured) = self.orientation.split_size(measure( + index, + self.orientation.make_size( + ConstraintLimit::Known( + self.layouts[index].size.into_px(scale).into_unsigned(), + ), + other_constraint, + ), + true, + )); + self.other = self.other.max(measured); + } } self.other = match other_constraint { @@ -461,7 +462,10 @@ impl Layout { }; // Finally layout the widgets with the final constraints + let mut offset = UPx(0); for index in 0..self.children.len() { + self.layouts[index].offset = offset; + offset += self.layouts[index].size; self.orientation.split_size(measure( index, self.orientation.make_size( diff --git a/src/window.rs b/src/window.rs index f14c46e..76c9b5c 100644 --- a/src/window.rs +++ b/src/window.rs @@ -27,7 +27,7 @@ use tracing::Level; use crate::animation::{LinearInterpolate, PercentBetween, ZeroToOne}; use crate::context::{ - AsEventContext, EventContext, Exclusive, GraphicsContext, LayoutContext, RedrawStatus, + AsEventContext, EventContext, Exclusive, GraphicsContext, InvalidationStatus, LayoutContext, WidgetContext, }; use crate::graphics::Graphics; @@ -275,7 +275,7 @@ struct GooeyWindow { contents: Drawing, should_close: bool, mouse_state: MouseState, - redraw_status: RedrawStatus, + redraw_status: InvalidationStatus, initial_frame: bool, occluded: Dynamic, focused: Dynamic, @@ -467,7 +467,7 @@ where widget: None, devices: AHashMap::default(), }, - redraw_status: RedrawStatus::default(), + redraw_status: InvalidationStatus::default(), initial_frame: true, occluded, focused, @@ -497,7 +497,9 @@ where self.redraw_status.refresh_received(); graphics.reset_text_attributes(); - self.root.tree.reset_render_order(); + // TODO re-check why we can't add drain without a range to kempt. Or even intoiter. + let invalidations = std::mem::take(&mut *self.redraw_status.invalidations()); + self.root.tree.new_frame(invalidations.iter().copied()); let resizable = window.winit().is_resizable(); let is_expanded = self.constrain_window_resizing(resizable, &window, graphics); @@ -651,7 +653,13 @@ where // fn scale_factor_changed(&mut self, window: kludgine::app::Window<'_, ()>) {} - // fn resized(&mut self, window: kludgine::app::Window<'_, ()>) {} + fn resized( + &mut self, + _window: kludgine::app::Window<'_, WindowCommand>, + _kludgine: &mut Kludgine, + ) { + self.root.invalidate(); + } // fn theme_changed(&mut self, window: kludgine::app::Window<'_, ()>) {} From b72e4b0caf54ff8434eb8413d7be588d31ab9963 Mon Sep 17 00:00:00 2001 From: Jonathan Johnson Date: Tue, 14 Nov 2023 07:59:13 -0800 Subject: [PATCH 02/24] Optimizing Stack layout in Known dimensions --- src/widgets/stack.rs | 31 ++++++++++++++++++++----------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/src/widgets/stack.rs b/src/widgets/stack.rs index 4eb0172..92feb66 100644 --- a/src/widgets/stack.rs +++ b/src/widgets/stack.rs @@ -400,6 +400,11 @@ impl Layout { let allocated_space = self.allocated_space.0 + self.allocated_space.1.into_px(scale).into_unsigned(); let mut remaining = available_space.saturating_sub(allocated_space); + // If our `other_constraint` is not known, we will need to give child + // widgets an opportunity to lay themselves out in the full area. This + // requires one extra layout call, so we avoid persisting layouts during + // the first loop if this is the case. + let needs_final_layout = !matches!(other_constraint, ConstraintLimit::Known(_)); // Measure the children that fit their content self.other = UPx(0); @@ -409,7 +414,7 @@ impl Layout { index, self.orientation .make_size(ConstraintLimit::ClippedAfter(remaining), other_constraint), - false, + !needs_final_layout, )); self.layouts[index].size = measured; self.other = self.other.max(other); @@ -450,7 +455,7 @@ impl Layout { ), other_constraint, ), - true, + !needs_final_layout, )); self.other = self.other.max(measured); } @@ -461,19 +466,23 @@ impl Layout { ConstraintLimit::ClippedAfter(clip_limit) => self.other.min(clip_limit), }; - // Finally layout the widgets with the final constraints + // Finally, compute the offsets of all of the widgets. let mut offset = UPx(0); for index in 0..self.children.len() { self.layouts[index].offset = offset; offset += self.layouts[index].size; - self.orientation.split_size(measure( - index, - self.orientation.make_size( - ConstraintLimit::Known(self.layouts[index].size.into_px(scale).into_unsigned()), - ConstraintLimit::Known(self.other), - ), - true, - )); + if needs_final_layout { + self.orientation.split_size(measure( + index, + self.orientation.make_size( + ConstraintLimit::Known( + self.layouts[index].size.into_px(scale).into_unsigned(), + ), + ConstraintLimit::Known(self.other), + ), + true, + )); + } } self.orientation.make_size(offset, self.other) From ca81c3702c1f3b0cd87f85c84ff63dd950d8b9f8 Mon Sep 17 00:00:00 2001 From: Jonathan Johnson Date: Tue, 14 Nov 2023 08:02:31 -0800 Subject: [PATCH 03/24] Updating examples --- examples/containers.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/examples/containers.rs b/examples/containers.rs index 3a703e0..dde1b65 100644 --- a/examples/containers.rs +++ b/examples/containers.rs @@ -8,6 +8,7 @@ fn main() -> gooey::Result { let theme_mode = Dynamic::default(); set_of_containers(3, theme_mode.clone()) .centered() + .expand() .into_window() .with_theme_mode(theme_mode) .run() From 494fa680cb3e0dc9f5e93d872d5ebbaea506c45b Mon Sep 17 00:00:00 2001 From: Jonathan Johnson Date: Tue, 14 Nov 2023 08:43:05 -0800 Subject: [PATCH 04/24] Contrast calculation adjustments I noticed that the new default theme settings were causing the "wrong" text color to be picked. After reviewing why the contrast values were the way they were, I reasoned that the less lightness, the less the ColorSource contrast matters. So I've applied a multiplication of the average ligntess between the two colors being compared. --- src/styles.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/styles.rs b/src/styles.rs index 4446f97..8d8c95f 100644 --- a/src/styles.rs +++ b/src/styles.rs @@ -1381,12 +1381,19 @@ impl ColorExt for Color { let (other_source, other_lightness) = self.into_source_and_lightness(); let lightness_delta = other_lightness.difference_between(check_lightness); + let average_lightness = ZeroToOne::new((*check_lightness + *other_lightness) / 2.); + let source_change = check_source.contrast_between(other_source); let other_alpha = ZeroToOne::new(self.alpha_f32()); let alpha_delta = check_alpha.difference_between(other_alpha); - ZeroToOne::new((*lightness_delta + *source_change + *alpha_delta) / 3.) + ZeroToOne::new( + (*lightness_delta + + *average_lightness * *source_change + + *average_lightness * *alpha_delta) + / 3., + ) } fn most_contrasting(self, others: &[Self]) -> Self From 4c7c3be5ba1756df30064807ce7fc47e7a0f31dd Mon Sep 17 00:00:00 2001 From: Jonathan Johnson Date: Tue, 14 Nov 2023 09:31:56 -0800 Subject: [PATCH 05/24] Helpers galore --- .crate-docs.md | 26 ++-- .rustme/docs.md | 4 +- README.md | 26 ++-- examples/animation.rs | 21 ++-- examples/basic-button.rs | 22 ++++ examples/{button.rs => buttons.rs} | 0 examples/containers.rs | 18 +-- examples/counter.rs | 9 +- examples/gameui.rs | 9 +- examples/input.rs | 4 +- examples/login.rs | 18 +-- examples/scroll.rs | 3 +- examples/stack-align-test.rs | 53 +++++++++ examples/style.rs | 4 +- examples/switcher.rs | 16 +-- examples/theme.rs | 183 ++++++++++++++--------------- gooey-macros/src/lib.rs | 2 +- src/value.rs | 16 +++ src/widget.rs | 12 +- src/widgets.rs | 4 +- src/widgets/mode_switch.rs | 6 +- src/widgets/slider.rs | 42 +++++++ src/window.rs | 12 +- 23 files changed, 336 insertions(+), 174 deletions(-) create mode 100644 examples/basic-button.rs rename examples/{button.rs => buttons.rs} (100%) create mode 100644 examples/stack-align-test.rs diff --git a/.crate-docs.md b/.crate-docs.md index 36caa93..a1998a7 100644 --- a/.crate-docs.md +++ b/.crate-docs.md @@ -16,16 +16,22 @@ reactive data models work, consider this example that displays a button that increments its own label: ```rust,ignore -// Create a dynamic usize. -let count = Dynamic::new(0_usize); +fn main() -> gooey::Result { + // Create a dynamic usize. + let count = Dynamic::new(0_isize); + // Create a dynamic that contains `count.to_string()` + let count_label = count.map_each(ToString::to_string); -// Create a new button with a label that is produced by mapping the contents -// of `count`. -Button::new(count.map_each(ToString::to_string)) - // Set the `on_click` callback to a closure that increments the counter. - .on_click(count.with_clone(|count| move |_| count.set(count.get() + 1))) - // Run the button as an an application. - .run() + // Create a new button whose text is our dynamic string. + count_label + .into_button() + // Set the `on_click` callback to a closure that increments the counter. + .on_click(count.with_clone(|count| move |_| count.set(count.get() + 1))) + // Position the button in the center + .centered() + // Run the application + .run() +} ``` [widget]: crate::widget::Widget @@ -33,7 +39,7 @@ Button::new(count.map_each(ToString::to_string)) [wgpu]: https://github.com/gfx-rs/wgpu [winit]: https://github.com/rust-windowing/winit [widgets]: mod@crate::widgets -[button-example]: https://github.com/khonsulabs/gooey/tree/main/examples/button.rs +[button-example]: https://github.com/khonsulabs/gooey/tree/main/examples/basic-button.rs ## Open-source Licenses diff --git a/.rustme/docs.md b/.rustme/docs.md index 7859fb6..2543870 100644 --- a/.rustme/docs.md +++ b/.rustme/docs.md @@ -16,7 +16,7 @@ reactive data models work, consider this example that displays a button that increments its own label: ```rust,ignore -$../examples/button.rs:readme$ +$../examples/basic-button.rs:readme$ ``` [widget]: $widget$ @@ -24,4 +24,4 @@ $../examples/button.rs:readme$ [wgpu]: https://github.com/gfx-rs/wgpu [winit]: https://github.com/rust-windowing/winit [widgets]: $widgets$ -[button-example]: https://github.com/khonsulabs/gooey/tree/$ref-name$/examples/button.rs +[button-example]: https://github.com/khonsulabs/gooey/tree/$ref-name$/examples/basic-button.rs diff --git a/README.md b/README.md index 30bebeb..ff46a56 100644 --- a/README.md +++ b/README.md @@ -18,16 +18,22 @@ reactive data models work, consider this example that displays a button that increments its own label: ```rust,ignore -// Create a dynamic usize. -let count = Dynamic::new(0_usize); +fn main() -> gooey::Result { + // Create a dynamic usize. + let count = Dynamic::new(0_isize); + // Create a dynamic that contains `count.to_string()` + let count_label = count.map_each(ToString::to_string); -// Create a new button with a label that is produced by mapping the contents -// of `count`. -Button::new(count.map_each(ToString::to_string)) - // Set the `on_click` callback to a closure that increments the counter. - .on_click(count.with_clone(|count| move |_| count.set(count.get() + 1))) - // Run the button as an an application. - .run() + // Create a new button whose text is our dynamic string. + count_label + .into_button() + // Set the `on_click` callback to a closure that increments the counter. + .on_click(count.with_clone(|count| move |_| count.set(count.get() + 1))) + // Position the button in the center + .centered() + // Run the application + .run() +} ``` [widget]: https://gooey.rs/main/gooey/widget/trait.Widget.html @@ -35,7 +41,7 @@ Button::new(count.map_each(ToString::to_string)) [wgpu]: https://github.com/gfx-rs/wgpu [winit]: https://github.com/rust-windowing/winit [widgets]: https://gooey.rs/main/gooey/widgets/index.html -[button-example]: https://github.com/khonsulabs/gooey/tree/main/examples/button.rs +[button-example]: https://github.com/khonsulabs/gooey/tree/main/examples/basic-button.rs ## Open-source Licenses diff --git a/examples/animation.rs b/examples/animation.rs index 33a1444..4e7a1a2 100644 --- a/examples/animation.rs +++ b/examples/animation.rs @@ -1,9 +1,8 @@ use std::time::Duration; use gooey::animation::{AnimationHandle, AnimationTarget, IntoAnimate, Spawn}; -use gooey::value::Dynamic; +use gooey::value::{Dynamic, StringValue}; use gooey::widget::MakeWidget; -use gooey::widgets::{Button, Label, Stack}; use gooey::{Run, WithClone}; fn main() -> gooey::Result { @@ -18,13 +17,17 @@ fn main() -> gooey::Result { .on_complete(|| println!("Gooey animations are neat!")) .launch(); - Stack::columns( - Button::new("To 0") - .on_click(animate_to(&animation, &value, 0)) - .and(Label::new(label)) - .and(Button::new("To 100").on_click(animate_to(&animation, &value, 100))), - ) - .run() + "To 0" + .into_button() + .on_click(animate_to(&animation, &value, 0)) + .and(label) + .and( + "To 100" + .into_button() + .on_click(animate_to(&animation, &value, 100)), + ) + .into_columns() + .run() } fn animate_to( diff --git a/examples/basic-button.rs b/examples/basic-button.rs new file mode 100644 index 0000000..a78f524 --- /dev/null +++ b/examples/basic-button.rs @@ -0,0 +1,22 @@ +use gooey::value::{Dynamic, StringValue}; +use gooey::widget::MakeWidget; +use gooey::Run; + +// begin rustme snippet: readme +fn main() -> gooey::Result { + // Create a dynamic usize. + let count = Dynamic::new(0_isize); + // Create a dynamic that contains `count.to_string()` + let count_label = count.map_each(ToString::to_string); + + // Create a new button whose text is our dynamic string. + count_label + .into_button() + // Set the `on_click` callback to a closure that increments the counter. + .on_click(count.with_clone(|count| move |_| count.set(count.get() + 1))) + // Position the button in the center + .centered() + // Run the application + .run() +} +// end rustme snippet diff --git a/examples/button.rs b/examples/buttons.rs similarity index 100% rename from examples/button.rs rename to examples/buttons.rs diff --git a/examples/containers.rs b/examples/containers.rs index dde1b65..98216fd 100644 --- a/examples/containers.rs +++ b/examples/containers.rs @@ -1,6 +1,5 @@ -use gooey::value::Dynamic; +use gooey::value::{Dynamic, StringValue}; use gooey::widget::{MakeWidget, WidgetInstance}; -use gooey::widgets::{Button, Label}; use gooey::window::ThemeMode; use gooey::Run; @@ -10,7 +9,7 @@ fn main() -> gooey::Result { .centered() .expand() .into_window() - .with_theme_mode(theme_mode) + .themed_mode(theme_mode) .run() } @@ -18,20 +17,21 @@ fn set_of_containers(repeat: usize, theme_mode: Dynamic) -> WidgetIns let inner = if let Some(remaining_iters) = repeat.checked_sub(1) { set_of_containers(remaining_iters, theme_mode) } else { - Button::new("Toggle Theme Mode") + "Toggle Theme Mode" + .into_button() .on_click(move |_| { theme_mode.map_mut(|mode| mode.toggle()); }) .make_widget() }; - Label::new("Lowest") + "Lowest" .and( - Label::new("Low") + "Low" .and( - Label::new("Mid") + "Mid" .and( - Label::new("High") - .and(Label::new("Highest").and(inner).into_rows().contain()) + "High" + .and("Highest".and(inner).into_rows().contain()) .into_rows() .contain(), ) diff --git a/examples/counter.rs b/examples/counter.rs index cabdb93..99bffea 100644 --- a/examples/counter.rs +++ b/examples/counter.rs @@ -1,8 +1,7 @@ use std::string::ToString; -use gooey::value::Dynamic; +use gooey::value::{Dynamic, StringValue}; use gooey::widget::MakeWidget; -use gooey::widgets::{Button, Label}; use gooey::Run; use kludgine::figures::units::Lp; @@ -10,14 +9,14 @@ fn main() -> gooey::Result { let counter = Dynamic::new(0i32); let label = counter.map_each(ToString::to_string); - Label::new(label) + label .width(Lp::points(100)) - .and(Button::new("+").on_click(counter.with_clone(|counter| { + .and("+".into_button().on_click(counter.with_clone(|counter| { move |_| { *counter.lock() += 1; } }))) - .and(Button::new("-").on_click(counter.with_clone(|counter| { + .and("-".into_button().on_click(counter.with_clone(|counter| { move |_| { *counter.lock() -= 1; } diff --git a/examples/gameui.rs b/examples/gameui.rs index 50c5a7e..f3e172d 100644 --- a/examples/gameui.rs +++ b/examples/gameui.rs @@ -1,6 +1,6 @@ -use gooey::value::Dynamic; +use gooey::value::{Dynamic, StringValue}; use gooey::widget::{MakeWidget, HANDLED, IGNORED}; -use gooey::widgets::{Input, Label, Space}; +use gooey::widgets::Space; use gooey::Run; use kludgine::app::winit::event::ElementState; use kludgine::app::winit::keyboard::Key; @@ -10,13 +10,14 @@ fn main() -> gooey::Result { let chat_log = Dynamic::new("Chat log goes here.\n".repeat(100)); let chat_message = Dynamic::new(String::new()); - Label::new(chat_log.clone()) + chat_log + .clone() .vertical_scroll() .expand() .and(Space::colored(Color::RED).expand_weighted(2)) .into_columns() .expand() - .and(Input::new(chat_message.clone()).on_key(move |input| { + .and(chat_message.clone().into_input().on_key(move |input| { match (input.state, input.logical_key) { (ElementState::Pressed, Key::Enter) => { let new_message = chat_message.map_mut(std::mem::take); diff --git a/examples/input.rs b/examples/input.rs index 48c1cef..65e3982 100644 --- a/examples/input.rs +++ b/examples/input.rs @@ -1,7 +1,7 @@ +use gooey::value::StringValue; use gooey::widget::MakeWidget; -use gooey::widgets::Input; use gooey::Run; fn main() -> gooey::Result { - Input::new("Hello").expand().run() + "Hello".into_input().expand().run() } diff --git a/examples/login.rs b/examples/login.rs index 60a7be9..f81dfe5 100644 --- a/examples/login.rs +++ b/examples/login.rs @@ -1,8 +1,8 @@ use std::process::exit; -use gooey::value::{Dynamic, MapEach}; +use gooey::value::{Dynamic, MapEach, StringValue}; use gooey::widget::MakeWidget; -use gooey::widgets::{Button, Expand, Input, Label}; +use gooey::widgets::Expand; use gooey::Run; use kludgine::figures::units::Lp; @@ -14,18 +14,19 @@ fn main() -> gooey::Result { (&username, &password).map_each(|(username, password)| validate(username, password)); // TODO this should be a grid layout to ensure proper visual alignment. - let username_row = Label::new("Username") - .and(Input::new(username.clone()).expand()) + let username_row = "Username" + .and(username.clone().into_input().expand()) .into_columns(); - let password_row = Label::new("Password") + let password_row = "Password" .and( // TODO secure input - Input::new(password.clone()).expand(), + password.clone().into_input().expand(), ) .into_columns(); - let buttons = Button::new("Cancel") + let buttons = "Cancel" + .into_button() .on_click(|_| { eprintln!("Login cancelled"); exit(0) @@ -33,7 +34,8 @@ fn main() -> gooey::Result { .into_escape() .and(Expand::empty()) .and( - Button::new("Log In") + "Log In" + .into_button() .enabled(valid) .on_click(move |_| { println!("Welcome, {}", username.get()); diff --git a/examples/scroll.rs b/examples/scroll.rs index 745ec1f..89daf67 100644 --- a/examples/scroll.rs +++ b/examples/scroll.rs @@ -1,9 +1,8 @@ use gooey::widget::MakeWidget; -use gooey::widgets::Label; use gooey::Run; fn main() -> gooey::Result { - Label::new(include_str!("../src/widgets/scroll.rs")) + include_str!("../src/widgets/scroll.rs") .scroll() .expand() .run() diff --git a/examples/stack-align-test.rs b/examples/stack-align-test.rs new file mode 100644 index 0000000..fa27f5d --- /dev/null +++ b/examples/stack-align-test.rs @@ -0,0 +1,53 @@ +use gooey::value::StringValue; +use gooey::widget::MakeWidget; +use gooey::Run; + +/// This example shows a tricky layout problem. The hierarchy of widgets is +/// this: +/// +/// ```text +/// Expand (.expand()) +/// | Align (.centered()) +/// | | Stack (.into_rows()) +/// | | | Label +/// | | | Align (.centered()) +/// | | | | Button +/// ``` +/// +/// When the Stack widget attempted to implmement a single-pass layout, this +/// caused the Button to be aligned to the left inside of the stack. The Stack +/// widget now utilizes two `layout()` operations for layouts like this. Here's +/// the reasoning: +/// +/// At the window root, we have an Align wrapped by an Expand. The Align widget +/// during layout asks its children to size-to-fit. This means the Stack is +/// asking its children to size-to-fit as well. +/// +/// The Stack's orientation is Rows, and since the children are Resizes or +/// Expands, the widgets are size-to-fit. This means that the Stack will measure +/// these widgets asking them to size to fit. +/// +/// After running this pass of measurement, we can assign the heights of each of +/// the rows to the measurements we received. The width of the stack becomes the +/// maximum width of all children measured. +/// +/// In a single-pass layout, this means the Align widget inside of the Stack +/// never receives an opportunity to lay its children out with the final width. +/// The Button does end up centered because of this. Fixing it also becomes +/// tricky, because if surround the button in an Expand, it now instructs the +/// Stack to expand to fill its parent. +/// +/// After some careful deliberation, @ecton reasoned that in the situation where +/// a Stack is asked to layout with the Stack's non-primary being a size-to-fit +/// measurement, a second layout call for all children is required with Known +/// measurements to allow layouts like this example to work correctly. +fn main() -> gooey::Result { + // TODO once we have offscreen rendering, turn this into a test case + "Really Long Label" + .and("Short".into_button().centered()) + .into_rows() + .contain() + .centered() + .expand() + .run() +} diff --git a/examples/style.rs b/examples/style.rs index 36c2f8d..d2c1ffa 100644 --- a/examples/style.rs +++ b/examples/style.rs @@ -1,12 +1,12 @@ use gooey::styles::components::TextColor; use gooey::widget::MakeWidget; use gooey::widgets::stack::Stack; -use gooey::widgets::{Button, Style}; +use gooey::widgets::Style; use gooey::Run; use kludgine::Color; fn main() -> gooey::Result { - Stack::rows(Button::new("Green").and(red_text(Button::new("Red")))) + Stack::rows("Green".and(red_text("Red"))) .with(&TextColor, Color::GREEN) .run() } diff --git a/examples/switcher.rs b/examples/switcher.rs index ea43ea6..d34a7bc 100644 --- a/examples/switcher.rs +++ b/examples/switcher.rs @@ -1,6 +1,6 @@ -use gooey::value::Dynamic; +use gooey::value::{Dynamic, StringValue}; use gooey::widget::{MakeWidget, WidgetInstance}; -use gooey::widgets::{Button, Label, Switcher}; +use gooey::widgets::Switcher; use gooey::Run; #[derive(Debug)] @@ -24,9 +24,10 @@ fn main() -> gooey::Result { fn intro(active: Dynamic) -> WidgetInstance { const INTRO: &str = "This example demonstrates the Switcher widget, which uses a mapping function to convert from a generic type to the widget it uses for its contents."; - Label::new(INTRO) + INTRO .and( - Button::new("Switch!") + "Switch!" + .into_button() .on_click(move |_| active.set(ActiveContent::Success)) .centered(), ) @@ -35,11 +36,12 @@ fn intro(active: Dynamic) -> WidgetInstance { } fn success(active: Dynamic) -> WidgetInstance { - Label::new("The value changed to `ActiveContent::Success`!") + "The value changed to `ActiveContent::Success`!" .and( - Button::new("Start Over") + "Start Over" + .into_button() .on_click(move |_| active.set(ActiveContent::Intro)) - // .centered(), + .centered(), ) .into_rows() .make_widget() diff --git a/examples/theme.rs b/examples/theme.rs index 65b8090..6067b5c 100644 --- a/examples/theme.rs +++ b/examples/theme.rs @@ -5,9 +5,10 @@ use gooey::styles::components::{TextColor, WidgetBackground}; use gooey::styles::{ ColorScheme, ColorSource, ColorTheme, FixedTheme, SurfaceTheme, Theme, ThemePair, }; -use gooey::value::{Dynamic, MapEach}; +use gooey::value::{Dynamic, MapEach, StringValue}; use gooey::widget::MakeWidget; -use gooey::widgets::{Input, Label, ModeSwitch, Scroll, Slider, Stack, Themed}; +use gooey::widgets::slider::Slidable; +use gooey::widgets::{Slider, Stack}; use gooey::window::ThemeMode; use gooey::Run; use kludgine::Color; @@ -44,38 +45,37 @@ fn main() -> gooey::Result { }, ); - Themed::new( - default_theme.clone(), - Stack::columns( - Scroll::vertical(Stack::rows( - theme_switcher - .and(primary_editor) - .and(secondary_editor) - .and(tertiary_editor) - .and(error_editor) - .and(neutral_editor) - .and(neutral_variant_editor), - )) - .and(fixed_themes( - default_theme.map_each(|theme| theme.primary_fixed), - default_theme.map_each(|theme| theme.secondary_fixed), - default_theme.map_each(|theme| theme.tertiary_fixed), - )) - .and(theme( - default_theme.map_each(|theme| theme.dark), - ThemeMode::Dark, - )) - .and(theme( - default_theme.map_each(|theme| theme.light), - ThemeMode::Light, - )), - ), - ) - .pad() - .expand() - .into_window() - .with_theme_mode(theme_mode) - .run() + let editors = theme_switcher + .and(primary_editor) + .and(secondary_editor) + .and(tertiary_editor) + .and(error_editor) + .and(neutral_editor) + .and(neutral_variant_editor) + .into_rows() + .vertical_scroll(); + + editors + .and(fixed_themes( + default_theme.map_each(|theme| theme.primary_fixed), + default_theme.map_each(|theme| theme.secondary_fixed), + default_theme.map_each(|theme| theme.tertiary_fixed), + )) + .and(theme( + default_theme.map_each(|theme| theme.dark), + ThemeMode::Dark, + )) + .and(theme( + default_theme.map_each(|theme| theme.light), + ThemeMode::Light, + )) + .into_columns() + .themed(default_theme) + .pad() + .expand() + .into_window() + .themed_mode(theme_mode) + .run() } fn dark_mode_slider() -> (Dynamic, impl MakeWidget) { @@ -83,7 +83,7 @@ fn dark_mode_slider() -> (Dynamic, impl MakeWidget) { ( theme_mode.clone(), - Stack::rows(Label::new("Theme Mode").and(Slider::::from_value(theme_mode))), + "Theme Mode".and(theme_mode.slider()).into_rows(), ) } @@ -114,11 +114,11 @@ fn color_editor( ( color, Stack::rows( - Label::new(label) - .and(Slider::::new(hue, 0., 360.)) - .and(Input::new(hue_text)) + label + .and(hue.slider_between(0., 360.)) + .and(hue_text.into_input()) .and(Slider::::from_value(saturation)) - .and(Input::new(saturation_text)), + .and(saturation_text.into_input()), ), ) } @@ -128,69 +128,64 @@ fn fixed_themes( secondary: Dynamic, tertiary: Dynamic, ) -> impl MakeWidget { - Stack::rows( - Label::new("Fixed") - .and(fixed_theme(primary, "Primary")) - .and(fixed_theme(secondary, "Secondary")) - .and(fixed_theme(tertiary, "Tertiary")), - ) - .contain() - .expand() + "Fixed" + .and(fixed_theme(primary, "Primary")) + .and(fixed_theme(secondary, "Secondary")) + .and(fixed_theme(tertiary, "Tertiary")) + .into_rows() + .contain() + .expand() } fn fixed_theme(theme: Dynamic, label: &str) -> impl MakeWidget { let color = theme.map_each(|theme| theme.color); let on_color = theme.map_each(|theme| theme.on_color); - Stack::columns( - swatch(color.clone(), &format!("{label} Fixed"), on_color.clone()) - .and(swatch( - theme.map_each(|theme| theme.dim_color), - &format!("Dim {label}"), - on_color.clone(), - )) - .and(swatch( - on_color.clone(), - &format!("On {label} Fixed"), - color.clone(), - )) - .and(swatch( - theme.map_each(|theme| theme.on_color_variant), - &format!("Variant On {label} Fixed"), - color, - )), - ) - .contain() - .expand() + + swatch(color.clone(), &format!("{label} Fixed"), on_color.clone()) + .and(swatch( + theme.map_each(|theme| theme.dim_color), + &format!("Dim {label}"), + on_color.clone(), + )) + .and(swatch( + on_color.clone(), + &format!("On {label} Fixed"), + color.clone(), + )) + .and(swatch( + theme.map_each(|theme| theme.on_color_variant), + &format!("Variant On {label} Fixed"), + color, + )) + .into_columns() + .contain() + .expand() } fn theme(theme: Dynamic, mode: ThemeMode) -> impl MakeWidget { - ModeSwitch::new( - mode, - Stack::rows( - Label::new(match mode { - ThemeMode::Light => "Light", - ThemeMode::Dark => "Dark", - }) - .and( - Stack::columns( - color_theme(theme.map_each(|theme| theme.primary), "Primary") - .and(color_theme( - theme.map_each(|theme| theme.secondary), - "Secondary", - )) - .and(color_theme( - theme.map_each(|theme| theme.tertiary), - "Tertiary", - )) - .and(color_theme(theme.map_each(|theme| theme.error), "Error")), - ) - .contain() - .expand(), - ) - .and(surface_theme(theme.map_each(|theme| theme.surface))), - ) - .contain(), + match mode { + ThemeMode::Light => "Light", + ThemeMode::Dark => "Dark", + } + .and( + color_theme(theme.map_each(|theme| theme.primary), "Primary") + .and(color_theme( + theme.map_each(|theme| theme.secondary), + "Secondary", + )) + .and(color_theme( + theme.map_each(|theme| theme.tertiary), + "Tertiary", + )) + .and(color_theme(theme.map_each(|theme| theme.error), "Error")) + .into_columns() + .contain() + .expand(), ) + .and(surface_theme(theme.map_each(|theme| theme.surface))) + .into_rows() + .contain() + .themed_mode(mode) .expand() } @@ -310,7 +305,7 @@ fn color_theme(theme: Dynamic, label: &str) -> impl MakeWidget { } fn swatch(background: Dynamic, label: &str, text: Dynamic) -> impl MakeWidget { - Label::new(label) + label .with(&TextColor, text) .with(&WidgetBackground, background) .fit_horizontally() diff --git a/gooey-macros/src/lib.rs b/gooey-macros/src/lib.rs index 051be8c..5780e60 100644 --- a/gooey-macros/src/lib.rs +++ b/gooey-macros/src/lib.rs @@ -1,6 +1,6 @@ use manyhow::{manyhow, Result}; -use quote_use::quote_use as quote; use proc_macro2::TokenStream; +use quote_use::quote_use as quote; mod animation; #[manyhow(proc_macro_derive(LinearInterpolate))] diff --git a/src/value.rs b/src/value.rs index 710a84b..cf8f484 100644 --- a/src/value.rs +++ b/src/value.rs @@ -16,6 +16,7 @@ use crate::animation::{DynamicTransition, LinearInterpolate}; use crate::context::{WidgetContext, WindowHandle}; use crate::utils::{IgnorePoison, WithClone}; use crate::widget::WidgetId; +use crate::widgets::{Button, Input}; /// An instance of a value that provides APIs to observe and react to its /// contents. @@ -1251,3 +1252,18 @@ macro_rules! impl_tuple_map_each { } impl_all_tuples!(impl_tuple_map_each); + +/// A type that can be converted into a [`Value`]. +pub trait StringValue: IntoValue + Sized { + /// Returns this string as a text input widget. + fn into_input(self) -> Input { + Input::new(self.into_value()) + } + + /// Returns this string as a clickable button. + fn into_button(self) -> Button { + Button::new(self.into_value()) + } +} + +impl StringValue for T where T: IntoValue {} diff --git a/src/widget.rs b/src/widget.rs index bd1c4ff..577b64f 100644 --- a/src/widget.rs +++ b/src/widget.rs @@ -24,7 +24,7 @@ use crate::styles::{ use crate::tree::Tree; use crate::utils::IgnorePoison; use crate::value::{IntoValue, Value}; -use crate::widgets::{Align, Container, Expand, Resize, Scroll, Stack, Style}; +use crate::widgets::{Align, Container, Expand, Resize, Scroll, Stack, Style, Themed, ThemedMode}; use crate::window::{RunningWindow, ThemeMode, Window, WindowBehavior}; use crate::{ConstraintLimit, Run}; @@ -727,6 +727,16 @@ pub trait MakeWidget: Sized { fn pad_by(self, padding: impl IntoValue>) -> Container { self.contain().transparent().pad_by(padding) } + + /// Applies `theme` to `self` and its children. + fn themed(self, theme: impl IntoValue) -> Themed { + Themed::new(theme, self) + } + + /// Applies `mode` to `self` and its children. + fn themed_mode(self, mode: impl IntoValue) -> ThemedMode { + ThemedMode::new(mode, self) + } } /// A type that can create a [`WidgetInstance`] with a preallocated diff --git a/src/widgets.rs b/src/widgets.rs index d090af2..b43bc77 100644 --- a/src/widgets.rs +++ b/src/widgets.rs @@ -10,7 +10,7 @@ pub mod label; mod mode_switch; mod resize; pub mod scroll; -mod slider; +pub mod slider; mod space; pub mod stack; mod style; @@ -25,7 +25,7 @@ pub use container::Container; pub use expand::Expand; pub use input::Input; pub use label::Label; -pub use mode_switch::ModeSwitch; +pub use mode_switch::ThemedMode; pub use resize::Resize; pub use scroll::Scroll; pub use slider::Slider; diff --git a/src/widgets/mode_switch.rs b/src/widgets/mode_switch.rs index 238b56e..2f2f61e 100644 --- a/src/widgets/mode_switch.rs +++ b/src/widgets/mode_switch.rs @@ -5,12 +5,12 @@ use crate::window::ThemeMode; /// A widget that applies a set of [`ThemeMode`] to all contained widgets. #[derive(Debug)] -pub struct ModeSwitch { +pub struct ThemedMode { mode: Value, child: WidgetRef, } -impl ModeSwitch { +impl ThemedMode { /// Returns a new widget that applies `mode` to all of its children. pub fn new(mode: impl IntoValue, child: impl MakeWidget) -> Self { Self { @@ -20,7 +20,7 @@ impl ModeSwitch { } } -impl WrapperWidget for ModeSwitch { +impl WrapperWidget for ThemedMode { fn child_mut(&mut self) -> &mut WidgetRef { &mut self.child } diff --git a/src/widgets/slider.rs b/src/widgets/slider.rs index 6dd210c..7fabf9c 100644 --- a/src/widgets/slider.rs +++ b/src/widgets/slider.rs @@ -1,3 +1,4 @@ +//! A widget that allows a user to "slide" between values. use std::fmt::Debug; use std::panic::UnwindSafe; @@ -327,3 +328,44 @@ define_components! { InactiveTrackColor(Color, "inactive_track_color", |context| context.get(&OpaqueWidgetColor)) } } + +/// A value that can be used in a [`Slider`] widget. +pub trait Slidable: IntoDynamic + Sized +where + T: Clone + + Debug + + PartialOrd + + LinearInterpolate + + PercentBetween + + UnwindSafe + + Send + + 'static, +{ + /// Returns a new slider over the full [range](Ranged) of the type. + fn slider(self) -> Slider + where + T: Ranged, + { + Slider::from_value(self.into_dynamic()) + } + + /// Returns a new slider using the value of `self`. The slider will be + /// limited to values between `min` and `max`. + fn slider_between(self, min: impl IntoValue, max: impl IntoValue) -> Slider { + Slider::new(self.into_dynamic(), min, max) + } +} + +impl Slidable for T +where + T: IntoDynamic, + U: Clone + + Debug + + PartialOrd + + LinearInterpolate + + PercentBetween + + UnwindSafe + + Send + + 'static, +{ +} diff --git a/src/window.rs b/src/window.rs index 76c9b5c..64f4aa1 100644 --- a/src/window.rs +++ b/src/window.rs @@ -139,7 +139,7 @@ impl Window { /// /// `focused` will be initialized with an initial state /// of `false`. - pub fn with_focused(mut self, focused: impl IntoDynamic) -> Self { + pub fn focused(mut self, focused: impl IntoDynamic) -> Self { let focused = focused.into_dynamic(); focused.update(false); self.focused = Some(focused); @@ -154,7 +154,7 @@ impl Window { /// visible, this value will contain `true`. /// /// `occluded` will be initialized with an initial state of `false`. - pub fn with_occluded(mut self, occluded: impl IntoDynamic) -> Self { + pub fn occluded(mut self, occluded: impl IntoDynamic) -> Self { let occluded = occluded.into_dynamic(); occluded.update(false); self.occluded = Some(occluded); @@ -174,10 +174,16 @@ impl Window { /// Setting the [`Dynamic`]'s value will also update the window with the new /// mode until a mode change is detected, upon which the new mode will be /// stored. - pub fn with_theme_mode(mut self, theme_mode: impl IntoValue) -> Self { + pub fn themed_mode(mut self, theme_mode: impl IntoValue) -> Self { self.theme_mode = Some(theme_mode.into_value()); self } + + /// Applies `theme` to the widgets in this window. + pub fn themed(mut self, theme: impl IntoValue) -> Self { + self.theme = theme.into_value(); + self + } } impl Window From 42ed86cdfdd513a1a1b1b26f7166c1df055ac960 Mon Sep 17 00:00:00 2001 From: Jonathan Johnson Date: Tue, 14 Nov 2023 10:16:22 -0800 Subject: [PATCH 06/24] Lerp/PercentBetween fixes Asserting condition on PercentBetween, Color lerping now works correctly according to testing with gray shades, but due to rounding errors, no unit test is being checked in at the moment. --- src/animation.rs | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/animation.rs b/src/animation.rs index 7a55f08..30cc1c0 100644 --- a/src/animation.rs +++ b/src/animation.rs @@ -779,6 +779,8 @@ macro_rules! impl_percent_between { ($type:ident, $float:ident) => { impl PercentBetween for $type { fn percent_between(&self, min: &Self, max: &Self) -> ZeroToOne { + assert!(min <= max, "percent_between requires min <= max"); + let range = *max - *min; ZeroToOne::from(*self as $float / range as $float) } @@ -812,10 +814,13 @@ impl PercentBetween for Color { func(value).percent_between(&func(min), &func(max)) } - channel_percent(*self, *min, *max, Color::red) - * channel_percent(*self, *min, *max, Color::green) - * channel_percent(*self, *min, *max, Color::blue) - * channel_percent(*self, *min, *max, Color::alpha) + ZeroToOne::new( + (*channel_percent(*self, *min, *max, Color::red) + + *channel_percent(*self, *min, *max, Color::green) + + *channel_percent(*self, *min, *max, Color::blue) + + *channel_percent(*self, *min, *max, Color::alpha)) + / 4., + ) } } From aec768617ac73b404a9b77a631d24eb5d96b6875 Mon Sep 17 00:00:00 2001 From: Roland Fredenhagen Date: Tue, 14 Nov 2023 20:03:30 +0100 Subject: [PATCH 07/24] derive(LinearInterpolate) on enum --- Cargo.lock | 33 ++++- Cargo.toml | 1 + gooey-macros/Cargo.toml | 2 +- gooey-macros/src/animation.rs | 120 ++++++++++++------ gooey-macros/src/lib.rs | 22 ++++ ...y_macros__animation__test__empty_enum.snap | 23 ++++ .../gooey_macros__animation__test__enum_.snap | 28 ++++ ...oey_macros__animation__test__struct_.snap} | 6 +- ...acros__animation__test__tuple_struct.snap} | 4 +- src/animation.rs | 42 +++++- 10 files changed, 233 insertions(+), 48 deletions(-) create mode 100644 gooey-macros/src/snapshots/gooey_macros__animation__test__empty_enum.snap create mode 100644 gooey-macros/src/snapshots/gooey_macros__animation__test__enum_.snap rename gooey-macros/src/snapshots/{gooey_macros__animation__test.snap => gooey_macros__animation__test__struct_.snap} (74%) rename gooey-macros/src/snapshots/{gooey_macros__animation__test-2.snap => gooey_macros__animation__test__tuple_struct.snap} (79%) diff --git a/Cargo.lock b/Cargo.lock index 6fff768..1a64d5f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -473,6 +473,26 @@ dependencies = [ "syn", ] +[[package]] +name = "derive_more" +version = "1.0.0-beta.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7abbfc297053be59290e3152f8cbcd52c8642e0728b69ee187d991d4c1af08d" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "1.0.0-beta.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bba3e9872d7c58ce7ef0fcf1844fcc3e23ef2a58377b50df35dd98e42a5726e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "dispatch" version = "0.2.0" @@ -721,6 +741,7 @@ version = "0.1.0" dependencies = [ "ahash", "alot", + "derive_more", "gooey-macros", "intentional", "interner", @@ -738,7 +759,7 @@ version = "0.1.0" dependencies = [ "attribute-derive", "insta", - "manyhow 0.9.0", + "manyhow 0.10.0", "prettyplease", "proc-macro2", "quote", @@ -1118,11 +1139,11 @@ dependencies = [ [[package]] name = "manyhow" -version = "0.9.0" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9aebef87880bafc898c6bed1435e8fdc58634275ff97693a4bb96ad561c73c43" +checksum = "4efde575f79afb9c637eb4663aa451f0bf227413aa734fbbec077cab5900be85" dependencies = [ - "manyhow-macros 0.9.0", + "manyhow-macros 0.10.0", "proc-macro2", "quote", "syn", @@ -1141,9 +1162,9 @@ dependencies = [ [[package]] name = "manyhow-macros" -version = "0.9.0" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f74cc8a0d8b05a7e919011c78a2744e7dea66567c05fb046666f3bae383d8d04" +checksum = "fcee04599474650eb26ae5a5c7837e30e55242267ff1bf0adc760b6fcdc3fa2a" dependencies = [ "proc-macro-utils", "proc-macro2", diff --git a/Cargo.toml b/Cargo.toml index f8dbfa8..6b642d1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,6 +25,7 @@ tracing-subscriber = { version = "0.3", optional = true, features = [ palette = "0.7.3" ahash = "0.8.6" gooey-macros = { version = "0.1.0", path = "gooey-macros" } +derive_more = { version = "1.0.0-beta.6", features = ["from"] } # [patch."https://github.com/khonsulabs/kludgine"] diff --git a/gooey-macros/Cargo.toml b/gooey-macros/Cargo.toml index a38fa69..e9c650d 100644 --- a/gooey-macros/Cargo.toml +++ b/gooey-macros/Cargo.toml @@ -10,7 +10,7 @@ proc-macro = true [dependencies] attribute-derive = "0.8.1" -manyhow = "0.9.0" +manyhow = "0.10.0" proc-macro2 = "1.0.69" quote = "1.0.33" quote-use = "0.7.2" diff --git a/gooey-macros/src/animation.rs b/gooey-macros/src/animation.rs index 2ef277a..15a7a12 100644 --- a/gooey-macros/src/animation.rs +++ b/gooey-macros/src/animation.rs @@ -1,66 +1,114 @@ -use manyhow::bail; +use manyhow::{bail, ensure}; use quote::ToTokens; -use syn::{Field, ItemStruct}; +use syn::{Data, DeriveInput, Field, Variant}; use crate::*; pub fn linear_interpolate( - ItemStruct { - ident, + DeriveInput { + ident: item_ident, generics, - fields, + data, .. - }: ItemStruct, + }: DeriveInput, ) -> Result { if let Some(generic) = generics.type_params().next() { bail!(generic, "generics not supported"); } - let fields = match fields { - syn::Fields::Unit => bail!(ident, "unit structs are not supported"), - fields => fields - .into_iter() - .enumerate() - .map(|(idx, Field { ident, .. })| { - let ident = ident - .map(ToTokens::into_token_stream) - .unwrap_or_else(|| proc_macro2::Literal::usize_unsuffixed(idx).into_token_stream()); - quote!(#ident: ::gooey::animation::LinearInterpolate::lerp(&self.#ident, &__target.#ident, __percent),) - }), + let doc; + + let body = match data { + Data::Struct(data) => { + let fields = match data.fields { + syn::Fields::Unit => bail!(item_ident, "unit structs are not supported"), + fields => fields + .into_iter() + .enumerate() + .map(|(idx, Field { ident, .. })| { + let ident = ident + .map(ToTokens::into_token_stream) + .unwrap_or_else(|| proc_macro2::Literal::usize_unsuffixed(idx).into_token_stream()); + quote!(#ident: ::gooey::animation::LinearInterpolate::lerp(&self.#ident, &__target.#ident, __percent),) + }), + }; + doc = "# Panics\n Panics if any field's lerp panics (this should only happen on percentages outside 0..1 range)."; + quote!(#item_ident{#(#fields)*}) + } + Data::Enum(data) => { + let variants = data + .variants + .into_iter() + .map( + |Variant { + ident, + fields, + discriminant, + .. + }| { + if let Some(discriminant) = discriminant { + bail!(discriminant, "discriminants are not supported"); + } + ensure!(fields.is_empty(), fields, "enum fields are not supported"); + Ok(quote!(#item_ident::#ident #fields)) + }, + ) + .collect::>>()?; + let last = variants + .last() + .map(ToTokens::to_token_stream) + .unwrap_or_else(|| quote!(unreachable!())); + + let idx: Vec<_> = (0..variants.len()).collect(); + doc = "# Panics\n Panics if the the enum variants are overflown (this can only happen on percentages outside 0..1 range)."; + quote! { + # use ::gooey::animation::LinearInterpolate; + fn variant_to_index(__v: &#item_ident) -> usize { + match __v { + #(#variants => #idx,)* + } + } + let __self = variant_to_index(&self); + let __target = variant_to_index(&__target); + match LinearInterpolate::lerp(&__self, &__target, __percent) { + #(#idx => #variants,)* + _ => #last, + } + } + } + Data::Union(union) => bail!((union.union_token, union.fields), "unions not supported"), }; Ok(quote! { - impl ::gooey::animation::LinearInterpolate for #ident { + impl ::gooey::animation::LinearInterpolate for #item_ident { + #[doc = #doc] fn lerp(&self, __target: &Self, __percent: f32) -> Self { - #ident{#(#fields)*} + #body } } }) } #[cfg(test)] -macro_rules! expansion_snapshot { - (#[derive($fn:expr)]$($tokens:tt)*) => {{ - use insta::assert_snapshot; - use prettyplease::unparse; - use syn::{parse2, parse_quote}; - let input = parse_quote!($($tokens)*); - let output = $fn(input).unwrap(); - assert_snapshot!(unparse(&parse2(output).unwrap())) - }}; -} - -#[test] -fn test() { - expansion_snapshot! { +mod test { + use super::*; + expansion_snapshot! {struct_ #[derive(linear_interpolate)] struct HelloWorld { fielda: Hello, fieldb: World, } - }; - expansion_snapshot! { + } + expansion_snapshot! {tuple_struct #[derive(linear_interpolate)] struct HelloWorld(Hello, World); - }; + } + expansion_snapshot! {enum_ + #[derive(linear_interpolate)] + enum Enum{A, B} + } + expansion_snapshot! {empty_enum + #[derive(linear_interpolate)] + enum Enum{} + } } diff --git a/gooey-macros/src/lib.rs b/gooey-macros/src/lib.rs index 5780e60..2c793ce 100644 --- a/gooey-macros/src/lib.rs +++ b/gooey-macros/src/lib.rs @@ -1,6 +1,28 @@ use manyhow::{manyhow, Result}; use proc_macro2::TokenStream; use quote_use::quote_use as quote; + +#[cfg(test)] +macro_rules! expansion_snapshot { + ($name:ident $($tokens:tt)*) => { + #[test] + fn $name() { + expansion_snapshot!{$($tokens)*} + } + }; + (#[derive($fn:expr)]$($tokens:tt)*) => {{ + use insta::assert_snapshot; + use prettyplease::unparse; + use syn::{parse2, parse_quote}; + let input = parse_quote!($($tokens)*); + let output = $fn(input).unwrap(); + match &parse2(output.clone()) { + Ok(ok) => assert_snapshot!(unparse(ok)), + Err(_) => panic!("{output}"), + } + }}; +} + mod animation; #[manyhow(proc_macro_derive(LinearInterpolate))] diff --git a/gooey-macros/src/snapshots/gooey_macros__animation__test__empty_enum.snap b/gooey-macros/src/snapshots/gooey_macros__animation__test__empty_enum.snap new file mode 100644 index 0000000..792d903 --- /dev/null +++ b/gooey-macros/src/snapshots/gooey_macros__animation__test__empty_enum.snap @@ -0,0 +1,23 @@ +--- +source: gooey-macros/src/animation.rs +expression: unparse(ok) +--- +impl ::gooey::animation::LinearInterpolate for Enum { + /**# Panics + Panics if the the enum variants are overflown (this can only happen on percentages outside 0..1 range).*/ + fn lerp(&self, __target: &Self, __percent: f32) -> Self { + fn variant_to_index(__v: &Enum) -> usize { + match __v {} + } + let __self = variant_to_index(&self); + let __target = variant_to_index(&self); + match ::gooey::animation::LinearInterpolate::lerp( + &__self, + &__target, + __percent, + ) { + _ => unreachable!(), + } + } +} + diff --git a/gooey-macros/src/snapshots/gooey_macros__animation__test__enum_.snap b/gooey-macros/src/snapshots/gooey_macros__animation__test__enum_.snap new file mode 100644 index 0000000..7076744 --- /dev/null +++ b/gooey-macros/src/snapshots/gooey_macros__animation__test__enum_.snap @@ -0,0 +1,28 @@ +--- +source: gooey-macros/src/animation.rs +expression: unparse(ok) +--- +impl ::gooey::animation::LinearInterpolate for Enum { + /**# Panics + Panics if the the enum variants are overflown (this can only happen on percentages outside 0..1 range).*/ + fn lerp(&self, __target: &Self, __percent: f32) -> Self { + fn variant_to_index(__v: &Enum) -> usize { + match __v { + Enum::A => 0usize, + Enum::B => 1usize, + } + } + let __self = variant_to_index(&self); + let __target = variant_to_index(&self); + match ::gooey::animation::LinearInterpolate::lerp( + &__self, + &__target, + __percent, + ) { + 0usize => Enum::A, + 1usize => Enum::B, + _ => Enum::B, + } + } +} + diff --git a/gooey-macros/src/snapshots/gooey_macros__animation__test.snap b/gooey-macros/src/snapshots/gooey_macros__animation__test__struct_.snap similarity index 74% rename from gooey-macros/src/snapshots/gooey_macros__animation__test.snap rename to gooey-macros/src/snapshots/gooey_macros__animation__test__struct_.snap index 8a133d1..f32fcd0 100644 --- a/gooey-macros/src/snapshots/gooey_macros__animation__test.snap +++ b/gooey-macros/src/snapshots/gooey_macros__animation__test__struct_.snap @@ -1,8 +1,10 @@ --- -source: src/animation.rs -expression: unparse(&parse2(output).unwrap()) +source: gooey-macros/src/animation.rs +expression: unparse(ok) --- impl ::gooey::animation::LinearInterpolate for HelloWorld { + /**# Panics + Panics if any field's lerp panics (this should only happen on percentages outside 0..1 range).*/ fn lerp(&self, __target: &Self, __percent: f32) -> Self { HelloWorld { fielda: ::gooey::animation::LinearInterpolate::lerp( diff --git a/gooey-macros/src/snapshots/gooey_macros__animation__test-2.snap b/gooey-macros/src/snapshots/gooey_macros__animation__test__tuple_struct.snap similarity index 79% rename from gooey-macros/src/snapshots/gooey_macros__animation__test-2.snap rename to gooey-macros/src/snapshots/gooey_macros__animation__test__tuple_struct.snap index 117910f..6152743 100644 --- a/gooey-macros/src/snapshots/gooey_macros__animation__test-2.snap +++ b/gooey-macros/src/snapshots/gooey_macros__animation__test__tuple_struct.snap @@ -1,8 +1,10 @@ --- source: gooey-macros/src/animation.rs -expression: unparse(&parse2(output).unwrap()) +expression: unparse(ok) --- impl ::gooey::animation::LinearInterpolate for HelloWorld { + /**# Panics + Panics if any field's lerp panics (this should only happen on percentages outside 0..1 range).*/ fn lerp(&self, __target: &Self, __percent: f32) -> Self { HelloWorld { 0: ::gooey::animation::LinearInterpolate::lerp( diff --git a/src/animation.rs b/src/animation.rs index 30cc1c0..4c20f57 100644 --- a/src/animation.rs +++ b/src/animation.rs @@ -48,6 +48,7 @@ use std::thread; use std::time::{Duration, Instant}; use alot::{LotId, Lots}; +use derive_more::From; use intentional::Cast; use kempt::Set; use kludgine::figures::Ranged; @@ -607,12 +608,49 @@ impl Animate for Duration { } /// Performs a linear interpolation between two values. +/// +/// This trait can be derived for structs and fieldless enums. +/// +/// Note: for fields that don't implement [`LinerarInterpolate`](trait@LinearInterpolate) +/// the wrappers [`BinaryLerp`] and [`ImmediateLerp`] can be used. +/// +/// ``` +/// use gooey::animation::{BinaryLerp, ImmediateLerp, LinearInterpolate}; +/// use gooey::kludgine::Color; +/// +/// #[derive(LinearInterpolate, PartialEq, Debug)] +/// struct Struct(Color, BinaryLerp<&'static str>, ImmediateLerp<&'static str>); +/// +/// let from = Struct(Color::BLACK, "hello".into(), "hello".into()); +/// let to = Struct(Color::WHITE, "world".into(), "world".into()); +/// +/// assert_eq!( +/// from.lerp(&to, 0.41), +/// Struct(Color::DIMGRAY, "hello".into(), "world".into()) +/// ); +/// assert_eq!( +/// from.lerp(&to, 0.663), +/// Struct(Color::DARKGRAY, "world".into(), "world".into()) +/// ); +/// +/// #[derive(LinearInterpolate, PartialEq, Debug)] +/// enum Enum { +/// A, +/// B, +/// C, +/// } +/// assert_eq!(Enum::A.lerp(&Enum::B, 0.4), Enum::A); +/// assert_eq!(Enum::A.lerp(&Enum::C, 0.1), Enum::A); +/// assert_eq!(Enum::A.lerp(&Enum::C, 0.4), Enum::B); +/// assert_eq!(Enum::A.lerp(&Enum::C, 0.9), Enum::C); +/// ``` pub trait LinearInterpolate: PartialEq { /// Interpolate linearly between `self` and `target` using `percent`. #[must_use] fn lerp(&self, target: &Self, percent: f32) -> Self; } +/// Derives [`LinerarInterpolate`](trait@LinearInterpolate) for structs and fieldless enums. pub use gooey_macros::LinearInterpolate; macro_rules! impl_lerp_for_int { @@ -732,7 +770,7 @@ impl LinearInterpolate for Color { /// /// This wrapper can be used to add [`LinearInterpolate`] to types that normally /// don't support interpolation. -#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Ord, PartialOrd)] +#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Ord, PartialOrd, From)] pub struct BinaryLerp(T); impl LinearInterpolate for BinaryLerp @@ -753,7 +791,7 @@ where /// /// This wrapper can be used to add [`LinearInterpolate`] to types that normally /// don't support interpolation. -#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Ord, PartialOrd)] +#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Ord, PartialOrd, From)] pub struct ImmediateLerp(T); impl LinearInterpolate for ImmediateLerp From 4668db398358c97db9633f127438df3b8ac96766 Mon Sep 17 00:00:00 2001 From: Jonathan Johnson Date: Tue, 14 Nov 2023 11:27:04 -0800 Subject: [PATCH 08/24] New slider example showing min/max --- examples/slider.rs | 29 ++++++++++ src/animation.rs | 71 ++++++++++++++++++----- src/styles.rs | 141 ++++++++++++++++++++++++++++++++++++++------- 3 files changed, 208 insertions(+), 33 deletions(-) create mode 100644 examples/slider.rs diff --git a/examples/slider.rs b/examples/slider.rs new file mode 100644 index 0000000..5672c9f --- /dev/null +++ b/examples/slider.rs @@ -0,0 +1,29 @@ +use gooey::value::{Dynamic, StringValue}; +use gooey::widget::MakeWidget; +use gooey::widgets::slider::Slidable; +use gooey::Run; +use kludgine::figures::units::Lp; + +fn main() -> gooey::Result { + let min_text = Dynamic::new(u8::MIN.to_string()); + let min = min_text.map_each(|min| min.parse().unwrap_or(u8::MIN)); + let max_text = Dynamic::new(u8::MAX.to_string()); + let max = max_text.map_each(|max| max.parse().unwrap_or(u8::MAX)); + let value = Dynamic::new(128_u8); + let value_text = value.map_each(ToString::to_string); + + "Min" + .and(min_text.into_input()) + .and("Max") + .and(max_text.into_input()) + .into_columns() + .centered() + .and(value.slider_between(min, max)) + .and(value_text.centered()) + .into_rows() + .expand_horizontally() + .width(..Lp::points(800)) + .centered() + .expand() + .run() +} diff --git a/src/animation.rs b/src/animation.rs index 30cc1c0..c3e5479 100644 --- a/src/animation.rs +++ b/src/animation.rs @@ -39,6 +39,7 @@ pub mod easings; +use std::cmp::Ordering; use std::fmt::{Debug, Display}; use std::ops::{ControlFlow, Deref, Div, Mul}; use std::panic::{RefUnwindSafe, UnwindSafe}; @@ -780,9 +781,13 @@ macro_rules! impl_percent_between { impl PercentBetween for $type { fn percent_between(&self, min: &Self, max: &Self) -> ZeroToOne { assert!(min <= max, "percent_between requires min <= max"); + assert!( + self >= min && self <= max, + "self must satisfy min <= self <= max" + ); let range = *max - *min; - ZeroToOne::from(*self as $float / range as $float) + ZeroToOne::from((*self - *min) as $float / range as $float) } } }; @@ -810,20 +815,60 @@ impl PercentBetween for Color { min: Color, max: Color, func: impl Fn(Color) -> u8, - ) -> ZeroToOne { - func(value).percent_between(&func(min), &func(max)) + ) -> Option { + let value = func(value); + let min = func(min); + let max = func(max); + match min.cmp(&max) { + Ordering::Less => Some(value.percent_between(&min, &max)), + Ordering::Equal => None, + Ordering::Greater => Some(value.percent_between(&max, &min).one_minus()), + } } - ZeroToOne::new( - (*channel_percent(*self, *min, *max, Color::red) - + *channel_percent(*self, *min, *max, Color::green) - + *channel_percent(*self, *min, *max, Color::blue) - + *channel_percent(*self, *min, *max, Color::alpha)) - / 4., - ) + let mut total_percent_change = 0.; + let mut different_channels = 0_u8; + + for func in [Color::red, Color::green, Color::blue, Color::alpha] { + if let Some(red) = channel_percent(*self, *min, *max, func) { + total_percent_change += *red; + different_channels += 1; + } + } + + if different_channels > 0 { + ZeroToOne::new(total_percent_change / f32::from(different_channels)) + } else { + ZeroToOne::ZERO + } } } +#[test] +fn int_percent_between() { + assert_eq!(1_u8.percent_between(&1_u8, &2_u8), ZeroToOne::ZERO); +} + +#[test] +fn color_lerp() { + let gray = Color::new(51, 51, 51, 51); + let percent_gray = gray.percent_between(&Color::CLEAR_BLACK, &Color::WHITE); + + assert_eq!(gray, Color::CLEAR_BLACK.lerp(&Color::WHITE, *percent_gray)); + + let gray = Color::new(51, 51, 51, 255); + let percent_gray = gray.percent_between(&Color::BLACK, &Color::WHITE); + + assert_eq!(gray, Color::BLACK.lerp(&Color::WHITE, *percent_gray)); + + let red_green = Color::RED.lerp(&Color::GREEN, 0.5); + let percent_between = red_green.percent_between(&Color::RED, &Color::GREEN); + // Why 1 / 255 / 4? This operation is working on u8s, and there are 4 + // channels that can be averaged. The percent is guaranteed to be within + // this range, which works out to be 0.0098 percent. + assert!((*percent_between - 0.5).abs() < 1. / 255. / 4.); +} + /// An `f32` that is clamped between 0.0 and 1.0 and cannot be NaN or Infinity. /// /// Because of these restrictions, this type implements `Ord` and `Eq`. @@ -922,19 +967,19 @@ impl PartialEq for ZeroToOne { } impl Ord for ZeroToOne { - fn cmp(&self, other: &Self) -> std::cmp::Ordering { + fn cmp(&self, other: &Self) -> Ordering { self.0.total_cmp(&other.0) } } impl PartialOrd for ZeroToOne { - fn partial_cmp(&self, other: &Self) -> Option { + fn partial_cmp(&self, other: &Self) -> Option { Some(self.cmp(other)) } } impl PartialOrd for ZeroToOne { - fn partial_cmp(&self, other: &f32) -> Option { + fn partial_cmp(&self, other: &f32) -> Option { Some(self.0.total_cmp(other)) } } diff --git a/src/styles.rs b/src/styles.rs index 8d8c95f..7cadfea 100644 --- a/src/styles.rs +++ b/src/styles.rs @@ -1665,27 +1665,21 @@ pub struct ColorSchemeBuilder { /// The neutral variant color of the scheme. If not provided, a mostly /// desaturated variation of the primary color will be used. pub neutral_variant: Option, - hue_shift: f32, + hue_shift: OklabHue, } impl ColorSchemeBuilder { - /// Returns a builder for the provided hue, in degrees. - #[must_use] - pub fn from_hue(hue: impl Into) -> Self { - Self::new(ColorSource::new(hue, 0.8)) - } - /// Returns a builder for the provided primary color. #[must_use] - pub fn new(primary: ColorSource) -> Self { + pub fn new(primary: impl ProtoColor) -> Self { Self { - primary, + primary: primary.into_source(ZeroToOne::new(0.8)), secondary: None, tertiary: None, error: None, neutral: None, neutral_variant: None, - hue_shift: 30., + hue_shift: OklabHue::new(30.), } } @@ -1697,7 +1691,8 @@ impl ColorSchemeBuilder { } fn generate_tertiary(&self, secondary: ColorSource) -> ColorSource { - let hue_shift = (secondary.hue - self.primary.hue).into_degrees().signum() * self.hue_shift; + let hue_shift = (secondary.hue - self.primary.hue).into_degrees().signum() + * self.hue_shift.into_degrees(); ColorSource { hue: self.primary.hue - hue_shift, saturation: self.primary.saturation / 3., @@ -1726,10 +1721,59 @@ impl ColorSchemeBuilder { fn generate_neutral_variant(&self) -> ColorSource { ColorSource { hue: self.primary.hue, - saturation: ZeroToOne::new(0.1), + saturation: self.primary.saturation / 10., } } + /// Sets the secondary color and returns self. + /// + /// If `secondary` doesn't specify a saturation, a saturation value that is + /// 50% of the primary saturation will be picked. + #[must_use] + pub fn secondary(mut self, secondary: impl ProtoColor) -> Self { + self.secondary = Some(secondary.into_source(self.primary.saturation / 2.)); + self + } + + /// Sets the tertiary color and returns self. + /// + /// If `tertiary` doesn't specify a saturation, a saturation value that is + /// 33% of the primary saturation will be picked. + #[must_use] + pub fn tertiary(mut self, tertiary: impl ProtoColor) -> Self { + self.secondary = Some(tertiary.into_source(self.primary.saturation / 3.)); + self + } + + /// Sets the neutral color and returns self. + /// + /// If `neutral` doesn't specify a saturation, a saturation of 1%. + #[must_use] + pub fn neutral(mut self, neutral: impl ProtoColor) -> Self { + self.neutral = Some(neutral.into_source(0.01)); + self + } + + /// Sets the neutral color and returns self. + /// + /// If `neutral_variant` doesn't specify a saturation, a saturation value + /// that is 10% of the primary saturation will be picked. + #[must_use] + pub fn neutral_variant(mut self, neutral_variant: impl ProtoColor) -> Self { + self.neutral_variant = Some(neutral_variant.into_source(self.primary.saturation / 10.)); + self + } + + /// Sets the amount the hue component is shifted when auto-generating colors + /// to fill in the palette. + /// + /// The default hue shift is 30 degrees. + #[must_use] + pub fn hue_shift(mut self, hue_shift: impl Into) -> Self { + self.hue_shift = hue_shift.into(); + self + } + /// Builds a color scheme from the provided colors, generating any /// unspecified colors. #[must_use] @@ -1753,6 +1797,69 @@ impl ColorSchemeBuilder { } } +/// A type that can be interpretted as a hue or hue and saturation. +pub trait ProtoColor: Sized { + /// Returns the hue of this prototype color. + #[must_use] + fn hue(&self) -> OklabHue; + /// Returns the saturation of this prototype color, if available. + #[must_use] + fn saturation(&self) -> Option; + + /// Returns a color source built from this prototype color + #[must_use] + fn into_source(self, saturation_if_not_provided: impl Into) -> ColorSource { + let saturation = self + .saturation() + .unwrap_or_else(|| saturation_if_not_provided.into()); + ColorSource::new(self.hue(), saturation) + } +} + +impl ProtoColor for f32 { + fn hue(&self) -> OklabHue { + (*self).into() + } + + fn saturation(&self) -> Option { + None + } +} + +impl ProtoColor for OklabHue { + fn hue(&self) -> OklabHue { + *self + } + + fn saturation(&self) -> Option { + None + } +} + +impl ProtoColor for ColorSource { + fn hue(&self) -> OklabHue { + self.hue + } + + fn saturation(&self) -> Option { + Some(self.saturation) + } +} + +impl ProtoColor for (Hue, Saturation) +where + Hue: Into + Copy, + Saturation: Into + Copy, +{ + fn hue(&self) -> OklabHue { + self.0.into() + } + + fn saturation(&self) -> Option { + Some(self.1.into()) + } +} + /// A color scheme for a Gooey application. #[derive(Debug, Clone, Copy, PartialEq)] pub struct ColorScheme { @@ -1773,20 +1880,14 @@ pub struct ColorScheme { impl ColorScheme { /// Returns a generated color scheme based on a `primary` color. #[must_use] - pub fn from_primary(primary: ColorSource) -> Self { + pub fn from_primary(primary: impl ProtoColor) -> Self { ColorSchemeBuilder::new(primary).build() } - - /// Returns a generated color scheme based on a `primary` hue, in degrees. - #[must_use] - pub fn from_primary_hue(hue: impl Into) -> Self { - ColorSchemeBuilder::from_hue(hue).build() - } } impl Default for ColorScheme { fn default() -> Self { - Self::from_primary_hue(138.5) + Self::from_primary(138.5) } } From 4a4bc5de1a010a2d4934deebbaa1ce4d4c94c8a1 Mon Sep 17 00:00:00 2001 From: Jonathan Johnson Date: Tue, 14 Nov 2023 11:44:32 -0800 Subject: [PATCH 09/24] Added slidable enum demo Also moved into_button to MakeWidget --- examples/animation.rs | 2 +- examples/basic-button.rs | 2 +- examples/containers.rs | 2 +- examples/counter.rs | 2 +- examples/slider.rs | 48 ++++++++++++++++++++++++++++++++---- examples/stack-align-test.rs | 1 - examples/switcher.rs | 2 +- src/value.rs | 7 +----- src/widget.rs | 9 ++++++- src/window.rs | 12 +-------- 10 files changed, 58 insertions(+), 29 deletions(-) diff --git a/examples/animation.rs b/examples/animation.rs index 4e7a1a2..a7eb101 100644 --- a/examples/animation.rs +++ b/examples/animation.rs @@ -1,7 +1,7 @@ use std::time::Duration; use gooey::animation::{AnimationHandle, AnimationTarget, IntoAnimate, Spawn}; -use gooey::value::{Dynamic, StringValue}; +use gooey::value::Dynamic; use gooey::widget::MakeWidget; use gooey::{Run, WithClone}; diff --git a/examples/basic-button.rs b/examples/basic-button.rs index a78f524..34ba73c 100644 --- a/examples/basic-button.rs +++ b/examples/basic-button.rs @@ -1,4 +1,4 @@ -use gooey::value::{Dynamic, StringValue}; +use gooey::value::Dynamic; use gooey::widget::MakeWidget; use gooey::Run; diff --git a/examples/containers.rs b/examples/containers.rs index 98216fd..f67e098 100644 --- a/examples/containers.rs +++ b/examples/containers.rs @@ -1,4 +1,4 @@ -use gooey::value::{Dynamic, StringValue}; +use gooey::value::Dynamic; use gooey::widget::{MakeWidget, WidgetInstance}; use gooey::window::ThemeMode; use gooey::Run; diff --git a/examples/counter.rs b/examples/counter.rs index 99bffea..8f9fabd 100644 --- a/examples/counter.rs +++ b/examples/counter.rs @@ -1,6 +1,6 @@ use std::string::ToString; -use gooey::value::{Dynamic, StringValue}; +use gooey::value::Dynamic; use gooey::widget::MakeWidget; use gooey::Run; use kludgine::figures::units::Lp; diff --git a/examples/slider.rs b/examples/slider.rs index 5672c9f..2c6d832 100644 --- a/examples/slider.rs +++ b/examples/slider.rs @@ -1,10 +1,23 @@ +use gooey::animation::{LinearInterpolate, PercentBetween}; use gooey::value::{Dynamic, StringValue}; use gooey::widget::MakeWidget; use gooey::widgets::slider::Slidable; use gooey::Run; use kludgine::figures::units::Lp; +use kludgine::figures::Ranged; fn main() -> gooey::Result { + u8_slider() + .and(enum_slider()) + .into_rows() + .expand_horizontally() + .width(..Lp::points(800)) + .centered() + .expand() + .run() +} + +fn u8_slider() -> impl MakeWidget { let min_text = Dynamic::new(u8::MIN.to_string()); let min = min_text.map_each(|min| min.parse().unwrap_or(u8::MIN)); let max_text = Dynamic::new(u8::MAX.to_string()); @@ -21,9 +34,34 @@ fn main() -> gooey::Result { .and(value.slider_between(min, max)) .and(value_text.centered()) .into_rows() - .expand_horizontally() - .width(..Lp::points(800)) - .centered() - .expand() - .run() +} + +#[derive(LinearInterpolate, Debug, Clone, Copy, Eq, PartialEq, Ord, PartialOrd)] +enum SlidableEnum { + A, + B, + C, +} + +impl PercentBetween for SlidableEnum { + fn percent_between(&self, min: &Self, max: &Self) -> gooey::animation::ZeroToOne { + let min = *min as u8; + let max = *max as u8; + let value = *self as u8; + value.percent_between(&min, &max) + } +} + +impl Ranged for SlidableEnum { + const MAX: Self = Self::C; + const MIN: Self = Self::A; +} + +fn enum_slider() -> impl MakeWidget { + let enum_value = Dynamic::new(SlidableEnum::A); + let enum_text = enum_value.map_each(|value| format!("{value:?}")); + "Custom Enum" + .and(enum_value.slider()) + .and(enum_text) + .into_rows() } diff --git a/examples/stack-align-test.rs b/examples/stack-align-test.rs index fa27f5d..f7453a2 100644 --- a/examples/stack-align-test.rs +++ b/examples/stack-align-test.rs @@ -1,4 +1,3 @@ -use gooey::value::StringValue; use gooey::widget::MakeWidget; use gooey::Run; diff --git a/examples/switcher.rs b/examples/switcher.rs index d34a7bc..f0e970b 100644 --- a/examples/switcher.rs +++ b/examples/switcher.rs @@ -1,4 +1,4 @@ -use gooey::value::{Dynamic, StringValue}; +use gooey::value::Dynamic; use gooey::widget::{MakeWidget, WidgetInstance}; use gooey::widgets::Switcher; use gooey::Run; diff --git a/src/value.rs b/src/value.rs index cf8f484..930ee32 100644 --- a/src/value.rs +++ b/src/value.rs @@ -16,7 +16,7 @@ use crate::animation::{DynamicTransition, LinearInterpolate}; use crate::context::{WidgetContext, WindowHandle}; use crate::utils::{IgnorePoison, WithClone}; use crate::widget::WidgetId; -use crate::widgets::{Button, Input}; +use crate::widgets::Input; /// An instance of a value that provides APIs to observe and react to its /// contents. @@ -1259,11 +1259,6 @@ pub trait StringValue: IntoValue + Sized { fn into_input(self) -> Input { Input::new(self.into_value()) } - - /// Returns this string as a clickable button. - fn into_button(self) -> Button { - Button::new(self.into_value()) - } } impl StringValue for T where T: IntoValue {} diff --git a/src/widget.rs b/src/widget.rs index 577b64f..45cb12d 100644 --- a/src/widget.rs +++ b/src/widget.rs @@ -24,7 +24,9 @@ use crate::styles::{ use crate::tree::Tree; use crate::utils::IgnorePoison; use crate::value::{IntoValue, Value}; -use crate::widgets::{Align, Container, Expand, Resize, Scroll, Stack, Style, Themed, ThemedMode}; +use crate::widgets::{ + Align, Button, Container, Expand, Resize, Scroll, Stack, Style, Themed, ThemedMode, +}; use crate::window::{RunningWindow, ThemeMode, Window, WindowBehavior}; use crate::{ConstraintLimit, Run}; @@ -643,6 +645,11 @@ pub trait MakeWidget: Sized { Resize::from_height(height, self) } + /// Returns this string as a clickable button. + fn into_button(self) -> Button { + Button::new(self) + } + /// Aligns `self` to the center vertically and horizontally. #[must_use] fn centered(self) -> Align { diff --git a/src/window.rs b/src/window.rs index 64f4aa1..8a0173f 100644 --- a/src/window.rs +++ b/src/window.rs @@ -1093,7 +1093,7 @@ pub(crate) mod sealed { } /// Controls whether the light or dark theme is applied. -#[derive(Default, Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd)] +#[derive(Default, Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, LinearInterpolate)] pub enum ThemeMode { /// Applies the light theme Light, @@ -1144,16 +1144,6 @@ impl From for window::Theme { } } -impl LinearInterpolate for ThemeMode { - fn lerp(&self, target: &Self, percent: f32) -> Self { - if percent >= 0.5 { - *target - } else { - *self - } - } -} - impl PercentBetween for ThemeMode { fn percent_between(&self, min: &Self, max: &Self) -> ZeroToOne { if *min == *max || *self == *min { From 89c8805924185e3e9e331e8e0ad736ee102c240a Mon Sep 17 00:00:00 2001 From: Jonathan Johnson Date: Tue, 14 Nov 2023 13:03:09 -0800 Subject: [PATCH 10/24] Refactored switcher to use Dynamic --- examples/switcher.rs | 20 +++++----- src/value.rs | 28 ++++++++++++- src/widgets/switcher.rs | 88 +++++++++++++++-------------------------- 3 files changed, 67 insertions(+), 69 deletions(-) diff --git a/examples/switcher.rs b/examples/switcher.rs index f0e970b..0d35052 100644 --- a/examples/switcher.rs +++ b/examples/switcher.rs @@ -1,6 +1,5 @@ -use gooey::value::Dynamic; +use gooey::value::{Dynamic, Switchable}; use gooey::widget::{MakeWidget, WidgetInstance}; -use gooey::widgets::Switcher; use gooey::Run; #[derive(Debug)] @@ -12,14 +11,15 @@ enum ActiveContent { fn main() -> gooey::Result { let active = Dynamic::new(ActiveContent::Intro); - Switcher::new(active.clone(), move |content| match content { - ActiveContent::Intro => intro(active.clone()), - ActiveContent::Success => success(active.clone()), - }) - .contain() - .centered() - .expand() - .run() + active + .switcher(|current, active| match current { + ActiveContent::Intro => intro(active.clone()), + ActiveContent::Success => success(active.clone()), + }) + .contain() + .centered() + .expand() + .run() } fn intro(active: Dynamic) -> WidgetInstance { diff --git a/src/value.rs b/src/value.rs index 930ee32..a19dacc 100644 --- a/src/value.rs +++ b/src/value.rs @@ -15,8 +15,8 @@ use intentional::Assert; use crate::animation::{DynamicTransition, LinearInterpolate}; use crate::context::{WidgetContext, WindowHandle}; use crate::utils::{IgnorePoison, WithClone}; -use crate::widget::WidgetId; -use crate::widgets::Input; +use crate::widget::{WidgetId, WidgetInstance}; +use crate::widgets::{Input, Switcher}; /// An instance of a value that provides APIs to observe and react to its /// contents. @@ -395,6 +395,15 @@ impl Dynamic { } } +impl Dynamic { + /// Returns a new [`Switcher`] widget whose contents is the value of this + /// dynamic. + #[must_use] + pub fn switcher(self) -> Switcher { + Switcher::new(self) + } +} + impl Default for Dynamic where T: Default, @@ -921,6 +930,21 @@ where } } +/// A type that can be the source of a [`Switcher`] widget. +pub trait Switchable: IntoDynamic + Sized { + /// Returns a new [`Switcher`] whose contents is the result of invoking + /// `map` each time `self` is updated. + fn switcher(self, map: F) -> Switcher + where + F: FnMut(&T, &Dynamic) -> WidgetInstance + Send + 'static, + T: Send + 'static, + { + Switcher::mapping(self, map) + } +} + +impl Switchable for W where W: IntoDynamic {} + /// A value that may be either constant or dynamic. #[derive(Debug)] pub enum Value { diff --git a/src/widgets/switcher.rs b/src/widgets/switcher.rs index fd9cd18..d4b9ee9 100644 --- a/src/widgets/switcher.rs +++ b/src/widgets/switcher.rs @@ -1,58 +1,49 @@ use std::fmt::Debug; -use std::panic::UnwindSafe; use kludgine::figures::Size; use crate::context::{AsEventContext, LayoutContext}; -use crate::value::{Generation, IntoValue, Value}; -use crate::widget::{MakeWidget, WidgetInstance, WidgetRef, WrapperWidget}; +use crate::value::{Dynamic, DynamicReader, IntoDynamic}; +use crate::widget::{WidgetInstance, WidgetRef, WrapperWidget}; use crate::ConstraintLimit; /// A widget that switches its contents based on a value of `T`. -pub struct Switcher { - value: Value, - value_generation: Option, - factory: Box>, +#[derive(Debug)] +pub struct Switcher { + source: DynamicReader, child: WidgetRef, } -impl Switcher { +impl Switcher { + /// Returns a new widget that replaces its contents with the results of + /// calling `map` each time `source` is updated. + /// + /// This function is equivalent to calling + /// `Self::new(source.into_dynamic().map_each(map))`, but this function's + /// signature helps the compiler's type inference work correctly. When using + /// new directly, the compiler often requires annotating the closure's + /// argument type. + pub fn mapping(source: impl IntoDynamic, mut map: F) -> Self + where + F: FnMut(&T, &Dynamic) -> WidgetInstance + Send + 'static, + T: Send + 'static, + { + let source = source.into_dynamic(); + + Self::new(source.clone().map_each(move |value| map(value, &source))) + } + /// Returns a new widget that replaces its contents with the result of /// `widget_factory` each time `value` changes. #[must_use] - pub fn new(value: impl IntoValue, mut widget_factory: F) -> Self - where - F: for<'a> FnMut(&'a T) -> W + Send + UnwindSafe + 'static, - W: MakeWidget, - { - let value = value.into_value(); - let value_generation = value.generation(); - let child = WidgetRef::new(value.map(|value| widget_factory(value))); - Self { - value, - value_generation, - factory: Box::new(widget_factory), - child, - } + pub fn new(source: impl IntoDynamic) -> Self { + let mut source = source.into_dynamic().into_reader(); + let child = WidgetRef::new(source.get()); + Self { source, child } } } -impl Debug for Switcher -where - T: Debug, -{ - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("Switcher") - .field("value", &self.value) - .field("child", &self.child) - .finish_non_exhaustive() - } -} - -impl WrapperWidget for Switcher -where - T: Debug + Send + UnwindSafe + 'static, -{ +impl WrapperWidget for Switcher { fn child_mut(&mut self) -> &mut WidgetRef { &mut self.child } @@ -63,11 +54,8 @@ where available_space: Size, context: &mut LayoutContext<'_, '_, '_, '_, '_>, ) -> Size { - let current_generation = self.value.generation(); - if self.value_generation != current_generation { - self.value_generation = current_generation; - let new_child = WidgetRef::new(self.value.map(|value| self.factory.invoke(value))); - let removed = std::mem::replace(&mut self.child, new_child); + if self.source.has_updated() { + let removed = std::mem::replace(&mut self.child, WidgetRef::new(self.source.get())); if let WidgetRef::Mounted(removed) = removed { context.remove_child(&removed); } @@ -75,17 +63,3 @@ where available_space } } - -trait SwitchMap: UnwindSafe + Send { - fn invoke(&mut self, value: &T) -> WidgetInstance; -} - -impl SwitchMap for F -where - F: for<'a> FnMut(&'a T) -> W + Send + UnwindSafe, - W: MakeWidget, -{ - fn invoke(&mut self, value: &T) -> WidgetInstance { - self(value).make_widget() - } -} From eb063c82f0373cb0686df2388876f77f453e5f05 Mon Sep 17 00:00:00 2001 From: Jonathan Johnson Date: Tue, 14 Nov 2023 14:12:12 -0800 Subject: [PATCH 11/24] Explicit focus order is now fully supported --- examples/focus-order.rs | 84 +++++++++++++++++++++++++++++++++++++++++ src/context.rs | 66 ++++++++++++++++++++++---------- src/tree.rs | 27 ++++++------- src/widget.rs | 20 ++++++++++ src/window.rs | 7 ++-- 5 files changed, 168 insertions(+), 36 deletions(-) create mode 100644 examples/focus-order.rs diff --git a/examples/focus-order.rs b/examples/focus-order.rs new file mode 100644 index 0000000..94776ac --- /dev/null +++ b/examples/focus-order.rs @@ -0,0 +1,84 @@ +use std::process::exit; + +use gooey::value::{Dynamic, MapEach, StringValue}; +use gooey::widget::{MakeWidget, MakeWidgetWithId, WidgetTag}; +use gooey::widgets::Expand; +use gooey::Run; +use kludgine::figures::units::Lp; + +/// This example is the same as login, but it has an explicit tab order to +/// change from the default order (username, password, cancel, log in) to +/// username, password, log in, cancel. +fn main() -> gooey::Result { + let username = Dynamic::default(); + let password = Dynamic::default(); + + let valid = + (&username, &password).map_each(|(username, password)| validate(username, password)); + + let (login_tag, login_id) = WidgetTag::new(); + let (cancel_tag, cancel_id) = WidgetTag::new(); + let (username_tag, username_id) = WidgetTag::new(); + + // TODO this should be a grid layout to ensure proper visual alignment. + let username_row = "Username" + .and( + username + .clone() + .into_input() + .make_with_id(username_tag) + .expand(), + ) + .into_columns(); + + let password_row = "Password" + .and( + // TODO secure input + password + .clone() + .into_input() + .with_next_focus(login_id) + .expand(), + ) + .into_columns(); + + let buttons = "Cancel" + .into_button() + .on_click(|_| { + eprintln!("Login cancelled"); + exit(0) + }) + .make_with_id(cancel_tag) + .into_escape() + .with_next_focus(username_id) + .and(Expand::empty()) + .and( + "Log In" + .into_button() + .enabled(valid) + .on_click(move |_| { + println!("Welcome, {}", username.get()); + exit(0); + }) + .make_with_id(login_tag) + .into_default() + .with_next_focus(cancel_id), + ) + .into_columns(); + + username_row + .pad() + .and(password_row.pad()) + .and(buttons.pad()) + .into_rows() + .contain() + .width(Lp::points(300)..Lp::points(600)) + .scroll() + .centered() + .expand() + .run() +} + +fn validate(username: &String, password: &String) -> bool { + !username.is_empty() && !password.is_empty() +} diff --git a/src/context.rs b/src/context.rs index e487a18..a7cc3ae 100644 --- a/src/context.rs +++ b/src/context.rs @@ -15,8 +15,8 @@ use kludgine::shapes::{Shape, StrokeOptions}; use kludgine::{Color, Kludgine}; use crate::graphics::Graphics; -use crate::styles::components::{HighlightColor, WidgetBackground}; -use crate::styles::{ComponentDefinition, Styles, Theme, ThemePair, VisualOrder}; +use crate::styles::components::{HighlightColor, LayoutOrder, WidgetBackground}; +use crate::styles::{ComponentDefinition, Styles, Theme, ThemePair}; use crate::utils::IgnorePoison; use crate::value::{Dynamic, IntoValue, Value}; use crate::widget::{EventHandling, ManagedWidget, WidgetId, WidgetInstance, WidgetRef}; @@ -235,10 +235,12 @@ impl<'context, 'window> EventContext<'context, 'window> { .accept_focus(&mut self.for_other(&focus)) { break Some(focus); - } else if let Some(next_focus) = focus.next_focus() { + } else if let Some(next_focus) = + focus.explicit_focus_target(self.pending_state.focus_is_advancing) + { focus = next_focus; } else { - break self.next_focus_after(focus, VisualOrder::left_to_right()); + break self.next_focus_after(focus, self.pending_state.focus_is_advancing); } }); let new = match self @@ -272,17 +274,17 @@ impl<'context, 'window> EventContext<'context, 'window> { fn next_focus_after( &mut self, mut focus: ManagedWidget, - order: VisualOrder, + advance: bool, ) -> Option { // First, look within the current focus for any focusable children. let stop_at = focus.id(); - if let Some(focus) = self.next_focus_within(&focus, None, stop_at, order) { + if let Some(focus) = self.next_focus_within(&focus, None, stop_at, advance) { return Some(focus); } // Now, look for the next widget in each hierarchy let root = loop { - if let Some(focus) = self.next_focus_sibling(&focus, stop_at, order) { + if let Some(focus) = self.next_focus_sibling(&focus, stop_at, advance) { return Some(focus); } let Some(parent) = focus.parent() else { @@ -293,16 +295,16 @@ impl<'context, 'window> EventContext<'context, 'window> { // We've exhausted a forward scan, we can now start searching the final // parent, which is the root. - self.next_focus_within(&root, None, stop_at, order) + self.next_focus_within(&root, None, stop_at, advance) } fn next_focus_sibling( &mut self, focus: &ManagedWidget, stop_at: WidgetId, - order: VisualOrder, + advance: bool, ) -> Option { - self.next_focus_within(&focus.parent()?, Some(focus.id()), stop_at, order) + self.next_focus_within(&focus.parent()?, Some(focus.id()), stop_at, advance) } /// Searches for the next focus inside of `focus`, returning `None` if @@ -313,10 +315,14 @@ impl<'context, 'window> EventContext<'context, 'window> { focus: &ManagedWidget, start_at: Option, stop_at: WidgetId, - order: VisualOrder, + advance: bool, ) -> Option { + let mut visual_order = self.get(&LayoutOrder); + if !advance { + visual_order = visual_order.rev(); + } let mut children = focus - .visually_ordered_children(order) + .visually_ordered_children(visual_order) .into_iter() .peekable(); if let Some(start_at) = start_at { @@ -340,7 +346,9 @@ impl<'context, 'window> EventContext<'context, 'window> { .accept_focus(&mut self.for_other(&child)) { return Some(child); - } else if let Some(focus) = self.next_focus_within(&child, None, stop_at, order) { + } else if let Some(next_focus) = self.widget().explicit_focus_target(advance) { + return Some(next_focus); + } else if let Some(focus) = self.next_focus_within(&child, None, stop_at, advance) { return Some(focus); } } @@ -348,14 +356,31 @@ impl<'context, 'window> EventContext<'context, 'window> { None } - /// Advances the focus from this widget to the next widget in `direction`. + /// Advances the focus to the next widget after this widget in the + /// configured focus order. /// - /// This widget does not need to be focused. - pub fn advance_focus(&mut self, direction: VisualOrder) { - // TODO check to see if the current node has an explicit next_focus (or - // if we're going in the opposite direction, previous_focus). + /// To focus in the reverse order, use [`EventContext::return_focus()`]. + pub fn advance_focus(&mut self) { + self.move_focus(true); + } - self.pending_state.focus = self.next_focus_after(self.current_node.clone(), direction); + /// Returns the focus to the previous widget before this widget in the + /// configured fous order. + /// + /// To focus in the forward order, use [`EventContext::advance_focus()`]. + pub fn return_focus(&mut self) { + self.move_focus(false); + } + + fn move_focus(&mut self, advance: bool) { + if let Some(explicit_next_focus) = self.current_node.explicit_focus_target(advance) { + self.for_other(&explicit_next_focus).focus(); + } else { + self.pending_state.focus = self.next_focus_after(self.current_node.clone(), advance); + } + // It is important to set focus-is_advancing after `focus()` because it + // sets it to `true` explicitly. + self.pending_state.focus_is_advancing = advance; } } @@ -702,6 +727,7 @@ impl<'context, 'window> WidgetContext<'context, 'window> { .tree .active_widget() .and_then(|id| current_node.tree.widget_from_node(id)), + focus_is_advancing: false, }), effective_styles: current_node.effective_styles(), current_node, @@ -783,6 +809,7 @@ impl<'context, 'window> WidgetContext<'context, 'window> { /// Widget events relating to focus changes are deferred until after the all /// contexts for the currently firing event are dropped. pub fn focus(&mut self) { + self.pending_state.focus_is_advancing = true; self.pending_state.focus = Some(self.current_node.clone()); } @@ -1045,6 +1072,7 @@ enum PendingState<'a> { #[derive(Default)] struct PendingWidgetState { + focus_is_advancing: bool, focus: Option, active: Option, } diff --git a/src/tree.rs b/src/tree.rs index 927e69b..3d23f82 100644 --- a/src/tree.rs +++ b/src/tree.rs @@ -56,12 +56,8 @@ impl Tree { let parent = &mut data.nodes[parent]; parent.children.push(node_id); } - if let Some(next_focus) = widget - .next_focus() - .and_then(|id| data.nodes_by_id.get(&id)) - .copied() - { - data.previous_focuses.insert(next_focus, node_id); + if let Some(next_focus) = widget.next_focus() { + data.previous_focuses.insert(next_focus, id); } ManagedWidget { node_id, @@ -252,6 +248,12 @@ impl Tree { data.update_tracked_widget(new_focus, self, |data| &mut data.focus) } + pub fn previous_focus(&self, focus: WidgetId) -> Option { + let data = self.data.lock().ignore_poison(); + let previous = *data.previous_focuses.get(&focus)?; + data.widget_from_id(previous, self) + } + pub fn activate( &self, new_active: Option<&ManagedWidget>, @@ -372,7 +374,7 @@ struct TreeData { defaults: Vec, escapes: Vec, render_order: Vec, - previous_focuses: AHashMap, + previous_focuses: AHashMap, } impl TreeData { @@ -435,17 +437,16 @@ impl TreeData { parent.children.remove(index); let mut detached_nodes = removed_node.children; - if let Some(next_focus) = removed_node - .widget - .next_focus() - .and_then(|id| self.nodes_by_id.get(&id)) - { - self.previous_focuses.remove(next_focus); + if let Some(next_focus) = removed_node.widget.next_focus() { + self.previous_focuses.remove(&next_focus); } while let Some(node) = detached_nodes.pop() { let mut node = self.nodes.remove(node).expect("detached node missing"); self.nodes_by_id.remove(&node.widget.id()); + if let Some(next_focus) = node.widget.next_focus() { + self.previous_focuses.remove(&next_focus); + } detached_nodes.append(&mut node.children); } } diff --git a/src/widget.rs b/src/widget.rs index 45cb12d..be850d9 100644 --- a/src/widget.rs +++ b/src/widget.rs @@ -1081,6 +1081,26 @@ impl ManagedWidget { .and_then(|next_focus| self.tree.widget(next_focus)) } + /// Returns the widget to focus before this widget. + /// + /// There is no direct way to set this value. This relationship is created + /// automatically using [`MakeWidget::with_next_focus()`]. + #[must_use] + pub fn previous_focus(&self) -> Option { + self.tree.previous_focus(self.id()) + } + + /// Returns the next or previous focus target, if one was set using + /// [`MakeWidget::with_next_focus()`]. + #[must_use] + pub fn explicit_focus_target(&self, advance: bool) -> Option { + if advance { + self.next_focus() + } else { + self.previous_focus() + } + } + /// Returns the region that the widget was last rendered at. #[must_use] pub fn last_layout(&self) -> Option> { diff --git a/src/window.rs b/src/window.rs index 8a0173f..a0ab113 100644 --- a/src/window.rs +++ b/src/window.rs @@ -31,7 +31,6 @@ use crate::context::{ WidgetContext, }; use crate::graphics::Graphics; -use crate::styles::components::LayoutOrder; use crate::styles::ThemePair; use crate::tree::Tree; use crate::utils::ModifiersExt; @@ -736,11 +735,11 @@ where ), kludgine, ); - let mut visual_order = target.get(&LayoutOrder); if reverse { - visual_order = visual_order.rev(); + target.return_focus(); + } else { + target.advance_focus(); } - target.advance_focus(visual_order); } } Key::Enter => { From 54e01f19114efa0160be19670f0c7a6707d7478d Mon Sep 17 00:00:00 2001 From: Jonathan Johnson Date: Tue, 14 Nov 2023 20:39:29 -0800 Subject: [PATCH 12/24] Checkbox, ButtonKind, linked/linked_string + more --- Cargo.lock | 30 ++-- examples/buttons.rs | 58 ++++--- examples/checkbox.rs | 19 +++ examples/theme.rs | 28 ++-- src/context.rs | 2 +- src/lib.rs | 4 +- src/styles.rs | 22 ++- src/styles/components.rs | 4 + src/value.rs | 108 +++++++++++++ src/widget.rs | 36 ++++- src/widgets.rs | 2 + src/widgets/align.rs | 10 +- src/widgets/button.rs | 330 ++++++++++++++++++++++++++++++++------- src/widgets/checkbox.rs | 233 +++++++++++++++++++++++++++ src/widgets/container.rs | 6 +- src/widgets/expand.rs | 3 +- src/widgets/input.rs | 5 +- src/widgets/label.rs | 7 +- src/widgets/resize.rs | 31 ++-- src/widgets/scroll.rs | 3 +- src/widgets/slider.rs | 16 +- src/widgets/stack.rs | 13 +- src/window.rs | 7 +- 23 files changed, 801 insertions(+), 176 deletions(-) create mode 100644 examples/checkbox.rs create mode 100644 src/widgets/checkbox.rs diff --git a/Cargo.lock b/Cargo.lock index 1a64d5f..734e3d7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -309,10 +309,11 @@ dependencies = [ [[package]] name = "cc" -version = "1.0.84" +version = "1.0.83" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f8e7c90afad890484a21653d08b6e209ae34770fb5ee298f9c699fcc1e5c856" +checksum = "f1174fb0b6ec23863f8b971027804a42614e347eafb0a95bf0b12cdae21fc4d0" dependencies = [ + "jobserver", "libc", ] @@ -564,7 +565,7 @@ checksum = "dd2e7510819d6fbf51a5545c8f922716ecfb14df168a3242f7d33e0239efe6a1" [[package]] name = "figures" version = "0.1.0" -source = "git+https://github.com/khonsulabs/figures#7b41393c44d4def606790e340c98450b603010b4" +source = "git+https://github.com/khonsulabs/figures#52d06f3623cdb47128f1537fdadfe190f7afa88e" dependencies = [ "bytemuck", "euclid", @@ -955,6 +956,15 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" +[[package]] +name = "jobserver" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c37f63953c4c63420ed5fd3d6d398c719489b9f872b9fa683262f8edd363c7d" +dependencies = [ + "libc", +] + [[package]] name = "js-sys" version = "0.3.65" @@ -990,7 +1000,7 @@ checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" [[package]] name = "kludgine" version = "0.1.0" -source = "git+https://github.com/khonsulabs/kludgine#09790aafb5a9c3b0da034387adead9960eb06bc7" +source = "git+https://github.com/khonsulabs/kludgine#c0135da62309896fab58a2f5b517c32cde151fb3" dependencies = [ "ahash", "alot", @@ -1828,9 +1838,9 @@ checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" [[package]] name = "rustix" -version = "0.38.23" +version = "0.38.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ffb93593068e9babdad10e4fce47dc9b3ac25315a72a59766ffd9e9a71996a04" +checksum = "9ad981d6c340a49cdc40a1028d9c6084ec7e9fa33fcb839cab656a267071e234" dependencies = [ "bitflags 2.4.1", "errno", @@ -2941,18 +2951,18 @@ checksum = "dd15f8e0dbb966fd9245e7498c7e9e5055d9e5c8b676b95bd67091cd11a1e697" [[package]] name = "zerocopy" -version = "0.7.25" +version = "0.7.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8cd369a67c0edfef15010f980c3cbe45d7f651deac2cd67ce097cd801de16557" +checksum = "e97e415490559a91254a2979b4829267a57d2fcd741a98eee8b722fb57289aa0" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.7.25" +version = "0.7.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2f140bda219a26ccc0cdb03dba58af72590c53b22642577d88a927bc5c87d6b" +checksum = "dd7e48ccf166952882ca8bd778a43502c64f33bf94c12ebe2a7f08e5a0f6689f" dependencies = [ "proc-macro2", "quote", diff --git a/examples/buttons.rs b/examples/buttons.rs index 170ffa6..7ebb3f8 100644 --- a/examples/buttons.rs +++ b/examples/buttons.rs @@ -1,29 +1,49 @@ use gooey::value::Dynamic; use gooey::widget::MakeWidget; -use gooey::widgets::button::ButtonOutline; -use gooey::widgets::Button; +use gooey::widgets::button::ButtonKind; +use gooey::widgets::{Button, Checkbox}; use gooey::Run; -use kludgine::Color; -// begin rustme snippet: readme fn main() -> gooey::Result { - // Create a dynamic usize. - let count = Dynamic::new(0_isize); + let clicked_label = Dynamic::new(String::from("Click a Button")); + let default_is_outline = Dynamic::new(false); + let default_button_style = default_is_outline.map_each(|is_outline| { + if *is_outline { + ButtonKind::Outline + } else { + ButtonKind::Solid + } + }); - // Create a new button with a label that is produced by mapping the contents - // of `count`. - Button::new(count.map_each(ToString::to_string)) - // Set the `on_click` callback to a closure that increments the counter. - .on_click(count.with_clone(|count| move |_| count.set(count.get() + 1))) + clicked_label + .clone() .and( - // Creates a second, outlined button - Button::new(count.map_each(ToString::to_string)) - // Set the `on_click` callback to a closure that decrements the counter. - .on_click(count.with_clone(|count| move |_| count.set(count.get() - 1))) - .with(&ButtonOutline, Color::DARKRED), + Button::new("Normal Button") + .on_click( + clicked_label.with_clone(|label| { + move |_| label.set(String::from("Clicked Normal Button")) + }), + ) + .and( + Button::new("Outline Button") + .on_click(clicked_label.with_clone(|label| { + move |_| label.set(String::from("Clicked Outline Button")) + })) + .kind(ButtonKind::Outline), + ) + .and( + Button::new("Default Button") + .on_click(clicked_label.with_clone(|label| { + move |_| label.set(String::from("Clicked Default Button")) + })) + .kind(default_button_style) + .into_default(), + ) + .and(Checkbox::new(default_is_outline, "Set Default to Outline")) + .into_columns(), ) - .into_columns() - // Run the button as an an application. + .into_rows() + .centered() + .expand() .run() } -// end rustme snippet diff --git a/examples/checkbox.rs b/examples/checkbox.rs new file mode 100644 index 0000000..18a5df4 --- /dev/null +++ b/examples/checkbox.rs @@ -0,0 +1,19 @@ +use gooey::value::Dynamic; +use gooey::widget::MakeWidget; +use gooey::widgets::checkbox::CheckboxState; +use gooey::widgets::Checkbox; +use gooey::Run; + +fn main() -> gooey::Result { + let checkbox_state = Dynamic::new(CheckboxState::Checked); + let label = checkbox_state.map_each(|state| format!("Check Me! Current: {state:?}")); + + Checkbox::new(checkbox_state.clone(), label) + .and("Maybe".into_button().on_click(move |()| { + checkbox_state.update(CheckboxState::Indeterminant); + })) + .into_columns() + .centered() + .expand() + .run() +} diff --git a/examples/theme.rs b/examples/theme.rs index 6067b5c..948c53b 100644 --- a/examples/theme.rs +++ b/examples/theme.rs @@ -1,5 +1,3 @@ -use std::str::FromStr; - use gooey::animation::ZeroToOne; use gooey::styles::components::{TextColor, WidgetBackground}; use gooey::styles::{ @@ -87,26 +85,14 @@ fn dark_mode_slider() -> (Dynamic, impl MakeWidget) { ) } -fn create_paired_string(initial_value: T) -> (Dynamic, Dynamic) -where - T: ToString + PartialEq + FromStr + Default + Send + Sync + 'static, -{ - let float = Dynamic::new(initial_value); - let text = float.map_each_unique(|f| f.to_string()); - text.for_each(float.with_clone(|float| { - move |text: &String| { - let _result = float.try_update(text.parse().unwrap_or_default()); - } - })); - (float, text) -} - fn color_editor( initial_color: ColorSource, label: &str, ) -> (Dynamic, impl MakeWidget) { - let (hue, hue_text) = create_paired_string(initial_color.hue.into_degrees()); - let (saturation, saturation_text) = create_paired_string(initial_color.saturation); + let hue = Dynamic::new(initial_color.hue.into_degrees()); + let hue_text = hue.linked_string(); + let saturation = Dynamic::new(initial_color.saturation); + let saturation_text = saturation.linked_string(); let color = (&hue, &saturation).map_each(|(hue, saturation)| ColorSource::new(*hue, *saturation)); @@ -274,6 +260,7 @@ fn surface_theme(theme: Dynamic) -> impl MakeWidget { fn color_theme(theme: Dynamic, label: &str) -> impl MakeWidget { let color = theme.map_each(|theme| theme.color); let dim_color = theme.map_each(|theme| theme.color_dim); + let bright_color = theme.map_each(|theme| theme.color_bright); let on_color = theme.map_each(|theme| theme.on_color); let container = theme.map_each(|theme| theme.container); let on_container = theme.map_each(|theme| theme.on_container); @@ -284,6 +271,11 @@ fn color_theme(theme: Dynamic, label: &str) -> impl MakeWidget { &format!("{label} Dim"), on_color.clone(), )) + .and(swatch( + bright_color.clone(), + &format!("{label} bright"), + on_color.clone(), + )) .and(swatch( on_color.clone(), &format!("On {label}"), diff --git a/src/context.rs b/src/context.rs index a7cc3ae..3782beb 100644 --- a/src/context.rs +++ b/src/context.rs @@ -481,7 +481,7 @@ impl<'context, 'window, 'clip, 'gfx, 'pass> GraphicsContext<'context, 'window, ' /// Strokes an outline around this widget's contents. pub fn stroke_outline(&mut self, color: Color, options: StrokeOptions) where - Unit: ScreenScale, + Unit: ScreenScale, { let visible_rect = Rect::from(self.gfx.region().size - (Px(1), Px(1))); let focus_ring = diff --git a/src/lib.rs b/src/lib.rs index 7309d9b..908878f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -25,7 +25,7 @@ use std::ops::Sub; pub use kludgine; use kludgine::app::winit::error::EventLoopError; use kludgine::figures::units::UPx; -use kludgine::figures::{Fraction, IntoUnsigned, ScreenUnit}; +use kludgine::figures::{Fraction, ScreenUnit}; pub use names::Name; pub use utils::{Lazy, WithClone}; @@ -60,7 +60,7 @@ impl ConstraintLimit { where Unit: ScreenUnit, { - let measured = measured.into_px(scale).into_unsigned(); + let measured = measured.into_upx(scale); match self { ConstraintLimit::Known(size) => size.max(measured), ConstraintLimit::ClippedAfter(_) => measured, diff --git a/src/styles.rs b/src/styles.rs index 7cadfea..ddc560e 100644 --- a/src/styles.rs +++ b/src/styles.rs @@ -12,7 +12,7 @@ use std::sync::Arc; use ahash::AHashMap; use kludgine::figures::units::{Lp, Px, UPx}; -use kludgine::figures::{Fraction, IntoUnsigned, Rect, ScreenScale, Size}; +use kludgine::figures::{Fraction, IntoSigned, IntoUnsigned, Rect, ScreenScale, Size}; use kludgine::Color; use palette::{IntoColor, Okhsl, OklabHue, Srgb}; @@ -376,6 +376,7 @@ impl From for Dimension { impl ScreenScale for Dimension { type Lp = Lp; type Px = Px; + type UPx = UPx; fn into_px(self, scale: kludgine::figures::Fraction) -> Px { match self { @@ -398,6 +399,17 @@ impl ScreenScale for Dimension { fn from_lp(lp: Lp, _scale: kludgine::figures::Fraction) -> Self { Self::from(lp) } + + fn into_upx(self, scale: Fraction) -> Self::UPx { + match self { + Dimension::Px(px) => px.into_unsigned(), + Dimension::Lp(lp) => lp.into_upx(scale), + } + } + + fn from_upx(px: Self::UPx, _scale: Fraction) -> Self { + Self::from(px.into_signed()) + } } impl Mul for Dimension { @@ -469,10 +481,10 @@ impl DimensionRange { #[must_use] pub fn clamp(&self, mut size: UPx, scale: Fraction) -> UPx { if let Some(min) = self.minimum() { - size = size.max(min.into_px(scale).into_unsigned()); + size = size.max(min.into_upx(scale)); } if let Some(max) = self.maximum() { - size = size.min(max.into_px(scale).into_unsigned()); + size = size.min(max.into_upx(scale)); } size } @@ -1152,6 +1164,8 @@ pub struct ColorTheme { pub color: Color, /// The primary color, dimmed for de-emphasized or disabled content. pub color_dim: Color, + /// The primary color, brightened for highlighting content. + pub color_bright: Color, /// The color for content that sits atop the primary color. pub on_color: Color, /// The backgrond color for containers. @@ -1167,6 +1181,7 @@ impl ColorTheme { Self { color: source.color(40), color_dim: source.color(30), + color_bright: source.color(45), on_color: source.color(100), container: source.color(90), on_container: source.color(10), @@ -1179,6 +1194,7 @@ impl ColorTheme { Self { color: source.color(70), color_dim: source.color(60), + color_bright: source.color(75), on_color: source.color(10), container: source.color(30), on_container: source.color(90), diff --git a/src/styles/components.rs b/src/styles/components.rs index 2e95ef4..ae50004 100644 --- a/src/styles/components.rs +++ b/src/styles/components.rs @@ -96,6 +96,8 @@ define_components! { SurfaceColor(Color, "surface_color", .surface.color) /// The [`Color`] to use when rendering text. TextColor(Color, "text_color", .surface.on_color) + /// The [`Color`] to use when rendering text in a more subdued tone. + TextColorVariant(Color, "text_color_variant", .surface.on_color_variant) /// A [`Color`] to be used as a highlight color. HighlightColor(Color,"highlight_color",.primary.color.with_alpha(128)) /// Intrinsic, uniform padding for a widget. @@ -122,6 +124,8 @@ define_components! { AutoFocusableControls(FocusableWidgets, "focus", FocusableWidgets::default()) /// A [`Color`] to be used as the background color of a widget. WidgetBackground(Color, "widget_backgrond_color", Color::CLEAR_WHITE) + /// A [`Color`] to be used to accent a widget. + WidgetAccentColor(Color, "widget_accent_color", .primary.color) /// A [`Color`] to be used as an outline color. OutlineColor(Color, "outline_color", .surface.outline) /// A [`Color`] to be used as an outline color. diff --git a/src/value.rs b/src/value.rs index a19dacc..c378755 100644 --- a/src/value.rs +++ b/src/value.rs @@ -5,6 +5,7 @@ use std::fmt::{Debug, Display}; use std::future::Future; use std::ops::{Deref, DerefMut}; use std::panic::AssertUnwindSafe; +use std::str::FromStr; use std::sync::{Arc, Condvar, Mutex, MutexGuard, TryLockError}; use std::task::{Poll, Waker}; use std::thread::ThreadId; @@ -43,6 +44,74 @@ impl Dynamic { })) } + /// Returns a new dynamic that has its contents linked with `self` by the + /// pair of mapping functions provided. + /// + /// When the returned dynamic is updated, `r_into_t` will be invoked. This + /// function accepts `&R` and can return `T`, or `Option`. If a value is + /// produced, `self` will be updated with the new value. + /// + /// When `self` is updated, `t_into_r` will be invoked. This function + /// accepts `&T` and can return `R` or `Option`. If a value is produced, + /// the returned dynamic will be updated with the new value. + /// + /// # Panics + /// + /// This function panics if calling `t_into_r` with the current contents of + /// the Dynamic produces a `None` value. This requirement is only for the + /// first invocation, and it is guaranteed to occur before this function + /// returns. + pub fn linked( + &self, + mut t_into_r: TIntoR, + mut r_into_t: RIntoT, + ) -> Dynamic + where + T: PartialEq + Send + 'static, + R: PartialEq + Send + 'static, + TIntoRResult: Into> + Send + 'static, + RIntoTResult: Into> + Send + 'static, + TIntoR: FnMut(&T) -> TIntoRResult + Send + 'static, + RIntoT: FnMut(&R) -> RIntoTResult + Send + 'static, + { + let initial_r = self + .map_ref(&mut t_into_r) + .into() + .expect("t_into_r must succeed with the current value"); + let r = Dynamic::new(initial_r); + r.with_clone(move |r| { + self.for_each(move |t| { + if let Some(update) = t_into_r(t).into() { + let _result = r.try_update(update); + } + }); + }); + + self.with_clone(|t| { + r.with_for_each(move |r| { + if let Some(update) = r_into_t(r).into() { + let _result = t.try_update(update); + } + }) + }) + } + + /// Creates a [linked](Self::linked) dynamic containing a `String`. + /// + /// When `self` is updated, [`ToString::to_string()`] will be called to + /// produce a new string value to store in the returned dynamic. + /// + /// When the returned dynamic is updated, [`str::parse`](std::str) is called + /// to produce a new `T`. If an error is returned, `self` will not be + /// updated. Otherwise, `self` will be updated with the produced value. + #[must_use] + pub fn linked_string(&self) -> Dynamic + where + T: ToString + FromStr + PartialEq + Send + 'static, + { + self.linked(ToString::to_string, |s: &String| s.parse().ok()) + } + /// Maps the contents with read-only access. /// /// # Panics @@ -749,6 +818,45 @@ impl DynamicReader { value } + /// Returns a clone of the currently contained value. + /// + /// This function marks the currently stored value as being read. + /// + /// `context` will be invalidated when the value is updated. + /// + /// # Panics + /// + /// This function panics if this value is already locked by the current + /// thread. + #[must_use] + pub fn get_tracking_refresh(&mut self, context: &WidgetContext<'_, '_>) -> T + where + T: Clone, + { + self.source.redraw_when_changed(context.handle()); + self.get() + } + + /// Returns a clone of the currently contained value. + /// + /// This function marks the currently stored value as being read. + /// + /// `context` will be invalidated when the value is updated. + /// + /// # Panics + /// + /// This function panics if this value is already locked by the current + /// thread. + #[must_use] + pub fn get_tracking_invalidate(&mut self, context: &WidgetContext<'_, '_>) -> T + where + T: Clone, + { + self.source + .invalidate_when_changed(context.handle(), context.widget().id()); + self.get() + } + /// Blocks the current thread until the contained value has been updated or /// there are no remaining writers for the value. /// diff --git a/src/widget.rs b/src/widget.rs index be850d9..1cb5c29 100644 --- a/src/widget.rs +++ b/src/widget.rs @@ -228,6 +228,18 @@ pub trait WrapperWidget: Debug + Send + UnwindSafe + 'static { /// Returns the child widget. fn child_mut(&mut self) -> &mut WidgetRef; + /// Draws the background of the widget. + /// + /// This is invoked before the wrapped widget is drawn. + #[allow(unused_variables)] + fn redraw_background(&mut self, context: &mut GraphicsContext<'_, '_, '_, '_, '_>) {} + + /// Draws the foreground of the widget. + /// + /// This is invoked after the wrapped widget is drawn. + #[allow(unused_variables)] + fn redraw_foreground(&mut self, context: &mut GraphicsContext<'_, '_, '_, '_, '_>) {} + /// Returns the rectangle that the child widget should occupy given /// `available_space`. #[allow(unused_variables)] @@ -266,7 +278,7 @@ pub trait WrapperWidget: Debug + Send + UnwindSafe + 'static { available_space: Size, context: &mut LayoutContext<'_, '_, '_, '_, '_>, ) -> WrappedLayout { - Size::::new( + Size::new( available_space .width .fit_measured(size.width, context.gfx.scale()), @@ -419,8 +431,12 @@ where context.gfx.fill(color); } + self.redraw_background(context); + let child = self.child_mut().mounted(&mut context.as_event_context()); context.for_other(&child).redraw(); + + self.redraw_foreground(context); } fn layout( @@ -627,9 +643,12 @@ pub trait MakeWidget: Sized { /// Resizes `self` to `width`. /// - /// `width` can be an individual - /// [`Dimension`]/[`Px`]/[`Lp`](crate::kludgine::figures::units::Lp) or a - /// range. + /// `width` can be an any of: + /// + /// - [`Dimension`] + /// - [`Px`] + /// - [`Lp`](crate::kludgine::figures::units::Lp) + /// - A range of any fo the above. #[must_use] fn width(self, width: impl Into) -> Resize { Resize::from_width(width, self) @@ -637,9 +656,12 @@ pub trait MakeWidget: Sized { /// Resizes `self` to `height`. /// - /// `height` can be an individual - /// [`Dimension`]/[`Px`]/[`Lp`](crate::kludgine::figures::units::Lp) or a - /// range. + /// `height` can be an any of: + /// + /// - [`Dimension`] + /// - [`Px`] + /// - [`Lp`](crate::kludgine::figures::units::Lp) + /// - A range of any fo the above. #[must_use] fn height(self, height: impl Into) -> Resize { Resize::from_height(height, self) diff --git a/src/widgets.rs b/src/widgets.rs index b43bc77..0c0061e 100644 --- a/src/widgets.rs +++ b/src/widgets.rs @@ -3,6 +3,7 @@ mod align; pub mod button; mod canvas; +pub mod checkbox; pub mod container; mod expand; mod input; @@ -21,6 +22,7 @@ mod tilemap; pub use align::Align; pub use button::Button; pub use canvas::Canvas; +pub use checkbox::Checkbox; pub use container::Container; pub use expand::Expand; pub use input::Input; diff --git a/src/widgets/align.rs b/src/widgets/align.rs index f7c7106..5ee0b72 100644 --- a/src/widgets/align.rs +++ b/src/widgets/align.rs @@ -1,7 +1,7 @@ use std::fmt::Debug; use kludgine::figures::units::UPx; -use kludgine::figures::{Fraction, IntoSigned, IntoUnsigned, Point, Rect, ScreenScale, Size}; +use kludgine::figures::{Fraction, IntoSigned, Point, Rect, ScreenScale, Size}; use crate::context::{AsEventContext, LayoutContext}; use crate::styles::{Edges, FlexibleDimension}; @@ -125,15 +125,11 @@ impl FrameInfo { fn new(scale: Fraction, a: FlexibleDimension, b: FlexibleDimension) -> Self { let a = match a { FlexibleDimension::Auto => None, - FlexibleDimension::Dimension(dimension) => { - Some(dimension.into_px(scale).into_unsigned()) - } + FlexibleDimension::Dimension(dimension) => Some(dimension.into_upx(scale)), }; let b = match b { FlexibleDimension::Auto => None, - FlexibleDimension::Dimension(dimension) => { - Some(dimension.into_px(scale).into_unsigned()) - } + FlexibleDimension::Dimension(dimension) => Some(dimension.into_upx(scale)), }; Self { a, b } } diff --git a/src/widgets/button.rs b/src/widgets/button.rs index c55b5ce..75f2b40 100644 --- a/src/widgets/button.rs +++ b/src/widgets/button.rs @@ -4,16 +4,17 @@ use std::time::Duration; use kludgine::app::winit::event::{DeviceId, ElementState, KeyEvent, MouseButton}; use kludgine::figures::units::{Lp, Px, UPx}; -use kludgine::figures::{IntoSigned, IntoUnsigned, Point, Rect, ScreenScale, Size}; -use kludgine::shapes::StrokeOptions; +use kludgine::figures::{IntoSigned, Point, Rect, ScreenScale, Size}; +use kludgine::shapes::{Shape, StrokeOptions}; use kludgine::Color; use crate::animation::{AnimationHandle, AnimationTarget, LinearInterpolate, Spawn}; use crate::context::{AsEventContext, EventContext, GraphicsContext, LayoutContext, WidgetContext}; use crate::styles::components::{ - AutoFocusableControls, Easing, IntrinsicPadding, OpaqueWidgetColor, SurfaceColor, TextColor, + AutoFocusableControls, Easing, HighlightColor, IntrinsicPadding, OpaqueWidgetColor, + OutlineColor, SurfaceColor, TextColor, }; -use crate::styles::Styles; +use crate::styles::{ColorExt, Styles}; use crate::utils::ModifiersExt; use crate::value::{Dynamic, IntoValue, Value}; use crate::widget::{Callback, EventHandling, MakeWidget, Widget, WidgetRef, HANDLED, IGNORED}; @@ -27,17 +28,100 @@ pub struct Button { pub on_click: Option>, /// The enabled state of the button. pub enabled: Value, - currently_enabled: bool, + /// The kind of button to draw. + pub kind: Value, buttons_pressed: usize, - active_style: Option>, + cached_state: CacheState, + active_colors: Option>, color_animation: AnimationHandle, } +#[derive(Debug, Eq, PartialEq, Clone, Copy)] +struct CacheState { + enabled: bool, + kind: ButtonKind, +} + +/// The type of a [`Button`] or similar clickable widget. +#[derive(Debug, Default, Eq, PartialEq, Clone, Copy)] +pub enum ButtonKind { + /// A solid button. + #[default] + Solid, + /// An outline button, which uses the same colors as [`ButtonKind::Solid`] + /// but swaps the outline and background colors. + Outline, + /// A transparent button, which is transparent until it is hovered. + Transparent, +} + +impl ButtonKind { + /// Returns the [`ButtonColors`] to apply for a + /// [default](MakeWidget::into_default) button. + #[must_use] + pub fn colors_for_default( + self, + visual_state: VisualState, + context: &WidgetContext<'_, '_>, + ) -> ButtonColors { + match self { + ButtonKind::Solid => match visual_state { + VisualState::Normal => ButtonColors { + background: context.theme().primary.color, + foreground: context.theme().primary.on_color, + outline: context.get(&ButtonOutline), + }, + VisualState::Hovered => ButtonColors { + background: context.theme().primary.color_bright, + foreground: context.theme().primary.on_color, + outline: context.get(&ButtonHoverOutline), + }, + VisualState::Active => ButtonColors { + background: context.theme().primary.color_dim, + foreground: context.theme().primary.on_color, + outline: context.get(&ButtonActiveOutline), + }, + VisualState::Disabled => ButtonColors { + background: context.theme().primary.color_dim, + foreground: context.theme().primary.on_color, + outline: context.get(&ButtonDisabledOutline), + }, + }, + ButtonKind::Outline | ButtonKind::Transparent => match visual_state { + VisualState::Normal => ButtonColors { + background: context.get(&ButtonOutline), + foreground: context.theme().primary.color, + outline: context.theme().primary.color, + }, + VisualState::Hovered => ButtonColors { + background: context.get(&ButtonHoverOutline), + foreground: context.theme().primary.color, + outline: context.theme().primary.color_bright, + }, + VisualState::Active => ButtonColors { + background: context.get(&ButtonActiveOutline), + foreground: context.theme().primary.color, + outline: context.theme().surface.color, + }, + VisualState::Disabled => ButtonColors { + background: context.get(&ButtonDisabledOutline), + foreground: context.theme().primary.on_color, + outline: context.theme().primary.color_dim, + }, + }, + } + } +} + +/// The coloring to apply to a [`Button`] or button-like widget. #[derive(Debug, PartialEq, Eq, Clone, Copy, LinearInterpolate)] -struct ButtonStyle { - background: Color, - foreground: Color, - outline: Color, +pub struct ButtonColors { + /// The background color of the button. + pub background: Color, + /// The foreground (text) color of the button. + pub foreground: Color, + /// A color to use to surround the button. + pub outline: Color, } impl Button { @@ -47,13 +131,24 @@ impl Button { content: content.widget_ref(), on_click: None, enabled: Value::Constant(true), - currently_enabled: true, + cached_state: CacheState { + enabled: true, + kind: ButtonKind::default(), + }, buttons_pressed: 0, - active_style: None, + active_colors: None, + kind: Value::Constant(ButtonKind::default()), color_animation: AnimationHandle::default(), } } + /// Sets the button's `kind` and returns self. + #[must_use] + pub fn kind(mut self, kind: impl IntoValue) -> Self { + self.kind = kind.into_value(); + self + } + /// Sets the `on_click` callback and returns self. /// /// This callback will be invoked each time the button is clicked. @@ -70,7 +165,6 @@ impl Button { #[must_use] pub fn enabled(mut self, enabled: impl IntoValue) -> Self { self.enabled = enabled.into_value(); - self.currently_enabled = self.enabled.get(); self } @@ -82,37 +176,72 @@ impl Button { } } - fn update_colors(&mut self, context: &WidgetContext<'_, '_>, immediate: bool) { - let new_style = match () { - () if !self.enabled.get() => ButtonStyle { - background: context.get(&ButtonDisabledBackground), - foreground: context.get(&ButtonDisabledForeground), - outline: context.get(&ButtonDisabledOutline), + fn visual_style(&self, context: &WidgetContext<'_, '_>) -> VisualState { + if !self.enabled.get_tracked(context) { + VisualState::Disabled + } else if context.active() { + VisualState::Active + } else if context.hovered() { + VisualState::Hovered + } else { + VisualState::Normal + } + } + + /// Returns the coloring to apply to a [`ButtonKind::Transparent`] button. + #[must_use] + pub fn colors_for_transparent( + visual_state: VisualState, + context: &WidgetContext<'_, '_>, + ) -> ButtonColors { + match visual_state { + VisualState::Normal => ButtonColors { + background: Color::CLEAR_BLACK, + foreground: context.get(&TextColor), + outline: context.get(&ButtonOutline), }, - // TODO this probably should use actual style. - () if context.is_default() => ButtonStyle { - background: context.theme().primary.color, - foreground: context.theme().primary.on_color, - outline: Color::CLEAR_BLACK, + VisualState::Hovered => ButtonColors { + background: context.get(&OpaqueWidgetColor), + foreground: context.get(&TextColor), + outline: context.get(&ButtonHoverOutline), }, - () if context.active() => ButtonStyle { + VisualState::Active => ButtonColors { background: context.get(&ButtonActiveBackground), foreground: context.get(&ButtonActiveForeground), outline: context.get(&ButtonActiveOutline), }, - () if context.hovered() => ButtonStyle { - background: context.get(&ButtonHoverBackground), - foreground: context.get(&ButtonHoverForeground), - outline: context.get(&ButtonHoverOutline), - }, - () => ButtonStyle { - background: context.get(&ButtonBackground), - foreground: context.get(&ButtonForeground), - outline: context.get(&ButtonOutline), + VisualState::Disabled => ButtonColors { + background: Color::CLEAR_BLACK, + foreground: context.theme().surface.on_color_variant, + outline: context.get(&ButtonDisabledOutline), }, + } + } + + fn determine_stateful_colors(&mut self, context: &WidgetContext<'_, '_>) -> ButtonColors { + let kind = self.kind.get_tracked(context); + let visual_state = self.visual_style(context); + + self.cached_state = CacheState { + enabled: !matches!(visual_state, VisualState::Disabled), + kind, }; - match (immediate, &self.active_style) { + if context.is_default() { + kind.colors_for_default(visual_state, context) + } else { + match kind { + ButtonKind::Transparent => Self::colors_for_transparent(visual_state, context), + ButtonKind::Solid => visual_state.solid_colors(context), + ButtonKind::Outline => visual_state.outline_colors(context), + } + } + } + + fn update_colors(&mut self, context: &WidgetContext<'_, '_>, immediate: bool) { + let new_style = self.determine_stateful_colors(context); + + match (immediate, &self.active_colors) { (false, Some(style)) => { self.color_animation = (style.transition_to(new_style)) .over(Duration::from_millis(150)) @@ -126,43 +255,139 @@ impl Button { _ => { let new_style = Dynamic::new(new_style); let foreground = new_style.map_each(|s| s.foreground); - self.active_style = Some(new_style); + self.active_colors = Some(new_style); context.attach_styles(Styles::new().with(&TextColor, foreground)); } } } - fn current_style(&mut self, context: &WidgetContext<'_, '_>) -> ButtonStyle { - if self.active_style.is_none() { + fn current_style(&mut self, context: &WidgetContext<'_, '_>) -> ButtonColors { + if self.active_colors.is_none() { self.update_colors(context, false); } - let style = self.active_style.as_ref().expect("always initialized"); + let style = self.active_colors.as_ref().expect("always initialized"); context.redraw_when_changed(style); style.get() } } +/// The effective visual state of an element. +/// +/// While an element may be multiple states (e.g., active and hovered), when +/// rendering a widget sometimes a single visual style must take priority. This +/// enum represents the various states a widget may be in for such a decision. +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub enum VisualState { + /// The widget should render in its normal state. + Normal, + /// The widget should render in reaction to the mouse cursor being above the + /// widget. + Hovered, + /// The widget should render in reaction to the widget being clicked on or + /// activated by the user. + Active, + /// The widget should render in a way to convey to the user it is not + /// enabled for interaction. + Disabled, +} + +impl VisualState { + /// Returns the colors to apply to a [`ButtonKind::Solid`] [`Button`] or + /// button-like widget. + #[must_use] + pub fn solid_colors(self, context: &WidgetContext<'_, '_>) -> ButtonColors { + match self { + VisualState::Normal => ButtonColors { + background: context.get(&ButtonBackground), + foreground: context.get(&ButtonForeground), + outline: context.get(&ButtonOutline), + }, + VisualState::Hovered => ButtonColors { + background: context.get(&ButtonHoverBackground), + foreground: context.get(&ButtonHoverForeground), + outline: context.get(&ButtonHoverOutline), + }, + VisualState::Active => ButtonColors { + background: context.get(&ButtonActiveBackground), + foreground: context.get(&ButtonActiveForeground), + outline: context.get(&ButtonActiveOutline), + }, + VisualState::Disabled => ButtonColors { + background: context.get(&ButtonDisabledBackground), + foreground: context.get(&ButtonDisabledForeground), + outline: context.get(&ButtonDisabledOutline), + }, + } + } + + /// Returns the colors to apply to a [`ButtonKind::Outline`] [`Button`] or + /// button-like widget. + #[must_use] + pub fn outline_colors(self, context: &WidgetContext<'_, '_>) -> ButtonColors { + let solid = self.solid_colors(context); + ButtonColors { + background: solid.outline, + foreground: solid.foreground, + outline: solid.background, + } + } +} + impl Widget for Button { fn redraw(&mut self, context: &mut GraphicsContext<'_, '_, '_, '_, '_>) { #![allow(clippy::similar_names)] - let enabled = self.enabled.get(); + let enabled = self.enabled.get_tracked(context); + // TODO This seems ugly. It needs context, so it can't be moved into the // dynamic system. - if self.currently_enabled != enabled { + let current_style = self.kind.get_tracked(context); + if self.cached_state.enabled != enabled || self.cached_state.kind != current_style { self.update_colors(context, false); - self.currently_enabled = enabled; } - self.enabled.redraw_when_changed(context); - let style = self.current_style(context); context.gfx.fill(style.background); - context.stroke_outline::(style.outline, StrokeOptions::default()); + let two_lp_stroke = StrokeOptions::lp_wide(Lp::points(2)); + context.stroke_outline(style.outline, two_lp_stroke); if context.focused() { - context.draw_focus_ring(); + if current_style == ButtonKind::Transparent { + let two_lp_stroke = two_lp_stroke.into_px(context.gfx.scale()); + let focus_color = context.get(&HighlightColor); + // Some states of a transparent button have solid background + // colors. most_contrasting from a 0-alpha color is not a + // meaningful measurement, so we only start measuring contrast + // once we reach 50% opacity. If we ever add solid background + // tracking (), + // we should use that color for most_contrasting always. + let color = if style.background.alpha() > 128 { + style + .background + .most_contrasting(&[focus_color, context.get(&TextColor)]) + } else { + focus_color + }; + + let inset = context.get(&IntrinsicPadding).into_px(context.gfx.scale()); + + let focus_ring = Shape::stroked_rect( + Rect::new( + Point::new(inset, inset), + context.gfx.region().size - inset * 2, + ), + color, + two_lp_stroke, + ); + context + .gfx + .draw_shape(&focus_ring, Point::default(), None, None); + } else if context.is_default() { + context.stroke_outline(context.get(&OutlineColor), two_lp_stroke); + } else { + context.draw_focus_ring(); + } } let content = self.content.mounted(&mut context.as_event_context()); @@ -237,10 +462,7 @@ impl Widget for Button { available_space: Size, context: &mut LayoutContext<'_, '_, '_, '_, '_>, ) -> Size { - let padding = context - .get(&IntrinsicPadding) - .into_px(context.gfx.scale()) - .into_unsigned(); + let padding = context.get(&IntrinsicPadding).into_upx(context.gfx.scale()); let mounted = self.content.mounted(&mut context.as_event_context()); let size = context.for_other(&mounted).layout(available_space); context.set_child_layout( @@ -317,7 +539,7 @@ define_components! { ButtonActiveBackground(Color, "active_background_color", .surface.color) /// The background color of the button when the mouse cursor is hovering over /// it. - ButtonHoverBackground(Color, "hover_background_color", .surface.bright_color) + ButtonHoverBackground(Color, "hover_background_color", .surface.lowest_container) /// The background color of the button when the mouse cursor is hovering over /// it. ButtonDisabledBackground(Color, "disabled_background_color", .surface.dim_color) @@ -334,12 +556,12 @@ define_components! { /// The outline color of the button. ButtonOutline(Color, "outline_color", Color::CLEAR_BLACK) /// The outline color of the button when it is active (depressed). - ButtonActiveOutline(Color, "active_outline_color", contrasting!(ButtonActiveBackground, ButtonOutline, TextColor, SurfaceColor)) + ButtonActiveOutline(Color, "active_outline_color", Color::CLEAR_BLACK) /// The outline color of the button when the mouse cursor is hovering over /// it. - ButtonHoverOutline(Color, "hover_outline_color", contrasting!(ButtonHoverBackground, ButtonOutline, TextColor, SurfaceColor)) + ButtonHoverOutline(Color, "hover_outline_color", Color::CLEAR_BLACK) /// The outline color of the button when the mouse cursor is hovering over /// it. - ButtonDisabledOutline(Color, "disabled_outline_color", contrasting!(ButtonDisabledBackground, ButtonOutline, TextColor, SurfaceColor)) + ButtonDisabledOutline(Color, "disabled_outline_color", Color::CLEAR_BLACK) } } diff --git a/src/widgets/checkbox.rs b/src/widgets/checkbox.rs new file mode 100644 index 0000000..ffa9144 --- /dev/null +++ b/src/widgets/checkbox.rs @@ -0,0 +1,233 @@ +//! A tri-state, labelable checkbox widget. +use std::error::Error; +use std::fmt::Display; +use std::ops::Not; + +use kludgine::figures::units::{Lp, Px}; +use kludgine::figures::{IntoUnsigned, Point, Rect, ScreenScale, Size}; +use kludgine::shapes::{PathBuilder, Shape, StrokeOptions}; + +use crate::context::{GraphicsContext, LayoutContext}; +use crate::styles::components::{ + IntrinsicPadding, LineHeight, OutlineColor, TextColor, WidgetAccentColor, +}; +use crate::value::{Dynamic, DynamicReader, IntoDynamic, IntoValue, Value}; +use crate::widget::{MakeWidget, WidgetInstance, WidgetRef, WrappedLayout, WrapperWidget}; +use crate::widgets::button::ButtonKind; +use crate::ConstraintLimit; + +/// A labeled-widget that supports three states: Checked, Unchecked, and +/// Indeterminant +pub struct Checkbox { + /// The state (value) of the checkbox. + pub state: Dynamic, + /// The button kind to use as the basis for this checkbox. Checkboxes + /// default to [`ButtonKind::Transparent`]. + pub kind: Value, + label: WidgetInstance, +} + +impl Checkbox { + /// Returns a new checkbox that updates `state` when clicked. `label` is + /// drawn next to the checkbox and is also clickable to toggle the checkbox. + /// + /// `state` can also be a `Dynamic` if there is no need to represent + /// an indeterminant state. + pub fn new(state: impl IntoDynamic, label: impl MakeWidget) -> Self { + Self { + state: state.into_dynamic(), + kind: Value::Constant(ButtonKind::Transparent), + label: label.make_widget(), + } + } + + /// Updates the button kind to use as the basis for this checkbox, and + /// returns self. + /// + /// Checkboxes default to [`ButtonKind::Transparent`]. + #[must_use] + pub fn kind(mut self, kind: impl IntoValue) -> Self { + self.kind = kind.into_value(); + self + } +} + +impl MakeWidget for Checkbox { + fn make_widget(self) -> WidgetInstance { + CheckboxLabel { + value: self.state.create_reader(), + label: WidgetRef::new(self.label), + } + .into_button() + .on_click(move |()| { + let mut value = self.state.lock(); + *value = !*value; + }) + .kind(self.kind) + .make_widget() + } +} + +/// The state/value of a [`Checkbox`]. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum CheckboxState { + /// The checkbox should display showing that it is neither checked or + /// unchecked. + /// + /// This state is used to represent concepts such as: + /// + /// - States that are neither true/false, or on/off. + /// - States that are partially true or partially on. + Indeterminant, + /// The checkbox should display in an unchecked/off/false state. + Unchecked, + /// The checkbox should display in an checked/on/true state. + Checked, +} + +impl From for CheckboxState { + fn from(value: bool) -> Self { + if value { + Self::Checked + } else { + Self::Unchecked + } + } +} + +impl TryFrom for bool { + type Error = CheckboxToBoolError; + + fn try_from(value: CheckboxState) -> Result { + match value { + CheckboxState::Checked => Ok(true), + CheckboxState::Unchecked => Ok(false), + CheckboxState::Indeterminant => Err(CheckboxToBoolError), + } + } +} + +impl Not for CheckboxState { + type Output = Self; + + fn not(self) -> Self::Output { + match self { + Self::Indeterminant | Self::Unchecked => Self::Checked, + Self::Checked => Self::Unchecked, + } + } +} + +impl IntoDynamic for Dynamic { + fn into_dynamic(self) -> Dynamic { + self.linked( + |bool| CheckboxState::from(*bool), + |tri_state: &CheckboxState| bool::try_from(*tri_state).ok(), + ) + } +} + +/// An [`CheckboxState::Indeterminant`] was encountered when converting to a +/// `bool`. +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub struct CheckboxToBoolError; + +impl Display for CheckboxToBoolError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("CheckboxState was Indeterminant") + } +} + +impl Error for CheckboxToBoolError {} + +#[derive(Debug)] +struct CheckboxLabel { + value: DynamicReader, + label: WidgetRef, +} + +impl WrapperWidget for CheckboxLabel { + fn child_mut(&mut self) -> &mut WidgetRef { + &mut self.label + } + + fn position_child( + &mut self, + size: Size, + _available_space: Size, + context: &mut LayoutContext<'_, '_, '_, '_, '_>, + ) -> WrappedLayout { + let checkbox_size = context.get(&LineHeight).into_px(context.gfx.scale()); // TODO create a component? + let padding = context.get(&IntrinsicPadding).into_px(context.gfx.scale()); + let label_inset = checkbox_size + padding; + let size_with_checkbox = Size::new(size.width + label_inset, size.height).into_unsigned(); + WrappedLayout { + child: Rect::new(Point::new(label_inset, Px(0)), size), + size: size_with_checkbox, + } + } + + fn redraw_background(&mut self, context: &mut GraphicsContext<'_, '_, '_, '_, '_>) { + let checkbox_size = context.get(&LineHeight).into_px(context.gfx.scale()); + let padding = context.get(&IntrinsicPadding).into_px(context.gfx.scale()); + let checkbox_rect = Rect::new( + Point::new(padding, padding), + Size::new(checkbox_size, checkbox_size), + ); + let stroke_options = StrokeOptions::lp_wide(Lp::points(2)).into_px(context.gfx.scale()); + match self.value.get_tracking_refresh(context) { + state @ (CheckboxState::Checked | CheckboxState::Indeterminant) => { + let color = context.get(&WidgetAccentColor); + context.gfx.draw_shape( + &Shape::filled_rect(checkbox_rect, color), + Point::default(), + None, + None, + ); + let icon_area = checkbox_rect.inset(Lp::points(3).into_px(context.gfx.scale())); + let text_color = context.get(&TextColor); + let center = icon_area.origin + icon_area.size / 2; + if matches!(state, CheckboxState::Checked) { + context.gfx.draw_shape( + &PathBuilder::new(Point::new(icon_area.origin.x, center.y)) + .line_to(Point::new( + icon_area.origin.x + icon_area.size.width / 4, + icon_area.origin.y + icon_area.size.height * 3 / 4, + )) + .line_to(Point::new( + icon_area.origin.x + icon_area.size.width, + icon_area.origin.y, + )) + .build() + .stroke(text_color, stroke_options), + Point::default(), + None, + None, + ); + } else { + context.gfx.draw_shape( + &PathBuilder::new(Point::new(icon_area.origin.x, center.y)) + .line_to(Point::new( + icon_area.origin.x + icon_area.size.width, + center.y, + )) + .build() + .stroke(text_color, stroke_options), + Point::default(), + None, + None, + ); + } + } + CheckboxState::Unchecked => { + let color = context.get(&OutlineColor); + context.gfx.draw_shape( + &Shape::stroked_rect(checkbox_rect, color, stroke_options), + Point::default(), + None, + None, + ); + } + } + } +} diff --git a/src/widgets/container.rs b/src/widgets/container.rs index 3b22687..7b28c4d 100644 --- a/src/widgets/container.rs +++ b/src/widgets/container.rs @@ -193,11 +193,7 @@ impl WrapperWidget for Container { available_space: Size, context: &mut LayoutContext<'_, '_, '_, '_, '_>, ) -> Size { - let padding_amount = self - .padding(context) - .size() - .into_px(context.gfx.scale()) - .into_unsigned(); + let padding_amount = self.padding(context).size().into_upx(context.gfx.scale()); Size::new( available_space.width - padding_amount.width, available_space.height - padding_amount.height, diff --git a/src/widgets/expand.rs b/src/widgets/expand.rs index 050ac2c..b654f52 100644 --- a/src/widgets/expand.rs +++ b/src/widgets/expand.rs @@ -1,4 +1,3 @@ -use kludgine::figures::units::UPx; use kludgine::figures::{IntoSigned, Size}; use crate::context::{AsEventContext, LayoutContext}; @@ -133,6 +132,6 @@ impl WrapperWidget for Expand { ), }; - Size::::new(width, height).into_signed().into() + Size::new(width, height).into_signed().into() } } diff --git a/src/widgets/input.rs b/src/widgets/input.rs index 4b74396..c294600 100644 --- a/src/widgets/input.rs +++ b/src/widgets/input.rs @@ -351,10 +351,7 @@ impl Widget for Input { available_space: Size, context: &mut LayoutContext<'_, '_, '_, '_, '_>, ) -> Size { - let padding = context - .get(&IntrinsicPadding) - .into_px(context.gfx.scale()) - .into_unsigned(); + let padding = context.get(&IntrinsicPadding).into_upx(context.gfx.scale()); if self.needs_to_select_all { self.needs_to_select_all = false; self.select_all(); diff --git a/src/widgets/label.rs b/src/widgets/label.rs index 0a573f0..a488902 100644 --- a/src/widgets/label.rs +++ b/src/widgets/label.rs @@ -1,7 +1,7 @@ //! A read-only text widget. use kludgine::figures::units::{Px, UPx}; -use kludgine::figures::{IntoUnsigned, Point, ScreenScale, Size}; +use kludgine::figures::{Point, ScreenScale, Size}; use kludgine::text::{MeasuredText, Text, TextOrigin}; use kludgine::Color; @@ -79,10 +79,7 @@ impl Widget for Label { available_space: Size, context: &mut LayoutContext<'_, '_, '_, '_, '_>, ) -> Size { - let padding = context - .get(&IntrinsicPadding) - .into_px(context.gfx.scale()) - .into_unsigned(); + let padding = context.get(&IntrinsicPadding).into_upx(context.gfx.scale()); let color = context.get(&TextColor); let width = available_space.width.max().try_into().unwrap_or(Px::MAX); let prepared = self.prepared_text(context, color, width); diff --git a/src/widgets/resize.rs b/src/widgets/resize.rs index 241655a..eb51cfc 100644 --- a/src/widgets/resize.rs +++ b/src/widgets/resize.rs @@ -1,5 +1,4 @@ -use kludgine::figures::units::UPx; -use kludgine::figures::{Fraction, IntoSigned, IntoUnsigned, ScreenScale, Size}; +use kludgine::figures::{Fraction, IntoSigned, ScreenScale, Size}; use crate::context::{AsEventContext, LayoutContext}; use crate::styles::DimensionRange; @@ -48,9 +47,12 @@ impl Resize { /// Resizes `self` to `width`. /// - /// `width` can be an individual - /// [`Dimension`]/[`Px`]/[`Lp`](crate::kludgine::figures::units::Lp) or a - /// range. + /// `width` can be an any of: + /// + /// - [`Dimension`](crate::styles::Dimension) + /// - [`Px`](crate::kludgine::figures::units::Px) + /// - [`Lp`](crate::kludgine::figures::units::Lp) + /// - A range of any fo the above. #[must_use] pub fn width(mut self, width: impl Into) -> Self { self.width = width.into(); @@ -59,9 +61,12 @@ impl Resize { /// Resizes `self` to `height`. /// - /// `width` can be an individual - /// [`Dimension`]/[`Px`]/[`Lp`](crate::kludgine::figures::units::Lp) or a - /// range. + /// `height` can be an any of: + /// + /// - [`Dimension`](crate::styles::Dimension) + /// - [`Px`](crate::kludgine::figures::units::Px) + /// - [`Lp`](crate::kludgine::figures::units::Lp) + /// - A range of any fo the above. #[must_use] pub fn height(mut self, height: impl Into) -> Self { self.height = height.into(); @@ -94,8 +99,8 @@ impl WrapperWidget for Resize { (self.width.exact_dimension(), self.height.exact_dimension()) { Size::new( - width.into_px(context.gfx.scale()).into_unsigned(), - height.into_px(context.gfx.scale()).into_unsigned(), + width.into_upx(context.gfx.scale()), + height.into_upx(context.gfx.scale()), ) } else { let available_space = Size::new( @@ -104,7 +109,7 @@ impl WrapperWidget for Resize { ); context.for_other(&child).layout(available_space) }; - Size::::new( + Size::new( self.width.clamp(size.width, context.gfx.scale()), self.height.clamp(size.height, context.gfx.scale()), ) @@ -121,9 +126,7 @@ fn override_constraint( match constraint { ConstraintLimit::Known(size) => ConstraintLimit::Known(range.clamp(size, scale)), ConstraintLimit::ClippedAfter(clipped_after) => match (range.minimum(), range.maximum()) { - (Some(min), Some(max)) if min == max => { - ConstraintLimit::Known(min.into_px(scale).into_unsigned()) - } + (Some(min), Some(max)) if min == max => ConstraintLimit::Known(min.into_upx(scale)), _ => ConstraintLimit::ClippedAfter(range.clamp(clipped_after, scale)), }, } diff --git a/src/widgets/scroll.rs b/src/widgets/scroll.rs index cd733ff..91f848e 100644 --- a/src/widgets/scroll.rs +++ b/src/widgets/scroll.rs @@ -172,8 +172,7 @@ impl Widget for Scroll { let (mut scroll, current_max_scroll) = self.constrain_scroll(); let control_size = - Size::::new(available_space.width.max(), available_space.height.max()) - .into_signed(); + Size::new(available_space.width.max(), available_space.height.max()).into_signed(); let max_extents = Size::new( if self.enabled.x { ConstraintLimit::ClippedAfter((control_size.width).into_unsigned()) diff --git a/src/widgets/slider.rs b/src/widgets/slider.rs index 7fabf9c..a1fc92d 100644 --- a/src/widgets/slider.rs +++ b/src/widgets/slider.rs @@ -5,15 +5,15 @@ use std::panic::UnwindSafe; use kludgine::app::winit::event::{DeviceId, MouseButton}; use kludgine::figures::units::{Lp, Px, UPx}; use kludgine::figures::{ - FloatConversion, FromComponents, IntoComponents, IntoSigned, IntoUnsigned, Point, Ranged, Rect, - ScreenScale, Size, + FloatConversion, FromComponents, IntoComponents, IntoSigned, Point, Ranged, Rect, ScreenScale, + Size, }; use kludgine::shapes::Shape; use kludgine::{Color, Origin}; use crate::animation::{LinearInterpolate, PercentBetween}; use crate::context::{EventContext, GraphicsContext, LayoutContext}; -use crate::styles::components::OpaqueWidgetColor; +use crate::styles::components::{OpaqueWidgetColor, WidgetAccentColor}; use crate::styles::Dimension; use crate::value::{Dynamic, IntoDynamic, IntoValue, Value}; use crate::widget::{EventHandling, Widget, HANDLED}; @@ -223,14 +223,10 @@ where available_space: Size, context: &mut LayoutContext<'_, '_, '_, '_, '_>, ) -> Size { - self.knob_size = context - .get(&KnobSize) - .into_px(context.gfx.scale()) - .into_unsigned(); + self.knob_size = context.get(&KnobSize).into_upx(context.gfx.scale()); let minimum_size = context .get(&MinimumSliderSize) - .into_px(context.gfx.scale()) - .into_unsigned(); + .into_upx(context.gfx.scale()); match (available_space.width, available_space.height) { (ConstraintLimit::Known(width), ConstraintLimit::Known(height)) => { @@ -321,7 +317,7 @@ define_components! { /// The minimum length of the slidable dimension. MinimumSliderSize(Dimension, "minimum_size", |context| context.get(&KnobSize) * 2) /// The color of the draggable portion of the knob. - KnobColor(Color, "knob_color", .primary.color) // TODO make this pull from a component multiple widgets can share + KnobColor(Color, "knob_color", @WidgetAccentColor) /// The color of the track that the knob rests on. TrackColor(Color,"track_color", |context| context.get(&KnobColor)) /// The color of the track that the knob rests on. diff --git a/src/widgets/stack.rs b/src/widgets/stack.rs index 92feb66..2d15673 100644 --- a/src/widgets/stack.rs +++ b/src/widgets/stack.rs @@ -377,7 +377,7 @@ impl Layout { Dimension::Px(size) => self.allocated_space.0 += size.into_unsigned(), Dimension::Lp(size) => self.allocated_space.1 += size, } - min.into_px(scale).into_unsigned() + min.into_upx(scale) } }; self.layouts.insert( @@ -397,8 +397,7 @@ impl Layout { ) -> Size { let (space_constraint, other_constraint) = self.orientation.split_size(available); let available_space = space_constraint.max(); - let allocated_space = - self.allocated_space.0 + self.allocated_space.1.into_px(scale).into_unsigned(); + let allocated_space = self.allocated_space.0 + self.allocated_space.1.into_upx(scale); let mut remaining = available_space.saturating_sub(allocated_space); // If our `other_constraint` is not known, we will need to give child // widgets an opportunity to lay themselves out in the full area. This @@ -450,9 +449,7 @@ impl Layout { let (_, measured) = self.orientation.split_size(measure( index, self.orientation.make_size( - ConstraintLimit::Known( - self.layouts[index].size.into_px(scale).into_unsigned(), - ), + ConstraintLimit::Known(self.layouts[index].size.into_upx(scale)), other_constraint, ), !needs_final_layout, @@ -475,9 +472,7 @@ impl Layout { self.orientation.split_size(measure( index, self.orientation.make_size( - ConstraintLimit::Known( - self.layouts[index].size.into_px(scale).into_unsigned(), - ), + ConstraintLimit::Known(self.layouts[index].size.into_upx(scale)), ConstraintLimit::Known(self.other), ), true, diff --git a/src/window.rs b/src/window.rs index a0ab113..531e2c0 100644 --- a/src/window.rs +++ b/src/window.rs @@ -1,5 +1,4 @@ -//! Types for displaying a [`Widget`](crate::widget::Widget) inside of a desktop -//! window. +//! Types for displaying a [`Widget`] inside of a desktop window. use std::cell::RefCell; use std::ffi::OsStr; @@ -382,14 +381,14 @@ where .map_or(Px::MAX, |height| height.into_px(graphics.scale())); let new_min_size = (min_width > 0 || min_height > 0) - .then_some(Size::::new(min_width, min_height).into_unsigned()); + .then_some(Size::new(min_width, min_height).into_unsigned()); if new_min_size != self.min_inner_size && resizable { window.set_min_inner_size(new_min_size); self.min_inner_size = new_min_size; } let new_max_size = (max_width > 0 || max_height > 0) - .then_some(Size::::new(max_width, max_height).into_unsigned()); + .then_some(Size::new(max_width, max_height).into_unsigned()); if new_max_size != self.max_inner_size && resizable { window.set_max_inner_size(new_max_size); From 80ba184c15a1de4edab6f0050069690f2295d50e Mon Sep 17 00:00:00 2001 From: Jonathan Johnson Date: Tue, 14 Nov 2023 20:41:04 -0800 Subject: [PATCH 13/24] Removing centered from readme example Until #78 is addressed. --- .crate-docs.md | 2 -- README.md | 2 -- examples/basic-button.rs | 2 -- 3 files changed, 6 deletions(-) diff --git a/.crate-docs.md b/.crate-docs.md index a1998a7..088a382 100644 --- a/.crate-docs.md +++ b/.crate-docs.md @@ -27,8 +27,6 @@ fn main() -> gooey::Result { .into_button() // Set the `on_click` callback to a closure that increments the counter. .on_click(count.with_clone(|count| move |_| count.set(count.get() + 1))) - // Position the button in the center - .centered() // Run the application .run() } diff --git a/README.md b/README.md index ff46a56..33a9ff5 100644 --- a/README.md +++ b/README.md @@ -29,8 +29,6 @@ fn main() -> gooey::Result { .into_button() // Set the `on_click` callback to a closure that increments the counter. .on_click(count.with_clone(|count| move |_| count.set(count.get() + 1))) - // Position the button in the center - .centered() // Run the application .run() } diff --git a/examples/basic-button.rs b/examples/basic-button.rs index 34ba73c..cdccc9f 100644 --- a/examples/basic-button.rs +++ b/examples/basic-button.rs @@ -14,8 +14,6 @@ fn main() -> gooey::Result { .into_button() // Set the `on_click` callback to a closure that increments the counter. .on_click(count.with_clone(|count| move |_| count.set(count.get() + 1))) - // Position the button in the center - .centered() // Run the application .run() } From 947f1cd8a7cf34235c713052659505abee03f043 Mon Sep 17 00:00:00 2001 From: Jonathan Johnson Date: Wed, 15 Nov 2023 07:47:23 -0800 Subject: [PATCH 14/24] Stack with premeasured content now work This makes nested scroll areas work correctly. --- examples/nested-scroll.rs | 18 ++++++++++++++++ src/widgets/stack.rs | 44 ++++++++++++++++++++++++++++----------- src/window.rs | 19 ++++++++++------- 3 files changed, 61 insertions(+), 20 deletions(-) create mode 100644 examples/nested-scroll.rs diff --git a/examples/nested-scroll.rs b/examples/nested-scroll.rs new file mode 100644 index 0000000..0dc0358 --- /dev/null +++ b/examples/nested-scroll.rs @@ -0,0 +1,18 @@ +use gooey::widget::MakeWidget; +use gooey::Run; +use kludgine::figures::units::Lp; + +fn main() -> gooey::Result { + include_str!("./nested-scroll.rs") + .vertical_scroll() + .height(Lp::inches(3)) + .and( + include_str!("./canvas.rs") + .vertical_scroll() + .height(Lp::inches(3)), + ) + .into_rows() + .vertical_scroll() + .expand() + .run() +} diff --git a/src/widgets/stack.rs b/src/widgets/stack.rs index 2d15673..06b733a 100644 --- a/src/widgets/stack.rs +++ b/src/widgets/stack.rs @@ -296,7 +296,8 @@ struct Layout { total_weights: u32, allocated_space: (UPx, Lp), fractional: Vec<(LotId, u8)>, - measured: Vec, + fit_to_content: Vec, + premeasured: Vec, pub orientation: StackDirection, } @@ -316,7 +317,8 @@ impl Layout { total_weights: 0, allocated_space: (UPx(0), Lp(0)), fractional: Vec::new(), - measured: Vec::new(), + fit_to_content: Vec::new(), + premeasured: Vec::new(), } } @@ -331,20 +333,23 @@ impl Layout { match dimension { StackDimension::FitContent => { - self.measured.retain(|&measured| measured != id); + self.fit_to_content.retain(|&measured| measured != id); } StackDimension::Fractional { weight } => { self.fractional.retain(|(measured, _)| *measured != id); self.total_weights -= u32::from(weight); } - StackDimension::Measured { min, .. } => match min { - Dimension::Px(pixels) => { - self.allocated_space.0 -= pixels.into_unsigned(); + StackDimension::Measured { min, .. } => { + self.premeasured.retain(|&measured| measured != id); + match min { + Dimension::Px(pixels) => { + self.allocated_space.0 -= pixels.into_unsigned(); + } + Dimension::Lp(lp) => { + self.allocated_space.1 -= lp; + } } - Dimension::Lp(lp) => { - self.allocated_space.1 -= lp; - } - }, + } } dimension @@ -364,7 +369,7 @@ impl Layout { let id = self.children.insert(index, child); let layout = match child { StackDimension::FitContent => { - self.measured.push(id); + self.fit_to_content.push(id); UPx(0) } StackDimension::Fractional { weight } => { @@ -373,6 +378,7 @@ impl Layout { UPx(0) } StackDimension::Measured { min, .. } => { + self.premeasured.push(id); match min { Dimension::Px(size) => self.allocated_space.0 += size.into_unsigned(), Dimension::Lp(size) => self.allocated_space.1 += size, @@ -407,7 +413,7 @@ impl Layout { // Measure the children that fit their content self.other = UPx(0); - for &id in &self.measured { + for &id in &self.fit_to_content { let index = self.children.index_of_id(id).expect("child not found"); let (measured, other) = self.orientation.split_size(measure( index, @@ -420,6 +426,20 @@ impl Layout { remaining = remaining.saturating_sub(measured); } + // Measure measure the "other" dimension for children that we know their size already. + for &id in &self.premeasured { + let index = self.children.index_of_id(id).expect("child not found"); + let (_, other) = self.orientation.split_size(measure( + index, + self.orientation.make_size( + ConstraintLimit::Known(self.layouts[index].size), + other_constraint, + ), + !needs_final_layout, + )); + self.other = self.other.max(other); + } + // Measure the weighted children within the remaining space if self.total_weights > 0 { let space_per_weight = remaining / self.total_weights; diff --git a/src/window.rs b/src/window.rs index 531e2c0..0d7b8bc 100644 --- a/src/window.rs +++ b/src/window.rs @@ -868,7 +868,9 @@ where ), kludgine, ); - let last_rendered_at = context.last_layout().expect("passed hit test"); + let Some(last_rendered_at) = context.last_layout() else { + continue; + }; context.mouse_drag(location - last_rendered_at.origin, device_id, *button); } } else { @@ -886,11 +888,10 @@ where self.mouse_state.widget = None; for widget in self.root.tree.widgets_at_point(location) { let mut widget_context = context.for_other(&widget); - let relative = location - - widget_context - .last_layout() - .expect("passed hit test") - .origin; + let Some(widget_layout) = widget_context.last_layout() else { + continue; + }; + let relative = location - widget_layout.origin; if widget_context.hit_test(relative) { widget_context.hover(relative); @@ -970,8 +971,10 @@ where kludgine, ), |context| { - let relative = - *location - context.last_layout().expect("passed hit test").origin; + let Some(layout) = context.last_layout() else { + return IGNORED; + }; + let relative = *location - layout.origin; context.mouse_down(relative, device_id, button) }, ) { From 577e97908defb6cdadee3fbfd1112e2c1482a584 Mon Sep 17 00:00:00 2001 From: Jonathan Johnson Date: Wed, 15 Nov 2023 07:53:31 -0800 Subject: [PATCH 15/24] Optimizing widgets at point Given the goal of this function, I'm not sure it can get more optimal than this even with specialized data structures like KD-trees. The problem is that we want all widgets that are hovered, not just some, and that makes nearest neighbor useless. The main optimizations here are simple: - Group up all the render data we need in a single vec to help cache. - Precompute the rect's extents to make the contains check at most 4 comparisons. This had a noticable effect on the "wiggle the mouse frantically" performance, where Gooey isn't actually repainting but is routing mouse events. --- src/tree.rs | 75 ++++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 63 insertions(+), 12 deletions(-) diff --git a/src/tree.rs b/src/tree.rs index 3d23f82..5a25dd5 100644 --- a/src/tree.rs +++ b/src/tree.rs @@ -103,7 +103,7 @@ impl Tree { pub(crate) fn new_frame(&self, invalidations: impl IntoIterator) { let mut data = self.data.lock().ignore_poison(); - data.render_order.clear(); + data.render_info.clear(); for id in invalidations { let Some(id) = data.nodes_by_id.get(&id).copied() else { @@ -116,7 +116,10 @@ impl Tree { pub(crate) fn note_widget_rendered(&self, widget: LotId) { let mut data = self.data.lock().ignore_poison(); - data.render_order.push(widget); + let Some(layout) = data.nodes.get(widget).and_then(|node| node.layout) else { + return; + }; + data.render_info.push(widget, layout); } pub(crate) fn begin_layout( @@ -307,15 +310,7 @@ impl Tree { pub(crate) fn widgets_at_point(&self, point: Point) -> Vec { let data = self.data.lock().ignore_poison(); - let mut hits = Vec::new(); - for id in data.render_order.iter().rev() { - if let Some(last_rendered) = data.nodes.get(*id).and_then(|widget| widget.layout) { - if last_rendered.contains(point) { - hits.push(data.widget_from_node(*id, self).expect("just accessed")); - } - } - } - hits + data.render_info.widgets_under_point(point, &data, self) } pub(crate) fn parent(&self, id: LotId) -> Option { @@ -373,7 +368,7 @@ struct TreeData { hover: Option, defaults: Vec, escapes: Vec, - render_order: Vec, + render_info: RenderInfo, previous_focuses: AHashMap, } @@ -496,6 +491,62 @@ impl TreeData { } } +#[derive(Default)] +struct RenderInfo { + order: Vec, +} + +impl RenderInfo { + pub fn push(&mut self, node: LotId, region: Rect) { + let area = RenderArea::new(node, region); + self.order.push(area); + } + + pub fn clear(&mut self) { + self.order.clear(); + } + + fn widgets_under_point( + &self, + point: Point, + tree_data: &TreeData, + tree: &Tree, + ) -> Vec { + // We pessimistically allocate a vector as if all widgets match, up to a + // reasonable limit. This should ensure minimal allocations in all but + // extreme circumstances where widgets are nested with a significant + // amount of depth. + let mut hits = Vec::with_capacity(self.order.len().min(256)); + for area in self.order.iter().rev() { + if area.min.x <= point.x + && area.min.y <= point.y + && area.max.x >= point.x + && area.max.y >= point.y + { + let Some(widget) = tree_data.widget_from_node(area.node, tree) else { + continue; + }; + hits.push(widget); + } + } + hits + } +} + +#[derive(Eq, PartialEq, Clone, Copy)] +struct RenderArea { + node: LotId, + min: Point, + max: Point, +} + +impl RenderArea { + fn new(node: LotId, area: Rect) -> Self { + let (min, max) = area.extents(); + Self { node, min, max } + } +} + struct Node { widget: WidgetInstance, children: Vec, From bc83b816872abf3c99fd922278457ea38b0ffd90 Mon Sep 17 00:00:00 2001 From: Jonathan Johnson Date: Wed, 15 Nov 2023 08:01:09 -0800 Subject: [PATCH 16/24] Add transparent button to buttons example --- examples/buttons.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/examples/buttons.rs b/examples/buttons.rs index 7ebb3f8..12ebd4b 100644 --- a/examples/buttons.rs +++ b/examples/buttons.rs @@ -31,6 +31,13 @@ fn main() -> gooey::Result { })) .kind(ButtonKind::Outline), ) + .and( + Button::new("Transparent Button") + .on_click(clicked_label.with_clone(|label| { + move |_| label.set(String::from("Clicked Transparent Button")) + })) + .kind(ButtonKind::Transparent), + ) .and( Button::new("Default Button") .on_click(clicked_label.with_clone(|label| { From 5a9aa6b55d07865844c82ac0eed0db1eabed711f Mon Sep 17 00:00:00 2001 From: Jonathan Johnson Date: Wed, 15 Nov 2023 09:32:28 -0800 Subject: [PATCH 17/24] Tic-tac-toe, Buttons labels now stretch to fill --- examples/tic-tac-toe.rs | 198 ++++++++++++++++++++++++++++++++++++++++ src/widgets/button.rs | 25 ++++- 2 files changed, 219 insertions(+), 4 deletions(-) create mode 100644 examples/tic-tac-toe.rs diff --git a/examples/tic-tac-toe.rs b/examples/tic-tac-toe.rs new file mode 100644 index 0000000..8e798a4 --- /dev/null +++ b/examples/tic-tac-toe.rs @@ -0,0 +1,198 @@ +use std::fmt::Display; +use std::iter; +use std::ops::Not; +use std::time::SystemTime; + +use gooey::value::Dynamic; +use gooey::widget::MakeWidget; +use gooey::widgets::button::ButtonKind; +use gooey::{Run, WithClone}; +use kludgine::figures::units::Lp; + +fn main() -> gooey::Result { + let app = Dynamic::new(AppState::Winner(None)); + app.map_each(app.with_clone(|app| { + move |state: &AppState| match state { + AppState::Playing => play_screen(&app).make_widget(), + AppState::Winner(winner) => game_end(*winner, &app).make_widget(), + } + })) + .switcher() + .contain() + .width(Lp::inches(2)..Lp::inches(6)) + .height(Lp::inches(2)..Lp::inches(6)) + .centered() + .expand() + .run() +} + +#[derive(Default, Debug)] +enum AppState { + #[default] + Playing, + Winner(Option), +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum Player { + X, + O, +} + +impl Display for Player { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Player::X => f.write_str("X"), + Player::O => f.write_str("O"), + } + } +} + +impl Not for Player { + type Output = Self; + + fn not(self) -> Self::Output { + match self { + Self::X => Self::O, + Self::O => Self::X, + } + } +} + +struct GameState { + app: Dynamic, + current_player: Player, + cells: Vec>, +} + +impl GameState { + fn new_game(app: &Dynamic) -> Self { + Self { + app: app.clone(), + // Bad RNG: if we have an even milliseconds in the current + // timestamp, it's O's turn first. + current_player: if SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .expect("invalid system time") + .as_millis() + % 2 + == 0 + { + Player::O + } else { + Player::X + }, + cells: iter::repeat(None).take(9).collect(), + } + } + + fn play(&mut self, row: usize, column: usize) { + let player = self.current_player; + self.current_player = !player; + + self.cells[row * 3 + column] = Some(player); + + if let Some(winner) = self.check_for_winner() { + self.app.set(AppState::Winner(Some(winner))); + } else if self.cells.iter().all(Option::is_some) { + self.app.set(AppState::Winner(None)); + } + } + + fn check_for_winner(&self) -> Option { + // Rows and columns + for i in 0..3 { + if let Some(winner) = self + .winner_in_cells([[i, 0], [i, 1], [i, 2]]) + .or_else(|| self.winner_in_cells([[0, i], [1, i], [2, i]])) + { + return Some(winner); + } + } + + // Diagonals + self.winner_in_cells([[0, 0], [1, 1], [2, 2]]) + .or_else(|| self.winner_in_cells([[2, 0], [1, 1], [0, 2]])) + } + + fn winner_in_cells(&self, cells: [[usize; 2]; 3]) -> Option { + match ( + self.cell(cells[0][0], cells[0][1]), + self.cell(cells[1][0], cells[1][1]), + self.cell(cells[2][0], cells[2][1]), + ) { + (Some(a), Some(b), Some(c)) if a == b && b == c => Some(a), + _ => None, + } + } + + fn cell(&self, row: usize, column: usize) -> Option { + self.cells[row * 3 + column] + } +} + +fn game_end(winner: Option, app: &Dynamic) -> impl MakeWidget { + // TODO we need typography styles + let app = app.clone(); + let label = if let Some(winner) = winner { + format!("{winner:?} wins!") + } else { + String::from("No winner") + }; + + label + .and("Play Again".into_button().on_click(move |_| { + app.set(AppState::Playing); + })) + .into_rows() + .centered() + .expand() +} + +fn play_screen(app: &Dynamic) -> impl MakeWidget { + let game = Dynamic::new(GameState::new_game(app)); + let current_player_label = game.map_each(|state| format!("{}'s Turn", state.current_player)); + + current_player_label.and(play_grid(&game)).into_rows() +} + +fn play_grid(game: &Dynamic) -> impl MakeWidget { + row_of_squares(0, game) + .expand() + .and(row_of_squares(1, game).expand()) + .and(row_of_squares(2, game).expand()) + .into_rows() +} + +fn row_of_squares(row: usize, game: &Dynamic) -> impl MakeWidget { + square(row, 0, game) + .expand() + .and(square(row, 1, game).expand()) + .and(square(row, 2, game).expand()) + .into_columns() +} + +fn square(row: usize, column: usize, game: &Dynamic) -> impl MakeWidget { + let game = game.clone(); + let enabled = Dynamic::new(true); + let label = Dynamic::default(); + (&enabled, &label).with_clone(|(enabled, label)| { + game.for_each(move |state| { + let Some(player) = state.cell(row, column) else { + return; + }; + + if enabled.update(false) { + label.update(player.to_string()); + } + }); + }); + + label + .clone() + .into_button() + .enabled(enabled) + .kind(ButtonKind::Outline) + .on_click(move |_| game.lock().play(row, column)) + .expand() +} diff --git a/src/widgets/button.rs b/src/widgets/button.rs index 75f2b40..6295b81 100644 --- a/src/widgets/button.rs +++ b/src/widgets/button.rs @@ -218,7 +218,7 @@ impl Button { } } - fn determine_stateful_colors(&mut self, context: &WidgetContext<'_, '_>) -> ButtonColors { + fn determine_stateful_colors(&mut self, context: &mut WidgetContext<'_, '_>) -> ButtonColors { let kind = self.kind.get_tracked(context); let visual_state = self.visual_style(context); @@ -227,6 +227,10 @@ impl Button { kind, }; + if !self.cached_state.enabled { + context.blur(); + } + if context.is_default() { kind.colors_for_default(visual_state, context) } else { @@ -238,7 +242,7 @@ impl Button { } } - fn update_colors(&mut self, context: &WidgetContext<'_, '_>, immediate: bool) { + fn update_colors(&mut self, context: &mut WidgetContext<'_, '_>, immediate: bool) { let new_style = self.determine_stateful_colors(context); match (immediate, &self.active_colors) { @@ -261,7 +265,7 @@ impl Button { } } - fn current_style(&mut self, context: &WidgetContext<'_, '_>) -> ButtonColors { + fn current_style(&mut self, context: &mut WidgetContext<'_, '_>) -> ButtonColors { if self.active_colors.is_none() { self.update_colors(context, false); } @@ -463,13 +467,26 @@ impl Widget for Button { context: &mut LayoutContext<'_, '_, '_, '_, '_>, ) -> Size { let padding = context.get(&IntrinsicPadding).into_upx(context.gfx.scale()); + let double_padding = padding * 2; let mounted = self.content.mounted(&mut context.as_event_context()); + let available_space = Size::new( + available_space.width - double_padding, + available_space.height - double_padding, + ); let size = context.for_other(&mounted).layout(available_space); + let size = Size::new( + available_space + .width + .fit_measured(size.width, context.gfx.scale()), + available_space + .height + .fit_measured(size.height, context.gfx.scale()), + ); context.set_child_layout( &mounted, Rect::new(Point::new(padding, padding), size).into_signed(), ); - size + padding * 2 + size + double_padding } fn keyboard_input( From 5965f19d271490aa89bb465e415fca41a3966cce Mon Sep 17 00:00:00 2001 From: Jonathan Johnson Date: Wed, 15 Nov 2023 09:38:27 -0800 Subject: [PATCH 18/24] Reverting back to default app state --- examples/tic-tac-toe.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/examples/tic-tac-toe.rs b/examples/tic-tac-toe.rs index 8e798a4..d7ded68 100644 --- a/examples/tic-tac-toe.rs +++ b/examples/tic-tac-toe.rs @@ -10,7 +10,7 @@ use gooey::{Run, WithClone}; use kludgine::figures::units::Lp; fn main() -> gooey::Result { - let app = Dynamic::new(AppState::Winner(None)); + let app = Dynamic::default(); app.map_each(app.with_clone(|app| { move |state: &AppState| match state { AppState::Playing => play_screen(&app).make_widget(), @@ -194,5 +194,6 @@ fn square(row: usize, column: usize, game: &Dynamic) -> impl MakeWidg .enabled(enabled) .kind(ButtonKind::Outline) .on_click(move |_| game.lock().play(row, column)) + .pad() .expand() } From 534f676ef0e5ca84fd805c7788eac0979f893720 Mon Sep 17 00:00:00 2001 From: Jonathan Johnson Date: Wed, 15 Nov 2023 10:23:42 -0800 Subject: [PATCH 19/24] Enable/disable is now handled for all widgets Closes #66 --- examples/focus-order.rs | 2 +- examples/login.rs | 4 ++-- examples/tic-tac-toe.rs | 2 +- src/context.rs | 26 ++++++++++++++++++----- src/tree.rs | 23 ++++++++++++++++++++ src/widget.rs | 47 ++++++++++++++++++++++++++++++++++++++++- src/widgets/button.rs | 30 +++++++++----------------- src/window.rs | 1 + 8 files changed, 105 insertions(+), 30 deletions(-) diff --git a/examples/focus-order.rs b/examples/focus-order.rs index 94776ac..24f573f 100644 --- a/examples/focus-order.rs +++ b/examples/focus-order.rs @@ -55,12 +55,12 @@ fn main() -> gooey::Result { .and( "Log In" .into_button() - .enabled(valid) .on_click(move |_| { println!("Welcome, {}", username.get()); exit(0); }) .make_with_id(login_tag) + .with_enabled(valid) .into_default() .with_next_focus(cancel_id), ) diff --git a/examples/login.rs b/examples/login.rs index f81dfe5..22a762e 100644 --- a/examples/login.rs +++ b/examples/login.rs @@ -36,12 +36,12 @@ fn main() -> gooey::Result { .and( "Log In" .into_button() - .enabled(valid) .on_click(move |_| { println!("Welcome, {}", username.get()); exit(0); }) - .into_default(), + .into_default() + .with_enabled(valid), ) .into_columns(); diff --git a/examples/tic-tac-toe.rs b/examples/tic-tac-toe.rs index d7ded68..8a31892 100644 --- a/examples/tic-tac-toe.rs +++ b/examples/tic-tac-toe.rs @@ -191,9 +191,9 @@ fn square(row: usize, column: usize, game: &Dynamic) -> impl MakeWidg label .clone() .into_button() - .enabled(enabled) .kind(ButtonKind::Outline) .on_click(move |_| game.lock().play(row, column)) + .with_enabled(enabled) .pad() .expand() } diff --git a/src/context.rs b/src/context.rs index 3782beb..b2c2037 100644 --- a/src/context.rs +++ b/src/context.rs @@ -229,11 +229,12 @@ impl<'context, 'window> EventContext<'context, 'window> { focus_changes += 1; self.pending_state.focus = focus.and_then(|mut focus| loop { - if focus - .lock() - .as_widget() - .accept_focus(&mut self.for_other(&focus)) - { + let mut focus_context = self.for_other(&focus); + let accept_focus = focus_context.enabled() + && focus.lock().as_widget().accept_focus(&mut focus_context); + drop(focus_context); + + if accept_focus { break Some(focus); } else if let Some(next_focus) = focus.explicit_focus_target(self.pending_state.focus_is_advancing) @@ -707,6 +708,7 @@ pub struct WidgetContext<'context, 'window> { pending_state: PendingState<'context>, theme_mode: ThemeMode, effective_styles: Styles, + enabled: bool, } impl<'context, 'window> WidgetContext<'context, 'window> { @@ -717,6 +719,10 @@ impl<'context, 'window> WidgetContext<'context, 'window> { window: &'context mut RunningWindow<'window>, theme_mode: ThemeMode, ) -> Self { + let enabled = current_node.enabled(&WindowHandle { + kludgine: window.handle(), + redraw_status: redraw_status.clone(), + }); Self { pending_state: PendingState::Owned(PendingWidgetState { focus: current_node @@ -730,6 +736,7 @@ impl<'context, 'window> WidgetContext<'context, 'window> { focus_is_advancing: false, }), effective_styles: current_node.effective_styles(), + enabled, current_node, redraw_status, theme: Cow::Borrowed(theme), @@ -748,6 +755,7 @@ impl<'context, 'window> WidgetContext<'context, 'window> { pending_state: self.pending_state.borrowed(), theme_mode: self.theme_mode, effective_styles: self.effective_styles.clone(), + enabled: self.enabled, } } @@ -774,6 +782,7 @@ impl<'context, 'window> WidgetContext<'context, 'window> { }; WidgetContext { effective_styles, + enabled: current_node.enabled(&self.handle()), current_node, redraw_status: self.redraw_status, window: &mut *self.window, @@ -784,6 +793,12 @@ impl<'context, 'window> WidgetContext<'context, 'window> { }) } + /// Returns true if this widget is enabled. + #[must_use] + pub const fn enabled(&self) -> bool { + self.enabled + } + pub(crate) fn parent(&self) -> Option { self.current_node.parent() } @@ -1005,6 +1020,7 @@ impl<'context, 'window> WidgetContext<'context, 'window> { } } +#[derive(Clone)] pub(crate) struct WindowHandle { kludgine: kludgine::app::WindowHandle, redraw_status: InvalidationStatus, diff --git a/src/tree.rs b/src/tree.rs index 5a25dd5..68f9996 100644 --- a/src/tree.rs +++ b/src/tree.rs @@ -6,6 +6,7 @@ use alot::{LotId, Lots}; use kludgine::figures::units::{Px, UPx}; use kludgine::figures::{Point, Rect, Size}; +use crate::context::WindowHandle; use crate::styles::{Styles, ThemePair, VisualOrder}; use crate::utils::IgnorePoison; use crate::value::Value; @@ -44,6 +45,7 @@ impl Tree { effective_styles, theme: None, theme_mode: None, + invalidation: 0, }); data.nodes_by_id.insert(id, node_id); if widget.is_default() { @@ -275,6 +277,25 @@ impl Tree { data.widget_from_node(id, self) } + pub(crate) fn is_enabled(&self, mut id: LotId, context: &WindowHandle) -> bool { + let data = self.data.lock().ignore_poison(); + loop { + let Some(node) = data.nodes.get(id) else { + return false; + }; + + if !node.widget.enabled(context) { + return false; + } + + let Some(parent) = node.parent else { break }; + + id = parent; + } + + true + } + pub(crate) fn active_widget(&self) -> Option { self.data.lock().ignore_poison().active } @@ -481,6 +502,7 @@ impl TreeData { let mut node = &mut self.nodes[id]; while node.layout.is_some() { node.layout = None; + node.invalidation += 1; node.last_layout_query = None; let (true, Some(parent)) = (include_hierarchy, node.parent) else { @@ -552,6 +574,7 @@ struct Node { children: Vec, parent: Option, layout: Option>, + invalidation: u64, last_layout_query: Option, associated_styles: Option>, effective_styles: Styles, diff --git a/src/widget.rs b/src/widget.rs index 1cb5c29..c063c23 100644 --- a/src/widget.rs +++ b/src/widget.rs @@ -16,7 +16,9 @@ use kludgine::figures::units::{Px, UPx}; use kludgine::figures::{IntoSigned, IntoUnsigned, Point, Rect, Size}; use kludgine::Color; -use crate::context::{AsEventContext, EventContext, GraphicsContext, LayoutContext, WidgetContext}; +use crate::context::{ + AsEventContext, EventContext, GraphicsContext, LayoutContext, WidgetContext, WindowHandle, +}; use crate::styles::{ ContainerLevel, Dimension, DimensionRange, Edges, IntoComponentValue, NamedComponent, Styles, ThemePair, VisualOrder, @@ -580,6 +582,19 @@ pub trait MakeWidget: Sized { self.make_widget().with_next_focus(next_focus) } + /// Sets this widget to be enabled/disabled based on `enabled` and returns + /// self. + /// + /// If this widget is disabled, all children widgets will also be disabled. + /// + /// # Panics + /// + /// This function can only be called when one instance of the widget exists. + /// If any clones exist, a panic will occur. + fn with_enabled(self, enabled: impl IntoValue) -> WidgetInstance { + self.make_widget().with_enabled(enabled) + } + /// Sets this widget as a "default" widget. /// /// Default widgets are automatically activated when the user signals they @@ -846,6 +861,7 @@ struct WidgetInstanceData { default: bool, cancel: bool, next_focus: Value>, + enabled: Value, widget: Box>, } @@ -863,6 +879,7 @@ impl WidgetInstance { default: false, cancel: false, widget: Box::new(Mutex::new(widget)), + enabled: Value::Constant(true), }), } } @@ -901,6 +918,23 @@ impl WidgetInstance { self } + /// Sets this widget to be enabled/disabled based on `enabled` and returns + /// self. + /// + /// If this widget is disabled, all children widgets will also be disabled. + /// + /// # Panics + /// + /// This function can only be called when one instance of the widget exists. + /// If any clones exist, a panic will occur. + #[must_use] + pub fn with_enabled(mut self, enabled: impl IntoValue) -> WidgetInstance { + let data = Arc::get_mut(&mut self.data) + .expect("with_enabled can only be called on newly created widget instances"); + data.enabled = enabled.into_value(); + self + } + /// Sets this widget as a "default" widget. /// /// Default widgets are automatically activated when the user signals they @@ -982,6 +1016,13 @@ impl WidgetInstance { pub fn is_escape(&self) -> bool { self.data.cancel } + + pub(crate) fn enabled(&self, context: &WindowHandle) -> bool { + if let Value::Dynamic(dynamic) = &self.data.enabled { + dynamic.redraw_when_changed(context.clone()); + } + self.data.enabled.get() + } } impl AsRef for WidgetInstance { @@ -1141,6 +1182,10 @@ impl ManagedWidget { self.tree.active_widget() == Some(self.node_id) } + pub(crate) fn enabled(&self, handle: &WindowHandle) -> bool { + self.tree.is_enabled(self.node_id, handle) + } + /// Returns true if this widget is currently the hovered widget. #[must_use] pub fn hovered(&self) -> bool { diff --git a/src/widgets/button.rs b/src/widgets/button.rs index 6295b81..5737e07 100644 --- a/src/widgets/button.rs +++ b/src/widgets/button.rs @@ -26,8 +26,6 @@ pub struct Button { pub content: WidgetRef, /// The callback that is invoked when the button is clicked. pub on_click: Option>, - /// The enabled state of the button. - pub enabled: Value, /// The kind of button to draw. pub kind: Value, buttons_pressed: usize, @@ -130,7 +128,6 @@ impl Button { Self { content: content.widget_ref(), on_click: None, - enabled: Value::Constant(true), cached_state: CacheState { enabled: true, kind: ButtonKind::default(), @@ -161,23 +158,16 @@ impl Button { self } - /// Sets the value to use for the button's enabled status. - #[must_use] - pub fn enabled(mut self, enabled: impl IntoValue) -> Self { - self.enabled = enabled.into_value(); - self - } - - fn invoke_on_click(&mut self) { - if self.enabled.get() { + fn invoke_on_click(&mut self, context: &WidgetContext<'_, '_>) { + if context.enabled() { if let Some(on_click) = self.on_click.as_mut() { on_click.invoke(()); } } } - fn visual_style(&self, context: &WidgetContext<'_, '_>) -> VisualState { - if !self.enabled.get_tracked(context) { + fn visual_style(context: &WidgetContext<'_, '_>) -> VisualState { + if !context.enabled() { VisualState::Disabled } else if context.active() { VisualState::Active @@ -220,7 +210,7 @@ impl Button { fn determine_stateful_colors(&mut self, context: &mut WidgetContext<'_, '_>) -> ButtonColors { let kind = self.kind.get_tracked(context); - let visual_state = self.visual_style(context); + let visual_state = Self::visual_style(context); self.cached_state = CacheState { enabled: !matches!(visual_state, VisualState::Disabled), @@ -341,7 +331,7 @@ impl VisualState { impl Widget for Button { fn redraw(&mut self, context: &mut GraphicsContext<'_, '_, '_, '_, '_>) { #![allow(clippy::similar_names)] - let enabled = self.enabled.get_tracked(context); + let enabled = context.enabled(); // TODO This seems ugly. It needs context, so it can't be moved into the // dynamic system. @@ -403,7 +393,7 @@ impl Widget for Button { } fn accept_focus(&mut self, context: &mut EventContext<'_, '_>) -> bool { - self.enabled.get() && context.get(&AutoFocusableControls).is_all() + context.get(&AutoFocusableControls).is_all() } fn mouse_down( @@ -455,7 +445,7 @@ impl Widget for Button { { context.focus(); - self.invoke_on_click(); + self.invoke_on_click(context); } } } @@ -503,7 +493,7 @@ impl Widget for Button { let changed = context.activate(); if !changed { // The widget was already active. This is now a repeated keypress - self.invoke_on_click(); + self.invoke_on_click(context); } changed } @@ -538,7 +528,7 @@ impl Widget for Button { // If we have no buttons pressed, the event should fire on activate not // on deactivate. if self.buttons_pressed == 0 { - self.invoke_on_click(); + self.invoke_on_click(context); } self.update_colors(context, true); } diff --git a/src/window.rs b/src/window.rs index 0d7b8bc..073ce33 100644 --- a/src/window.rs +++ b/src/window.rs @@ -1087,6 +1087,7 @@ pub(crate) mod sealed { pub transparent: bool, } + #[derive(Clone)] pub enum WindowCommand { Redraw, // RequestClose, From 5506a24dae80a20ea5f82a246ad557784a4a0763 Mon Sep 17 00:00:00 2001 From: Jonathan Johnson Date: Wed, 15 Nov 2023 10:36:00 -0800 Subject: [PATCH 20/24] Added WidgetCacheKey --- src/context.rs | 56 +++++++++++++++++++++++++++++++++---------- src/tree.rs | 5 ++++ src/widget.rs | 4 ++++ src/widgets/button.rs | 18 +++++++------- 4 files changed, 62 insertions(+), 21 deletions(-) diff --git a/src/context.rs b/src/context.rs index b2c2037..94a301b 100644 --- a/src/context.rs +++ b/src/context.rs @@ -706,9 +706,8 @@ pub struct WidgetContext<'context, 'window> { window: &'context mut RunningWindow<'window>, theme: Cow<'context, ThemePair>, pending_state: PendingState<'context>, - theme_mode: ThemeMode, effective_styles: Styles, - enabled: bool, + cache: WidgetCacheKey, } impl<'context, 'window> WidgetContext<'context, 'window> { @@ -736,11 +735,14 @@ impl<'context, 'window> WidgetContext<'context, 'window> { focus_is_advancing: false, }), effective_styles: current_node.effective_styles(), - enabled, + cache: WidgetCacheKey { + theme_mode, + enabled, + invalidation: current_node.invalidation(), + }, current_node, redraw_status, theme: Cow::Borrowed(theme), - theme_mode, window, } } @@ -753,9 +755,8 @@ impl<'context, 'window> WidgetContext<'context, 'window> { window: &mut *self.window, theme: Cow::Borrowed(self.theme.as_ref()), pending_state: self.pending_state.borrowed(), - theme_mode: self.theme_mode, + cache: self.cache, effective_styles: self.effective_styles.clone(), - enabled: self.enabled, } } @@ -778,17 +779,20 @@ impl<'context, 'window> WidgetContext<'context, 'window> { let theme_mode = if let Some(mode) = theme_mode { mode.get_tracked(self) } else { - self.theme_mode + self.cache.theme_mode }; WidgetContext { effective_styles, - enabled: current_node.enabled(&self.handle()), + cache: WidgetCacheKey { + theme_mode, + enabled: current_node.enabled(&self.handle()), + invalidation: current_node.invalidation(), + }, current_node, redraw_status: self.redraw_status, window: &mut *self.window, theme, pending_state: self.pending_state.borrowed(), - theme_mode, } }) } @@ -796,7 +800,7 @@ impl<'context, 'window> WidgetContext<'context, 'window> { /// Returns true if this widget is enabled. #[must_use] pub const fn enabled(&self) -> bool { - self.enabled + self.cache.enabled } pub(crate) fn parent(&self) -> Option { @@ -1004,7 +1008,7 @@ impl<'context, 'window> WidgetContext<'context, 'window> { /// Returns the current theme in either light or dark mode. #[must_use] pub fn theme(&self) -> &Theme { - match self.theme_mode { + match self.cache.theme_mode { ThemeMode::Light => &self.theme.light, ThemeMode::Dark => &self.theme.dark, } @@ -1013,11 +1017,18 @@ impl<'context, 'window> WidgetContext<'context, 'window> { /// Returns the opposite theme of [`Self::theme()`]. #[must_use] pub fn inverse_theme(&self) -> &Theme { - match self.theme_mode { + match self.cache.theme_mode { ThemeMode::Light => &self.theme.dark, ThemeMode::Dark => &self.theme.light, } } + + /// Returns a key that can be checked to see if a widget should invalidate + /// caches it stores. + #[must_use] + pub fn cache_key(&self) -> WidgetCacheKey { + self.cache + } } #[derive(Clone)] @@ -1207,3 +1218,24 @@ impl MapManagedWidget for ManagedWidget { map(self) } } + +/// An type that contains information about the state of a widget. +/// +/// This value can be stored and compared in future widget events. If the cache +/// keys are not equal, the widget should clear all caches. +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub struct WidgetCacheKey { + theme_mode: ThemeMode, + enabled: bool, + invalidation: u64, +} + +impl Default for WidgetCacheKey { + fn default() -> Self { + Self { + theme_mode: ThemeMode::default().inverse(), + enabled: false, + invalidation: u64::MAX, + } + } +} diff --git a/src/tree.rs b/src/tree.rs index 68f9996..3aea4c8 100644 --- a/src/tree.rs +++ b/src/tree.rs @@ -277,6 +277,11 @@ impl Tree { data.widget_from_node(id, self) } + pub(crate) fn invalidation(&self, id: LotId) -> Option { + let data = self.data.lock().ignore_poison(); + data.nodes.get(id).map(|node| node.invalidation) + } + pub(crate) fn is_enabled(&self, mut id: LotId, context: &WindowHandle) -> bool { let data = self.data.lock().ignore_poison(); loop { diff --git a/src/widget.rs b/src/widget.rs index c063c23..4673918 100644 --- a/src/widget.rs +++ b/src/widget.rs @@ -1186,6 +1186,10 @@ impl ManagedWidget { self.tree.is_enabled(self.node_id, handle) } + pub(crate) fn invalidation(&self) -> u64 { + self.tree.invalidation(self.node_id).expect("missing node") + } + /// Returns true if this widget is currently the hovered widget. #[must_use] pub fn hovered(&self) -> bool { diff --git a/src/widgets/button.rs b/src/widgets/button.rs index 5737e07..5a03829 100644 --- a/src/widgets/button.rs +++ b/src/widgets/button.rs @@ -9,7 +9,9 @@ use kludgine::shapes::{Shape, StrokeOptions}; use kludgine::Color; use crate::animation::{AnimationHandle, AnimationTarget, LinearInterpolate, Spawn}; -use crate::context::{AsEventContext, EventContext, GraphicsContext, LayoutContext, WidgetContext}; +use crate::context::{ + AsEventContext, EventContext, GraphicsContext, LayoutContext, WidgetCacheKey, WidgetContext, +}; use crate::styles::components::{ AutoFocusableControls, Easing, HighlightColor, IntrinsicPadding, OpaqueWidgetColor, OutlineColor, SurfaceColor, TextColor, @@ -36,7 +38,7 @@ pub struct Button { #[derive(Debug, Eq, PartialEq, Clone, Copy)] struct CacheState { - enabled: bool, + key: WidgetCacheKey, kind: ButtonKind, } @@ -129,7 +131,7 @@ impl Button { content: content.widget_ref(), on_click: None, cached_state: CacheState { - enabled: true, + key: WidgetCacheKey::default(), kind: ButtonKind::default(), }, buttons_pressed: 0, @@ -213,11 +215,12 @@ impl Button { let visual_state = Self::visual_style(context); self.cached_state = CacheState { - enabled: !matches!(visual_state, VisualState::Disabled), + key: context.cache_key(), kind, }; - if !self.cached_state.enabled { + // TODO this should be genericized to happen automatically. + if !context.enabled() { context.blur(); } @@ -331,12 +334,9 @@ impl VisualState { impl Widget for Button { fn redraw(&mut self, context: &mut GraphicsContext<'_, '_, '_, '_, '_>) { #![allow(clippy::similar_names)] - let enabled = context.enabled(); - // TODO This seems ugly. It needs context, so it can't be moved into the - // dynamic system. let current_style = self.kind.get_tracked(context); - if self.cached_state.enabled != enabled || self.cached_state.kind != current_style { + if self.cached_state.key != context.cache_key() || self.cached_state.kind != current_style { self.update_colors(context, false); } From 42840b950c8cf1e62bd7556c0171dc86d549915f Mon Sep 17 00:00:00 2001 From: Jonathan Johnson Date: Wed, 15 Nov 2023 12:39:32 -0800 Subject: [PATCH 21/24] Hover updates after widget removal This also fixes some inconsistencies that arose when the focus widget was "stuck" on a removed widget. Button previously handled it hackily in a redraw function, but now Gooey handles it automatically without needing to wait for a repaint. --- examples/tic-tac-toe.rs | 11 ++-- src/context.rs | 112 ++++++++++++++++++++++++++-------------- src/tree.rs | 42 ++++++++------- src/widgets/button.rs | 5 -- src/window.rs | 104 +++++++++++++++++-------------------- 5 files changed, 154 insertions(+), 120 deletions(-) diff --git a/examples/tic-tac-toe.rs b/examples/tic-tac-toe.rs index 8a31892..b28de4c 100644 --- a/examples/tic-tac-toe.rs +++ b/examples/tic-tac-toe.rs @@ -141,9 +141,14 @@ fn game_end(winner: Option, app: &Dynamic) -> impl MakeWidget }; label - .and("Play Again".into_button().on_click(move |_| { - app.set(AppState::Playing); - })) + .and( + "Play Again" + .into_button() + .on_click(move |_| { + app.set(AppState::Playing); + }) + .into_default(), + ) .into_rows() .centered() .expand() diff --git a/src/context.rs b/src/context.rs index 94a301b..0cb8ae0 100644 --- a/src/context.rs +++ b/src/context.rs @@ -21,7 +21,7 @@ use crate::utils::IgnorePoison; use crate::value::{Dynamic, IntoValue, Value}; use crate::widget::{EventHandling, ManagedWidget, WidgetId, WidgetInstance, WidgetRef}; use crate::window::sealed::WindowCommand; -use crate::window::{RunningWindow, ThemeMode}; +use crate::window::{CursorState, RunningWindow, ThemeMode}; use crate::ConstraintLimit; /// A context to an event function. @@ -185,7 +185,10 @@ impl<'context, 'window> EventContext<'context, 'window> { let mut activation_changes = 0; while activation_changes < MAX_ITERS { - let active = self.pending_state.active.clone(); + let active = self + .pending_state + .active + .and_then(|w| self.current_node.tree.widget(w)); if self.current_node.tree.active_widget() == active.as_ref().map(|w| w.node_id) { break; } @@ -202,13 +205,16 @@ impl<'context, 'window> EventContext<'context, 'window> { Err(()) => false, }; if new { - if let Some(active) = self.pending_state.active.clone() { + let active = self + .pending_state + .active + .and_then(|w| self.current_node.tree.widget(w)); + if let Some(active) = &active { active .lock() .as_widget() - .activate(&mut self.for_other(&active)); + .activate(&mut self.for_other(active)); } - self.pending_state.active = active; } else { break; } @@ -222,7 +228,10 @@ impl<'context, 'window> EventContext<'context, 'window> { let mut focus_changes = 0; while focus_changes < MAX_ITERS { - let focus = self.pending_state.focus.clone(); + let focus = self + .pending_state + .focus + .and_then(|w| self.current_node.tree.widget(w)); if self.current_node.tree.focused_widget() == focus.as_ref().map(|w| w.node_id) { break; } @@ -235,7 +244,7 @@ impl<'context, 'window> EventContext<'context, 'window> { drop(focus_context); if accept_focus { - break Some(focus); + break Some(focus.id()); } else if let Some(next_focus) = focus.explicit_focus_target(self.pending_state.focus_is_advancing) { @@ -244,11 +253,7 @@ impl<'context, 'window> EventContext<'context, 'window> { break self.next_focus_after(focus, self.pending_state.focus_is_advancing); } }); - let new = match self - .current_node - .tree - .focus(self.pending_state.focus.as_ref()) - { + let new = match self.current_node.tree.focus(self.pending_state.focus) { Ok(old) => { if let Some(old) = old { let mut old_context = self.for_other(&old); @@ -259,7 +264,11 @@ impl<'context, 'window> EventContext<'context, 'window> { Err(()) => false, }; if new { - if let Some(focus) = self.pending_state.focus.clone() { + if let Some(focus) = self + .pending_state + .focus + .and_then(|w| self.current_node.tree.widget(w)) + { focus.lock().as_widget().focus(&mut self.for_other(&focus)); } } else { @@ -270,13 +279,40 @@ impl<'context, 'window> EventContext<'context, 'window> { if focus_changes == MAX_ITERS { tracing::error!("focus change force stopped after {focus_changes} sequential changes"); } + + // Check that our hover widget still exists. If not, we should try to find a new one. + if let Some(hover) = self.current_node.tree.hovered_widget() { + if self.current_node.tree.widget_from_node(hover).is_none() { + self.update_hovered_widget(); + } + } } - fn next_focus_after( - &mut self, - mut focus: ManagedWidget, - advance: bool, - ) -> Option { + pub(crate) fn update_hovered_widget(&mut self) { + self.cursor.widget = None; + if let Some(location) = self.cursor.location { + for widget in self.current_node.tree.widgets_under_point(location) { + let mut widget_context = self.for_other(&widget); + let Some(widget_layout) = widget_context.last_layout() else { + continue; + }; + let relative = location - widget_layout.origin; + + if widget_context.hit_test(relative) { + widget_context.hover(relative); + drop(widget_context); + self.cursor.widget = Some(widget.id()); + break; + } + } + } + + if self.cursor.widget.is_none() { + self.clear_hover(); + } + } + + fn next_focus_after(&mut self, mut focus: ManagedWidget, advance: bool) -> Option { // First, look within the current focus for any focusable children. let stop_at = focus.id(); if let Some(focus) = self.next_focus_within(&focus, None, stop_at, advance) { @@ -304,7 +340,7 @@ impl<'context, 'window> EventContext<'context, 'window> { focus: &ManagedWidget, stop_at: WidgetId, advance: bool, - ) -> Option { + ) -> Option { self.next_focus_within(&focus.parent()?, Some(focus.id()), stop_at, advance) } @@ -317,7 +353,7 @@ impl<'context, 'window> EventContext<'context, 'window> { start_at: Option, stop_at: WidgetId, advance: bool, - ) -> Option { + ) -> Option { let mut visual_order = self.get(&LayoutOrder); if !advance { visual_order = visual_order.rev(); @@ -346,9 +382,9 @@ impl<'context, 'window> EventContext<'context, 'window> { .as_widget() .accept_focus(&mut self.for_other(&child)) { - return Some(child); + return Some(child.id()); } else if let Some(next_focus) = self.widget().explicit_focus_target(advance) { - return Some(next_focus); + return Some(next_focus.id()); } else if let Some(focus) = self.next_focus_within(&child, None, stop_at, advance) { return Some(focus); } @@ -705,6 +741,7 @@ pub struct WidgetContext<'context, 'window> { redraw_status: &'context InvalidationStatus, window: &'context mut RunningWindow<'window>, theme: Cow<'context, ThemePair>, + cursor: &'context mut CursorState, pending_state: PendingState<'context>, effective_styles: Styles, cache: WidgetCacheKey, @@ -717,6 +754,7 @@ impl<'context, 'window> WidgetContext<'context, 'window> { theme: &'context ThemePair, window: &'context mut RunningWindow<'window>, theme_mode: ThemeMode, + cursor: &'context mut CursorState, ) -> Self { let enabled = current_node.enabled(&WindowHandle { kludgine: window.handle(), @@ -727,11 +765,11 @@ impl<'context, 'window> WidgetContext<'context, 'window> { focus: current_node .tree .focused_widget() - .and_then(|id| current_node.tree.widget_from_node(id)), + .and_then(|id| current_node.tree.widget_from_node(id).map(|w| w.id())), active: current_node .tree .active_widget() - .and_then(|id| current_node.tree.widget_from_node(id)), + .and_then(|id| current_node.tree.widget_from_node(id).map(|w| w.id())), focus_is_advancing: false, }), effective_styles: current_node.effective_styles(), @@ -740,6 +778,7 @@ impl<'context, 'window> WidgetContext<'context, 'window> { enabled, invalidation: current_node.invalidation(), }, + cursor, current_node, redraw_status, theme: Cow::Borrowed(theme), @@ -757,6 +796,7 @@ impl<'context, 'window> WidgetContext<'context, 'window> { pending_state: self.pending_state.borrowed(), cache: self.cache, effective_styles: self.effective_styles.clone(), + cursor: &mut *self.cursor, } } @@ -793,6 +833,7 @@ impl<'context, 'window> WidgetContext<'context, 'window> { window: &mut *self.window, theme, pending_state: self.pending_state.borrowed(), + cursor: &mut *self.cursor, } }) } @@ -829,7 +870,7 @@ impl<'context, 'window> WidgetContext<'context, 'window> { /// contexts for the currently firing event are dropped. pub fn focus(&mut self) { self.pending_state.focus_is_advancing = true; - self.pending_state.focus = Some(self.current_node.clone()); + self.pending_state.focus = Some(self.current_node.id()); } pub(crate) fn clear_focus(&mut self) { @@ -859,16 +900,11 @@ impl<'context, 'window> WidgetContext<'context, 'window> { /// Widget events relating to activation changes are deferred until after /// the all contexts for the currently firing event are dropped. pub fn activate(&mut self) -> bool { - if self - .pending_state - .active - .as_ref() - .map_or(true, |active| active != &self.current_node) - { - self.pending_state.active = Some(self.current_node.clone()); - true - } else { + if self.pending_state.active == Some(self.current_node.id()) { false + } else { + self.pending_state.active = Some(self.current_node.id()); + true } } @@ -895,7 +931,7 @@ impl<'context, 'window> WidgetContext<'context, 'window> { /// Returns true if this widget is currently the active widget. #[must_use] pub fn active(&self) -> bool { - self.pending_state.active.as_ref() == Some(&self.current_node) + self.pending_state.active == Some(self.current_node.id()) } /// Returns true if this widget is currently hovered, even if the cursor is @@ -914,7 +950,7 @@ impl<'context, 'window> WidgetContext<'context, 'window> { /// Returns true if this widget is currently focused for user input. #[must_use] pub fn focused(&self) -> bool { - self.pending_state.focus.as_ref() == Some(&self.current_node) + self.pending_state.focus == Some(self.current_node.id()) } /// Returns true if this widget is the target to activate when the user @@ -1100,8 +1136,8 @@ enum PendingState<'a> { #[derive(Default)] struct PendingWidgetState { focus_is_advancing: bool, - focus: Option, - active: Option, + focus: Option, + active: Option, } impl PendingState<'_> { diff --git a/src/tree.rs b/src/tree.rs index 3aea4c8..462fbf7 100644 --- a/src/tree.rs +++ b/src/tree.rs @@ -231,24 +231,27 @@ impl Tree { let hovered = new_hover .map(|new_hover| data.widget_hierarchy(new_hover.node_id, self)) .unwrap_or_default(); - let unhovered = match data.update_tracked_widget(new_hover, self, |data| &mut data.hover) { - Ok(Some(old_hover)) => { - let mut old_hovered = data.widget_hierarchy(old_hover.node_id, self); - // For any widgets that were shared, remove them, as they don't - // need to have their events fired again. - let mut new_index = 0; - while !old_hovered.is_empty() && old_hovered.get(0) == hovered.get(new_index) { - old_hovered.remove(0); - new_index += 1; + let unhovered = + match data.update_tracked_widget(new_hover.map(ManagedWidget::id), self, |data| { + &mut data.hover + }) { + Ok(Some(old_hover)) => { + let mut old_hovered = data.widget_hierarchy(old_hover.node_id, self); + // For any widgets that were shared, remove them, as they don't + // need to have their events fired again. + let mut new_index = 0; + while !old_hovered.is_empty() && old_hovered.get(0) == hovered.get(new_index) { + old_hovered.remove(0); + new_index += 1; + } + old_hovered } - old_hovered - } - _ => Vec::new(), - }; + _ => Vec::new(), + }; HoverResults { unhovered, hovered } } - pub fn focus(&self, new_focus: Option<&ManagedWidget>) -> Result, ()> { + pub fn focus(&self, new_focus: Option) -> Result, ()> { let mut data = self.data.lock().ignore_poison(); data.update_tracked_widget(new_focus, self, |data| &mut data.focus) } @@ -264,7 +267,9 @@ impl Tree { new_active: Option<&ManagedWidget>, ) -> Result, ()> { let mut data = self.data.lock().ignore_poison(); - data.update_tracked_widget(new_active, self, |data| &mut data.active) + data.update_tracked_widget(new_active.map(ManagedWidget::id), self, |data| { + &mut data.active + }) } pub fn widget(&self, id: WidgetId) -> Option { @@ -334,7 +339,7 @@ impl Tree { self.data.lock().ignore_poison().focus } - pub(crate) fn widgets_at_point(&self, point: Point) -> Vec { + pub(crate) fn widgets_under_point(&self, point: Point) -> Vec { let data = self.data.lock().ignore_poison(); data.render_info.widgets_under_point(point, &data, self) } @@ -489,12 +494,13 @@ impl TreeData { fn update_tracked_widget( &mut self, - new_widget: Option<&ManagedWidget>, + new_widget: Option, tree: &Tree, property: impl FnOnce(&mut Self) -> &mut Option, ) -> Result, ()> { + let new_widget = new_widget.and_then(|w| self.widget_from_id(w, tree)); match ( - mem::replace(property(self), new_widget.map(|w| w.node_id)), + mem::replace(property(self), new_widget.as_ref().map(|w| w.node_id)), new_widget, ) { (Some(old_widget), Some(new_widget)) if old_widget == new_widget.node_id => Err(()), diff --git a/src/widgets/button.rs b/src/widgets/button.rs index 5a03829..c8ae9ee 100644 --- a/src/widgets/button.rs +++ b/src/widgets/button.rs @@ -219,11 +219,6 @@ impl Button { kind, }; - // TODO this should be genericized to happen automatically. - if !context.enabled() { - context.blur(); - } - if context.is_default() { kind.colors_for_default(visual_state, context) } else { diff --git a/src/window.rs b/src/window.rs index 073ce33..29dcad0 100644 --- a/src/window.rs +++ b/src/window.rs @@ -278,7 +278,8 @@ struct GooeyWindow { root: ManagedWidget, contents: Drawing, should_close: bool, - mouse_state: MouseState, + cursor: CursorState, + mouse_buttons: AHashMap>, redraw_status: InvalidationStatus, initial_frame: bool, occluded: Dynamic, @@ -319,6 +320,7 @@ where &self.current_theme, window, self.theme_mode.get(), + &mut self.cursor, ), kludgine, ) @@ -331,6 +333,7 @@ where &self.current_theme, window, self.theme_mode.get(), + &mut self.cursor, ), kludgine, ) @@ -345,6 +348,7 @@ where &self.current_theme, window, self.theme_mode.get(), + &mut self.cursor, ), kludgine, ) @@ -466,11 +470,11 @@ where root, contents: Drawing::default(), should_close: false, - mouse_state: MouseState { + cursor: CursorState { location: None, widget: None, - devices: AHashMap::default(), }, + mouse_buttons: AHashMap::default(), redraw_status: InvalidationStatus::default(), initial_frame: true, occluded, @@ -517,6 +521,7 @@ where &self.current_theme, &mut window, self.theme_mode.get(), + &mut self.cursor, ), gfx: Exclusive::Owned(Graphics::new(graphics)), }; @@ -684,11 +689,9 @@ where is_synthetic: bool, ) { let target = self.root.tree.focused_widget().unwrap_or(self.root.node_id); - let target = self - .root - .tree - .widget_from_node(target) - .expect("missing widget"); + let Some(target) = self.root.tree.widget_from_node(target) else { + return; + }; let mut window = RunningWindow::new(window, &self.focused, &self.occluded); let mut target = EventContext::new( WidgetContext::new( @@ -697,6 +700,7 @@ where &self.current_theme, &mut window, self.theme_mode.get(), + &mut self.cursor, ), kludgine, ); @@ -731,6 +735,7 @@ where &self.current_theme, &mut window, self.theme_mode.get(), + &mut self.cursor, ), kludgine, ); @@ -798,6 +803,7 @@ where &self.current_theme, &mut window, self.theme_mode.get(), + &mut self.cursor, ), kludgine, ); @@ -833,6 +839,7 @@ where &self.current_theme, &mut window, self.theme_mode.get(), + &mut self.cursor, ), kludgine, ); @@ -849,10 +856,24 @@ where position: PhysicalPosition, ) { let location = Point::::from(position); - self.mouse_state.location = Some(location); + self.cursor.location = Some(location); let mut window = RunningWindow::new(window, &self.focused, &self.occluded); - if let Some(state) = self.mouse_state.devices.get(&device_id) { + + EventContext::new( + WidgetContext::new( + self.root.clone(), + &self.redraw_status, + &self.current_theme, + &mut window, + self.theme_mode.get(), + &mut self.cursor, + ), + kludgine, + ) + .update_hovered_widget(); + + if let Some(state) = self.mouse_buttons.get(&device_id) { // Mouse Drag for (button, handler) in state { let Some(handler) = self.root.tree.widget(*handler) else { @@ -865,6 +886,7 @@ where &self.current_theme, &mut window, self.theme_mode.get(), + &mut self.cursor, ), kludgine, ); @@ -873,37 +895,6 @@ where }; context.mouse_drag(location - last_rendered_at.origin, device_id, *button); } - } else { - // Hover - let mut context = EventContext::new( - WidgetContext::new( - self.root.clone(), - &self.redraw_status, - &self.current_theme, - &mut window, - self.theme_mode.get(), - ), - kludgine, - ); - self.mouse_state.widget = None; - for widget in self.root.tree.widgets_at_point(location) { - let mut widget_context = context.for_other(&widget); - let Some(widget_layout) = widget_context.last_layout() else { - continue; - }; - let relative = location - widget_layout.origin; - - if widget_context.hit_test(relative) { - widget_context.hover(relative); - drop(widget_context); - self.mouse_state.widget = Some(widget.id()); - break; - } - } - - if self.mouse_state.widget.is_none() { - context.clear_hover(); - } } } @@ -913,7 +904,7 @@ where kludgine: &mut Kludgine, _device_id: DeviceId, ) { - if self.mouse_state.widget.take().is_some() { + if self.cursor.widget.take().is_some() { let mut window = RunningWindow::new(window, &self.focused, &self.occluded); let mut context = EventContext::new( WidgetContext::new( @@ -922,6 +913,7 @@ where &self.current_theme, &mut window, self.theme_mode.get(), + &mut self.cursor, ), kludgine, ); @@ -947,6 +939,7 @@ where &self.current_theme, &mut window, self.theme_mode.get(), + &mut self.cursor, ), kludgine, ) @@ -954,10 +947,8 @@ where if let (ElementState::Pressed, Some(location), Some(hovered)) = ( state, - &self.mouse_state.location, - self.mouse_state - .widget - .and_then(|id| self.root.tree.widget(id)), + self.cursor.location, + self.cursor.widget.and_then(|id| self.root.tree.widget(id)), ) { if let Some(handler) = recursively_handle_event( &mut EventContext::new( @@ -967,6 +958,7 @@ where &self.current_theme, &mut window, self.theme_mode.get(), + &mut self.cursor, ), kludgine, ), @@ -974,12 +966,11 @@ where let Some(layout) = context.last_layout() else { return IGNORED; }; - let relative = *location - layout.origin; + let relative = location - layout.origin; context.mouse_down(relative, device_id, button) }, ) { - self.mouse_state - .devices + self.mouse_buttons .entry(device_id) .or_default() .insert(button, handler.id()); @@ -987,18 +978,19 @@ where } } ElementState::Released => { - let Some(device_buttons) = self.mouse_state.devices.get_mut(&device_id) else { + let Some(device_buttons) = self.mouse_buttons.get_mut(&device_id) else { return; }; let Some(handler) = device_buttons.remove(&button) else { return; }; if device_buttons.is_empty() { - self.mouse_state.devices.remove(&device_id); + self.mouse_buttons.remove(&device_id); } let Some(handler) = self.root.tree.widget(handler) else { return; }; + let cursor_location = self.cursor.location; let mut context = EventContext::new( WidgetContext::new( handler, @@ -1006,12 +998,13 @@ where &self.current_theme, &mut window, self.theme_mode.get(), + &mut self.cursor, ), kludgine, ); let relative = if let (Some(last_rendered), Some(location)) = - (context.last_layout(), self.mouse_state.location) + (context.last_layout(), cursor_location) { Some(location - last_rendered.origin) } else { @@ -1060,10 +1053,9 @@ fn recursively_handle_event( } #[derive(Default)] -struct MouseState { - location: Option>, - widget: Option, - devices: AHashMap>, +pub(crate) struct CursorState { + pub(crate) location: Option>, + pub(crate) widget: Option, } pub(crate) mod sealed { From 294b1350c4bc15112461b0e90eabacb78f3e4843 Mon Sep 17 00:00:00 2001 From: Jonathan Johnson Date: Wed, 15 Nov 2023 13:04:28 -0800 Subject: [PATCH 22/24] Fixing button animations being "slow" The issue was that my last set of changes were causing the animations to restart, causing the animation to keep being extended to another 150ms. I think the only way for this to work is to switch to an event mechanism to notify widgets once they've been invalidated. This event could include a parameter stating whether it was a direct invalidation or an invalidation due to another widget in the hierarchy. Button doesn't really care about the rest of the hierarchy, it only cares about its own state, and the cache key was including too many changes. --- src/context.rs | 4 ---- src/tree.rs | 8 -------- src/widget.rs | 4 ---- 3 files changed, 16 deletions(-) diff --git a/src/context.rs b/src/context.rs index 0cb8ae0..1146fdd 100644 --- a/src/context.rs +++ b/src/context.rs @@ -776,7 +776,6 @@ impl<'context, 'window> WidgetContext<'context, 'window> { cache: WidgetCacheKey { theme_mode, enabled, - invalidation: current_node.invalidation(), }, cursor, current_node, @@ -826,7 +825,6 @@ impl<'context, 'window> WidgetContext<'context, 'window> { cache: WidgetCacheKey { theme_mode, enabled: current_node.enabled(&self.handle()), - invalidation: current_node.invalidation(), }, current_node, redraw_status: self.redraw_status, @@ -1263,7 +1261,6 @@ impl MapManagedWidget for ManagedWidget { pub struct WidgetCacheKey { theme_mode: ThemeMode, enabled: bool, - invalidation: u64, } impl Default for WidgetCacheKey { @@ -1271,7 +1268,6 @@ impl Default for WidgetCacheKey { Self { theme_mode: ThemeMode::default().inverse(), enabled: false, - invalidation: u64::MAX, } } } diff --git a/src/tree.rs b/src/tree.rs index 462fbf7..afdf43f 100644 --- a/src/tree.rs +++ b/src/tree.rs @@ -45,7 +45,6 @@ impl Tree { effective_styles, theme: None, theme_mode: None, - invalidation: 0, }); data.nodes_by_id.insert(id, node_id); if widget.is_default() { @@ -282,11 +281,6 @@ impl Tree { data.widget_from_node(id, self) } - pub(crate) fn invalidation(&self, id: LotId) -> Option { - let data = self.data.lock().ignore_poison(); - data.nodes.get(id).map(|node| node.invalidation) - } - pub(crate) fn is_enabled(&self, mut id: LotId, context: &WindowHandle) -> bool { let data = self.data.lock().ignore_poison(); loop { @@ -513,7 +507,6 @@ impl TreeData { let mut node = &mut self.nodes[id]; while node.layout.is_some() { node.layout = None; - node.invalidation += 1; node.last_layout_query = None; let (true, Some(parent)) = (include_hierarchy, node.parent) else { @@ -585,7 +578,6 @@ struct Node { children: Vec, parent: Option, layout: Option>, - invalidation: u64, last_layout_query: Option, associated_styles: Option>, effective_styles: Styles, diff --git a/src/widget.rs b/src/widget.rs index 4673918..c063c23 100644 --- a/src/widget.rs +++ b/src/widget.rs @@ -1186,10 +1186,6 @@ impl ManagedWidget { self.tree.is_enabled(self.node_id, handle) } - pub(crate) fn invalidation(&self) -> u64 { - self.tree.invalidation(self.node_id).expect("missing node") - } - /// Returns true if this widget is currently the hovered widget. #[must_use] pub fn hovered(&self) -> bool { From 70eecb7429c52cccc01c5f4526ab7b2e448f11d4 Mon Sep 17 00:00:00 2001 From: Jonathan Johnson Date: Wed, 15 Nov 2023 13:43:26 -0800 Subject: [PATCH 23/24] Focus is now blurred when disabled apply_pending_state now checks that the focused widget is still enabled. If not, it transitions to no focus. --- src/context.rs | 18 +++++++++++------- src/window.rs | 16 ++++++++++++---- 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/src/context.rs b/src/context.rs index 1146fdd..6c81f7c 100644 --- a/src/context.rs +++ b/src/context.rs @@ -228,10 +228,14 @@ impl<'context, 'window> EventContext<'context, 'window> { let mut focus_changes = 0; while focus_changes < MAX_ITERS { - let focus = self + let focus = match self .pending_state .focus - .and_then(|w| self.current_node.tree.widget(w)); + .and_then(|w| self.current_node.tree.widget(w)) + { + Some(focus) => self.for_other(&focus).enabled().then_some(focus), + None => None, + }; if self.current_node.tree.focused_widget() == focus.as_ref().map(|w| w.node_id) { break; } @@ -377,11 +381,11 @@ impl<'context, 'window> EventContext<'context, 'window> { break; } - if child - .lock() - .as_widget() - .accept_focus(&mut self.for_other(&child)) - { + let mut child_context = self.for_other(&child); + let accept_focus = child_context.enabled() + && child.lock().as_widget().accept_focus(&mut child_context); + drop(child_context); + if accept_focus { return Some(child.id()); } else if let Some(next_focus) = self.widget().explicit_focus_target(advance) { return Some(next_focus.id()); diff --git a/src/window.rs b/src/window.rs index 29dcad0..e052d68 100644 --- a/src/window.rs +++ b/src/window.rs @@ -284,7 +284,7 @@ struct GooeyWindow { initial_frame: bool, occluded: Dynamic, focused: Dynamic, - keyboard_activated: Option, + keyboard_activated: Option, min_inner_size: Option>, max_inner_size: Option>, theme: Option>, @@ -312,7 +312,11 @@ where ) { if is_pressed { if let Some(default) = widget.and_then(|id| self.root.tree.widget_from_node(id)) { - if let Some(previously_active) = self.keyboard_activated.take() { + if let Some(previously_active) = self + .keyboard_activated + .take() + .and_then(|id| self.root.tree.widget(id)) + { EventContext::new( WidgetContext::new( previously_active, @@ -338,9 +342,13 @@ where kludgine, ) .activate(); - self.keyboard_activated = Some(default); + self.keyboard_activated = Some(default.id()); } - } else if let Some(keyboard_activated) = self.keyboard_activated.take() { + } else if let Some(keyboard_activated) = self + .keyboard_activated + .take() + .and_then(|id| self.root.tree.widget(id)) + { EventContext::new( WidgetContext::new( keyboard_activated, From 1ed1a95a1d8561f92d7850ebd76f0361aca062ce Mon Sep 17 00:00:00 2001 From: Jonathan Johnson Date: Wed, 15 Nov 2023 14:25:59 -0800 Subject: [PATCH 24/24] Input copy/paste works now Also updated to wgpu 0.18.1 --- Cargo.lock | 202 +++++++++++++++++++++++++++++++++++++++++-- Cargo.toml | 1 + src/widgets/input.rs | 156 ++++++++++++++++++++------------- src/window.rs | 68 +++++++++++---- 4 files changed, 339 insertions(+), 88 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 734e3d7..efea6db 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -121,6 +121,25 @@ dependencies = [ "num-traits", ] +[[package]] +name = "arboard" +version = "3.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac57f2b058a76363e357c056e4f74f1945bf734d37b8b3ef49066c4787dde0fc" +dependencies = [ + "clipboard-win", + "core-graphics", + "image", + "log", + "objc", + "objc-foundation", + "objc_id", + "parking_lot", + "thiserror", + "winapi", + "x11rb 0.10.1", +] + [[package]] name = "arrayref" version = "0.3.7" @@ -335,6 +354,17 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" +[[package]] +name = "clipboard-win" +version = "4.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7191c27c2357d9b7ef96baac1773290d4ca63b24205b82a3fd8a0637afcf0362" +dependencies = [ + "error-code", + "str-buf", + "winapi", +] + [[package]] name = "codespan-reporting" version = "0.11.1" @@ -446,6 +476,15 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "crc32fast" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b540bd8bc810d3885c6ea91e2018302f68baba2129ab3e88f32389ee9370880d" +dependencies = [ + "cfg-if", +] + [[package]] name = "cursor-icon" version = "1.1.0" @@ -529,14 +568,24 @@ checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" [[package]] name = "errno" -version = "0.3.6" +version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c18ee0ed65a5f1f81cac6b1d213b69c35fa47d4252ad41f1486dbd8226fe36e" +checksum = "f258a7194e7f7c2a7837a8913aeab7fd8c383457034fa20ce4dd3dcb813e8eb8" dependencies = [ "libc", "windows-sys 0.48.0", ] +[[package]] +name = "error-code" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64f18991e7bf11e7ffee451b5318b5c1a73c52d0d0ada6e5a3017c8c1ced6a21" +dependencies = [ + "libc", + "str-buf", +] + [[package]] name = "etagere" version = "0.2.8" @@ -562,6 +611,15 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd2e7510819d6fbf51a5545c8f922716ecfb14df168a3242f7d33e0239efe6a1" +[[package]] +name = "fdeflate" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64d6dafc854908ff5da46ff3f8f473c6984119a2876a383a860246dd7841a868" +dependencies = [ + "simd-adler32", +] + [[package]] name = "figures" version = "0.1.0" @@ -574,6 +632,16 @@ dependencies = [ "winit", ] +[[package]] +name = "flate2" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46303f565772937ffe1d394a4fac6f411c6013172fadde9dcdb1e147a086940e" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "float_next_after" version = "1.0.0" @@ -675,6 +743,16 @@ version = "0.3.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e36d3378ee38c2a36ad710c5d30c2911d752cb941c00c72dbabfb786a7970817" +[[package]] +name = "gethostname" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1ebd34e35c46e00bb73e81363248d627782724609fe1b6396f553f68fe3862e" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "gethostname" version = "0.3.0" @@ -742,6 +820,7 @@ version = "0.1.0" dependencies = [ "ahash", "alot", + "arboard", "derive_more", "gooey-macros", "intentional", @@ -880,6 +959,8 @@ dependencies = [ "color_quant", "num-rational", "num-traits", + "png", + "tiff", ] [[package]] @@ -965,6 +1046,12 @@ dependencies = [ "libc", ] +[[package]] +name = "jpeg-decoder" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc0000e42512c92e31c2252315bda326620a4e034105e900c98ec492fa077b3e" + [[package]] name = "js-sys" version = "0.3.65" @@ -1260,13 +1347,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7810e0be55b428ada41041c41f32c9f1a42817901b4ccf45fa3d4b6561e74c7" dependencies = [ "adler", + "simd-adler32", ] [[package]] name = "naga" -version = "0.14.0" +version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61d829abac9f5230a85d8cc83ec0879b4c09790208ae25b5ea031ef84562e071" +checksum = "6cd05939c491da968a42986204b7431678be21fdcd4b10cc84997ba130ada5a4" dependencies = [ "bit-set", "bitflags 2.4.1", @@ -1321,6 +1409,18 @@ dependencies = [ "jni-sys", ] +[[package]] +name = "nix" +version = "0.24.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa52e972a9a719cecb6864fb88568781eb706bac2cd1d4f04a648542dbf78069" +dependencies = [ + "bitflags 1.3.2", + "cfg-if", + "libc", + "memoffset 0.6.5", +] + [[package]] name = "nix" version = "0.25.1" @@ -1428,6 +1528,17 @@ dependencies = [ "objc_exception", ] +[[package]] +name = "objc-foundation" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1add1b659e36c9607c7aab864a76c7a4c2760cd0cd2e120f3fb8b952c7e22bf9" +dependencies = [ + "block", + "objc", + "objc_id", +] + [[package]] name = "objc-sys" version = "0.3.1" @@ -1459,6 +1570,15 @@ dependencies = [ "cc", ] +[[package]] +name = "objc_id" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92d4ddb4bd7b50d730c215ff871754d0da6b2178849f8a2a2ab69712d0c073b" +dependencies = [ + "objc", +] + [[package]] name = "object" version = "0.32.1" @@ -1610,6 +1730,19 @@ version = "0.3.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26072860ba924cbfa98ea39c8c19b4dd6a4a25423dbdf219c1eca91aa0cf6964" +[[package]] +name = "png" +version = "0.17.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd75bf2d8dd3702b9707cdbc56a5b9ef42cec752eb8b3bafc01234558442aa64" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + [[package]] name = "pollster" version = "0.3.0" @@ -1935,6 +2068,12 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "simd-adler32" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" + [[package]] name = "similar" version = "2.3.0" @@ -2018,6 +2157,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "str-buf" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e08d8363704e6c71fc928674353e6b7c23dcea9d82d7012c8faf2a3a025f8d0" + [[package]] name = "strict-num" version = "0.1.1" @@ -2099,6 +2244,17 @@ dependencies = [ "once_cell", ] +[[package]] +name = "tiff" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d172b0f4d3fba17ba89811858b9d3d97f928aece846475bbda076ca46736211" +dependencies = [ + "flate2", + "jpeg-decoder", + "weezl", +] + [[package]] name = "tiny-skia" version = "0.11.2" @@ -2496,6 +2652,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "weezl" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9193164d4de03a926d909d3bc7c30543cecb35400c02114792c2cae20d5e2dbb" + [[package]] name = "wgpu" version = "0.18.0" @@ -2523,9 +2685,9 @@ dependencies = [ [[package]] name = "wgpu-core" -version = "0.18.0" +version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "837e02ddcdc6d4a9b56ba4598f7fd4202a7699ab03f6ef4dcdebfad2c966aea6" +checksum = "ef91c1d62d1e9e81c79e600131a258edf75c9531cbdbde09c44a011a47312726" dependencies = [ "arrayvec", "bit-vec", @@ -2838,7 +3000,7 @@ dependencies = [ "web-time", "windows-sys 0.48.0", "x11-dl", - "x11rb", + "x11rb 0.12.0", "xkbcommon-dl", ] @@ -2862,6 +3024,19 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "x11rb" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "592b4883219f345e712b3209c62654ebda0bb50887f330cbd018d0f654bfd507" +dependencies = [ + "gethostname 0.2.3", + "nix 0.24.3", + "winapi", + "winapi-wsapoll", + "x11rb-protocol 0.10.0", +] + [[package]] name = "x11rb" version = "0.12.0" @@ -2869,14 +3044,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b1641b26d4dec61337c35a1b1aaf9e3cba8f46f0b43636c609ab0291a648040a" dependencies = [ "as-raw-xcb-connection", - "gethostname", + "gethostname 0.3.0", "libc", "libloading 0.7.4", "nix 0.26.4", "once_cell", "winapi", "winapi-wsapoll", - "x11rb-protocol", + "x11rb-protocol 0.12.0", +] + +[[package]] +name = "x11rb-protocol" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56b245751c0ac9db0e006dc812031482784e434630205a93c73cfefcaabeac67" +dependencies = [ + "nix 0.24.3", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 6b642d1..3b3f92a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,6 +26,7 @@ palette = "0.7.3" ahash = "0.8.6" gooey-macros = { version = "0.1.0", path = "gooey-macros" } derive_more = { version = "1.0.0-beta.6", features = ["from"] } +arboard = "3.2.1" # [patch."https://github.com/khonsulabs/kludgine"] diff --git a/src/widgets/input.rs b/src/widgets/input.rs index c294600..da35c71 100644 --- a/src/widgets/input.rs +++ b/src/widgets/input.rs @@ -118,6 +118,101 @@ impl Input { editor.set_select_opt(Some(Cursor::new_with_affinity(0, 0, Affinity::Before))); } } + + fn handle_key( + &mut self, + input: KeyEvent, + context: &mut EventContext<'_, '_>, + ) -> (bool, EventHandling) { + let editor = self.editor_mut(context.kludgine, &context.widget); + + match (input.state, input.logical_key, input.text.as_deref()) { + (ElementState::Pressed, key @ (Key::Backspace | Key::Delete), _) => { + editor.action( + context.kludgine.font_system(), + match key { + Key::Backspace => Action::Backspace, + Key::Delete => Action::Delete, + _ => unreachable!("previously matched"), + }, + ); + (true, HANDLED) + } + (ElementState::Pressed, key @ (Key::ArrowLeft | Key::ArrowDown | Key::ArrowUp | Key::ArrowRight), _) => { + let modifiers = context.modifiers(); + match (editor.select_opt(), modifiers.state().shift_key()) { + (None, true) => { + editor.set_select_opt(Some(editor.cursor())); + } + (Some(_), false) => { + editor.set_select_opt(None); + } + _ => {} + }; + + editor.action( + context.kludgine.font_system(), + match key { + Key::ArrowLeft if modifiers.word_select() => Action::PreviousWord, + Key::ArrowLeft => Action::Left, + Key::ArrowDown => Action::Down, + Key::ArrowUp => Action::Up, + Key::ArrowRight if modifiers.word_select() => Action::NextWord, + Key::ArrowRight => Action::Right, + _ => unreachable!("previously matched"), + }, + ); + (false, HANDLED) + } + (state, _, Some("a")) if context.modifiers().primary() => { + if state.is_pressed() { + self.select_all(); + } + (false, HANDLED) + } + (state, _, Some("c")) if context.modifiers().primary() => { + + if state.is_pressed() { + if let Some(mut clipboard) = context.clipboard_guard() { + if let Some(selection) = editor.copy_selection() { + match clipboard.set_text(selection) { + Ok(()) => {}, + Err(err) => tracing::error!("error copying to clipboard: {err}"), + } + } + } + } + (false, HANDLED) + } + (state, _, Some("v")) if context.modifiers().primary() => { + let pasted = state.is_pressed() && + match context.clipboard_guard().map(|mut clipboard| clipboard.get_text()) { + Some(Ok(text)) => { + editor.insert_string(&text, None); + true + }, + None | Some(Err(arboard::Error::ConversionFailure)) => false, + Some(Err(err)) => {tracing::error!("error retrieving clipboard contents: {err}"); false}, + } + + ; + (pasted, HANDLED) + } + (state, _, Some(text)) + if !context.modifiers().primary() + && text != "\t" // tab + && text != "\r" // enter/return + && text != "\u{1b}" // escape + => + { + if state.is_pressed() { + editor.insert_string(text, None); + } + (state.is_pressed(), HANDLED) + } + (_, _, _) => (false, IGNORED), + } + } } impl Default for Input { @@ -389,70 +484,11 @@ impl Widget for Input { on_key.invoke(input.clone())?; } - let editor = self.editor_mut(context.kludgine, &context.widget); - // println!( // "Keyboard input: {:?}. {:?}, {:?}", // input.logical_key, input.text, input.physical_key // ); - let (text_changed, handled) = match (input.state, input.logical_key, input.text.as_deref()) { - (ElementState::Pressed, key @ (Key::Backspace | Key::Delete), _) => { - editor.action( - context.kludgine.font_system(), - match key { - Key::Backspace => Action::Backspace, - Key::Delete => Action::Delete, - _ => unreachable!("previously matched"), - }, - ); - (true, HANDLED) - } - (ElementState::Pressed, key @ (Key::ArrowLeft | Key::ArrowDown | Key::ArrowUp | Key::ArrowRight), _) => { - let modifiers = context.modifiers(); - match (editor.select_opt(), modifiers.state().shift_key()) { - (None, true) => { - editor.set_select_opt(Some(editor.cursor())); - } - (Some(_), false) => { - editor.set_select_opt(None); - } - _ => {} - }; - - editor.action( - context.kludgine.font_system(), - match key { - Key::ArrowLeft if modifiers.word_select() => Action::PreviousWord, - Key::ArrowLeft => Action::Left, - Key::ArrowDown => Action::Down, - Key::ArrowUp => Action::Up, - Key::ArrowRight if modifiers.word_select() => Action::NextWord, - Key::ArrowRight => Action::Right, - _ => unreachable!("previously matched"), - }, - ); - (false, HANDLED) - } - (state, _, Some("a")) if context.modifiers().primary() => { - if state.is_pressed() { - self.select_all(); - } - (false, HANDLED) - } - (state, _, Some(text)) - if !context.modifiers().primary() - && text != "\t" // tab - && text != "\r" // enter/return - && text != "\u{1b}" // escape - => - { - if state.is_pressed() { - editor.insert_string(text, None); - } - (state.is_pressed(), HANDLED) - } - (_, _, _) => (false, IGNORED), - }; + let (text_changed, handled) = self.handle_key(input, context); if handled.is_break() { context.set_needs_redraw(); diff --git a/src/window.rs b/src/window.rs index e052d68..fdbc0f2 100644 --- a/src/window.rs +++ b/src/window.rs @@ -6,10 +6,11 @@ use std::ops::{Deref, DerefMut, Not}; use std::panic::{AssertUnwindSafe, UnwindSafe}; use std::path::Path; use std::string::ToString; -use std::sync::OnceLock; +use std::sync::{Arc, Mutex, MutexGuard, OnceLock}; use ahash::AHashMap; use alot::LotId; +use arboard::Clipboard; use kludgine::app::winit::dpi::{PhysicalPosition, PhysicalSize}; use kludgine::app::winit::event::{ DeviceId, ElementState, Ime, KeyEvent, MouseButton, MouseScrollDelta, TouchPhase, @@ -32,7 +33,7 @@ use crate::context::{ use crate::graphics::Graphics; use crate::styles::ThemePair; use crate::tree::Tree; -use crate::utils::ModifiersExt; +use crate::utils::{IgnorePoison, ModifiersExt}; use crate::value::{Dynamic, DynamicReader, IntoDynamic, IntoValue, Value}; use crate::widget::{ EventHandling, ManagedWidget, Widget, WidgetId, WidgetInstance, HANDLED, IGNORED, @@ -44,6 +45,7 @@ use crate::{initialize_tracing, ConstraintLimit, Run}; /// A currently running Gooey window. pub struct RunningWindow<'window> { window: kludgine::app::Window<'window, WindowCommand>, + clipboard: Option>>, focused: Dynamic, occluded: Dynamic, } @@ -51,11 +53,13 @@ pub struct RunningWindow<'window> { impl<'window> RunningWindow<'window> { pub(crate) fn new( window: kludgine::app::Window<'window, WindowCommand>, + clipboard: &Option>>, focused: &Dynamic, occluded: &Dynamic, ) -> Self { Self { window, + clipboard: clipboard.clone(), focused: focused.clone(), occluded: occluded.clone(), } @@ -74,6 +78,15 @@ impl<'window> RunningWindow<'window> { pub fn occluded(&self) -> &Dynamic { &self.occluded } + + /// Returns a locked mutex guard to the OS's clipboard, if one was able to be + /// initialized when the window opened. + #[must_use] + pub fn clipboard_guard(&mut self) -> Option> { + self.clipboard + .as_ref() + .map(|mutex| mutex.lock().ignore_poison()) + } } impl<'window> Deref for RunningWindow<'window> { @@ -291,16 +304,21 @@ struct GooeyWindow { current_theme: ThemePair, theme_mode: Value, transparent: bool, + clipboard: Option>>, } impl GooeyWindow where T: WindowBehavior, { - fn request_close(&mut self, window: &mut RunningWindow<'_>) -> bool { - self.should_close |= self.behavior.close_requested(window); + fn request_close( + should_close: &mut bool, + behavior: &mut T, + window: &mut RunningWindow<'_>, + ) -> bool { + *should_close |= behavior.close_requested(window); - self.should_close + *should_close } fn keyboard_activate_widget( @@ -453,6 +471,10 @@ where .take() .expect("theme always present"); + let clipboard = Clipboard::new() + .ok() + .map(|clipboard| Arc::new(Mutex::new(clipboard))); + let theme_mode = match context.settings.borrow_mut().theme_mode.take() { Some(Value::Dynamic(dynamic)) => { dynamic.update(window.theme().into()); @@ -463,7 +485,7 @@ where }; let transparent = context.settings.borrow().transparent; let mut behavior = T::initialize( - &mut RunningWindow::new(window, &focused, &occluded), + &mut RunningWindow::new(window, &clipboard, &focused, &occluded), context.user, ); let root = Tree::default().push_boxed(behavior.make_root(), None); @@ -494,6 +516,7 @@ where theme, theme_mode, transparent, + clipboard, } } @@ -521,7 +544,7 @@ where let is_expanded = self.constrain_window_resizing(resizable, &window, graphics); let graphics = self.contents.new_frame(graphics); - let mut window = RunningWindow::new(window, &self.focused, &self.occluded); + let mut window = RunningWindow::new(window, &self.clipboard, &self.focused, &self.occluded); let mut context = GraphicsContext { widget: WidgetContext::new( self.root.clone(), @@ -633,11 +656,11 @@ where window: kludgine::app::Window<'_, WindowCommand>, _kludgine: &mut Kludgine, ) -> bool { - self.request_close(&mut RunningWindow::new( - window, - &self.focused, - &self.occluded, - )) + Self::request_close( + &mut self.should_close, + &mut self.behavior, + &mut RunningWindow::new(window, &self.clipboard, &self.focused, &self.occluded), + ) } // fn power_preference() -> wgpu::PowerPreference { @@ -700,7 +723,7 @@ where let Some(target) = self.root.tree.widget_from_node(target) else { return; }; - let mut window = RunningWindow::new(window, &self.focused, &self.occluded); + let mut window = RunningWindow::new(window, &self.clipboard, &self.focused, &self.occluded); let mut target = EventContext::new( WidgetContext::new( target, @@ -722,7 +745,13 @@ where if !handled { match input.logical_key { Key::Character(ch) if ch == "w" && window.modifiers().primary() => { - if input.state.is_pressed() && self.request_close(&mut window) { + if input.state.is_pressed() + && Self::request_close( + &mut self.should_close, + &mut self.behavior, + &mut window, + ) + { window.set_needs_redraw(); } } @@ -803,7 +832,7 @@ where .expect("missing widget") }); - let mut window = RunningWindow::new(window, &self.focused, &self.occluded); + let mut window = RunningWindow::new(window, &self.clipboard, &self.focused, &self.occluded); let mut widget = EventContext::new( WidgetContext::new( widget, @@ -839,7 +868,7 @@ where .widget(self.root.id()) .expect("missing widget") }); - let mut window = RunningWindow::new(window, &self.focused, &self.occluded); + let mut window = RunningWindow::new(window, &self.clipboard, &self.focused, &self.occluded); let mut target = EventContext::new( WidgetContext::new( widget, @@ -866,7 +895,7 @@ where let location = Point::::from(position); self.cursor.location = Some(location); - let mut window = RunningWindow::new(window, &self.focused, &self.occluded); + let mut window = RunningWindow::new(window, &self.clipboard, &self.focused, &self.occluded); EventContext::new( WidgetContext::new( @@ -913,7 +942,8 @@ where _device_id: DeviceId, ) { if self.cursor.widget.take().is_some() { - let mut window = RunningWindow::new(window, &self.focused, &self.occluded); + let mut window = + RunningWindow::new(window, &self.clipboard, &self.focused, &self.occluded); let mut context = EventContext::new( WidgetContext::new( self.root.clone(), @@ -937,7 +967,7 @@ where state: ElementState, button: MouseButton, ) { - let mut window = RunningWindow::new(window, &self.focused, &self.occluded); + let mut window = RunningWindow::new(window, &self.clipboard, &self.focused, &self.occluded); match state { ElementState::Pressed => { EventContext::new(