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 9, 2020
1 parent 0bf2c9b commit 4968718
Show file tree
Hide file tree
Showing 3 changed files with 103 additions and 21 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ You can find its changes [documented below](#060---2020-06-01).
- `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])
- `WidgetPod::is_initialized` to check if a widget has received `WidgetAdded`. ([#1259] by [@finnerale])
- `TextBox::with_text_alignment` and `TextBox::set_text_alignment` ([#1371] by [@cmyr])

### Changed

Expand Down Expand Up @@ -529,6 +530,7 @@ Last release without a changelog :(
[#1351]: https://github.com/linebender/druid/pull/1351
[#1259]: https://github.com/linebender/druid/pull/1259
[#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
120 changes: 100 additions & 20 deletions druid/src/widget/textbox.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,13 @@ 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, KbKey, KeyOrValue, Point, Selector,
SysMods, TimerToken,
SysMods, TextAlignment, TimerToken,
};

const MAC_OR_LINUX: bool = cfg!(any(target_os = "macos", target_os = "linux"));
Expand All @@ -42,6 +43,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 @@ -70,6 +73,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 @@ -98,6 +103,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 @@ -146,6 +174,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 @@ -175,6 +225,7 @@ impl<T: TextStorage + EditableText> TextBox<T> {
let overall_text_width = self.editor.layout().size().width;
let text_insets = env.get(theme::TEXTBOX_INSETS);

//// when advancing the cursor, we want some additional padding
if overall_text_width < self_width - text_insets.x_value() {
// There's no offset if text is smaller than text box
//
Expand Down Expand Up @@ -217,18 +268,30 @@ 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> {
fn event(&mut self, ctx: &mut EventCtx, event: &Event, data: &mut T, env: &Env) {
fn event(&mut self, ctx: &mut EventCtx, event: &Event, data: &mut T, _env: &Env) {
self.suppress_adjust_hscroll = false;
match event {
Event::MouseDown(mouse) => {
ctx.request_focus();
ctx.set_active(true);
let mut mouse = mouse.clone();
let text_insets = env.get(theme::TEXTBOX_INSETS);
mouse.pos += Vec2::new(self.hscroll_offset - text_insets.x0, 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 @@ -240,8 +303,7 @@ impl<T: TextStorage + EditableText> Widget<T> for TextBox<T> {
}
Event::MouseMove(mouse) => {
let mut mouse = mouse.clone();
let text_insets = env.get(theme::TEXTBOX_INSETS);
mouse.pos += Vec2::new(self.hscroll_offset - text_insets.x0, 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 @@ -330,7 +392,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);
let text_insets = env.get(theme::TEXTBOX_INSETS);

Expand All @@ -341,10 +403,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 @@ -384,8 +453,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 @@ -402,14 +470,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.x1 - 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 4968718

Please sign in to comment.