diff --git a/Cargo.lock b/Cargo.lock index d49972b..7fa337a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -105,7 +105,7 @@ dependencies = [ [[package]] name = "appit" version = "0.1.0" -source = "git+https://github.com/khonsulabs/appit#043bfe2c78524d6a06ed159289ea1cd7a62b0fec" +source = "git+https://github.com/khonsulabs/appit#5ed0d923ded6520950d14b3b869cbcac89452f5c" dependencies = [ "raw-window-handle 0.5.2", "winit", @@ -455,9 +455,9 @@ dependencies = [ [[package]] name = "etagere" -version = "0.2.9" +version = "0.2.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5bf70b9ea3a235a7432b4f481854815e2d4fb2fe824c1f5fb09b8985dd06b3e9" +checksum = "fcf22f748754352918e082e0039335ee92454a5d62bcaf69b5e8daf5907d9644" dependencies = [ "euclid", "svg_fmt", @@ -481,7 +481,7 @@ checksum = "dd2e7510819d6fbf51a5545c8f922716ecfb14df168a3242f7d33e0239efe6a1" [[package]] name = "figures" version = "0.1.0" -source = "git+https://github.com/khonsulabs/figures#f5b9ca5cf181b748897b269ad47d7a9f2d1f3eac" +source = "git+https://github.com/khonsulabs/figures#7b41393c44d4def606790e340c98450b603010b4" dependencies = [ "bytemuck", "euclid", @@ -880,7 +880,7 @@ checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" [[package]] name = "kludgine" version = "0.1.0" -source = "git+https://github.com/khonsulabs/kludgine#a88961b726101ef9bb46bdae4737308d2dcb12a0" +source = "git+https://github.com/khonsulabs/kludgine#a26299823498dccbbbb3c28abc820b660fcc1289" dependencies = [ "ahash", "alot", diff --git a/examples/login.rs b/examples/login.rs index 3075628..89d1281 100644 --- a/examples/login.rs +++ b/examples/login.rs @@ -46,6 +46,7 @@ fn main() -> gooey::Result { Lp::points(300)..Lp::points(600), Stack::rows(username_row.and(password_row).and(buttons)), ) + .scroll() .centered() .expand() .run() diff --git a/examples/scroll.rs b/examples/scroll.rs index 4875ca7..745ec1f 100644 --- a/examples/scroll.rs +++ b/examples/scroll.rs @@ -5,5 +5,6 @@ use gooey::Run; fn main() -> gooey::Result { Label::new(include_str!("../src/widgets/scroll.rs")) .scroll() + .expand() .run() } diff --git a/examples/theme.rs b/examples/theme.rs index 29bcbb1..39f28df 100644 --- a/examples/theme.rs +++ b/examples/theme.rs @@ -1,30 +1,128 @@ -use gooey::styles::components::TextColor; -use gooey::styles::{ColorTheme, FixedTheme, InverseTheme, SurfaceTheme, Theme, ThemePair}; +use std::str::FromStr; + +use gooey::animation::ZeroToOne; +use gooey::styles::components::{TextColor, WidgetBackground}; +use gooey::styles::{ColorSource, ColorTheme, FixedTheme, SurfaceTheme, Theme, ThemePair}; +use gooey::value::{Dynamic, MapEach}; use gooey::widget::MakeWidget; -use gooey::widgets::label::LabelBackground; -use gooey::widgets::{Label, Stack}; +use gooey::widgets::{Input, Label, Scroll, Slider, Stack, Themed}; +use gooey::window::ThemeMode; use gooey::Run; use kludgine::Color; +const PRIMARY_HUE: f32 = 240.; +const SECONDARY_HUE: f32 = 0.; +const TERTIARY_HUE: f32 = 330.; +const ERROR_HUE: f32 = 30.; + fn main() -> gooey::Result { - let default_theme = ThemePair::default(); - Stack::columns( - theme(default_theme.dark, "Dark") - .and(theme(default_theme.light, "Light")) + let (primary, primary_editor) = color_editor(PRIMARY_HUE, 0.8, "Primary"); + let (secondary, secondary_editor) = color_editor(SECONDARY_HUE, 0.3, "Secondary"); + let (tertiary, tertiary_editor) = color_editor(TERTIARY_HUE, 0.3, "Tertiary"); + let (error, error_editor) = color_editor(ERROR_HUE, 0.8, "Error"); + let (neutral, neutral_editor) = color_editor(PRIMARY_HUE, 0.001, "Neutral"); + let (neutral_variant, neutral_variant_editor) = + color_editor(PRIMARY_HUE, 0.001, "Neutral Variant"); + let (theme_mode, theme_switcher) = dark_mode_slider(); + + let default_theme = ( + &primary, + &secondary, + &tertiary, + &error, + &neutral, + &neutral_variant, + ) + .map_each( + |(primary, secondary, tertiary, error, neutral, neutral_variant)| { + ThemePair::from_sources( + *primary, + *secondary, + *tertiary, + *error, + *neutral, + *neutral_variant, + ) + }, + ); + + 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(theme(default_theme.map_each(|theme| theme.dark), "Dark")) + .and(theme(default_theme.map_each(|theme| theme.light), "Light")) .and(fixed_themes( - default_theme.primary_fixed, - default_theme.secondary_fixed, - default_theme.tertiary_fixed, + default_theme.map_each(|theme| theme.primary_fixed), + default_theme.map_each(|theme| theme.secondary_fixed), + default_theme.map_each(|theme| theme.tertiary_fixed), )), + ), ) .expand() + .into_window() + .with_theme_mode(theme_mode) .run() } +fn dark_mode_slider() -> (Dynamic, impl MakeWidget) { + let theme_mode = Dynamic::default(); + + ( + theme_mode.clone(), + Stack::rows(Label::new("Theme Mode").and(Slider::::from_value(theme_mode))), + ) +} + +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_hue: f32, + initial_saturation: impl Into, + label: &str, +) -> (Dynamic, impl MakeWidget) { + let (hue, hue_text) = create_paired_string(initial_hue); + let (saturation, saturation_text) = create_paired_string(initial_saturation.into()); + + let color = + (&hue, &saturation).map_each(|(hue, saturation)| ColorSource::new(*hue, *saturation)); + + ( + color, + Stack::rows( + Label::new(label) + .and(Slider::::new(hue, 0., 360.)) + .and(Input::new(hue_text)) + .and(Slider::::from_value(saturation)) + .and(Input::new(saturation_text)), + ), + ) +} + fn fixed_themes( - primary: FixedTheme, - secondary: FixedTheme, - tertiary: FixedTheme, + primary: Dynamic, + secondary: Dynamic, + tertiary: Dynamic, ) -> impl MakeWidget { Stack::rows( Label::new("Fixed") @@ -35,85 +133,118 @@ fn fixed_themes( .expand() } -fn fixed_theme(theme: FixedTheme, label: &str) -> impl MakeWidget { +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(theme.color, &format!("{label} Fixed"), theme.on_color) + swatch(color.clone(), &format!("{label} Fixed"), on_color.clone()) .and(swatch( - theme.dim_color, + theme.map_each(|theme| theme.dim_color), &format!("Dim {label}"), - theme.on_color, + on_color.clone(), )) .and(swatch( - theme.on_color, + on_color.clone(), &format!("On {label} Fixed"), - theme.color, + color.clone(), )) .and(swatch( - theme.on_color_variant, + theme.map_each(|theme| theme.on_color_variant), &format!("Variant On {label} Fixed"), - theme.color, + color, )), ) .expand() } -fn theme(theme: Theme, label: &str) -> impl MakeWidget { +fn theme(theme: Dynamic, label: &str) -> impl MakeWidget { Stack::rows( Label::new(label) .and( Stack::columns( - color_theme(theme.primary, "Primary") - .and(color_theme(theme.secondary, "Secondary")) - .and(color_theme(theme.tertiary, "Tertiary")) - .and(color_theme(theme.error, "Error")), + 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")), ) .expand(), ) - .and(surface_and_inverse_themes(theme.surface, theme.inverse)), + .and(surface_theme(theme.map_each(|theme| theme.surface))), ) .expand() } -fn surface_and_inverse_themes(theme: SurfaceTheme, inverse: InverseTheme) -> impl MakeWidget { +fn surface_theme(theme: Dynamic) -> impl MakeWidget { + let color = theme.map_each(|theme| theme.color); + let on_color = theme.map_each(|theme| theme.on_color); Stack::rows( Stack::columns( - swatch(theme.color, "Surface", theme.on_color) - .and(swatch(theme.dim_color, "Dim Surface", theme.on_color)) - .and(swatch(theme.bright_color, "Bright Surface", theme.on_color)), + swatch(color.clone(), "Surface", on_color.clone()) + .and(swatch( + theme.map_each(|theme| theme.dim_color), + "Dim Surface", + on_color.clone(), + )) + .and(swatch( + theme.map_each(|theme| theme.bright_color), + "Bright Surface", + on_color.clone(), + )), ) .expand() - .and(inverse_theme(inverse)) .and( Stack::columns( - swatch(theme.lowest_container, "Lowest Container", theme.on_color) - .and(swatch(theme.low_container, "Low Container", theme.on_color)) - .and(swatch(theme.container, "Container", theme.on_color)) - .and(swatch( - theme.high_container, - "High Container", - theme.on_color, - )) - .and(swatch( - theme.highest_container, - "Highest Container", - theme.on_color, - )), + swatch( + theme.map_each(|theme| theme.lowest_container), + "Lowest Container", + on_color.clone(), + ) + .and(swatch( + theme.map_each(|theme| theme.low_container), + "Low Container", + on_color.clone(), + )) + .and(swatch( + theme.map_each(|theme| theme.container), + "Container", + on_color.clone(), + )) + .and(swatch( + theme.map_each(|theme| theme.high_container), + "High Container", + on_color.clone(), + )) + .and(swatch( + theme.map_each(|theme| theme.highest_container), + "Highest Container", + on_color.clone(), + )), ) .expand(), ) .and( Stack::columns( - swatch(theme.on_color, "On Surface", theme.color) + swatch(on_color.clone(), "On Surface", color.clone()) .and(swatch( - theme.on_color_variant, + theme.map_each(|theme| theme.on_color_variant), "On Color Variant", - theme.color, + color.clone(), )) - .and(swatch(theme.outline, "Outline", theme.color)) .and(swatch( - theme.outline_variant, + theme.map_each(|theme| theme.outline), + "Outline", + color.clone(), + )) + .and(swatch( + theme.map_each(|theme| theme.outline_variant), "Outline Variant", - theme.color, + color, )), ) .expand(), @@ -122,42 +253,37 @@ fn surface_and_inverse_themes(theme: SurfaceTheme, inverse: InverseTheme) -> imp .expand() } -fn inverse_theme(theme: InverseTheme) -> impl MakeWidget { - Stack::columns( - swatch(theme.surface, "Inverse Surface", theme.on_surface) - .and(swatch( - theme.on_surface, - "On Inverse Surface", - theme.surface, - )) - .and(swatch(theme.primary, "Inverse Primary", theme.surface)), - ) - .expand() -} - -fn color_theme(theme: ColorTheme, label: &str) -> impl MakeWidget { +fn color_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); + let container = theme.map_each(|theme| theme.container); + let on_container = theme.map_each(|theme| theme.on_container); Stack::rows( - swatch(theme.color, label, theme.on_color) - .and(swatch(theme.on_color, &format!("On {label}"), theme.color)) + swatch(color.clone(), label, on_color.clone()) .and(swatch( - theme.container, - &format!("{label} Container"), - theme.on_container, + on_color.clone(), + &format!("On {label}"), + color.clone(), )) .and(swatch( - theme.on_container, + container.clone(), + &format!("{label} Container"), + on_container.clone(), + )) + .and(swatch( + on_container, &format!("On {label} Container"), - theme.container, + container, )), ) .expand() } -fn swatch(background: Color, label: &str, text: Color) -> impl MakeWidget { +fn swatch(background: Dynamic, label: &str, text: Dynamic) -> impl MakeWidget { Label::new(label) + .with(&TextColor, text) + .with(&WidgetBackground, background) .fit_horizontally() .fit_vertically() - .with(&TextColor, text) - .with(&LabelBackground, background) .expand() } diff --git a/src/animation.rs b/src/animation.rs index 3c56536..14d680e 100644 --- a/src/animation.rs +++ b/src/animation.rs @@ -39,15 +39,18 @@ pub mod easings; -use std::fmt::Debug; -use std::ops::{ControlFlow, Deref}; +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::thread; use std::time::{Duration, Instant}; use alot::{LotId, Lots}; +use intentional::Cast; use kempt::Set; +use kludgine::figures::Ranged; use kludgine::Color; use crate::animation::easings::Linear; @@ -672,6 +675,26 @@ impl LinearInterpolate for f64 { } } +impl LinearInterpolate for bool { + fn lerp(&self, target: &Self, percent: f32) -> Self { + if percent >= 0.5 { + *target + } else { + *self + } + } +} + +impl PercentBetween for bool { + fn percent_between(&self, min: &Self, max: &Self) -> ZeroToOne { + if *min == *max || *self == *min { + ZeroToOne::ZERO + } else { + ZeroToOne::ONE + } + } +} + #[test] fn integer_lerps() { #[track_caller] @@ -703,6 +726,56 @@ impl LinearInterpolate for Color { } } +/// Calculates the ratio of one value against a minimum and maximum. +pub trait PercentBetween { + /// Return the percentage that `self` is between `min` and `max`. + fn percent_between(&self, min: &Self, max: &Self) -> ZeroToOne; +} + +macro_rules! impl_percent_between { + ($type:ident, $float:ident) => { + impl PercentBetween for $type { + fn percent_between(&self, min: &Self, max: &Self) -> ZeroToOne { + let range = *max - *min; + ZeroToOne::from(*self as $float / range as $float) + } + } + }; +} + +impl_percent_between!(u8, f32); +impl_percent_between!(u16, f32); +impl_percent_between!(u32, f32); +impl_percent_between!(u64, f32); +impl_percent_between!(u128, f64); +impl_percent_between!(usize, f64); +impl_percent_between!(i8, f32); +impl_percent_between!(i16, f32); +impl_percent_between!(i32, f32); +impl_percent_between!(i64, f32); +impl_percent_between!(i128, f64); +impl_percent_between!(isize, f64); +impl_percent_between!(f32, f32); +impl_percent_between!(f64, f64); + +impl PercentBetween for Color { + fn percent_between(&self, min: &Self, max: &Self) -> ZeroToOne { + fn channel_percent( + value: Color, + min: Color, + max: Color, + func: impl Fn(Color) -> u8, + ) -> ZeroToOne { + 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) + } +} + /// 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`. @@ -727,6 +800,12 @@ impl ZeroToOne { Self(value.clamp(0., 1.)) } + /// Returns the difference between `self` and `other` as a positive number. + #[must_use] + pub fn difference_between(self, other: Self) -> Self { + Self((self.0 - other.0).abs()) + } + /// Returns the contained floating point value. #[must_use] pub fn into_f32(self) -> f32 { @@ -734,6 +813,32 @@ impl ZeroToOne { } } +impl Display for ZeroToOne { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + Display::fmt(&self.0, f) + } +} + +impl FromStr for ZeroToOne { + type Err = std::num::ParseFloatError; + + fn from_str(s: &str) -> Result { + s.parse().map(Self) + } +} + +impl From for ZeroToOne { + fn from(value: f32) -> Self { + Self::new(value) + } +} + +impl From for ZeroToOne { + fn from(value: f64) -> Self { + Self::new(value.cast()) + } +} + impl Default for ZeroToOne { fn default() -> Self { Self::ZERO @@ -787,6 +892,33 @@ impl LinearInterpolate for ZeroToOne { } } +impl PercentBetween for ZeroToOne { + fn percent_between(&self, min: &Self, max: &Self) -> ZeroToOne { + self.0.percent_between(&min.0, &max.0) + } +} + +impl Mul for ZeroToOne { + type Output = Self; + + fn mul(self, rhs: Self) -> Self::Output { + Self(self.0 * rhs.0) + } +} + +impl Div for ZeroToOne { + type Output = Self; + + fn div(self, rhs: Self) -> Self::Output { + Self(self.0 / rhs.0) + } +} + +impl Ranged for ZeroToOne { + const MAX: Self = Self::ONE; + const MIN: Self = Self::ZERO; +} + /// An easing function for customizing animations. #[derive(Debug, Clone)] pub enum EasingFunction { diff --git a/src/context.rs b/src/context.rs index a55c566..1086ae4 100644 --- a/src/context.rs +++ b/src/context.rs @@ -1,4 +1,5 @@ //! Types that provide access to the Gooey runtime. +use std::borrow::Cow; use std::ops::{Deref, DerefMut}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; @@ -6,19 +7,18 @@ use std::sync::Arc; use kludgine::app::winit::event::{ DeviceId, Ime, KeyEvent, MouseButton, MouseScrollDelta, TouchPhase, }; -use kludgine::app::winit::window; -use kludgine::figures::units::{Px, UPx}; -use kludgine::figures::{IntoSigned, Point, Rect, Size}; +use kludgine::figures::units::{Lp, Px, UPx}; +use kludgine::figures::{IntoSigned, Point, Rect, ScreenScale, Size}; use kludgine::shapes::{Shape, StrokeOptions}; -use kludgine::Kludgine; +use kludgine::{Color, Kludgine}; use crate::graphics::Graphics; -use crate::styles::components::{HighlightColor, VisualOrder}; +use crate::styles::components::{HighlightColor, VisualOrder, WidgetBackground}; use crate::styles::{ComponentDefaultvalue, ComponentDefinition, Styles, Theme, ThemePair}; -use crate::value::Dynamic; +use crate::value::{Dynamic, Value}; use crate::widget::{EventHandling, ManagedWidget, WidgetId, WidgetInstance, WidgetRef}; use crate::window::sealed::WindowCommand; -use crate::window::RunningWindow; +use crate::window::{RunningWindow, ThemeMode}; use crate::ConstraintLimit; /// A context to an event function. @@ -349,6 +349,9 @@ impl<'context, 'window> EventContext<'context, 'window> { /// /// 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). + self.pending_state.focus = self.next_focus_after(self.current_node.clone(), direction); } } @@ -447,6 +450,18 @@ 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, + { + let visible_rect = Rect::from(self.gfx.region().size - (Px(1), Px(1))); + let focus_ring = + Shape::stroked_rect(visible_rect, color, options.into_px(self.gfx.scale())); + self.gfx + .draw_shape(&focus_ring, Point::default(), None, None); + } + /// Renders the default focus ring for this widget. /// /// To ensure the correct color is used, include [`HighlightColor`] in the @@ -457,14 +472,8 @@ impl<'context, 'window, 'clip, 'gfx, 'pass> GraphicsContext<'context, 'window, ' return; } - let visible_rect = Rect::from(self.gfx.region().size - (Px(1), Px(1))); - let focus_ring = Shape::stroked_rect( - visible_rect, - styles.get(&HighlightColor, self), - StrokeOptions::default(), - ); - self.gfx - .draw_shape(&focus_ring, Point::default(), None, None); + let color = styles.get(&HighlightColor, self); + self.stroke_outline::(color, StrokeOptions::default()); } /// Renders the default focus ring for this widget. @@ -485,6 +494,9 @@ impl<'context, 'window, 'clip, 'gfx, 'pass> GraphicsContext<'context, 'window, ' "redraw called without set_widget_layout" ); + let background = self.query_style(&WidgetBackground); + self.gfx.fill(background); + self.current_node .tree .note_widget_rendered(self.current_node.id()); @@ -662,8 +674,9 @@ pub struct WidgetContext<'context, 'window> { current_node: ManagedWidget, redraw_status: &'context RedrawStatus, window: &'context mut RunningWindow<'window>, - theme: &'context ThemePair, + theme: Cow<'context, ThemePair>, pending_state: PendingState<'context>, + theme_mode: ThemeMode, } impl<'context, 'window> WidgetContext<'context, 'window> { @@ -672,6 +685,7 @@ impl<'context, 'window> WidgetContext<'context, 'window> { redraw_status: &'context RedrawStatus, theme: &'context ThemePair, window: &'context mut RunningWindow<'window>, + theme_mode: ThemeMode, ) -> Self { Self { pending_state: PendingState::Owned(PendingWidgetState { @@ -686,7 +700,8 @@ impl<'context, 'window> WidgetContext<'context, 'window> { }), current_node, redraw_status, - theme, + theme: Cow::Borrowed(theme), + theme_mode, window, } } @@ -697,8 +712,9 @@ impl<'context, 'window> WidgetContext<'context, 'window> { current_node: self.current_node.clone(), redraw_status: self.redraw_status, window: &mut *self.window, - theme: self.theme, + theme: Cow::Borrowed(self.theme.as_ref()), pending_state: self.pending_state.borrowed(), + theme_mode: self.theme_mode, } } @@ -711,12 +727,26 @@ impl<'context, 'window> WidgetContext<'context, 'window> { Widget: ManageWidget, Widget::Managed: MapManagedWidget>, { - widget.manage(self).map(|current_node| WidgetContext { - current_node, - redraw_status: self.redraw_status, - window: &mut *self.window, - theme: self.theme, - pending_state: self.pending_state.borrowed(), + widget.manage(self).map(|current_node| { + let (theme, theme_mode) = current_node.overidden_theme(); + let theme = if let Some(theme) = theme { + Cow::Owned(theme.get_tracked(self)) + } else { + Cow::Borrowed(self.theme.as_ref()) + }; + let theme_mode = if let Some(mode) = theme_mode { + mode.get_tracked(self) + } else { + self.theme_mode + }; + WidgetContext { + current_node, + redraw_status: self.redraw_status, + window: &mut *self.window, + theme, + pending_state: self.pending_state.borrowed(), + theme_mode, + } }) } @@ -860,10 +890,24 @@ impl<'context, 'window> WidgetContext<'context, 'window> { /// /// Style queries for children will return any values matching this /// collection. - pub fn attach_styles(&self, styles: Styles) { + pub fn attach_styles(&self, styles: Value) { self.current_node.attach_styles(styles); } + /// Attaches `theme` to the widget hierarchy for this widget. + /// + /// All children nodes will access this theme in their contexts. + pub fn attach_theme(&self, theme: Value) { + self.current_node.attach_theme(theme); + } + + /// Attaches `theme_mode` to the widget hierarchy for this widget. + /// + /// All children nodes will use this theme mode. + pub fn attach_theme_mode(&self, theme_mode: Value) { + self.current_node.attach_theme_mode(theme_mode); + } + /// Queries the widget hierarchy for matching style components. /// /// This function traverses up the widget hierarchy looking for the @@ -878,7 +922,7 @@ impl<'context, 'window> WidgetContext<'context, 'window> { pub fn query_styles(&self, query: &[&dyn ComponentDefaultvalue]) -> Styles { self.current_node .tree - .query_styles(&self.current_node, query) + .query_styles(&self.current_node, query, self) } /// Queries the widget hierarchy for a single style component. @@ -919,15 +963,24 @@ impl<'context, 'window> WidgetContext<'context, 'window> { /// Returns the theme pair for the window. #[must_use] pub fn theme_pair(&self) -> &ThemePair { - self.theme + self.theme.as_ref() } /// Returns the current theme in either light or dark mode. #[must_use] pub fn theme(&self) -> &Theme { - match self.window.theme() { - window::Theme::Light => &self.theme.light, - window::Theme::Dark => &self.theme.dark, + match self.theme_mode { + ThemeMode::Light => &self.theme.light, + ThemeMode::Dark => &self.theme.dark, + } + } + + /// Returns the opposite theme of [`Self::theme()`]. + #[must_use] + pub fn inverse_theme(&self) -> &Theme { + match self.theme_mode { + ThemeMode::Light => &self.theme.dark, + ThemeMode::Dark => &self.theme.light, } } } diff --git a/src/lib.rs b/src/lib.rs index b4dea87..7d0ef5e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -48,10 +48,10 @@ impl ConstraintLimit { } /// Converts `measured` to unsigned pixels, and adjusts it according to the - /// contraint's intentions. + /// constraint's intentions. /// /// If this constraint is of a known size, it will return the maximum of the - /// measured size and the contraint. If it is of an unknown size, it will + /// measured size and the constraint. If it is of an unknown size, it will /// return the measured size. pub fn fit_measured(self, measured: Unit, scale: Fraction) -> UPx where diff --git a/src/styles.rs b/src/styles.rs index 1e7dd58..89327ba 100644 --- a/src/styles.rs +++ b/src/styles.rs @@ -459,7 +459,7 @@ where fn from(value: RangeInclusive) -> Self { Self { start: Bound::Included(value.start().clone().into()), - end: Bound::Excluded(value.end().clone().into()), + end: Bound::Included(value.end().clone().into()), } } } @@ -572,9 +572,15 @@ where pub struct Group(Name); impl Group { + /// Returns a new group with `name`. + #[must_use] + pub fn new(name: impl Into>) -> Self { + Self(Name::new(name)) + } + /// Returns a new instance using the group name of `T`. #[must_use] - pub fn new() -> Self + pub fn from_group() -> Self where T: ComponentGroup, { @@ -626,7 +632,7 @@ impl ComponentName { /// Returns a new instance using `G` and `name`. pub fn named(name: impl Into) -> Self { - Self::new(Group::new::(), name) + Self::new(Group::from_group::(), name) } } @@ -921,9 +927,6 @@ pub struct Theme { /// The theme to color surfaces. pub surface: SurfaceTheme, - - /// A theme of inverse colors to provide high contrast to other elements. - pub inverse: InverseTheme, } impl Theme { @@ -943,7 +946,6 @@ impl Theme { tertiary: ColorTheme::light_from_source(tertiary), error: ColorTheme::light_from_source(error), surface: SurfaceTheme::light_from_sources(neutral, neutral_variant), - inverse: InverseTheme::light_from_sources(primary, neutral), } } @@ -963,7 +965,6 @@ impl Theme { tertiary: ColorTheme::dark_from_source(tertiary), error: ColorTheme::dark_from_source(error), surface: SurfaceTheme::dark_from_sources(neutral, neutral_variant), - inverse: InverseTheme::dark_from_sources(primary, neutral), } } } @@ -1106,40 +1107,6 @@ impl FixedTheme { } } -/// An inverse color theme for displaying highly contrasted elements. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub struct InverseTheme { - /// An inverse surface color. - pub surface: Color, - /// The default color for content atop an inverted surface. - pub on_surface: Color, - /// The inverted primary color. - pub primary: Color, - // TODO why not inverse for the other colorthemes? -} - -impl InverseTheme { - /// Returns the light-mode, inverse theme for given sources. - #[must_use] - pub fn light_from_sources(primary: ColorSource, surface: ColorSource) -> Self { - Self { - surface: surface.color(30), - on_surface: surface.color(90), - primary: primary.color(80), - } - } - - /// Returns the dark-mode, inverse theme for given sources. - #[must_use] - pub fn dark_from_sources(primary: ColorSource, surface: ColorSource) -> Self { - Self { - surface: surface.color(90), - on_surface: surface.color(10), - primary: primary.color(40), - } - } -} - /// A source for [`Color`]s. /// /// This type is a combination of an [`OklabHue`] and a saturation ranging from @@ -1170,10 +1137,10 @@ impl ColorSource { /// Returns a new source with the given hue (in degrees) and saturation (0.0 /// - 1.0). #[must_use] - pub fn new(hue: f32, saturation: f32) -> Self { + pub fn new(hue: impl Into, saturation: impl Into) -> Self { Self { - hue: OklabHue::new(hue), - saturation: ZeroToOne::new(saturation), + hue: hue.into(), + saturation: saturation.into(), } } @@ -1184,6 +1151,32 @@ impl ColorSource { Okhsl::new(self.hue, *self.saturation, *lightness.into_lightness()).into_color(); Color::new_f32(rgb.red, rgb.blue, rgb.green, 1.0) } + + /// Calculates an approximate ratio between 0.0 and 1.0 of how contrasting + /// these colors are, with perfect constrast being two clors that are + /// opposite of each other on the hue circle and one fully desaturated and + /// the other fully saturated. + #[must_use] + pub fn contrast_between(self, other: Self) -> ZeroToOne { + let saturation_delta = self.saturation.difference_between(other.saturation); + let self_hue = self.hue.into_positive_degrees(); + let other_hue = other.hue.into_positive_degrees(); + // Calculate the shortest distance between the hues, taking into account + // that 0 and 359 are one degree apart. + let hue_delta = ZeroToOne::new( + if self_hue < other_hue { + let hue_delta_a = other_hue - self_hue; + let hue_delta_b = self_hue + 360. - other_hue; + hue_delta_a.min(hue_delta_b) + } else { + let hue_delta_a = self_hue - other_hue; + let hue_delta_b = other_hue + 360. - self_hue; + hue_delta_a.min(hue_delta_b) + } / 180., + ); + + saturation_delta * hue_delta + } } /// A value that can represent the lightness of a color. @@ -1231,29 +1224,31 @@ pub trait ColorExt: Copy { self.into_source_and_lightness().1 } + /// Returns the contrast between this color and the components provided. + /// + /// To achieve a contrast of 1.0: + /// + /// - `self`'s hue and `check_source.hue` must be 180 degrees apart. + /// - `self`'s saturation and `check_source.saturation` must be different by + /// 1.0. + /// - `self`'s lightness and `check_lightness` must be different by 1.0. + /// - `self`'s alpha and `check_alpha` must be different by 1.0. + /// + /// The algorithm currently used is purposely left undocumented as it will + /// likely change. It should be a reasonable heuristic until someone smarter + /// than @ecton comes along. + fn contrast_between( + self, + check_source: ColorSource, + check_lightness: ZeroToOne, + check_alpha: ZeroToOne, + ) -> ZeroToOne; + /// Returns the color in `others` that contrasts the most from `self`. #[must_use] fn most_contrasting(self, others: &[Self]) -> Self where - Self: Copy, - { - // TODO this currently only checks lightness. We should probably factor - // in hue/saturation changes too. - let check = self.lightness(); - - let mut others = others.iter().copied(); - let mut most_contrasting = others.next().expect("at least one comparison"); - let mut most_contrast_amount = (*most_contrasting.lightness() - *check).abs(); - for other in others { - let contrast_amount = (*other.lightness() - *check).abs(); - if contrast_amount > most_contrast_amount { - most_contrasting = other; - most_contrast_amount = contrast_amount; - } - } - - most_contrasting - } + Self: Copy; } impl ColorExt for Color { @@ -1268,4 +1263,44 @@ impl ColorExt for Color { ZeroToOne::new(hsl.lightness * self.alpha_f32()), ) } + + fn contrast_between( + self, + check_source: ColorSource, + check_lightness: ZeroToOne, + check_alpha: ZeroToOne, + ) -> ZeroToOne { + let (other_source, other_lightness) = self.into_source_and_lightness(); + let lightness_delta = other_lightness.difference_between(check_lightness); + + 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); + + lightness_delta * source_change * alpha_delta + } + + fn most_contrasting(self, others: &[Self]) -> Self + where + Self: Copy, + { + let (check_source, check_lightness) = self.into_source_and_lightness(); + let check_alpha = ZeroToOne::new(self.alpha_f32()); + + let mut others = others.iter().copied(); + let mut most_contrasting = others.next().expect("at least one comparison"); + let mut most_contrast_amount = + most_contrasting.contrast_between(check_source, check_lightness, check_alpha); + for other in others { + let contrast_amount = + other.contrast_between(check_source, check_lightness, check_alpha); + if contrast_amount > most_contrast_amount { + most_contrasting = other; + most_contrast_amount = contrast_amount; + } + } + + most_contrasting + } } diff --git a/src/styles/components.rs b/src/styles/components.rs index 7ce062d..b077d91 100644 --- a/src/styles/components.rs +++ b/src/styles/components.rs @@ -422,3 +422,57 @@ impl FocusableWidgets { matches!(self, Self::OnlyTextual) } } + +/// A [`Color`] to be used as a highlight color. +#[derive(Clone, Copy, Eq, PartialEq, Debug)] +pub struct WidgetBackground; + +impl NamedComponent for WidgetBackground { + fn name(&self) -> Cow<'_, ComponentName> { + Cow::Owned(ComponentName::named::("widget_background_color")) + } +} + +impl ComponentDefinition for WidgetBackground { + type ComponentType = Color; + + fn default_value(&self, _context: &WidgetContext<'_, '_>) -> Color { + Color::CLEAR_WHITE + } +} + +/// A [`Color`] to be used as an outline color. +#[derive(Clone, Copy, Eq, PartialEq, Debug)] +pub struct OutlineColor; + +impl NamedComponent for OutlineColor { + fn name(&self) -> Cow<'_, ComponentName> { + Cow::Owned(ComponentName::named::("outline_color")) + } +} + +impl ComponentDefinition for OutlineColor { + type ComponentType = Color; + + fn default_value(&self, context: &WidgetContext<'_, '_>) -> Color { + context.theme().surface.outline + } +} + +/// A [`Color`] to be used as an outline color. +#[derive(Clone, Copy, Eq, PartialEq, Debug)] +pub struct DisabledOutlineColor; + +impl NamedComponent for DisabledOutlineColor { + fn name(&self) -> Cow<'_, ComponentName> { + Cow::Owned(ComponentName::named::("disabled_outline_color")) + } +} + +impl ComponentDefinition for DisabledOutlineColor { + type ComponentType = Color; + + fn default_value(&self, context: &WidgetContext<'_, '_>) -> Color { + context.theme().surface.outline_variant + } +} diff --git a/src/tree.rs b/src/tree.rs index a7e8d22..aca381c 100644 --- a/src/tree.rs +++ b/src/tree.rs @@ -7,8 +7,10 @@ use kludgine::figures::{Point, Rect}; use crate::context::WidgetContext; use crate::styles::components::VisualOrder; -use crate::styles::{ComponentDefaultvalue, ComponentDefinition, ComponentType, Styles}; +use crate::styles::{ComponentDefaultvalue, ComponentDefinition, ComponentType, Styles, ThemePair}; +use crate::value::Value; use crate::widget::{ManagedWidget, WidgetId, WidgetInstance}; +use crate::window::ThemeMode; #[derive(Clone, Default)] pub struct Tree { @@ -31,6 +33,8 @@ impl Tree { parent: parent.map(ManagedWidget::id), layout: None, styles: None, + theme: None, + theme_mode: None, }, ); if widget.is_default() { @@ -277,20 +281,47 @@ impl Tree { data.nodes.get(&id).expect("missing widget").parent } - pub(crate) fn attach_styles(&self, id: WidgetId, styles: Styles) { + pub(crate) fn attach_styles(&self, id: WidgetId, styles: Value) { let mut data = self.data.lock().map_or_else(PoisonError::into_inner, |g| g); data.nodes.get_mut(&id).expect("missing widget").styles = Some(styles); } + pub(crate) fn attach_theme(&self, id: WidgetId, theme: Value) { + let mut data = self.data.lock().map_or_else(PoisonError::into_inner, |g| g); + data.nodes.get_mut(&id).expect("missing widget").theme = Some(theme); + } + + pub(crate) fn attach_theme_mode(&self, id: WidgetId, theme: Value) { + let mut data = self.data.lock().map_or_else(PoisonError::into_inner, |g| g); + data.nodes.get_mut(&id).expect("missing widget").theme_mode = Some(theme); + } + + pub(crate) fn overriden_theme( + &self, + id: WidgetId, + ) -> (Option>, Option>) { + let data = self.data.lock().map_or_else(PoisonError::into_inner, |g| g); + + ( + data.nodes.get(&id).expect("missing widget").theme.clone(), + data.nodes + .get(&id) + .expect("missing widget") + .theme_mode + .clone(), + ) + } + pub fn query_styles( &self, perspective: &ManagedWidget, query: &[&dyn ComponentDefaultvalue], + context: &WidgetContext<'_, '_>, ) -> Styles { self.data .lock() .map_or_else(PoisonError::into_inner, |g| g) - .query_styles(perspective.id(), query) + .query_styles(perspective.id(), query, context) } pub fn query_style( @@ -391,19 +422,22 @@ impl TreeData { &self, mut perspective: WidgetId, query: &[&dyn ComponentDefaultvalue], + context: &WidgetContext<'_, '_>, ) -> Styles { let mut query = query.iter().map(|n| n.name()).collect::>(); let mut resolved = Styles::new(); while !query.is_empty() { let node = &self.nodes[&perspective]; if let Some(styles) = &node.styles { - query.retain(|name| { - if let Some(component) = styles.get_named(name) { - resolved.insert(name, component.clone()); - false - } else { - true - } + styles.map_tracked(context, |styles| { + query.retain(|name| { + if let Some(component) = styles.get_named(name) { + resolved.insert(name, component.clone()); + false + } else { + true + } + }); }); } let Some(parent) = node.parent else { break }; @@ -422,13 +456,21 @@ impl TreeData { loop { let node = &self.nodes[&perspective]; if let Some(styles) = &node.styles { - if let Some(component) = styles.get_named(&name) { - let Ok(value) = ::try_from_component(component.get()) - else { - break; - }; - component.redraw_when_changed(context); - return value; + match styles.map_tracked(context, |styles| { + if let Some(component) = styles.get_named(&name) { + let Ok(value) = + ::try_from_component(component.get()) + else { + return Err(()); + }; + component.redraw_when_changed(context); + return Ok(Some(value)); + } + Ok(None) + }) { + Ok(Some(value)) => return value, + Ok(None) => {} + Err(()) => break, } } let Some(parent) = node.parent else { break }; @@ -443,5 +485,7 @@ pub struct Node { pub children: Vec, pub parent: Option, pub layout: Option>, - pub styles: Option, + pub styles: Option>, + pub theme: Option>, + pub theme_mode: Option>, } diff --git a/src/value.rs b/src/value.rs index 4434039..4d6ed51 100644 --- a/src/value.rs +++ b/src/value.rs @@ -1,11 +1,15 @@ //! Types for storing and interacting with values in Widgets. -use std::fmt::Debug; +use std::cell::Cell; +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}; +use std::sync::{Arc, Condvar, Mutex, MutexGuard, PoisonError, TryLockError}; use std::task::{Poll, Waker}; +use std::thread::ThreadId; + +use intentional::Assert; use crate::animation::{DynamicTransition, LinearInterpolate}; use crate::context::{WidgetContext, WindowHandle}; @@ -30,21 +34,32 @@ impl Dynamic { readers: 0, wakers: Vec::new(), }), + during_callback_state: Mutex::default(), sync: AssertUnwindSafe(Condvar::new()), })) } /// Maps the contents with read-only access. + /// + /// # Panics + /// + /// This function panics if this value is already locked by the current + /// thread. pub fn map_ref(&self, map: impl FnOnce(&T) -> R) -> R { - let state = self.state(); + let state = self.state().expect("deadlocked"); map(&state.wrapped.value) } /// Maps the contents with exclusive access. Before returning from this /// function, all observers will be notified that the contents have been /// updated. + /// + /// # Panics + /// + /// This function panics if this value is already locked by the current + /// thread. pub fn map_mut(&self, map: impl FnOnce(&mut T) -> R) -> R { - self.0.map_mut(|value, _| map(value)) + self.0.map_mut(|value, _| map(value)).expect("deadlocked") } /// Returns a new dynamic that is updated using `U::from(T.clone())` each @@ -99,6 +114,19 @@ impl Dynamic { self.0.map_each(move |gen| map(&gen.value)) } + /// Creates a new dynamic value that contains the result of invoking `map` + /// each time this value is changed. + /// + /// This version of `map_each` uses [`Dynamic::try_update`] to prevent + /// deadlocks and debounce dependent values. + pub fn map_each_unique(&self, mut map: F) -> Dynamic + where + F: for<'a> FnMut(&'a T) -> R + Send + 'static, + R: Send + PartialEq + 'static, + { + self.0.map_each_unique(move |gen| map(&gen.value)) + } + /// A helper function that invokes `with_clone` with a clone of self. This /// code may produce slightly more readable code. /// @@ -131,16 +159,43 @@ impl Dynamic { } /// Returns a clone of the currently contained value. + /// + /// # Panics + /// + /// This function panics if this value is already locked by the current + /// thread. #[must_use] pub fn get(&self) -> T where T: Clone, { - self.0.get().value + self.0.get().expect("deadlocked").value + } + + /// 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_tracked(&self, context: &WidgetContext<'_, '_>) -> T + where + T: Clone, + { + context.redraw_when_changed(self); + self.get() } /// Returns the currently stored value, replacing the current contents with /// `T::default()`. + /// + /// # Panics + /// + /// This function panics if this value is already locked by the current + /// thread. #[must_use] pub fn take(&self) -> T where @@ -151,6 +206,11 @@ impl Dynamic { /// Checks if the currently stored value is different than `T::default()`, /// and if so, returns `Some(self.take())`. + /// + /// # Panics + /// + /// This function panics if this value is already locked by the current + /// thread. #[must_use] pub fn take_if_not_default(&self) -> Option where @@ -168,44 +228,99 @@ impl Dynamic { /// Replaces the contents with `new_value`, returning the previous contents. /// Before returning from this function, all observers will be notified that /// the contents have been updated. + /// + /// # Panics + /// + /// This function panics if this value is already locked by the current + /// thread. #[must_use] pub fn replace(&self, new_value: T) -> T { self.0 .map_mut(|value, _| std::mem::replace(value, new_value)) + .expect("deadlocked") } /// Stores `new_value` in this dynamic. Before returning from this function, /// all observers will be notified that the contents have been updated. + /// + /// # Panics + /// + /// This function panics if this value is already locked by the current + /// thread. pub fn set(&self, new_value: T) { let _old = self.replace(new_value); } /// Updates this dynamic with `new_value`, but only if `new_value` is not /// equal to the currently stored value. - pub fn update(&self, new_value: T) + /// + /// Returns true if the value was updated. + /// + /// # Panics + /// + /// This function panics if this value is already locked by the current + /// thread. + pub fn update(&self, new_value: T) -> bool where T: PartialEq, { - self.0.map_mut(|value, changed| { - if *value == new_value { - *changed = false; - } else { - *value = new_value; - } - }); + self.0 + .map_mut(|value, changed| { + if *value == new_value { + *changed = false; + false + } else { + *value = new_value; + true + } + }) + .expect("deadlocked") + } + + /// Attempt to store `new_value` in `self`. If the value cannot be stored + /// due to a deadlock, it is returned as an error. + /// + /// Returns true if the value was updated. + pub fn try_update(&self, new_value: T) -> Result + where + T: PartialEq, + { + let cell = Cell::new(Some(new_value)); + self.0 + .map_mut(|value, changed| { + let new_value = cell.take().assert("only one callback will be invoked"); + if *value == new_value { + *changed = false; + false + } else { + *value = new_value; + true + } + }) + .map_err(|_| cell.take().assert("only one callback will be invoked")) } /// Returns a new reference-based reader for this dynamic value. + /// + /// # Panics + /// + /// This function panics if this value is already locked by the current + /// thread. #[must_use] pub fn create_reader(&self) -> DynamicReader { - self.state().readers += 1; + self.state().expect("deadlocked").readers += 1; DynamicReader { source: self.0.clone(), - read_generation: self.0.state().wrapped.generation, + read_generation: self.0.state().expect("deadlocked").wrapped.generation, } } /// Converts this [`Dynamic`] into a reader. + /// + /// # Panics + /// + /// This function panics if this value is already locked by the current + /// thread. #[must_use] pub fn into_reader(self) -> DynamicReader { self.create_reader() @@ -215,22 +330,32 @@ impl Dynamic { /// /// This call will block until all other guards for this dynamic have been /// dropped. + /// + /// # Panics + /// + /// This function panics if this value is already locked by the current + /// thread. #[must_use] pub fn lock(&self) -> DynamicGuard<'_, T> { DynamicGuard { - guard: self.0.state(), + guard: self.0.state().expect("deadlocked"), accessed_mut: false, } } - fn state(&self) -> MutexGuard<'_, State> { + fn state(&self) -> Result, DeadlockError> { self.0.state() } /// Returns the current generation of the value. + /// + /// # Panics + /// + /// This function panics if this value is already locked by the current + /// thread. #[must_use] pub fn generation(&self) -> Generation { - self.state().wrapped.generation + self.state().expect("deadlocked").wrapped.generation } /// Returns a pending transition for this value to `new_value`. @@ -262,7 +387,7 @@ impl Clone for Dynamic { impl Drop for Dynamic { fn drop(&mut self) { - let state = self.state(); + let state = self.state().expect("deadlocked"); if state.readers == 0 { drop(state); self.0.sync.notify_all(); @@ -276,9 +401,47 @@ impl From> for DynamicReader { } } +#[derive(Debug)] +struct DynamicMutexGuard<'a, T> { + dynamic: &'a DynamicData, + guard: MutexGuard<'a, State>, +} + +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); + *during_state = None; + drop(during_state); + self.dynamic.sync.notify_all(); + } +} + +impl<'a, T> Deref for DynamicMutexGuard<'a, T> { + type Target = State; + + fn deref(&self) -> &Self::Target { + &self.guard + } +} +impl<'a, T> DerefMut for DynamicMutexGuard<'a, T> { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.guard + } +} + +#[derive(Debug)] +struct LockState { + locked_thread: ThreadId, +} + #[derive(Debug)] struct DynamicData { state: Mutex>, + during_callback_state: Mutex>, // The AssertUnwindSafe is only needed on Mac. For some reason on // Mac OS, Condvar isn't RefUnwindSafe. @@ -286,27 +449,56 @@ struct DynamicData { } impl DynamicData { - fn state(&self) -> MutexGuard<'_, State> { - self.state + fn state(&self) -> Result, DeadlockError> { + let mut during_sync = self + .during_callback_state .lock() - .map_or_else(PoisonError::into_inner, |g| g) + .map_or_else(PoisonError::into_inner, |g| g); + + let current_thread_id = std::thread::current().id(); + let guard = loop { + match self.state.try_lock() { + Ok(g) => break g, + Err(TryLockError::Poisoned(poision)) => break poision.into_inner(), + Err(TryLockError::WouldBlock) => loop { + match &*during_sync { + Some(state) if state.locked_thread == current_thread_id => { + return Err(DeadlockError) + } + Some(_) => { + during_sync = self + .sync + .wait(during_sync) + .map_or_else(PoisonError::into_inner, |g| g); + } + None => break, + } + }, + } + }; + *during_sync = Some(LockState { + locked_thread: current_thread_id, + }); + Ok(DynamicMutexGuard { + dynamic: self, + guard, + }) } pub fn redraw_when_changed(&self, window: WindowHandle) { - let mut state = self.state(); + let mut state = self.state().expect("deadlocked"); state.windows.push(window); } - #[must_use] - pub fn get(&self) -> GenerationalValue + pub fn get(&self) -> Result, DeadlockError> where T: Clone, { - self.state().wrapped.clone() + self.state().map(|state| state.wrapped.clone()) } - pub fn map_mut(&self, map: impl FnOnce(&mut T, &mut bool) -> R) -> R { - let mut state = self.state(); + pub fn map_mut(&self, map: impl FnOnce(&mut T, &mut bool) -> R) -> Result { + let mut state = self.state()?; let old = { let state = &mut *state; let mut changed = true; @@ -321,14 +513,14 @@ impl DynamicData { self.sync.notify_all(); - old + Ok(old) } pub fn for_each(&self, map: F) where F: for<'a> FnMut(&'a GenerationalValue) + Send + 'static, { - let mut state = self.state(); + let mut state = self.state().expect("deadlocked"); state.callbacks.push(Box::new(map)); } @@ -337,7 +529,7 @@ impl DynamicData { F: for<'a> FnMut(&'a GenerationalValue) -> R + Send + 'static, R: Send + 'static, { - let mut state = self.state(); + let mut state = self.state().expect("deadlocked"); let initial_value = map(&state.wrapped); let mapped_value = Dynamic::new(initial_value); let returned = mapped_value.clone(); @@ -349,6 +541,39 @@ impl DynamicData { returned } + + pub fn map_each_unique(&self, mut map: F) -> Dynamic + where + F: for<'a> FnMut(&'a GenerationalValue) -> R + Send + 'static, + R: PartialEq + Send + 'static, + { + let mut state = self.state().expect("deadlocked"); + let initial_value = map(&state.wrapped); + let mapped_value = Dynamic::new(initial_value); + let returned = mapped_value.clone(); + state + .callbacks + .push(Box::new(move |updated: &GenerationalValue| { + let _deadlock = mapped_value.try_update(map(updated)); + })); + + returned + } +} + +/// A deadlock occurred accessing a [`Dynamic`]. +/// +/// Currently Gooey is only able to detect deadlocks where a single thread tries +/// to lock the same [`Dynamic`] multiple times. +#[derive(Debug)] +pub struct DeadlockError; + +impl std::error::Error for DeadlockError {} + +impl Display for DeadlockError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("a deadlock was detected") + } } struct State { @@ -412,7 +637,7 @@ struct GenerationalValue { /// notified of a change when this guard is dropped. #[derive(Debug)] pub struct DynamicGuard<'a, T> { - guard: MutexGuard<'a, State>, + guard: DynamicMutexGuard<'a, T>, accessed_mut: bool, } @@ -450,28 +675,43 @@ impl DynamicReader { /// Maps the contents of the dynamic value and returns the result. /// /// This function marks the currently stored value as being read. + /// + /// # Panics + /// + /// This function panics if this value is already locked by the current + /// thread. pub fn map_ref(&mut self, map: impl FnOnce(&T) -> R) -> R { - let state = self.source.state(); + let state = self.source.state().expect("deadlocked"); self.read_generation = state.wrapped.generation; map(&state.wrapped.value) } /// Returns true if the dynamic has been modified since the last time the /// value was accessed through this reader. + /// + /// # Panics + /// + /// This function panics if this value is already locked by the current + /// thread. #[must_use] pub fn has_updated(&self) -> bool { - self.source.state().wrapped.generation != self.read_generation + self.source.state().expect("deadlocked").wrapped.generation != self.read_generation } /// Returns a clone of the currently contained value. /// /// This function marks the currently stored value as being read. + /// + /// # Panics + /// + /// This function panics if this value is already locked by the current + /// thread. #[must_use] pub fn get(&mut self) -> T where T: Clone, { - let GenerationalValue { value, generation } = self.source.get(); + let GenerationalValue { value, generation } = self.source.get().expect("deadlocked"); self.read_generation = generation; value } @@ -480,19 +720,42 @@ impl DynamicReader { /// there are no remaining writers for the value. /// /// Returns true if a newly updated value was discovered. + /// + /// # Panics + /// + /// This function panics if this value is already locked by the current + /// thread. pub fn block_until_updated(&mut self) -> bool { - let mut state = self.source.state(); + let mut deadlock_state = self + .source + .during_callback_state + .lock() + .map_or_else(PoisonError::into_inner, |g| g); + assert!( + deadlock_state + .as_ref() + .map_or(true, |state| state.locked_thread + != std::thread::current().id()), + "deadlocked" + ); loop { + let state = self + .source + .state + .lock() + .map_or_else(PoisonError::into_inner, |g| g); if state.wrapped.generation != self.read_generation { return true; } else if state.readers == Arc::strong_count(&self.source) { return false; } + drop(state); - state = self + // Wait for a notification of a change, which is synch + deadlock_state = self .source .sync - .wait(state) + .wait(deadlock_state) .map_or_else(PoisonError::into_inner, |g| g); } } @@ -508,7 +771,7 @@ impl DynamicReader { impl Clone for DynamicReader { fn clone(&self) -> Self { - self.source.state().readers += 1; + self.source.state().expect("deadlocked").readers += 1; Self { source: self.source.clone(), read_generation: self.read_generation, @@ -518,7 +781,7 @@ impl Clone for DynamicReader { impl Drop for DynamicReader { fn drop(&mut self) { - let mut state = self.source.state(); + let mut state = self.source.state().expect("deadlocked"); state.readers -= 1; } } @@ -535,7 +798,7 @@ impl<'a, T> Future for BlockUntilUpdatedFuture<'a, T> { type Output = bool; fn poll(self: std::pin::Pin<&mut Self>, cx: &mut std::task::Context<'_>) -> Poll { - let mut state = self.0.source.state(); + let mut state = self.0.source.state().expect("deadlocked"); if state.wrapped.generation != self.0.read_generation { return Poll::Ready(true); } else if state.readers == Arc::strong_count(&self.0.source) { @@ -669,6 +932,20 @@ 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_tracked(&self, context: &WidgetContext<'_, '_>, map: impl FnOnce(&T) -> R) -> R { + match self { + Value::Constant(value) => map(value), + Value::Dynamic(dynamic) => { + context.redraw_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 { @@ -685,6 +962,17 @@ impl Value { self.map(Clone::clone) } + /// Returns a clone of the currently stored value. + /// + /// If `self` is a dynamic, `context` will be invalidated when the value is + /// updated. + pub fn get_tracked(&self, context: &WidgetContext<'_, '_>) -> T + where + T: Clone, + { + self.map_tracked(context, Clone::clone) + } + /// Returns the current generation of the data stored, if the contained /// value is [`Dynamic`]. pub fn generation(&self) -> Option { diff --git a/src/widget.rs b/src/widget.rs index cfb68b3..18b6a7c 100644 --- a/src/widget.rs +++ b/src/widget.rs @@ -16,11 +16,11 @@ use kludgine::figures::{IntoSigned, IntoUnsigned, Point, Rect, Size}; use crate::context::{AsEventContext, EventContext, GraphicsContext, LayoutContext}; use crate::styles::components::VisualOrder; -use crate::styles::{Component, NamedComponent, Styles}; +use crate::styles::{IntoComponentValue, NamedComponent, Styles, ThemePair}; use crate::tree::Tree; use crate::value::{IntoValue, Value}; use crate::widgets::{Align, Expand, Scroll, Style}; -use crate::window::{RunningWindow, Window, WindowBehavior}; +use crate::window::{RunningWindow, ThemeMode, Window, WindowBehavior}; use crate::{ConstraintLimit, Run}; /// A type that makes up a graphical user interface. @@ -448,7 +448,7 @@ pub trait MakeWidget: Sized { /// Associates `styles` with this widget. /// /// This is equivalent to `Style::new(styles, self)`. - fn with_styles(self, styles: impl Into) -> Style + fn with_styles(self, styles: impl IntoValue) -> Style where Self: Sized, { @@ -456,7 +456,7 @@ pub trait MakeWidget: Sized { } /// Associates a style component with `self`. - fn with(self, name: &impl NamedComponent, component: impl Into) -> Style { + fn with(self, name: &impl NamedComponent, component: impl IntoComponentValue) -> Style { let mut styles = Styles::new(); styles.insert(name, component); Style::new(styles, self) @@ -957,10 +957,22 @@ impl ManagedWidget { self.tree.parent(self.id()).is_some() } - pub(crate) fn attach_styles(&self, styles: Styles) { + pub(crate) fn attach_styles(&self, styles: Value) { self.tree.attach_styles(self.id(), styles); } + pub(crate) fn attach_theme(&self, theme: Value) { + self.tree.attach_theme(self.id(), theme); + } + + pub(crate) fn attach_theme_mode(&self, theme: Value) { + self.tree.attach_theme_mode(self.id(), theme); + } + + pub(crate) fn overidden_theme(&self) -> (Option>, Option>) { + self.tree.overriden_theme(self.id()) + } + pub(crate) fn reset_child_layouts(&self) { self.tree.reset_child_layouts(self.id()); } diff --git a/src/widgets.rs b/src/widgets.rs index aa64c5a..e1a0adf 100644 --- a/src/widgets.rs +++ b/src/widgets.rs @@ -6,11 +6,14 @@ mod canvas; mod expand; mod input; pub mod label; +mod mode_switch; mod resize; pub mod scroll; +mod slider; mod space; pub mod stack; mod style; +mod themed; mod tilemap; pub use align::Align; @@ -19,9 +22,12 @@ pub use canvas::Canvas; pub use expand::Expand; pub use input::Input; pub use label::Label; +pub use mode_switch::ModeSwitch; pub use resize::Resize; pub use scroll::Scroll; +pub use slider::Slider; pub use space::Space; pub use stack::Stack; pub use style::Style; +pub use themed::Themed; pub use tilemap::TileMap; diff --git a/src/widgets/input.rs b/src/widgets/input.rs index 900987f..44482fd 100644 --- a/src/widgets/input.rs +++ b/src/widgets/input.rs @@ -5,17 +5,19 @@ use std::time::Duration; use kludgine::app::winit::event::{ElementState, Ime, KeyEvent}; use kludgine::app::winit::keyboard::Key; -use kludgine::cosmic_text::{Action, Attrs, Buffer, Cursor, Edit, Editor, Metrics, Shaping}; -use kludgine::figures::units::{Px, UPx}; +use kludgine::cosmic_text::{ + Action, Affinity, Attrs, Buffer, Cursor, Edit, Editor, Metrics, Shaping, +}; +use kludgine::figures::units::{Lp, Px, UPx}; use kludgine::figures::{ FloatConversion, IntoSigned, IntoUnsigned, Point, Rect, ScreenScale, Size, }; -use kludgine::shapes::Shape; +use kludgine::shapes::{Shape, StrokeOptions}; use kludgine::text::TextOrigin; use kludgine::{Color, Kludgine}; use crate::context::{EventContext, LayoutContext, WidgetContext}; -use crate::styles::components::{HighlightColor, LineHeight, TextColor, TextSize}; +use crate::styles::components::{HighlightColor, LineHeight, OutlineColor, TextColor, TextSize}; use crate::styles::Styles; use crate::utils::ModifiersExt; use crate::value::{Generation, IntoValue, Value}; @@ -32,6 +34,8 @@ pub struct Input { on_key: Option>, editor: Option, cursor_state: CursorState, + needs_to_select_all: bool, + mouse_buttons_down: usize, } impl Input { @@ -47,6 +51,8 @@ impl Input { editor: None, cursor_state: CursorState::default(), on_key: None, + mouse_buttons_down: 0, + needs_to_select_all: true, } } @@ -100,6 +106,22 @@ impl Input { fn styles(context: &WidgetContext<'_, '_>) -> Styles { context.query_styles(&[&TextColor, &TextSize, &LineHeight]) } + + fn select_all(&mut self) { + let Some(editor) = self.editor.as_mut().map(|editor| &mut editor.editor) else { + return; + }; + if !editor.buffer().lines.is_empty() { + let line = editor.buffer().lines.len() - 1; + let end = Cursor::new_with_affinity( + line, + editor.buffer().lines[line].text().len(), + Affinity::After, + ); + editor.set_cursor(end); + editor.set_select_opt(Some(Cursor::new_with_affinity(0, 0, Affinity::Before))); + } + } } impl Default for Input { @@ -132,7 +154,9 @@ impl Widget for Input { _button: kludgine::app::winit::event::MouseButton, context: &mut EventContext<'_, '_>, ) -> EventHandling { + self.mouse_buttons_down += 1; context.focus(); + self.needs_to_select_all = false; let styles = context.query_styles(&[&TextColor]); self.editor_mut(context.kludgine, &styles, &context.widget) .action( @@ -166,12 +190,22 @@ impl Widget for Input { context.set_needs_redraw(); } + fn mouse_up( + &mut self, + _location: Option>, + _device_id: kludgine::app::winit::event::DeviceId, + _button: kludgine::app::winit::event::MouseButton, + _context: &mut EventContext<'_, '_>, + ) { + self.mouse_buttons_down -= 1; + } + #[allow(clippy::too_many_lines)] fn redraw(&mut self, context: &mut crate::context::GraphicsContext<'_, '_, '_, '_, '_>) { self.cursor_state.update(context.elapsed()); let cursor_state = self.cursor_state; let size = context.gfx.size(); - let styles = context.query_styles(&[&TextColor, &HighlightColor]); + let styles = context.query_styles(&[&TextColor, &HighlightColor, &OutlineColor]); let highlight = styles.get(&HighlightColor, context); let editor = self.editor_mut(&mut context.gfx, &styles, &context.widget); let cursor = editor.cursor(); @@ -311,6 +345,9 @@ impl Widget for Input { context.redraw_when_changed(context.window().focused()); } } + } else { + let outline_color = styles.get(&OutlineColor, context); + context.stroke_outline::(outline_color, StrokeOptions::default()); } let text_color = styles.get(&TextColor, context); @@ -330,6 +367,10 @@ impl Widget for Input { context: &mut LayoutContext<'_, '_, '_, '_, '_>, ) -> Size { let styles = context.query_styles(&[&TextColor]); + if self.needs_to_select_all { + self.needs_to_select_all = false; + self.select_all(); + } let editor = self.editor_mut(&mut context.graphics.gfx, &styles, &context.graphics.widget); let buffer = editor.buffer_mut(); buffer.set_size( @@ -362,7 +403,7 @@ impl Widget for Input { // "Keyboard input: {:?}. {:?}, {:?}", // input.logical_key, input.text, input.physical_key // ); - let (text_changed, handled) = match (input.state, input.logical_key, input.text) { + 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(), @@ -400,6 +441,12 @@ impl Widget for Input { ); (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 @@ -408,7 +455,7 @@ impl Widget for Input { => { if state.is_pressed() { - editor.insert_string(&text, None); + editor.insert_string(text, None); } (state.is_pressed(), HANDLED) } @@ -456,6 +503,10 @@ impl Widget for Input { } fn focus(&mut self, context: &mut EventContext<'_, '_>) { + if self.mouse_buttons_down == 0 { + self.needs_to_select_all = true; + } + context.set_ime_allowed(true); context.set_needs_redraw(); } diff --git a/src/widgets/mode_switch.rs b/src/widgets/mode_switch.rs new file mode 100644 index 0000000..a4bd1be --- /dev/null +++ b/src/widgets/mode_switch.rs @@ -0,0 +1,31 @@ +use crate::context::EventContext; +use crate::value::{IntoValue, Value}; +use crate::widget::{MakeWidget, WidgetRef, WrapperWidget}; +use crate::window::ThemeMode; + +/// A widget that applies a set of [`Styles`] to all contained widgets. +#[derive(Debug)] +pub struct ModeSwitch { + mode: Value, + child: WidgetRef, +} + +impl ModeSwitch { + /// Returns a new widget that applies `mode` to all of its children. + pub fn new(mode: impl IntoValue, child: impl MakeWidget) -> Self { + Self { + mode: mode.into_value(), + child: WidgetRef::new(child), + } + } +} + +impl WrapperWidget for ModeSwitch { + fn child_mut(&mut self) -> &mut WidgetRef { + &mut self.child + } + + fn mounted(&mut self, context: &mut EventContext<'_, '_>) { + context.attach_theme_mode(self.mode.clone()); + } +} diff --git a/src/widgets/resize.rs b/src/widgets/resize.rs index af7b5ae..7dc81e3 100644 --- a/src/widgets/resize.rs +++ b/src/widgets/resize.rs @@ -1,3 +1,4 @@ +use kludgine::figures::units::UPx; use kludgine::figures::{Fraction, IntoSigned, IntoUnsigned, Rect, ScreenScale, Size}; use crate::context::{AsEventContext, LayoutContext}; @@ -81,7 +82,12 @@ impl WrapperWidget for Resize { ); context.for_other(&child).layout(available_space) }; - Rect::from(size.into_signed()) + Size::::new( + self.width.clamp(size.width, context.gfx.scale()), + self.height.clamp(size.height, context.gfx.scale()), + ) + .into_signed() + .into() } } @@ -92,8 +98,11 @@ fn override_constraint( ) -> ConstraintLimit { match constraint { ConstraintLimit::Known(size) => ConstraintLimit::Known(range.clamp(size, scale)), - ConstraintLimit::ClippedAfter(clipped_after) => { - ConstraintLimit::ClippedAfter(range.clamp(clipped_after, 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()) + } + _ => ConstraintLimit::ClippedAfter(range.clamp(clipped_after, scale)), + }, } } diff --git a/src/widgets/scroll.rs b/src/widgets/scroll.rs index 58b8e09..9811800 100644 --- a/src/widgets/scroll.rs +++ b/src/widgets/scroll.rs @@ -126,22 +126,17 @@ impl Widget for Scroll { fn redraw(&mut self, context: &mut crate::context::GraphicsContext<'_, '_, '_, '_, '_>) { context.redraw_when_changed(&self.scrollbar_opacity); - let Some(visible_rect) = context.gfx.visible_rect() else { - return; - }; - let visible_bottom_right = visible_rect.into_signed().extent(); let managed = self.contents.mounted(&mut context.as_event_context()); context.for_other(&managed).redraw(); + let size = context.gfx.region().size; + if self.horizontal_bar.amount_hidden > 0 { context.gfx.draw_shape( &Shape::filled_rect( Rect::new( - Point::new( - self.horizontal_bar.offset, - self.control_size.height - self.bar_width, - ), + Point::new(self.horizontal_bar.offset, size.height - self.bar_width), Size::new(self.horizontal_bar.size, self.bar_width), ), Color::new_f32(1.0, 1.0, 1.0, *self.scrollbar_opacity.get()), @@ -156,10 +151,7 @@ impl Widget for Scroll { context.gfx.draw_shape( &Shape::filled_rect( Rect::new( - Point::new( - visible_bottom_right.x - self.bar_width, - self.vertical_bar.offset, - ), + Point::new(size.width - self.bar_width, self.vertical_bar.offset), Size::new(self.bar_width, self.vertical_bar.size), ), Color::new_f32(1.0, 1.0, 1.0, *self.scrollbar_opacity.get()), @@ -193,12 +185,12 @@ impl Widget for Scroll { if self.enabled.x { ConstraintLimit::ClippedAfter(UPx::MAX - scroll.x.into_unsigned()) } else { - ConstraintLimit::Known(control_size.width.into_unsigned()) + available_space.width }, if self.enabled.y { ConstraintLimit::ClippedAfter(UPx::MAX - scroll.y.into_unsigned()) } else { - ConstraintLimit::Known(control_size.height.into_unsigned()) + available_space.height }, ); let managed = self.contents.mounted(&mut context.as_event_context()); @@ -255,7 +247,22 @@ impl Widget for Scroll { ); context.set_child_layout(&managed, region); - Size::new(available_space.width.max(), available_space.height.max()) + Size::new( + if self.enabled.x { + available_space + .width + .fit_measured(self.content_size.width, context.gfx.scale()) + } else { + self.content_size.width.into_unsigned() + }, + if self.enabled.y { + available_space + .height + .fit_measured(self.content_size.height, context.gfx.scale()) + } else { + self.content_size.height.into_unsigned() + }, + ) } fn mouse_wheel( diff --git a/src/widgets/slider.rs b/src/widgets/slider.rs new file mode 100644 index 0000000..0f73095 --- /dev/null +++ b/src/widgets/slider.rs @@ -0,0 +1,420 @@ +use std::borrow::Cow; +use std::fmt::Debug; +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, +}; +use kludgine::shapes::Shape; +use kludgine::{Color, Origin}; + +use crate::animation::{LinearInterpolate, PercentBetween}; +use crate::context::{EventContext, GraphicsContext, LayoutContext, WidgetContext}; +use crate::styles::{ComponentDefinition, ComponentName, Dimension, Group, NamedComponent}; +use crate::value::{Dynamic, IntoDynamic, IntoValue, Value}; +use crate::widget::{EventHandling, Widget, HANDLED}; +use crate::ConstraintLimit; + +/// A widget that allows sliding between two values. +#[derive(Debug, Clone)] +pub struct Slider { + /// The current value. + pub value: Dynamic, + /// The minimum value represented by this slider. + pub minimum: Value, + /// The maximum value represented by this slider. + pub maximum: Value, + knob_size: UPx, + horizontal: bool, + rendered_size: Px, +} + +impl Slider +where + T: Ranged, +{ + /// Returns a new slider over `value` using the types full range. + #[must_use] + pub fn from_value(value: impl IntoDynamic) -> Self { + Self::new(value, T::MIN, T::MAX) + } +} + +impl Slider { + /// Returns a new slider using `value` as the slider's value, keeping the + /// value between `min` and `max`. + #[must_use] + pub fn new(value: impl IntoDynamic, min: impl IntoValue, max: impl IntoValue) -> Self { + Self { + value: value.into_dynamic(), + minimum: min.into_value(), + maximum: max.into_value(), + knob_size: UPx(0), + horizontal: true, + rendered_size: Px(0), + } + } + + /// Sets the maximum value of this slider to `max` and returns self. + #[must_use] + pub fn maximum(mut self, max: impl IntoValue) -> Self { + self.maximum = max.into_value(); + self + } + + /// Sets the minimum value of this slider to `min` and returns self. + #[must_use] + pub fn minimum(mut self, min: impl IntoValue) -> Self { + self.minimum = min.into_value(); + self + } + + fn draw_track(&mut self, spec: &TrackSpec, context: &mut GraphicsContext<'_, '_, '_, '_, '_>) { + if self.horizontal { + self.rendered_size = spec.size.width; + } else { + self.rendered_size = spec.size.height; + } + let track_length = self.rendered_size - spec.knob_size; + let value_location = (track_length) * spec.percent + spec.half_knob; + + let half_track = spec.track_size / 2; + // Draw the track + if value_location > spec.half_knob { + context.gfx.draw_shape( + &Shape::filled_rect( + Rect::new( + flipped( + !self.horizontal, + Point::new(spec.half_knob, spec.half_knob - half_track), + ), + flipped(!self.horizontal, Size::new(value_location, spec.track_size)), + ), + spec.track_color, + ), + Point::default(), + None, + None, + ); + } + + if value_location < track_length { + context.gfx.draw_shape( + &Shape::filled_rect( + Rect::new( + flipped( + !self.horizontal, + Point::new(value_location, spec.half_knob - half_track), + ), + flipped( + !self.horizontal, + Size::new( + track_length - value_location + spec.half_knob, + spec.track_size, + ), + ), + ), + spec.inactive_track_color, + ), + Point::default(), + None, + None, + ); + } + + // Draw the knob + context.gfx.draw_shape( + &Shape::filled_circle(spec.half_knob, spec.knob_color, Origin::Center), + flipped(!self.horizontal, Point::new(value_location, spec.half_knob)), + None, + None, + ); + } +} + +impl Slider +where + T: LinearInterpolate + Clone, +{ + fn update_from_click(&mut self, position: Point) { + let position = if self.horizontal { + position.x + } else { + position.y + }; + let position = position.clamp(Px(0), self.rendered_size); + let percent = position.into_float() / self.rendered_size.into_float(); + let min = self.minimum.get(); + let max = self.maximum.get(); + self.value.update(min.lerp(&max, percent)); + } +} + +impl Widget for Slider +where + T: Clone + + Debug + + PartialOrd + + LinearInterpolate + + PercentBetween + + UnwindSafe + + Send + + 'static, +{ + fn redraw(&mut self, context: &mut GraphicsContext<'_, '_, '_, '_, '_>) { + let styles = + context.query_styles(&[&TrackColor, &InactiveTrackColor, &KnobColor, &TrackSize]); + let track_color = styles.get(&TrackColor, context); + let inactive_track_color = styles.get(&InactiveTrackColor, context); + let knob_color = styles.get(&KnobColor, context); + let knob_size = self.knob_size.into_signed(); + let track_size = styles + .get(&TrackSize, context) + .into_px(context.gfx.scale()) + .min(knob_size); + + let half_knob = knob_size / 2; + + let mut value = self.value.get_tracked(context); + let min = self.minimum.get_tracked(context); + let mut max = self.maximum.get_tracked(context); + + if max < min { + self.maximum.map_mut(|max| *max = min.clone()); + max = min.clone(); + } + let mut value_clamped = false; + if value < min { + value_clamped = true; + value = min.clone(); + } else if value > max { + value_clamped = true; + value = max.clone(); + } + + if value_clamped { + self.value.map_mut(|v| *v = value.clone()); + } + + let percent = value.percent_between(&min, &max); + + let size = context.gfx.region().size; + self.horizontal = size.width >= size.height; + + self.draw_track( + &TrackSpec { + size, + percent: *percent, + half_knob, + knob_size, + track_size, + knob_color, + track_color, + inactive_track_color, + }, + context, + ); + } + + fn layout( + &mut self, + available_space: Size, + context: &mut LayoutContext<'_, '_, '_, '_, '_>, + ) -> Size { + let styles = context.query_styles(&[&KnobSize, &MinimumSliderSize]); + self.knob_size = styles + .get(&KnobSize, context) + .into_px(context.gfx.scale()) + .into_unsigned(); + let minimum_size = styles + .get(&MinimumSliderSize, context) + .into_px(context.gfx.scale()) + .into_unsigned(); + + match (available_space.width, available_space.height) { + (ConstraintLimit::Known(width), ConstraintLimit::Known(height)) => { + // This comparison is done such that if width == height, we end + // up with a horizontal slider. + if width < height { + // Vertical slider + Size::new(self.knob_size, height.max(minimum_size)) + } else { + // Horizontal slider + Size::new(width.max(minimum_size), self.knob_size) + } + } + (ConstraintLimit::Known(width), ConstraintLimit::ClippedAfter(_)) => { + Size::new(width.max(minimum_size), self.knob_size) + } + (ConstraintLimit::ClippedAfter(_), ConstraintLimit::Known(height)) => { + Size::new(self.knob_size, height.max(minimum_size)) + } + (ConstraintLimit::ClippedAfter(width), ConstraintLimit::ClippedAfter(_)) => { + // When we have no limit on our, we still want to be draggable. + // Since we have no limit in both directions, we have to make a + // choice: horizontal or vertical. It seems to @ecton at the + // time of writing this that when there is no intent from the + // user of the slider, a horizontal slider is expected. So, we + // set the minimum measurement based on a horizontal + // orientation. + Size::new(width.min(minimum_size), self.knob_size) + } + } + } + + fn hit_test(&mut self, _location: Point, _context: &mut EventContext<'_, '_>) -> bool { + true + } + + fn mouse_down( + &mut self, + location: Point, + _device_id: DeviceId, + _button: MouseButton, + _context: &mut EventContext<'_, '_>, + ) -> EventHandling { + self.update_from_click(location); + HANDLED + } + + fn mouse_drag( + &mut self, + location: Point, + _device_id: DeviceId, + _button: MouseButton, + _context: &mut EventContext<'_, '_>, + ) { + self.update_from_click(location); + } +} + +struct TrackSpec { + size: Size, + percent: f32, + half_knob: Px, + knob_size: Px, + track_size: Px, + knob_color: Color, + track_color: Color, + inactive_track_color: Color, +} + +fn flipped(flip: bool, value: T) -> T +where + T: IntoComponents + FromComponents, +{ + if flip { + let (a, b) = value.into_components(); + T::from_components((b, a)) + } else { + value + } +} + +/// The size of the track that the knob of a [`Slider`] traversesq. +pub struct TrackSize; + +impl ComponentDefinition for TrackSize { + type ComponentType = Dimension; + + fn default_value(&self, _context: &WidgetContext<'_, '_>) -> Self::ComponentType { + Dimension::Lp(Lp::points(5)) + } +} + +impl NamedComponent for TrackSize { + fn name(&self) -> Cow<'_, ComponentName> { + Cow::Owned(ComponentName::new(Group::new("Slider"), "track_size")) + } +} + +/// The width and height of the draggable portion of a [`Slider`]. +pub struct KnobSize; + +impl ComponentDefinition for KnobSize { + type ComponentType = Dimension; + + fn default_value(&self, _context: &WidgetContext<'_, '_>) -> Self::ComponentType { + Dimension::Lp(Lp::points(14)) + } +} + +impl NamedComponent for KnobSize { + fn name(&self) -> Cow<'_, ComponentName> { + Cow::Owned(ComponentName::new(Group::new("Slider"), "knob_size")) + } +} + +/// The minimum length of the slidable dimension. +pub struct MinimumSliderSize; + +impl ComponentDefinition for MinimumSliderSize { + type ComponentType = Dimension; + + fn default_value(&self, _context: &WidgetContext<'_, '_>) -> Self::ComponentType { + Dimension::Lp(Lp::points(14)) + } +} + +impl NamedComponent for MinimumSliderSize { + fn name(&self) -> Cow<'_, ComponentName> { + Cow::Owned(ComponentName::new(Group::new("Slider"), "minimum_size")) + } +} + +/// The color of the draggable portion of the knob. +pub struct KnobColor; + +impl ComponentDefinition for KnobColor { + type ComponentType = Color; + + fn default_value(&self, context: &WidgetContext<'_, '_>) -> Self::ComponentType { + context.theme().primary.color + } +} + +impl NamedComponent for KnobColor { + fn name(&self) -> Cow<'_, ComponentName> { + Cow::Owned(ComponentName::new(Group::new("Slider"), "knob_color")) + } +} + +/// The color of the track that the knob rests on. +pub struct TrackColor; + +impl ComponentDefinition for TrackColor { + type ComponentType = Color; + + fn default_value(&self, context: &WidgetContext<'_, '_>) -> Self::ComponentType { + context.theme().primary.color + } +} + +impl NamedComponent for TrackColor { + fn name(&self) -> Cow<'_, ComponentName> { + Cow::Owned(ComponentName::new(Group::new("Slider"), "track_color")) + } +} + +/// The color of the draggable portion of the knob. +pub struct InactiveTrackColor; + +impl ComponentDefinition for InactiveTrackColor { + type ComponentType = Color; + + fn default_value(&self, context: &WidgetContext<'_, '_>) -> Self::ComponentType { + context.theme().surface.outline + } +} + +impl NamedComponent for InactiveTrackColor { + fn name(&self) -> Cow<'_, ComponentName> { + Cow::Owned(ComponentName::new( + Group::new("Slider"), + "inactive_track_color", + )) + } +} diff --git a/src/widgets/stack.rs b/src/widgets/stack.rs index 0dc1d51..d47a899 100644 --- a/src/widgets/stack.rs +++ b/src/widgets/stack.rs @@ -89,7 +89,7 @@ impl Stack { if let Some(expand) = guard.downcast_ref::() { let weight = expand.weight; ( - WidgetRef::Unmounted(widget.clone()), + expand.child().clone(), StackDimension::Fractional { weight }, ) } else if let Some((child, size)) = @@ -403,18 +403,14 @@ impl Layout { // Measure the children that fit their content for &id in &self.measured { let index = self.children.index_of_id(id).expect("child not found"); - if remaining > 0 { - let (measured, _) = self.orientation.split_size(measure( - index, - self.orientation - .make_size(ConstraintLimit::ClippedAfter(remaining), other_constraint), - false, - )); - self.layouts[index].size = measured; - remaining = remaining.saturating_sub(measured); - } else { - self.layouts[index].size = UPx(0); - } + let (measured, _) = self.orientation.split_size(measure( + index, + self.orientation + .make_size(ConstraintLimit::ClippedAfter(remaining), other_constraint), + false, + )); + self.layouts[index].size = measured; + remaining = remaining.saturating_sub(measured); } // Measure the weighted children within the remaining space diff --git a/src/widgets/style.rs b/src/widgets/style.rs index 870e7c4..d1c714a 100644 --- a/src/widgets/style.rs +++ b/src/widgets/style.rs @@ -1,20 +1,21 @@ use crate::context::EventContext; use crate::styles::Styles; +use crate::value::{IntoValue, Value}; use crate::widget::{MakeWidget, WidgetRef, WrapperWidget}; /// A widget that applies a set of [`Styles`] to all contained widgets. #[derive(Debug)] pub struct Style { - styles: Styles, + styles: Value, child: WidgetRef, } impl Style { /// Returns a new widget that applies `styles` to `child` and any children /// it may have. - pub fn new(styles: impl Into, child: impl MakeWidget) -> Self { + pub fn new(styles: impl IntoValue, child: impl MakeWidget) -> Self { Self { - styles: styles.into(), + styles: styles.into_value(), child: WidgetRef::new(child), } } diff --git a/src/widgets/themed.rs b/src/widgets/themed.rs new file mode 100644 index 0000000..4a60442 --- /dev/null +++ b/src/widgets/themed.rs @@ -0,0 +1,31 @@ +use crate::context::EventContext; +use crate::styles::ThemePair; +use crate::value::{IntoValue, Value}; +use crate::widget::{MakeWidget, WidgetRef, WrapperWidget}; + +/// A widget that applies a set of [`Styles`] to all contained widgets. +#[derive(Debug)] +pub struct Themed { + theme: Value, + child: WidgetRef, +} + +impl Themed { + /// Returns a new widget that applies `theme` to all of its children. + pub fn new(theme: impl IntoValue, child: impl MakeWidget) -> Self { + Self { + theme: theme.into_value(), + child: WidgetRef::new(child), + } + } +} + +impl WrapperWidget for Themed { + fn child_mut(&mut self) -> &mut WidgetRef { + &mut self.child + } + + fn mounted(&mut self, context: &mut EventContext<'_, '_>) { + context.attach_theme(self.theme.clone()); + } +} diff --git a/src/window.rs b/src/window.rs index 520b97a..7261433 100644 --- a/src/window.rs +++ b/src/window.rs @@ -15,13 +15,16 @@ use kludgine::app::winit::event::{ DeviceId, ElementState, Ime, KeyEvent, MouseButton, MouseScrollDelta, TouchPhase, }; use kludgine::app::winit::keyboard::Key; +use kludgine::app::winit::window; use kludgine::app::WindowBehavior as _; use kludgine::figures::units::{Px, UPx}; -use kludgine::figures::{IntoSigned, IntoUnsigned, Point, Rect, ScreenScale, Size}; +use kludgine::figures::{IntoSigned, IntoUnsigned, Point, Ranged, Rect, ScreenScale, Size}; use kludgine::render::Drawing; +use kludgine::wgpu::CompositeAlphaMode; use kludgine::Kludgine; use tracing::Level; +use crate::animation::{LinearInterpolate, PercentBetween, ZeroToOne}; use crate::context::{ AsEventContext, EventContext, Exclusive, GraphicsContext, LayoutContext, RedrawStatus, WidgetContext, @@ -31,7 +34,7 @@ use crate::styles::components::LayoutOrder; use crate::styles::ThemePair; use crate::tree::Tree; use crate::utils::ModifiersExt; -use crate::value::{Dynamic, DynamicReader, IntoDynamic, Value}; +use crate::value::{Dynamic, DynamicReader, IntoDynamic, IntoValue, Value}; use crate::widget::{ EventHandling, ManagedWidget, Widget, WidgetId, WidgetInstance, HANDLED, IGNORED, }; @@ -104,6 +107,7 @@ where pub theme: Value, occluded: Option>, focused: Option>, + theme_mode: Option>, } impl Default for Window @@ -155,6 +159,24 @@ impl Window { self.occluded = Some(occluded); self } + + /// Sets the [`ThemeMode`] for this window. + /// + /// If a [`ThemeMode`] is provided, the window will be set to this theme + /// mode upon creation and will not be updated while the window is running. + /// + /// If a [`Dynamic`] is provided, the initial value will be ignored and the + /// dynamic will be updated when the window opens with the user's current + /// theme mode. The dynamic will also be updated any time the user's theme + /// mode changes. + /// + /// 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 { + self.theme_mode = Some(theme_mode.into_value()); + self + } } impl Window @@ -188,6 +210,7 @@ where theme: Value::default(), occluded: None, focused: None, + theme_mode: None, } } } @@ -201,10 +224,12 @@ where GooeyWindow::::run_with(AssertUnwindSafe(sealed::Context { user: self.context, settings: RefCell::new(sealed::WindowSettings { + transparent: self.attributes.transparent, attributes: Some(self.attributes), occluded: self.occluded, focused: self.focused, theme: Some(self.theme), + theme_mode: self.theme_mode, }), })) } @@ -258,6 +283,8 @@ struct GooeyWindow { max_inner_size: Option>, theme: Option>, current_theme: ThemePair, + theme_mode: Dynamic, + transparent: bool, } impl GooeyWindow @@ -286,6 +313,7 @@ where &self.redraw_status, &self.current_theme, window, + self.theme_mode.get(), ), kludgine, ) @@ -297,6 +325,7 @@ where &self.redraw_status, &self.current_theme, window, + self.theme_mode.get(), ), kludgine, ) @@ -310,6 +339,7 @@ where &self.redraw_status, &self.current_theme, window, + self.theme_mode.get(), ), kludgine, ) @@ -348,7 +378,7 @@ where let new_min_size = (min_width > 0 || min_height > 0) .then_some(Size::::new(min_width, min_height).into_unsigned()); - if new_min_size != self.min_inner_size { + if new_min_size != self.min_inner_size && resizable { window.set_min_inner_size(new_min_size); self.min_inner_size = new_min_size; } @@ -405,6 +435,15 @@ where .theme .take() .expect("theme always present"); + + let theme_mode = match context.settings.borrow_mut().theme_mode.take() { + Some(Value::Dynamic(dynamic)) => { + dynamic.update(window.theme().into()); + dynamic + } + Some(Value::Constant(_)) | None => Dynamic::new(window.theme().into()), + }; + let transparent = context.settings.borrow().transparent; let mut behavior = T::initialize( &mut RunningWindow::new(window, &focused, &occluded), context.user, @@ -435,6 +474,8 @@ where max_inner_size: None, current_theme, theme, + theme_mode, + transparent, } } @@ -467,14 +508,19 @@ where &self.redraw_status, &self.current_theme, &mut window, + self.theme_mode.get(), ), gfx: Exclusive::Owned(Graphics::new(graphics)), }; + context.redraw_when_changed(&self.theme_mode); let mut layout_context = LayoutContext::new(&mut context); let window_size = layout_context.gfx.size(); - let background_color = layout_context.theme().surface.color; - layout_context.graphics.gfx.fill(background_color); + if !self.transparent { + let background_color = layout_context.theme().surface.color; + layout_context.graphics.gfx.fill(background_color); + } + let actual_size = layout_context.layout(if is_expanded { Size::new( ConstraintLimit::Known(window_size.width), @@ -549,12 +595,16 @@ where fn initial_window_attributes( context: &Self::Context, ) -> kludgine::app::WindowAttributes { - context + let mut attrs = context .settings .borrow_mut() .attributes .take() - .expect("called more than once") + .expect("called more than once"); + if let Some(Value::Constant(theme_mode)) = &context.settings.borrow().theme_mode { + attrs.preferred_theme = Some((*theme_mode).into()); + } + attrs } fn close_requested( @@ -577,9 +627,21 @@ where // wgpu::Limits::downlevel_webgl2_defaults().using_resolution(adapter_limits) // } - // fn clear_color() -> Option { - // Some(kludgine::Color::BLACK) - // } + fn clear_color(&self) -> Option { + Some(if self.transparent { + kludgine::Color::CLEAR_BLACK + } else { + kludgine::Color::BLACK + }) + } + + fn composite_alpha_mode(&self, supported_modes: &[CompositeAlphaMode]) -> CompositeAlphaMode { + if self.transparent && supported_modes.contains(&CompositeAlphaMode::PreMultiplied) { + CompositeAlphaMode::PreMultiplied + } else { + CompositeAlphaMode::Auto + } + } // fn focus_changed(&mut self, window: kludgine::app::Window<'_, ()>) {} @@ -616,6 +678,7 @@ where &self.redraw_status, &self.current_theme, &mut window, + self.theme_mode.get(), ), kludgine, ); @@ -645,6 +708,7 @@ where &self.redraw_status, &self.current_theme, &mut window, + self.theme_mode.get(), ), kludgine, ); @@ -711,6 +775,7 @@ where &self.redraw_status, &self.current_theme, &mut window, + self.theme_mode.get(), ), kludgine, ); @@ -745,6 +810,7 @@ where &self.redraw_status, &self.current_theme, &mut window, + self.theme_mode.get(), ), kludgine, ); @@ -773,6 +839,7 @@ where &self.redraw_status, &self.current_theme, &mut window, + self.theme_mode.get(), ), kludgine, ); @@ -787,6 +854,7 @@ where &self.redraw_status, &self.current_theme, &mut window, + self.theme_mode.get(), ), kludgine, ); @@ -827,6 +895,7 @@ where &self.redraw_status, &self.current_theme, &mut window, + self.theme_mode.get(), ), kludgine, ); @@ -851,6 +920,7 @@ where &self.redraw_status, &self.current_theme, &mut window, + self.theme_mode.get(), ), kludgine, ) @@ -866,6 +936,7 @@ where &self.redraw_status, &self.current_theme, &mut window, + self.theme_mode.get(), ), kludgine, ), @@ -900,6 +971,7 @@ where &self.redraw_status, &self.current_theme, &mut window, + self.theme_mode.get(), ), kludgine, ); @@ -917,6 +989,14 @@ where } } + fn theme_changed( + &mut self, + window: kludgine::app::Window<'_, WindowCommand>, + _kludgine: &mut Kludgine, + ) { + self.theme_mode.update(window.theme().into()); + } + fn event( &mut self, mut window: kludgine::app::Window<'_, WindowCommand>, @@ -955,7 +1035,7 @@ pub(crate) mod sealed { use crate::styles::ThemePair; use crate::value::{Dynamic, Value}; - use crate::window::WindowAttributes; + use crate::window::{ThemeMode, WindowAttributes}; pub struct Context { pub user: C, @@ -967,6 +1047,8 @@ pub(crate) mod sealed { pub occluded: Option>, pub focused: Option>, pub theme: Option>, + pub theme_mode: Option>, + pub transparent: bool, } pub enum WindowCommand { @@ -974,3 +1056,56 @@ pub(crate) mod sealed { // RequestClose, } } + +/// Controls whether the light or dark theme is applied. +#[derive(Default, Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd)] +pub enum ThemeMode { + /// Applies the light theme + Light, + /// Applies the dark theme + #[default] + Dark, +} + +impl From for ThemeMode { + fn from(value: window::Theme) -> Self { + match value { + window::Theme::Light => Self::Light, + window::Theme::Dark => Self::Dark, + } + } +} + +impl From for window::Theme { + fn from(value: ThemeMode) -> Self { + match value { + ThemeMode::Light => Self::Light, + ThemeMode::Dark => Self::Dark, + } + } +} + +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 { + ZeroToOne::ZERO + } else { + ZeroToOne::ONE + } + } +} + +impl Ranged for ThemeMode { + const MAX: Self = Self::Dark; + const MIN: Self = Self::Light; +}