From 75551afadf86e1338a665438ca045c8d9be8ca62 Mon Sep 17 00:00:00 2001 From: Colin Rofls Date: Tue, 3 Nov 2020 18:01:01 -0500 Subject: [PATCH] Support text alignment in single-line TextBox This is a pragmatic patch that allows the user to control how text is aligned within a single-line textbox when the textbox itself is larger than the contained text. This is directly motivated by Runebender; there are a number of places where we would like to have a textbox where the *text* is centered or right-aligned, and this is not currently possible. This is a bit hacky: in particular, we are not BiDi aware (even in the current textbox) which means that TextAlignment::Start is always left, and TextAlignment::End is always right. (Center is fine, and Justified is meaningless for a single line.) One complication encountered here is where to draw the cursor when there is no data; our options are either to draw the cursor at the start of the placeholder, or to draw it in the correct position for the alignment; we choose the latter, which feels less disruptive. --- CHANGELOG.md | 2 + druid/src/text/mod.rs | 2 +- druid/src/widget/textbox.rs | 124 +++++++++++++++++++++++++++++------- 3 files changed, 105 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dde44d1401..918cfe8502 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,6 +53,7 @@ You can find its changes [documented below](#060---2020-06-01). - `Checkbox::set_text` to update the label. ([#1346] by [@finnerale]) - `Event::should_propagate_to_hidden` and `Lifecycle::should_propagate_to_hidden` to determine whether an event should be sent to hidden widgets (e.g. in `Tabs` or `Either`). ([#1351] by [@andrewhickman]) - `set_cursor` can be called in the `update` method. ([#1361] by [@jneem]) +- `TextBox::with_text_alignment` and `TextBox::set_text_alignment` ([#1371] by [@cmyr]) ### Changed @@ -525,6 +526,7 @@ Last release without a changelog :( [#1346]: https://github.com/linebender/druid/pull/1346 [#1351]: https://github.com/linebender/druid/pull/1351 [#1361]: https://github.com/linebender/druid/pull/1361 +[#1371]: https://github.com/linebender/druid/pull/1371 [Unreleased]: https://github.com/linebender/druid/compare/v0.6.0...master [0.6.0]: https://github.com/linebender/druid/compare/v0.5.0...v0.6.0 diff --git a/druid/src/text/mod.rs b/druid/src/text/mod.rs index 8d870954f4..babffe4c0f 100644 --- a/druid/src/text/mod.rs +++ b/druid/src/text/mod.rs @@ -29,7 +29,7 @@ pub use self::attribute::{Attribute, AttributeSpans}; pub use self::backspace::offset_for_delete_backwards; pub use self::editable_text::{EditableText, EditableTextCursor, StringCursor}; pub use self::font_descriptor::FontDescriptor; -pub use self::layout::TextLayout; +pub use self::layout::{LayoutMetrics, TextLayout}; pub use self::movement::{movement, Movement}; pub use self::selection::Selection; pub use self::text_input::{BasicTextInput, EditAction, MouseAction, TextInput}; diff --git a/druid/src/widget/textbox.rs b/druid/src/widget/textbox.rs index e02273a648..71ee6ff0c5 100644 --- a/druid/src/widget/textbox.rs +++ b/druid/src/widget/textbox.rs @@ -18,18 +18,19 @@ use std::time::Duration; use crate::kurbo::Vec2; use crate::text::{ - BasicTextInput, EditAction, EditableText, Editor, TextInput, TextLayout, TextStorage, + BasicTextInput, EditAction, EditableText, Editor, LayoutMetrics, TextInput, TextLayout, + TextStorage, }; use crate::widget::prelude::*; use crate::{ theme, Affine, Color, Cursor, FontDescriptor, HotKey, Insets, KbKey, KeyOrValue, Point, - Selector, SysMods, TimerToken, + Selector, SysMods, TextAlignment, TimerToken, }; const MAC_OR_LINUX: bool = cfg!(any(target_os = "mac", target_os = "linux")); const BORDER_WIDTH: f64 = 1.; -const TEXT_INSETS: Insets = Insets::new(4.0, 2.0, 0.0, 2.0); +const TEXT_INSETS: Insets = Insets::new(4.0, 2.0, 4.0, 2.0); const CURSOR_BLINK_DURATION: Duration = Duration::from_millis(500); @@ -46,6 +47,8 @@ pub struct TextBox { cursor_timer: TimerToken, cursor_on: bool, multiline: bool, + alignment: TextAlignment, + alignment_offset: f64, /// true if a click event caused us to gain focus. /// /// On macOS, if focus happens via click then we set the selection based @@ -74,6 +77,8 @@ impl TextBox { cursor_on: false, placeholder, multiline: false, + alignment: TextAlignment::Start, + alignment_offset: 0.0, was_focused_from_click: false, } } @@ -102,6 +107,29 @@ impl TextBox { self } + /// Builder-style method to set the [`TextAlignment`]. + /// + /// This is only relevant when the `TextBox` is *not* [`multiline`], + /// in which case it determines how the text is positioned inside the + /// `TextBox` when it does not fill the available space. + /// + /// # Note: + /// + /// This does not behave exactly like [`TextAlignment`] does when used + /// with label; in particular this does not account for reading direction. + /// This means that `TextAlignment::Start` (the default) always means + /// *left aligned*, and `TextAlignment::End` always means *right aligned*. + /// + /// This should be considered a bug, but it will not be fixed until proper + /// BiDi support is implemented. + /// + /// [`TextAlignment`]: enum.TextAlignment.html + /// [`multiline`]: #method.multiline + pub fn with_text_alignment(mut self, alignment: TextAlignment) -> Self { + self.set_text_alignment(alignment); + self + } + /// Builder-style method for setting the font. /// /// The argument can be a [`FontDescriptor`] or a [`Key`] @@ -150,6 +178,28 @@ impl TextBox { self.placeholder.set_font(font); } + /// Set the [`TextAlignment`] for this `TextBox``. + /// + /// This is only relevant when the `TextBox` is *not* [`multiline`], + /// in which case it determines how the text is positioned inside the + /// `TextBox` when it does not fill the available space. + /// + /// # Note: + /// + /// This does not behave exactly like [`TextAlignment`] does when used + /// with label; in particular this does not account for reading direction. + /// This means that `TextAlignment::Start` (the default) always means + /// *left aligned*, and `TextAlignment::End` always means *right aligned*. + /// + /// This should be considered a bug, but it will not be fixed until proper + /// BiDi support is implemented. + /// + /// [`TextAlignment`]: enum.TextAlignment.html + /// [`multiline`]: #method.multiline + pub fn set_text_alignment(&mut self, alignment: TextAlignment) { + self.alignment = alignment; + } + /// Set the text color. /// /// The argument can be either a `Color` or a [`Key`]. @@ -179,19 +229,18 @@ impl TextBox { let overall_text_width = self.editor.layout().size().width; //// when advancing the cursor, we want some additional padding - let padding = TEXT_INSETS.x0 * 2.; - if overall_text_width < self_width - padding { + if overall_text_width < self_width - TEXT_INSETS.x_value() { // There's no offset if text is smaller than text box // // [***I* ] // ^ self.hscroll_offset = 0.; - } else if cursor_x > self_width + self.hscroll_offset - padding { + } else if cursor_x > self_width + self.hscroll_offset - TEXT_INSETS.x_value() { // If cursor goes past right side, bump the offset // -> // **[****I]**** // ^ - self.hscroll_offset = cursor_x - self_width + padding; + self.hscroll_offset = cursor_x - self_width + TEXT_INSETS.x_value(); } else if cursor_x < self.hscroll_offset { // If cursor goes past left side, match the offset // <- @@ -216,6 +265,19 @@ impl TextBox { fn should_draw_cursor(&self) -> bool { self.cursor_on } + + fn update_alignment_adjustment(&mut self, available_width: f64, metrics: &LayoutMetrics) { + self.alignment_offset = if self.multiline { + 0.0 + } else { + let extra_space = (available_width - metrics.size.width).max(0.0); + match self.alignment { + TextAlignment::Start | TextAlignment::Justified => 0.0, + TextAlignment::End => extra_space, + TextAlignment::Center => extra_space / 2.0, + } + } + } } impl Widget for TextBox { @@ -226,7 +288,7 @@ impl Widget for TextBox { ctx.request_focus(); ctx.set_active(true); let mut mouse = mouse.clone(); - mouse.pos += Vec2::new(self.hscroll_offset, 0.0); + mouse.pos += Vec2::new(self.hscroll_offset - self.alignment_offset, 0.0); if !mouse.focus { self.was_focused_from_click = true; @@ -238,7 +300,7 @@ impl Widget for TextBox { } Event::MouseMove(mouse) => { let mut mouse = mouse.clone(); - mouse.pos += Vec2::new(self.hscroll_offset, 0.0); + mouse.pos += Vec2::new(self.hscroll_offset - self.alignment_offset, 0.0); ctx.set_cursor(&Cursor::IBeam); if ctx.is_active() { self.editor.drag(&mouse, data); @@ -327,7 +389,7 @@ impl Widget for TextBox { } } - fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints, _data: &T, env: &Env) -> Size { + fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints, data: &T, env: &Env) -> Size { let width = env.get(theme::WIDE_WIDGET_WIDTH); self.placeholder.rebuild_if_needed(ctx.text(), env); @@ -337,10 +399,17 @@ impl Widget for TextBox { } self.editor.rebuild_if_needed(ctx.text(), env); - let text_metrics = self.editor.layout().layout_metrics(); - let height = text_metrics.size.height + TEXT_INSETS.y_value(); + let text_metrics = if data.is_empty() { + self.placeholder.layout_metrics() + } else { + self.editor.layout().layout_metrics() + }; + let height = text_metrics.size.height + TEXT_INSETS.y_value(); let size = bc.constrain((width, height)); + // if we have a non-left text-alignment, we need to manually adjust our position. + self.update_alignment_adjustment(size.width - TEXT_INSETS.x_value(), &text_metrics); + let bottom_padding = (size.height - text_metrics.size.height) / 2.0; let baseline_off = bottom_padding + (text_metrics.size.height - text_metrics.first_baseline); @@ -378,8 +447,7 @@ impl Widget for TextBox { // Shift everything inside the clip by the hscroll_offset rc.transform(Affine::translate((-self.hscroll_offset, 0.))); - let text_pos = Point::new(TEXT_INSETS.x0, TEXT_INSETS.y0); - + let text_pos = Point::new(TEXT_INSETS.x0 + self.alignment_offset, TEXT_INSETS.y0); // Draw selection rect if !data.is_empty() { if is_focused { @@ -396,14 +464,26 @@ impl Widget for TextBox { // Paint the cursor if focused and there's no selection if is_focused && self.should_draw_cursor() { - // the cursor position can extend past the edge of the layout - // (commonly when there is trailing whitespace) so we clamp it - // to the right edge. - let mut cursor = self.editor.cursor_line() + text_pos.to_vec2(); - let dx = size.width + self.hscroll_offset - TEXT_INSETS.x_value() - cursor.p0.x; - if dx < 0.0 { - cursor = cursor + Vec2::new(dx, 0.); - } + // if there's no data, we always draw the cursor based on + // our alignment. + let cursor = if data.is_empty() { + let dx = match self.alignment { + TextAlignment::Start | TextAlignment::Justified => TEXT_INSETS.x0, + TextAlignment::Center => size.width / 2.0, + TextAlignment::End => size.width - TEXT_INSETS.x1, + }; + self.editor.cursor_line() + Vec2::new(dx, TEXT_INSETS.y0) + } else { + // the cursor position can extend past the edge of the layout + // (commonly when there is trailing whitespace) so we clamp it + // to the right edge. + let mut cursor = self.editor.cursor_line() + text_pos.to_vec2(); + let dx = size.width + self.hscroll_offset - TEXT_INSETS.x0 - cursor.p0.x; + if dx < 0.0 { + cursor = cursor + Vec2::new(dx, 0.); + } + cursor + }; rc.stroke(cursor, &cursor_color, 1.); } });