Skip to content

Commit

Permalink
Support text alignment in single-line TextBox
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
cmyr committed Nov 3, 2020
1 parent f7b3904 commit 75551af
Show file tree
Hide file tree
Showing 3 changed files with 105 additions and 23 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion druid/src/text/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down
124 changes: 102 additions & 22 deletions druid/src/widget/textbox.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -46,6 +47,8 @@ pub struct TextBox<T> {
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
Expand Down Expand Up @@ -74,6 +77,8 @@ impl<T> TextBox<T> {
cursor_on: false,
placeholder,
multiline: false,
alignment: TextAlignment::Start,
alignment_offset: 0.0,
was_focused_from_click: false,
}
}
Expand Down Expand Up @@ -102,6 +107,29 @@ impl<T> TextBox<T> {
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<FontDescriptor>`]
Expand Down Expand Up @@ -150,6 +178,28 @@ impl<T> TextBox<T> {
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<Color>`].
Expand Down Expand Up @@ -179,19 +229,18 @@ impl<T: TextStorage + EditableText> TextBox<T> {
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
// <-
Expand All @@ -216,6 +265,19 @@ impl<T: TextStorage + EditableText> TextBox<T> {
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<T: TextStorage + EditableText> Widget<T> for TextBox<T> {
Expand All @@ -226,7 +288,7 @@ impl<T: TextStorage + EditableText> Widget<T> for TextBox<T> {
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;
Expand All @@ -238,7 +300,7 @@ impl<T: TextStorage + EditableText> Widget<T> for TextBox<T> {
}
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);
Expand Down Expand Up @@ -327,7 +389,7 @@ impl<T: TextStorage + EditableText> Widget<T> for TextBox<T> {
}
}

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);
Expand All @@ -337,10 +399,17 @@ impl<T: TextStorage + EditableText> Widget<T> for TextBox<T> {
}
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);
Expand Down Expand Up @@ -378,8 +447,7 @@ impl<T: TextStorage + EditableText> Widget<T> for TextBox<T> {
// 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 {
Expand All @@ -396,14 +464,26 @@ impl<T: TextStorage + EditableText> Widget<T> for TextBox<T> {

// 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.);
}
});
Expand Down

0 comments on commit 75551af

Please sign in to comment.