WARNING: Cushy is in early alpha. This guide doubly so.
Composing User Interfaces
Designing user interfaces in Cushy can feel different than in other frameworks. Part of what makes Cushy unique is its reactive data model. The other significant architectural design was to focus on composition.
The content area of each window in Cushy is a single widget. A window cannot have more than one widget at its root. So, how is a user interface with multiple widgets built? Through composition.
Cushy has a category of widgets dedicated to composing multiple widgets into a
single widget: multi-widget layout widgets. For
example, the Stack
widget positions its children
horizontally as a set of columns or vertically as a set of rows, while the
Layers
widget positions its children on top of
each other in the Z direction.
The power to this approach is that adding new layout strategies is as simple as
implementing a new Widget
that implements
Widget::layout
, and that new widget can be used anywhere in
Cushy that any other widget can be used.
Creating Composable Widgets
At the core of widget composition are the MakeWidget
and
MakeWidgetWithTag
traits. The goal of both of these traits is
simple: transform self
into a WidgetInstance
.
A WidgetInstance
is a type-erased, reference-counted instance of a
Widget
trait implementation. This type enables widgets to refer to
other widgets without knowing their underlying types. For example, the label of
a Button
can be any widget, not just a string, because it accepts a
MakeWidget
implementor in its constructor.
Let's see the various ways that Cushy offers to create the same component: a labeled form field.
Example: A FormField
widget
Let's design a way to present a widget resembling this reusable structure:
Label
Field
The structure definition for this widget might look like this:
#![allow(unused)] fn main() { use cushy::widget::{MakeWidget, WidgetInstance}; struct FormField { label: Value<String>, field: WidgetInstance, } impl FormField { pub fn new(label: impl IntoValue<String>, field: impl MakeWidget) -> Self { Self { label: label.into_value(), field: field.make_widget(), } } } }
While it would arguably be better to accept label
as another WidgetInstance
,
by focusing on composing a single widget, this example can also include a
utility trait: WrapperWidget
.
Approach A: Using the MakeWidget
trait
The simplest approach to making new widgets is to avoid implementing them at
all! In this case, we can reuse the existing Stack
and
Align
widgets to position the label and field. So, instead of
creating a Widget
implementation, if we implement MakeWidget
, we can compose
our interface using existing widgets:
#![allow(unused)] fn main() { impl MakeWidget for FormField { fn make_widget(self) -> WidgetInstance { self.label .align_left() .and(self.field) .into_rows() .make_widget() } } FormField::new( "Label", Dynamic::<String>::default() .into_input() .placeholder("Field"), ) }
The example FormField
when rendered looks like this:
Approach B: Using the WrapperWidget
trait
The WrapperWidget
trait is an alternate trait from Widget
that makes it less error-prone to implement a widget that wraps a single other
child widget. The only required function is
WrapperWidget::child_mut
, which returns a &mut WidgetRef
.
Previously, we were using WidgetInstance
to store our field.
When a WidgetInstance
is mounted inside of another widget in a window, a
MountedWidget
is returned. A WidgetRef
is a
type that manages mounting and unmounting a widget automatically through its API
usage. It also keeps track of each window's MountedWidget
.
Updating the type to use WidgetRef
is fairly straightforward, and does not
impact the type's public API:
#![allow(unused)] fn main() { use cushy::widget::{MakeWidget, WidgetRef}; #[derive(Debug)] struct FormField { label: Value<String>, field: WidgetRef, } impl FormField { pub fn new(label: impl IntoValue<String>, field: impl MakeWidget) -> Self { Self { label: label.into_value(), field: WidgetRef::new(field), } } } }
Instead of calling field.make_widget()
, we now use WidgetRef::new(field)
.
Now, let's look at the WrapperWidget
implementation:
#![allow(unused)] fn main() { impl WrapperWidget for FormField { fn child_mut(&mut self) -> &mut WidgetRef { &mut self.field } }
As mentioned before, this child_mut
is the only required function. All other
functions provide default behaviors that ensure that the child is mounted,
positioned, and rendered for us. Running this example without the rest of the
implementation would show only the field without our label.
Before we can start drawing and positioning the field based on the label's size,
let's define a couple of helper functions that we can use to implement the
WrapperWidget
functions needed:
#![allow(unused)] fn main() { impl FormField { fn measured_label( &self, context: &mut GraphicsContext<'_, '_, '_, '_>, ) -> MeasuredText<Px> { self.label.invalidate_when_changed(context); self.label.map(|label| context.gfx.measure_text(label)) } fn label_and_padding_size( &self, context: &mut GraphicsContext<'_, '_, '_, '_>, ) -> Size<UPx> { let label_size = self.measured_label(context).size.into_unsigned(); let padding = context.get(&IntrinsicPadding).into_upx(context.gfx.scale()); Size::new(label_size.width, label_size.height + padding) } } }
The first function uses our graphics context to return Kludgine's
MeasuredText<Px>
type. The second function looks up the
current size to use for padding, and adds it to the height of the measured text.
Advanced Tip:
MeasuredText
not only contains the dimensions of the text we asked it to measure, it also has all the information necessary to draw all the glyphs necessary in the future. It would be more efficient to cache this data structure in aWindowLocal
and only re-cache the text layout when needed.
The Widget::layout()
implementation for WrapperWidget
s splits the
layout process into multiple steps. The goal is to allow wrapper widget authors
to be able to customize as little or as much of the process as needed. The first
function we are going to use is
adjust_child_constraints()
:
#![allow(unused)] fn main() { fn adjust_child_constraints( &mut self, available_space: Size<ConstraintLimit>, context: &mut LayoutContext<'_, '_, '_, '_>, ) -> Size<ConstraintLimit> { let label_and_padding = self.label_and_padding_size(context); Size::new( available_space.width, available_space.height - label_and_padding.height, ) } }
adjust_child_constraints()
is responsible for adjusting the incoming
available_space
constraints by whatever space we need to surround the child.
In our case, we need to subtract the label and padding height from the available
height. By reducing the available space, we ensure that even if a field is
wrapped in an Expand
widget, we will have already allocated space
for the label to be visible with an appropriate amount of padding.
The next step in the layout process is to position the child:
#![allow(unused)] fn main() { fn position_child( &mut self, child_size: Size<Px>, available_space: Size<ConstraintLimit>, context: &mut LayoutContext<'_, '_, '_, '_>, ) -> WrappedLayout { let label_and_padding = self.label_and_padding_size(context).into_signed(); let full_size = Size::new( available_space .width .min() .into_signed() .max(child_size.width) .max(label_and_padding.width), child_size.height + label_and_padding.height, ); WrappedLayout { child: Rect::new( Point::new(Px::ZERO, label_and_padding.height), Size::new(full_size.width, child_size.height), ), size: full_size.into_unsigned(), } } }
The above implementation calculates the full size's width by taking the maximum of the minimum available width, the child's width, and the label's width. It calcaultes the full size's height by adding the child's height and the label and padding height.
The result of this function is a structure that contains the child's rectangle
and the total size this widget is requesting. The child's rectangle is placed
below the label and padding, and its width is set to full_size.width
. This is
to mimic the behavior our original choice of placing the widgets in a stack. In
our example, this is what stretches the text input field to be the full width of
the field.
The final step is to draw the label:
#![allow(unused)] fn main() { fn redraw_foreground(&mut self, context: &mut GraphicsContext<'_, '_, '_, '_>) { let label = self.measured_label(context); context.gfx.draw_measured_text(&label, TextOrigin::TopLeft); } } }
Because the Widget::redraw
implementation takes care of drawing the
field for us, all this function needed to do was draw the label.
This is the result of using our new implementation:
It looks the exact same as the previous image. That was our goal! Rest assured
that this image was generated using the WrapperWidget
implementation shown.
Approach C: Using the Widget
trait
Implementing Widget
is very similar to implementing
WrapperWidget
, except that we are in full control of layout
and rendering. Since our WrapperWidget
implementation needed to fully adjust
the layout, our layout()
function is basically just the combination of the two
layout functions from before:
#![allow(unused)] fn main() { impl Widget for FormField { fn layout( &mut self, available_space: Size<ConstraintLimit>, context: &mut LayoutContext<'_, '_, '_, '_>, ) -> Size<UPx> { let label_and_padding = self.label_and_padding_size(context); let field_available_space = Size::new( available_space.width, available_space.height - label_and_padding.height, ); let field = self.field.mounted(context); let field_size = context.for_other(&field).layout(field_available_space); let full_size = Size::new( available_space .width .min() .max(field_size.width) .max(label_and_padding.width), field_size.height + label_and_padding.height, ); context.set_child_layout( &field, Rect::new( Point::new(UPx::ZERO, label_and_padding.height), Size::new(full_size.width, field_size.height), ) .into_signed(), ); full_size } }
The major difference in this function is that we are manually calling layout()
on our field widget. This is done by creating a context for our field
(context.for_other()
), and calling the layout function on that context. After
we receive the field's size, we must call
set_child_layout()
to finish laying out the field.
Finally, the result of the layout()
function is the full size that the field
needs. With layout done, rendering is the next step:
#![allow(unused)] fn main() { fn redraw(&mut self, context: &mut GraphicsContext<'_, '_, '_, '_>) { let label = self.measured_label(context); context.gfx.draw_measured_text(&label, TextOrigin::TopLeft); let field = self.field.mounted(context); context.for_other(&field).redraw(); } }
This function isn't much more complicated than the WrapperWidget
's
implementation. The extra two lines again use context.for_other()
to create a
context for the field
widget, but this time we call the field's redraw()
function instead.
Finally, we have one last bit of housekeeping that WrapperWidget
did
automatically: unmounting the field when the form field itself is unmounted.
This is important to minimize memory usage by ensuring that if the widget is
shared between multiple windows, each window's state is cleaned up indepdent of
the widget itself:
#![allow(unused)] fn main() { fn unmounted(&mut self, context: &mut cushy::context::EventContext<'_>) { self.field.unmount_in(context); } } }
And with this, we can look at an identical picture that shows this implementation works the same as the previous implementations:
Conclusion
Cushy tries to provide the tools needed to avoid implementing your own widgets
by utilizing composition. It also has many tools for creating reusable,
composible widgets. If you find yourself uncertain of what path to use when
creating a reusable component, try using a MakeWidget
implementation initially.
If using MakeWidget
is cumbersome or doesn't expose as much functionality as
needed, you may still find that you can utilize MakeWidget
along with a
simpler custom widget than implementing a more complex, multi-part widget. This
approach is how the built-in Checkbox
, Radio
and
Select
widgets are all built using a Button
.