diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e73950c12..a467e7202 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,12 +16,6 @@ env: NO_WASM_PKGS: "--exclude masonry --exclude xilem" # Only some of our examples support Android (primarily due to extra required boilerplate). ANDROID_TARGETS: "-p xilem --example mason_android --example calc_android --example stopwatch_android --example variable_clock_android --example http_cats_android --example to_do_mvc_android" - # We do not run the masonry snapshot tests, because those currently require a specific font stack - # See https://github.com/linebender/xilem/pull/233 - SKIP_RENDER_SNAPSHOTS: 1 - # We do not run the masonry render tests, because those require Vello rendering to be working - # See also https://github.com/linebender/vello/pull/610 - SKIP_RENDER_TESTS: 1 # Rationale @@ -259,6 +253,9 @@ jobs: - name: cargo nextest run: cargo nextest run --workspace --locked --all-features --no-fail-fast env: + # We do not run the masonry render tests on platforms without a working GPU, + # because those require Vello rendering to be working + # See also https://github.com/linebender/vello/pull/610 SKIP_RENDER_TESTS: ${{ matrix.skip_gpu }} - name: Upload test results due to failure diff --git a/masonry/examples/grid_masonry.rs b/masonry/examples/grid_masonry.rs index 18a01d667..e8fe50c9e 100644 --- a/masonry/examples/grid_masonry.rs +++ b/masonry/examples/grid_masonry.rs @@ -8,7 +8,7 @@ use masonry::dpi::LogicalSize; use masonry::text::StyleProperty; -use masonry::widget::{Button, Grid, GridParams, Prose, RootWidget, SizedBox}; +use masonry::widget::{Button, Grid, GridParams, Prose, RootWidget, SizedBox, TextArea}; use masonry::{Action, AppDriver, Color, DriverCtx, PointerButton, WidgetId}; use parley::layout::Alignment; use winit::window::Window; @@ -43,11 +43,11 @@ fn grid_button(params: GridParams) -> Button { } fn main() { - let label = SizedBox::new( - Prose::new("Change spacing by right and left clicking on the buttons") + let label = SizedBox::new(Prose::from_text_area( + TextArea::new_immutable("Change spacing by right and left clicking on the buttons") .with_style(StyleProperty::FontSize(14.0)) .with_alignment(Alignment::Middle), - ) + )) .border(Color::rgb8(40, 40, 80), 1.0); let button_inputs = vec![ GridParams { diff --git a/masonry/examples/to_do_list.rs b/masonry/examples/to_do_list.rs index 57a9c294f..58fca2c91 100644 --- a/masonry/examples/to_do_list.rs +++ b/masonry/examples/to_do_list.rs @@ -9,7 +9,7 @@ #![expect(elided_lifetimes_in_paths, reason = "Deferred: Noisy")] use masonry::dpi::LogicalSize; -use masonry::widget::{Button, Flex, Label, Portal, RootWidget, Textbox, WidgetMut}; +use masonry::widget::{Button, Flex, Label, Portal, RootWidget, TextArea, Textbox, WidgetMut}; use masonry::{Action, AppDriver, DriverCtx, WidgetId}; use winit::window::Window; @@ -32,7 +32,8 @@ impl AppDriver for Driver { let mut first_row = first_row.downcast::(); let mut textbox = Flex::child_mut(&mut first_row, 0).unwrap(); let mut textbox = textbox.downcast::(); - Textbox::reset_text(&mut textbox, String::new()); + let mut text_area = Textbox::text_mut(&mut textbox); + TextArea::reset_text(&mut text_area, ""); } Action::TextChanged(new_text) => { self.next_task = new_text.clone(); diff --git a/masonry/examples/two_textboxes.rs b/masonry/examples/two_textboxes.rs index f06366cc7..d791c52f3 100644 --- a/masonry/examples/two_textboxes.rs +++ b/masonry/examples/two_textboxes.rs @@ -22,9 +22,11 @@ impl AppDriver for Driver { fn main() { let main_widget = Flex::column() + .gap(0.0) + .with_spacer(VERTICAL_WIDGET_SPACING) .with_child(Textbox::new("")) - .with_child(Textbox::new("")) - .with_spacer(VERTICAL_WIDGET_SPACING); + .with_spacer(VERTICAL_WIDGET_SPACING) + .with_child(Textbox::new("")); let window_size = LogicalSize::new(400.0, 400.0); let window_attributes = Window::default_attributes() diff --git a/masonry/src/text/editor.rs b/masonry/src/text/editor.rs index 6a0e304c8..d72d5fb48 100644 --- a/masonry/src/text/editor.rs +++ b/masonry/src/text/editor.rs @@ -14,10 +14,12 @@ use parley::{ cursor::{Cursor, Selection, VisualMode}, Affinity, Alignment, Layout, Line, }, - style::{Brush, StyleProperty}, + style::Brush, FontContext, LayoutContext, Rect, }; -use std::{borrow::ToOwned, string::String, sync::Arc, vec::Vec}; +use std::{borrow::ToOwned, string::String, vec::Vec}; + +use super::styleset::StyleSet; #[derive(Copy, Clone, Debug)] pub enum ActiveText<'a> { @@ -50,7 +52,7 @@ pub struct PlainEditor where T: Brush + Clone + Debug + PartialEq + Default, { - default_style: Arc<[StyleProperty<'static, T>]>, + default_style: StyleSet, buffer: String, layout: Layout, layout_access: LayoutAccessibility, @@ -72,22 +74,21 @@ where generation: Generation, } -// TODO: When MSRV >= 1.80 we can remove this. Default was not implemented for Arc<[T]> where T: !Default until 1.80 -impl Default for PlainEditor +impl PlainEditor where - T: Brush + Clone + Debug + PartialEq + Default, + T: Brush, { - fn default() -> Self { + pub fn new(font_size: f32) -> Self { Self { - default_style: Arc::new([]), + default_style: StyleSet::new(font_size), buffer: Default::default(), layout: Default::default(), layout_access: Default::default(), selection: Default::default(), cursor_mode: Default::default(), - width: Default::default(), + width: None, scale: 1.0, - layout_dirty: Default::default(), + layout_dirty: false, alignment: Alignment::Start, // We don't use the `default` value to start with, as our consumers // will choose to use that as their initial value, but will probably need @@ -112,37 +113,7 @@ impl PlainEditorTxn<'_, T> where T: Brush + Clone + Debug + PartialEq + Default, { - /// Replace the whole text buffer. - pub fn set_text(&mut self, is: &str) { - self.editor.buffer.clear(); - self.editor.buffer.push_str(is); - self.editor.layout_dirty = true; - } - - /// Set the width of the layout. - pub fn set_width(&mut self, width: Option) { - self.editor.width = width; - self.editor.layout_dirty = true; - } - - /// Set the alignment of the layout. - pub fn set_alignment(&mut self, alignment: Alignment) { - self.editor.alignment = alignment; - self.editor.layout_dirty = true; - } - - /// Set the scale for the layout. - pub fn set_scale(&mut self, scale: f32) { - self.editor.scale = scale; - self.editor.layout_dirty = true; - } - - /// Set the default style for the layout. - pub fn set_default_style(&mut self, style: Arc<[StyleProperty<'static, T>]>) { - self.editor.default_style = style; - self.editor.layout_dirty = true; - } - + // --- MARK: Forced relayout --- /// Insert at cursor, or replace selection. pub fn insert_or_replace_selection(&mut self, s: &str) { self.editor @@ -239,6 +210,7 @@ where } } + // --- MARK: Cursor Movement --- /// Move the cursor to the cluster boundary nearest this point in the layout. pub fn move_to_point(&mut self, x: f32, y: f32) { self.refresh_layout(); @@ -497,6 +469,7 @@ where } } + // --- MARK: Internal helpers --- fn update_layout(&mut self) { self.editor.update_layout(self.font_cx, self.layout_cx); } @@ -510,26 +483,39 @@ impl PlainEditor where T: Brush + Clone + Debug + PartialEq + Default, { - /// Run a series of [`PlainEditorTxn`] methods, updating the layout - /// if necessary. + /// Run a series of [`PlainEditorTxn`] methods. + /// + /// This is a utility shorthand around [`transaction`](Self::transaction); pub fn transact( &mut self, font_cx: &mut FontContext, layout_cx: &mut LayoutContext, callback: impl FnOnce(&mut PlainEditorTxn<'_, T>) -> R, ) -> R { - let mut txn = PlainEditorTxn { + let mut txn = self.transaction(font_cx, layout_cx); + callback(&mut txn) + } + + /// Run a series of [`PlainEditorTxn`] methods, updating the layout + /// if necessary. + /// + /// This is a utility shorthand to simplify methods which require the editor + /// and the provided contexts. + pub fn transaction<'txn>( + &'txn mut self, + font_cx: &'txn mut FontContext, + layout_cx: &'txn mut LayoutContext, + ) -> PlainEditorTxn<'txn, T> { + PlainEditorTxn { editor: self, font_cx, layout_cx, - }; - let ret = callback(&mut txn); - txn.update_layout(); - ret + } } /// Make a cursor at a given byte index fn cursor_at(&self, index: usize) -> Cursor { + // TODO: Do we need to be non-dirty? // FIXME: `Selection` should make this easier if index >= self.buffer.len() { Cursor::from_index( @@ -548,6 +534,7 @@ where layout_cx: &mut LayoutContext, s: &str, ) { + // TODO: Do we need to be non-dirty? let range = self.selection.text_range(); let start = range.start; if self.selection.is_collapsed() { @@ -618,11 +605,62 @@ where self.generation } - /// Get the full read-only details from the layout. - pub fn layout(&self) -> &Layout { + /// Get the full read-only details from the layout + pub fn layout( + &mut self, + font_cx: &mut FontContext, + layout_cx: &mut LayoutContext, + ) -> &Layout { + self.refresh_layout(font_cx, layout_cx); &self.layout } + /// Get the full read-only details from the layout, if valid. + pub fn get_layout(&self) -> Option<&Layout> { + if self.layout_dirty { + None + } else { + Some(&self.layout) + } + } + + /// Get the (potentially invalid) details from the layout. + pub fn layout_raw(&self) -> &Layout { + &self.layout + } + + /// Replace the whole text buffer. + pub fn set_text(&mut self, is: &str) { + self.buffer.clear(); + self.buffer.push_str(is); + self.layout_dirty = true; + } + + /// Set the width of the layout. + // TODO: If this is infinite, is the width used for alignnment the min width? + pub fn set_width(&mut self, width: Option) { + self.width = width; + self.layout_dirty = true; + } + + /// Set the alignment of the layout. + pub fn set_alignment(&mut self, alignment: Alignment) { + self.alignment = alignment; + self.layout_dirty = true; + } + + /// Set the scale for the layout. + pub fn set_scale(&mut self, scale: f32) { + self.scale = scale; + self.layout_dirty = true; + } + + /// Set the default style for the layout. + pub fn edit_styles(&mut self) -> &mut StyleSet { + self.layout_dirty = true; + &mut self.default_style + } + /// Update the layout if it is dirty. fn refresh_layout(&mut self, font_cx: &mut FontContext, layout_cx: &mut LayoutContext) { if self.layout_dirty { @@ -633,7 +671,7 @@ where /// Update the layout. fn update_layout(&mut self, font_cx: &mut FontContext, layout_cx: &mut LayoutContext) { let mut builder = layout_cx.ranged_builder(font_cx, &self.buffer, self.scale); - for prop in self.default_style.iter() { + for prop in self.default_style.inner().values() { builder.push_default(prop.to_owned()); } builder.build_into(&mut self.layout, &self.buffer); diff --git a/masonry/src/text/mod.rs b/masonry/src/text/mod.rs index 1b5e9cdcc..37a84a45d 100644 --- a/masonry/src/text/mod.rs +++ b/masonry/src/text/mod.rs @@ -12,8 +12,7 @@ mod editor; mod render_text; - -use std::{collections::HashMap, mem::Discriminant}; +mod styleset; pub use editor::{ActiveText, Generation, PlainEditor, PlainEditorTxn}; pub use render_text::render_text; @@ -29,30 +28,4 @@ pub struct BrushIndex(pub usize); pub type StyleProperty = parley::StyleProperty<'static, BrushIndex>; -/// A set of Parley styles. -pub struct StyleSet(HashMap, StyleProperty>); - -impl StyleSet { - pub fn new(font_size: f32) -> Self { - let mut this = Self(Default::default()); - this.insert(StyleProperty::FontSize(font_size)); - this - } - - pub fn insert(&mut self, style: StyleProperty) -> Option { - let discriminant = std::mem::discriminant(&style); - self.0.insert(discriminant, style) - } - - pub fn retain(&mut self, mut f: impl FnMut(&StyleProperty) -> bool) { - self.0.retain(|_, v| f(v)); - } - - pub fn remove(&mut self, property: Discriminant) -> Option { - self.0.remove(&property) - } - - pub fn inner(&self) -> &HashMap, StyleProperty> { - &self.0 - } -} +pub type StyleSet = styleset::StyleSet; diff --git a/masonry/src/text/styleset.rs b/masonry/src/text/styleset.rs new file mode 100644 index 000000000..930ed6c02 --- /dev/null +++ b/masonry/src/text/styleset.rs @@ -0,0 +1,40 @@ +// Copyright 2018 the Xilem Authors and the Druid Authors +// SPDX-License-Identifier: Apache-2.0 + +use std::{collections::HashMap, mem::Discriminant}; + +type StyleProperty = parley::StyleProperty<'static, Brush>; + +/// A set of Parley styles. +#[derive(Clone, Debug)] +pub struct StyleSet( + HashMap>, StyleProperty>, +); + +impl StyleSet { + pub fn new(font_size: f32) -> Self { + let mut this = Self(Default::default()); + this.insert(StyleProperty::FontSize(font_size)); + this + } + + pub fn insert(&mut self, style: StyleProperty) -> Option> { + let discriminant = std::mem::discriminant(&style); + self.0.insert(discriminant, style) + } + + pub fn retain(&mut self, mut f: impl FnMut(&StyleProperty) -> bool) { + self.0.retain(|_, v| f(v)); + } + + pub fn remove( + &mut self, + property: Discriminant>, + ) -> Option> { + self.0.remove(&property) + } + + pub fn inner(&self) -> &HashMap>, StyleProperty> { + &self.0 + } +} diff --git a/masonry/src/widget/checkbox.rs b/masonry/src/widget/checkbox.rs index bf56a729f..b8e10a40d 100644 --- a/masonry/src/widget/checkbox.rs +++ b/masonry/src/widget/checkbox.rs @@ -229,11 +229,11 @@ impl Widget for Checkbox { #[cfg(test)] mod tests { use insta::assert_debug_snapshot; - use parley::StyleProperty; use super::*; use crate::assert_render_snapshot; use crate::testing::{widget_ids, TestHarness, TestWidgetExt}; + use crate::text::StyleProperty; use crate::theme::PRIMARY_LIGHT; #[test] diff --git a/masonry/src/widget/label.rs b/masonry/src/widget/label.rs index 3f4b46a05..31f99f638 100644 --- a/masonry/src/widget/label.rs +++ b/masonry/src/widget/label.rs @@ -80,7 +80,7 @@ pub struct Label { impl Label { /// Create a new label with the given text. /// - // This is written out fully to appease rust-analyzer. + // This is written out fully to appease rust-analyzer; StyleProperty is imported but not recognised. /// To change the font size, use `with_style`, setting [`StyleProperty::FontSize`](parley::StyleProperty::FontSize). pub fn new(text: impl Into) -> Self { Self { diff --git a/masonry/src/widget/mod.rs b/masonry/src/widget/mod.rs index e547e0a22..b4cd5d89a 100644 --- a/masonry/src/widget/mod.rs +++ b/masonry/src/widget/mod.rs @@ -28,6 +28,7 @@ mod scroll_bar; mod sized_box; mod spinner; mod split; +mod text_area; mod textbox; mod variable_label; mod widget_arena; @@ -47,6 +48,7 @@ pub use scroll_bar::ScrollBar; pub use sized_box::{Padding, SizedBox}; pub use spinner::Spinner; pub use split::Split; +pub use text_area::TextArea; pub use textbox::Textbox; pub use variable_label::VariableLabel; pub use widget_mut::WidgetMut; diff --git a/masonry/src/widget/prose.rs b/masonry/src/widget/prose.rs index 07a673398..c43320903 100644 --- a/masonry/src/widget/prose.rs +++ b/masonry/src/widget/prose.rs @@ -1,622 +1,150 @@ // Copyright 2018 the Xilem Authors and the Druid Authors // SPDX-License-Identifier: Apache-2.0 -use std::mem::Discriminant; -use std::time::Instant; - -use crate::text::{render_text, Generation, PlainEditor}; -use accesskit::{Node, NodeId, Role}; -use parley::layout::Alignment; -use smallvec::SmallVec; +use accesskit::{Node, Role}; +use smallvec::{smallvec, SmallVec}; use tracing::{trace_span, Span}; -use vello::kurbo::{Affine, Point, Size}; -use vello::peniko::{BlendMode, Brush, Color, Fill}; +use vello::kurbo::{Point, Rect, Size}; use vello::Scene; -use winit::keyboard::{Key, NamedKey}; -use crate::text::{ArcStr, BrushIndex, StyleProperty, StyleSet}; -use crate::widget::{LineBreaking, WidgetMut}; +use crate::widget::WidgetMut; use crate::{ - theme, AccessCtx, AccessEvent, BoxConstraints, CursorIcon, EventCtx, LayoutCtx, PaintCtx, - PointerButton, PointerEvent, QueryCtx, RegisterCtx, TextEvent, Update, UpdateCtx, Widget, - WidgetId, + AccessCtx, AccessEvent, BoxConstraints, EventCtx, LayoutCtx, PaintCtx, PointerEvent, QueryCtx, + RegisterCtx, TextEvent, Update, UpdateCtx, Widget, WidgetId, }; +use super::{Padding, TextArea, WidgetPod}; + /// Added padding between each horizontal edge of the widget /// and the text in logical pixels. -const PROSE_X_PADDING: f64 = 2.0; +/// +/// This gives the text the some slight breathing room. +const PROSE_PADDING: Padding = Padding::horizontal(2.0); +// The bottom padding is to workaround https://github.com/linebender/parley/issues/165 +// const PROSE_PADDING: Padding = Padding::new(0.0, 2.0, 5.0, 2.0); -/// The prose widget is a widget which displays text which can be -/// selected with keyboard and mouse, and which can be copied from, -/// but cannot be modified by the user. +/// The prose widget displays immutable text which can be +/// selected within. +/// +/// The text can also be copied from, but cannot be modified by the user. +/// Note that copying is not yet implemented. +/// +/// At runtime, most properties of the text will be set using [`text_mut`](Self::text_mut). +/// This is because `Prose` largely serves as a wrapper around a [`TextArea`]. +/// +/// This should be used instead of [`Label`](super::Label) for immutable text, +/// as it enables users to copy/paste from the text. /// -/// This should be preferred over [`Label`](super::Label) for most -/// immutable text, other than that within other widgets. +/// This widget has no actions. pub struct Prose { - editor: PlainEditor, - rendered_generation: Generation, + text: WidgetPod>, - pending_text: Option, - - last_click_time: Option, - click_count: u32, - - // TODO: Support for links? - //https://github.com/linebender/xilem/issues/360 - styles: StyleSet, - /// Whether `styles` has been updated since `text_layout` was updated. - /// - /// If they have, the layout needs to be recreated. - styles_changed: bool, - - line_break_mode: LineBreaking, - alignment: Alignment, - /// Whether the alignment has changed since the last layout, which would force a re-alignment. - alignment_changed: bool, - /// The value of `max_advance` when this layout was last calculated. - /// - /// If it has changed, we need to re-perform line-breaking. - last_max_advance: Option, - - /// The brush for drawing this label's text. - /// - /// Requires a new paint if edited whilst `disabled_brush` is not being used. - brush: Brush, - /// The brush to use whilst this widget is disabled. - /// - /// When this is `None`, `brush` will be used. - /// Requires a new paint if edited whilst this widget is disabled. - disabled_brush: Option, - /// Whether to hint whilst drawing the text. - /// - /// Should be disabled whilst an animation involving this label is ongoing. - // TODO: What classes of animations? - hint: bool, + /// Whether to clip the contained text. + clip: bool, } -// --- MARK: BUILDERS --- impl Prose { - pub fn new(text: impl Into) -> Self { - let editor = PlainEditor::default(); - Prose { - editor, - rendered_generation: Generation::default(), - pending_text: Some(text.into()), - last_click_time: None, - click_count: 0, - styles: StyleSet::new(theme::TEXT_SIZE_NORMAL), - styles_changed: true, - line_break_mode: LineBreaking::WordWrap, - alignment: Alignment::Start, - alignment_changed: true, - last_max_advance: None, - brush: theme::TEXT_COLOR.into(), - disabled_brush: Some(theme::DISABLED_TEXT_COLOR.into()), - hint: true, - } - } - - /// Get the current text of this label. - /// - /// To update the text of an active label, use [`set_text`](Self::set_text). - pub fn text(&self) -> &str { - self.editor.text() - } - - /// Set a style property for the new label. - /// - /// Setting [`StyleProperty::Brush`](parley::StyleProperty::Brush) is not supported. - /// Use `with_brush` instead. - /// - /// To set a style property on an active label, use [`insert_style`](Self::insert_style). - pub fn with_style(mut self, property: impl Into) -> Self { - self.insert_style_inner(property.into()); - self - } - - /// Set a style property for the new label, returning the old value. - /// - /// Most users should prefer [`with_style`](Self::with_style) instead. - pub fn try_with_style( - mut self, - property: impl Into, - ) -> (Self, Option) { - let old = self.insert_style_inner(property.into()); - (self, old) - } - - /// Set how line breaks will be handled by this label. - /// - /// To modify this on an active label, use [`set_line_break_mode`](Self::set_line_break_mode). - pub fn with_line_break_mode(mut self, line_break_mode: LineBreaking) -> Self { - self.line_break_mode = line_break_mode; - self - } - - /// Set the alignment of the text. + /// Create a new `Prose` with the given text. /// - /// Text alignment might have unexpected results when the label has no horizontal constraints. - /// To modify this on an active label, use [`set_alignment`](Self::set_alignment). - pub fn with_alignment(mut self, alignment: Alignment) -> Self { - self.alignment = alignment; - self + /// To use non-default text properties, use [`from_text_area`](Self::from_text_area) instead. + pub fn new(text: &str) -> Self { + Self::from_text_area(TextArea::new_immutable(text)) } - /// Set the brush used to paint this label. - /// - /// In most cases, this will be the text's color, but gradients and images are also supported. - /// - /// To modify this on an active label, use [`set_brush`](Self::set_brush). - #[doc(alias = "with_color")] - pub fn with_brush(mut self, brush: impl Into) -> Self { - self.brush = brush.into(); - self + /// Create a new `Prose` from a styled text area. + pub fn from_text_area(text: TextArea) -> Self { + let text = text.with_padding_if_default(PROSE_PADDING); + Self { + text: WidgetPod::new(text), + clip: false, + } } - /// Set the brush which will be used to paint this label whilst it is disabled. + /// Create a new `Prose` from a styled text area in a [`WidgetPod`]. /// - /// If this is `None`, the [normal brush](Self::with_brush) will be used. - /// To modify this on an active label, use [`set_disabled_brush`](Self::set_disabled_brush). - #[doc(alias = "with_color")] - pub fn with_disabled_brush(mut self, disabled_brush: impl Into>) -> Self { - self.disabled_brush = disabled_brush.into(); - self + /// Note that the default padding used for prose will not be applied. + pub fn from_text_area_pod(text: WidgetPod>) -> Self { + Self { text, clip: false } } - /// Set whether [hinting](https://en.wikipedia.org/wiki/Font_hinting) will be used for this label. - /// - /// Hinting is a process where text is drawn "snapped" to pixel boundaries to improve fidelity. - /// The default is true, i.e. hinting is enabled by default. + /// Whether to clip the text to the available space. /// - /// This should be set to false if the label will be animated at creation. - /// The kinds of relevant animations include changing variable font parameters, - /// translating or scaling. - /// Failing to do so will likely lead to an unpleasant shimmering effect, as different parts of the - /// text "snap" at different times. + /// If this is set to true, it is recommended, but not required, that this + /// wraps a text area with [word wrapping](TextArea::with_word_wrap) enabled. /// - /// To modify this on an active label, use [`set_hint`](Self::set_hint). - // TODO: Should we tell each widget if smooth scrolling is ongoing so they can disable their hinting? - // Alternatively, we should automate disabling hinting at the Vello layer when composing. - pub fn with_hint(mut self, hint: bool) -> Self { - self.hint = hint; + /// To modify this on active prose, use [`set_clip`](Self::set_clip). + pub fn with_clip(mut self, clip: bool) -> Self { + self.clip = clip; self } - /// Shared logic between `with_style` and `insert_style` - fn insert_style_inner(&mut self, property: StyleProperty) -> Option { - if let StyleProperty::Brush(idx @ BrushIndex(1..)) = &property { - debug_panic!( - "Can't set a non-zero brush index ({idx:?}) on a `Label`, as it only supports global styling." - ); - } - self.styles.insert(property) + /// Read the underlying text area. Useful for getting its ID. + // This is a bit of a hack, to work around `from_text_area_pod` not being + // able to set padding. + pub fn text_area_pod(&self) -> &WidgetPod> { + &self.text } } // --- MARK: WIDGETMUT --- impl Prose { - // Note: These docs are lazy, but also have a decreased likelihood of going out of date. - /// The runtime requivalent of [`with_style`](Self::with_style). - /// - /// Setting [`StyleProperty::Brush`](parley::StyleProperty::Brush) is not supported. - /// Use [`set_brush`](Self::set_brush) instead. - pub fn insert_style( - this: &mut WidgetMut<'_, Self>, - property: impl Into, - ) -> Option { - let old = this.widget.insert_style_inner(property.into()); - - this.widget.styles_changed = true; - this.ctx.request_layout(); - old - } - - /// Keep only the styles for which `f` returns true. - /// - /// Styles which are removed return to Parley's default values. - /// In most cases, these are the defaults for this widget. + /// Edit the underlying text area. /// - /// Of note, behaviour is unspecified for unsetting the [`FontSize`](parley::StyleProperty::FontSize). - pub fn retain_styles(this: &mut WidgetMut<'_, Self>, f: impl FnMut(&StyleProperty) -> bool) { - this.widget.styles.retain(f); - - this.widget.styles_changed = true; - this.ctx.request_layout(); + /// Used to modify most properties of the text. + pub fn text_mut<'t>(this: &'t mut WidgetMut<'_, Self>) -> WidgetMut<'t, TextArea> { + this.ctx.get_mut(&mut this.widget.text) } - /// Remove the style with the discriminant `property`. - /// - /// To get the discriminant requires constructing a valid `StyleProperty` for the - /// the desired property and passing it to [`core::mem::discriminant`]. - /// Getting this discriminant is usually possible in a `const` context. + /// Whether to clip the text to the available space. /// - /// Styles which are removed return to Parley's default values. - /// In most cases, these are the defaults for this widget. + /// If this is set to true, it is recommended, but not required, that this + /// wraps a text area with [word wrapping](TextArea::set_word_wrap) enabled. /// - /// Of note, behaviour is unspecified for unsetting the [`FontSize`](parley::StyleProperty::FontSize). - pub fn remove_style( - this: &mut WidgetMut<'_, Self>, - property: Discriminant, - ) -> Option { - let old = this.widget.styles.remove(property); - - this.widget.styles_changed = true; + /// The runtime requivalent of [`with_clip`](Self::with_clip). + pub fn set_clip(this: &mut WidgetMut<'_, Self>, clip: bool) { + this.widget.clip = clip; this.ctx.request_layout(); - old - } - - /// Replace the text of this widget. - pub fn set_text(this: &mut WidgetMut<'_, Self>, new_text: impl Into) { - this.widget.pending_text = Some(new_text.into()); - - this.ctx.request_layout(); - } - - /// The runtime requivalent of [`with_line_break_mode`](Self::with_line_break_mode). - pub fn set_line_break_mode(this: &mut WidgetMut<'_, Self>, line_break_mode: LineBreaking) { - this.widget.line_break_mode = line_break_mode; - // We don't need to set an internal invalidation, as `max_advance` is always recalculated - this.ctx.request_layout(); - } - - /// The runtime requivalent of [`with_alignment`](Self::with_alignment). - pub fn set_alignment(this: &mut WidgetMut<'_, Self>, alignment: Alignment) { - this.widget.alignment = alignment; - - this.widget.alignment_changed = true; - this.ctx.request_layout(); - } - - #[doc(alias = "set_color")] - /// The runtime requivalent of [`with_brush`](Self::with_brush). - pub fn set_brush(this: &mut WidgetMut<'_, Self>, brush: impl Into) { - let brush = brush.into(); - this.widget.brush = brush; - - // We need to repaint unless the disabled brush is currently being used. - if this.widget.disabled_brush.is_none() || this.ctx.is_disabled() { - this.ctx.request_paint_only(); - } - } - - /// The runtime requivalent of [`with_disabled_brush`](Self::with_disabled_brush). - pub fn set_disabled_brush(this: &mut WidgetMut<'_, Self>, brush: impl Into>) { - let brush = brush.into(); - this.widget.disabled_brush = brush; - - if this.ctx.is_disabled() { - this.ctx.request_paint_only(); - } - } - - /// The runtime requivalent of [`with_hint`](Self::with_hint). - pub fn set_hint(this: &mut WidgetMut<'_, Self>, hint: bool) { - this.widget.hint = hint; - this.ctx.request_paint_only(); } } // --- MARK: IMPL WIDGET --- impl Widget for Prose { - fn on_pointer_event(&mut self, ctx: &mut EventCtx, event: &PointerEvent) { - if self.pending_text.is_some() { - debug_panic!("`set_text` on `Prose` was called before an event started"); - } - let window_origin = ctx.widget_state.window_origin(); - let inner_origin = Point::new(window_origin.x + PROSE_X_PADDING, window_origin.y); - match event { - PointerEvent::PointerDown(button, state) => { - if !ctx.is_disabled() && *button == PointerButton::Primary { - let now = Instant::now(); - if let Some(last) = self.last_click_time.take() { - if now.duration_since(last).as_secs_f64() < 0.25 { - self.click_count = (self.click_count + 1) % 4; - } else { - self.click_count = 1; - } - } else { - self.click_count = 1; - } - self.last_click_time = Some(now); - let click_count = self.click_count; - let cursor_pos = Point::new(state.position.x, state.position.y) - inner_origin; - let (fctx, lctx) = ctx.text_contexts(); - self.editor.transact(fctx, lctx, |txn| match click_count { - 2 => txn.select_word_at_point(cursor_pos.x as f32, cursor_pos.y as f32), - 3 => txn.select_line_at_point(cursor_pos.x as f32, cursor_pos.y as f32), - _ => txn.move_to_point(cursor_pos.x as f32, cursor_pos.y as f32), - }); - - let new_generation = self.editor.generation(); - if new_generation != self.rendered_generation { - ctx.request_render(); - self.rendered_generation = new_generation; - } - ctx.request_focus(); - ctx.capture_pointer(); - } - } - PointerEvent::PointerMove(state) => { - if !ctx.is_disabled() && ctx.has_pointer_capture() { - let cursor_pos = Point::new(state.position.x, state.position.y) - inner_origin; - let (fctx, lctx) = ctx.text_contexts(); - self.editor.transact(fctx, lctx, |txn| { - txn.extend_selection_to_point(cursor_pos.x as f32, cursor_pos.y as f32); - }); - let new_generation = self.editor.generation(); - if new_generation != self.rendered_generation { - ctx.request_render(); - self.rendered_generation = new_generation; - } - } - } - _ => {} - } - } + fn on_pointer_event(&mut self, _: &mut EventCtx, _: &PointerEvent) {} - fn on_text_event(&mut self, ctx: &mut EventCtx, event: &TextEvent) { - if self.pending_text.is_some() { - debug_panic!("`set_text` on `Prose` was called before an event started"); - } - match event { - TextEvent::KeyboardKey(key_event, modifiers_state) => { - if !key_event.state.is_pressed() { - return; - } - #[allow(unused)] - let (shift, action_mod) = ( - modifiers_state.shift_key(), - if cfg!(target_os = "macos") { - modifiers_state.super_key() - } else { - modifiers_state.control_key() - }, - ); - let (fctx, lctx) = ctx.text_contexts(); - match &key_event.logical_key { - #[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))] - Key::Character(c) if action_mod && matches!(c.as_str(), "c") => { - // TODO: use clipboard_rs::{Clipboard, ClipboardContext}; - match c.to_lowercase().as_str() { - "c" => { - if let crate::text::ActiveText::Selection(_) = - self.editor.active_text() - { - // let cb = ClipboardContext::new().unwrap(); - // cb.set_text(text.to_owned()).ok(); - } - } - _ => (), - } - } - Key::Character(c) if action_mod && matches!(c.to_lowercase().as_str(), "a") => { - self.editor.transact(fctx, lctx, |txn| { - if shift { - txn.collapse_selection(); - } else { - txn.select_all(); - } - }); - } - Key::Named(NamedKey::ArrowLeft) => self.editor.transact(fctx, lctx, |txn| { - if action_mod { - if shift { - txn.select_word_left(); - } else { - txn.move_word_left(); - } - } else if shift { - txn.select_left(); - } else { - txn.move_left(); - } - }), - Key::Named(NamedKey::ArrowRight) => self.editor.transact(fctx, lctx, |txn| { - if action_mod { - if shift { - txn.select_word_right(); - } else { - txn.move_word_right(); - } - } else if shift { - txn.select_right(); - } else { - txn.move_right(); - } - }), - Key::Named(NamedKey::ArrowUp) => self.editor.transact(fctx, lctx, |txn| { - if shift { - txn.select_up(); - } else { - txn.move_up(); - } - }), - Key::Named(NamedKey::ArrowDown) => self.editor.transact(fctx, lctx, |txn| { - if shift { - txn.select_down(); - } else { - txn.move_down(); - } - }), - Key::Named(NamedKey::Home) => self.editor.transact(fctx, lctx, |txn| { - if action_mod { - if shift { - txn.select_to_text_start(); - } else { - txn.move_to_text_start(); - } - } else if shift { - txn.select_to_line_start(); - } else { - txn.move_to_line_start(); - } - }), - Key::Named(NamedKey::End) => self.editor.transact(fctx, lctx, |txn| { - if action_mod { - if shift { - txn.select_to_text_end(); - } else { - txn.move_to_text_end(); - } - } else if shift { - txn.select_to_line_end(); - } else { - txn.move_to_line_end(); - } - }), - _ => (), - } - let new_generation = self.editor.generation(); - if new_generation != self.rendered_generation { - ctx.request_render(); - self.rendered_generation = new_generation; - } - } - // TODO: Set our highlighting colour to a lighter blue as window unfocused - TextEvent::FocusChange(_) => {} - TextEvent::Ime(e) => { - // TODO: Handle the cursor movement things from https://github.com/rust-windowing/winit/pull/3824 - tracing::warn!(event = ?e, "Prose doesn't accept IME"); - } - TextEvent::ModifierChange(_) => {} - } - } + fn on_text_event(&mut self, _ctx: &mut EventCtx, _event: &TextEvent) {} - fn accepts_focus(&self) -> bool { - false - } + fn on_access_event(&mut self, _ctx: &mut EventCtx, _event: &AccessEvent) {} - fn on_access_event(&mut self, ctx: &mut EventCtx, event: &AccessEvent) { - if event.action == accesskit::Action::SetTextSelection { - if let Some(accesskit::ActionData::SetTextSelection(selection)) = &event.data { - let (fctx, lctx) = ctx.text_contexts(); - self.editor - .transact(fctx, lctx, |txn| txn.select_from_accesskit(selection)); - } - } + fn register_children(&mut self, ctx: &mut RegisterCtx) { + ctx.register_child(&mut self.text); } - fn register_children(&mut self, _ctx: &mut RegisterCtx) {} - - fn update(&mut self, ctx: &mut UpdateCtx, event: &Update) { - match event { - Update::FocusChanged(false) => { - ctx.request_render(); - } - Update::FocusChanged(true) => { - ctx.request_render(); - } - Update::DisabledChanged(_) => { - // We might need to use the disabled brush, and stop displaying the selection. - ctx.request_render(); - } - _ => {} - } - } + fn update(&mut self, _ctx: &mut UpdateCtx, _event: &Update) {} fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size { - let (fctx, lctx) = ctx.text_contexts(); - let max_advance = self.editor.transact(fctx, lctx, |txn| { - if let Some(pending_text) = self.pending_text.take() { - txn.select_to_text_start(); - txn.collapse_selection(); - txn.set_text(&pending_text); - } - let available_width = if bc.max().width.is_finite() { - Some(bc.max().width as f32 - 2. * PROSE_X_PADDING as f32) - } else { - None - }; - - let max_advance = if self.line_break_mode == LineBreaking::WordWrap { - available_width - } else { - None - }; - if self.styles_changed { - let style = self.styles.inner().values().cloned().collect(); - txn.set_default_style(style); - self.styles_changed = false; - } - if max_advance != self.last_max_advance { - txn.set_width(max_advance); - } - if self.alignment_changed { - txn.set_alignment(self.alignment); - } - max_advance - }); - // We can't use the same feature as in label to make the width be minimal when the alignment is Start, - // because we don't have separate control over the alignment width in PlainEditor. - let alignment_width = max_advance.unwrap_or(self.editor.layout().width()); - let text_size = Size::new(alignment_width.into(), self.editor.layout().height().into()); - - let prose_size = Size { - height: text_size.height, - width: text_size.width + 2. * PROSE_X_PADDING, - }; - bc.constrain(prose_size) - } - - fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut Scene) { - if self.line_break_mode == LineBreaking::Clip { - let clip_rect = ctx.size().to_rect(); - scene.push_layer(BlendMode::default(), 1., Affine::IDENTITY, &clip_rect); - } - let transform = Affine::translate((PROSE_X_PADDING, 0.)); - for rect in self.editor.selection_geometry().iter() { - // TODO: If window not focused, use a different color - // TODO: Make configurable - scene.fill(Fill::NonZero, transform, Color::STEEL_BLUE, None, &rect); - } - - if ctx.is_focused() { - if let Some(cursor) = self.editor.selection_strong_geometry(1.5) { - // TODO: Make configurable - scene.fill(Fill::NonZero, transform, Color::WHITE, None, &cursor); - }; - if let Some(cursor) = self.editor.selection_weak_geometry(1.5) { - // TODO: Make configurable - scene.fill(Fill::NonZero, transform, Color::LIGHT_GRAY, None, &cursor); - }; - } - - let brush = if ctx.is_disabled() { - self.disabled_brush - .clone() - .unwrap_or_else(|| self.brush.clone()) - } else { - self.brush.clone() - }; - // TODO: Is disabling hinting ever right for prose? - render_text(scene, transform, self.editor.layout(), &[brush], self.hint); - - if self.line_break_mode == LineBreaking::Clip { - scene.pop_layer(); + // TODO: Set minimum to deal with alignment + let size = ctx.run_layout(&mut self.text, bc); + ctx.place_child(&mut self.text, Point::ORIGIN); + if self.clip { + // Workaround for https://github.com/linebender/parley/issues/165 + let clip_size = Size::new(size.width, size.height + 20.); + ctx.set_clip_path(Rect::from_origin_size(Point::ORIGIN, clip_size)); } + size } - fn get_cursor(&self, _ctx: &QueryCtx, _pos: Point) -> CursorIcon { - CursorIcon::Text + fn paint(&mut self, _ctx: &mut PaintCtx, _scene: &mut Scene) { + // All painting is handled by the child } fn accessibility_role(&self) -> Role { - Role::Document + Role::GenericContainer } - fn accessibility(&mut self, ctx: &mut AccessCtx, node: &mut Node) { - node.set_read_only(); - self.editor.accessibility( - ctx.tree_update, - node, - || NodeId::from(WidgetId::next()), - PROSE_X_PADDING, - 0.0, - ); - } + fn accessibility(&mut self, _ctx: &mut AccessCtx, _node: &mut Node) {} fn children_ids(&self) -> SmallVec<[WidgetId; 16]> { - SmallVec::new() + smallvec![self.text.id()] } fn make_trace_span(&self, ctx: &QueryCtx<'_>) -> Span { @@ -624,7 +152,7 @@ impl Widget for Prose { } fn get_debug_text(&self) -> Option { - Some(self.editor.text().chars().take(100).collect()) + self.clip.then(|| "(clip)".into()) } } @@ -634,35 +162,57 @@ mod tests { use parley::{layout::Alignment, StyleProperty}; use vello::kurbo::Size; + use super::*; use crate::{ assert_render_snapshot, testing::TestHarness, - widget::{CrossAxisAlignment, Flex, LineBreaking, Prose}, + widget::{CrossAxisAlignment, Flex, SizedBox, TextArea}, }; #[test] - /// A wrapping prose's alignment should be respected, regardkess of + /// A wrapping prose's alignment should be respected, regardless of + /// its parent's alignment. + fn prose_clipping() { + let prose = Prose::from_text_area( + TextArea::new_immutable("Hello this text should be truncated") + .with_style(StyleProperty::FontSize(10.0)) + .with_word_wrap(false), + ) + .with_clip(true); + + let sized_box = Flex::row().with_child(SizedBox::new(prose).width(60.)); + + let mut harness = TestHarness::create_with_size(sized_box, Size::new(80.0, 15.0)); + + assert_render_snapshot!(harness, "prose_clipping"); + } + + #[test] + /// A wrapping prose's alignment should be respected, regardless of /// its parent's alignment. fn prose_alignment_flex() { - fn base_label() -> Prose { + fn base_prose(alignment: Alignment) -> Prose { // Trailing whitespace is displayed when laying out prose. - Prose::new("Hello ") - .with_style(StyleProperty::FontSize(10.0)) - .with_line_break_mode(LineBreaking::WordWrap) + Prose::from_text_area( + TextArea::new_immutable("Hello ") + .with_style(StyleProperty::FontSize(10.0)) + .with_alignment(alignment) + .with_word_wrap(true), + ) } - let label1 = base_label().with_alignment(Alignment::Start); - let label2 = base_label().with_alignment(Alignment::Middle); - let label3 = base_label().with_alignment(Alignment::End); - let label4 = base_label().with_alignment(Alignment::Start); - let label5 = base_label().with_alignment(Alignment::Middle); - let label6 = base_label().with_alignment(Alignment::End); + let prose1 = base_prose(Alignment::Start); + let prose2 = base_prose(Alignment::Middle); + let prose3 = base_prose(Alignment::End); + let prose4 = base_prose(Alignment::Start); + let prose5 = base_prose(Alignment::Middle); + let prose6 = base_prose(Alignment::End); let flex = Flex::column() - .with_flex_child(label1, CrossAxisAlignment::Start) - .with_flex_child(label2, CrossAxisAlignment::Start) - .with_flex_child(label3, CrossAxisAlignment::Start) - .with_flex_child(label4, CrossAxisAlignment::Center) - .with_flex_child(label5, CrossAxisAlignment::Center) - .with_flex_child(label6, CrossAxisAlignment::Center) + .with_flex_child(prose1, CrossAxisAlignment::Start) + .with_flex_child(prose2, CrossAxisAlignment::Start) + .with_flex_child(prose3, CrossAxisAlignment::Start) + .with_flex_child(prose4, CrossAxisAlignment::Center) + .with_flex_child(prose5, CrossAxisAlignment::Center) + .with_flex_child(prose6, CrossAxisAlignment::Center) .gap(0.0); let mut harness = TestHarness::create_with_size(flex, Size::new(80.0, 80.0)); diff --git a/masonry/src/widget/screenshots/masonry__widget__prose__tests__prose_clipping.png b/masonry/src/widget/screenshots/masonry__widget__prose__tests__prose_clipping.png new file mode 100644 index 000000000..afea15054 --- /dev/null +++ b/masonry/src/widget/screenshots/masonry__widget__prose__tests__prose_clipping.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:868c0329260acb58948ff22cb8388719152119c11a954413c4fc6dda179d78f9 +size 1088 diff --git a/masonry/src/widget/screenshots/masonry__widget__textbox__tests__prose_alignment_flex.png b/masonry/src/widget/screenshots/masonry__widget__textbox__tests__prose_alignment_flex.png deleted file mode 100644 index b3db207b5..000000000 --- a/masonry/src/widget/screenshots/masonry__widget__textbox__tests__prose_alignment_flex.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:a19a2fd637f0d5337f458985fed9484d8130e75f1ed07124ac6e4d570f04207b -size 2433 diff --git a/masonry/src/widget/screenshots/masonry__widget__textbox__tests__textbox_outline.png b/masonry/src/widget/screenshots/masonry__widget__textbox__tests__textbox_outline.png new file mode 100644 index 000000000..3b9bbf3bd --- /dev/null +++ b/masonry/src/widget/screenshots/masonry__widget__textbox__tests__textbox_outline.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:279bd83a4fbbb701586a597cdaf31e90b24bda5a8092ef941a0f1460c1058fbf +size 1664 diff --git a/masonry/src/widget/sized_box.rs b/masonry/src/widget/sized_box.rs index e817f2015..af8f874d5 100644 --- a/masonry/src/widget/sized_box.rs +++ b/masonry/src/widget/sized_box.rs @@ -82,6 +82,22 @@ impl Padding { /// A padding of zero for all edges. pub const ZERO: Padding = Padding::all(0.); + /// An empty padding which can be used as a sentinel value. + /// + /// If parent widgets wish to override a padding only if it has not been modified by the user, + /// they should use [`is_unset`](Self::is_unset) to determine that there were no modifications. + /// + /// Otherwise, this padding will behave as [`Padding::ZERO`]. + pub const UNSET: Padding = Padding::all(-0.0); + + /// Determine if self is [`Padding::UNSET`]. + pub fn is_unset(self) -> bool { + is_negative_zero(self.top) + && is_negative_zero(self.leading) + && is_negative_zero(self.trailing) + && is_negative_zero(self.bottom) + } + /// Constructs a new `Padding` with equal amount of padding for all edges. pub const fn all(padding: f64) -> Self { Self::new(padding, padding, padding, padding) @@ -118,6 +134,28 @@ impl Padding { pub const fn leading(padding: f64) -> Self { Self::new(0., 0., 0., padding) } + + /// Get the padding to the left, given whether we're in a right-to-left context. + pub const fn get_left(self, is_rtl: bool) -> f64 { + if is_rtl { + self.trailing + } else { + self.leading + } + } + + /// Get the padding to the right, given whether we're in a right-to-left context. + pub const fn get_right(self, is_rtl: bool) -> f64 { + if is_rtl { + self.leading + } else { + self.trailing + } + } +} + +fn is_negative_zero(val: f64) -> bool { + val == 0.0 && val.is_sign_negative() } impl From for Padding { diff --git a/masonry/src/widget/text_area.rs b/masonry/src/widget/text_area.rs new file mode 100644 index 000000000..e6aa190fc --- /dev/null +++ b/masonry/src/widget/text_area.rs @@ -0,0 +1,968 @@ +// Copyright 2018 the Xilem Authors and the Druid Authors +// SPDX-License-Identifier: Apache-2.0 + +use std::mem::Discriminant; +use std::time::Instant; + +use crate::kurbo::{Affine, Point, Size}; +use crate::text::{render_text, Generation, PlainEditor}; +use accesskit::{Node, NodeId, Role}; +use parley::layout::Alignment; +use smallvec::SmallVec; +use tracing::{trace_span, Span}; +use vello::kurbo::Vec2; +use vello::peniko::{Brush, Color, Fill}; +use vello::Scene; +use winit::keyboard::{Key, NamedKey}; + +use crate::text::{BrushIndex, StyleProperty}; +use crate::widget::{Padding, WidgetMut}; +use crate::{ + theme, AccessCtx, AccessEvent, BoxConstraints, CursorIcon, EventCtx, LayoutCtx, PaintCtx, + PointerButton, PointerEvent, QueryCtx, RegisterCtx, TextEvent, Update, UpdateCtx, Widget, + WidgetId, +}; + +/// `TextArea` implements the core of interactive text. +/// +/// It is used to implement [`Textbox`](super::Textbox) and [`Prose`](super::Prose). +/// It is rare that you will use a raw `TextArea` as a widget in your app; most users +/// should prefer one of those wrappers. +/// +/// This ensures that the editable and read-only text have the same text selection and +/// copy/paste behaviour. +/// +/// The `USER_EDITABLE` const generic parameter determines whether the text area's contents can be +/// edited by the user of the app. +/// This is true for `Textbox` and false for `Prose`. +/// +/// This widget emits the following actions only when `USER_EDITABLE` is true: +/// +/// - `TextEntered`, which is sent when the enter key is pressed +/// - `TextChanged`, which is sent whenever the text is changed +/// +/// The exact semantics of how much horizontal space this widget takes up has not been determined. +/// In particular, this has consequences when the alignment is set. +// TODO: RichTextBox 👀 +// TODO: Support for links - https://github.com/linebender/xilem/issues/360 +pub struct TextArea { + // TODO: Placeholder text? + /// The underlying `PlainEditor`, which provides a high-level interface for us to dispatch into. + editor: PlainEditor, + /// The generation of `editor` which we have rendered. + /// + /// TODO: Split into rendered and layout generation. This will make the `edited` mechanism in [`on_text_event`](Widget::on_text_event). + rendered_generation: Generation, + + /// The time when this element was last clicked. + /// + /// Used to detect double/triple clicks. + /// The long-term plan is for this to be provided by the platform (i.e. winit), as that has more context. + last_click_time: Option, + /// How many clicks have occurred in this click sequence. + click_count: u32, + + /// Whether to wrap words in this area. + /// + /// Note that if clipping is desired, that should be added by the parent widget. + /// Can be set using [`set_word_wrap`](Self::set_word_wrap). + word_wrap: bool, + /// The amount of horizontal space available when [layout](Widget::layout) was + /// last performed. + /// + /// If word wrapping is enabled, we use this for line breaking. + /// We store this to avoid redoing work in layout and to set the + /// width when `word_wrap` is re-enabled. + last_available_width: Option, + + /// The brush for drawing this label's text. + /// + /// Requires a new paint if edited whilst `disabled_brush` is not being used. + /// Can be set using [`set_brush`](Self::set_brush). + brush: Brush, + /// The brush to use whilst this widget is disabled. + /// + /// When this is `None`, `brush` will be used. + /// Requires a new paint if edited whilst this widget is disabled. + /// /// Can be set using [`set_disabled_brush`](Self::set_disabled_brush). + disabled_brush: Option, + /// Whether to hint whilst drawing the text. + /// + /// Should be disabled whilst an animation involving this text is ongoing. + /// Can be set using [`set_hint`](Self::set_hint). + // TODO: What classes of animations? I.e does scrolling count? + hint: bool, + /// The amount of Padding inside this text area. + /// + /// This is generally expected to be set by the parent, but + /// can also be overridden. + /// Can be set using [`set_padding`](Self::set_padding). + /// Immediate parent widgets should use [`with_padding_if_default`](Self::with_padding_if_default). + padding: Padding, +} + +// --- MARK: BUILDERS --- +impl TextArea { + /// Create a new `TextArea` which can be edited. + /// + /// Useful for creating a styled [Textbox](super::Textbox). + // This is written out fully to appease rust-analyzer; StyleProperty is imported but not recognised. + /// To change the font size, use `with_style`, setting [`StyleProperty::FontSize`](parley::StyleProperty::FontSize). + pub fn new_editable(text: &str) -> Self { + Self::new(text) + } +} + +impl TextArea { + /// Create a new `TextArea` which cannot be edited by the user. + /// + /// Useful for creating a styled [Prose](super::Prose). + // This is written out fully to appease rust-analyzer; StyleProperty is imported but not recognised. + /// To change the font size, use `with_style`, setting [`StyleProperty::FontSize`](parley::StyleProperty::FontSize). + pub fn new_immutable(text: &str) -> Self { + Self::new(text) + } +} + +impl TextArea { + /// Create a new `TextArea` with the given text and default settings. + /// + // This is written out fully to appease rust-analyzer; StyleProperty is imported but not recognised. + /// To change the font size, use `with_style`, setting [`StyleProperty::FontSize`](parley::StyleProperty::FontSize). + pub fn new(text: &str) -> Self { + let mut editor = PlainEditor::new(theme::TEXT_SIZE_NORMAL); + editor.set_text(text); + TextArea { + editor, + rendered_generation: Generation::default(), + last_click_time: None, + click_count: 0, + word_wrap: true, + last_available_width: None, + brush: theme::TEXT_COLOR.into(), + disabled_brush: Some(theme::DISABLED_TEXT_COLOR.into()), + hint: true, + // We use -0.0 to mark the default padding. + // This allows parent views to overwrite it only if another source didn't configure it. + padding: Padding::UNSET, + } + } + + /// Get the current text of this text area. + /// + /// To update the text of an active text area, use [`reset_text`](Self::reset_text). + pub fn text(&self) -> &str { + self.editor.text() + } + + /// Set a style property for the new text area. + /// + /// Style properties set by this method include [text size](parley::StyleProperty::FontSize), + /// [font family](parley::StyleProperty::FontStack), [font weight](parley::StyleProperty::FontWeight), + /// and [variable font parameters](parley::StyleProperty::FontVariations). + /// The styles inserted here apply to the entire text; we currently do not + /// support inline rich text. + /// + /// Setting [`StyleProperty::Brush`](parley::StyleProperty::Brush) is not supported. + /// Use [`set_brush`](Self::set_brush) instead. + /// This is also not additive for [font stacks](parley::StyleProperty::FontStack), and + /// instead overwrites any previous font stack. + /// + /// To set a style property on an active text area, use [`insert_style`](Self::insert_style). + #[track_caller] + pub fn with_style(mut self, property: impl Into) -> Self { + self.insert_style_inner(property.into()); + self + } + + /// Set a style property for the new text area, returning the old value. + /// + /// Most users should prefer [`with_style`](Self::with_style) instead. + pub fn try_with_style( + mut self, + property: impl Into, + ) -> (Self, Option) { + let old = self.insert_style_inner(property.into()); + (self, old) + } + + /// Control [word wrapping](https://en.wikipedia.org/wiki/Line_wrap_and_word_wrap) for the new text area. + /// + /// When enabled, the text will be laid out to fit within the available width. + /// If word wrapping is disabled, the text will likely flow past the available area. + /// Note that parent widgets will often clip this, so the overflow will not be visible. + /// + /// This widget does not currently support scrolling to the cursor, + /// so it is recommended to leave word wrapping enabled. + /// + /// To modify this on an active text area, use [`set_word_wrap`](Self::set_word_wrap). + pub fn with_word_wrap(mut self, wrap_words: bool) -> Self { + self.word_wrap = wrap_words; + self + } + + /// Set the [alignment](https://en.wikipedia.org/wiki/Typographic_alignment) of the text. + /// + /// Text alignment might have unexpected results when the text area has no horizontal constraints. + /// + /// To modify this on an active text area, use [`set_alignment`](Self::set_alignment). + // TODO: Document behaviour based on provided minimum constraint? + pub fn with_alignment(mut self, alignment: Alignment) -> Self { + self.editor.set_alignment(alignment); + self + } + + /// Set the brush used to paint the text in this text area. + /// + /// In most cases, this will be the text's color, but gradients and images are also supported. + /// + /// To modify this on an active text area, use [`set_brush`](Self::set_brush). + #[doc(alias = "with_color")] + pub fn with_brush(mut self, brush: impl Into) -> Self { + self.brush = brush.into(); + self + } + + /// Set the brush which will be used to paint this text area whilst it is disabled. + /// + /// If this is `None`, the [normal brush](Self::with_brush) will be used. + /// + /// To modify this on an active text area, use [`set_disabled_brush`](Self::set_disabled_brush). + #[doc(alias = "with_color")] + pub fn with_disabled_brush(mut self, disabled_brush: impl Into>) -> Self { + self.disabled_brush = disabled_brush.into(); + self + } + + /// Set whether [hinting](https://en.wikipedia.org/wiki/Font_hinting) will be used for this text area. + /// + /// Hinting is a process where text is drawn "snapped" to pixel boundaries to improve fidelity. + /// The default is true, i.e. hinting is enabled by default. + /// + /// This should be set to false if the text area will be animated at creation. + /// The kinds of relevant animations include changing variable font parameters, + /// translating or scaling. + /// Failing to do so will likely lead to an unpleasant shimmering effect, as different parts of the + /// text "snap" at different times. + /// + /// To modify this on an active text area, use [`set_hint`](Self::set_hint). + /// You should do so as an animation starts and ends. + // TODO: Should we tell each widget if smooth scrolling is ongoing so they can disable their hinting? + // Alternatively, we should automate disabling hinting at the Vello layer when composing. + pub fn with_hint(mut self, hint: bool) -> Self { + self.hint = hint; + self + } + + /// Set the padding around the text. + /// + /// This is the area outside the tight bound on the text where pointer events will be detected. + /// + /// To modify this on an active text area, use [`set_padding`](Self::set_padding). + pub fn with_padding(mut self, padding: impl Into) -> Self { + self.padding = padding.into(); + self + } + + /// Adds `padding` unless [`with_padding`](Self::with_padding) was previously called. + /// + /// This is expected to be called when creating parent widgets. + pub fn with_padding_if_default(mut self, padding: Padding) -> Self { + if self.padding.is_unset() { + self.padding = padding; + } + self + } + + /// Shared logic between `with_style` and `insert_style` + #[track_caller] + fn insert_style_inner(&mut self, property: StyleProperty) -> Option { + if let StyleProperty::Brush(idx @ BrushIndex(1..)) = &property { + debug_panic!( + "Can't set a non-zero brush index ({idx:?}) on a `TextArea`, as it only supports global styling.\n\ + To modify the active brush, use `set_brush` or `with_brush` instead" + ); + None + } else { + self.editor.edit_styles().insert(property) + } + } +} + +// --- MARK: WIDGETMUT --- +impl TextArea { + /// Set font styling for an active text area. + /// + /// Style properties set by this method include [text size](parley::StyleProperty::FontSize), + /// [font family](parley::StyleProperty::FontStack), [font weight](parley::StyleProperty::FontWeight), + /// and [variable font parameters](parley::StyleProperty::FontVariations). + /// The styles inserted here apply to the entire text; we currently do not + /// support inline rich text. + /// + /// Setting [`StyleProperty::Brush`](parley::StyleProperty::Brush) is not supported. + /// Use [`set_brush`](Self::set_brush) instead. + /// This is also not additive for [font stacks](parley::StyleProperty::FontStack), and + /// instead overwrites any previous font stack. + /// + /// This is the runtime equivalent of [`with_style`](Self::with_style). + #[track_caller] + pub fn insert_style( + this: &mut WidgetMut<'_, Self>, + property: impl Into, + ) -> Option { + let old = this.widget.insert_style_inner(property.into()); + + this.ctx.request_layout(); + old + } + + /// [Retain](std::vec::Vec::retain) only the styles for which `f` returns true. + /// + /// Styles which are removed return to Parley's default values. + /// In most cases, these are the defaults for this widget. + /// + /// Of note, behaviour is unspecified for unsetting the [`FontSize`](parley::StyleProperty::FontSize). + pub fn retain_styles(this: &mut WidgetMut<'_, Self>, f: impl FnMut(&StyleProperty) -> bool) { + this.widget.editor.edit_styles().retain(f); + + this.ctx.request_layout(); + } + + /// Remove the style with the discriminant `property`. + /// + /// Styles which are removed return to Parley's default values. + /// In most cases, these are the defaults for this widget. + /// + /// To get the discriminant requires constructing a valid `StyleProperty` for the + /// the desired property and passing it to [`core::mem::discriminant`]. + /// Getting this discriminant is usually possible in a `const` context. + /// + /// Of note, behaviour is unspecified for unsetting the [`FontSize`](parley::StyleProperty::FontSize). + pub fn remove_style( + this: &mut WidgetMut<'_, Self>, + property: Discriminant, + ) -> Option { + let old = this.widget.editor.edit_styles().remove(property); + + this.ctx.request_layout(); + old + } + + /// Set the text displayed in this widget. + /// + /// This is likely to be disruptive if the user is focused on this widget, + /// as it does not retain selections, and may cause undesirable interactions with IME. + pub fn reset_text(this: &mut WidgetMut<'_, Self>, new_text: &str) { + this.widget.editor.set_text(new_text); + + this.ctx.request_layout(); + } + + /// Control [word wrapping](https://en.wikipedia.org/wiki/Line_wrap_and_word_wrap) for the text area. + /// + /// When enabled, the text will be laid out to fit within the available width. + /// If word wrapping is disabled, the text will likely flow past the available area. + /// Note that parent widgets will often clip this, so the overflow will not be visible. + /// + /// This widget does not currently support scrolling to the cursor, + /// so it is recommended to leave word wrapping enabled. + /// + /// The runtime equivalent of [`with_word_wrap`](Self::with_word_wrap). + pub fn set_word_wrap(this: &mut WidgetMut<'_, Self>, wrap_words: bool) { + this.widget.word_wrap = wrap_words; + let width = if wrap_words { + this.widget.last_available_width + } else { + None + }; + this.widget.editor.set_width(width); + this.ctx.request_layout(); + } + + /// Set the [alignment](https://en.wikipedia.org/wiki/Typographic_alignment) of the text. + /// + /// Text alignment might have unexpected results when the text area has no horizontal constraints. + /// + /// The runtime equivalent of [`with_alignment`](Self::with_alignment). + pub fn set_alignment(this: &mut WidgetMut<'_, Self>, alignment: Alignment) { + this.widget.editor.set_alignment(alignment); + + this.ctx.request_layout(); + } + + #[doc(alias = "set_color")] + /// Set the brush used to paint the text in this text area. + /// + /// In most cases, this will be the text's color, but gradients and images are also supported. + /// + /// The runtime equivalent of [`with_brush`](Self::with_brush). + pub fn set_brush(this: &mut WidgetMut<'_, Self>, brush: impl Into) { + let brush = brush.into(); + this.widget.brush = brush; + + // We need to repaint unless the disabled brush is currently being used. + if this.widget.disabled_brush.is_none() || !this.ctx.is_disabled() { + this.ctx.request_paint_only(); + } + } + + /// Set the brush used to paint this text area whilst it is disabled. + /// + /// If this is `None`, the [normal brush](Self::set_brush) will be used. + /// + /// The runtime equivalent of [`with_disabled_brush`](Self::with_disabled_brush). + pub fn set_disabled_brush(this: &mut WidgetMut<'_, Self>, brush: impl Into>) { + let brush = brush.into(); + this.widget.disabled_brush = brush; + + if this.ctx.is_disabled() { + this.ctx.request_paint_only(); + } + } + + /// Set whether [hinting](https://en.wikipedia.org/wiki/Font_hinting) will be used for this text area. + /// + /// The runtime equivalent of [`with_hint`](Self::with_hint). + /// For full documentation, see that method. + pub fn set_hint(this: &mut WidgetMut<'_, Self>, hint: bool) { + this.widget.hint = hint; + this.ctx.request_paint_only(); + } + + /// Set the padding around the text. + /// + /// This is the area outside the tight bound on the text where pointer events will be detected. + /// + /// The runtime equivalent of [`with_padding`](Self::with_padding). + pub fn set_padding(this: &mut WidgetMut<'_, Self>, padding: impl Into) { + this.widget.padding = padding.into(); + // TODO: We could reset the width available to the editor here directly. + // Determine whether there's any advantage to that + this.ctx.request_layout(); + } +} + +// --- MARK: IMPL WIDGET --- +impl Widget for TextArea { + fn on_pointer_event(&mut self, ctx: &mut EventCtx, event: &PointerEvent) { + let window_origin = ctx.widget_state.window_origin(); + let (fctx, lctx) = ctx.text_contexts(); + let is_rtl = self.editor.layout(fctx, lctx).is_rtl(); + let inner_origin = Point::new( + window_origin.x + self.padding.get_left(is_rtl), + window_origin.y + self.padding.top, + ); + match event { + PointerEvent::PointerDown(button, state) => { + if !ctx.is_disabled() && *button == PointerButton::Primary { + let now = Instant::now(); + if let Some(last) = self.last_click_time.take() { + if now.duration_since(last).as_secs_f64() < 0.25 { + self.click_count = (self.click_count + 1) % 4; + } else { + self.click_count = 1; + } + } else { + self.click_count = 1; + } + self.last_click_time = Some(now); + let click_count = self.click_count; + let cursor_pos = Point::new(state.position.x, state.position.y) - inner_origin; + let (fctx, lctx) = ctx.text_contexts(); + self.editor.transact(fctx, lctx, |txn| match click_count { + 2 => txn.select_word_at_point(cursor_pos.x as f32, cursor_pos.y as f32), + 3 => txn.select_line_at_point(cursor_pos.x as f32, cursor_pos.y as f32), + _ => txn.move_to_point(cursor_pos.x as f32, cursor_pos.y as f32), + }); + + let new_generation = self.editor.generation(); + if new_generation != self.rendered_generation { + ctx.request_render(); + self.rendered_generation = new_generation; + } + ctx.request_focus(); + ctx.capture_pointer(); + } + } + PointerEvent::PointerMove(state) => { + if !ctx.is_disabled() && ctx.has_pointer_capture() { + let cursor_pos = Point::new(state.position.x, state.position.y) - inner_origin; + let (fctx, lctx) = ctx.text_contexts(); + self.editor.transact(fctx, lctx, |txn| { + txn.extend_selection_to_point(cursor_pos.x as f32, cursor_pos.y as f32); + }); + let new_generation = self.editor.generation(); + if new_generation != self.rendered_generation { + ctx.request_render(); + self.rendered_generation = new_generation; + } + } + } + _ => {} + } + } + + fn on_text_event(&mut self, ctx: &mut EventCtx, event: &TextEvent) { + match event { + TextEvent::KeyboardKey(key_event, modifiers_state) => { + if !key_event.state.is_pressed() { + return; + } + #[allow(unused)] + let (shift, action_mod) = ( + modifiers_state.shift_key(), + if cfg!(target_os = "macos") { + modifiers_state.super_key() + } else { + modifiers_state.control_key() + }, + ); + let (fctx, lctx) = ctx.text_contexts(); + let mut edited = false; + // Ideally we'd use key_without_modifiers, but that's broken + match &key_event.logical_key { + // Cut + #[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))] + Key::Character(x) + if EDITABLE && action_mod && x.as_str().eq_ignore_ascii_case("x") => + { + edited = true; + // TODO: use clipboard_rs::{Clipboard, ClipboardContext}; + // if let crate::text::ActiveText::Selection(_) = self.editor.active_text() { + // let cb = ClipboardContext::new().unwrap(); + // cb.set_text(text.to_owned()).ok(); + // self.editor.transact(fcx, lcx, |txn| txn.delete_selection()); + // } + // edited = true; + } + // Copy + #[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))] + Key::Character(c) if action_mod && c.as_str().eq_ignore_ascii_case("c") => { + // TODO: use clipboard_rs::{Clipboard, ClipboardContext}; + // if let crate::text::ActiveText::Selection(_) = self.editor.active_text() { + // let cb = ClipboardContext::new().unwrap(); + // cb.set_text(text.to_owned()).ok(); + // } + } + // Paste + #[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))] + Key::Character(v) + if EDITABLE && action_mod && v.as_str().eq_ignore_ascii_case("v") => + { + edited = true; + // TODO: use clipboard_rs::{Clipboard, ClipboardContext}; + // let cb = ClipboardContext::new().unwrap(); + // let text = cb.get_text().unwrap_or_default(); + // self.editor.transact(fcx, lcx, |txn| txn.insert_or_replace_selection(&text)); + // edited = true; + } + Key::Character(a) if action_mod && a.as_str().eq_ignore_ascii_case("a") => { + self.editor.transact(fctx, lctx, |txn| { + if shift { + txn.collapse_selection(); + } else { + txn.select_all(); + } + }); + } + Key::Named(NamedKey::ArrowLeft) => self.editor.transact(fctx, lctx, |txn| { + if action_mod { + if shift { + txn.select_word_left(); + } else { + txn.move_word_left(); + } + } else if shift { + txn.select_left(); + } else { + txn.move_left(); + } + }), + Key::Named(NamedKey::ArrowRight) => self.editor.transact(fctx, lctx, |txn| { + if action_mod { + if shift { + txn.select_word_right(); + } else { + txn.move_word_right(); + } + } else if shift { + txn.select_right(); + } else { + txn.move_right(); + } + }), + Key::Named(NamedKey::ArrowUp) => self.editor.transact(fctx, lctx, |txn| { + if shift { + txn.select_up(); + } else { + txn.move_up(); + } + }), + Key::Named(NamedKey::ArrowDown) => self.editor.transact(fctx, lctx, |txn| { + if shift { + txn.select_down(); + } else { + txn.move_down(); + } + }), + Key::Named(NamedKey::Home) => self.editor.transact(fctx, lctx, |txn| { + if action_mod { + if shift { + txn.select_to_text_start(); + } else { + txn.move_to_text_start(); + } + } else if shift { + txn.select_to_line_start(); + } else { + txn.move_to_line_start(); + } + }), + Key::Named(NamedKey::End) => self.editor.transact(fctx, lctx, |txn| { + if action_mod { + if shift { + txn.select_to_text_end(); + } else { + txn.move_to_text_end(); + } + } else if shift { + txn.select_to_line_end(); + } else { + txn.move_to_line_end(); + } + }), + Key::Named(NamedKey::Delete) if EDITABLE => { + self.editor.transact(fctx, lctx, |txn| { + if action_mod { + txn.delete_word(); + } else { + txn.delete(); + } + }); + edited = true; + } + Key::Named(NamedKey::Backspace) if EDITABLE => { + self.editor.transact(fctx, lctx, |txn| { + if action_mod { + txn.backdelete_word(); + } else { + txn.backdelete(); + } + }); + edited = true; + } + Key::Named(NamedKey::Enter) => { + // TODO: Multiline? + let multiline = false; + if multiline { + let (fctx, lctx) = ctx.text_contexts(); + self.editor + .transact(fctx, lctx, |txn| txn.insert_or_replace_selection("\n")); + edited = true; + } else { + ctx.submit_action(crate::Action::TextEntered(self.text().to_string())); + } + } + Key::Named(NamedKey::Space) => { + self.editor + .transact(fctx, lctx, |txn| txn.insert_or_replace_selection(" ")); + edited = true; + } + Key::Named(NamedKey::Tab) => { + // Intentionally do nothing so that tabbing from a textbox/Prose works. + // Note that this doesn't allow input of the tab character; we need to be more clever here at some point + return; + } + _ if EDITABLE => match &key_event.text { + Some(text) => { + self.editor + .transact(fctx, lctx, |txn| txn.insert_or_replace_selection(text)); + edited = true; + } + None => { + // Do nothing, don't set as handled. + return; + } + }, + _ => { + // Do nothing, don't set as handled. + return; + } + } + ctx.set_handled(); + let new_generation = self.editor.generation(); + if new_generation != self.rendered_generation { + if edited { + ctx.submit_action(crate::Action::TextChanged(self.text().to_string())); + ctx.request_layout(); + } else { + ctx.request_render(); + } + self.rendered_generation = new_generation; + } + } + // TODO: Set our highlighting colour to a lighter blue as window unfocused + TextEvent::FocusChange(_) => {} + TextEvent::Ime(e) => { + // TODO: Handle the cursor movement things from https://github.com/rust-windowing/winit/pull/3824 + tracing::warn!(event = ?e, "TextArea doesn't accept IME"); + } + TextEvent::ModifierChange(_) => {} + } + } + + fn accepts_focus(&self) -> bool { + true + } + + fn accepts_text_input(&self) -> bool { + // TODO: Implement IME, then flip back to EDITABLE. + false + } + + fn on_access_event(&mut self, ctx: &mut EventCtx, event: &AccessEvent) { + if event.action == accesskit::Action::SetTextSelection { + if let Some(accesskit::ActionData::SetTextSelection(selection)) = &event.data { + let (fctx, lctx) = ctx.text_contexts(); + self.editor + .transact(fctx, lctx, |txn| txn.select_from_accesskit(selection)); + } + } + } + + fn register_children(&mut self, _ctx: &mut RegisterCtx) {} + + fn update(&mut self, ctx: &mut UpdateCtx, event: &Update) { + match event { + Update::FocusChanged(_) => { + ctx.request_render(); + } + Update::DisabledChanged(_) => { + // We might need to use the disabled brush, and stop displaying the selection. + ctx.request_render(); + } + _ => {} + } + } + + fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size { + // Shrink constraints by padding inset + let padding_size = Size::new( + self.padding.leading + self.padding.trailing, + self.padding.top + self.padding.bottom, + ); + let sub_bc = bc.shrink(padding_size); + + let available_width = if bc.max().width.is_finite() { + Some((sub_bc.max().width) as f32) + } else { + None + }; + let max_advance = if self.word_wrap { + available_width + } else { + None + }; + if self.last_available_width != available_width && self.word_wrap { + self.editor.set_width(max_advance); + } + self.last_available_width = available_width; + // TODO: Use the minimum width in the bc for alignment + + let new_generation = self.editor.generation(); + if new_generation != self.rendered_generation { + self.rendered_generation = new_generation; + } + + let (fctx, lctx) = ctx.text_contexts(); + let layout = self.editor.layout(fctx, lctx); + let text_width = max_advance.unwrap_or(layout.full_width()); + let text_size = Size::new(text_width.into(), layout.height().into()); + + let area_size = Size { + height: text_size.height + padding_size.height, + width: text_size.width + padding_size.width, + }; + bc.constrain(area_size) + } + + fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut Scene) { + let layout = if let Some(layout) = self.editor.get_layout() { + layout + } else { + debug_panic!("Widget `layout` should have happened before paint"); + let (fctx, lctx) = ctx.text_contexts(); + // The `layout` method takes `&mut self`, so we get borrow-checker errors if we return it from this block. + self.editor.layout(fctx, lctx); + self.editor.layout_raw() + }; + let is_rtl = layout.is_rtl(); + let origin = Vec2::new(self.padding.get_left(is_rtl), self.padding.top); + let transform = Affine::translate(origin); + if ctx.is_focused() { + for rect in self.editor.selection_geometry().iter() { + // TODO: If window not focused, use a different color + // TODO: Make configurable + scene.fill(Fill::NonZero, transform, Color::STEEL_BLUE, None, &rect); + } + if let Some(cursor) = self.editor.selection_strong_geometry(1.5) { + // TODO: Make configurable + scene.fill(Fill::NonZero, transform, Color::WHITE, None, &cursor); + }; + if let Some(cursor) = self.editor.selection_weak_geometry(1.5) { + // TODO: Make configurable + scene.fill(Fill::NonZero, transform, Color::LIGHT_GRAY, None, &cursor); + }; + } + + let brush = if ctx.is_disabled() { + self.disabled_brush + .clone() + .unwrap_or_else(|| self.brush.clone()) + } else { + self.brush.clone() + }; + render_text(scene, transform, layout, &[brush], self.hint); + } + + fn get_cursor(&self, _ctx: &QueryCtx, _pos: Point) -> CursorIcon { + CursorIcon::Text + } + + fn accessibility_role(&self) -> Role { + if EDITABLE { + Role::TextInput + // TODO: Role::MultilineTextInput + } else { + Role::Document + } + } + + fn accessibility(&mut self, ctx: &mut AccessCtx, node: &mut Node) { + let (fctx, lctx) = ctx.text_contexts(); + let is_rtl = self.editor.layout(fctx, lctx).is_rtl(); + let (x_offset, y_offset) = (self.padding.get_left(is_rtl), self.padding.top); + self.editor.accessibility( + ctx.tree_update, + node, + || NodeId::from(WidgetId::next()), + x_offset, + y_offset, + ); + } + + fn children_ids(&self) -> SmallVec<[WidgetId; 16]> { + SmallVec::new() + } + + fn make_trace_span(&self, ctx: &QueryCtx<'_>) -> Span { + trace_span!("Textbox", id = ctx.widget_id().trace()) + } + + fn get_debug_text(&self) -> Option { + Some(self.editor.text().chars().take(100).collect()) + } +} + +// TODO: What other tests can we have? Some options: +// - Clicking in the right place changes the selection as expected? +// - Keyboard actions have expected results? + +#[cfg(test)] +mod tests { + use vello::{kurbo::Size, peniko::Color}; + + use super::*; + use crate::testing::TestHarness; + // Tests of alignment happen in Prose. + + #[test] + fn edit_wordwrap() { + let base_with_wrapping = { + let area = TextArea::new_immutable("String which will wrap").with_word_wrap(true); + + let mut harness = TestHarness::create_with_size(area, Size::new(60.0, 40.0)); + + harness.render() + }; + + { + let area = TextArea::new_immutable("String which will wrap").with_word_wrap(false); + + let mut harness = TestHarness::create_with_size(area, Size::new(60.0, 40.0)); + + let without_wrapping = harness.render(); + + // Hack: If we are using `SKIP_RENDER_TESTS`, the output image is a 1x1 white pixel + // This means that the not equal comparison won't work, so we skip it. + // We should have a more principled solution here (or even better, get render tests working on windows) + if !std::env::var("SKIP_RENDER_TESTS").is_ok_and(|it| !it.is_empty()) { + // We don't use assert_eq because we don't want rich assert + assert!( + base_with_wrapping != without_wrapping, + "Word wrapping being disabled should be obvious" + ); + } + + harness.edit_root_widget(|mut root| { + let mut area = root.downcast::>(); + TextArea::set_word_wrap(&mut area, true); + }); + + let with_enabled_wrap = harness.render(); + + // We don't use assert_eq because we don't want rich assert + assert!( + base_with_wrapping == with_enabled_wrap, + "Updating the word wrap should correctly update" + ); + }; + } + + #[test] + fn edit_textarea() { + let base_target = { + let area = TextArea::new_immutable("Test string").with_brush(Color::AZURE); + + let mut harness = TestHarness::create_with_size(area, Size::new(200.0, 20.0)); + + harness.render() + }; + + { + let area = TextArea::new_immutable("Different string").with_brush(Color::AZURE); + + let mut harness = TestHarness::create_with_size(area, Size::new(200.0, 20.0)); + + harness.edit_root_widget(|mut root| { + let mut area = root.downcast::>(); + TextArea::reset_text(&mut area, "Test string"); + }); + + let with_updated_text = harness.render(); + + // We don't use assert_eq because we don't want rich assert + assert!( + base_target == with_updated_text, + "Updating the text should match with base text" + ); + + harness.edit_root_widget(|mut root| { + let mut area = root.downcast::>(); + TextArea::set_brush(&mut area, Color::BROWN); + }); + + let with_updated_brush = harness.render(); + + // Hack: If we are using `SKIP_RENDER_TESTS`, the output image is a 1x1 white pixel + // This means that the not equal comparison won't work, so we skip it. + if !std::env::var("SKIP_RENDER_TESTS").is_ok_and(|it| !it.is_empty()) { + // We don't use assert_eq because we don't want rich assert + assert!( + base_target != with_updated_brush, + "Updating the brush should have a visible change" + ); + } + }; + } +} diff --git a/masonry/src/widget/textbox.rs b/masonry/src/widget/textbox.rs index 55c946fcb..a319494a5 100644 --- a/masonry/src/widget/textbox.rs +++ b/masonry/src/widget/textbox.rs @@ -1,674 +1,150 @@ // Copyright 2018 the Xilem Authors and the Druid Authors // SPDX-License-Identifier: Apache-2.0 -use std::mem::Discriminant; -use std::time::Instant; - -use crate::text::{render_text, Generation, PlainEditor}; -use accesskit::{Node, NodeId, Role}; -use parley::layout::Alignment; -use smallvec::SmallVec; +use accesskit::{Node, Role}; +use smallvec::{smallvec, SmallVec}; use tracing::{trace_span, Span}; -use vello::kurbo::{Affine, Insets, Point, Size, Stroke}; -use vello::peniko::{BlendMode, Brush, Color, Fill}; +use vello::kurbo::{Affine, Insets, Point, Rect, Size, Stroke}; +use vello::peniko::Color; use vello::Scene; -use winit::keyboard::{Key, NamedKey}; -use crate::text::{ArcStr, BrushIndex, StyleProperty, StyleSet}; -use crate::widget::{LineBreaking, WidgetMut}; +use crate::widget::WidgetMut; use crate::{ - theme, AccessCtx, AccessEvent, BoxConstraints, CursorIcon, EventCtx, LayoutCtx, PaintCtx, - PointerButton, PointerEvent, QueryCtx, RegisterCtx, TextEvent, Update, UpdateCtx, Widget, - WidgetId, + AccessCtx, AccessEvent, BoxConstraints, EventCtx, LayoutCtx, PaintCtx, PointerEvent, QueryCtx, + RegisterCtx, TextEvent, Update, UpdateCtx, Widget, WidgetId, }; -const TEXTBOX_PADDING: f64 = 5.0; -/// HACK: A "margin" which is placed around the outside of all textboxes, ensuring that -/// they do not fill the entire width of the window. -/// -/// This is added by making the width of the textbox be (twice) this amount less than -/// the space available, which is absolutely horrible. -/// -/// In theory, this should be proper margin/padding in the parent widget, but that hasn't been -/// designed. -const TEXTBOX_X_MARGIN: f64 = 6.0; -/// The fallback minimum width for a textbox with infinite provided maximum width. -const INFINITE_TEXTBOX_WIDTH: f32 = 400.0; +use super::{Padding, TextArea, WidgetPod}; -/// The textbox widget is a widget which shows text which can be edited by the user +/// Added padding between each horizontal edge of the widget +/// and the text in logical pixels. /// -/// For immutable text [`Prose`](super::Prose) should be preferred -// TODO: RichTextBox 👀 -pub struct Textbox { - editor: PlainEditor, - rendered_generation: Generation, +/// This makes it so that the surrounding box isn't crowding out the text. +const TEXTBOX_PADDING: Padding = Padding::all(5.0); - pending_text: Option, - - last_click_time: Option, - click_count: u32, - - // TODO: Support for links? - //https://github.com/linebender/xilem/issues/360 - styles: StyleSet, - /// Whether `styles` has been updated since `text_layout` was updated. - /// - /// If they have, the layout needs to be recreated. - styles_changed: bool, +/// The margin added around textboxes to allow the boundaries to be visible inside the window edge. +const TEXTBOX_MARGIN: Padding = Padding::horizontal(2.0); - line_break_mode: LineBreaking, - alignment: Alignment, - /// Whether the alignment has changed since the last layout, which would force a re-alignment. - alignment_changed: bool, - /// The value of `max_advance` when this layout was last calculated. - /// - /// If it has changed, we need to re-perform line-breaking. - last_max_advance: Option, +/// The textbox widget displays text which can be edited by the user, +/// inside a surrounding box. +/// +/// This currently does not support newlines entered by the user, +/// although pre-existing newlines are handled correctly. +/// +/// This widget itself does not emit any actions. +/// However, the child widget will do so, as it is user editable. +/// The ID of the child can be accessed using [`area_pod`](Self::area_pod). +/// +/// At runtime, most properties of the text will be set using [`text_mut`](Self::text_mut). +/// This is because `Textbox` largely serves as a wrapper around a [`TextArea`]. +pub struct Textbox { + text: WidgetPod>, - /// The brush for drawing this label's text. - /// - /// Requires a new paint if edited whilst `disabled_brush` is not being used. - brush: Brush, - /// The brush to use whilst this widget is disabled. - /// - /// When this is `None`, `brush` will be used. - /// Requires a new paint if edited whilst this widget is disabled. - disabled_brush: Option, - /// Whether to hint whilst drawing the text. - /// - /// Should be disabled whilst an animation involving this label is ongoing. - // TODO: What classes of animations? - hint: bool, + /// Whether to clip the contained text. + clip: bool, } -// --- MARK: BUILDERS --- impl Textbox { - pub fn new(text: impl Into) -> Self { - let editor = PlainEditor::default(); - Textbox { - editor, - rendered_generation: Generation::default(), - pending_text: Some(text.into()), - last_click_time: None, - click_count: 0, - styles: StyleSet::new(theme::TEXT_SIZE_NORMAL), - styles_changed: true, - line_break_mode: LineBreaking::WordWrap, - alignment: Alignment::Start, - alignment_changed: true, - last_max_advance: None, - brush: theme::TEXT_COLOR.into(), - disabled_brush: Some(theme::DISABLED_TEXT_COLOR.into()), - hint: true, - } - } - - /// Get the current text of this label. - /// - /// To update the text of an active label, use [`set_text`](Self::set_text). - pub fn text(&self) -> &str { - self.editor.text() - } - - /// Set a style property for the new label. - /// - /// Setting [`StyleProperty::Brush`](parley::StyleProperty::Brush) is not supported. - /// Use `with_brush` instead. - /// - /// To set a style property on an active label, use [`insert_style`](Self::insert_style). - pub fn with_style(mut self, property: impl Into) -> Self { - self.insert_style_inner(property.into()); - self - } - - /// Set a style property for the new label, returning the old value. + /// Create a new `Prose` with the given text. /// - /// Most users should prefer [`with_style`](Self::with_style) instead. - pub fn try_with_style( - mut self, - property: impl Into, - ) -> (Self, Option) { - let old = self.insert_style_inner(property.into()); - (self, old) + /// To use non-default text properties, use [`from_text_area`](Self::from_text_area) instead. + pub fn new(text: &str) -> Self { + Self::from_text_area(TextArea::new_editable(text)) } - /// Set how line breaks will be handled by this label. - /// - /// To modify this on an active label, use [`set_line_break_mode`](Self::set_line_break_mode). - pub fn with_line_break_mode(mut self, line_break_mode: LineBreaking) -> Self { - self.line_break_mode = line_break_mode; - self + /// Create a new `Prose` from a styled text area. + pub fn from_text_area(text: TextArea) -> Self { + let text = text.with_padding_if_default(TEXTBOX_PADDING); + Self { + text: WidgetPod::new(text), + clip: false, + } } - /// Set the alignment of the text. + /// Create a new `Prose` from a styled text area in a [`WidgetPod`]. /// - /// Text alignment might have unexpected results when the label has no horizontal constraints. - /// To modify this on an active label, use [`set_alignment`](Self::set_alignment). - pub fn with_alignment(mut self, alignment: Alignment) -> Self { - self.alignment = alignment; - self + /// Note that the default padding used for prose will not apply. + pub fn from_text_area_pod(text: WidgetPod>) -> Self { + Self { text, clip: false } } - /// Set the brush used to paint this label. + /// Whether to clip the text to the drawn boundaries. /// - /// In most cases, this will be the text's color, but gradients and images are also supported. + /// If this is set to true, it is recommended, but not required, that this + /// wraps a text area with [word wrapping](TextArea::with_word_wrap) enabled. /// - /// To modify this on an active label, use [`set_brush`](Self::set_brush). - #[doc(alias = "with_color")] - pub fn with_brush(mut self, brush: impl Into) -> Self { - self.brush = brush.into(); + /// To modify this on active textbox, use [`set_clip`](Self::set_clip). + pub fn with_clip(mut self, clip: bool) -> Self { + self.clip = clip; self } - /// Set the brush which will be used to paint this label whilst it is disabled. + /// Read the underlying text area. /// - /// If this is `None`, the [normal brush](Self::with_brush) will be used. - /// To modify this on an active label, use [`set_disabled_brush`](Self::set_disabled_brush). - #[doc(alias = "with_color")] - pub fn with_disabled_brush(mut self, disabled_brush: impl Into>) -> Self { - self.disabled_brush = disabled_brush.into(); - self - } - - /// Set whether [hinting](https://en.wikipedia.org/wiki/Font_hinting) will be used for this label. - /// - /// Hinting is a process where text is drawn "snapped" to pixel boundaries to improve fidelity. - /// The default is true, i.e. hinting is enabled by default. - /// - /// This should be set to false if the label will be animated at creation. - /// The kinds of relevant animations include changing variable font parameters, - /// translating or scaling. - /// Failing to do so will likely lead to an unpleasant shimmering effect, as different parts of the - /// text "snap" at different times. - /// - /// To modify this on an active label, use [`set_hint`](Self::set_hint). - // TODO: Should we tell each widget if smooth scrolling is ongoing so they can disable their hinting? - // Alternatively, we should automate disabling hinting at the Vello layer when composing. - pub fn with_hint(mut self, hint: bool) -> Self { - self.hint = hint; - self - } - - /// Shared logic between `with_style` and `insert_style` - fn insert_style_inner(&mut self, property: StyleProperty) -> Option { - if let StyleProperty::Brush(idx @ BrushIndex(1..)) = &property { - debug_panic!( - "Can't set a non-zero brush index ({idx:?}) on a `Label`, as it only supports global styling." - ); - } - self.styles.insert(property) + /// Useful for getting its ID, as most actions from the textbox will be sent by the child. + pub fn area_pod(&self) -> &WidgetPod> { + &self.text } } // --- MARK: WIDGETMUT --- impl Textbox { - // Note: These docs are lazy, but also have a decreased likelihood of going out of date. - /// The runtime requivalent of [`with_style`](Self::with_style). + /// Edit the underlying text area. /// - /// Setting [`StyleProperty::Brush`](parley::StyleProperty::Brush) is not supported. - /// Use [`set_brush`](Self::set_brush) instead. - pub fn insert_style( - this: &mut WidgetMut<'_, Self>, - property: impl Into, - ) -> Option { - let old = this.widget.insert_style_inner(property.into()); - - this.widget.styles_changed = true; - this.ctx.request_layout(); - old + /// Used to modify most properties of the text. + pub fn text_mut<'t>(this: &'t mut WidgetMut<'_, Self>) -> WidgetMut<'t, TextArea> { + this.ctx.get_mut(&mut this.widget.text) } - /// Keep only the styles for which `f` returns true. + /// Whether to clip the text to the drawn boundaries. /// - /// Styles which are removed return to Parley's default values. - /// In most cases, these are the defaults for this widget. + /// If this is set to true, it is recommended, but not required, that this + /// wraps a text area with [word wrapping](TextArea::set_word_wrap) enabled. /// - /// Of note, behaviour is unspecified for unsetting the [`FontSize`](parley::StyleProperty::FontSize). - pub fn retain_styles(this: &mut WidgetMut<'_, Self>, f: impl FnMut(&StyleProperty) -> bool) { - this.widget.styles.retain(f); - - this.widget.styles_changed = true; - this.ctx.request_layout(); - } - - /// Remove the style with the discriminant `property`. - /// - /// To get the discriminant requires constructing a valid `StyleProperty` for the - /// the desired property and passing it to [`core::mem::discriminant`]. - /// Getting this discriminant is usually possible in a `const` context. - /// - /// Styles which are removed return to Parley's default values. - /// In most cases, these are the defaults for this widget. - /// - /// Of note, behaviour is unspecified for unsetting the [`FontSize`](parley::StyleProperty::FontSize). - pub fn remove_style( - this: &mut WidgetMut<'_, Self>, - property: Discriminant, - ) -> Option { - let old = this.widget.styles.remove(property); - - this.widget.styles_changed = true; - this.ctx.request_layout(); - old - } - - /// This is likely to be disruptive if the user is focused on this widget, - /// and so should be avoided if possible. - pub fn reset_text(this: &mut WidgetMut<'_, Self>, new_text: impl Into) { - this.widget.pending_text = Some(new_text.into()); - + /// The runtime requivalent of [`with_clip`](Self::with_clip). + pub fn set_clip(this: &mut WidgetMut<'_, Self>, clip: bool) { + this.widget.clip = clip; this.ctx.request_layout(); } - - /// The runtime requivalent of [`with_line_break_mode`](Self::with_line_break_mode). - pub fn set_line_break_mode(this: &mut WidgetMut<'_, Self>, line_break_mode: LineBreaking) { - this.widget.line_break_mode = line_break_mode; - // We don't need to set an internal invalidation, as `max_advance` is always recalculated - this.ctx.request_layout(); - } - - /// The runtime requivalent of [`with_alignment`](Self::with_alignment). - pub fn set_alignment(this: &mut WidgetMut<'_, Self>, alignment: Alignment) { - this.widget.alignment = alignment; - - this.widget.alignment_changed = true; - this.ctx.request_layout(); - } - - #[doc(alias = "set_color")] - /// The runtime requivalent of [`with_brush`](Self::with_brush). - pub fn set_brush(this: &mut WidgetMut<'_, Self>, brush: impl Into) { - let brush = brush.into(); - this.widget.brush = brush; - - // We need to repaint unless the disabled brush is currently being used. - if this.widget.disabled_brush.is_none() || this.ctx.is_disabled() { - this.ctx.request_paint_only(); - } - } - - /// The runtime requivalent of [`with_disabled_brush`](Self::with_disabled_brush). - pub fn set_disabled_brush(this: &mut WidgetMut<'_, Self>, brush: impl Into>) { - let brush = brush.into(); - this.widget.disabled_brush = brush; - - if this.ctx.is_disabled() { - this.ctx.request_paint_only(); - } - } - - /// The runtime requivalent of [`with_hint`](Self::with_hint). - pub fn set_hint(this: &mut WidgetMut<'_, Self>, hint: bool) { - this.widget.hint = hint; - this.ctx.request_paint_only(); - } } // --- MARK: IMPL WIDGET --- impl Widget for Textbox { - fn on_pointer_event(&mut self, ctx: &mut EventCtx, event: &PointerEvent) { - if self.pending_text.is_some() { - debug_panic!("`set_text` on `Prose` was called before an event started"); - } - let window_origin = ctx.widget_state.window_origin(); - let inner_origin = Point::new( - window_origin.x + TEXTBOX_X_MARGIN + TEXTBOX_PADDING, - window_origin.y, - ); - match event { - PointerEvent::PointerDown(button, state) => { - if !ctx.is_disabled() && *button == PointerButton::Primary { - let now = Instant::now(); - if let Some(last) = self.last_click_time.take() { - if now.duration_since(last).as_secs_f64() < 0.25 { - self.click_count = (self.click_count + 1) % 4; - } else { - self.click_count = 1; - } - } else { - self.click_count = 1; - } - self.last_click_time = Some(now); - let click_count = self.click_count; - let cursor_pos = Point::new(state.position.x, state.position.y) - inner_origin; - let (fctx, lctx) = ctx.text_contexts(); - self.editor.transact(fctx, lctx, |txn| match click_count { - 2 => txn.select_word_at_point(cursor_pos.x as f32, cursor_pos.y as f32), - 3 => txn.select_line_at_point(cursor_pos.x as f32, cursor_pos.y as f32), - _ => txn.move_to_point(cursor_pos.x as f32, cursor_pos.y as f32), - }); - - let new_generation = self.editor.generation(); - if new_generation != self.rendered_generation { - ctx.request_render(); - self.rendered_generation = new_generation; - } - ctx.request_focus(); - ctx.capture_pointer(); - } - } - PointerEvent::PointerMove(state) => { - if !ctx.is_disabled() && ctx.has_pointer_capture() { - let cursor_pos = Point::new(state.position.x, state.position.y) - inner_origin; - let (fctx, lctx) = ctx.text_contexts(); - self.editor.transact(fctx, lctx, |txn| { - txn.extend_selection_to_point(cursor_pos.x as f32, cursor_pos.y as f32); - }); - let new_generation = self.editor.generation(); - if new_generation != self.rendered_generation { - ctx.request_render(); - self.rendered_generation = new_generation; - } - } - } - _ => {} - } - } - - fn on_text_event(&mut self, ctx: &mut EventCtx, event: &TextEvent) { - if self.pending_text.take().is_some() { - debug_panic!("`set_text` on `Prose` was called before an event started"); - } - match event { - TextEvent::KeyboardKey(key_event, modifiers_state) => { - if !key_event.state.is_pressed() { - return; - } - #[allow(unused)] - let (shift, action_mod) = ( - modifiers_state.shift_key(), - if cfg!(target_os = "macos") { - modifiers_state.super_key() - } else { - modifiers_state.control_key() - }, - ); - let (fctx, lctx) = ctx.text_contexts(); - // Ideally we'd use key_without_modifiers, but that's broken - match &key_event.logical_key { - #[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))] - Key::Character(c) if action_mod && matches!(c.as_str(), "c" | "x" | "v") => { - // TODO: use clipboard_rs::{Clipboard, ClipboardContext}; - match c.to_lowercase().as_str() { - "c" => { - if let crate::text::ActiveText::Selection(_) = - self.editor.active_text() - { - // let cb = ClipboardContext::new().unwrap(); - // cb.set_text(text.to_owned()).ok(); - } - } - "x" => { - // if let crate::text::ActiveText::Selection(text) = self.editor.active_text() { - // let cb = ClipboardContext::new().unwrap(); - // cb.set_text(text.to_owned()).ok(); - // self.editor.transact(fcx, lcx, |txn| txn.delete_selection()); - // } - } - "v" => { - // let cb = ClipboardContext::new().unwrap(); - // let text = cb.get_text().unwrap_or_default(); - // self.editor.transact(fcx, lcx, |txn| txn.insert_or_replace_selection(&text)); - } - _ => (), - } - } - Key::Character(c) if action_mod && matches!(c.to_lowercase().as_str(), "a") => { - self.editor.transact(fctx, lctx, |txn| { - if shift { - txn.collapse_selection(); - } else { - txn.select_all(); - } - }); - } - Key::Named(NamedKey::ArrowLeft) => self.editor.transact(fctx, lctx, |txn| { - if action_mod { - if shift { - txn.select_word_left(); - } else { - txn.move_word_left(); - } - } else if shift { - txn.select_left(); - } else { - txn.move_left(); - } - }), - Key::Named(NamedKey::ArrowRight) => self.editor.transact(fctx, lctx, |txn| { - if action_mod { - if shift { - txn.select_word_right(); - } else { - txn.move_word_right(); - } - } else if shift { - txn.select_right(); - } else { - txn.move_right(); - } - }), - Key::Named(NamedKey::ArrowUp) => self.editor.transact(fctx, lctx, |txn| { - if shift { - txn.select_up(); - } else { - txn.move_up(); - } - }), - Key::Named(NamedKey::ArrowDown) => self.editor.transact(fctx, lctx, |txn| { - if shift { - txn.select_down(); - } else { - txn.move_down(); - } - }), - Key::Named(NamedKey::Home) => self.editor.transact(fctx, lctx, |txn| { - if action_mod { - if shift { - txn.select_to_text_start(); - } else { - txn.move_to_text_start(); - } - } else if shift { - txn.select_to_line_start(); - } else { - txn.move_to_line_start(); - } - }), - Key::Named(NamedKey::End) => self.editor.transact(fctx, lctx, |txn| { - if action_mod { - if shift { - txn.select_to_text_end(); - } else { - txn.move_to_text_end(); - } - } else if shift { - txn.select_to_line_end(); - } else { - txn.move_to_line_end(); - } - }), - Key::Named(NamedKey::Delete) => self.editor.transact(fctx, lctx, |txn| { - if action_mod { - txn.delete_word(); - } else { - txn.delete(); - } - }), - Key::Named(NamedKey::Backspace) => self.editor.transact(fctx, lctx, |txn| { - if action_mod { - txn.backdelete_word(); - } else { - txn.backdelete(); - } - }), - Key::Named(NamedKey::Enter) => { - ctx.submit_action(crate::Action::TextEntered(self.text().to_string())); - return; - // let (fctx, lctx) = ctx.text_contexts(); - // self.editor - // .transact(fctx, lctx, |txn| txn.insert_or_replace_selection("\n")); - } - Key::Named(NamedKey::Space) => { - self.editor - .transact(fctx, lctx, |txn| txn.insert_or_replace_selection(" ")); - } - _ => match &key_event.text { - Some(text) => { - self.editor - .transact(fctx, lctx, |txn| txn.insert_or_replace_selection(text)); - } - None => {} - }, - } - let new_generation = self.editor.generation(); - if new_generation != self.rendered_generation { - ctx.submit_action(crate::Action::TextChanged(self.text().to_string())); - // TODO: For all the non-text-input actions - ctx.request_layout(); - self.rendered_generation = new_generation; - } - } - // TODO: Set our highlighting colour to a lighter blue as window unfocused - TextEvent::FocusChange(_) => {} - TextEvent::Ime(e) => { - // TODO: Handle the cursor movement things from https://github.com/rust-windowing/winit/pull/3824 - tracing::warn!(event = ?e, "Prose doesn't accept IME"); - } - TextEvent::ModifierChange(_) => {} - } - } + fn on_pointer_event(&mut self, _: &mut EventCtx, _: &PointerEvent) {} - fn accepts_focus(&self) -> bool { - false - } + fn on_text_event(&mut self, _ctx: &mut EventCtx, _event: &TextEvent) {} - fn accepts_text_input(&self) -> bool { - // TODO: Flip back to true. - false - } + fn on_access_event(&mut self, _ctx: &mut EventCtx, _event: &AccessEvent) {} - fn on_access_event(&mut self, ctx: &mut EventCtx, event: &AccessEvent) { - if event.action == accesskit::Action::SetTextSelection { - if let Some(accesskit::ActionData::SetTextSelection(selection)) = &event.data { - let (fctx, lctx) = ctx.text_contexts(); - self.editor - .transact(fctx, lctx, |txn| txn.select_from_accesskit(selection)); - } - } + fn register_children(&mut self, ctx: &mut RegisterCtx) { + ctx.register_child(&mut self.text); } - fn register_children(&mut self, _ctx: &mut RegisterCtx) {} - - fn update(&mut self, ctx: &mut UpdateCtx, event: &Update) { - match event { - Update::FocusChanged(false) => { - ctx.request_render(); - } - Update::FocusChanged(true) => { - ctx.request_render(); - } - Update::DisabledChanged(_) => { - // We might need to use the disabled brush, and stop displaying the selection. - ctx.request_render(); - } - _ => {} - } - } + fn update(&mut self, _ctx: &mut UpdateCtx, _event: &Update) {} fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size { - let (fctx, lctx) = ctx.text_contexts(); - let available_width = self.editor.transact(fctx, lctx, |txn| { - if let Some(pending_text) = self.pending_text.take() { - txn.select_to_text_start(); - txn.collapse_selection(); - txn.set_text(&pending_text); - } - let available_width = if bc.max().width.is_finite() { - Some((bc.max().width - 2. * TEXTBOX_X_MARGIN - 2. * TEXTBOX_PADDING) as f32) - } else { - None - }; - - let max_advance = if self.line_break_mode == LineBreaking::WordWrap { - available_width - } else { - None - }; - if self.styles_changed { - let style = self.styles.inner().values().cloned().collect(); - txn.set_default_style(style); - self.styles_changed = false; - } - if max_advance != self.last_max_advance { - txn.set_width(max_advance); - } - if self.alignment_changed { - txn.set_alignment(self.alignment); - } - max_advance - }); - let new_generation = self.editor.generation(); - if new_generation != self.rendered_generation { - self.rendered_generation = new_generation; + let margin = TEXTBOX_MARGIN; + // Shrink constraints by padding inset + let margin_size = Size::new(margin.leading + margin.trailing, margin.top + margin.bottom); + let child_bc = bc.shrink(margin_size); + // TODO: Set minimum to deal with alignment + let size = ctx.run_layout(&mut self.text, &child_bc); + // TODO: How do we handle RTL here? + ctx.place_child(&mut self.text, Point::new(margin.leading, margin.top)); + if self.clip { + ctx.set_clip_path(Rect::from_origin_size(Point::ORIGIN, size)); } - - let text_width = available_width - .unwrap_or(self.editor.layout().full_width()) - .max( - INFINITE_TEXTBOX_WIDTH.min(bc.max().width as f32) - - (2. * TEXTBOX_PADDING + 2. * TEXTBOX_X_MARGIN) as f32, - ); - let text_size = Size::new(text_width.into(), self.editor.layout().height().into()); - - let textbox_size = Size { - height: text_size.height + 2. * TEXTBOX_PADDING, - width: text_size.width + 2. * TEXTBOX_PADDING + 2. * TEXTBOX_X_MARGIN, - }; - bc.constrain(textbox_size) + size + margin_size } fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut Scene) { - if self.line_break_mode == LineBreaking::Clip { - let clip_rect = ctx.size().to_rect(); - scene.push_layer(BlendMode::default(), 1., Affine::IDENTITY, &clip_rect); - } - - let transform = Affine::translate((TEXTBOX_PADDING + TEXTBOX_X_MARGIN, TEXTBOX_PADDING)); - for rect in self.editor.selection_geometry().iter() { - // TODO: If window not focused, use a different color - // TODO: Make configurable - scene.fill(Fill::NonZero, transform, Color::STEEL_BLUE, None, &rect); - } - - if ctx.is_focused() { - if let Some(cursor) = self.editor.selection_strong_geometry(1.5) { - // TODO: Make configurable - scene.fill(Fill::NonZero, transform, Color::WHITE, None, &cursor); - }; - if let Some(cursor) = self.editor.selection_weak_geometry(1.5) { - // TODO: Make configurable - scene.fill(Fill::NonZero, transform, Color::LIGHT_GRAY, None, &cursor); - }; - } - - let brush = if ctx.is_disabled() { - self.disabled_brush - .clone() - .unwrap_or_else(|| self.brush.clone()) - } else { - self.brush.clone() - }; - // TODO: Is disabling hinting ever right for textbox? - render_text(scene, transform, self.editor.layout(), &[brush], self.hint); - - if self.line_break_mode == LineBreaking::Clip { - scene.pop_layer(); - } let size = ctx.size(); - let outline_rect = size - .to_rect() - .inset(Insets::uniform_xy(-TEXTBOX_X_MARGIN - 1.0, -1.0)); + let outline_rect = size.to_rect().inset(Insets::new( + -TEXTBOX_MARGIN.leading, + -TEXTBOX_MARGIN.top, + -TEXTBOX_MARGIN.trailing, + -TEXTBOX_MARGIN.bottom, + )); scene.stroke( &Stroke::new(1.0), Affine::IDENTITY, @@ -678,76 +154,44 @@ impl Widget for Textbox { ); } - fn get_cursor(&self, _ctx: &QueryCtx, _pos: Point) -> CursorIcon { - CursorIcon::Text - } - fn accessibility_role(&self) -> Role { - Role::TextInput + Role::GenericContainer } - fn accessibility(&mut self, ctx: &mut AccessCtx, node: &mut Node) { - self.editor.accessibility( - ctx.tree_update, - node, - || NodeId::from(WidgetId::next()), - TEXTBOX_X_MARGIN + TEXTBOX_PADDING, - TEXTBOX_PADDING, - ); - } + fn accessibility(&mut self, _ctx: &mut AccessCtx, _node: &mut Node) {} fn children_ids(&self) -> SmallVec<[WidgetId; 16]> { - SmallVec::new() + smallvec![self.text.id()] } fn make_trace_span(&self, ctx: &QueryCtx<'_>) -> Span { - trace_span!("Textbox", id = ctx.widget_id().trace()) + trace_span!("Prose", id = ctx.widget_id().trace()) } fn get_debug_text(&self) -> Option { - Some(self.editor.text().chars().take(100).collect()) + self.clip.then(|| "(clip)".into()) } } // TODO - Add more tests #[cfg(test)] mod tests { - use parley::{layout::Alignment, StyleProperty}; use vello::kurbo::Size; + use super::*; use crate::{ - assert_render_snapshot, - testing::TestHarness, - widget::{CrossAxisAlignment, Flex, LineBreaking, Prose}, + assert_render_snapshot, testing::TestHarness, text::StyleProperty, widget::TextArea, }; #[test] - /// A wrapping prose's alignment should be respected, regardkess of + /// A wrapping prose's alignment should be respected, regardless of /// its parent's alignment. - fn prose_alignment_flex() { - fn base_label() -> Prose { - // Trailing whitespace is displayed when laying out prose. - Prose::new("Hello ") - .with_style(StyleProperty::FontSize(10.0)) - .with_line_break_mode(LineBreaking::WordWrap) - } - let label1 = base_label().with_alignment(Alignment::Start); - let label2 = base_label().with_alignment(Alignment::Middle); - let label3 = base_label().with_alignment(Alignment::End); - let label4 = base_label().with_alignment(Alignment::Start); - let label5 = base_label().with_alignment(Alignment::Middle); - let label6 = base_label().with_alignment(Alignment::End); - let flex = Flex::column() - .with_flex_child(label1, CrossAxisAlignment::Start) - .with_flex_child(label2, CrossAxisAlignment::Start) - .with_flex_child(label3, CrossAxisAlignment::Start) - .with_flex_child(label4, CrossAxisAlignment::Center) - .with_flex_child(label5, CrossAxisAlignment::Center) - .with_flex_child(label6, CrossAxisAlignment::Center) - .gap(0.0); - - let mut harness = TestHarness::create_with_size(flex, Size::new(80.0, 80.0)); + fn textbox_outline() { + let textbox = Textbox::from_text_area( + TextArea::new_editable("Textbox contents").with_style(StyleProperty::FontSize(10.0)), + ); + let mut harness = TestHarness::create_with_size(textbox, Size::new(150.0, 20.0)); - assert_render_snapshot!(harness, "prose_alignment_flex"); + assert_render_snapshot!(harness, "textbox_outline"); } } diff --git a/masonry/src/widget/variable_label.rs b/masonry/src/widget/variable_label.rs index 420d7734b..a624cce84 100644 --- a/masonry/src/widget/variable_label.rs +++ b/masonry/src/widget/variable_label.rs @@ -7,13 +7,12 @@ use std::cmp::Ordering; use accesskit::{Node, Role}; use parley::fontique::Weight; -use parley::StyleProperty; use smallvec::{smallvec, SmallVec}; use tracing::{trace_span, Span}; use vello::kurbo::{Point, Size}; use vello::Scene; -use crate::text::ArcStr; +use crate::text::{ArcStr, StyleProperty}; use crate::widget::WidgetMut; use crate::{ AccessCtx, AccessEvent, BoxConstraints, EventCtx, LayoutCtx, PaintCtx, PointerEvent, QueryCtx, diff --git a/xilem/examples/http_cats.rs b/xilem/examples/http_cats.rs index fa7de8868..f3dd486dd 100644 --- a/xilem/examples/http_cats.rs +++ b/xilem/examples/http_cats.rs @@ -200,8 +200,9 @@ impl Status { image, // TODO: Overlay on top of the image? // HACK: Trailing padding workaround scrollbar covering content + // HACK: Bottom padding to workaround https://github.com/linebender/parley/issues/165 sized_box(prose("Copyright Šī¸ https://http.cat").alignment(TextAlignment::End)) - .padding(Padding::trailing(15.)), + .padding(Padding::new(0., 15., 10., 0.)), )) .main_axis_alignment(xilem::view::MainAxisAlignment::Start) } diff --git a/xilem/src/lib.rs b/xilem/src/lib.rs index bc8f619fa..e149c5c03 100644 --- a/xilem/src/lib.rs +++ b/xilem/src/lib.rs @@ -178,6 +178,7 @@ where /// Equivalent to [`WidgetPod`], but in the [`xilem`](crate) crate to work around the orphan rule. pub struct Pod { pub inner: WidgetPod, + // TODO: Maybe this should just be a (WidgetId, W) pair. } impl ViewElement for Pod { @@ -306,9 +307,14 @@ impl ViewCtx { pub fn with_action_widget(&mut self, f: impl FnOnce(&mut Self) -> Pod) -> Pod { let value = f(self); let id = value.inner.id(); + self.record_action(id); + value + } + + /// Record that the actions from the widget `id` should be routed to this view. + pub fn record_action(&mut self, id: WidgetId) { let path = self.id_path.clone(); self.widget_map.insert(id, path); - value } pub fn teardown_leaf(&mut self, widget: WidgetMut) { diff --git a/xilem/src/view/prose.rs b/xilem/src/view/prose.rs index 401140c36..191ab4d01 100644 --- a/xilem/src/view/prose.rs +++ b/xilem/src/view/prose.rs @@ -63,18 +63,24 @@ impl Prose { } } +fn line_break_clips(linebreaking: LineBreaking) -> bool { + matches!(linebreaking, LineBreaking::Clip | LineBreaking::WordWrap) +} + impl ViewMarker for Prose {} impl View for Prose { type Element = Pod; type ViewState = (); fn build(&self, ctx: &mut ViewCtx) -> (Self::Element, Self::ViewState) { + let text_area = widget::TextArea::new_immutable(&self.content) + .with_brush(self.text_brush.clone()) + .with_alignment(self.alignment) + .with_style(StyleProperty::FontSize(self.text_size)) + .with_word_wrap(self.line_break_mode == LineBreaking::WordWrap); let widget_pod = ctx.new_pod( - widget::Prose::new(self.content.clone()) - .with_brush(self.text_brush.clone()) - .with_alignment(self.alignment) - .with_style(StyleProperty::FontSize(self.text_size)) - .with_line_break_mode(self.line_break_mode), + widget::Prose::from_text_area(text_area) + .with_clip(line_break_clips(self.line_break_mode)), ); (widget_pod, ()) } @@ -86,20 +92,26 @@ impl View for Prose { _ctx: &mut ViewCtx, mut element: Mut, ) { + let mut text_area = widget::Prose::text_mut(&mut element); if prev.content != self.content { - widget::Prose::set_text(&mut element, self.content.clone()); + widget::TextArea::reset_text(&mut text_area, &self.content); } if prev.text_brush != self.text_brush { - widget::Prose::set_brush(&mut element, self.text_brush.clone()); + widget::TextArea::set_brush(&mut text_area, self.text_brush.clone()); } if prev.alignment != self.alignment { - widget::Prose::set_alignment(&mut element, self.alignment); + widget::TextArea::set_alignment(&mut text_area, self.alignment); } if prev.text_size != self.text_size { - widget::Prose::insert_style(&mut element, StyleProperty::FontSize(self.text_size)); + widget::TextArea::insert_style(&mut text_area, StyleProperty::FontSize(self.text_size)); } if prev.line_break_mode != self.line_break_mode { - widget::Prose::set_line_break_mode(&mut element, self.line_break_mode); + widget::TextArea::set_word_wrap( + &mut text_area, + self.line_break_mode == LineBreaking::WordWrap, + ); + drop(text_area); + widget::Prose::set_clip(&mut element, line_break_clips(self.line_break_mode)); } } diff --git a/xilem/src/view/textbox.rs b/xilem/src/view/textbox.rs index 0e8e1af57..535ee52f5 100644 --- a/xilem/src/view/textbox.rs +++ b/xilem/src/view/textbox.rs @@ -24,7 +24,7 @@ where on_enter: None, text_brush: Color::WHITE.into(), alignment: TextAlignment::default(), - disabled: false, + // TODO?: disabled: false, } } @@ -35,8 +35,7 @@ pub struct Textbox { on_enter: Option>, text_brush: Brush, alignment: TextAlignment, - disabled: bool, - // TODO: add more attributes of `masonry::widget::Label` + // TODO: add more attributes of `masonry::widget::TextBox` } impl Textbox { @@ -51,11 +50,6 @@ impl Textbox { self } - pub fn disabled(mut self) -> Self { - self.disabled = true; - self - } - pub fn on_enter(mut self, on_enter: F) -> Self where F: Fn(&mut State, String) -> Action + Send + Sync + 'static, @@ -71,13 +65,17 @@ impl View for Textbox (Self::Element, Self::ViewState) { - ctx.with_leaf_action_widget(|ctx| { - ctx.new_pod( - widget::Textbox::new(self.contents.clone()) - .with_brush(self.text_brush.clone()) - .with_alignment(self.alignment), - ) - }) + // TODO: Maybe we want a shared TextArea View? + let text_area = widget::TextArea::new_editable(&self.contents) + .with_brush(self.text_brush.clone()) + .with_alignment(self.alignment); + let textbox = widget::Textbox::from_text_area(text_area); + + // Ensure that the actions from the *inner* TextArea get routed correctly. + let id = textbox.area_pod().id(); + ctx.record_action(id); + let widget_pod = ctx.new_pod(textbox); + (widget_pod, ()) } fn rebuild( @@ -87,6 +85,8 @@ impl View for Textbox, ) { + let mut text_area = widget::Textbox::text_mut(&mut element); + // Unlike the other properties, we don't compare to the previous value; // instead, we compare directly to the element's text. This is to handle // cases like "Previous data says contents is 'fooba', user presses 'r', @@ -94,15 +94,15 @@ impl View for Textbox