Added Disclose widget

This commit is contained in:
Jonathan Johnson 2023-12-28 14:12:26 -08:00
parent 2fe28729df
commit a0478e266a
No known key found for this signature in database
GPG key ID: A66D6A34D6620579
10 changed files with 420 additions and 42 deletions

View file

@ -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 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 continue to work. This was added as an attempt to fix links on docs.rs (see
rust-lang/docs.rs#1588). 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 ## v0.2.0

4
Cargo.lock generated
View file

@ -738,9 +738,9 @@ dependencies = [
[[package]] [[package]]
name = "figures" name = "figures"
version = "0.2.0" version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63172cefd2b4018ab01e207ce8b951e757575412d58a9370619d19ea16ddc948" checksum = "813f0f9e0ba0d378a8e2cd51df24dd724ba8fbc07baa3dd192813eeba407ea86"
dependencies = [ dependencies = [
"bytemuck", "bytemuck",
"euclid", "euclid",

View file

@ -19,7 +19,7 @@ roboto-flex = []
[dependencies] [dependencies]
kludgine = { version = "0.7.0", features = ["app"] } kludgine = { version = "0.7.0", features = ["app"] }
figures = "0.2.0" figures = "0.2.1"
# kludgine = { git = "https://github.com/khonsulabs/kludgine", features = [ # kludgine = { git = "https://github.com/khonsulabs/kludgine", features = [
# "app", # "app",
# ] } # ] }

14
examples/disclose.rs Normal file
View file

@ -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()
}

View file

@ -642,16 +642,13 @@ impl<'context, 'window, 'clip, 'gfx, 'pass> GraphicsContext<'context, 'window, '
/// Invokes [`Widget::redraw()`](crate::widget::Widget::redraw) on this /// Invokes [`Widget::redraw()`](crate::widget::Widget::redraw) on this
/// context's widget. /// 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) { pub fn redraw(&mut self) {
assert!( let Some(layout) = self.last_layout() else {
self.last_layout().is_some(), return;
"redraw called without set_widget_layout" };
); if layout.size.width <= 0 || layout.size.height <= 0 {
return;
}
self.tree.note_widget_rendered(self.current_node.node_id); self.tree.note_widget_rendered(self.current_node.node_id);
let widget = self.current_node.clone(); let widget = self.current_node.clone();

View file

@ -21,7 +21,7 @@ pub mod value;
pub mod widget; pub mod widget;
pub mod widgets; pub mod widgets;
pub mod window; pub mod window;
use std::ops::Sub; use std::ops::{Add, AddAssign, Sub, SubAssign};
pub use app::{App, Application, Cushy, Open, PendingApp, Run}; pub use app::{App, Application, Cushy, Open, PendingApp, Run};
use figures::units::UPx; use figures::units::UPx;
@ -102,14 +102,39 @@ impl FitMeasuredSize for Size<ConstraintLimit> {
} }
} }
impl Add<UPx> for ConstraintLimit {
type Output = Self;
fn add(mut self, rhs: UPx) -> Self::Output {
self += rhs;
self
}
}
impl AddAssign<UPx> 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<UPx> for ConstraintLimit { impl Sub<UPx> for ConstraintLimit {
type Output = Self; type Output = Self;
fn sub(self, rhs: UPx) -> Self::Output { fn sub(mut self, rhs: UPx) -> Self::Output {
match self { self -= rhs;
self
}
}
impl SubAssign<UPx> for ConstraintLimit {
fn sub_assign(&mut self, rhs: UPx) {
*self = match *self {
ConstraintLimit::Fill(px) => ConstraintLimit::Fill(px.saturating_sub(rhs)), ConstraintLimit::Fill(px) => ConstraintLimit::Fill(px.saturating_sub(rhs)),
ConstraintLimit::SizeToFit(px) => ConstraintLimit::SizeToFit(px.saturating_sub(rhs)), ConstraintLimit::SizeToFit(px) => ConstraintLimit::SizeToFit(px.saturating_sub(rhs)),
} };
} }
} }

View file

