mirror of https://github.com/linebender/xilem
Move `text` into `reference` folder (#433)
This was imported in #241 See also [#masonry > text vs text2](https://xi.zulipchat.com/#narrow/stream/317477-masonry/topic/text.20vs.20text2) We do want to sort out text properly, see #337, so I'm keeping the reference code around.
This commit is contained in:
parent
7a28babdd6
commit
732cfa8376
|
@ -14,7 +14,7 @@ use crate::action::Action;
|
|||
use crate::dpi::LogicalPosition;
|
||||
use crate::promise::PromiseToken;
|
||||
use crate::render_root::{RenderRootSignal, RenderRootState};
|
||||
use crate::text2::TextBrush;
|
||||
use crate::text::TextBrush;
|
||||
use crate::text_helpers::{ImeChangeSignal, TextFieldRegistration};
|
||||
use crate::tree_arena::TreeArenaTokenMut;
|
||||
use crate::widget::{CursorChange, WidgetMut, WidgetState};
|
||||
|
|
|
@ -115,7 +115,7 @@ pub mod app_driver;
|
|||
pub mod debug_logger;
|
||||
pub mod debug_values;
|
||||
pub mod event_loop_runner;
|
||||
pub mod text2;
|
||||
pub mod text;
|
||||
mod tracing_backend;
|
||||
mod tree_arena;
|
||||
|
||||
|
|
|
@ -21,7 +21,7 @@ use crate::debug_logger::DebugLogger;
|
|||
use crate::dpi::{LogicalPosition, LogicalSize, PhysicalSize};
|
||||
use crate::event::{PointerEvent, TextEvent, WindowEvent};
|
||||
use crate::kurbo::Point;
|
||||
use crate::text2::TextBrush;
|
||||
use crate::text::TextBrush;
|
||||
use crate::tree_arena::TreeArena;
|
||||
use crate::widget::{WidgetMut, WidgetRef, WidgetState};
|
||||
use crate::{
|
||||
|
|
|
@ -5,17 +5,16 @@
|
|||
|
||||
use std::rc::Rc;
|
||||
|
||||
use kurbo::{Line, Point, Rect, Size};
|
||||
use kurbo::{Affine, Line, Point, Rect, Size};
|
||||
use parley::context::RangedBuilder;
|
||||
use parley::fontique::{Style, Weight};
|
||||
use parley::layout::{Alignment, Cursor};
|
||||
use parley::style::{FontFamily, GenericFamily};
|
||||
use parley::{Layout, LayoutContext};
|
||||
use vello::peniko::{Brush, Color};
|
||||
use parley::style::{Brush as BrushTrait, FontFamily, FontStack, GenericFamily, StyleProperty};
|
||||
use parley::{FontContext, Layout, LayoutContext};
|
||||
use vello::peniko::{self, Color, Gradient};
|
||||
use vello::Scene;
|
||||
|
||||
use crate::PaintCtx;
|
||||
|
||||
use super::attribute::Link;
|
||||
use super::font_descriptor::FontDescriptor;
|
||||
use super::storage::TextStorage;
|
||||
use super::{Link, TextStorage};
|
||||
|
||||
/// A component for displaying text on screen.
|
||||
///
|
||||
|
@ -35,20 +34,67 @@ use super::storage::TextStorage;
|
|||
/// [`update`]: trait.Widget.html#tymethod.update
|
||||
/// [`needs_rebuild_after_update`]: #method.needs_rebuild_after_update
|
||||
/// [`rebuild_if_needed`]: #method.rebuild_if_needed
|
||||
///
|
||||
/// TODO: Update docs to mentionParley
|
||||
#[derive(Clone)]
|
||||
pub struct TextLayout<T> {
|
||||
// TODO - remove Option
|
||||
text: Option<T>,
|
||||
font: FontDescriptor,
|
||||
// when set, this will be used to override the size in he font descriptor.
|
||||
// This provides an easy way to change only the font size, while still
|
||||
// using a `FontDescriptor` in the `Env`.
|
||||
text_size_override: Option<f32>,
|
||||
text_color: Color,
|
||||
layout: Option<Layout<Brush>>,
|
||||
wrap_width: f64,
|
||||
text: T,
|
||||
// TODO: Find a way to let this use borrowed data
|
||||
scale: f32,
|
||||
|
||||
brush: TextBrush,
|
||||
font: FontStack<'static>,
|
||||
text_size: f32,
|
||||
weight: Weight,
|
||||
style: Style,
|
||||
|
||||
alignment: Alignment,
|
||||
max_advance: Option<f32>,
|
||||
|
||||
links: Rc<[(Rect, usize)]>,
|
||||
|
||||
needs_layout: bool,
|
||||
needs_line_breaks: bool,
|
||||
layout: Layout<TextBrush>,
|
||||
scratch_scene: Scene,
|
||||
}
|
||||
|
||||
/// A custom brush for `Parley`, enabling using Parley to pass-through
|
||||
/// which glyphs are selected/highlighted
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum TextBrush {
|
||||
Normal(peniko::Brush),
|
||||
Highlight {
|
||||
text: peniko::Brush,
|
||||
fill: peniko::Brush,
|
||||
},
|
||||
}
|
||||
|
||||
impl BrushTrait for TextBrush {}
|
||||
|
||||
impl From<peniko::Brush> for TextBrush {
|
||||
fn from(value: peniko::Brush) -> Self {
|
||||
Self::Normal(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Gradient> for TextBrush {
|
||||
fn from(value: Gradient) -> Self {
|
||||
Self::Normal(value.into())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Color> for TextBrush {
|
||||
fn from(value: Color) -> Self {
|
||||
Self::Normal(value.into())
|
||||
}
|
||||
}
|
||||
|
||||
// Parley requires their Brush implementations to implement Default
|
||||
impl Default for TextBrush {
|
||||
fn default() -> Self {
|
||||
Self::Normal(Default::default())
|
||||
}
|
||||
}
|
||||
|
||||
/// Metrics describing the layout text.
|
||||
|
@ -65,80 +111,129 @@ pub struct LayoutMetrics {
|
|||
|
||||
impl<T> TextLayout<T> {
|
||||
/// Create a new `TextLayout` object.
|
||||
///
|
||||
/// You must set the text ([`set_text`]) before using this object.
|
||||
///
|
||||
/// [`set_text`]: #method.set_text
|
||||
pub fn new() -> Self {
|
||||
pub fn new(text: T, text_size: f32) -> Self {
|
||||
TextLayout {
|
||||
text: None,
|
||||
font: FontDescriptor::new(FontFamily::Generic(GenericFamily::SystemUi)),
|
||||
text_color: crate::theme::TEXT_COLOR,
|
||||
text_size_override: None,
|
||||
layout: None,
|
||||
wrap_width: f64::INFINITY,
|
||||
text,
|
||||
scale: 1.0,
|
||||
|
||||
brush: crate::theme::TEXT_COLOR.into(),
|
||||
font: FontStack::Single(FontFamily::Generic(GenericFamily::SansSerif)),
|
||||
text_size,
|
||||
weight: Weight::NORMAL,
|
||||
style: Style::Normal,
|
||||
|
||||
max_advance: None,
|
||||
alignment: Default::default(),
|
||||
|
||||
links: Rc::new([]),
|
||||
|
||||
needs_layout: true,
|
||||
needs_line_breaks: true,
|
||||
layout: Layout::new(),
|
||||
scratch_scene: Scene::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the default text color for this layout.
|
||||
pub fn set_text_color(&mut self, color: Color) {
|
||||
if color != self.text_color {
|
||||
self.text_color = color;
|
||||
self.layout = None;
|
||||
/// Mark that the inner layout needs to be updated.
|
||||
///
|
||||
/// This should be used if your `T` has interior mutability
|
||||
pub fn invalidate(&mut self) {
|
||||
self.needs_layout = true;
|
||||
self.needs_line_breaks = true;
|
||||
}
|
||||
|
||||
/// Set the scaling factor
|
||||
pub fn set_scale(&mut self, scale: f32) {
|
||||
if scale != self.scale {
|
||||
self.scale = scale;
|
||||
self.invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the default font.
|
||||
/// Set the default brush used for the layout.
|
||||
///
|
||||
/// The argument is a [`FontDescriptor`].
|
||||
///
|
||||
/// [`FontDescriptor`]: struct.FontDescriptor.html
|
||||
pub fn set_font(&mut self, font: FontDescriptor) {
|
||||
/// This is the non-layout impacting styling (primarily colour)
|
||||
/// used when displaying the text
|
||||
#[doc(alias = "set_color")]
|
||||
pub fn set_brush(&mut self, brush: impl Into<TextBrush>) {
|
||||
let brush = brush.into();
|
||||
if brush != self.brush {
|
||||
self.brush = brush;
|
||||
self.invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the default font stack.
|
||||
pub fn set_font(&mut self, font: FontStack<'static>) {
|
||||
if font != self.font {
|
||||
self.font = font;
|
||||
self.layout = None;
|
||||
self.text_size_override = None;
|
||||
self.invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the font size.
|
||||
///
|
||||
/// This overrides the size in the [`FontDescriptor`] provided to [`set_font`].
|
||||
///
|
||||
/// [`set_font`]: #method.set_font.html
|
||||
/// [`FontDescriptor`]: struct.FontDescriptor.html
|
||||
#[doc(alias = "set_font_size")]
|
||||
pub fn set_text_size(&mut self, size: f32) {
|
||||
if Some(&size) != self.text_size_override.as_ref() {
|
||||
self.text_size_override = Some(size);
|
||||
self.layout = None;
|
||||
if size != self.text_size {
|
||||
self.text_size = size;
|
||||
self.invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the font weight.
|
||||
pub fn set_weight(&mut self, weight: Weight) {
|
||||
if weight != self.weight {
|
||||
self.weight = weight;
|
||||
self.invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the font style.
|
||||
pub fn set_style(&mut self, style: Style) {
|
||||
if style != self.style {
|
||||
self.style = style;
|
||||
self.invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the [`Alignment`] for this layout.
|
||||
pub fn set_text_alignment(&mut self, alignment: Alignment) {
|
||||
if self.alignment != alignment {
|
||||
self.alignment = alignment;
|
||||
self.invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the width at which to wrap words.
|
||||
///
|
||||
/// You may pass `f64::INFINITY` to disable word wrapping
|
||||
/// You may pass `None` to disable word wrapping
|
||||
/// (the default behaviour).
|
||||
pub fn set_wrap_width(&mut self, width: f64) {
|
||||
let width = width.max(0.0);
|
||||
// 1e-4 is an arbitrary small-enough value that we don't care to rewrap
|
||||
if (width - self.wrap_width).abs() > 1e-4 {
|
||||
self.wrap_width = width;
|
||||
self.layout = None;
|
||||
pub fn set_max_advance(&mut self, max_advance: Option<f32>) {
|
||||
let max_advance = max_advance.map(|it| it.max(0.0));
|
||||
if self.max_advance.is_some() != max_advance.is_some()
|
||||
|| self
|
||||
.max_advance
|
||||
.zip(max_advance)
|
||||
// 1e-4 is an arbitrary small-enough value that we don't care to rewrap
|
||||
.map(|(old, new)| (old - new).abs() >= 1e-4)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
self.max_advance = max_advance;
|
||||
self.needs_line_breaks = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the [`TextAlignment`] for this layout.
|
||||
/// Returns `true` if this layout needs to be rebuilt.
|
||||
///
|
||||
/// [`TextAlignment`]: enum.TextAlignment.html
|
||||
pub fn set_text_alignment(&mut self, alignment: Alignment) {
|
||||
if self.alignment != alignment {
|
||||
self.alignment = alignment;
|
||||
self.layout = None;
|
||||
}
|
||||
/// This happens (for instance) after style attributes are modified.
|
||||
///
|
||||
/// This does not account for things like the text changing, handling that
|
||||
/// is the responsibility of the user.
|
||||
pub fn needs_rebuild(&self) -> bool {
|
||||
self.needs_layout || self.needs_line_breaks
|
||||
}
|
||||
|
||||
// TODO: What are the valid use cases for this, where we shouldn't use a run-specific check instead?
|
||||
// /// Returns `true` if this layout's text appears to be right-to-left.
|
||||
// ///
|
||||
// /// See [`piet::util::first_strong_rtl`] for more information.
|
||||
|
@ -150,99 +245,85 @@ impl<T> TextLayout<T> {
|
|||
}
|
||||
|
||||
impl<T: TextStorage> TextLayout<T> {
|
||||
/// Create a new `TextLayout` with the provided text.
|
||||
///
|
||||
/// This is useful when the text is not tied to application data.
|
||||
pub fn from_text(text: impl Into<T>) -> Self {
|
||||
let mut this = TextLayout::new();
|
||||
this.set_text(text.into());
|
||||
this
|
||||
}
|
||||
|
||||
/// Returns `true` if this layout needs to be rebuilt.
|
||||
///
|
||||
/// This happens (for instance) after style attributes are modified.
|
||||
///
|
||||
/// This does not account for things like the text changing, handling that
|
||||
/// is the responsibility of the user.
|
||||
pub fn needs_rebuild(&self) -> bool {
|
||||
self.layout.is_none()
|
||||
#[track_caller]
|
||||
fn assert_rebuilt(&self, method: &str) {
|
||||
if self.needs_layout || self.needs_line_breaks {
|
||||
debug_panic!(
|
||||
"TextLayout::{method} called without rebuilding layout object. Text was '{}'",
|
||||
self.text.as_str().chars().take(250).collect::<String>()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the text to display.
|
||||
pub fn set_text(&mut self, text: T) {
|
||||
if self.text.is_none() || !self.text.as_ref().unwrap().maybe_eq(&text) {
|
||||
self.text = Some(text);
|
||||
self.layout = None;
|
||||
if !self.text.maybe_eq(&text) {
|
||||
self.text = text;
|
||||
self.invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the [`TextStorage`] backing this layout, if it exists.
|
||||
pub fn text(&self) -> Option<&T> {
|
||||
self.text.as_ref()
|
||||
pub fn text(&self) -> &T {
|
||||
&self.text
|
||||
}
|
||||
|
||||
/// Returns the length of the [`TextStorage`] backing this layout, if it exists.
|
||||
pub fn text_len(&self) -> usize {
|
||||
if let Some(text) = &self.text {
|
||||
text.as_str().len()
|
||||
} else {
|
||||
0
|
||||
}
|
||||
/// Returns the [`TextStorage`] backing this layout, if it exists.
|
||||
///
|
||||
/// Invalidates the layout and so should only be used when definitely applying an edit
|
||||
pub fn text_mut(&mut self) -> &mut T {
|
||||
self.invalidate();
|
||||
&mut self.text
|
||||
}
|
||||
|
||||
/// Returns the inner Piet [`TextLayout`] type.
|
||||
///
|
||||
/// [`TextLayout`]: ./piet/trait.TextLayout.html
|
||||
pub fn layout(&self) -> Option<&Layout<Brush>> {
|
||||
self.layout.as_ref()
|
||||
/// Returns the inner Parley [`Layout`] value.
|
||||
pub fn layout(&self) -> &Layout<TextBrush> {
|
||||
self.assert_rebuilt("layout");
|
||||
&self.layout
|
||||
}
|
||||
|
||||
/// The size of the laid-out text.
|
||||
/// The size of the laid-out text, excluding any trailing whitespace.
|
||||
///
|
||||
/// This is not meaningful until [`rebuild_if_needed`] has been called.
|
||||
///
|
||||
/// [`rebuild_if_needed`]: #method.rebuild_if_needed
|
||||
/// This is not meaningful until [`Self::rebuild`] has been called.
|
||||
pub fn size(&self) -> Size {
|
||||
self.layout
|
||||
.as_ref()
|
||||
.map(|layout| Size::new(layout.width().into(), layout.height().into()))
|
||||
.unwrap_or_default()
|
||||
self.assert_rebuilt("size");
|
||||
Size::new(self.layout.width().into(), self.layout.height().into())
|
||||
}
|
||||
|
||||
/// The size of the laid-out text, including any trailing whitespace.
|
||||
///
|
||||
/// This is not meaningful until [`Self::rebuild`] has been called.
|
||||
pub fn full_size(&self) -> Size {
|
||||
self.assert_rebuilt("full_size");
|
||||
Size::new(self.layout.full_width().into(), self.layout.height().into())
|
||||
}
|
||||
|
||||
/// Return the text's [`LayoutMetrics`].
|
||||
///
|
||||
/// This is not meaningful until [`rebuild_if_needed`] has been called.
|
||||
///
|
||||
/// [`rebuild_if_needed`]: #method.rebuild_if_needed
|
||||
/// [`LayoutMetrics`]: struct.LayoutMetrics.html
|
||||
/// This is not meaningful until [`Self::rebuild`] has been called.
|
||||
pub fn layout_metrics(&self) -> LayoutMetrics {
|
||||
debug_assert!(
|
||||
self.layout.is_some(),
|
||||
"TextLayout::layout_metrics called without rebuilding layout object. Text was '{}'",
|
||||
self.text().as_ref().map(|s| s.as_str()).unwrap_or_default()
|
||||
);
|
||||
self.assert_rebuilt("layout_metrics");
|
||||
|
||||
if let Some(layout) = self.layout.as_ref() {
|
||||
let first_baseline = layout.get(0).unwrap().metrics().baseline;
|
||||
let size = Size::new(layout.width().into(), layout.height().into());
|
||||
LayoutMetrics {
|
||||
size,
|
||||
first_baseline,
|
||||
trailing_whitespace_width: layout.width(),
|
||||
}
|
||||
} else {
|
||||
LayoutMetrics::default()
|
||||
let first_baseline = self.layout.get(0).unwrap().metrics().baseline;
|
||||
let size = Size::new(self.layout.width().into(), self.layout.height().into());
|
||||
LayoutMetrics {
|
||||
size,
|
||||
first_baseline,
|
||||
trailing_whitespace_width: self.layout.full_width(),
|
||||
}
|
||||
}
|
||||
|
||||
/// For a given `Point` (relative to this object's origin), returns index
|
||||
/// into the underlying text of the nearest grapheme boundary.
|
||||
pub fn text_position_for_point(&self, point: Point) -> usize {
|
||||
self.layout
|
||||
.as_ref()
|
||||
.map(|layout| Cursor::from_point(layout, point.x as f32, point.y as f32).insert_point)
|
||||
.unwrap_or_default()
|
||||
///
|
||||
/// This is not meaningful until [`Self::rebuild`] has been called.
|
||||
pub fn cursor_for_point(&self, point: Point) -> Cursor {
|
||||
self.assert_rebuilt("text_position_for_point");
|
||||
|
||||
// TODO: This is a mostly good first pass, but doesn't handle cursor positions in
|
||||
// grapheme clusters within a parley cluster.
|
||||
// We can also try
|
||||
Cursor::from_point(&self.layout, point.x as f32, point.y as f32)
|
||||
}
|
||||
|
||||
/// Given the utf-8 position of a character boundary in the underlying text,
|
||||
|
@ -252,20 +333,38 @@ impl<T: TextStorage> TextLayout<T> {
|
|||
/// # Panics
|
||||
///
|
||||
/// Panics if `text_pos` is not a character boundary.
|
||||
pub fn point_for_text_position(&self, text_pos: usize) -> Point {
|
||||
self.layout
|
||||
.as_ref()
|
||||
.map(|layout| {
|
||||
let from_position = Cursor::from_position(layout, text_pos, /* TODO */ false);
|
||||
///
|
||||
/// This is not meaningful until [`Self::rebuild`] has been called.
|
||||
pub fn cursor_for_text_position(&self, text_pos: usize) -> Cursor {
|
||||
self.assert_rebuilt("cursor_for_text_position");
|
||||
|
||||
Point::new(
|
||||
from_position.advance as f64,
|
||||
(from_position.baseline + from_position.offset) as f64,
|
||||
)
|
||||
})
|
||||
.unwrap_or_default()
|
||||
// TODO: As a reminder, `is_leading` is not very useful to us; we don't know this ahead of time
|
||||
// We're going to need to do quite a bit of remedial work on these
|
||||
// e.g. to handle a inside a ligature made of multiple (unicode) grapheme clusters
|
||||
// https://raphlinus.github.io/text/2020/10/26/text-layout.html#shaping-cluster
|
||||
// But we're choosing to defer this work
|
||||
// This also needs to handle affinity.
|
||||
Cursor::from_position(&self.layout, text_pos, true)
|
||||
}
|
||||
|
||||
/// Given the utf-8 position of a character boundary in the underlying text,
|
||||
/// return the `Point` (relative to this object's origin) representing the
|
||||
/// boundary of the containing grapheme.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if `text_pos` is not a character boundary.
|
||||
///
|
||||
/// This is not meaningful until [`Self::rebuild`] has been called.
|
||||
pub fn point_for_text_position(&self, text_pos: usize) -> Point {
|
||||
let cursor = self.cursor_for_text_position(text_pos);
|
||||
Point::new(
|
||||
cursor.advance as f64,
|
||||
(cursor.baseline + cursor.offset) as f64,
|
||||
)
|
||||
}
|
||||
|
||||
// TODO: needed for text selection
|
||||
// /// Given a utf-8 range in the underlying text, return a `Vec` of `Rect`s
|
||||
// /// representing the nominal bounding boxes of the text in that range.
|
||||
// ///
|
||||
|
@ -273,30 +372,28 @@ impl<T: TextStorage> TextLayout<T> {
|
|||
// ///
|
||||
// /// Panics if the range start or end is not a character boundary.
|
||||
// pub fn rects_for_range(&self, range: Range<usize>) -> Vec<Rect> {
|
||||
// self.layout
|
||||
// .as_ref()
|
||||
// .map(|layout| layout.rects_for_range(range))
|
||||
// .unwrap_or_default()
|
||||
// self.layout.rects_for_range(range)
|
||||
// }
|
||||
|
||||
/// Given the utf-8 position of a character boundary in the underlying text,
|
||||
/// return a `Line` suitable for drawing a vertical cursor at that boundary.
|
||||
///
|
||||
/// This is not meaningful until [`Self::rebuild`] has been called.
|
||||
// TODO: This is too simplistic. See https://raphlinus.github.io/text/2020/10/26/text-layout.html#shaping-cluster
|
||||
// for example. This would break in a `fi` ligature
|
||||
pub fn cursor_line_for_text_position(&self, text_pos: usize) -> Line {
|
||||
self.layout
|
||||
.as_ref()
|
||||
.map(|layout| {
|
||||
let from_position = Cursor::from_position(layout, text_pos, /* TODO */ false);
|
||||
let from_position = self.cursor_for_text_position(text_pos);
|
||||
|
||||
let line_metrics = from_position.path.line(layout).unwrap().metrics();
|
||||
let line = from_position.path.line(&self.layout).unwrap();
|
||||
let line_metrics = line.metrics();
|
||||
|
||||
let p1 = (from_position.advance as f64, line_metrics.baseline as f64);
|
||||
let p2 = (
|
||||
from_position.advance as f64,
|
||||
(line_metrics.baseline + line_metrics.size()) as f64,
|
||||
);
|
||||
Line::new(p1, p2)
|
||||
})
|
||||
.unwrap_or_else(|| Line::new(Point::ZERO, Point::ZERO))
|
||||
let baseline = line_metrics.baseline + line_metrics.descent;
|
||||
let p1 = (from_position.offset as f64, baseline as f64);
|
||||
let p2 = (
|
||||
from_position.offset as f64,
|
||||
(baseline - line_metrics.size()) as f64,
|
||||
);
|
||||
Line::new(p1, p2)
|
||||
}
|
||||
|
||||
/// Returns the [`Link`] at the provided point (relative to the layout's origin) if one exists.
|
||||
|
@ -312,8 +409,7 @@ impl<T: TextStorage> TextLayout<T> {
|
|||
.iter()
|
||||
.rfind(|(hit_box, _)| hit_box.contains(pos))?;
|
||||
|
||||
let text = self.text()?;
|
||||
text.links().get(*i)
|
||||
self.text.links().get(*i)
|
||||
}
|
||||
|
||||
/// Rebuild the inner layout as needed.
|
||||
|
@ -324,94 +420,95 @@ impl<T: TextStorage> TextLayout<T> {
|
|||
///
|
||||
/// This method should be called whenever any of these things may have changed.
|
||||
/// A simple way to ensure this is correct is to always call this method
|
||||
/// as part of your widget's [`layout`] method.
|
||||
/// as part of your widget's [`layout`][crate::Widget::layout] method.
|
||||
pub fn rebuild(
|
||||
&mut self,
|
||||
font_ctx: &mut FontContext,
|
||||
layout_ctx: &mut LayoutContext<TextBrush>,
|
||||
) {
|
||||
self.rebuild_with_attributes(font_ctx, layout_ctx, |builder| builder);
|
||||
}
|
||||
|
||||
/// Rebuild the inner layout as needed, adding attributes to the underlying layout.
|
||||
///
|
||||
/// [`layout`]: trait.Widget.html#method.layout
|
||||
pub fn rebuild_if_needed(&mut self, factory: &mut LayoutContext<Brush>) {
|
||||
if let Some(text) = &self.text {
|
||||
if self.layout.is_none() {
|
||||
let font = self.font.clone();
|
||||
let color = self.text_color;
|
||||
let size_override = self.text_size_override;
|
||||
/// See [`Self::rebuild`] for more information
|
||||
pub fn rebuild_with_attributes(
|
||||
&mut self,
|
||||
font_ctx: &mut FontContext,
|
||||
layout_ctx: &mut LayoutContext<TextBrush>,
|
||||
attributes: impl for<'b> FnOnce(
|
||||
RangedBuilder<'b, TextBrush, &'b str>,
|
||||
) -> RangedBuilder<'b, TextBrush, &'b str>,
|
||||
) {
|
||||
if self.needs_layout {
|
||||
self.needs_layout = false;
|
||||
|
||||
let descriptor = if let Some(size) = size_override {
|
||||
font.with_size(size)
|
||||
} else {
|
||||
font
|
||||
};
|
||||
let mut builder = layout_ctx.ranged_builder(font_ctx, self.text.as_str(), self.scale);
|
||||
builder.push_default(&StyleProperty::Brush(self.brush.clone()));
|
||||
builder.push_default(&StyleProperty::FontSize(self.text_size));
|
||||
builder.push_default(&StyleProperty::FontStack(self.font));
|
||||
builder.push_default(&StyleProperty::FontWeight(self.weight));
|
||||
builder.push_default(&StyleProperty::FontStyle(self.style));
|
||||
// For more advanced features (e.g. variable font axes), these can be set in add_attributes
|
||||
|
||||
let builder = factory.ranged_builder(fcx, text, 1.0);
|
||||
builder
|
||||
.push_default(StyleProperty)
|
||||
.new_text_layout(text.clone())
|
||||
.max_width(self.wrap_width)
|
||||
.alignment(self.alignment)
|
||||
.font(descriptor.family.clone(), descriptor.size)
|
||||
.default_attribute(descriptor.weight)
|
||||
.default_attribute(descriptor.style);
|
||||
// .default_attribute(TextAttribute::TextColor(color));
|
||||
let layout = text.add_attributes(builder).build().unwrap();
|
||||
let builder = self.text.add_attributes(builder);
|
||||
let mut builder = attributes(builder);
|
||||
builder.build_into(&mut self.layout);
|
||||
|
||||
self.links = text
|
||||
.links()
|
||||
.iter()
|
||||
.enumerate()
|
||||
.flat_map(|(i, link)| {
|
||||
layout
|
||||
.rects_for_range(link.range())
|
||||
.into_iter()
|
||||
.map(move |rect| (rect, i))
|
||||
})
|
||||
.collect();
|
||||
self.needs_line_breaks = true;
|
||||
}
|
||||
if self.needs_line_breaks {
|
||||
self.needs_line_breaks = false;
|
||||
self.layout
|
||||
.break_all_lines(self.max_advance, self.alignment);
|
||||
|
||||
self.layout = Some(layout);
|
||||
}
|
||||
// TODO:
|
||||
// self.links = text
|
||||
// .links()
|
||||
// ...
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw the layout at the provided `Point`.
|
||||
/// Draw the layout at the provided `Point`.
|
||||
///
|
||||
/// The origin of the layout is the top-left corner.
|
||||
/// The origin of the layout is the top-left corner.
|
||||
///
|
||||
/// You must call [`rebuild_if_needed`] at some point before you first
|
||||
/// call this method.
|
||||
///
|
||||
/// [`rebuild_if_needed`]: #method.rebuild_if_needed
|
||||
pub fn draw(&self, ctx: &mut PaintCtx, point: impl Into<Point>) {
|
||||
debug_assert!(
|
||||
self.layout.is_some(),
|
||||
"TextLayout::draw called without rebuilding layout object. Text was '{}'",
|
||||
self.text
|
||||
.as_ref()
|
||||
.map(|t| t.as_str())
|
||||
.unwrap_or("layout is missing text")
|
||||
/// You must call [`Self::rebuild`] at some point before you first
|
||||
/// call this method.
|
||||
pub fn draw(&mut self, scene: &mut Scene, point: impl Into<Point>) {
|
||||
self.assert_rebuilt("draw");
|
||||
// TODO: This translation doesn't seem great
|
||||
let p: Point = point.into();
|
||||
crate::text_helpers::render_text(
|
||||
scene,
|
||||
&mut self.scratch_scene,
|
||||
Affine::translate((p.x, p.y)),
|
||||
&self.layout,
|
||||
);
|
||||
if let Some(layout) = self.layout.as_ref() {
|
||||
ctx.draw_text(layout, point);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> std::fmt::Debug for TextLayout<T> {
|
||||
impl<T: TextStorage> std::fmt::Debug for TextLayout<T> {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
f.debug_struct("TextLayout")
|
||||
.field("text", &self.text.as_str().len())
|
||||
.field("scale", &self.scale)
|
||||
.field("brush", &self.brush)
|
||||
.field("font", &self.font)
|
||||
.field("text_size_override", &self.text_size_override)
|
||||
.field("text_color", &self.text_color)
|
||||
.field(
|
||||
"layout",
|
||||
if self.layout.is_some() {
|
||||
&"Some"
|
||||
} else {
|
||||
&"None"
|
||||
},
|
||||
)
|
||||
.field("text_size", &self.text_size)
|
||||
.field("weight", &self.weight)
|
||||
.field("style", &self.style)
|
||||
.field("alignment", &self.alignment)
|
||||
.field("wrap_width", &self.max_advance)
|
||||
.field("outdated?", &self.needs_rebuild())
|
||||
.field("width", &self.layout.width())
|
||||
.field("height", &self.layout.height())
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: TextStorage> Default for TextLayout<T> {
|
||||
impl<T: TextStorage + Default> Default for TextLayout<T> {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
Self::new(Default::default(), crate::theme::TEXT_SIZE_NORMAL as f32)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,20 +1,30 @@
|
|||
// Copyright 2018 the Xilem Authors and the Druid Authors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//! Editing and displaying text.
|
||||
//! Support for text display and rendering
|
||||
//!
|
||||
//! There are three kinds of text commonly needed:
|
||||
//! 1) Entirely display text (e.g. a button)
|
||||
//! 2) Selectable text (e.g. a paragraph of content)
|
||||
//! 3) Editable text (e.g. a search bar)
|
||||
//!
|
||||
//! All of these have the same set of global styling options, and can contain rich text
|
||||
|
||||
// TODO
|
||||
#![allow(clippy::all)]
|
||||
mod store;
|
||||
pub use store::{Link, TextStorage};
|
||||
|
||||
mod attribute;
|
||||
mod backspace;
|
||||
mod editable_text;
|
||||
mod font_descriptor;
|
||||
|
||||
mod input_component;
|
||||
mod layout;
|
||||
mod movement;
|
||||
mod rich_text;
|
||||
mod shell_text;
|
||||
mod storage;
|
||||
mod util;
|
||||
pub use layout::{LayoutMetrics, TextBrush, TextLayout};
|
||||
|
||||
mod selection;
|
||||
pub use selection::{
|
||||
len_utf8_from_first_byte, EditableTextCursor, Selectable, StringCursor, TextWithSelection,
|
||||
};
|
||||
|
||||
// mod movement;
|
||||
|
||||
mod edit;
|
||||
pub use edit::{EditableText, TextEditor};
|
||||
|
||||
mod backspace;
|
||||
pub use backspace::offset_for_delete_backwards;
|
||||
|
|
|
@ -9,12 +9,7 @@ use unicode_segmentation::UnicodeSegmentation;
|
|||
|
||||
use crate::kurbo::Point;
|
||||
|
||||
use super::{
|
||||
editable_text::EditableText,
|
||||
layout::TextLayout,
|
||||
shell_text::{Movement, Selection, VerticalMovement, WritingDirection},
|
||||
storage::TextStorage,
|
||||
};
|
||||
use super::{layout::TextLayout, Selectable, TextStorage};
|
||||
|
||||
/// Compute the result of a [`Movement`] on a [`Selection`].
|
||||
///
|
||||
|
@ -23,48 +18,58 @@ use super::{
|
|||
/// If `modify` is true, only the 'active' edge (the `end`) of the selection
|
||||
/// should be changed; this is the case when the user moves with the shift
|
||||
/// key pressed.
|
||||
pub fn movement<T: EditableText + TextStorage>(
|
||||
pub fn movement<T: Selectable + TextStorage>(
|
||||
m: Movement,
|
||||
s: Selection,
|
||||
layout: &TextLayout<T>,
|
||||
modify: bool,
|
||||
) -> Selection {
|
||||
let (text, layout) = match (layout.text(), layout.layout()) {
|
||||
(Some(text), Some(layout)) => (text, layout),
|
||||
_ => {
|
||||
debug_assert!(false, "movement() called before layout rebuild");
|
||||
return s;
|
||||
if layout.needs_rebuild() {
|
||||
debug_panic!("movement() called before layout rebuild");
|
||||
return s;
|
||||
}
|
||||
let text = layout.text();
|
||||
let parley_layout = layout.layout();
|
||||
|
||||
let writing_direction = || {
|
||||
if layout
|
||||
.cursor_for_text_position(s.active, s.active_affinity)
|
||||
.is_rtl
|
||||
{
|
||||
WritingDirection::RightToLeft
|
||||
} else {
|
||||
WritingDirection::LeftToRight
|
||||
}
|
||||
};
|
||||
// TODO
|
||||
let writing_direction = WritingDirection::LeftToRight;
|
||||
|
||||
let (offset, h_pos) = match m {
|
||||
Movement::Grapheme(d) if d.is_upstream_for_direction(writing_direction) => {
|
||||
if s.is_caret() || modify {
|
||||
text.prev_grapheme_offset(s.active)
|
||||
.map(|off| (off, None))
|
||||
.unwrap_or((0, s.h_pos))
|
||||
Movement::Grapheme(d) => {
|
||||
let direction = writing_direction();
|
||||
if d.is_upstream_for_direction(direction) {
|
||||
if s.is_caret() || modify {
|
||||
text.prev_grapheme_offset(s.active)
|
||||
.map(|off| (off, None))
|
||||
.unwrap_or((0, s.h_pos))
|
||||
} else {
|
||||
(s.min(), None)
|
||||
}
|
||||
} else {
|
||||
(s.min(), None)
|
||||
}
|
||||
}
|
||||
Movement::Grapheme(_) => {
|
||||
if s.is_caret() || modify {
|
||||
text.next_grapheme_offset(s.active)
|
||||
.map(|off| (off, None))
|
||||
.unwrap_or((s.active, s.h_pos))
|
||||
} else {
|
||||
(s.max(), None)
|
||||
if s.is_caret() || modify {
|
||||
text.next_grapheme_offset(s.active)
|
||||
.map(|off| (off, None))
|
||||
.unwrap_or((s.active, s.h_pos))
|
||||
} else {
|
||||
(s.max(), None)
|
||||
}
|
||||
}
|
||||
}
|
||||
Movement::Vertical(VerticalMovement::LineUp) => {
|
||||
let cur_pos = layout.hit_test_text_position(s.active);
|
||||
let h_pos = s.h_pos.unwrap_or(cur_pos.point.x);
|
||||
if cur_pos.line == 0 {
|
||||
let cur_pos = layout.cursor_for_text_position(s.active, s.active_affinity);
|
||||
let h_pos = s.h_pos.unwrap_or(cur_pos.advance);
|
||||
if cur_pos.path.line_index == 0 {
|
||||
(0, Some(h_pos))
|
||||
} else {
|
||||
let lm = layout.line_metric(cur_pos.line).unwrap();
|
||||
let lm = cur_pos.path.line(&parley_layout).unwrap();
|
||||
let point_above = Point::new(h_pos, cur_pos.point.y - lm.height);
|
||||
let up_pos = layout.hit_test_point(point_above);
|
||||
if up_pos.is_inside {
|
||||
|
@ -110,21 +115,22 @@ pub fn movement<T: EditableText + TextStorage>(
|
|||
};
|
||||
(offset, None)
|
||||
}
|
||||
Movement::Word(d) if d.is_upstream_for_direction(writing_direction) => {
|
||||
let offset = if s.is_caret() || modify {
|
||||
text.prev_word_offset(s.active).unwrap_or(0)
|
||||
Movement::Word(d) => {
|
||||
if d.is_upstream_for_direction(writing_direction()) {
|
||||
let offset = if s.is_caret() || modify {
|
||||
text.prev_word_offset(s.active).unwrap_or(0)
|
||||
} else {
|
||||
s.min()
|
||||
};
|
||||
(offset, None)
|
||||
} else {
|
||||
s.min()
|
||||
};
|
||||
(offset, None)
|
||||
}
|
||||
Movement::Word(_) => {
|
||||
let offset = if s.is_caret() || modify {
|
||||
text.next_word_offset(s.active).unwrap_or(s.active)
|
||||
} else {
|
||||
s.max()
|
||||
};
|
||||
(offset, None)
|
||||
let offset = if s.is_caret() || modify {
|
||||
text.next_word_offset(s.active).unwrap_or(s.active)
|
||||
} else {
|
||||
s.max()
|
||||
};
|
||||
(offset, None)
|
||||
}
|
||||
}
|
||||
|
||||
// These two are not handled; they require knowledge of the size
|
||||
|
@ -141,6 +147,164 @@ pub fn movement<T: EditableText + TextStorage>(
|
|||
Selection::new(start, offset).with_h_pos(h_pos)
|
||||
}
|
||||
|
||||
/// Indicates a movement that transforms a particular text position in a
|
||||
/// document.
|
||||
///
|
||||
/// These movements transform only single indices — not selections.
|
||||
///
|
||||
/// You'll note that a lot of these operations are idempotent, but you can get
|
||||
/// around this by first sending a `Grapheme` movement. If for instance, you
|
||||
/// want a `ParagraphStart` that is not idempotent, you can first send
|
||||
/// `Movement::Grapheme(Direction::Upstream)`, and then follow it with
|
||||
/// `ParagraphStart`.
|
||||
#[non_exhaustive]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum Movement {
|
||||
/// A movement that stops when it reaches an extended grapheme cluster boundary.
|
||||
///
|
||||
/// This movement is achieved on most systems by pressing the left and right
|
||||
/// arrow keys. For more information on grapheme clusters, see
|
||||
/// [Unicode Text Segmentation](https://unicode.org/reports/tr29/#Grapheme_Cluster_Boundaries).
|
||||
Grapheme(Direction),
|
||||
/// A movement that stops when it reaches a word boundary.
|
||||
///
|
||||
/// This movement is achieved on most systems by pressing the left and right
|
||||
/// arrow keys while holding control. For more information on words, see
|
||||
/// [Unicode Text Segmentation](https://unicode.org/reports/tr29/#Word_Boundaries).
|
||||
Word(Direction),
|
||||
/// A movement that stops when it reaches a soft line break.
|
||||
///
|
||||
/// This movement is achieved on macOS by pressing the left and right arrow
|
||||
/// keys while holding command. `Line` should be idempotent: if the
|
||||
/// position is already at the end of a soft-wrapped line, this movement
|
||||
/// should never push it onto another soft-wrapped line.
|
||||
///
|
||||
/// In order to implement this properly, your text positions should remember
|
||||
/// their affinity.
|
||||
Line(Direction),
|
||||
/// An upstream movement that stops when it reaches a hard line break.
|
||||
///
|
||||
/// `ParagraphStart` should be idempotent: if the position is already at the
|
||||
/// start of a hard-wrapped line, this movement should never push it onto
|
||||
/// the previous line.
|
||||
ParagraphStart,
|
||||
/// A downstream movement that stops when it reaches a hard line break.
|
||||
///
|
||||
/// `ParagraphEnd` should be idempotent: if the position is already at the
|
||||
/// end of a hard-wrapped line, this movement should never push it onto the
|
||||
/// next line.
|
||||
ParagraphEnd,
|
||||
/// A vertical movement, see `VerticalMovement` for more details.
|
||||
Vertical(VerticalMovement),
|
||||
}
|
||||
|
||||
/// Indicates a horizontal direction in the text.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum Direction {
|
||||
/// The direction visually to the left.
|
||||
///
|
||||
/// This may be byte-wise forwards or backwards in the document, depending
|
||||
/// on the text direction around the position being moved.
|
||||
Left,
|
||||
/// The direction visually to the right.
|
||||
///
|
||||
/// This may be byte-wise forwards or backwards in the document, depending
|
||||
/// on the text direction around the position being moved.
|
||||
Right,
|
||||
/// Byte-wise backwards in the document.
|
||||
///
|
||||
/// In a left-to-right context, this value is the same as `Left`.
|
||||
Upstream,
|
||||
/// Byte-wise forwards in the document.
|
||||
///
|
||||
/// In a left-to-right context, this value is the same as `Right`.
|
||||
Downstream,
|
||||
}
|
||||
|
||||
impl Direction {
|
||||
/// Returns `true` if this direction is byte-wise backwards for
|
||||
/// the provided [`WritingDirection`].
|
||||
///
|
||||
/// The provided direction *must not be* `WritingDirection::Natural`.
|
||||
pub fn is_upstream_for_direction(self, direction: WritingDirection) -> bool {
|
||||
// assert!(
|
||||
// !matches!(direction, WritingDirection::Natural),
|
||||
// "writing direction must be resolved"
|
||||
// );
|
||||
match self {
|
||||
Direction::Upstream => true,
|
||||
Direction::Downstream => false,
|
||||
Direction::Left => matches!(direction, WritingDirection::LeftToRight),
|
||||
Direction::Right => matches!(direction, WritingDirection::RightToLeft),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Distinguishes between two visually distinct locations with the same byte
|
||||
/// index.
|
||||
///
|
||||
/// Sometimes, a byte location in a document has two visual locations. For
|
||||
/// example, the end of a soft-wrapped line and the start of the subsequent line
|
||||
/// have different visual locations (and we want to be able to place an input
|
||||
/// caret in either place!) but the same byte-wise location. This also shows up
|
||||
/// in bidirectional text contexts. Affinity allows us to disambiguate between
|
||||
/// these two visual locations.
|
||||
///
|
||||
/// Note that in scenarios where soft line breaks interact with bidi text, this gets
|
||||
/// more complicated.
|
||||
#[derive(Copy, Clone, Debug, Hash, PartialEq)]
|
||||
pub enum Affinity {
|
||||
/// The position which has an apparent position "earlier" in the text.
|
||||
/// For soft line breaks, this is the position at the end of the first line.
|
||||
///
|
||||
/// For positions in-between bidi contexts, this is the position which is
|
||||
/// related to the "outgoing" text section. E.g. for the string "abcDEF" (rendered `abcFED`),
|
||||
/// with the cursor at "abc|DEF" with upstream affinity, the cursor would be rendered at the
|
||||
/// position `abc|DEF`
|
||||
Upstream,
|
||||
/// The position which has a higher apparent position in the text.
|
||||
/// For soft line breaks, this is the position at the beginning of the second line.
|
||||
///
|
||||
/// For positions in-between bidi contexts, this is the position which is
|
||||
/// related to the "incoming" text section. E.g. for the string "abcDEF" (rendered `abcFED`),
|
||||
/// with the cursor at "abc|DEF" with downstream affinity, the cursor would be rendered at the
|
||||
/// position `abcDEF|`
|
||||
Downstream,
|
||||
}
|
||||
|
||||
impl Affinity {
|
||||
/// Convert into the `parley` form of "leading"
|
||||
pub fn is_leading(&self) -> bool {
|
||||
match self {
|
||||
Affinity::Upstream => false,
|
||||
Affinity::Downstream => true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Indicates a horizontal direction for writing text.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum WritingDirection {
|
||||
LeftToRight,
|
||||
RightToLeft,
|
||||
// /// Indicates writing direction should be automatically detected based on
|
||||
// /// the text contents.
|
||||
// See also `is_upstream_for_direction` if adding back in
|
||||
// Natural,
|
||||
}
|
||||
|
||||
/// Indicates a vertical movement in a text document.
|
||||
#[non_exhaustive]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum VerticalMovement {
|
||||
LineUp,
|
||||
LineDown,
|
||||
PageUp,
|
||||
PageDown,
|
||||
DocumentStart,
|
||||
DocumentEnd,
|
||||
}
|
||||
|
||||
/// Given a position in some text, return the containing word boundaries.
|
||||
///
|
||||
/// The returned range may not necessary be a 'word'; for instance it could be
|
||||
|
|
|
@ -0,0 +1,417 @@
|
|||
// Copyright 2018 the Xilem Authors and the Druid Authors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//! A type for laying out, drawing, and interacting with text.
|
||||
|
||||
use std::rc::Rc;
|
||||
|
||||
use kurbo::{Line, Point, Rect, Size};
|
||||
use parley::layout::{Alignment, Cursor};
|
||||
use parley::style::{FontFamily, GenericFamily};
|
||||
use parley::{Layout, LayoutContext};
|
||||
use vello::peniko::{Brush, Color};
|
||||
|
||||
use crate::PaintCtx;
|
||||
|
||||
use super::attribute::Link;
|
||||
use super::font_descriptor::FontDescriptor;
|
||||
use super::storage::TextStorage;
|
||||
|
||||
/// A component for displaying text on screen.
|
||||
///
|
||||
/// This is a type intended to be used by other widgets that display text.
|
||||
/// It allows for the text itself as well as font and other styling information
|
||||
/// to be set and modified. It wraps an inner layout object, and handles
|
||||
/// invalidating and rebuilding it as required.
|
||||
///
|
||||
/// This object is not valid until the [`rebuild_if_needed`] method has been
|
||||
/// called. You should generally do this in your widget's [`layout`] method.
|
||||
/// Additionally, you should call [`needs_rebuild_after_update`]
|
||||
/// as part of your widget's [`update`] method; if this returns `true`, you will need
|
||||
/// to call [`rebuild_if_needed`] again, generally by scheduling another [`layout`]
|
||||
/// pass.
|
||||
///
|
||||
/// [`layout`]: trait.Widget.html#tymethod.layout
|
||||
/// [`update`]: trait.Widget.html#tymethod.update
|
||||
/// [`needs_rebuild_after_update`]: #method.needs_rebuild_after_update
|
||||
/// [`rebuild_if_needed`]: #method.rebuild_if_needed
|
||||
#[derive(Clone)]
|
||||
pub struct TextLayout<T> {
|
||||
// TODO - remove Option
|
||||
text: Option<T>,
|
||||
font: FontDescriptor,
|
||||
// when set, this will be used to override the size in he font descriptor.
|
||||
// This provides an easy way to change only the font size, while still
|
||||
// using a `FontDescriptor` in the `Env`.
|
||||
text_size_override: Option<f32>,
|
||||
text_color: Color,
|
||||
layout: Option<Layout<Brush>>,
|
||||
wrap_width: f64,
|
||||
alignment: Alignment,
|
||||
links: Rc<[(Rect, usize)]>,
|
||||
}
|
||||
|
||||
/// Metrics describing the layout text.
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct LayoutMetrics {
|
||||
/// The nominal size of the layout.
|
||||
pub size: Size,
|
||||
/// The distance from the nominal top of the layout to the first baseline.
|
||||
pub first_baseline: f32,
|
||||
/// The width of the layout, inclusive of trailing whitespace.
|
||||
pub trailing_whitespace_width: f32,
|
||||
//TODO: add inking_rect
|
||||
}
|
||||
|
||||
impl<T> TextLayout<T> {
|
||||
/// Create a new `TextLayout` object.
|
||||
///
|
||||
/// You must set the text ([`set_text`]) before using this object.
|
||||
///
|
||||
/// [`set_text`]: #method.set_text
|
||||
pub fn new() -> Self {
|
||||
TextLayout {
|
||||
text: None,
|
||||
font: FontDescriptor::new(FontFamily::Generic(GenericFamily::SystemUi)),
|
||||
text_color: crate::theme::TEXT_COLOR,
|
||||
text_size_override: None,
|
||||
layout: None,
|
||||
wrap_width: f64::INFINITY,
|
||||
alignment: Default::default(),
|
||||
links: Rc::new([]),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the default text color for this layout.
|
||||
pub fn set_text_color(&mut self, color: Color) {
|
||||
if color != self.text_color {
|
||||
self.text_color = color;
|
||||
self.layout = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the default font.
|
||||
///
|
||||
/// The argument is a [`FontDescriptor`].
|
||||
///
|
||||
/// [`FontDescriptor`]: struct.FontDescriptor.html
|
||||
pub fn set_font(&mut self, font: FontDescriptor) {
|
||||
if font != self.font {
|
||||
self.font = font;
|
||||
self.layout = None;
|
||||
self.text_size_override = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the font size.
|
||||
///
|
||||
/// This overrides the size in the [`FontDescriptor`] provided to [`set_font`].
|
||||
///
|
||||
/// [`set_font`]: #method.set_font.html
|
||||
/// [`FontDescriptor`]: struct.FontDescriptor.html
|
||||
pub fn set_text_size(&mut self, size: f32) {
|
||||
if Some(&size) != self.text_size_override.as_ref() {
|
||||
self.text_size_override = Some(size);
|
||||
self.layout = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the width at which to wrap words.
|
||||
///
|
||||
/// You may pass `f64::INFINITY` to disable word wrapping
|
||||
/// (the default behaviour).
|
||||
pub fn set_wrap_width(&mut self, width: f64) {
|
||||
let width = width.max(0.0);
|
||||
// 1e-4 is an arbitrary small-enough value that we don't care to rewrap
|
||||
if (width - self.wrap_width).abs() > 1e-4 {
|
||||
self.wrap_width = width;
|
||||
self.layout = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the [`TextAlignment`] for this layout.
|
||||
///
|
||||
/// [`TextAlignment`]: enum.TextAlignment.html
|
||||
pub fn set_text_alignment(&mut self, alignment: Alignment) {
|
||||
if self.alignment != alignment {
|
||||
self.alignment = alignment;
|
||||
self.layout = None;
|
||||
}
|
||||
}
|
||||
|
||||
// /// Returns `true` if this layout's text appears to be right-to-left.
|
||||
// ///
|
||||
// /// See [`piet::util::first_strong_rtl`] for more information.
|
||||
// ///
|
||||
// /// [`piet::util::first_strong_rtl`]: crate::piet::util::first_strong_rtl
|
||||
// pub fn text_is_rtl(&self) -> bool {
|
||||
// self.text_is_rtl
|
||||
// }
|
||||
}
|
||||
|
||||
impl<T: TextStorage> TextLayout<T> {
|
||||
/// Create a new `TextLayout` with the provided text.
|
||||
///
|
||||
/// This is useful when the text is not tied to application data.
|
||||
pub fn from_text(text: impl Into<T>) -> Self {
|
||||
let mut this = TextLayout::new();
|
||||
this.set_text(text.into());
|
||||
this
|
||||
}
|
||||
|
||||
/// Returns `true` if this layout needs to be rebuilt.
|
||||
///
|
||||
/// This happens (for instance) after style attributes are modified.
|
||||
///
|
||||
/// This does not account for things like the text changing, handling that
|
||||
/// is the responsibility of the user.
|
||||
pub fn needs_rebuild(&self) -> bool {
|
||||
self.layout.is_none()
|
||||
}
|
||||
|
||||
/// Set the text to display.
|
||||
pub fn set_text(&mut self, text: T) {
|
||||
if self.text.is_none() || !self.text.as_ref().unwrap().maybe_eq(&text) {
|
||||
self.text = Some(text);
|
||||
self.layout = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the [`TextStorage`] backing this layout, if it exists.
|
||||
pub fn text(&self) -> Option<&T> {
|
||||
self.text.as_ref()
|
||||
}
|
||||
|
||||
/// Returns the length of the [`TextStorage`] backing this layout, if it exists.
|
||||
pub fn text_len(&self) -> usize {
|
||||
if let Some(text) = &self.text {
|
||||
text.as_str().len()
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the inner Piet [`TextLayout`] type.
|
||||
///
|
||||
/// [`TextLayout`]: ./piet/trait.TextLayout.html
|
||||
pub fn layout(&self) -> Option<&Layout<Brush>> {
|
||||
self.layout.as_ref()
|
||||
}
|
||||
|
||||
/// The size of the laid-out text.
|
||||
///
|
||||
/// This is not meaningful until [`rebuild_if_needed`] has been called.
|
||||
///
|
||||
/// [`rebuild_if_needed`]: #method.rebuild_if_needed
|
||||
pub fn size(&self) -> Size {
|
||||
self.layout
|
||||
.as_ref()
|
||||
.map(|layout| Size::new(layout.width().into(), layout.height().into()))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Return the text's [`LayoutMetrics`].
|
||||
///
|
||||
/// This is not meaningful until [`rebuild_if_needed`] has been called.
|
||||
///
|
||||
/// [`rebuild_if_needed`]: #method.rebuild_if_needed
|
||||
/// [`LayoutMetrics`]: struct.LayoutMetrics.html
|
||||
pub fn layout_metrics(&self) -> LayoutMetrics {
|
||||
debug_assert!(
|
||||
self.layout.is_some(),
|
||||
"TextLayout::layout_metrics called without rebuilding layout object. Text was '{}'",
|
||||
self.text().as_ref().map(|s| s.as_str()).unwrap_or_default()
|
||||
);
|
||||
|
||||
if let Some(layout) = self.layout.as_ref() {
|
||||
let first_baseline = layout.get(0).unwrap().metrics().baseline;
|
||||
let size = Size::new(layout.width().into(), layout.height().into());
|
||||
LayoutMetrics {
|
||||
size,
|
||||
first_baseline,
|
||||
trailing_whitespace_width: layout.width(),
|
||||
}
|
||||
} else {
|
||||
LayoutMetrics::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// For a given `Point` (relative to this object's origin), returns index
|
||||
/// into the underlying text of the nearest grapheme boundary.
|
||||
pub fn text_position_for_point(&self, point: Point) -> usize {
|
||||
self.layout
|
||||
.as_ref()
|
||||
.map(|layout| Cursor::from_point(layout, point.x as f32, point.y as f32).insert_point)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Given the utf-8 position of a character boundary in the underlying text,
|
||||
/// return the `Point` (relative to this object's origin) representing the
|
||||
/// boundary of the containing grapheme.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if `text_pos` is not a character boundary.
|
||||
pub fn point_for_text_position(&self, text_pos: usize) -> Point {
|
||||
self.layout
|
||||
.as_ref()
|
||||
.map(|layout| {
|
||||
let from_position = Cursor::from_position(layout, text_pos, /* TODO */ false);
|
||||
|
||||
Point::new(
|
||||
from_position.advance as f64,
|
||||
(from_position.baseline + from_position.offset) as f64,
|
||||
)
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
// /// Given a utf-8 range in the underlying text, return a `Vec` of `Rect`s
|
||||
// /// representing the nominal bounding boxes of the text in that range.
|
||||
// ///
|
||||
// /// # Panics
|
||||
// ///
|
||||
// /// Panics if the range start or end is not a character boundary.
|
||||
// pub fn rects_for_range(&self, range: Range<usize>) -> Vec<Rect> {
|
||||
// self.layout
|
||||
// .as_ref()
|
||||
// .map(|layout| layout.rects_for_range(range))
|
||||
// .unwrap_or_default()
|
||||
// }
|
||||
|
||||
/// Given the utf-8 position of a character boundary in the underlying text,
|
||||
/// return a `Line` suitable for drawing a vertical cursor at that boundary.
|
||||
pub fn cursor_line_for_text_position(&self, text_pos: usize) -> Line {
|
||||
self.layout
|
||||
.as_ref()
|
||||
.map(|layout| {
|
||||
let from_position = Cursor::from_position(layout, text_pos, /* TODO */ false);
|
||||
|
||||
let line_metrics = from_position.path.line(layout).unwrap().metrics();
|
||||
|
||||
let p1 = (from_position.advance as f64, line_metrics.baseline as f64);
|
||||
let p2 = (
|
||||
from_position.advance as f64,
|
||||
(line_metrics.baseline + line_metrics.size()) as f64,
|
||||
);
|
||||
Line::new(p1, p2)
|
||||
})
|
||||
.unwrap_or_else(|| Line::new(Point::ZERO, Point::ZERO))
|
||||
}
|
||||
|
||||
/// Returns the [`Link`] at the provided point (relative to the layout's origin) if one exists.
|
||||
///
|
||||
/// This can be used both for hit-testing (deciding whether to change the mouse cursor,
|
||||
/// or performing some other action when hovering) as well as for retrieving a [`Link`]
|
||||
/// on click.
|
||||
///
|
||||
/// [`Link`]: super::attribute::Link
|
||||
pub fn link_for_pos(&self, pos: Point) -> Option<&Link> {
|
||||
let (_, i) = self
|
||||
.links
|
||||
.iter()
|
||||
.rfind(|(hit_box, _)| hit_box.contains(pos))?;
|
||||
|
||||
let text = self.text()?;
|
||||
text.links().get(*i)
|
||||
}
|
||||
|
||||
/// Rebuild the inner layout as needed.
|
||||
///
|
||||
/// This `TextLayout` object manages a lower-level layout object that may
|
||||
/// need to be rebuilt in response to changes to the text or attributes
|
||||
/// like the font.
|
||||
///
|
||||
/// This method should be called whenever any of these things may have changed.
|
||||
/// A simple way to ensure this is correct is to always call this method
|
||||
/// as part of your widget's [`layout`] method.
|
||||
///
|
||||
/// [`layout`]: trait.Widget.html#method.layout
|
||||
pub fn rebuild_if_needed(&mut self, factory: &mut LayoutContext<Brush>) {
|
||||
if let Some(text) = &self.text {
|
||||
if self.layout.is_none() {
|
||||
let font = self.font.clone();
|
||||
let color = self.text_color;
|
||||
let size_override = self.text_size_override;
|
||||
|
||||
let descriptor = if let Some(size) = size_override {
|
||||
font.with_size(size)
|
||||
} else {
|
||||
font
|
||||
};
|
||||
|
||||
let builder = factory.ranged_builder(fcx, text, 1.0);
|
||||
builder
|
||||
.push_default(StyleProperty)
|
||||
.new_text_layout(text.clone())
|
||||
.max_width(self.wrap_width)
|
||||
.alignment(self.alignment)
|
||||
.font(descriptor.family.clone(), descriptor.size)
|
||||
.default_attribute(descriptor.weight)
|
||||
.default_attribute(descriptor.style);
|
||||
// .default_attribute(TextAttribute::TextColor(color));
|
||||
let layout = text.add_attributes(builder).build().unwrap();
|
||||
|
||||
self.links = text
|
||||
.links()
|
||||
.iter()
|
||||
.enumerate()
|
||||
.flat_map(|(i, link)| {
|
||||
layout
|
||||
.rects_for_range(link.range())
|
||||
.into_iter()
|
||||
.map(move |rect| (rect, i))
|
||||
})
|
||||
.collect();
|
||||
|
||||
self.layout = Some(layout);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw the layout at the provided `Point`.
|
||||
///
|
||||
/// The origin of the layout is the top-left corner.
|
||||
///
|
||||
/// You must call [`rebuild_if_needed`] at some point before you first
|
||||
/// call this method.
|
||||
///
|
||||
/// [`rebuild_if_needed`]: #method.rebuild_if_needed
|
||||
pub fn draw(&self, ctx: &mut PaintCtx, point: impl Into<Point>) {
|
||||
debug_assert!(
|
||||
self.layout.is_some(),
|
||||
"TextLayout::draw called without rebuilding layout object. Text was '{}'",
|
||||
self.text
|
||||
.as_ref()
|
||||
.map(|t| t.as_str())
|
||||
.unwrap_or("layout is missing text")
|
||||
);
|
||||
if let Some(layout) = self.layout.as_ref() {
|
||||
ctx.draw_text(layout, point);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> std::fmt::Debug for TextLayout<T> {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
f.debug_struct("TextLayout")
|
||||
.field("font", &self.font)
|
||||
.field("text_size_override", &self.text_size_override)
|
||||
.field("text_color", &self.text_color)
|
||||
.field(
|
||||
"layout",
|
||||
if self.layout.is_some() {
|
||||
&"Some"
|
||||
} else {
|
||||
&"None"
|
||||
},
|
||||
)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: TextStorage> Default for TextLayout<T> {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,20 @@
|
|||
// Copyright 2018 the Xilem Authors and the Druid Authors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//! Editing and displaying text.
|
||||
|
||||
// TODO
|
||||
#![allow(clippy::all)]
|
||||
|
||||
mod attribute;
|
||||
mod backspace;
|
||||
mod editable_text;
|
||||
mod font_descriptor;
|
||||
|
||||
mod input_component;
|
||||
mod layout;
|
||||
mod movement;
|
||||
mod rich_text;
|
||||
mod shell_text;
|
||||
mod storage;
|
||||
mod util;
|
|
@ -0,0 +1,193 @@
|
|||
// Copyright 2018 the Xilem Authors and the Druid Authors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//! Text editing movements.
|
||||
|
||||
use std::ops::Range;
|
||||
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
|
||||
use crate::kurbo::Point;
|
||||
|
||||
use super::{
|
||||
editable_text::EditableText,
|
||||
layout::TextLayout,
|
||||
shell_text::{Movement, Selection, VerticalMovement, WritingDirection},
|
||||
storage::TextStorage,
|
||||
};
|
||||
|
||||
/// Compute the result of a [`Movement`] on a [`Selection`].
|
||||
///
|
||||
/// returns a new selection representing the state after the movement.
|
||||
///
|
||||
/// If `modify` is true, only the 'active' edge (the `end`) of the selection
|
||||
/// should be changed; this is the case when the user moves with the shift
|
||||
/// key pressed.
|
||||
pub fn movement<T: EditableText + TextStorage>(
|
||||
m: Movement,
|
||||
s: Selection,
|
||||
layout: &TextLayout<T>,
|
||||
modify: bool,
|
||||
) -> Selection {
|
||||
let (text, layout) = match (layout.text(), layout.layout()) {
|
||||
(Some(text), Some(layout)) => (text, layout),
|
||||
_ => {
|
||||
debug_assert!(false, "movement() called before layout rebuild");
|
||||
return s;
|
||||
}
|
||||
};
|
||||
// TODO
|
||||
let writing_direction = WritingDirection::LeftToRight;
|
||||
|
||||
let (offset, h_pos) = match m {
|
||||
Movement::Grapheme(d) if d.is_upstream_for_direction(writing_direction) => {
|
||||
if s.is_caret() || modify {
|
||||
text.prev_grapheme_offset(s.active)
|
||||
.map(|off| (off, None))
|
||||
.unwrap_or((0, s.h_pos))
|
||||
} else {
|
||||
(s.min(), None)
|
||||
}
|
||||
}
|
||||
Movement::Grapheme(_) => {
|
||||
if s.is_caret() || modify {
|
||||
text.next_grapheme_offset(s.active)
|
||||
.map(|off| (off, None))
|
||||
.unwrap_or((s.active, s.h_pos))
|
||||
} else {
|
||||
(s.max(), None)
|
||||
}
|
||||
}
|
||||
Movement::Vertical(VerticalMovement::LineUp) => {
|
||||
let cur_pos = layout.hit_test_text_position(s.active);
|
||||
let h_pos = s.h_pos.unwrap_or(cur_pos.point.x);
|
||||
if cur_pos.line == 0 {
|
||||
(0, Some(h_pos))
|
||||
} else {
|
||||
let lm = layout.line_metric(cur_pos.line).unwrap();
|
||||
let point_above = Point::new(h_pos, cur_pos.point.y - lm.height);
|
||||
let up_pos = layout.hit_test_point(point_above);
|
||||
if up_pos.is_inside {
|
||||
(up_pos.idx, Some(h_pos))
|
||||
} else {
|
||||
// because we can't specify affinity, moving up when h_pos
|
||||
// is wider than both the current line and the previous line
|
||||
// can result in a cursor position at the visual start of the
|
||||
// current line; so we handle this as a special-case.
|
||||
let lm_prev = layout.line_metric(cur_pos.line.saturating_sub(1)).unwrap();
|
||||
let up_pos = lm_prev.end_offset - lm_prev.trailing_whitespace;
|
||||
(up_pos, Some(h_pos))
|
||||
}
|
||||
}
|
||||
}
|
||||
Movement::Vertical(VerticalMovement::LineDown) => {
|
||||
let cur_pos = layout.hit_test_text_position(s.active);
|
||||
let h_pos = s.h_pos.unwrap_or(cur_pos.point.x);
|
||||
if cur_pos.line == layout.line_count() - 1 {
|
||||
(text.len(), Some(h_pos))
|
||||
} else {
|
||||
let lm = layout.line_metric(cur_pos.line).unwrap();
|
||||
// may not work correctly for point sizes below 1.0
|
||||
let y_below = lm.y_offset + lm.height + 1.0;
|
||||
let point_below = Point::new(h_pos, y_below);
|
||||
let up_pos = layout.hit_test_point(point_below);
|
||||
(up_pos.idx, Some(point_below.x))
|
||||
}
|
||||
}
|
||||
Movement::Vertical(VerticalMovement::DocumentStart) => (0, None),
|
||||
Movement::Vertical(VerticalMovement::DocumentEnd) => (text.len(), None),
|
||||
|
||||
Movement::ParagraphStart => (text.preceding_line_break(s.active), None),
|
||||
Movement::ParagraphEnd => (text.next_line_break(s.active), None),
|
||||
|
||||
Movement::Line(d) => {
|
||||
let hit = layout.hit_test_text_position(s.active);
|
||||
let lm = layout.line_metric(hit.line).unwrap();
|
||||
let offset = if d.is_upstream_for_direction(writing_direction) {
|
||||
lm.start_offset
|
||||
} else {
|
||||
lm.end_offset - lm.trailing_whitespace
|
||||
};
|
||||
(offset, None)
|
||||
}
|
||||
Movement::Word(d) if d.is_upstream_for_direction(writing_direction) => {
|
||||
let offset = if s.is_caret() || modify {
|
||||
text.prev_word_offset(s.active).unwrap_or(0)
|
||||
} else {
|
||||
s.min()
|
||||
};
|
||||
(offset, None)
|
||||
}
|
||||
Movement::Word(_) => {
|
||||
let offset = if s.is_caret() || modify {
|
||||
text.next_word_offset(s.active).unwrap_or(s.active)
|
||||
} else {
|
||||
s.max()
|
||||
};
|
||||
(offset, None)
|
||||
}
|
||||
|
||||
// These two are not handled; they require knowledge of the size
|
||||
// of the viewport.
|
||||
Movement::Vertical(VerticalMovement::PageDown)
|
||||
| Movement::Vertical(VerticalMovement::PageUp) => (s.active, s.h_pos),
|
||||
other => {
|
||||
tracing::warn!("unhandled movement {:?}", other);
|
||||
(s.anchor, s.h_pos)
|
||||
}
|
||||
};
|
||||
|
||||
let start = if modify { s.anchor } else { offset };
|
||||
Selection::new(start, offset).with_h_pos(h_pos)
|
||||
}
|
||||
|
||||
/// Given a position in some text, return the containing word boundaries.
|
||||
///
|
||||
/// The returned range may not necessary be a 'word'; for instance it could be
|
||||
/// the sequence of whitespace between two words.
|
||||
///
|
||||
/// If the position is on a word boundary, that will be considered the start
|
||||
/// of the range.
|
||||
///
|
||||
/// This uses Unicode word boundaries, as defined in [UAX#29].
|
||||
///
|
||||
/// [UAX#29]: http://www.unicode.org/reports/tr29/
|
||||
pub(crate) fn word_range_for_pos(text: &str, pos: usize) -> Range<usize> {
|
||||
text.split_word_bound_indices()
|
||||
.map(|(ix, word)| ix..(ix + word.len()))
|
||||
.find(|range| range.contains(&pos))
|
||||
.unwrap_or(pos..pos)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn word_range_simple() {
|
||||
assert_eq!(word_range_for_pos("hello world", 3), 0..5);
|
||||
assert_eq!(word_range_for_pos("hello world", 8), 6..11);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn word_range_whitespace() {
|
||||
assert_eq!(word_range_for_pos("hello world", 5), 5..6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn word_range_rtl() {
|
||||
let rtl = "مرحبا بالعالم";
|
||||
assert_eq!(word_range_for_pos(rtl, 5), 0..10);
|
||||
assert_eq!(word_range_for_pos(rtl, 16), 11..25);
|
||||
assert_eq!(word_range_for_pos(rtl, 10), 10..11);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn word_range_mixed() {
|
||||
let mixed = "hello مرحبا بالعالم world";
|
||||
assert_eq!(word_range_for_pos(mixed, 3), 0..5);
|
||||
assert_eq!(word_range_for_pos(mixed, 8), 6..16);
|
||||
assert_eq!(word_range_for_pos(mixed, 19), 17..31);
|
||||
assert_eq!(word_range_for_pos(mixed, 36), 32..37);
|
||||
}
|
||||
}
|
|
@ -1,514 +0,0 @@
|
|||
// Copyright 2018 the Xilem Authors and the Druid Authors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//! A type for laying out, drawing, and interacting with text.
|
||||
|
||||
use std::rc::Rc;
|
||||
|
||||
use kurbo::{Affine, Line, Point, Rect, Size};
|
||||
use parley::context::RangedBuilder;
|
||||
use parley::fontique::{Style, Weight};
|
||||
use parley::layout::{Alignment, Cursor};
|
||||
use parley::style::{Brush as BrushTrait, FontFamily, FontStack, GenericFamily, StyleProperty};
|
||||
use parley::{FontContext, Layout, LayoutContext};
|
||||
use vello::peniko::{self, Color, Gradient};
|
||||
use vello::Scene;
|
||||
|
||||
use super::{Link, TextStorage};
|
||||
|
||||
/// A component for displaying text on screen.
|
||||
///
|
||||
/// This is a type intended to be used by other widgets that display text.
|
||||
/// It allows for the text itself as well as font and other styling information
|
||||
/// to be set and modified. It wraps an inner layout object, and handles
|
||||
/// invalidating and rebuilding it as required.
|
||||
///
|
||||
/// This object is not valid until the [`rebuild_if_needed`] method has been
|
||||
/// called. You should generally do this in your widget's [`layout`] method.
|
||||
/// Additionally, you should call [`needs_rebuild_after_update`]
|
||||
/// as part of your widget's [`update`] method; if this returns `true`, you will need
|
||||
/// to call [`rebuild_if_needed`] again, generally by scheduling another [`layout`]
|
||||
/// pass.
|
||||
///
|
||||
/// [`layout`]: trait.Widget.html#tymethod.layout
|
||||
/// [`update`]: trait.Widget.html#tymethod.update
|
||||
/// [`needs_rebuild_after_update`]: #method.needs_rebuild_after_update
|
||||
/// [`rebuild_if_needed`]: #method.rebuild_if_needed
|
||||
///
|
||||
/// TODO: Update docs to mentionParley
|
||||
#[derive(Clone)]
|
||||
pub struct TextLayout<T> {
|
||||
text: T,
|
||||
// TODO: Find a way to let this use borrowed data
|
||||
scale: f32,
|
||||
|
||||
brush: TextBrush,
|
||||
font: FontStack<'static>,
|
||||
text_size: f32,
|
||||
weight: Weight,
|
||||
style: Style,
|
||||
|
||||
alignment: Alignment,
|
||||
max_advance: Option<f32>,
|
||||
|
||||
links: Rc<[(Rect, usize)]>,
|
||||
|
||||
needs_layout: bool,
|
||||
needs_line_breaks: bool,
|
||||
layout: Layout<TextBrush>,
|
||||
scratch_scene: Scene,
|
||||
}
|
||||
|
||||
/// A custom brush for `Parley`, enabling using Parley to pass-through
|
||||
/// which glyphs are selected/highlighted
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum TextBrush {
|
||||
Normal(peniko::Brush),
|
||||
Highlight {
|
||||
text: peniko::Brush,
|
||||
fill: peniko::Brush,
|
||||
},
|
||||
}
|
||||
|
||||
impl BrushTrait for TextBrush {}
|
||||
|
||||
impl From<peniko::Brush> for TextBrush {
|
||||
fn from(value: peniko::Brush) -> Self {
|
||||
Self::Normal(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Gradient> for TextBrush {
|
||||
fn from(value: Gradient) -> Self {
|
||||
Self::Normal(value.into())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Color> for TextBrush {
|
||||
fn from(value: Color) -> Self {
|
||||
Self::Normal(value.into())
|
||||
}
|
||||
}
|
||||
|
||||
// Parley requires their Brush implementations to implement Default
|
||||
impl Default for TextBrush {
|
||||
fn default() -> Self {
|
||||
Self::Normal(Default::default())
|
||||
}
|
||||
}
|
||||
|
||||
/// Metrics describing the layout text.
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct LayoutMetrics {
|
||||
/// The nominal size of the layout.
|
||||
pub size: Size,
|
||||
/// The distance from the nominal top of the layout to the first baseline.
|
||||
pub first_baseline: f32,
|
||||
/// The width of the layout, inclusive of trailing whitespace.
|
||||
pub trailing_whitespace_width: f32,
|
||||
//TODO: add inking_rect
|
||||
}
|
||||
|
||||
impl<T> TextLayout<T> {
|
||||
/// Create a new `TextLayout` object.
|
||||
pub fn new(text: T, text_size: f32) -> Self {
|
||||
TextLayout {
|
||||
text,
|
||||
scale: 1.0,
|
||||
|
||||
brush: crate::theme::TEXT_COLOR.into(),
|
||||
font: FontStack::Single(FontFamily::Generic(GenericFamily::SansSerif)),
|
||||
text_size,
|
||||
weight: Weight::NORMAL,
|
||||
style: Style::Normal,
|
||||
|
||||
max_advance: None,
|
||||
alignment: Default::default(),
|
||||
|
||||
links: Rc::new([]),
|
||||
|
||||
needs_layout: true,
|
||||
needs_line_breaks: true,
|
||||
layout: Layout::new(),
|
||||
scratch_scene: Scene::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark that the inner layout needs to be updated.
|
||||
///
|
||||
/// This should be used if your `T` has interior mutability
|
||||
pub fn invalidate(&mut self) {
|
||||
self.needs_layout = true;
|
||||
self.needs_line_breaks = true;
|
||||
}
|
||||
|
||||
/// Set the scaling factor
|
||||
pub fn set_scale(&mut self, scale: f32) {
|
||||
if scale != self.scale {
|
||||
self.scale = scale;
|
||||
self.invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the default brush used for the layout.
|
||||
///
|
||||
/// This is the non-layout impacting styling (primarily colour)
|
||||
/// used when displaying the text
|
||||
#[doc(alias = "set_color")]
|
||||
pub fn set_brush(&mut self, brush: impl Into<TextBrush>) {
|
||||
let brush = brush.into();
|
||||
if brush != self.brush {
|
||||
self.brush = brush;
|
||||
self.invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the default font stack.
|
||||
pub fn set_font(&mut self, font: FontStack<'static>) {
|
||||
if font != self.font {
|
||||
self.font = font;
|
||||
self.invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the font size.
|
||||
#[doc(alias = "set_font_size")]
|
||||
pub fn set_text_size(&mut self, size: f32) {
|
||||
if size != self.text_size {
|
||||
self.text_size = size;
|
||||
self.invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the font weight.
|
||||
pub fn set_weight(&mut self, weight: Weight) {
|
||||
if weight != self.weight {
|
||||
self.weight = weight;
|
||||
self.invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the font style.
|
||||
pub fn set_style(&mut self, style: Style) {
|
||||
if style != self.style {
|
||||
self.style = style;
|
||||
self.invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the [`Alignment`] for this layout.
|
||||
pub fn set_text_alignment(&mut self, alignment: Alignment) {
|
||||
if self.alignment != alignment {
|
||||
self.alignment = alignment;
|
||||
self.invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the width at which to wrap words.
|
||||
///
|
||||
/// You may pass `None` to disable word wrapping
|
||||
/// (the default behaviour).
|
||||
pub fn set_max_advance(&mut self, max_advance: Option<f32>) {
|
||||
let max_advance = max_advance.map(|it| it.max(0.0));
|
||||
if self.max_advance.is_some() != max_advance.is_some()
|
||||
|| self
|
||||
.max_advance
|
||||
.zip(max_advance)
|
||||
// 1e-4 is an arbitrary small-enough value that we don't care to rewrap
|
||||
.map(|(old, new)| (old - new).abs() >= 1e-4)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
self.max_advance = max_advance;
|
||||
self.needs_line_breaks = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `true` if this layout needs to be rebuilt.
|
||||
///
|
||||
/// This happens (for instance) after style attributes are modified.
|
||||
///
|
||||
/// This does not account for things like the text changing, handling that
|
||||
/// is the responsibility of the user.
|
||||
pub fn needs_rebuild(&self) -> bool {
|
||||
self.needs_layout || self.needs_line_breaks
|
||||
}
|
||||
|
||||
// TODO: What are the valid use cases for this, where we shouldn't use a run-specific check instead?
|
||||
// /// Returns `true` if this layout's text appears to be right-to-left.
|
||||
// ///
|
||||
// /// See [`piet::util::first_strong_rtl`] for more information.
|
||||
// ///
|
||||
// /// [`piet::util::first_strong_rtl`]: crate::piet::util::first_strong_rtl
|
||||
// pub fn text_is_rtl(&self) -> bool {
|
||||
// self.text_is_rtl
|
||||
// }
|
||||
}
|
||||
|
||||
impl<T: TextStorage> TextLayout<T> {
|
||||
#[track_caller]
|
||||
fn assert_rebuilt(&self, method: &str) {
|
||||
if self.needs_layout || self.needs_line_breaks {
|
||||
debug_panic!(
|
||||
"TextLayout::{method} called without rebuilding layout object. Text was '{}'",
|
||||
self.text.as_str().chars().take(250).collect::<String>()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the text to display.
|
||||
pub fn set_text(&mut self, text: T) {
|
||||
if !self.text.maybe_eq(&text) {
|
||||
self.text = text;
|
||||
self.invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the [`TextStorage`] backing this layout, if it exists.
|
||||
pub fn text(&self) -> &T {
|
||||
&self.text
|
||||
}
|
||||
|
||||
/// Returns the [`TextStorage`] backing this layout, if it exists.
|
||||
///
|
||||
/// Invalidates the layout and so should only be used when definitely applying an edit
|
||||
pub fn text_mut(&mut self) -> &mut T {
|
||||
self.invalidate();
|
||||
&mut self.text
|
||||
}
|
||||
|
||||
/// Returns the inner Parley [`Layout`] value.
|
||||
pub fn layout(&self) -> &Layout<TextBrush> {
|
||||
self.assert_rebuilt("layout");
|
||||
&self.layout
|
||||
}
|
||||
|
||||
/// The size of the laid-out text, excluding any trailing whitespace.
|
||||
///
|
||||
/// This is not meaningful until [`Self::rebuild`] has been called.
|
||||
pub fn size(&self) -> Size {
|
||||
self.assert_rebuilt("size");
|
||||
Size::new(self.layout.width().into(), self.layout.height().into())
|
||||
}
|
||||
|
||||
/// The size of the laid-out text, including any trailing whitespace.
|
||||
///
|
||||
/// This is not meaningful until [`Self::rebuild`] has been called.
|
||||
pub fn full_size(&self) -> Size {
|
||||
self.assert_rebuilt("full_size");
|
||||
Size::new(self.layout.full_width().into(), self.layout.height().into())
|
||||
}
|
||||
|
||||
/// Return the text's [`LayoutMetrics`].
|
||||
///
|
||||
/// This is not meaningful until [`Self::rebuild`] has been called.
|
||||
pub fn layout_metrics(&self) -> LayoutMetrics {
|
||||
self.assert_rebuilt("layout_metrics");
|
||||
|
||||
let first_baseline = self.layout.get(0).unwrap().metrics().baseline;
|
||||
let size = Size::new(self.layout.width().into(), self.layout.height().into());
|
||||
LayoutMetrics {
|
||||
size,
|
||||
first_baseline,
|
||||
trailing_whitespace_width: self.layout.full_width(),
|
||||
}
|
||||
}
|
||||
|
||||
/// For a given `Point` (relative to this object's origin), returns index
|
||||
/// into the underlying text of the nearest grapheme boundary.
|
||||
///
|
||||
/// This is not meaningful until [`Self::rebuild`] has been called.
|
||||
pub fn cursor_for_point(&self, point: Point) -> Cursor {
|
||||
self.assert_rebuilt("text_position_for_point");
|
||||
|
||||
// TODO: This is a mostly good first pass, but doesn't handle cursor positions in
|
||||
// grapheme clusters within a parley cluster.
|
||||
// We can also try
|
||||
Cursor::from_point(&self.layout, point.x as f32, point.y as f32)
|
||||
}
|
||||
|
||||
/// Given the utf-8 position of a character boundary in the underlying text,
|
||||
/// return the `Point` (relative to this object's origin) representing the
|
||||
/// boundary of the containing grapheme.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if `text_pos` is not a character boundary.
|
||||
///
|
||||
/// This is not meaningful until [`Self::rebuild`] has been called.
|
||||
pub fn cursor_for_text_position(&self, text_pos: usize) -> Cursor {
|
||||
self.assert_rebuilt("cursor_for_text_position");
|
||||
|
||||
// TODO: As a reminder, `is_leading` is not very useful to us; we don't know this ahead of time
|
||||
// We're going to need to do quite a bit of remedial work on these
|
||||
// e.g. to handle a inside a ligature made of multiple (unicode) grapheme clusters
|
||||
// https://raphlinus.github.io/text/2020/10/26/text-layout.html#shaping-cluster
|
||||
// But we're choosing to defer this work
|
||||
// This also needs to handle affinity.
|
||||
Cursor::from_position(&self.layout, text_pos, true)
|
||||
}
|
||||
|
||||
/// Given the utf-8 position of a character boundary in the underlying text,
|
||||
/// return the `Point` (relative to this object's origin) representing the
|
||||
/// boundary of the containing grapheme.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if `text_pos` is not a character boundary.
|
||||
///
|
||||
/// This is not meaningful until [`Self::rebuild`] has been called.
|
||||
pub fn point_for_text_position(&self, text_pos: usize) -> Point {
|
||||
let cursor = self.cursor_for_text_position(text_pos);
|
||||
Point::new(
|
||||
cursor.advance as f64,
|
||||
(cursor.baseline + cursor.offset) as f64,
|
||||
)
|
||||
}
|
||||
|
||||
// TODO: needed for text selection
|
||||
// /// Given a utf-8 range in the underlying text, return a `Vec` of `Rect`s
|
||||
// /// representing the nominal bounding boxes of the text in that range.
|
||||
// ///
|
||||
// /// # Panics
|
||||
// ///
|
||||
// /// Panics if the range start or end is not a character boundary.
|
||||
// pub fn rects_for_range(&self, range: Range<usize>) -> Vec<Rect> {
|
||||
// self.layout.rects_for_range(range)
|
||||
// }
|
||||
|
||||
/// Given the utf-8 position of a character boundary in the underlying text,
|
||||
/// return a `Line` suitable for drawing a vertical cursor at that boundary.
|
||||
///
|
||||
/// This is not meaningful until [`Self::rebuild`] has been called.
|
||||
// TODO: This is too simplistic. See https://raphlinus.github.io/text/2020/10/26/text-layout.html#shaping-cluster
|
||||
// for example. This would break in a `fi` ligature
|
||||
pub fn cursor_line_for_text_position(&self, text_pos: usize) -> Line {
|
||||
let from_position = self.cursor_for_text_position(text_pos);
|
||||
|
||||
let line = from_position.path.line(&self.layout).unwrap();
|
||||
let line_metrics = line.metrics();
|
||||
|
||||
let baseline = line_metrics.baseline + line_metrics.descent;
|
||||
let p1 = (from_position.offset as f64, baseline as f64);
|
||||
let p2 = (
|
||||
from_position.offset as f64,
|
||||
(baseline - line_metrics.size()) as f64,
|
||||
);
|
||||
Line::new(p1, p2)
|
||||
}
|
||||
|
||||
/// Returns the [`Link`] at the provided point (relative to the layout's origin) if one exists.
|
||||
///
|
||||
/// This can be used both for hit-testing (deciding whether to change the mouse cursor,
|
||||
/// or performing some other action when hovering) as well as for retrieving a [`Link`]
|
||||
/// on click.
|
||||
///
|
||||
/// [`Link`]: super::attribute::Link
|
||||
pub fn link_for_pos(&self, pos: Point) -> Option<&Link> {
|
||||
let (_, i) = self
|
||||
.links
|
||||
.iter()
|
||||
.rfind(|(hit_box, _)| hit_box.contains(pos))?;
|
||||
|
||||
self.text.links().get(*i)
|
||||
}
|
||||
|
||||
/// Rebuild the inner layout as needed.
|
||||
///
|
||||
/// This `TextLayout` object manages a lower-level layout object that may
|
||||
/// need to be rebuilt in response to changes to the text or attributes
|
||||
/// like the font.
|
||||
///
|
||||
/// This method should be called whenever any of these things may have changed.
|
||||
/// A simple way to ensure this is correct is to always call this method
|
||||
/// as part of your widget's [`layout`][crate::Widget::layout] method.
|
||||
pub fn rebuild(
|
||||
&mut self,
|
||||
font_ctx: &mut FontContext,
|
||||
layout_ctx: &mut LayoutContext<TextBrush>,
|
||||
) {
|
||||
self.rebuild_with_attributes(font_ctx, layout_ctx, |builder| builder);
|
||||
}
|
||||
|
||||
/// Rebuild the inner layout as needed, adding attributes to the underlying layout.
|
||||
///
|
||||
/// See [`Self::rebuild`] for more information
|
||||
pub fn rebuild_with_attributes(
|
||||
&mut self,
|
||||
font_ctx: &mut FontContext,
|
||||
layout_ctx: &mut LayoutContext<TextBrush>,
|
||||
attributes: impl for<'b> FnOnce(
|
||||
RangedBuilder<'b, TextBrush, &'b str>,
|
||||
) -> RangedBuilder<'b, TextBrush, &'b str>,
|
||||
) {
|
||||
if self.needs_layout {
|
||||
self.needs_layout = false;
|
||||
|
||||
let mut builder = layout_ctx.ranged_builder(font_ctx, self.text.as_str(), self.scale);
|
||||
builder.push_default(&StyleProperty::Brush(self.brush.clone()));
|
||||
builder.push_default(&StyleProperty::FontSize(self.text_size));
|
||||
builder.push_default(&StyleProperty::FontStack(self.font));
|
||||
builder.push_default(&StyleProperty::FontWeight(self.weight));
|
||||
builder.push_default(&StyleProperty::FontStyle(self.style));
|
||||
// For more advanced features (e.g. variable font axes), these can be set in add_attributes
|
||||
|
||||
let builder = self.text.add_attributes(builder);
|
||||
let mut builder = attributes(builder);
|
||||
builder.build_into(&mut self.layout);
|
||||
|
||||
self.needs_line_breaks = true;
|
||||
}
|
||||
if self.needs_line_breaks {
|
||||
self.needs_line_breaks = false;
|
||||
self.layout
|
||||
.break_all_lines(self.max_advance, self.alignment);
|
||||
|
||||
// TODO:
|
||||
// self.links = text
|
||||
// .links()
|
||||
// ...
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw the layout at the provided `Point`.
|
||||
///
|
||||
/// The origin of the layout is the top-left corner.
|
||||
///
|
||||
/// You must call [`Self::rebuild`] at some point before you first
|
||||
/// call this method.
|
||||
pub fn draw(&mut self, scene: &mut Scene, point: impl Into<Point>) {
|
||||
self.assert_rebuilt("draw");
|
||||
// TODO: This translation doesn't seem great
|
||||
let p: Point = point.into();
|
||||
crate::text_helpers::render_text(
|
||||
scene,
|
||||
&mut self.scratch_scene,
|
||||
Affine::translate((p.x, p.y)),
|
||||
&self.layout,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: TextStorage> std::fmt::Debug for TextLayout<T> {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
f.debug_struct("TextLayout")
|
||||
.field("text", &self.text.as_str().len())
|
||||
.field("scale", &self.scale)
|
||||
.field("brush", &self.brush)
|
||||
.field("font", &self.font)
|
||||
.field("text_size", &self.text_size)
|
||||
.field("weight", &self.weight)
|
||||
.field("style", &self.style)
|
||||
.field("alignment", &self.alignment)
|
||||
.field("wrap_width", &self.max_advance)
|
||||
.field("outdated?", &self.needs_rebuild())
|
||||
.field("width", &self.layout.width())
|
||||
.field("height", &self.layout.height())
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: TextStorage + Default> Default for TextLayout<T> {
|
||||
fn default() -> Self {
|
||||
Self::new(Default::default(), crate::theme::TEXT_SIZE_NORMAL as f32)
|
||||
}
|
||||
}
|
|
@ -1,30 +0,0 @@
|
|||
// Copyright 2018 the Xilem Authors and the Druid Authors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//! Support for text display and rendering
|
||||
//!
|
||||
//! There are three kinds of text commonly needed:
|
||||
//! 1) Entirely display text (e.g. a button)
|
||||
//! 2) Selectable text (e.g. a paragraph of content)
|
||||
//! 3) Editable text (e.g. a search bar)
|
||||
//!
|
||||
//! All of these have the same set of global styling options, and can contain rich text
|
||||
|
||||
mod store;
|
||||
pub use store::{Link, TextStorage};
|
||||
|
||||
mod layout;
|
||||
pub use layout::{LayoutMetrics, TextBrush, TextLayout};
|
||||
|
||||
mod selection;
|
||||
pub use selection::{
|
||||
len_utf8_from_first_byte, EditableTextCursor, Selectable, StringCursor, TextWithSelection,
|
||||
};
|
||||
|
||||
// mod movement;
|
||||
|
||||
mod edit;
|
||||
pub use edit::{EditableText, TextEditor};
|
||||
|
||||
mod backspace;
|
||||
pub use backspace::offset_for_delete_backwards;
|
|
@ -1,357 +0,0 @@
|
|||
// Copyright 2018 the Xilem Authors and the Druid Authors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//! Text editing movements.
|
||||
|
||||
use std::ops::Range;
|
||||
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
|
||||
use crate::kurbo::Point;
|
||||
|
||||
use super::{layout::TextLayout, Selectable, TextStorage};
|
||||
|
||||
/// Compute the result of a [`Movement`] on a [`Selection`].
|
||||
///
|
||||
/// returns a new selection representing the state after the movement.
|
||||
///
|
||||
/// If `modify` is true, only the 'active' edge (the `end`) of the selection
|
||||
/// should be changed; this is the case when the user moves with the shift
|
||||
/// key pressed.
|
||||
pub fn movement<T: Selectable + TextStorage>(
|
||||
m: Movement,
|
||||
s: Selection,
|
||||
layout: &TextLayout<T>,
|
||||
modify: bool,
|
||||
) -> Selection {
|
||||
if layout.needs_rebuild() {
|
||||
debug_panic!("movement() called before layout rebuild");
|
||||
return s;
|
||||
}
|
||||
let text = layout.text();
|
||||
let parley_layout = layout.layout();
|
||||
|
||||
let writing_direction = || {
|
||||
if layout
|
||||
.cursor_for_text_position(s.active, s.active_affinity)
|
||||
.is_rtl
|
||||
{
|
||||
WritingDirection::RightToLeft
|
||||
} else {
|
||||
WritingDirection::LeftToRight
|
||||
}
|
||||
};
|
||||
|
||||
let (offset, h_pos) = match m {
|
||||
Movement::Grapheme(d) => {
|
||||
let direction = writing_direction();
|
||||
if d.is_upstream_for_direction(direction) {
|
||||
if s.is_caret() || modify {
|
||||
text.prev_grapheme_offset(s.active)
|
||||
.map(|off| (off, None))
|
||||
.unwrap_or((0, s.h_pos))
|
||||
} else {
|
||||
(s.min(), None)
|
||||
}
|
||||
} else {
|
||||
if s.is_caret() || modify {
|
||||
text.next_grapheme_offset(s.active)
|
||||
.map(|off| (off, None))
|
||||
.unwrap_or((s.active, s.h_pos))
|
||||
} else {
|
||||
(s.max(), None)
|
||||
}
|
||||
}
|
||||
}
|
||||
Movement::Vertical(VerticalMovement::LineUp) => {
|
||||
let cur_pos = layout.cursor_for_text_position(s.active, s.active_affinity);
|
||||
let h_pos = s.h_pos.unwrap_or(cur_pos.advance);
|
||||
if cur_pos.path.line_index == 0 {
|
||||
(0, Some(h_pos))
|
||||
} else {
|
||||
let lm = cur_pos.path.line(&parley_layout).unwrap();
|
||||
let point_above = Point::new(h_pos, cur_pos.point.y - lm.height);
|
||||
let up_pos = layout.hit_test_point(point_above);
|
||||
if up_pos.is_inside {
|
||||
(up_pos.idx, Some(h_pos))
|
||||
} else {
|
||||
// because we can't specify affinity, moving up when h_pos
|
||||
// is wider than both the current line and the previous line
|
||||
// can result in a cursor position at the visual start of the
|
||||
// current line; so we handle this as a special-case.
|
||||
let lm_prev = layout.line_metric(cur_pos.line.saturating_sub(1)).unwrap();
|
||||
let up_pos = lm_prev.end_offset - lm_prev.trailing_whitespace;
|
||||
(up_pos, Some(h_pos))
|
||||
}
|
||||
}
|
||||
}
|
||||
Movement::Vertical(VerticalMovement::LineDown) => {
|
||||
let cur_pos = layout.hit_test_text_position(s.active);
|
||||
let h_pos = s.h_pos.unwrap_or(cur_pos.point.x);
|
||||
if cur_pos.line == layout.line_count() - 1 {
|
||||
(text.len(), Some(h_pos))
|
||||
} else {
|
||||
let lm = layout.line_metric(cur_pos.line).unwrap();
|
||||
// may not work correctly for point sizes below 1.0
|
||||
let y_below = lm.y_offset + lm.height + 1.0;
|
||||
let point_below = Point::new(h_pos, y_below);
|
||||
let up_pos = layout.hit_test_point(point_below);
|
||||
(up_pos.idx, Some(point_below.x))
|
||||
}
|
||||
}
|
||||
Movement::Vertical(VerticalMovement::DocumentStart) => (0, None),
|
||||
Movement::Vertical(VerticalMovement::DocumentEnd) => (text.len(), None),
|
||||
|
||||
Movement::ParagraphStart => (text.preceding_line_break(s.active), None),
|
||||
Movement::ParagraphEnd => (text.next_line_break(s.active), None),
|
||||
|
||||
Movement::Line(d) => {
|
||||
let hit = layout.hit_test_text_position(s.active);
|
||||
let lm = layout.line_metric(hit.line).unwrap();
|
||||
let offset = if d.is_upstream_for_direction(writing_direction) {
|
||||
lm.start_offset
|
||||
} else {
|
||||
lm.end_offset - lm.trailing_whitespace
|
||||
};
|
||||
(offset, None)
|
||||
}
|
||||
Movement::Word(d) => {
|
||||
if d.is_upstream_for_direction(writing_direction()) {
|
||||
let offset = if s.is_caret() || modify {
|
||||
text.prev_word_offset(s.active).unwrap_or(0)
|
||||
} else {
|
||||
s.min()
|
||||
};
|
||||
(offset, None)
|
||||
} else {
|
||||
let offset = if s.is_caret() || modify {
|
||||
text.next_word_offset(s.active).unwrap_or(s.active)
|
||||
} else {
|
||||
s.max()
|
||||
};
|
||||
(offset, None)
|
||||
}
|
||||
}
|
||||
|
||||
// These two are not handled; they require knowledge of the size
|
||||
// of the viewport.
|
||||
Movement::Vertical(VerticalMovement::PageDown)
|
||||
| Movement::Vertical(VerticalMovement::PageUp) => (s.active, s.h_pos),
|
||||
other => {
|
||||
tracing::warn!("unhandled movement {:?}", other);
|
||||
(s.anchor, s.h_pos)
|
||||
}
|
||||
};
|
||||
|
||||
let start = if modify { s.anchor } else { offset };
|
||||
Selection::new(start, offset).with_h_pos(h_pos)
|
||||
}
|
||||
|
||||
/// Indicates a movement that transforms a particular text position in a
|
||||
/// document.
|
||||
///
|
||||
/// These movements transform only single indices — not selections.
|
||||
///
|
||||
/// You'll note that a lot of these operations are idempotent, but you can get
|
||||
/// around this by first sending a `Grapheme` movement. If for instance, you
|
||||
/// want a `ParagraphStart` that is not idempotent, you can first send
|
||||
/// `Movement::Grapheme(Direction::Upstream)`, and then follow it with
|
||||
/// `ParagraphStart`.
|
||||
#[non_exhaustive]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum Movement {
|
||||
/// A movement that stops when it reaches an extended grapheme cluster boundary.
|
||||
///
|
||||
/// This movement is achieved on most systems by pressing the left and right
|
||||
/// arrow keys. For more information on grapheme clusters, see
|
||||
/// [Unicode Text Segmentation](https://unicode.org/reports/tr29/#Grapheme_Cluster_Boundaries).
|
||||
Grapheme(Direction),
|
||||
/// A movement that stops when it reaches a word boundary.
|
||||
///
|
||||
/// This movement is achieved on most systems by pressing the left and right
|
||||
/// arrow keys while holding control. For more information on words, see
|
||||
/// [Unicode Text Segmentation](https://unicode.org/reports/tr29/#Word_Boundaries).
|
||||
Word(Direction),
|
||||
/// A movement that stops when it reaches a soft line break.
|
||||
///
|
||||
/// This movement is achieved on macOS by pressing the left and right arrow
|
||||
/// keys while holding command. `Line` should be idempotent: if the
|
||||
/// position is already at the end of a soft-wrapped line, this movement
|
||||
/// should never push it onto another soft-wrapped line.
|
||||
///
|
||||
/// In order to implement this properly, your text positions should remember
|
||||
/// their affinity.
|
||||
Line(Direction),
|
||||
/// An upstream movement that stops when it reaches a hard line break.
|
||||
///
|
||||
/// `ParagraphStart` should be idempotent: if the position is already at the
|
||||
/// start of a hard-wrapped line, this movement should never push it onto
|
||||
/// the previous line.
|
||||
ParagraphStart,
|
||||
/// A downstream movement that stops when it reaches a hard line break.
|
||||
///
|
||||
/// `ParagraphEnd` should be idempotent: if the position is already at the
|
||||
/// end of a hard-wrapped line, this movement should never push it onto the
|
||||
/// next line.
|
||||
ParagraphEnd,
|
||||
/// A vertical movement, see `VerticalMovement` for more details.
|
||||
Vertical(VerticalMovement),
|
||||
}
|
||||
|
||||
/// Indicates a horizontal direction in the text.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum Direction {
|
||||
/// The direction visually to the left.
|
||||
///
|
||||
/// This may be byte-wise forwards or backwards in the document, depending
|
||||
/// on the text direction around the position being moved.
|
||||
Left,
|
||||
/// The direction visually to the right.
|
||||
///
|
||||
/// This may be byte-wise forwards or backwards in the document, depending
|
||||
/// on the text direction around the position being moved.
|
||||
Right,
|
||||
/// Byte-wise backwards in the document.
|
||||
///
|
||||
/// In a left-to-right context, this value is the same as `Left`.
|
||||
Upstream,
|
||||
/// Byte-wise forwards in the document.
|
||||
///
|
||||
/// In a left-to-right context, this value is the same as `Right`.
|
||||
Downstream,
|
||||
}
|
||||
|
||||
impl Direction {
|
||||
/// Returns `true` if this direction is byte-wise backwards for
|
||||
/// the provided [`WritingDirection`].
|
||||
///
|
||||
/// The provided direction *must not be* `WritingDirection::Natural`.
|
||||
pub fn is_upstream_for_direction(self, direction: WritingDirection) -> bool {
|
||||
// assert!(
|
||||
// !matches!(direction, WritingDirection::Natural),
|
||||
// "writing direction must be resolved"
|
||||
// );
|
||||
match self {
|
||||
Direction::Upstream => true,
|
||||
Direction::Downstream => false,
|
||||
Direction::Left => matches!(direction, WritingDirection::LeftToRight),
|
||||
Direction::Right => matches!(direction, WritingDirection::RightToLeft),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Distinguishes between two visually distinct locations with the same byte
|
||||
/// index.
|
||||
///
|
||||
/// Sometimes, a byte location in a document has two visual locations. For
|
||||
/// example, the end of a soft-wrapped line and the start of the subsequent line
|
||||
/// have different visual locations (and we want to be able to place an input
|
||||
/// caret in either place!) but the same byte-wise location. This also shows up
|
||||
/// in bidirectional text contexts. Affinity allows us to disambiguate between
|
||||
/// these two visual locations.
|
||||
///
|
||||
/// Note that in scenarios where soft line breaks interact with bidi text, this gets
|
||||
/// more complicated.
|
||||
#[derive(Copy, Clone, Debug, Hash, PartialEq)]
|
||||
pub enum Affinity {
|
||||
/// The position which has an apparent position "earlier" in the text.
|
||||
/// For soft line breaks, this is the position at the end of the first line.
|
||||
///
|
||||
/// For positions in-between bidi contexts, this is the position which is
|
||||
/// related to the "outgoing" text section. E.g. for the string "abcDEF" (rendered `abcFED`),
|
||||
/// with the cursor at "abc|DEF" with upstream affinity, the cursor would be rendered at the
|
||||
/// position `abc|DEF`
|
||||
Upstream,
|
||||
/// The position which has a higher apparent position in the text.
|
||||
/// For soft line breaks, this is the position at the beginning of the second line.
|
||||
///
|
||||
/// For positions in-between bidi contexts, this is the position which is
|
||||
/// related to the "incoming" text section. E.g. for the string "abcDEF" (rendered `abcFED`),
|
||||
/// with the cursor at "abc|DEF" with downstream affinity, the cursor would be rendered at the
|
||||
/// position `abcDEF|`
|
||||
Downstream,
|
||||
}
|
||||
|
||||
impl Affinity {
|
||||
/// Convert into the `parley` form of "leading"
|
||||
pub fn is_leading(&self) -> bool {
|
||||
match self {
|
||||
Affinity::Upstream => false,
|
||||
Affinity::Downstream => true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Indicates a horizontal direction for writing text.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum WritingDirection {
|
||||
LeftToRight,
|
||||
RightToLeft,
|
||||
// /// Indicates writing direction should be automatically detected based on
|
||||
// /// the text contents.
|
||||
// See also `is_upstream_for_direction` if adding back in
|
||||
// Natural,
|
||||
}
|
||||
|
||||
/// Indicates a vertical movement in a text document.
|
||||
#[non_exhaustive]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum VerticalMovement {
|
||||
LineUp,
|
||||
LineDown,
|
||||
PageUp,
|
||||
PageDown,
|
||||
DocumentStart,
|
||||
DocumentEnd,
|
||||
}
|
||||
|
||||
/// Given a position in some text, return the containing word boundaries.
|
||||
///
|
||||
/// The returned range may not necessary be a 'word'; for instance it could be
|
||||
/// the sequence of whitespace between two words.
|
||||
///
|
||||
/// If the position is on a word boundary, that will be considered the start
|
||||
/// of the range.
|
||||
///
|
||||
/// This uses Unicode word boundaries, as defined in [UAX#29].
|
||||
///
|
||||
/// [UAX#29]: http://www.unicode.org/reports/tr29/
|
||||
pub(crate) fn word_range_for_pos(text: &str, pos: usize) -> Range<usize> {
|
||||
text.split_word_bound_indices()
|
||||
.map(|(ix, word)| ix..(ix + word.len()))
|
||||
.find(|range| range.contains(&pos))
|
||||
.unwrap_or(pos..pos)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn word_range_simple() {
|
||||
assert_eq!(word_range_for_pos("hello world", 3), 0..5);
|
||||
assert_eq!(word_range_for_pos("hello world", 8), 6..11);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn word_range_whitespace() {
|
||||
assert_eq!(word_range_for_pos("hello world", 5), 5..6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn word_range_rtl() {
|
||||
let rtl = "مرحبا بالعالم";
|
||||
assert_eq!(word_range_for_pos(rtl, 5), 0..10);
|
||||
assert_eq!(word_range_for_pos(rtl, 16), 11..25);
|
||||
assert_eq!(word_range_for_pos(rtl, 10), 10..11);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn word_range_mixed() {
|
||||
let mixed = "hello مرحبا بالعالم world";
|
||||
assert_eq!(word_range_for_pos(mixed, 3), 0..5);
|
||||
assert_eq!(word_range_for_pos(mixed, 8), 6..16);
|
||||
assert_eq!(word_range_for_pos(mixed, 19), 17..31);
|
||||
assert_eq!(word_range_for_pos(mixed, 36), 32..37);
|
||||
}
|
||||
}
|
|
@ -7,7 +7,7 @@ use kurbo::{Line, Rect, Stroke};
|
|||
use parley::Layout;
|
||||
use vello::{kurbo::Affine, peniko::Fill, Scene};
|
||||
|
||||
use crate::{text2::TextBrush, WidgetId};
|
||||
use crate::{text::TextBrush, WidgetId};
|
||||
|
||||
/// A reference counted string slice.
|
||||
///
|
||||
|
|
|
@ -11,7 +11,7 @@ use vello::Scene;
|
|||
use crate::action::Action;
|
||||
use crate::event::PointerButton;
|
||||
use crate::paint_scene_helpers::{fill_lin_gradient, stroke, UnitPoint};
|
||||
use crate::text2::TextStorage;
|
||||
use crate::text::TextStorage;
|
||||
use crate::widget::{Label, WidgetMut, WidgetPod};
|
||||
use crate::{
|
||||
theme, AccessCtx, AccessEvent, ArcStr, BoxConstraints, EventCtx, Insets, LayoutCtx, LifeCycle,
|
||||
|
|
|
@ -12,7 +12,7 @@ use vello::Scene;
|
|||
use crate::action::Action;
|
||||
use crate::kurbo::{BezPath, Cap, Join, Size};
|
||||
use crate::paint_scene_helpers::{fill_lin_gradient, stroke, UnitPoint};
|
||||
use crate::text2::TextStorage;
|
||||
use crate::text::TextStorage;
|
||||
use crate::widget::{Label, WidgetMut};
|
||||
use crate::{
|
||||
theme, AccessCtx, AccessEvent, ArcStr, BoxConstraints, EventCtx, LayoutCtx, LifeCycle,
|
||||
|
|
|
@ -12,7 +12,7 @@ use tracing::{trace, trace_span, Span};
|
|||
use vello::peniko::BlendMode;
|
||||
use vello::Scene;
|
||||
|
||||
use crate::text2::{TextBrush, TextLayout, TextStorage};
|
||||
use crate::text::{TextBrush, TextLayout, TextStorage};
|
||||
use crate::widget::WidgetMut;
|
||||
use crate::{
|
||||
AccessCtx, AccessEvent, ArcStr, BoxConstraints, EventCtx, LayoutCtx, LifeCycle, LifeCycleCtx,
|
||||
|
|
|
@ -13,7 +13,7 @@ use vello::{peniko::BlendMode, Scene};
|
|||
|
||||
use crate::widget::{LineBreaking, WidgetMut};
|
||||
use crate::{
|
||||
text2::{TextBrush, TextStorage, TextWithSelection},
|
||||
text::{TextBrush, TextStorage, TextWithSelection},
|
||||
widget::label::LABEL_X_PADDING,
|
||||
AccessCtx, AccessEvent, ArcStr, BoxConstraints, CursorIcon, EventCtx, LayoutCtx, LifeCycle,
|
||||
LifeCycleCtx, PaintCtx, PointerEvent, StatusChange, TextEvent, Widget, WidgetId,
|
||||
|
|
|
@ -17,7 +17,7 @@ use vello::{
|
|||
use crate::widget::{LineBreaking, WidgetMut};
|
||||
use crate::{
|
||||
dpi::{LogicalPosition, LogicalSize},
|
||||
text2::{TextBrush, TextEditor, TextStorage, TextWithSelection},
|
||||
text::{TextBrush, TextEditor, TextStorage, TextWithSelection},
|
||||
AccessCtx, AccessEvent, BoxConstraints, CursorIcon, EventCtx, LayoutCtx, LifeCycle,
|
||||
LifeCycleCtx, PaintCtx, PointerEvent, StatusChange, TextEvent, Widget, WidgetId,
|
||||
};
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
// Copyright 2024 the Xilem Authors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
use masonry::{text2::TextBrush, widget, ArcStr};
|
||||
use masonry::{text::TextBrush, widget, ArcStr};
|
||||
use xilem_core::Mut;
|
||||
|
||||
use crate::{Color, MessageResult, Pod, TextAlignment, View, ViewCtx, ViewId};
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
// Copyright 2024 the Xilem Authors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
use masonry::{text2::TextBrush, widget, ArcStr};
|
||||
use masonry::{text::TextBrush, widget, ArcStr};
|
||||
use xilem_core::Mut;
|
||||
|
||||
use crate::{Color, MessageResult, Pod, TextAlignment, View, ViewCtx, ViewId};
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
// Copyright 2024 the Xilem Authors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
use masonry::{text2::TextBrush, widget};
|
||||
use masonry::{text::TextBrush, widget};
|
||||
use xilem_core::{Mut, View};
|
||||
|
||||
use crate::{Color, MessageResult, Pod, TextAlignment, ViewCtx, ViewId};
|
||||
|
|
Loading…
Reference in New Issue