From d57d9a79ae0a09b365f4a50e93ac17af55244930 Mon Sep 17 00:00:00 2001 From: Colin Rofls Date: Thu, 8 Oct 2020 16:55:38 -0400 Subject: [PATCH] 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. --- druid/examples/flex.rs | 11 ++--- druid/src/contexts.rs | 13 ++++++ druid/src/core.rs | 13 ++++++ druid/src/text/layout.rs | 36 +++++++++++++++ druid/src/widget/button.rs | 12 ++--- druid/src/widget/checkbox.rs | 5 ++- druid/src/widget/flex.rs | 86 +++++++++++++++++++++++++++++++----- druid/src/widget/label.rs | 15 +++++-- druid/src/widget/slider.rs | 29 ++++++------ druid/src/widget/switch.rs | 13 ++++-- druid/src/widget/textbox.rs | 18 +++++--- 11 files changed, 197 insertions(+), 54 deletions(-) diff --git a/druid/examples/flex.rs b/druid/examples/flex.rs index c2980e5f27..07cf3b73aa 100644 --- a/druid/examples/flex.rs +++ b/druid/examples/flex.rs @@ -146,6 +146,7 @@ fn make_control_row() -> impl Widget { ("Start", CrossAxisAlignment::Start), ("Center", CrossAxisAlignment::Center), ("End", CrossAxisAlignment::End), + ("Baseline", CrossAxisAlignment::Baseline), ]) .lens(Params::cross_alignment), ), @@ -261,12 +262,14 @@ fn build_widget(state: &Params) -> Box> { 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)); @@ -278,8 +281,6 @@ fn build_widget(state: &Params) -> Box> { .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 { diff --git a/druid/src/contexts.rs b/druid/src/contexts.rs index 9b2dc669f9..8bc80461be 100644 --- a/druid/src/contexts.rs +++ b/druid/src/contexts.rs @@ -595,6 +595,19 @@ impl LayoutCtx<'_, '_> { pub fn set_paint_insets(&mut self, insets: impl Into) { 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<'_, '_, '_> { diff --git a/druid/src/core.rs b/druid/src/core.rs index 13f583ea6c..44750547b0 100644 --- a/druid/src/core.rs +++ b/druid/src/core.rs @@ -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, @@ -313,6 +320,11 @@ impl> WidgetPod { 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`. /// @@ -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, diff --git a/druid/src/text/layout.rs b/druid/src/text/layout.rs index db92de6cd2..61349649c2 100644 --- a/druid/src/text/layout.rs +++ b/druid/src/text/layout.rs @@ -1,5 +1,6 @@ // Copyright 2020 The xi-editor Authors. // +///// Return the distance from // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at @@ -57,6 +58,16 @@ pub struct TextLayout { 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 TextLayout { /// Create a new `TextLayout` object. /// @@ -193,6 +204,31 @@ impl TextLayout { .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 { diff --git a/druid/src/widget/button.rs b/druid/src/widget/button.rs index 9eb12fbbb0..4665795294 100644 --- a/druid/src/widget/button.rs +++ b/druid/src/widget/button.rs @@ -137,20 +137,16 @@ impl Widget for Button { 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, diff --git a/druid/src/widget/checkbox.rs b/druid/src/widget/checkbox.rs index a2372c61b4..09a6e91b19 100644 --- a/druid/src/widget/checkbox.rs +++ b/druid/src/widget/checkbox.rs @@ -79,7 +79,10 @@ impl Widget 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) { diff --git a/druid/src/widget/flex.rs b/druid/src/widget/flex.rs index 5e5e1f8bf5..4e629f0882 100644 --- a/druid/src/widget/flex.rs +++ b/druid/src/widget/flex.rs @@ -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. @@ -607,15 +614,21 @@ impl Widget for Flex { // 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."); @@ -627,6 +640,8 @@ impl Widget for Flex { 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); @@ -651,9 +666,12 @@ impl Widget for Flex { .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); @@ -670,21 +688,39 @@ impl Widget for Flex { }; 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.); } @@ -696,7 +732,7 @@ impl Widget for Flex { 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 { @@ -711,6 +747,22 @@ impl Widget for Flex { 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 } @@ -718,6 +770,15 @@ impl Widget for Flex { 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); + } } } @@ -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, } } diff --git a/druid/src/widget/label.rs b/druid/src/widget/label.rs index 95aede9095..8724edd512 100644 --- a/druid/src/widget/label.rs +++ b/druid/src/widget/label.rs @@ -268,6 +268,12 @@ impl RawLabel { pub fn draw_at(&self, ctx: &mut PaintCtx, origin: impl Into) { 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 Label { @@ -533,9 +539,12 @@ impl Widget for RawLabel { 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) { diff --git a/druid/src/widget/slider.rs b/druid/src/widget/slider.rs index aa35169850..599ba5529d 100644 --- a/druid/src/widget/slider.rs +++ b/druid/src/widget/slider.rs @@ -18,6 +18,10 @@ use crate::kurbo::{Circle, Shape}; use crate::widget::prelude::*; use crate::{theme, LinearGradient, Point, Rect, UnitPoint}; +const TRACK_THICKNESS: f64 = 4.0; +const BORDER_WIDTH: f64 = 2.0; +const KNOB_STROKE_WIDTH: f64 = 2.0; + /// A slider, allowing interactive update of a numeric value. /// /// This slider implements `Widget`, and works on values clamped @@ -117,16 +121,12 @@ impl Widget for Slider { ctx.request_paint(); } - fn layout( - &mut self, - _layout_ctx: &mut LayoutCtx, - bc: &BoxConstraints, - _data: &f64, - env: &Env, - ) -> Size { + fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints, _data: &f64, env: &Env) -> Size { bc.debug_check("Slider"); let height = env.get(theme::BASIC_WIDGET_HEIGHT); let width = env.get(theme::WIDE_WIDGET_WIDTH); + let baseline_offset = (height / 2.0) - TRACK_THICKNESS; + ctx.set_baseline_offset(baseline_offset); bc.constrain((width, height)) } @@ -134,16 +134,13 @@ impl Widget for Slider { let clamped = self.normalize(*data); let rect = ctx.size().to_rect(); let knob_size = env.get(theme::BASIC_WIDGET_HEIGHT); - let track_thickness = 4.; - let border_width = 2.; - let knob_stroke_width = 2.; //Paint the background let background_width = rect.width() - knob_size; - let background_origin = Point::new(knob_size / 2., (knob_size - track_thickness) / 2.); - let background_size = Size::new(background_width, track_thickness); + let background_origin = Point::new(knob_size / 2., (knob_size - TRACK_THICKNESS) / 2.); + let background_size = Size::new(background_width, TRACK_THICKNESS); let background_rect = Rect::from_origin_size(background_origin, background_size) - .inset(-border_width / 2.) + .inset(-BORDER_WIDTH / 2.) .to_rounded_rect(2.); let background_gradient = LinearGradient::new( @@ -155,7 +152,7 @@ impl Widget for Slider { ), ); - ctx.stroke(background_rect, &env.get(theme::BORDER_DARK), border_width); + ctx.stroke(background_rect, &env.get(theme::BORDER_DARK), BORDER_WIDTH); ctx.fill(background_rect, &background_gradient); @@ -165,7 +162,7 @@ impl Widget for Slider { let knob_position = (rect.width() - knob_size) * clamped + knob_size / 2.; self.knob_pos = Point::new(knob_position, knob_size / 2.); - let knob_circle = Circle::new(self.knob_pos, (knob_size - knob_stroke_width) / 2.); + let knob_circle = Circle::new(self.knob_pos, (knob_size - KNOB_STROKE_WIDTH) / 2.); let normal_knob_gradient = LinearGradient::new( UnitPoint::TOP, @@ -197,7 +194,7 @@ impl Widget for Slider { env.get(theme::FOREGROUND_DARK) }; - ctx.stroke(knob_circle, &border_color, knob_stroke_width); + ctx.stroke(knob_circle, &border_color, KNOB_STROKE_WIDTH); //Actually paint the knob ctx.fill(knob_circle, &knob_gradient); diff --git a/druid/src/widget/switch.rs b/druid/src/widget/switch.rs index 7c89dd4de2..038b27d61d 100644 --- a/druid/src/widget/switch.rs +++ b/druid/src/widget/switch.rs @@ -169,9 +169,16 @@ impl Widget for Switch { } } - fn layout(&mut self, _ctx: &mut LayoutCtx, bc: &BoxConstraints, _: &bool, env: &Env) -> Size { - let width = env.get(theme::BORDERED_WIDGET_HEIGHT) * SWITCH_WIDTH_RATIO; - bc.constrain(Size::new(width, env.get(theme::BORDERED_WIDGET_HEIGHT))) + fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints, _: &bool, env: &Env) -> Size { + let text_metrics = self.on_text.layout_metrics(); + let height = env.get(theme::BORDERED_WIDGET_HEIGHT); + let width = height * SWITCH_WIDTH_RATIO; + + let label_y = (height - text_metrics.size.height).max(0.0) / 2.0; + let text_bottom_padding = height - (text_metrics.size.height + label_y); + let text_baseline_offset = text_metrics.size.height - text_metrics.first_baseline; + ctx.set_baseline_offset(text_bottom_padding + text_baseline_offset); + bc.constrain(Size::new(width, height)) } fn paint(&mut self, ctx: &mut PaintCtx, data: &bool, env: &Env) { diff --git a/druid/src/widget/textbox.rs b/druid/src/widget/textbox.rs index bca8e6c458..53728b1d6f 100644 --- a/druid/src/widget/textbox.rs +++ b/druid/src/widget/textbox.rs @@ -268,21 +268,27 @@ impl Widget for TextBox { } fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints, _data: &T, env: &Env) -> Size { + let width = env.get(theme::WIDE_WIDGET_WIDTH); + let min_height = env.get(theme::BORDERED_WIDGET_HEIGHT); + self.placeholder.rebuild_if_needed(ctx.text(), env); if self.multiline { self.editor .set_wrap_width(bc.max().width - TEXT_INSETS.x_value()); } self.editor.rebuild_if_needed(ctx.text(), env); - let size = self.editor.layout().size(); - let width = env.get(theme::WIDE_WIDGET_WIDTH); - let min_height = env.get(theme::BORDERED_WIDGET_HEIGHT); - - let text_height = size.height + TEXT_INSETS.y_value(); + let text_metrics = self.editor.layout().layout_metrics(); + let text_height = text_metrics.size.height + TEXT_INSETS.y_value(); let height = text_height.max(min_height); - bc.constrain((width, height)) + let size = bc.constrain((width, height)); + let bottom_padding = (size.height - text_metrics.size.height) / 2.0; + let baseline_off = + bottom_padding + (text_metrics.size.height - text_metrics.first_baseline); + ctx.set_baseline_offset(baseline_off); + + size } fn paint(&mut self, ctx: &mut PaintCtx, data: &T, env: &Env) {