Default + Cancel widgets

This commit is contained in:
Jonathan Johnson 2023-11-08 11:03:17 -08:00
parent 5e055376e7
commit bf9836a82b
No known key found for this signature in database
GPG key ID: A66D6A34D6620579
11 changed files with 450 additions and 60 deletions

70
examples/login.rs Normal file
View file

@ -0,0 +1,70 @@
use std::process::exit;
use gooey::value::Dynamic;
use gooey::widget::MakeWidget;
use gooey::widgets::{Align, Button, Expand, Input, Label, Resize, Stack};
use gooey::{children, Run, WithClone};
use kludgine::figures::units::Lp;
fn main() -> gooey::Result {
let username = Dynamic::default();
let password = Dynamic::default();
// TODO This is absolutely horrible. The problem is that within for_each,
// the value is still locked. Thus, we can't have a generic callback that
// tries to lock the value that is being mapped in for_each.
//
// We might be able to make a genericized implementation for_each for
// tuples, ie, (&Dynamic, &Dynamic).for_each(|(a, b)| ..).
let valid = Dynamic::default();
username.for_each((&valid, &password).with_clone(|(valid, password)| {
move |username: &String| {
password.map_ref(|password| valid.update(validate(username, password)))
}
}));
password.for_each((&valid, &username).with_clone(|(valid, username)| {
move |password: &String| {
username.map_ref(|username| valid.update(validate(username, password)))
}
}));
Expand::new(Align::centered(Resize::width(
// TODO We need a min/max range for the Resize widget
Lp::points(400),
Stack::rows(children![
Stack::columns(children![
Label::new("Username"),
Expand::new(Align::centered(Input::new(username.clone())).fit_horizontally()),
]),
Stack::columns(children![
Label::new("Password"),
Expand::new(
Align::centered(
// TODO secure input
Input::new(password.clone())
)
.fit_horizontally()
),
]),
Stack::columns(children![
Button::new("Cancel").on_click(|_| exit(0)).into_escape(),
Expand::empty(),
Button::new("Log In")
.on_click(move |_| {
if valid.get() {
println!("Welcome, {}", username.get());
exit(0);
} else {
eprintln!("Enter a username and password")
}
})
.into_default(), // TODO enable/disable based on valid
]),
]),
)))
.run()
}
fn validate(username: &String, password: &String) -> bool {
!username.is_empty() && !password.is_empty()
}

View file

@ -805,6 +805,28 @@ impl<'context, 'window> WidgetContext<'context, 'window> {
self.pending_state.focus.as_ref() == Some(&self.current_node)
}
/// Returns true if this widget is the target to activate when the user
/// triggers a default action.
///
/// See
/// [`MakeWidget::into_default()`](crate::widget::MakeWidget::into_default)
/// for more information.
#[must_use]
pub fn is_default(&self) -> bool {
self.current_node.tree.default_widget() == Some(self.current_node.id())
}
/// Returns true if this widget is the target to activate when the user
/// triggers an escape action.
///
/// See
/// [`MakeWidget::into_escape()`](crate::widget::MakeWidget::into_escape)
/// for more information.
#[must_use]
pub fn is_escape(&self) -> bool {
self.current_node.tree.escape_widget() == Some(self.current_node.id())
}
/// Returns the widget this context is for.
#[must_use]
pub const fn widget(&self) -> &ManagedWidget {

View file

@ -63,6 +63,24 @@ impl ComponentDefinition for TextColor {
}
}
/// A [`Color`] to be used as a highlight color.
#[derive(Clone, Copy, Eq, PartialEq, Debug)]
pub struct PrimaryColor;
impl NamedComponent for PrimaryColor {
fn name(&self) -> Cow<'_, ComponentName> {
Cow::Owned(ComponentName::named::<Global>("primary_color"))
}
}
impl ComponentDefinition for PrimaryColor {
type ComponentType = Color;
fn default_value(&self) -> Color {
Color::BLUE
}
}
/// A [`Color`] to be used as a highlight color.
#[derive(Clone, Copy, Eq, PartialEq, Debug)]
pub struct HighlightColor;

View file

