diff --git a/examples/radio.rs b/examples/radio.rs new file mode 100644 index 0000000..387f36f --- /dev/null +++ b/examples/radio.rs @@ -0,0 +1,24 @@ +use gooey::value::Dynamic; +use gooey::widget::MakeWidget; +use gooey::Run; + +#[derive(Default, Eq, PartialEq, Debug, Clone, Copy)] +pub enum Choice { + #[default] + A, + B, + C, +} + +fn main() -> gooey::Result { + let option = Dynamic::default(); + + option + .new_radio(Choice::A, "A") + .and(option.new_radio(Choice::B, "B")) + .and(option.new_radio(Choice::C, "C")) + .into_rows() + .centered() + .expand() + .run() +} diff --git a/src/context.rs b/src/context.rs index 37c3db1..c480c5f 100644 --- a/src/context.rs +++ b/src/context.rs @@ -1,6 +1,5 @@ //! 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, Mutex, MutexGuard}; @@ -14,6 +13,7 @@ use kludgine::figures::{IntoSigned, Point, Px2D, Rect, Round, ScreenScale, Size, use kludgine::shapes::{Shape, StrokeOptions}; use kludgine::{Color, Kludgine}; +use crate::context::sealed::WindowHandle; use crate::graphics::Graphics; use crate::styles::components::{ CornerRadius, FontFamily, FontStyle, FontWeight, HighlightColor, LayoutOrder, TextSize, @@ -21,9 +21,8 @@ use crate::styles::components::{ }; use crate::styles::{ComponentDefinition, Styles, Theme, ThemePair}; use crate::utils::IgnorePoison; -use crate::value::{Dynamic, IntoValue, Value}; +use crate::value::{IntoValue, Value}; use crate::widget::{EventHandling, ManagedWidget, WidgetId, WidgetInstance, WidgetRef}; -use crate::window::sealed::WindowCommand; use crate::window::{CursorState, RunningWindow, ThemeMode}; use crate::ConstraintLimit; @@ -940,12 +939,12 @@ impl<'context, 'window> WidgetContext<'context, 'window> { } /// Ensures that this widget will be redrawn when `value` has been updated. - pub fn redraw_when_changed(&self, value: &Dynamic) { + pub fn redraw_when_changed(&self, value: &impl Trackable) { 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) { + pub fn invalidate_when_changed(&self, value: &impl Trackable) { value.invalidate_when_changed(self.handle(), self.current_node.id()); } @@ -1166,43 +1165,6 @@ impl<'context, 'window> WidgetContext<'context, 'window> { } } -#[derive(Clone)] -pub(crate) struct WindowHandle { - kludgine: kludgine::app::WindowHandle, - 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 { - pub fn redraw(&self) { - if self.redraw_status.should_send_refresh() { - let _result = self.kludgine.send(WindowCommand::Redraw); - } - } - - pub fn invalidate(&self, widget: WidgetId) { - if self.redraw_status.invalidate(widget) { - self.redraw(); - } - } -} - impl dyn AsEventContext<'_> {} impl Drop for EventContext<'_, '_> { @@ -1372,3 +1334,59 @@ impl Default for WidgetCacheKey { } } } + +/// A type that can be tracked to refresh or invalidate widgets. +pub trait Trackable: sealed::Trackable {} + +impl Trackable for T where T: sealed::Trackable {} + +pub(crate) mod sealed { + use std::hash::{Hash, Hasher}; + use std::sync::Arc; + + use crate::context::InvalidationStatus; + use crate::widget::WidgetId; + use crate::window::sealed::WindowCommand; + + pub trait Trackable { + fn redraw_when_changed(&self, handle: WindowHandle); + fn invalidate_when_changed(&self, handle: WindowHandle, id: WidgetId); + } + + #[derive(Clone)] + pub struct WindowHandle { + pub(crate) kludgine: kludgine::app::WindowHandle, + pub(crate) 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 { + pub fn redraw(&self) { + if self.redraw_status.should_send_refresh() { + let _result = self.kludgine.send(WindowCommand::Redraw); + } + } + + pub fn invalidate(&self, widget: WidgetId) { + if self.redraw_status.invalidate(widget) { + self.redraw(); + } + } + } +} diff --git a/src/tree.rs b/src/tree.rs index 5b8212d..7dff39b 100644 --- a/src/tree.rs +++ b/src/tree.rs @@ -6,7 +6,7 @@ use alot::{LotId, Lots}; use kludgine::figures::units::{Px, UPx}; use kludgine::figures::{Point, Rect, Size}; -use crate::context::WindowHandle; +use crate::context::sealed::WindowHandle; use crate::styles::{Styles, ThemePair, VisualOrder}; use crate::utils::IgnorePoison; use crate::value::Value; diff --git a/src/value.rs b/src/value.rs index 2602f4e..4eceb05 100644 --- a/src/value.rs +++ b/src/value.rs @@ -13,10 +13,11 @@ use ahash::AHashSet; use intentional::Assert; use crate::animation::{DynamicTransition, LinearInterpolate}; -use crate::context::{WidgetContext, WindowHandle}; +use crate::context::sealed::WindowHandle; +use crate::context::{self, WidgetContext}; use crate::utils::{IgnorePoison, UnwindsafeCondvar, WithClone}; -use crate::widget::{WidgetId, WidgetInstance}; -use crate::widgets::Switcher; +use crate::widget::{MakeWidget, WidgetId, WidgetInstance}; +use crate::widgets::{Radio, Switcher}; /// An instance of a value that provides APIs to observe and react to its /// contents. @@ -474,6 +475,21 @@ impl Dynamic { new_value, } } + + /// Returns a new [`Radio`] that updates this dynamic to `widget_value` when + /// pressed. `label` is drawn next to the checkbox and is also clickable to + /// select the radio. + #[must_use] + pub fn new_radio(&self, widget_value: T, label: impl MakeWidget) -> Radio + where + Self: Clone, + // Technically this trait bound isn't necessary, but it prevents trying + // to call into_radio on unsupported types. The MakeWidget/Widget + // implementations require these bounds (and more). + T: Clone + Eq, + { + Radio::new(widget_value, self.clone(), label) + } } impl Dynamic { @@ -485,6 +501,16 @@ impl Dynamic { } } +impl context::sealed::Trackable for Dynamic { + fn redraw_when_changed(&self, handle: WindowHandle) { + self.redraw_when_changed(handle); + } + + fn invalidate_when_changed(&self, handle: WindowHandle, id: WidgetId) { + self.invalidate_when_changed(handle, id); + } +} + impl Default for Dynamic where T: Default, @@ -919,6 +945,16 @@ impl DynamicReader { } } +impl context::sealed::Trackable for DynamicReader { + fn redraw_when_changed(&self, handle: WindowHandle) { + self.source.redraw_when_changed(handle); + } + + fn invalidate_when_changed(&self, handle: WindowHandle, id: WidgetId) { + self.source.invalidate_when_changed(handle, id); + } +} + impl Clone for DynamicReader { fn clone(&self) -> Self { self.source.state().expect("deadlocked").readers += 1; diff --git a/src/widget.rs b/src/widget.rs index 63bc0e1..4575dd5 100644 --- a/src/widget.rs +++ b/src/widget.rs @@ -16,9 +16,8 @@ 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, WindowHandle, -}; +use crate::context::sealed::WindowHandle; +use crate::context::{AsEventContext, EventContext, GraphicsContext, LayoutContext, WidgetContext}; use crate::styles::{ ComponentDefinition, ContainerLevel, Dimension, DimensionRange, Edges, IntoComponentValue, Styles, ThemePair, VisualOrder, diff --git a/src/widgets.rs b/src/widgets.rs index 04d42ab..f28c313 100644 --- a/src/widgets.rs +++ b/src/widgets.rs @@ -12,6 +12,7 @@ pub mod input; pub mod label; mod mode_switch; pub mod progress; +mod radio; mod resize; pub mod scroll; pub mod slider; @@ -34,6 +35,7 @@ pub use input::Input; pub use label::Label; pub use mode_switch::ThemedMode; pub use progress::ProgressBar; +pub use radio::Radio; pub use resize::Resize; pub use scroll::Scroll; pub use slider::Slider; diff --git a/src/widgets/checkbox.rs b/src/widgets/checkbox.rs index e6464e7..aeb29df 100644 --- a/src/widgets/checkbox.rs +++ b/src/widgets/checkbox.rs @@ -3,16 +3,14 @@ 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::figures::units::Lp; +use kludgine::figures::{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::styles::components::{LineHeight, OutlineColor, TextColor, WidgetAccentColor}; use crate::value::{Dynamic, DynamicReader, IntoDynamic, IntoValue, Value}; -use crate::widget::{MakeWidget, WidgetInstance, WidgetRef, WrappedLayout, WrapperWidget}; +use crate::widget::{MakeWidget, Widget, WidgetInstance}; use crate::widgets::button::ButtonKind; use crate::ConstraintLimit; @@ -56,8 +54,9 @@ impl MakeWidget for Checkbox { fn make_widget(self) -> WidgetInstance { CheckboxLabel { value: self.state.create_reader(), - label: WidgetRef::new(self.label), } + .and(self.label) + .into_columns() .into_button() .on_click(move |()| { let mut value = self.state.lock(); @@ -172,39 +171,18 @@ 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 - } +impl Widget for CheckboxLabel { + fn redraw(&mut self, context: &mut GraphicsContext<'_, '_, '_, '_, '_>) { + let checkbox_size = context + .gfx + .region() + .size + .width + .min(context.gfx.region().size.height); - 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 * 2; - let effective_height = size.height.max(label_inset); - let size_with_checkbox = - Size::new(size.width + label_inset + padding, effective_height).into_unsigned(); - WrappedLayout { - child: Rect::new( - Point::new(label_inset, Px::ZERO), - Size::new(size.width, effective_height), - ), - 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::squared(padding), Size::squared(checkbox_size)); + let checkbox_rect = Rect::from(Size::squared(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) => { @@ -250,6 +228,15 @@ impl WrapperWidget for CheckboxLabel { } } } + + fn layout( + &mut self, + _available_space: Size, + context: &mut LayoutContext<'_, '_, '_, '_, '_>, + ) -> Size { + let checkbox_size = context.get(&LineHeight).into_upx(context.gfx.scale()); // TODO create a component? + Size::squared(checkbox_size) + } } /// A value that can be used as a checkbox. diff --git a/src/widgets/radio.rs b/src/widgets/radio.rs new file mode 100644 index 0000000..277f836 --- /dev/null +++ b/src/widgets/radio.rs @@ -0,0 +1,127 @@ +//! A tri-state, labelable checkbox widget. +use std::fmt::Debug; +use std::panic::UnwindSafe; + +use kludgine::figures::units::Lp; +use kludgine::figures::{Point, ScreenScale, Size}; +use kludgine::shapes::{Shape, StrokeOptions}; +use kludgine::DrawableExt; + +use crate::context::{GraphicsContext, LayoutContext}; +use crate::styles::components::{LineHeight, OutlineColor, WidgetAccentColor}; +use crate::value::{Dynamic, DynamicReader, IntoDynamic, IntoValue, Value}; +use crate::widget::{MakeWidget, Widget, WidgetInstance}; +use crate::widgets::button::ButtonKind; +use crate::ConstraintLimit; + +/// A labeled-widget that supports three states: Checked, Unchecked, and +/// Indeterminant +pub struct Radio { + /// The value this button represents. + pub value: T, + /// The state (value) of the checkbox. + pub state: Dynamic, + /// The button kind to use as the basis for this radio. Radios default to + /// [`ButtonKind::Transparent`]. + pub kind: Value, + label: WidgetInstance, +} + +impl Radio { + /// Returns a new radio that sets `state` to `value` when pressed. `label` + /// is drawn next to the checkbox and is also clickable to select the radio. + pub fn new(value: T, state: impl IntoDynamic, label: impl MakeWidget) -> Self { + Self { + value, + state: state.into_dynamic(), + kind: Value::Constant(ButtonKind::Transparent), + label: label.make_widget(), + } + } + + /// Updates the button kind to use as the basis for this radio, and + /// returns self. + /// + /// Radios default to [`ButtonKind::Transparent`]. + #[must_use] + pub fn kind(mut self, kind: impl IntoValue) -> Self { + self.kind = kind.into_value(); + self + } +} + +impl MakeWidget for Radio +where + T: Clone + Debug + Eq + UnwindSafe + Send + 'static, +{ + fn make_widget(self) -> WidgetInstance { + RadioOrnament { + value: self.value.clone(), + state: self.state.create_reader(), + } + .and(self.label) + .into_columns() + .into_button() + .on_click(move |()| { + self.state.update(self.value.clone()); + }) + .kind(self.kind) + .make_widget() + } +} + +#[derive(Debug)] +struct RadioOrnament { + value: T, + state: DynamicReader, +} + +impl Widget for RadioOrnament +where + T: Debug + Eq + UnwindSafe + Send + 'static, +{ + fn redraw(&mut self, context: &mut GraphicsContext<'_, '_, '_, '_, '_>) { + let radio_size = context + .gfx + .region() + .size + .width + .min(context.gfx.region().size.height); + let vertical_center = context.gfx.region().size.height / 2; + + let stroke_options = StrokeOptions::lp_wide(Lp::points(2)).into_px(context.gfx.scale()); + context.redraw_when_changed(&self.state); + let selected = self.state.map_ref(|state| state == &self.value); + let color = context.get(&OutlineColor); + let radius = radio_size / 2; + context.gfx.draw_shape( + Shape::stroked_circle( + radius - stroke_options.line_width / 2, + color, + kludgine::Origin::Center, + stroke_options.colored(color), + ) + .translate_by(Point::new(radius, vertical_center)), + ); + if selected { + let color = context.get(&WidgetAccentColor); + context.gfx.draw_shape( + Shape::filled_circle( + radius - stroke_options.line_width * 2, + color, + kludgine::Origin::Center, + ) + .translate_by(Point::new(radius, vertical_center)), + ); + } + } + + fn layout( + &mut self, + _available_space: Size, + context: &mut LayoutContext<'_, '_, '_, '_, '_>, + ) -> Size { + let radio_size = context.get(&LineHeight).into_upx(context.gfx.scale()); // TODO create a component? Same as checkbox + Size::squared(radio_size) + } +}