diff --git a/CHANGELOG.md b/CHANGELOG.md index 8bd63d8..222145d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 also provides this export, so existing references through kludgine will continue to work. This was added as an attempt to fix links on docs.rs (see rust-lang/docs.rs#1588). +- `Disclose` is a new widget that shows a disclosure triangle and uses a + `Collapse` widget to show/hide the content when the disclosure button is + clicked. This widget also supports an optional label that is shown above the + content and is also clickable. ## v0.2.0 diff --git a/Cargo.lock b/Cargo.lock index da4113c..61b3c8b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -738,9 +738,9 @@ dependencies = [ [[package]] name = "figures" -version = "0.2.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63172cefd2b4018ab01e207ce8b951e757575412d58a9370619d19ea16ddc948" +checksum = "813f0f9e0ba0d378a8e2cd51df24dd724ba8fbc07baa3dd192813eeba407ea86" dependencies = [ "bytemuck", "euclid", diff --git a/Cargo.toml b/Cargo.toml index e909a2e..c6b3574 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,7 +19,7 @@ roboto-flex = [] [dependencies] kludgine = { version = "0.7.0", features = ["app"] } -figures = "0.2.0" +figures = "0.2.1" # kludgine = { git = "https://github.com/khonsulabs/kludgine", features = [ # "app", # ] } diff --git a/examples/disclose.rs b/examples/disclose.rs new file mode 100644 index 0000000..42b9af3 --- /dev/null +++ b/examples/disclose.rs @@ -0,0 +1,14 @@ +use cushy::widget::MakeWidget; +use cushy::widgets::Disclose; +use cushy::Run; + +fn main() -> cushy::Result { + Disclose::new( + "This is some inner content" + .align_left() + .and(Disclose::new("This is even further inside")) + .into_rows(), + ) + .labelled_by("This demonstrates the Disclose widget") + .run() +} diff --git a/src/context.rs b/src/context.rs index a96a0c0..5e21ea6 100644 --- a/src/context.rs +++ b/src/context.rs @@ -642,16 +642,13 @@ impl<'context, 'window, 'clip, 'gfx, 'pass> GraphicsContext<'context, 'window, ' /// Invokes [`Widget::redraw()`](crate::widget::Widget::redraw) on this /// context's widget. - /// - /// # Panics - /// - /// This function panics if the widget being drawn has no layout set (via - /// [`LayoutContext::set_child_layout()`]). pub fn redraw(&mut self) { - assert!( - self.last_layout().is_some(), - "redraw called without set_widget_layout" - ); + let Some(layout) = self.last_layout() else { + return; + }; + if layout.size.width <= 0 || layout.size.height <= 0 { + return; + } self.tree.note_widget_rendered(self.current_node.node_id); let widget = self.current_node.clone(); diff --git a/src/lib.rs b/src/lib.rs index f645686..3db086b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -21,7 +21,7 @@ pub mod value; pub mod widget; pub mod widgets; pub mod window; -use std::ops::Sub; +use std::ops::{Add, AddAssign, Sub, SubAssign}; pub use app::{App, Application, Cushy, Open, PendingApp, Run}; use figures::units::UPx; @@ -102,14 +102,39 @@ impl FitMeasuredSize for Size { } } +impl Add for ConstraintLimit { + type Output = Self; + + fn add(mut self, rhs: UPx) -> Self::Output { + self += rhs; + self + } +} + +impl AddAssign for ConstraintLimit { + fn add_assign(&mut self, rhs: UPx) { + *self = match *self { + ConstraintLimit::Fill(px) => ConstraintLimit::Fill(px.saturating_add(rhs)), + ConstraintLimit::SizeToFit(px) => ConstraintLimit::SizeToFit(px.saturating_add(rhs)), + }; + } +} + impl Sub for ConstraintLimit { type Output = Self; - fn sub(self, rhs: UPx) -> Self::Output { - match self { + fn sub(mut self, rhs: UPx) -> Self::Output { + self -= rhs; + self + } +} + +impl SubAssign for ConstraintLimit { + fn sub_assign(&mut self, rhs: UPx) { + *self = match *self { ConstraintLimit::Fill(px) => ConstraintLimit::Fill(px.saturating_sub(rhs)), ConstraintLimit::SizeToFit(px) => ConstraintLimit::SizeToFit(px.saturating_sub(rhs)), - } + }; } } diff --git a/src/widget.rs b/src/widget.rs index c0f824b..015dbe6 100644 --- a/src/widget.rs +++ b/src/widget.rs @@ -39,8 +39,8 @@ use crate::value::{Dynamic, Generation, IntoDynamic, IntoValue, Validation, Valu use crate::widgets::checkbox::{Checkable, CheckboxState}; use crate::widgets::layers::{OverlayLayer, Tooltipped}; use crate::widgets::{ - Align, Button, Checkbox, Collapse, Container, Expand, Layers, Resize, Scroll, Space, Stack, - Style, Themed, ThemedMode, Validated, Wrap, + Align, Button, Checkbox, Collapse, Container, Disclose, Expand, Layers, Resize, Scroll, Space, + Stack, Style, Themed, ThemedMode, Validated, Wrap, }; use crate::window::{RunningWindow, ThemeMode, Window, WindowBehavior, WindowHandle}; use crate::ConstraintLimit; @@ -1298,6 +1298,11 @@ pub trait MakeWidget: Sized { Collapse::vertical(collapse_when, self) } + /// Returns a new widget that allows hiding and showing `contents`. + fn disclose(self) -> Disclose { + Disclose::new(self) + } + /// Returns a widget that shows validation errors and/or hints. fn validation(self, validation: impl IntoDynamic) -> Validated { Validated::new(validation, self) diff --git a/src/widgets.rs b/src/widgets.rs index d3dbbf9..8e4a362 100644 --- a/src/widgets.rs +++ b/src/widgets.rs @@ -9,6 +9,7 @@ pub mod color; pub mod container; mod custom; mod data; +pub mod disclose; mod expand; pub mod grid; pub mod image; @@ -39,6 +40,7 @@ pub use collapse::Collapse; pub use container::Container; pub use custom::Custom; pub use data::Data; +pub use disclose::Disclose; pub use expand::Expand; pub use image::Image; pub use input::Input; diff --git a/src/widgets/container.rs b/src/widgets/container.rs index 5f43b93..5c814ac 100644 --- a/src/widgets/container.rs +++ b/src/widgets/container.rs @@ -587,32 +587,20 @@ fn shadow_arc( context: &mut GraphicsContext<'_, '_, '_, '_, '_>, ) { let full_radius = radius + gradient; - let mut current_outer_arc = rotate_point( - origin, - Point::new(origin.x + full_radius, origin.y), - start_angle, - ); - let mut current_inner_arc = - rotate_point(origin, Point::new(origin.x + radius, origin.y), start_angle); + let mut current_outer_arc = origin + Point::new(full_radius, Px::ZERO).rotate_by(start_angle); + + let mut current_inner_arc = origin + Point::new(radius, Px::ZERO).rotate_by(start_angle); let mut angle = Angle::degrees(0); while angle < Angle::degrees(90) { angle += Angle::degrees(5); - let outer_arc = rotate_point( - origin, - Point::new(origin.x + full_radius, origin.y), - start_angle + angle, - ); + let outer_arc = origin + Point::new(full_radius, Px::ZERO).rotate_by(start_angle + angle); if outer_arc == current_outer_arc { continue; } - let inner_arc = rotate_point( - origin, - Point::new(origin.x + radius, origin.y), - start_angle + angle, - ); + let inner_arc = origin + Point::new(radius, Px::ZERO).rotate_by(start_angle + angle); let mut path = PathBuilder::new((current_inner_arc, solid_color)); path = path @@ -638,13 +626,6 @@ fn shadow_arc( } } -fn rotate_point(origin: Point, point: Point, angle: Angle) -> Point { - let cos = angle.into_raidans_f().cos(); - let sin = angle.into_raidans_f().sin(); - let d = point - origin; - origin + Point::new(d.x * cos - d.y * sin, d.y * cos + d.x * sin) -} - /// The selected background configuration of a [`Container`]. #[derive(Debug, Clone, Copy, Eq, PartialEq)] pub enum EffectiveBackground { diff --git a/src/widgets/disclose.rs b/src/widgets/disclose.rs new file mode 100644 index 0000000..f5e94c4 --- /dev/null +++ b/src/widgets/disclose.rs @@ -0,0 +1,350 @@ +//! A widget that hides/shows associated content. + +use std::time::Duration; + +use figures::units::{Lp, Px, UPx}; +use figures::{Angle, IntoSigned, Point, Rect, Round, ScreenScale, Size, Zero}; +use kludgine::app::winit::window::CursorIcon; +use kludgine::shapes::{PathBuilder, StrokeOptions}; +use kludgine::{Color, DrawableExt}; + +use super::button::{ButtonActiveBackground, ButtonBackground, ButtonHoverBackground}; +use crate::animation::{AnimationHandle, AnimationTarget, Spawn}; +use crate::context::LayoutContext; +use crate::styles::components::{HighlightColor, IntrinsicPadding, OutlineColor, TextSize}; +use crate::styles::Dimension; +use crate::value::{Dynamic, IntoDynamic, IntoValue, Value}; +use crate::widget::{ + EventHandling, MakeWidget, MakeWidgetWithTag, Widget, WidgetInstance, WidgetRef, WidgetTag, + HANDLED, IGNORED, +}; +use crate::ConstraintLimit; + +/// A widget that hides and shows another widget. +pub struct Disclose { + contents: WidgetInstance, + label: Option, + collapsed: Value, +} + +impl Disclose { + /// Returns a new widget that allows hiding and showing `contents`. + #[must_use] + pub fn new(contents: impl MakeWidget) -> Self { + Self { + contents: contents.make_widget(), + label: None, + collapsed: Value::Constant(true), + } + } + + /// Sets `label` as a clickable label for this widget. + #[must_use] + pub fn labelled_by(mut self, label: impl MakeWidget) -> Self { + self.label = Some(label.make_widget()); + self + } + + /// Sets this widget's collapsed value. + /// + /// If a `Value::Constant` is provided, it is used as the initial collapse + /// state. If a `Value::Dynamic` is provided, it will be updated when the + /// contents are shown and hidden. + #[must_use] + pub fn collapsed(mut self, collapsed: impl IntoValue) -> Self { + self.collapsed = collapsed.into_value(); + self + } +} + +impl MakeWidgetWithTag for Disclose { + fn make_with_tag(self, tag: WidgetTag) -> WidgetInstance { + let collapsed = self.collapsed.into_dynamic(); + + DiscloseIndicator::new(collapsed.clone(), self.label, self.contents) + .make_with_tag(tag) + .make_widget() + } +} + +#[derive(Debug)] +struct DiscloseIndicator { + label: Option, + contents: WidgetRef, + collapsed: Dynamic, + hovering_indicator: bool, + target_colors: Option<(Color, Color)>, + color_animation: AnimationHandle, + color: Dynamic, + stroke_color: Dynamic, + angle: Dynamic, + mouse_buttons_pressed: usize, +} + +fn collapse_angle(collapsed: bool) -> Angle { + if collapsed { + Angle::degrees(0) + } else { + Angle::degrees(90) + } +} + +impl DiscloseIndicator { + fn new( + collapsed: Dynamic, + label: Option, + contents: WidgetInstance, + ) -> Self { + let angle = Dynamic::new(collapse_angle(collapsed.get())); + + let mut _angle_animation = AnimationHandle::default(); + angle.set_source({ + let angle = angle.clone(); + collapsed.for_each(move |collapsed| { + _angle_animation = angle + .transition_to(collapse_angle(*collapsed)) + .over(Duration::from_millis(125)) + .spawn(); + }) + }); + + Self { + contents: WidgetRef::new(contents.collapse_vertically(collapsed.clone())), + collapsed, + hovering_indicator: false, + label: label.map(WidgetRef::Unmounted), + target_colors: None, + color: Dynamic::new(Color::CLEAR_WHITE), + stroke_color: Dynamic::new(Color::CLEAR_WHITE), + color_animation: AnimationHandle::default(), + angle, + mouse_buttons_pressed: 0, + } + } + + fn effective_colors( + &mut self, + context: &mut crate::context::GraphicsContext<'_, '_, '_, '_, '_>, + ) -> (Color, Color) { + let current_color = if context.active() { + context.get(&ButtonActiveBackground) + } else if self.hovering_indicator { + context.get(&ButtonHoverBackground) + } else { + context.get(&ButtonBackground) + }; + let stroke_color = if self.hovering_indicator { + context.get(&OutlineColor) + } else { + context.get(&OutlineColor).with_alpha(0) + }; + let target_colors = (current_color, stroke_color); + if self.target_colors.is_none() { + self.target_colors = Some(target_colors); + self.color.set(current_color); + } else if self.target_colors != Some(target_colors) { + self.target_colors = Some(target_colors); + self.color_animation = ( + self.color.transition_to(current_color), + self.stroke_color.transition_to(stroke_color), + ) + .over(Duration::from_millis(125)) + .spawn(); + } + + ( + self.color.get_tracking_redraw(context), + self.stroke_color.get_tracking_redraw(context), + ) + } +} + +impl Widget for DiscloseIndicator { + fn redraw(&mut self, context: &mut crate::context::GraphicsContext<'_, '_, '_, '_, '_>) { + let angle = self.angle.get_tracking_redraw(context); + let (color, stroke_color) = self.effective_colors(context); + let size = context + .get(&IndicatorSize) + .into_px(context.gfx.scale()) + .round(); + let stroke = StrokeOptions::px_wide(Lp::points(2).into_px(context.gfx.scale()).round()); + + let radius = ((size - stroke.line_width) / 2).round(); + let pt1 = Point::new(radius, Px::ZERO).rotate_by(Angle::degrees(0)); + let pt2 = Point::new(radius, Px::ZERO).rotate_by(Angle::degrees(120)); + let pt3 = Point::new(radius, Px::ZERO).rotate_by(Angle::degrees(240)); + + let path = PathBuilder::new(pt1).line_to(pt2).line_to(pt3).close(); + + let indicator_layout_height = if let Some(label) = &mut self.label { + let label = label.mounted(context); + context.for_other(&label).redraw(); + label + .last_layout() + .unwrap_or_default() + .size + .height + .max(size) + } else { + size + }; + + let center = (Point::new(size, indicator_layout_height) / 2).round(); + context + .gfx + .draw_shape(path.fill(color).translate_by(center).rotate_by(angle)); + + let stroke_options = if context.focused(true) { + stroke.colored(context.get(&HighlightColor)) + } else { + StrokeOptions::px_wide(Lp::points(1).into_px(context.gfx.scale()).round()) + .colored(stroke_color) + }; + context.gfx.draw_shape( + path.stroke(stroke_options) + .translate_by(center) + .rotate_by(angle), + ); + + let contents = self.contents.mounted(context); + context.for_other(&contents).redraw(); + } + + fn layout( + &mut self, + mut available_space: Size, + context: &mut LayoutContext<'_, '_, '_, '_, '_>, + ) -> Size { + let indicator_size = context + .get(&IndicatorSize) + .into_upx(context.gfx.scale()) + .round(); + let padding = context + .get(&IntrinsicPadding) + .into_upx(context.gfx.scale()) + .round(); + + let content_inset = indicator_size + padding; + available_space.width -= content_inset; + + let label_size = if let Some(label) = &mut self.label { + let label = label.mounted(context); + let label_size = context.for_other(&label).layout(available_space); + let label_vertical_offset = if label_size.height < indicator_size { + (indicator_size - label_size.height).round() + } else { + UPx::ZERO + }; + context.set_child_layout( + &label, + Rect::new(Point::new(content_inset, label_vertical_offset), label_size) + .into_signed(), + ); + Size::new(label_size.width, label_size.height.max(indicator_size)) + } else { + Size::ZERO + }; + + let content_vertical_offset = if label_size.height > 0 { + label_size.height + padding + } else { + label_size.height + }; + + available_space.height -= content_vertical_offset; + + let contents = self.contents.mounted(context); + let content_size = context.for_other(&contents).layout(available_space); + let content_rect = Rect::new( + Point::new(content_inset, content_vertical_offset), + content_size, + ); + context.set_child_layout(&contents, content_rect.into_signed()); + Size::new( + content_inset + content_rect.size.width.max(label_size.width), + indicator_size.max(content_rect.origin.y + content_rect.size.height), + ) + } + + fn hit_test( + &mut self, + location: Point, + context: &mut crate::context::EventContext<'_, '_>, + ) -> bool { + let size = context + .get(&IndicatorSize) + .into_px(context.kludgine.scale()) + .round(); + if let Some(label) = &mut self.label { + let layout = label.mounted(context).last_layout().unwrap_or_default(); + + location.y < size.max(layout.size.height) + } else { + location.x < size && location.y < size + } + } + + fn hover( + &mut self, + location: Point, + context: &mut crate::context::EventContext<'_, '_>, + ) -> Option { + let hovering = self.hit_test(location, context); + if self.hovering_indicator != hovering { + context.set_needs_redraw(); + self.hovering_indicator = true; + } + + hovering.then_some(CursorIcon::Pointer) + } + + fn unhover(&mut self, context: &mut crate::context::EventContext<'_, '_>) { + if self.hovering_indicator { + self.hovering_indicator = false; + context.set_needs_redraw(); + } + } + + fn mouse_down( + &mut self, + location: Point, + _device_id: kludgine::app::winit::event::DeviceId, + _button: kludgine::app::winit::event::MouseButton, + context: &mut crate::context::EventContext<'_, '_>, + ) -> EventHandling { + if self.hit_test(location, context) { + self.mouse_buttons_pressed += 1; + self.activate(context); + HANDLED + } else { + IGNORED + } + } + + fn mouse_up( + &mut self, + _location: Option>, + _device_id: kludgine::app::winit::event::DeviceId, + _button: kludgine::app::winit::event::MouseButton, + context: &mut crate::context::EventContext<'_, '_>, + ) { + self.mouse_buttons_pressed -= 1; + if self.mouse_buttons_pressed == 0 { + self.deactivate(context); + self.collapsed.toggle(); + } + } + + fn activate(&mut self, _context: &mut crate::context::EventContext<'_, '_>) { + if self.mouse_buttons_pressed == 0 { + self.collapsed.toggle(); + } + } +} + +define_components! { + Disclose { + /// The size to render a [`Disclose`] indicator. + IndicatorSize(Dimension, "size", @TextSize) + } +}