Skip to content

Commit 2230b23

Browse files
committed
Expose baseline on TextLayout, add Baseline alignment to Flex
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.
1 parent fc63745 commit 2230b23

File tree

12 files changed

+198
-54
lines changed

12 files changed

+198
-54
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ You can find its changes [documented below](#060---2020-06-01).
4747
- `RichText` and `Attribute` types for creating rich text ([#1255] by [@cmyr])
4848
- `request_timer` can now be called from `LayoutCtx` ([#1278] by [@Majora320])
4949
- TextBox supports vertical movement ([#1280] by [@cmyr])
50+
- Widgets can specify a baseline, flex rows can align baselines ([#1295] by [@cmyr])
5051

5152
### Changed
5253

@@ -495,6 +496,7 @@ Last release without a changelog :(
495496
[#1276]: https://github.com/linebender/druid/pull/1276
496497
[#1278]: https://github.com/linebender/druid/pull/1278
497498
[#1280]: https://github.com/linebender/druid/pull/1280
499+
[#1295]: https://github.com/linebender/druid/pull/1280
498500
[#1298]: https://github.com/linebender/druid/pull/1298
499501
[#1299]: https://github.com/linebender/druid/pull/1299
500502

druid/examples/flex.rs

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,7 @@ fn make_control_row() -> impl Widget<AppState> {
146146
("Start", CrossAxisAlignment::Start),
147147
("Center", CrossAxisAlignment::Center),
148148
("End", CrossAxisAlignment::End),
149+
("Baseline", CrossAxisAlignment::Baseline),
149150
])
150151
.lens(Params::cross_alignment),
151152
),
@@ -261,12 +262,14 @@ fn build_widget(state: &Params) -> Box<dyn Widget<AppState>> {
261262

262263
space_if_needed(&mut flex, state);
263264

264-
flex.add_child(Label::new(|data: &DemoState, _: &Env| {
265-
data.input_text.clone()
266-
}));
265+
flex.add_child(
266+
Label::new(|data: &DemoState, _: &Env| data.input_text.clone()).with_text_size(32.0),
267+
);
267268
space_if_needed(&mut flex, state);
268269
flex.add_child(Checkbox::new("Demo").lens(DemoState::enabled));
269270
space_if_needed(&mut flex, state);
271+
flex.add_child(Switch::new().lens(DemoState::enabled));
272+
space_if_needed(&mut flex, state);
270273
flex.add_child(Slider::new().lens(DemoState::volume));
271274
space_if_needed(&mut flex, state);
272275
flex.add_child(ProgressBar::new().lens(DemoState::volume));
@@ -278,8 +281,6 @@ fn build_widget(state: &Params) -> Box<dyn Widget<AppState>> {
278281
.with_wraparound(true)
279282
.lens(DemoState::volume),
280283
);
281-
space_if_needed(&mut flex, state);
282-
flex.add_child(Switch::new().lens(DemoState::enabled));
283284

284285
let mut flex = SizedBox::new(flex);
285286
if state.fix_minor_axis {

druid/src/contexts.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -595,6 +595,19 @@ impl LayoutCtx<'_, '_> {
595595
pub fn set_paint_insets(&mut self, insets: impl Into<Insets>) {
596596
self.widget_state.paint_insets = insets.into().nonnegative();
597597
}
598+
599+
/// Set an explicit baseline position for this widget.
600+
///
601+
/// The baseline position is used to align widgets that contain text,
602+
/// such as buttons, labels, and other controls. It may also be used
603+
/// by other widgets that are opinionated about how they are aligned
604+
/// relative to neighbouring text, such as switches or checkboxes.
605+
///
606+
/// The provided value should be the distance from the *bottom* of the
607+
/// widget to the baseline.
608+
pub fn set_baseline_offset(&mut self, baseline: f64) {
609+
self.widget_state.baseline_offset = baseline
610+
}
598611
}
599612

600613
impl PaintCtx<'_, '_, '_> {

druid/src/core.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,13 @@ pub(crate) struct WidgetState {
8080
/// drop shadows or overflowing text.
8181
pub(crate) paint_insets: Insets,
8282

83+
/// The offset of the baseline relative to the bottom of the widget.
84+
///
85+
/// In general, this will be zero; the bottom of the widget will be considered
86+
/// the baseline. Widgets that contain text or controls that expect to be
87+
/// laid out alongside text can set this as appropriate.
88+
pub(crate) baseline_offset: f64,
89+
8390
// The region that needs to be repainted, relative to the widget's bounds.
8491
pub(crate) invalid: Region,
8592

@@ -313,6 +320,11 @@ impl<T, W: Widget<T>> WidgetPod<T, W> {
313320
union_pant_rect - parent_bounds
314321
}
315322

323+
/// The distance from the bottom of this widget to the baseline.
324+
pub fn baseline_offset(&self) -> f64 {
325+
self.state.baseline_offset
326+
}
327+
316328
/// Determines if the provided `mouse_pos` is inside `rect`
317329
/// and if so updates the hot state and sends `LifeCycle::HotChanged`.
318330
///
@@ -884,6 +896,7 @@ impl WidgetState {
884896
paint_insets: Insets::ZERO,
885897
invalid: Region::EMPTY,
886898
viewport_offset: Vec2::ZERO,
899+
baseline_offset: 0.0,
887900
is_hot: false,
888901
needs_layout: false,
889902
is_active: false,

druid/src/text/layout.rs

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,16 @@ pub struct TextLayout<T> {
5757
alignment: TextAlignment,
5858
}
5959

60+
/// Metrics describing the layout text.
61+
#[derive(Debug, Clone, Copy, Default)]
62+
pub struct LayoutMetrics {
63+
/// The nominal size of the layout.
64+
pub size: Size,
65+
/// The distance from the nominal top of the layout to the first baseline.
66+
pub first_baseline: f64,
67+
//TODO: add inking_rect
68+
}
69+
6070
impl<T> TextLayout<T> {
6171
/// Create a new `TextLayout` object.
6272
///
@@ -194,6 +204,31 @@ impl<T: TextStorage> TextLayout<T> {
194204
.unwrap_or_default()
195205
}
196206

207+
/// Return the text's [`LayoutMetrics`].
208+
///
209+
/// This is not meaningful until [`rebuild_if_needed`] has been called.
210+
///
211+
/// [`rebuild_if_needed`]: #method.rebuild_if_needed
212+
/// [`LayoutMetrics`]: struct.LayoutMetrics.html
213+
pub fn layout_metrics(&self) -> LayoutMetrics {
214+
debug_assert!(
215+
self.layout.is_some(),
216+
"TextLayout::layout_metrics called without rebuilding layout object. Text was '{}'",
217+
self.text().as_ref().map(|s| s.as_str()).unwrap_or_default()
218+
);
219+
220+
if let Some(layout) = self.layout.as_ref() {
221+
let first_baseline = layout.line_metric(0).unwrap().baseline;
222+
let size = layout.size();
223+
LayoutMetrics {
224+
size,
225+
first_baseline,
226+
}
227+
} else {
228+
LayoutMetrics::default()
229+
}
230+
}
231+
197232
/// For a given `Point` (relative to this object's origin), returns index
198233
/// into the underlying text of the nearest grapheme boundary.
199234
pub fn text_position_for_point(&self, point: Point) -> usize {

druid/src/widget/button.rs

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -137,20 +137,16 @@ impl<T: Data> Widget<T> for Button<T> {
137137
self.label.update(ctx, old_data, data, env)
138138
}
139139

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

155151
bc.constrain(Size::new(
156152
self.label_size.width + padding.width,

druid/src/widget/checkbox.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,10 @@ impl Widget<bool> for Checkbox {
7979
check_size + x_padding + label_size.width,
8080
check_size.max(label_size.height),
8181
);
82-
bc.constrain(desired_size)
82+
let our_size = bc.constrain(desired_size);
83+
let baseline = self.child_label.baseline_offset() + (our_size.height - label_size.height);
84+
ctx.set_baseline_offset(baseline);
85+
our_size
8386
}
8487

8588
fn paint(&mut self, ctx: &mut PaintCtx, data: &bool, env: &Env) {

druid/src/widget/flex.rs

Lines changed: 74 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,13 @@ pub enum CrossAxisAlignment {
294294
/// In a vertical container, widgets are bottom aligned. In a horiziontal
295295
/// container, their trailing edges are aligned.
296296
End,
297+
/// Align on the baseline.
298+
///
299+
/// In a horizontal container, widgets are aligned along the calculated
300+
/// baseline. In a vertical container, this is equivalent to `End`.
301+
///
302+
/// The calculated baseline is the maximum baseline offset of the children.
303+
Baseline,
297304
}
298305

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

617+
// minor-axis values for all children
618+
let mut minor = self.direction.minor(bc.min());
619+
// these two are calculated but only used if we're baseline aligned
620+
let mut max_above_baseline = 0f64;
621+
let mut max_below_baseline = 0f64;
622+
610623
// Measure non-flex children.
611624
let mut major_non_flex = 0.0;
612-
let mut minor = self.direction.minor(bc.min());
613625
for child in &mut self.children {
614626
if child.params.flex == 0.0 {
615627
let child_bc = self
616628
.direction
617629
.constraints(&loosened_bc, 0., std::f64::INFINITY);
618630
let child_size = child.widget.layout(ctx, &child_bc, data, env);
631+
let baseline_offset = child.widget.baseline_offset();
619632

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

628641
major_non_flex += self.direction.major(child_size).expand();
629642
minor = minor.max(self.direction.minor(child_size).expand());
643+
max_above_baseline = max_above_baseline.max(child_size.height - baseline_offset);
644+
max_below_baseline = max_below_baseline.max(baseline_offset);
630645
// Stash size.
631646
let rect = child_size.to_rect();
632647
child.widget.set_layout_rect(ctx, data, env, rect);
@@ -651,9 +666,12 @@ impl<T: Data> Widget<T> for Flex<T> {
651666
.direction
652667
.constraints(&loosened_bc, min_major, actual_major);
653668
let child_size = child.widget.layout(ctx, &child_bc, data, env);
669+
let baseline_offset = child.widget.baseline_offset();
654670

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

672690
let mut spacing = Spacing::new(self.main_alignment, extra, self.children.len());
673-
// Finalize layout, assigning positions to each child.
691+
692+
// the actual size needed to tightly fit the children on the minor axis.
693+
// Unlike the 'minor' var, this ignores the incoming constraints.
694+
let minor_dim = match self.direction {
695+
Axis::Horizontal => max_below_baseline + max_above_baseline,
696+
Axis::Vertical => minor,
697+
};
698+
674699
let mut major = spacing.next().unwrap_or(0.);
675700
let mut child_paint_rect = Rect::ZERO;
676701
for child in &mut self.children {
677-
let rect = child.widget.layout_rect();
678-
let extra_minor = minor - self.direction.minor(rect.size());
702+
let child_size = child.widget.layout_rect().size();
679703
let alignment = child.params.alignment.unwrap_or(self.cross_alignment);
680-
let align_minor = alignment.align(extra_minor);
681-
let pos: Point = self.direction.pack(major, align_minor).into();
704+
let child_minor_offset = match alignment {
705+
// This will ignore baseline alignment if it is overridden on children,
706+
// but is not the default for the container. Is this okay?
707+
CrossAxisAlignment::Baseline if matches!(self.direction, Axis::Horizontal) => {
708+
let extra_height = minor - minor_dim.min(minor);
709+
let child_baseline = child.widget.baseline_offset();
710+
let child_above_baseline = child_size.height - child_baseline;
711+
extra_height + (max_above_baseline - child_above_baseline)
712+
}
713+
_ => {
714+
let extra_minor = minor_dim - self.direction.minor(child_size);
715+
alignment.align(extra_minor)
716+
}
717+
};
682718

683-
child
684-
.widget
685-
.set_layout_rect(ctx, data, env, rect.with_origin(pos));
719+
let child_pos: Point = self.direction.pack(major, child_minor_offset).into();
720+
let child_frame = Rect::from_origin_size(child_pos, child_size);
721+
child.widget.set_layout_rect(ctx, data, env, child_frame);
686722
child_paint_rect = child_paint_rect.union(child.widget.paint_rect());
687-
major += self.direction.major(rect.size()).expand();
723+
major += self.direction.major(child_size).expand();
688724
major += spacing.next().unwrap_or(0.);
689725
}
690726

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

699-
let my_size: Size = self.direction.pack(major, minor).into();
735+
let my_size: Size = self.direction.pack(major, minor_dim).into();
700736

701737
// if we don't have to fill the main axis, we loosen that axis before constraining
702738
let my_size = if !self.fill_major_axis {
@@ -711,13 +747,38 @@ impl<T: Data> Widget<T> for Flex<T> {
711747
let my_bounds = Rect::ZERO.with_size(my_size);
712748
let insets = child_paint_rect - my_bounds;
713749
ctx.set_paint_insets(insets);
750+
751+
let baseline_offset = match self.direction {
752+
Axis::Horizontal => max_below_baseline,
753+
Axis::Vertical => self
754+
.children
755+
.last()
756+
.map(|last| {
757+
let child_bl = last.widget.baseline_offset();
758+
let child_max_y = last.widget.layout_rect().max_y();
759+
let extra_bottom_padding = my_size.height - child_max_y;
760+
child_bl + extra_bottom_padding
761+
})
762+
.unwrap_or(0.0),
763+
};
764+
765+
ctx.set_baseline_offset(baseline_offset);
714766
my_size
715767
}
716768

717769
fn paint(&mut self, ctx: &mut PaintCtx, data: &T, env: &Env) {
718770
for child in &mut self.children {
719771
child.widget.paint(ctx, data, env);
720772
}
773+
774+
// paint the baseline if we're debugging layout
775+
if env.get(Env::DEBUG_PAINT) && ctx.widget_state.baseline_offset != 0.0 {
776+
let color = env.get_debug_color(ctx.widget_id().to_raw());
777+
let my_baseline = ctx.size().height - ctx.widget_state.baseline_offset;
778+
let line = crate::kurbo::Line::new((0.0, my_baseline), (ctx.size().width, my_baseline));
779+
let stroke_style = crate::piet::StrokeStyle::new().dash(vec![4.0, 4.0], 0.0);
780+
ctx.stroke_styled(line, &color, 1.0, &stroke_style);
781+
}
721782
}
722783
}
723784

@@ -728,7 +789,8 @@ impl CrossAxisAlignment {
728789
fn align(self, val: f64) -> f64 {
729790
match self {
730791
CrossAxisAlignment::Start => 0.0,
731-
CrossAxisAlignment::Center => (val / 2.0).round(),
792+
// in vertical layout, baseline is equivalent to center
793+
CrossAxisAlignment::Center | CrossAxisAlignment::Baseline => (val / 2.0).round(),
732794
CrossAxisAlignment::End => val,
733795
}
734796
}

druid/src/widget/label.rs

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,12 @@ impl<T: TextStorage> RawLabel<T> {
268268
pub fn draw_at(&self, ctx: &mut PaintCtx, origin: impl Into<Point>) {
269269
self.layout.draw(ctx, origin)
270270
}
271+
272+
/// Return the offset of the first baseline relative to the bottom of the widget.
273+
pub fn baseline_offset(&self) -> f64 {
274+
let text_metrics = self.layout.layout_metrics();
275+
text_metrics.size.height - text_metrics.first_baseline
276+
}
271277
}
272278

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

536-
let mut text_size = self.layout.size();
537-
text_size.width += 2. * LABEL_X_PADDING;
538-
bc.constrain(text_size)
542+
let text_metrics = self.layout.layout_metrics();
543+
ctx.set_baseline_offset(text_metrics.size.height - text_metrics.first_baseline);
544+
bc.constrain(Size::new(
545+
text_metrics.size.width + 2. * LABEL_X_PADDING,
546+
text_metrics.size.height,
547+
))
539548
}
540549

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

0 commit comments

Comments
 (0)