diff --git a/examples/manual-tabs.rs b/examples/manual-tabs.rs new file mode 100644 index 0000000..2520fc3 --- /dev/null +++ b/examples/manual-tabs.rs @@ -0,0 +1,34 @@ +//! This example show show to use a stack of buttons and a switcher to achieve a +//! tab-like widget. + +use std::collections::HashMap; + +use gooey::value::{Dynamic, Switchable}; +use gooey::widget::MakeWidget; +use gooey::Run; + +#[derive(Debug, Eq, PartialEq, Clone, Hash)] +enum Tab { + First, + Second, + Missing, +} + +fn main() -> gooey::Result { + let mut tab_contents = HashMap::new(); + tab_contents.insert(Tab::First, "This is the first tab!".make_widget()); + tab_contents.insert(Tab::Second, "This is the second tab!".make_widget()); + + let selected_tab = Dynamic::new(Tab::First); + + let tabs = selected_tab + .new_select(Tab::First, "First") + .and(selected_tab.new_select(Tab::Second, "Second")) + .and(selected_tab.new_select(Tab::Missing, "Missing")) + .into_columns(); + + tabs.and(selected_tab.switch_between(tab_contents)) + .into_rows() + .fit_vertically() + .run() +} diff --git a/examples/select.rs b/examples/select.rs new file mode 100644 index 0000000..29d8b63 --- /dev/null +++ b/examples/select.rs @@ -0,0 +1,23 @@ +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_select(Choice::A, "A") + .and(option.new_select(Choice::B, "B")) + .and(option.new_select(Choice::C, "C")) + .into_rows() + .centered() + .run() +} diff --git a/src/value.rs b/src/value.rs index 8500fed..133f81e 100644 --- a/src/value.rs +++ b/src/value.rs @@ -1,7 +1,9 @@ //! Types for storing and interacting with values in Widgets. +use std::collections::HashMap; use std::fmt::{Debug, Display}; use std::future::Future; +use std::hash::{BuildHasher, Hash}; use std::ops::{Deref, DerefMut, Not}; use std::panic::UnwindSafe; use std::str::FromStr; @@ -12,13 +14,14 @@ use std::thread::ThreadId; use std::time::Duration; use ahash::AHashSet; +use kempt::{Map, Sort}; use crate::animation::{AnimationHandle, DynamicTransition, IntoAnimate, LinearInterpolate, Spawn}; use crate::context::sealed::WindowHandle; use crate::context::{self, WidgetContext}; use crate::utils::{run_in_bg, IgnorePoison, UnwindsafeCondvar, WithClone}; -use crate::widget::{MakeWidget, WidgetId, WidgetInstance}; -use crate::widgets::{Radio, Space, Switcher}; +use crate::widget::{Children, MakeWidget, WidgetId, WidgetInstance}; +use crate::widgets::{Radio, Select, Space, Switcher}; /// An instance of a value that provides APIs to observe and react to its /// contents. @@ -550,13 +553,28 @@ impl Dynamic { where Self: Clone, // Technically this trait bound isn't necessary, but it prevents trying - // to call into_radio on unsupported types. The MakeWidget/Widget + // to call new_radio on unsupported types. The MakeWidget/Widget // implementations require these bounds (and more). T: Clone + Eq, { Radio::new(widget_value, self.clone(), label) } + /// Returns a new [`Select`] that updates this dynamic to `widget_value` + /// when pressed. `label` is drawn next to the checkbox and is also + /// clickable to select the widget. + #[must_use] + pub fn new_select(&self, widget_value: T, label: impl MakeWidget) -> Select + where + Self: Clone, + // Technically this trait bound isn't necessary, but it prevents trying + // to call new_select on unsupported types. The MakeWidget/Widget + // implementations require these bounds (and more). + T: Clone + Eq, + { + Select::new(widget_value, self.clone(), label) + } + /// Validates the contents of this dynamic using the `check` function, /// returning a dynamic that contains the validation status. #[must_use] @@ -1287,6 +1305,56 @@ pub trait Switchable: IntoDynamic + Sized { { Switcher::mapping(self, map) } + + /// Returns a new [`Switcher`] whose contents switches between the values + /// contained in `map` using the value in `self` as the key. + fn switch_between(self, map: Collection) -> Switcher + where + Collection: GetWidget + Send + 'static, + T: Send + 'static, + { + Switcher::mapping(self, move |key, _| { + map.get(key) + .map_or_else(|| Space::clear().make_widget(), Clone::clone) + }) + } +} + +/// A collection of widgets that can be queried by `Key`. +pub trait GetWidget { + /// Returns the widget associated with `key`, if found. + fn get<'a>(&'a self, key: &Key) -> Option<&'a WidgetInstance>; +} + +impl GetWidget for HashMap +where + Key: Hash + Eq, + State: BuildHasher, +{ + fn get<'a>(&'a self, key: &Key) -> Option<&'a WidgetInstance> { + HashMap::get(self, key) + } +} + +impl GetWidget for Map +where + Key: Sort, +{ + fn get<'a>(&'a self, key: &Key) -> Option<&'a WidgetInstance> { + Map::get(self, key) + } +} + +impl GetWidget for Children { + fn get<'a>(&'a self, key: &usize) -> Option<&'a WidgetInstance> { + (**self).get(*key) + } +} + +impl GetWidget for Vec { + fn get<'a>(&'a self, key: &usize) -> Option<&'a WidgetInstance> { + (**self).get(*key) + } } impl Switchable for W where W: IntoDynamic {} diff --git a/src/widgets.rs b/src/widgets.rs index 0324e24..05f8b8c 100644 --- a/src/widgets.rs +++ b/src/widgets.rs @@ -16,6 +16,7 @@ pub mod progress; mod radio; mod resize; pub mod scroll; +pub mod select; pub mod slider; mod space; pub mod stack; @@ -41,6 +42,7 @@ pub use progress::ProgressBar; pub use radio::Radio; pub use resize::Resize; pub use scroll::Scroll; +pub use select::Select; pub use slider::Slider; pub use space::Space; pub use stack::Stack; diff --git a/src/widgets/radio.rs b/src/widgets/radio.rs index 4fe02ec..cf218f8 100644 --- a/src/widgets/radio.rs +++ b/src/widgets/radio.rs @@ -1,4 +1,4 @@ -//! A tri-state, labelable checkbox widget. +//! A labeled widget with a circular indicator representing a value. use std::fmt::Debug; use std::panic::UnwindSafe; @@ -14,12 +14,11 @@ 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 +/// A labeled widget with a circular indicator representing a value. pub struct Radio { /// The value this button represents. pub value: T, - /// The state (value) of the checkbox. + /// The state (value) of the radio. pub state: Dynamic, /// The button kind to use as the basis for this radio. Radios default to /// [`ButtonKind::Transparent`]. @@ -29,7 +28,8 @@ pub struct Radio { 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. + /// is drawn next to the radio indicator and is also clickable to select the + /// radio. pub fn new(value: T, state: impl IntoDynamic, label: impl MakeWidget) -> Self { Self { value, diff --git a/src/widgets/select.rs b/src/widgets/select.rs new file mode 100644 index 0000000..44b02eb --- /dev/null +++ b/src/widgets/select.rs @@ -0,0 +1,91 @@ +//! A selectable, labeled widget representing a value. +use std::fmt::Debug; +use std::panic::{RefUnwindSafe, UnwindSafe}; + +use kludgine::Color; + +use crate::styles::components::OutlineColor; +use crate::styles::{Component, DynamicComponent}; +use crate::value::{Dynamic, IntoDynamic, IntoValue, MapEach, Value}; +use crate::widget::{MakeWidget, WidgetInstance}; +use crate::widgets::button::{ButtonBackground, ButtonHoverBackground, ButtonKind}; + +/// A selectable, labeled widget representing a value. +pub struct Select { + /// The value this button represents. + pub value: T, + /// The state (value) of the select. + pub state: Dynamic, + /// The button kind to use as the basis for this select. Selects default to + /// [`ButtonKind::Transparent`]. + pub kind: Value, + label: WidgetInstance, +} + +impl Select { + /// Returns a new select that sets `state` to `value` when pressed. `label` + /// is drawn inside of the button. + 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 select, and + /// returns self. + /// + /// Selects default to [`ButtonKind::Transparent`]. + #[must_use] + pub fn kind(mut self, kind: impl IntoValue) -> Self { + self.kind = kind.into_value(); + self + } +} + +impl MakeWidget for Select +where + T: Clone + Debug + Eq + RefUnwindSafe + UnwindSafe + Send + Sync + 'static, +{ + fn make_widget(self) -> WidgetInstance { + let selected = self.state.map_each({ + let value = self.value.clone(); + move |state| state == &value + }); + let selected_color = DynamicComponent::new({ + let selected = selected.clone(); + move |context| { + if selected.get_tracking_refresh(context) { + Some(Component::Color(context.get(&SelectedColor))) + } else { + None + } + } + }); + let kind = (&selected, &self.kind.into_dynamic()).map_each(|(selected, default_kind)| { + if *selected { + ButtonKind::Solid + } else { + *default_kind + } + }); + self.label + .into_button() + .on_click(move |()| { + self.state.set(self.value.clone()); + }) + .kind(kind) + .with_dynamic(&ButtonBackground, selected_color.clone()) + .with_dynamic(&ButtonHoverBackground, selected_color) + .make_widget() + } +} + +define_components! { + Select { + /// The color of the selected [`Select`] widget. + SelectedColor(Color, "color", @OutlineColor) + } +} diff --git a/src/widgets/stack.rs b/src/widgets/stack.rs index 46a78ee..e7ce420 100644 --- a/src/widgets/stack.rs +++ b/src/widgets/stack.rs @@ -431,9 +431,7 @@ impl Layout { !needs_final_layout, )); self.layouts[index].size = measured; - if measured == 0 { - self.other = UPx::ZERO; - } else { + if measured > 0 { if fit_index < self.fit_to_content.len() - 1 || self.fit_to_content.len() != self.children.len() {