@ -32,6 +32,12 @@ impl Tree {
styles: None,
},
);
if widget.is_default() {
data.defaults.push(id);
}
if widget.is_escape() {
data.escapes.push(id);
}
if let Some(parent) = parent {
let parent = data.nodes.get_mut(&parent.id()).expect("missing parent");
parent.children.push(id);
@ -48,6 +54,13 @@ impl Tree {
pub fn remove_child(&self, child: &ManagedWidget, parent: &ManagedWidget) {
let mut data = self.data.lock().map_or_else(PoisonError::into_inner, |g| g);
data.remove_child(child.id(), parent.id());
if child.widget.is_default() {
data.defaults.retain(|id| *id != child.id());
}
if child.widget.is_escape() {
data.escapes.retain(|id| *id != child.id());
}
}
pub(crate) fn set_layout(&self, widget: WidgetId, rect: Rect<Px>) {
@ -204,6 +217,24 @@ impl Tree {
.hover
}
pub fn default_widget(&self) -> Option<WidgetId> {
self.data
.lock()
.map_or_else(PoisonError::into_inner, |g| g)
.defaults
.last()
.copied()
}
pub fn escape_widget(&self) -> Option<WidgetId> {
self.data
.lock()
.map_or_else(PoisonError::into_inner, |g| g)
.escapes
.last()
.copied()
}
pub fn is_hovered(&self, id: WidgetId) -> bool {
let data = self.data.lock().map_or_else(PoisonError::into_inner, |g| g);
let mut search = data.hover;
@ -284,6 +315,8 @@ struct TreeData {
active: Option<WidgetId>,
focus: Option<WidgetId>,
hover: Option<WidgetId>,
defaults: Vec<WidgetId>,
escapes: Vec<WidgetId>,
render_order: Vec<WidgetId>,
previous_focuses: HashMap<WidgetId, WidgetId>,
}

View file

@ -194,6 +194,34 @@ pub trait MakeWidget: Sized {
fn with_next_focus(self, next_focus: impl IntoValue<Option<WidgetId>>) -> WidgetInstance {
self.make_widget().with_next_focus(next_focus)
}
/// Sets this widget as a "default" widget.
///
/// Default widgets are automatically activated when the user signals they
/// are ready for the default action to occur.
///
/// Example widgets this is used for are:
///
/// - Submit buttons on forms
/// - Ok buttons
#[must_use]
fn into_default(self) -> WidgetInstance {
self.make_widget().into_default()
}
/// Sets this widget as an "escape" widget.
///
/// Escape widgets are automatically activated when the user signals they
/// are ready to escape their current situation.
///
/// Example widgets this is used for are:
///
/// - Close buttons
/// - Cancel buttons
#[must_use]
fn into_escape(self) -> WidgetInstance {
self.make_widget().into_escape()
}
}
/// A type that can create a [`WidgetInstance`] with a preallocated
@ -265,9 +293,16 @@ where
/// An instance of a [`Widget`].
#[derive(Clone, Debug)]
pub struct WidgetInstance {
data: Arc<WidgetInstanceData>,
}
#[derive(Debug)]
struct WidgetInstanceData {
id: WidgetId,
widget: Arc<Mutex<dyn AnyWidget>>,
default: bool,
cancel: bool,
next_focus: Value<Option<WidgetId>>,
widget: Box<Mutex<dyn AnyWidget>>,
}
impl WidgetInstance {
@ -278,9 +313,13 @@ impl WidgetInstance {
W: Widget,
{
Self {
id: id.into(),
widget: Arc::new(Mutex::new(widget)),
next_focus: Value::default(),
data: Arc::new(WidgetInstanceData {
id: id.into(),
next_focus: Value::default(),
default: false,
cancel: false,
widget: Box::new(Mutex::new(widget)),
}),
}
}
@ -295,19 +334,70 @@ impl WidgetInstance {
/// Returns the unique id of this widget instance.
#[must_use]
pub fn id(&self) -> WidgetId {
self.id
self.data.id
}
/// Sets the widget that should be focused next.
///
/// Gooey automatically determines reverse tab order by using this same
/// relationship.
///
/// # Panics
///
/// This function can only be called when one instance of the widget exists.
/// If any clones exist, a panic will occur.
#[must_use]
pub fn with_next_focus(
mut self,
next_focus: impl IntoValue<Option<WidgetId>>,
) -> WidgetInstance {
self.next_focus = next_focus.into_value();
let data = Arc::get_mut(&mut self.data)
.expect("with_next_focus can only be called on newly created widget instances");
data.next_focus = next_focus.into_value();
self
}
/// Sets this widget as a "default" widget.
///
/// Default widgets are automatically activated when the user signals they
/// are ready for the default action to occur.
///
/// Example widgets this is used for are:
///
/// - Submit buttons on forms
/// - Ok buttons
///
/// # Panics
///
/// This function can only be called when one instance of the widget exists.
/// If any clones exist, a panic will occur.
#[must_use]
pub fn into_default(mut self) -> WidgetInstance {
let data = Arc::get_mut(&mut self.data)
.expect("with_next_focus can only be called on newly created widget instances");
data.default = true;
self
}
/// Sets this widget as an "escape" widget.
///
/// Escape widgets are automatically activated when the user signals they
/// are ready to escape their current situation.
///
/// Example widgets this is used for are:
///
/// - Close buttons
/// - Cancel buttons
///
/// # Panics
///
/// This function can only be called when one instance of the widget exists.
/// If any clones exist, a panic will occur.
#[must_use]
pub fn into_escape(mut self) -> WidgetInstance {
let data = Arc::get_mut(&mut self.data)
.expect("with_next_focus can only be called on newly created widget instances");
data.cancel = true;
self
}
@ -316,7 +406,8 @@ impl WidgetInstance {
/// occur due to other widget locks being held.
pub fn lock(&self) -> WidgetGuard<'_> {
WidgetGuard(
self.widget
self.data
.widget
.lock()
.map_or_else(PoisonError::into_inner, |g| g),
)
@ -333,12 +424,29 @@ impl WidgetInstance {
/// This value comes from [`MakeWidget::with_next_focus()`].
#[must_use]
pub fn next_focus(&self) -> Option<WidgetId> {
self.next_focus.get()
self.data.next_focus.get()
}
/// Returns true if this is a default widget.
///
/// See [`MakeWidget::into_default()`] for more information.
#[must_use]
pub fn is_default(&self) -> bool {
self.data.default
}
/// Returns true if this is an escape widget.
///
/// See [`MakeWidget::into_escape()`] for more information.
#[must_use]
pub fn is_escape(&self) -> bool {
self.data.cancel
}
}
impl AsRef<WidgetId> for WidgetInstance {
fn as_ref(&self) -> &WidgetId {
&self.id
&self.data.id
}
}
@ -346,7 +454,7 @@ impl Eq for WidgetInstance {}
impl PartialEq for WidgetInstance {
fn eq(&self, other: &Self) -> bool {
Arc::ptr_eq(&self.widget, &other.widget)
Arc::ptr_eq(&self.data, &other.data)
}
}
@ -435,7 +543,7 @@ impl ManagedWidget {
/// Returns the unique id of this widget instance.
#[must_use]
pub fn id(&self) -> WidgetId {
self.widget.id
self.widget.id()
}
/// Returns the next widget to focus after this widget.

View file

@ -4,7 +4,7 @@ use kludgine::figures::units::UPx;
use kludgine::figures::{Fraction, IntoSigned, IntoUnsigned, Point, Rect, ScreenScale, Size};
use crate::context::{AsEventContext, GraphicsContext, LayoutContext};
use crate::styles::{Edges, FlexibleDimension};
use crate::styles::{Dimension, Edges, FlexibleDimension};
use crate::value::{IntoValue, Value};
use crate::widget::{MakeWidget, Widget, WidgetRef};
use crate::ConstraintLimit;
@ -32,6 +32,26 @@ impl Align {
Self::new(FlexibleDimension::Auto, widget)
}
/// Sets the left and right edges to 0 and returns self.
#[must_use]
pub fn fit_horizontally(mut self) -> Self {
self.edges.map_mut(|edges| {
edges.left = FlexibleDimension::Dimension(Dimension::default());
edges.right = FlexibleDimension::Dimension(Dimension::default());
});
self
}
/// Sets the top and bottom edges to 0 and returns self.
#[must_use]
pub fn fit_vertically(mut self) -> Self {
self.edges.map_mut(|edges| {
edges.top = FlexibleDimension::Dimension(Dimension::default());
edges.bottom = FlexibleDimension::Dimension(Dimension::default());
});
self
}
fn measure(
&mut self,
available_space: Size<ConstraintLimit>,
@ -102,7 +122,7 @@ impl FrameInfo {
fn measure(&self, available: ConstraintLimit, content: UPx) -> (UPx, UPx, UPx) {
match available {
ConstraintLimit::Known(size) => {
let remaining = size - content;
let remaining = size.saturating_sub(content);
let (a, b) = match (self.a, self.b) {
(Some(a), Some(b)) => (a, b),
(Some(a), None) => (a, remaining - a),

View file

@ -4,7 +4,6 @@ use std::panic::UnwindSafe;
use std::time::Duration;
use kludgine::app::winit::event::{DeviceId, ElementState, KeyEvent, MouseButton};
use kludgine::app::winit::keyboard::KeyCode;
use kludgine::figures::units::{Px, UPx};
use kludgine::figures::{IntoUnsigned, Point, Rect, ScreenScale, Size};
use kludgine::shapes::Shape;
@ -14,8 +13,11 @@ use kludgine::Color;
use crate::animation::{AnimationHandle, AnimationTarget, Spawn};
use crate::context::{EventContext, GraphicsContext, LayoutContext, WidgetContext};
use crate::names::Name;
use crate::styles::components::{Easing, HighlightColor, IntrinsicPadding, TextColor};
use crate::styles::components::{
Easing, HighlightColor, IntrinsicPadding, PrimaryColor, TextColor,
};
use crate::styles::{ComponentDefinition, ComponentGroup, ComponentName, NamedComponent};
use crate::utils::ModifiersExt;
use crate::value::{Dynamic, IntoValue, Value};
use crate::widget::{Callback, EventHandling, Widget, HANDLED, IGNORED};
@ -66,12 +68,15 @@ impl Button {
&ButtonActiveBackground,
&ButtonBackground,
&ButtonHoverBackground,
&PrimaryColor,
&Easing,
]);
let background_color = if context.active() {
styles.get_or_default(&ButtonActiveBackground)
} else if context.hovered() {
styles.get_or_default(&ButtonHoverBackground)
} else if context.is_default() {
styles.get_or_default(&PrimaryColor)
} else {
styles.get_or_default(&ButtonBackground)
};
@ -237,13 +242,18 @@ impl Widget for Button {
_is_synthetic: bool,
context: &mut EventContext<'_, '_>,
) -> EventHandling {
if input.physical_key == KeyCode::Space {
// TODO should this be handled at the window level?
if input.text.as_deref() == Some(" ") && !context.modifiers().possible_shortcut() {
let changed = match input.state {
ElementState::Pressed => context.activate(),
ElementState::Released => {
self.invoke_on_click();
context.deactivate()
ElementState::Pressed => {
let changed = context.activate();
if !changed {
// The widget was already active. This is now a repeated keypress
self.invoke_on_click();
}
changed
}
ElementState::Released => context.deactivate(),
};
if changed {
context.set_needs_redraw();
@ -271,6 +281,11 @@ impl Widget for Button {
}
fn activate(&mut self, context: &mut EventContext<'_, '_>) {
// If we have no buttons pressed, the event should fire on activate not
// on deactivate.
if self.buttons_pressed == 0 {
self.invoke_on_click();
}
self.update_background_color(context, true);
}

View file

@ -14,7 +14,13 @@ pub struct Expand {
/// The weight to use when splitting available space with multiple
/// [`Expand`] widgets.
pub weight: u8,
child: WidgetRef,
child: Option<WidgetRef>,
}
impl Default for Expand {
fn default() -> Self {
Self::empty()
}
}
impl Expand {
@ -22,7 +28,16 @@ impl Expand {
#[must_use]
pub fn new(child: impl MakeWidget) -> Self {
Self {
child: WidgetRef::new(child),
child: Some(WidgetRef::new(child)),
weight: 1,
}
}
/// Returns a widget that expands to fill its parent, but has no contents.
#[must_use]
pub fn empty() -> Self {
Self {
child: None,
weight: 1,
}
}
@ -34,21 +49,22 @@ impl Expand {
#[must_use]
pub fn weighted(weight: u8, child: impl MakeWidget) -> Self {
Self {
child: WidgetRef::new(child),
child: Some(WidgetRef::new(child)),
weight,
}
}
/// Returns a reference to the child widget.
#[must_use]
pub fn child(&self) -> &WidgetRef {
&self.child
pub fn child(&self) -> Option<&WidgetRef> {
self.child.as_ref()
}
}
impl Widget for Expand {
fn redraw(&mut self, context: &mut GraphicsContext<'_, '_, '_, '_, '_>) {
let child = self.child.mounted(&mut context.as_event_context());
let Some(child) = &mut self.child else { return };
let child = child.mounted(&mut context.as_event_context());
context.for_other(&child).redraw();
}
@ -61,8 +77,15 @@ impl Widget for Expand {
ConstraintLimit::Known(available_space.width.max()),
ConstraintLimit::Known(available_space.height.max()),
);
let child = self.child.mounted(&mut context.as_event_context());
let size = context.for_other(&child).layout(available_space);
let child = self
.child
.as_mut()
.map(|child| child.mounted(&mut context.as_event_context()));
let size = if let Some(child) = &child {
context.for_other(child).layout(available_space)
} else {
Size::default()
};
let expanded_size = Size::new(
available_space
@ -72,7 +95,11 @@ impl Widget for Expand {
.height
.fit_measured(size.height, context.graphics.scale()),
);
context.set_child_layout(&child, Rect::from(expanded_size.into_signed()));
if let Some(child) = child {
context.set_child_layout(&child, Rect::from(expanded_size.into_signed()));
}
expanded_size
}
}

View file

@ -100,6 +100,12 @@ impl Input {
}
}
impl Default for Input {
fn default() -> Self {
Self::new(String::new())
}
}
impl Debug for Input {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Input")
@ -393,8 +399,14 @@ impl Widget for Input {
);
(false, HANDLED)
}
(_, Some(text)) if !context.modifiers().state().primary() && text != "\t" => {
editor.insert_string(&text, None);
(_, Some(text))
if !context.modifiers().primary()
&& text != "\t" // tab
&& text != "\r" // enter/return
&& text != "\u{1b}" // escape
=>
{
editor.insert_string(dbg!(&text), None);
(true, HANDLED)
}
(_, _) => (false, IGNORED),
@ -438,10 +450,12 @@ impl Widget for Input {
fn focus(&mut self, context: &mut EventContext<'_, '_>) {
context.set_ime_allowed(true);
context.set_needs_redraw();
}
fn blur(&mut self, context: &mut EventContext<'_, '_>) {
context.set_ime_allowed(false);
context.set_needs_redraw();
}
}

View file

@ -11,7 +11,7 @@ use crate::context::{AsEventContext, EventContext, GraphicsContext, LayoutContex
use crate::styles::Dimension;
use crate::value::{Generation, IntoValue, Value};
use crate::widget::{Children, ManagedWidget, Widget, WidgetRef};
use crate::widgets::{Expand, Resize};
use crate::widgets::{Expand, Label, Resize};
use crate::ConstraintLimit;
/// A widget that displays a collection of [`Widgets`] in a
@ -87,12 +87,21 @@ impl Stack {
let guard = widget.lock();
let (mut widget, dimension) =
if let Some(expand) = guard.downcast_ref::<Expand>() {
(
expand.child().clone(),
StackDimension::Fractional {
weight: expand.weight,
},
)
if let Some(child) = expand.child() {
(
child.clone(),
StackDimension::Fractional {
weight: expand.weight,
},
)
} else {
(
WidgetRef::new(Label::new("")), // TODO this should be an empty widget.
StackDimension::Fractional {
weight: expand.weight,
},
)
}
} else if let Some((child, size)) =
guard.downcast_ref::<Resize>().and_then(|r| {
match self.layout.orientation.orientation {

View file

@ -14,7 +14,7 @@ use kludgine::app::winit::dpi::PhysicalPosition;
use kludgine::app::winit::event::{
DeviceId, ElementState, Ime, KeyEvent, MouseButton, MouseScrollDelta, TouchPhase,
};
use kludgine::app::winit::keyboard::KeyCode;
use kludgine::app::winit::keyboard::Key;
use kludgine::app::WindowBehavior as _;
use kludgine::figures::units::Px;
use kludgine::figures::{IntoSigned, Point, Rect, Size};
@ -30,7 +30,9 @@ use crate::styles::components::VisualOrder;
use crate::tree::Tree;
use crate::utils::ModifiersExt;
use crate::value::{Dynamic, IntoDynamic};
use crate::widget::{EventHandling, ManagedWidget, Widget, WidgetInstance, HANDLED, IGNORED};
use crate::widget::{
EventHandling, ManagedWidget, Widget, WidgetId, WidgetInstance, HANDLED, IGNORED,
};
use crate::window::sealed::WindowCommand;
use crate::{ConstraintLimit, Run};
@ -243,6 +245,7 @@ struct GooeyWindow<T> {
initial_frame: bool,
occluded: Dynamic<bool>,
focused: Dynamic<bool>,
keyboard_activated: Option<ManagedWidget>,
}
impl<T> GooeyWindow<T>
@ -254,6 +257,38 @@ where
self.should_close
}
fn keyboard_activate_widget(
&mut self,
is_pressed: bool,
widget: Option<WidgetId>,
window: &mut RunningWindow<'_>,
kludgine: &mut Kludgine,
) {
if is_pressed {
if let Some(default) = widget.and_then(|id| self.root.tree.widget(id)) {
if let Some(previously_active) = self.keyboard_activated.take() {
EventContext::new(
WidgetContext::new(previously_active, &self.redraw_status, window),
kludgine,
)
.deactivate();
}
EventContext::new(
WidgetContext::new(default.clone(), &self.redraw_status, window),
kludgine,
)
.activate();
self.keyboard_activated = Some(default);
}
} else if let Some(keyboard_activated) = self.keyboard_activated.take() {
EventContext::new(
WidgetContext::new(keyboard_activated, &self.redraw_status, window),
kludgine,
)
.deactivate();
}
}
}
impl<T> kludgine::app::WindowBehavior<WindowCommand> for GooeyWindow<T>
@ -299,6 +334,7 @@ where
initial_frame: true,
occluded,
focused,
keyboard_activated: None,
}
}
@ -442,32 +478,50 @@ where
drop(target);
if !handled {
match input.physical_key {
KeyCode::KeyW
if window.modifiers().state().primary() && input.state.is_pressed() =>
{
if self.request_close(&mut window) {
match input.logical_key {
Key::Character(ch) if ch == "w" && window.modifiers().primary() => {
if input.state.is_pressed() && self.request_close(&mut window) {
window.set_needs_redraw();
}
}
KeyCode::Tab
if !window.modifiers().state().possible_shortcut()
&& input.state.is_pressed() =>
{
let direction = if window.modifiers().state().shift_key() {
VisualOrder::left_to_right().rev()
} else {
VisualOrder::left_to_right()
};
let target = self.root.tree.focused_widget().unwrap_or(self.root.id());
let target = self.root.tree.widget(target).expect("missing widget");
let mut target = EventContext::new(
WidgetContext::new(target, &self.redraw_status, &mut window),
Key::Tab if !window.modifiers().possible_shortcut() => {
if input.state.is_pressed() {
let direction = if window.modifiers().state().shift_key() {
VisualOrder::left_to_right().rev()
} else {
VisualOrder::left_to_right()
};
let target = self.root.tree.focused_widget().unwrap_or(self.root.id());
let target = self.root.tree.widget(target).expect("missing widget");
let mut target = EventContext::new(
WidgetContext::new(target, &self.redraw_status, &mut window),
kludgine,
);
target.advance_focus(direction);
}
}
Key::Enter => {
self.keyboard_activate_widget(
input.state.is_pressed(),
self.root.tree.default_widget(),
&mut window,
kludgine,
);
target.advance_focus(direction);
}
_ => {}
Key::Escape => {
self.keyboard_activate_widget(
input.state.is_pressed(),
self.root.tree.escape_widget(),
&mut window,
kludgine,
);
}
_ => {
println!(
"Ignored Keyboard Input: {:?} ({:?}); {:?}",
input.logical_key, input.physical_key, input.state
);
}
}
}
}