@ -39,8 +39,8 @@ use crate::value::{Dynamic, Generation, IntoDynamic, IntoValue, Validation, Valu
use crate::widgets::checkbox::{Checkable, CheckboxState}; use crate::widgets::checkbox::{Checkable, CheckboxState};
use crate::widgets::layers::{OverlayLayer, Tooltipped}; use crate::widgets::layers::{OverlayLayer, Tooltipped};
use crate::widgets::{ use crate::widgets::{
Align, Button, Checkbox, Collapse, Container, Expand, Layers, Resize, Scroll, Space, Stack, Align, Button, Checkbox, Collapse, Container, Disclose, Expand, Layers, Resize, Scroll, Space,
Style, Themed, ThemedMode, Validated, Wrap, Stack, Style, Themed, ThemedMode, Validated, Wrap,
}; };
use crate::window::{RunningWindow, ThemeMode, Window, WindowBehavior, WindowHandle}; use crate::window::{RunningWindow, ThemeMode, Window, WindowBehavior, WindowHandle};
use crate::ConstraintLimit; use crate::ConstraintLimit;
@ -1298,6 +1298,11 @@ pub trait MakeWidget: Sized {
Collapse::vertical(collapse_when, self) 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. /// Returns a widget that shows validation errors and/or hints.
fn validation(self, validation: impl IntoDynamic<Validation>) -> Validated { fn validation(self, validation: impl IntoDynamic<Validation>) -> Validated {
Validated::new(validation, self) Validated::new(validation, self)

View file

@ -9,6 +9,7 @@ pub mod color;
pub mod container; pub mod container;
mod custom; mod custom;
mod data; mod data;
pub mod disclose;
mod expand; mod expand;
pub mod grid; pub mod grid;
pub mod image; pub mod image;
@ -39,6 +40,7 @@ pub use collapse::Collapse;
pub use container::Container; pub use container::Container;
pub use custom::Custom; pub use custom::Custom;
pub use data::Data; pub use data::Data;
pub use disclose::Disclose;
pub use expand::Expand; pub use expand::Expand;
pub use image::Image; pub use image::Image;
pub use input::Input; pub use input::Input;

View file

@ -587,32 +587,20 @@ fn shadow_arc(
context: &mut GraphicsContext<'_, '_, '_, '_, '_>, context: &mut GraphicsContext<'_, '_, '_, '_, '_>,
) { ) {
let full_radius = radius + gradient; let full_radius = radius + gradient;
let mut current_outer_arc = rotate_point( let mut current_outer_arc = origin + Point::new(full_radius, Px::ZERO).rotate_by(start_angle);
origin,
Point::new(origin.x + full_radius, origin.y), let mut current_inner_arc = origin + Point::new(radius, Px::ZERO).rotate_by(start_angle);
start_angle,
);
let mut current_inner_arc =
rotate_point(origin, Point::new(origin.x + radius, origin.y), start_angle);
let mut angle = Angle::degrees(0); let mut angle = Angle::degrees(0);
while angle < Angle::degrees(90) { while angle < Angle::degrees(90) {
angle += Angle::degrees(5); angle += Angle::degrees(5);
let outer_arc = rotate_point( let outer_arc = origin + Point::new(full_radius, Px::ZERO).rotate_by(start_angle + angle);
origin,
Point::new(origin.x + full_radius, origin.y),
start_angle + angle,
);
if outer_arc == current_outer_arc { if outer_arc == current_outer_arc {
continue; continue;
} }
let inner_arc = rotate_point( let inner_arc = origin + Point::new(radius, Px::ZERO).rotate_by(start_angle + angle);
origin,
Point::new(origin.x + radius, origin.y),
start_angle + angle,
);
let mut path = PathBuilder::new((current_inner_arc, solid_color)); let mut path = PathBuilder::new((current_inner_arc, solid_color));
path = path path = path
@ -638,13 +626,6 @@ fn shadow_arc(
} }
} }
fn rotate_point(origin: Point<Px>, point: Point<Px>, angle: Angle) -> Point<Px> {
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`]. /// The selected background configuration of a [`Container`].
#[derive(Debug, Clone, Copy, Eq, PartialEq)] #[derive(Debug, Clone, Copy, Eq, PartialEq)]
pub enum EffectiveBackground { pub enum EffectiveBackground {

350
src/widgets/disclose.rs Normal file
View file

@ -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<WidgetInstance>,
collapsed: Value<bool>,
}
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<bool>) -> 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<WidgetRef>,
contents: WidgetRef,
collapsed: Dynamic<bool>,
hovering_indicator: bool,
target_colors: Option<(Color, Color)>,
color_animation: AnimationHandle,
color: Dynamic<Color>,
stroke_color: Dynamic<Color>,
angle: Dynamic<Angle>,
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<bool>,
label: Option<WidgetInstance>,
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<ConstraintLimit>,
context: &mut LayoutContext<'_, '_, '_, '_, '_>,
) -> Size<UPx> {
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<Px>,
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<Px>,
context: &mut crate::context::EventContext<'_, '_>,
) -> Option<CursorIcon> {
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<Px>,
_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<Point<Px>>,
_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)
}
}