Skip to content

Commit

Permalink
Expose baseline on TextLayout, add Baseline alignment to Flex
Browse files Browse the repository at this point in the history
This introduces the idea of baseline alignment as a component
of layout.

During their `layout` calls, widgets can specify their baseline_offset,
which is the distance from the bottom of their reported size to their
baseline.

Generally, the baseline will be derived from some text object,
although widgets that do not contain text but which appear next
to widgets that do can specify an arbitrary baseline.

This also adds CrossAxisAlignment::Baseline to Flex; this is
only meaningful in a Flex::row, in which case it aligns all of
its children based on their own reported baselines.

The best place to play around with this code is in examples/flex.rs.
  • Loading branch information
cmyr committed Oct 13, 2020
1 parent fc63745 commit 2230b23
Show file tree
Hide file tree
Showing 12 changed files with 198 additions and 54 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ You can find its changes [documented below](#060---2020-06-01).
- `RichText` and `Attribute` types for creating rich text ([#1255] by [@cmyr])
- `request_timer` can now be called from `LayoutCtx` ([#1278] by [@Majora320])
- TextBox supports vertical movement ([#1280] by [@cmyr])
- Widgets can specify a baseline, flex rows can align baselines ([#1295] by [@cmyr])

### Changed

Expand Down Expand Up @@ -495,6 +496,7 @@ Last release without a changelog :(
[#1276]: https://github.com/linebender/druid/pull/1276
[#1278]: https://github.com/linebender/druid/pull/1278
[#1280]: https://github.com/linebender/druid/pull/1280
[#1295]: https://github.com/linebender/druid/pull/1280
[#1298]: https://github.com/linebender/druid/pull/1298
[#1299]: https://github.com/linebender/druid/pull/1299

Expand Down
11 changes: 6 additions & 5 deletions druid/examples/flex.rs
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ fn make_control_row() -> impl Widget<AppState> {
("Start", CrossAxisAlignment::Start),
("Center", CrossAxisAlignment::Center),
("End", CrossAxisAlignment::End),
("Baseline", CrossAxisAlignment::Baseline),
])
.lens(Params::cross_alignment),
),
Expand Down Expand Up @@ -261,12 +262,14 @@ fn build_widget(state: &Params) -> Box<dyn Widget<AppState>> {

space_if_needed(&mut flex, state);

flex.add_child(Label::new(|data: &DemoState, _: &Env| {
data.input_text.clone()
}));
flex.add_child(
Label::new(|data: &DemoState, _: &Env| data.input_text.clone()).with_text_size(32.0),
);
space_if_needed(&mut flex, state);
flex.add_child(Checkbox::new("Demo").lens(DemoState::enabled));
space_if_needed(&mut flex, state);
flex.add_child(Switch::new().lens(DemoState::enabled));
space_if_needed(&mut flex, state);
flex.add_child(Slider::new().lens(DemoState::volume));
space_if_needed(&mut flex, state);
flex.add_child(ProgressBar::new().lens(DemoState::volume));
Expand All @@ -278,8 +281,6 @@ fn build_widget(state: &Params) -> Box<dyn Widget<AppState>> {
.with_wraparound(true)
.lens(DemoState::volume),
);
space_if_needed(&mut flex, state);
flex.add_child(Switch::new().lens(DemoState::enabled));

let mut flex = SizedBox::new(flex);
if state.fix_minor_axis {
Expand Down
13 changes: 13 additions & 0 deletions druid/src/contexts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -595,6 +595,19 @@ impl LayoutCtx<'_, '_> {
pub fn set_paint_insets(&mut self, insets: impl Into<Insets>) {
self.widget_state.paint_insets = insets.into().nonnegative();
}

/// Set an explicit baseline position for this widget.
///
/// The baseline position is used to align widgets that contain text,
/// such as buttons, labels, and other controls. It may also be used
/// by other widgets that are opinionated about how they are aligned
/// relative to neighbouring text, such as switches or checkboxes.
///
/// The provided value should be the distance from the *bottom* of the
/// widget to the baseline.
pub fn set_baseline_offset(&mut self, baseline: f64) {
self.widget_state.baseline_offset = baseline
}
}

impl PaintCtx<'_, '_, '_> {
Expand Down
13 changes: 13 additions & 0 deletions druid/src/core.rs
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,13 @@ pub(crate) struct WidgetState {
/// drop shadows or overflowing text.
pub(crate) paint_insets: Insets,

/// The offset of the baseline relative to the bottom of the widget.
///
/// In general, this will be zero; the bottom of the widget will be considered
/// the baseline. Widgets that contain text or controls that expect to be
/// laid out alongside text can set this as appropriate.
pub(crate) baseline_offset: f64,

// The region that needs to be repainted, relative to the widget's bounds.
pub(crate) invalid: Region,

Expand Down Expand Up @@ -313,6 +320,11 @@ impl<T, W: Widget<T>> WidgetPod<T, W> {
union_pant_rect - parent_bounds
}

/// The distance from the bottom of this widget to the baseline.
pub fn baseline_offset(&self) -> f64 {
self.state.baseline_offset
}

/// Determines if the provided `mouse_pos` is inside `rect`
/// and if so updates the hot state and sends `LifeCycle::HotChanged`.
///
Expand Down Expand Up @@ -884,6 +896,7 @@ impl WidgetState {
paint_insets: Insets::ZERO,
invalid: Region::EMPTY,
viewport_offset: Vec2::ZERO,
baseline_offset: 0.0,
is_hot: false,
needs_layout: false,
is_active: false,
Expand Down
35 changes: 35 additions & 0 deletions druid/src/text/layout.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,16 @@ pub struct TextLayout<T> {
alignment: TextAlignment,
}

/// Metrics describing the layout text.
#[derive(Debug, Clone, Copy, Default)]
pub struct LayoutMetrics {
/// The nominal size of the layout.
pub size: Size,
/// The distance from the nominal top of the layout to the first baseline.
pub first_baseline: f64,
//TODO: add inking_rect
}

impl<T> TextLayout<T> {
/// Create a new `TextLayout` object.
///
Expand Down Expand Up @@ -194,6 +204,31 @@ impl<T: TextStorage> TextLayout<T> {
.unwrap_or_default()
}

/// Return the text's [`LayoutMetrics`].
///
/// This is not meaningful until [`rebuild_if_needed`] has been called.
///
/// [`rebuild_if_needed`]: #method.rebuild_if_needed
/// [`LayoutMetrics`]: struct.LayoutMetrics.html
pub fn layout_metrics(&self) -> LayoutMetrics {
debug_assert!(
self.layout.is_some(),
"TextLayout::layout_metrics called without rebuilding layout object. Text was '{}'",
self.text().as_ref().map(|s| s.as_str()).unwrap_or_default()
);

if let Some(layout) = self.layout.as_ref() {
let first_baseline = layout.line_metric(0).unwrap().baseline;
let size = layout.size();
LayoutMetrics {
size,
first_baseline,
}
} else {
LayoutMetrics::default()
}
}

/// For a given `Point` (relative to this object's origin), returns index
/// into the underlying text of the nearest grapheme boundary.
pub fn text_position_for_point(&self, point: Point) -> usize {
Expand Down
12 changes: 4 additions & 8 deletions druid/src/widget/button.rs
Original file line number Diff line number Diff line change
Expand Up @@ -137,20 +137,16 @@ impl<T: Data> Widget<T> for Button<T> {
self.label.update(ctx, old_data, data, env)
}

fn layout(
&mut self,
layout_ctx: &mut LayoutCtx,
bc: &BoxConstraints,
data: &T,
env: &Env,
) -> Size {
fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints, data: &T, env: &Env) -> Size {
bc.debug_check("Button");
let padding = Size::new(LABEL_INSETS.x_value(), LABEL_INSETS.y_value());
let label_bc = bc.shrink(padding).loosen();
self.label_size = self.label.layout(layout_ctx, &label_bc, data, env);
self.label_size = self.label.layout(ctx, &label_bc, data, env);
// HACK: to make sure we look okay at default sizes when beside a textbox,
// we make sure we will have at least the same height as the default textbox.
let min_height = env.get(theme::BORDERED_WIDGET_HEIGHT);
let baseline = self.label.baseline_offset();
ctx.set_baseline_offset(baseline + LABEL_INSETS.y1);

bc.constrain(Size::new(
self.label_size.width + padding.width,
Expand Down
5 changes: 4 additions & 1 deletion druid/src/widget/checkbox.rs
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,10 @@ impl Widget<bool> for Checkbox {
check_size + x_padding + label_size.width,
check_size.max(label_size.height),
);
bc.constrain(desired_size)
let our_size = bc.constrain(desired_size);
let baseline = self.child_label.baseline_offset() + (our_size.height - label_size.height);
ctx.set_baseline_offset(baseline);
our_size
}

fn paint(&mut self, ctx: &mut PaintCtx, data: &bool, env: &Env) {
Expand Down
86 changes: 74 additions & 12 deletions druid/src/widget/flex.rs
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,13 @@ pub enum CrossAxisAlignment {
/// In a vertical container, widgets are bottom aligned. In a horiziontal
/// container, their trailing edges are aligned.
End,
/// Align on the baseline.
///
/// In a horizontal container, widgets are aligned along the calculated
/// baseline. In a vertical container, this is equivalent to `End`.
///
/// The calculated baseline is the maximum baseline offset of the children.
Baseline,
}

/// Arrangement of children on the main axis.
Expand Down Expand Up @@ -607,15 +614,21 @@ impl<T: Data> Widget<T> for Flex<T> {
// we loosen our constraints when passing to children.
let loosened_bc = bc.loosen();

// minor-axis values for all children
let mut minor = self.direction.minor(bc.min());
// these two are calculated but only used if we're baseline aligned
let mut max_above_baseline = 0f64;
let mut max_below_baseline = 0f64;

// Measure non-flex children.
let mut major_non_flex = 0.0;
let mut minor = self.direction.minor(bc.min());
for child in &mut self.children {
if child.params.flex == 0.0 {
let child_bc = self
.direction
.constraints(&loosened_bc, 0., std::f64::INFINITY);
let child_size = child.widget.layout(ctx, &child_bc, data, env);
let baseline_offset = child.widget.baseline_offset();

if child_size.width.is_infinite() {
log::warn!("A non-Flex child has an infinite width.");
Expand All @@ -627,6 +640,8 @@ impl<T: Data> Widget<T> for Flex<T> {

major_non_flex += self.direction.major(child_size).expand();
minor = minor.max(self.direction.minor(child_size).expand());
max_above_baseline = max_above_baseline.max(child_size.height - baseline_offset);
max_below_baseline = max_below_baseline.max(baseline_offset);
// Stash size.
let rect = child_size.to_rect();
child.widget.set_layout_rect(ctx, data, env, rect);
Expand All @@ -651,9 +666,12 @@ impl<T: Data> Widget<T> for Flex<T> {
.direction
.constraints(&loosened_bc, min_major, actual_major);
let child_size = child.widget.layout(ctx, &child_bc, data, env);
let baseline_offset = child.widget.baseline_offset();

major_flex += self.direction.major(child_size).expand();
minor = minor.max(self.direction.minor(child_size).expand());
max_above_baseline = max_above_baseline.max(child_size.height - baseline_offset);
max_below_baseline = max_below_baseline.max(baseline_offset);
// Stash size.
let rect = child_size.to_rect();
child.widget.set_layout_rect(ctx, data, env, rect);
Expand All @@ -670,21 +688,39 @@ impl<T: Data> Widget<T> for Flex<T> {
};

let mut spacing = Spacing::new(self.main_alignment, extra, self.children.len());
// Finalize layout, assigning positions to each child.

// the actual size needed to tightly fit the children on the minor axis.
// Unlike the 'minor' var, this ignores the incoming constraints.
let minor_dim = match self.direction {
Axis::Horizontal => max_below_baseline + max_above_baseline,
Axis::Vertical => minor,
};

let mut major = spacing.next().unwrap_or(0.);
let mut child_paint_rect = Rect::ZERO;
for child in &mut self.children {
let rect = child.widget.layout_rect();
let extra_minor = minor - self.direction.minor(rect.size());
let child_size = child.widget.layout_rect().size();
let alignment = child.params.alignment.unwrap_or(self.cross_alignment);
let align_minor = alignment.align(extra_minor);
let pos: Point = self.direction.pack(major, align_minor).into();
let child_minor_offset = match alignment {
// This will ignore baseline alignment if it is overridden on children,
// but is not the default for the container. Is this okay?
CrossAxisAlignment::Baseline if matches!(self.direction, Axis::Horizontal) => {
let extra_height = minor - minor_dim.min(minor);
let child_baseline = child.widget.baseline_offset();
let child_above_baseline = child_size.height - child_baseline;
extra_height + (max_above_baseline - child_above_baseline)
}
_ => {
let extra_minor = minor_dim - self.direction.minor(child_size);
alignment.align(extra_minor)
}
};

child
.widget
.set_layout_rect(ctx, data, env, rect.with_origin(pos));
let child_pos: Point = self.direction.pack(major, child_minor_offset).into();
let child_frame = Rect::from_origin_size(child_pos, child_size);
child.widget.set_layout_rect(ctx, data, env, child_frame);
child_paint_rect = child_paint_rect.union(child.widget.paint_rect());
major += self.direction.major(rect.size()).expand();
major += self.direction.major(child_size).expand();
major += spacing.next().unwrap_or(0.);
}

Expand All @@ -696,7 +732,7 @@ impl<T: Data> Widget<T> for Flex<T> {
major = total_major;
}

let my_size: Size = self.direction.pack(major, minor).into();
let my_size: Size = self.direction.pack(major, minor_dim).into();

// if we don't have to fill the main axis, we loosen that axis before constraining
let my_size = if !self.fill_major_axis {
Expand All @@ -711,13 +747,38 @@ impl<T: Data> Widget<T> for Flex<T> {
let my_bounds = Rect::ZERO.with_size(my_size);
let insets = child_paint_rect - my_bounds;
ctx.set_paint_insets(insets);

let baseline_offset = match self.direction {
Axis::Horizontal => max_below_baseline,
Axis::Vertical => self
.children
.last()
.map(|last| {
let child_bl = last.widget.baseline_offset();
let child_max_y = last.widget.layout_rect().max_y();
let extra_bottom_padding = my_size.height - child_max_y;
child_bl + extra_bottom_padding
})
.unwrap_or(0.0),
};

ctx.set_baseline_offset(baseline_offset);
my_size
}

fn paint(&mut self, ctx: &mut PaintCtx, data: &T, env: &Env) {
for child in &mut self.children {
child.widget.paint(ctx, data, env);
}

// paint the baseline if we're debugging layout
if env.get(Env::DEBUG_PAINT) && ctx.widget_state.baseline_offset != 0.0 {
let color = env.get_debug_color(ctx.widget_id().to_raw());
let my_baseline = ctx.size().height - ctx.widget_state.baseline_offset;
let line = crate::kurbo::Line::new((0.0, my_baseline), (ctx.size().width, my_baseline));
let stroke_style = crate::piet::StrokeStyle::new().dash(vec![4.0, 4.0], 0.0);
ctx.stroke_styled(line, &color, 1.0, &stroke_style);
}
}
}

Expand All @@ -728,7 +789,8 @@ impl CrossAxisAlignment {
fn align(self, val: f64) -> f64 {
match self {
CrossAxisAlignment::Start => 0.0,
CrossAxisAlignment::Center => (val / 2.0).round(),
// in vertical layout, baseline is equivalent to center
CrossAxisAlignment::Center | CrossAxisAlignment::Baseline => (val / 2.0).round(),
CrossAxisAlignment::End => val,
}
}
Expand Down
15 changes: 12 additions & 3 deletions druid/src/widget/label.rs
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,12 @@ impl<T: TextStorage> RawLabel<T> {
pub fn draw_at(&self, ctx: &mut PaintCtx, origin: impl Into<Point>) {
self.layout.draw(ctx, origin)
}

/// Return the offset of the first baseline relative to the bottom of the widget.
pub fn baseline_offset(&self) -> f64 {
let text_metrics = self.layout.layout_metrics();
text_metrics.size.height - text_metrics.first_baseline
}
}

impl<T: TextStorage> Label<T> {
Expand Down Expand Up @@ -533,9 +539,12 @@ impl<T: TextStorage> Widget<T> for RawLabel<T> {
self.layout.set_wrap_width(width);
self.layout.rebuild_if_needed(ctx.text(), env);

let mut text_size = self.layout.size();
text_size.width += 2. * LABEL_X_PADDING;
bc.constrain(text_size)
let text_metrics = self.layout.layout_metrics();
ctx.set_baseline_offset(text_metrics.size.height - text_metrics.first_baseline);
bc.constrain(Size::new(
text_metrics.size.width + 2. * LABEL_X_PADDING,
text_metrics.size.height,
))
}

fn paint(&mut self, ctx: &mut PaintCtx, _data: &T, _env: &Env) {
Expand Down
Loading

0 comments on commit 2230b23

Please sign in to comment.