Update to Vello 0.3.0, Parley main, AccessKit 0.17 (#616)

The biggest remaining issue in this PR is that IME support is not
present.

However, I think landing this is *better* than not landing it, because:
1) If we don't land it, it's going to languish again
2) Getting IME support back can be parallelised (cc @tomcur)
3) Getting Vello 0.3.0 and Parley 0.2.0 unlocks real advantages,
including full emoji support (#420).

To be clear, my first follow-up priority will be connecting the IME back
up. I do not however think this should block on Parley 0.3.0.

Discussion in
https://xi.zulipchat.com/#narrow/channel/317477-masonry/topic/Updating.20Parley.20dependency
This commit is contained in:
Daniel McNab 2024-11-18 17:25:02 +00:00 committed by GitHub
parent 30cb5fb6a6
commit 10dc9d171c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
61 changed files with 3149 additions and 4419 deletions

1445
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -28,7 +28,7 @@ homepage = "https://xilem.dev/"
[workspace.lints]
# unsafe code is not allowed in Xilem or Masonry
# We would like to set this to `forbid`, but we have to use `deny` because `android_activity`
# We would like to set this to `forbid`, but we have to use `deny` because `android_activity`
# requires us to use the unsafe `#[no_mangle]` attribute
# (And cargo doesn't let us have platform specific lints here)
rust.unsafe_code = "deny"
@ -104,11 +104,13 @@ clippy.duplicated_attributes = "allow"
[workspace.dependencies]
masonry = { version = "0.2.0", path = "masonry" }
xilem_core = { version = "0.1.0", path = "xilem_core" }
vello = "0.2.1"
wgpu = "0.20.1"
vello = "0.3"
wgpu = "22.1.0"
kurbo = "0.11.1"
parley = "0.1.0"
peniko = "0.1.1"
parley = { git = "https://github.com/linebender/parley", rev = "217f243aa61178229da694b1d2b0598afcf29aff", features = [
"accesskit",
] }
peniko = "0.2.0"
winit = "0.30.4"
tracing = { version = "0.1.40", default-features = false }
smallvec = "1.13.2"
@ -116,7 +118,7 @@ dpi = "0.1.1"
image = { version = "0.25.2", default-features = false }
web-time = "1.1.0"
bitflags = "2.6.0"
accesskit = "0.16.0"
accesskit_winit = "0.22.0"
accesskit = "0.17.0"
accesskit_winit = "0.23.0"
nv-flip = "0.1.2"
time = "0.3.36"

View File

@ -47,9 +47,6 @@ serde = { version = "1.0.204", features = ["derive"] }
serde_json = "1.0.120"
futures-intrusive = "0.5.0"
pollster = "0.3.0"
unicode-segmentation = "1.11.0"
# TODO: Is this still the most up-to-date crate for this?
xi-unicode = "0.3.0"
tracing-subscriber = { version = "0.3.18", features = ["env-filter", "time"] }
accesskit.workspace = true
accesskit_winit.workspace = true
@ -58,7 +55,7 @@ cursor-icon = "1.1.0"
dpi.workspace = true
nv-flip.workspace = true
tracing-tracy = { version = "0.11.3", optional = true }
wgpu-profiler = { optional = true, version = "0.17.0", default-features = false }
wgpu-profiler = { optional = true, version = "0.18.2", default-features = false }
[target.'cfg(target_arch = "wasm32")'.dependencies]
web-time.workspace = true

View File

@ -12,8 +12,9 @@
)]
#![expect(elided_lifetimes_in_paths, reason = "Deferred: Noisy")]
use accesskit::{DefaultActionVerb, NodeBuilder, Role};
use accesskit::{Node, Role};
use masonry::dpi::LogicalSize;
use masonry::text::StyleProperty;
use masonry::widget::{Align, CrossAxisAlignment, Flex, Label, RootWidget, SizedBox};
use masonry::{
AccessCtx, AccessEvent, Action, AppDriver, BoxConstraints, Color, DriverCtx, EventCtx,
@ -178,7 +179,7 @@ impl Widget for CalcButton {
fn on_access_event(&mut self, ctx: &mut EventCtx, event: &AccessEvent) {
if ctx.target() == ctx.widget_id() {
match event.action {
accesskit::Action::Default => {
accesskit::Action::Click => {
ctx.submit_action(Action::Other(Box::new(self.action)));
}
_ => {}
@ -230,14 +231,14 @@ impl Widget for CalcButton {
Role::Button
}
fn accessibility(&mut self, _ctx: &mut AccessCtx, node: &mut NodeBuilder) {
fn accessibility(&mut self, _ctx: &mut AccessCtx, node: &mut Node) {
let _name = match self.action {
CalcAction::Digit(digit) => digit.to_string(),
CalcAction::Op(op) => op.to_string(),
};
// We may want to add a name if it doesn't interfere with the child label
// ctx.current_node().set_name(name);
node.set_default_action_verb(DefaultActionVerb::Click);
node.add_action(accesskit::Action::Click);
}
fn children_ids(&self) -> SmallVec<[WidgetId; 16]> {
@ -274,9 +275,11 @@ fn op_button_with_label(op: char, label: String) -> CalcButton {
const LIGHT_BLUE: Color = Color::rgb8(0x5c, 0xc4, 0xff);
CalcButton::new(
SizedBox::new(Align::centered(Label::new(label).with_text_size(24.)))
.background(BLUE)
.expand(),
SizedBox::new(Align::centered(
Label::new(label).with_style(StyleProperty::FontSize(24.)),
))
.background(BLUE)
.expand(),
CalcAction::Op(op),
BLUE,
LIGHT_BLUE,
@ -292,7 +295,7 @@ fn digit_button(digit: u8) -> CalcButton {
const LIGHT_GRAY: Color = Color::rgb8(0x71, 0x71, 0x71);
CalcButton::new(
SizedBox::new(Align::centered(
Label::new(format!("{digit}")).with_text_size(24.),
Label::new(format!("{digit}")).with_style(StyleProperty::FontSize(24.)),
))
.background(GRAY)
.expand(),
@ -320,7 +323,7 @@ fn flex_row(
}
fn build_calc() -> impl Widget {
let display = Label::new(String::new()).with_text_size(32.0);
let display = Label::new(String::new()).with_style(StyleProperty::FontSize(32.));
Flex::column()
.gap(0.0)
.with_flex_spacer(0.2)

View File

@ -10,7 +10,7 @@
#![expect(clippy::shadow_unrelated, reason = "Deferred: Noisy")]
#![expect(clippy::cast_possible_truncation, reason = "Deferred: Noisy")]
use accesskit::{NodeBuilder, Role};
use accesskit::{Node, Role};
use masonry::kurbo::{BezPath, Stroke};
use masonry::widget::{ObjectFit, RootWidget};
use masonry::{
@ -22,7 +22,7 @@ use parley::layout::Alignment;
use parley::style::{FontFamily, FontStack, StyleProperty};
use smallvec::SmallVec;
use tracing::{trace_span, Span};
use vello::peniko::{Brush, Fill, Format, Image};
use vello::peniko::{Fill, Format, Image};
use vello::Scene;
use winit::window::Window;
@ -100,22 +100,22 @@ impl Widget for CustomWidget {
let mut lcx = parley::LayoutContext::new();
let mut text_layout_builder = lcx.ranged_builder(ctx.text_contexts().0, &self.0, 1.0);
text_layout_builder.push_default(&StyleProperty::FontStack(FontStack::Single(
text_layout_builder.push_default(StyleProperty::FontStack(FontStack::Single(
FontFamily::Generic(parley::style::GenericFamily::Serif),
)));
text_layout_builder.push_default(&StyleProperty::FontSize(24.0));
text_layout_builder.push_default(&StyleProperty::Brush(Brush::Solid(fill_color).into()));
text_layout_builder.push_default(StyleProperty::FontSize(24.0));
let mut text_layout = text_layout_builder.build();
text_layout.break_all_lines(None, Alignment::Start);
let mut text_layout = text_layout_builder.build(&self.0);
text_layout.break_all_lines(None);
text_layout.align(None, Alignment::Start);
let mut scratch_scene = Scene::new();
// We can pass a transform matrix to rotate the text we render
masonry::text::render_text(
scene,
&mut scratch_scene,
Affine::rotate(std::f64::consts::FRAC_PI_4).then_translate((80.0, 40.0).into()),
&text_layout,
&[fill_color.into()],
true,
);
// Let's burn some CPU to make a (partially transparent) image buffer
@ -129,9 +129,9 @@ impl Widget for CustomWidget {
Role::Window
}
fn accessibility(&mut self, _ctx: &mut AccessCtx, node: &mut NodeBuilder) {
fn accessibility(&mut self, _ctx: &mut AccessCtx, node: &mut Node) {
let text = &self.0;
node.set_name(
node.set_label(
format!("This is a demo of the Masonry Widget trait. Masonry has accessibility tree support. The demo shows colored shapes with the text '{text}'."),
);
}

View File

@ -7,6 +7,7 @@
#![windows_subsystem = "windows"]
use masonry::dpi::LogicalSize;
use masonry::text::StyleProperty;
use masonry::widget::{Button, Grid, GridParams, Prose, RootWidget, SizedBox};
use masonry::{Action, AppDriver, Color, DriverCtx, PointerButton, WidgetId};
use parley::layout::Alignment;
@ -44,8 +45,8 @@ fn grid_button(params: GridParams) -> Button {
fn main() {
let label = SizedBox::new(
Prose::new("Change spacing by right and left clicking on the buttons")
.with_text_size(14.0)
.with_text_alignment(Alignment::Middle),
.with_style(StyleProperty::FontSize(14.0))
.with_alignment(Alignment::Middle),
)
.border(Color::rgb8(40, 40, 80), 1.0);
let button_inputs = vec![

View File

@ -8,6 +8,7 @@
#![windows_subsystem = "windows"]
use masonry::dpi::LogicalSize;
use masonry::text::StyleProperty;
use masonry::widget::{Button, Flex, Label, RootWidget};
use masonry::{Action, AppDriver, DriverCtx, WidgetId};
use parley::fontique::Weight;
@ -32,8 +33,9 @@ impl AppDriver for Driver {
fn main() {
let label = Label::new("Hello")
.with_text_size(32.0)
.with_weight(Weight::BOLD);
.with_style(StyleProperty::FontSize(32.0))
// Ideally there's be an Into in Parley for this
.with_style(StyleProperty::FontWeight(Weight::BOLD));
let button = Button::new("Say hello");
// Arrange the two widgets vertically, with some padding

View File

@ -16,7 +16,7 @@ use winit::window::ResizeDirection;
use crate::action::Action;
use crate::passes::layout::run_layout_on;
use crate::render_root::{MutateCallback, RenderRootSignal, RenderRootState};
use crate::text::TextBrush;
use crate::text::BrushIndex;
use crate::theme::get_debug_color;
use crate::tree_arena::{ArenaMutChildren, ArenaRefChildren};
use crate::widget::{WidgetMut, WidgetRef, WidgetState};
@ -199,6 +199,26 @@ impl_context_method!(
}
);
// Methods for all exclusive context types (i.e. those which have exclusive access to the global state).
impl_context_method! {
AccessCtx<'_>,
ComposeCtx<'_>,
EventCtx<'_>,
LayoutCtx<'_>,
MutateCtx<'_>,
PaintCtx<'_>,
UpdateCtx<'_>,
{
/// Get the contexts needed to build and paint text sections.
///
/// Note that in many cases, these contexts are.
pub fn text_contexts(&mut self) -> (&mut FontContext, &mut LayoutContext<BrushIndex>) {
(
&mut self.global_state.font_context,
&mut self.global_state.text_layout_context,
)
}
}}
// --- MARK: GET LAYOUT ---
// Methods on all context types except LayoutCtx
// These methods access layout info calculated during the layout pass.
@ -1119,17 +1139,6 @@ impl ComposeCtx<'_> {
}
}
// --- MARK: OTHER STUFF ---
impl_context_method!(LayoutCtx<'_>, PaintCtx<'_>, {
/// Get the contexts needed to build and paint text sections.
pub fn text_contexts(&mut self) -> (&mut FontContext, &mut LayoutContext<TextBrush>) {
(
&mut self.global_state.font_context,
&mut self.global_state.text_layout_context,
)
}
});
impl PaintCtx<'_> {
/// Whether debug paint is enabled.
///

View File

@ -38,7 +38,7 @@ trait Widget {
fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut Scene);
fn accessibility_role(&self) -> Role;
fn accessibility(&mut self, ctx: &mut AccessCtx, node: &mut NodeBuilder);
fn accessibility(&mut self, ctx: &mut AccessCtx, node: &mut Node);
// ...
}
@ -218,7 +218,7 @@ use masonry::{
PaintCtx, AccessCtx
};
use vello::Scene;
use accesskit::{NodeBuilder, Role};
use accesskit::{Node, Role};
impl Widget for ColorRectangle {
// ...
@ -238,7 +238,7 @@ impl Widget for ColorRectangle {
Role::Button
}
fn accessibility(&mut self, ctx: &mut AccessCtx, node: &mut NodeBuilder) {
fn accessibility(&mut self, ctx: &mut AccessCtx, node: &mut Node) {
node.set_default_action_verb(DefaultActionVerb::Click);
}

View File

@ -243,7 +243,7 @@ impl Widget for VerticalStack {
Role::GenericContainer
}
fn accessibility(&mut self, _ctx: &mut AccessCtx, _node: &mut NodeBuilder) {}
fn accessibility(&mut self, _ctx: &mut AccessCtx, _node: &mut Node) {}
// ...
}

View File

@ -410,7 +410,7 @@ impl TextEvent {
impl AccessEvent {
pub fn short_name(&self) -> &'static str {
match self.action {
accesskit::Action::Default => "Default",
accesskit::Action::Click => "Click",
accesskit::Action::Focus => "Focus",
accesskit::Action::Blur => "Blur",
accesskit::Action::Collapse => "Collapse",

View File

@ -134,10 +134,6 @@
#![expect(clippy::todo, reason = "We have a lot of 'real' todos")]
#![expect(clippy::missing_errors_doc, reason = "Can be quite noisy?")]
#![expect(clippy::missing_panics_doc, reason = "Can be quite noisy?")]
#![expect(
clippy::partial_pub_fields,
reason = "Potentially controversial code style"
)]
#![expect(
clippy::shadow_unrelated,
reason = "Potentially controversial code style"

View File

@ -1,7 +1,7 @@
// Copyright 2024 the Xilem Authors
// SPDX-License-Identifier: Apache-2.0
use accesskit::{NodeBuilder, NodeId, Tree, TreeUpdate};
use accesskit::{Node, NodeId, Tree, TreeUpdate};
use tracing::{debug, info_span, trace};
use vello::kurbo::Rect;
@ -53,16 +53,10 @@ fn build_accessibility_tree(
};
let mut node = build_access_node(widget.item, &mut ctx);
widget.item.accessibility(&mut ctx, &mut node);
let node = node.build();
let id: NodeId = ctx.widget_state.id.into();
if ctx.global_state.trace.access {
trace!(
"Built node {} with role={:?}, default_action={:?}",
id.0,
node.role(),
node.default_action_verb(),
);
trace!("Built node {} with role={:?}", id.0, node.role());
}
ctx.tree_update.nodes.push((id, node));
}
@ -93,8 +87,8 @@ fn build_accessibility_tree(
}
// --- MARK: BUILD NODE ---
fn build_access_node(widget: &mut dyn Widget, ctx: &mut AccessCtx) -> NodeBuilder {
let mut node = NodeBuilder::new(widget.accessibility_role());
fn build_access_node(widget: &mut dyn Widget, ctx: &mut AccessCtx) -> Node {
let mut node = Node::new(widget.accessibility_role());
node.set_bounds(to_accesskit_rect(
ctx.widget_state.window_layout_rect(),
ctx.scale_factor,
@ -111,9 +105,6 @@ fn build_access_node(widget: &mut dyn Widget, ctx: &mut AccessCtx) -> NodeBuilde
// Note - These WidgetState flags can be modified by other passes.
// When that happens, the other pass should set flags to request an accessibility pass.
if ctx.is_hovered() {
node.set_hovered();
}
if ctx.is_disabled() {
node.set_disabled();
}

View File

@ -598,8 +598,6 @@ pub(crate) fn run_update_pointer_pass(root: &mut RenderRoot) {
if ctx.widget_state.is_hovered != is_hovered {
widget.update(ctx, &Update::HoveredChanged(is_hovered));
ctx.widget_state.request_accessibility = true;
ctx.widget_state.needs_accessibility = true;
}
ctx.widget_state.is_hovered = is_hovered;
});

View File

@ -34,7 +34,7 @@ use crate::passes::update::{
run_update_widget_tree_pass,
};
use crate::passes::{recurse_on_children, PassTracing};
use crate::text::TextBrush;
use crate::text::BrushIndex;
use crate::tree_arena::{ArenaMut, TreeArena};
use crate::widget::{WidgetArena, WidgetMut, WidgetRef, WidgetState};
use crate::{AccessEvent, Action, CursorIcon, Handled, QueryCtx, Widget, WidgetId, WidgetPod};
@ -71,7 +71,7 @@ pub(crate) struct RenderRootState {
pub(crate) pointer_capture_target: Option<WidgetId>,
pub(crate) cursor_icon: CursorIcon,
pub(crate) font_context: FontContext,
pub(crate) text_layout_context: LayoutContext<TextBrush>,
pub(crate) text_layout_context: LayoutContext<BrushIndex>,
pub(crate) mutate_callbacks: Vec<MutateCallback>,
pub(crate) is_ime_active: bool,
pub(crate) scenes: HashMap<WidgetId, Scene>,

View File

@ -10,8 +10,8 @@ use cursor_icon::CursorIcon;
use dpi::LogicalSize;
use image::{DynamicImage, ImageReader, Rgba, RgbaImage};
use tracing::debug;
use vello::util::RenderContext;
use vello::{block_on_wgpu, RendererOptions};
use vello::util::{block_on_wgpu, RenderContext};
use vello::RendererOptions;
use wgpu::{
BufferDescriptor, BufferUsages, CommandEncoderDescriptor, Extent3d, ImageCopyBuffer,
TextureDescriptor, TextureFormat, TextureUsages,

View File

@ -12,7 +12,7 @@ use std::cell::RefCell;
use std::collections::VecDeque;
use std::rc::Rc;
use accesskit::{NodeBuilder, Role};
use accesskit::{Node, Role};
use smallvec::SmallVec;
use tracing::trace_span;
use vello::Scene;
@ -36,7 +36,7 @@ pub type LayoutFn<S> = dyn FnMut(&mut S, &mut LayoutCtx, &BoxConstraints) -> Siz
pub type ComposeFn<S> = dyn FnMut(&mut S, &mut ComposeCtx);
pub type PaintFn<S> = dyn FnMut(&mut S, &mut PaintCtx, &mut Scene);
pub type RoleFn<S> = dyn Fn(&S) -> Role;
pub type AccessFn<S> = dyn FnMut(&mut S, &mut AccessCtx, &mut NodeBuilder);
pub type AccessFn<S> = dyn FnMut(&mut S, &mut AccessCtx, &mut Node);
pub type ChildrenFn<S> = dyn Fn(&S) -> SmallVec<[WidgetId; 16]>;
#[cfg(FALSE)]
@ -267,10 +267,7 @@ impl<S> ModularWidget<S> {
}
/// See [`Widget::accessibility`]
pub fn access_fn(
mut self,
f: impl FnMut(&mut S, &mut AccessCtx, &mut NodeBuilder) + 'static,
) -> Self {
pub fn access_fn(mut self, f: impl FnMut(&mut S, &mut AccessCtx, &mut Node) + 'static) -> Self {
self.access = Some(Box::new(f));
self
}
@ -349,7 +346,7 @@ impl<S: 'static> Widget for ModularWidget<S> {
}
}
fn accessibility(&mut self, ctx: &mut AccessCtx, node: &mut NodeBuilder) {
fn accessibility(&mut self, ctx: &mut AccessCtx, node: &mut Node) {
if let Some(f) = self.access.as_mut() {
f(&mut self.state, ctx, node);
}
@ -468,7 +465,7 @@ impl Widget for ReplaceChild {
Role::GenericContainer
}
fn accessibility(&mut self, _ctx: &mut AccessCtx, _node: &mut NodeBuilder) {}
fn accessibility(&mut self, _ctx: &mut AccessCtx, _node: &mut Node) {}
fn children_ids(&self) -> SmallVec<[WidgetId; 16]> {
todo!()
@ -560,7 +557,7 @@ impl<W: Widget> Widget for Recorder<W> {
self.child.accessibility_role()
}
fn accessibility(&mut self, ctx: &mut AccessCtx, node: &mut NodeBuilder) {
fn accessibility(&mut self, ctx: &mut AccessCtx, node: &mut Node) {
self.recording.push(Record::Access);
self.child.accessibility(ctx, node);
}

View File

@ -1,525 +0,0 @@
// Copyright 2018 the Xilem Authors and the Druid Authors
// SPDX-License-Identifier: Apache-2.0
//! Calc start of a backspace delete interval
// TODO: `expect` doesn't work here
#[allow(
clippy::wildcard_imports,
reason = "Mostly a wrapper around xi_unicode"
)]
use xi_unicode::*;
use crate::text::StringCursor;
/// Logic adapted from Android and
/// <https://github.com/xi-editor/xi-editor/pull/837>
/// See links present in that PR for upstream Android Source
/// Matches Android Logic as at 2024-05-10
#[allow(clippy::cognitive_complexity)]
fn backspace_offset(text: &str, start: usize) -> usize {
#[derive(PartialEq)]
enum State {
Start,
Lf,
BeforeKeycap,
BeforeVsAndKeycap,
BeforeEmojiModifier,
BeforeVsAndEmojiModifier,
BeforeVs,
BeforeEmoji,
BeforeZwj,
BeforeVsAndZwj,
OddNumberedRis,
EvenNumberedRis,
InTagSequence,
Finished,
}
let mut state = State::Start;
let mut delete_code_point_count = 0;
let mut last_seen_vs_code_point_count = 0;
let mut cursor = StringCursor {
text,
position: start,
};
assert!(
cursor.is_boundary(),
"Backspace must begin at a valid codepoint boundary."
);
while state != State::Finished && cursor.pos() > 0 {
let code_point = cursor.prev_codepoint().unwrap_or('0');
match state {
State::Start => {
delete_code_point_count = 1;
if code_point == '\n' {
state = State::Lf;
} else if is_variation_selector(code_point) {
state = State::BeforeVs;
} else if code_point.is_regional_indicator_symbol() {
state = State::OddNumberedRis;
} else if code_point.is_emoji_modifier() {
state = State::BeforeEmojiModifier;
} else if code_point.is_emoji_combining_enclosing_keycap() {
state = State::BeforeKeycap;
} else if code_point.is_emoji() {
state = State::BeforeEmoji;
} else if code_point.is_emoji_cancel_tag() {
state = State::InTagSequence;
} else {
state = State::Finished;
}
}
State::Lf => {
if code_point == '\r' {
delete_code_point_count += 1;
}
state = State::Finished;
}
State::OddNumberedRis => {
if code_point.is_regional_indicator_symbol() {
delete_code_point_count += 1;
state = State::EvenNumberedRis;
} else {
state = State::Finished;
}
}
State::EvenNumberedRis => {
if code_point.is_regional_indicator_symbol() {
delete_code_point_count -= 1;
state = State::OddNumberedRis;
} else {
state = State::Finished;
}
}
State::BeforeKeycap => {
if is_variation_selector(code_point) {
last_seen_vs_code_point_count = 1;
state = State::BeforeVsAndKeycap;
} else {
if is_keycap_base(code_point) {
delete_code_point_count += 1;
}
state = State::Finished;
}
}
State::BeforeVsAndKeycap => {
if is_keycap_base(code_point) {
delete_code_point_count += last_seen_vs_code_point_count + 1;
}
state = State::Finished;
}
State::BeforeEmojiModifier => {
if is_variation_selector(code_point) {
last_seen_vs_code_point_count = 1;
state = State::BeforeVsAndEmojiModifier;
} else if code_point.is_emoji_modifier_base() {
delete_code_point_count += 1;
state = State::BeforeEmoji;
} else {
state = State::Finished;
}
}
State::BeforeVsAndEmojiModifier => {
if code_point.is_emoji_modifier_base() {
delete_code_point_count += last_seen_vs_code_point_count + 1;
}
state = State::Finished;
}
State::BeforeVs => {
if code_point.is_emoji() {
delete_code_point_count += 1;
state = State::BeforeEmoji;
} else {
if !is_variation_selector(code_point) {
//TODO: UCharacter.getCombiningClass(codePoint) == 0
delete_code_point_count += 1;
}
state = State::Finished;
}
}
State::BeforeEmoji => {
if code_point.is_zwj() {
state = State::BeforeZwj;
} else {
state = State::Finished;
}
}
State::BeforeZwj => {
if code_point.is_emoji() {
delete_code_point_count += 2;
state = if code_point.is_emoji_modifier() {
State::BeforeEmojiModifier
} else {
State::BeforeEmoji
};
} else if is_variation_selector(code_point) {
last_seen_vs_code_point_count = 1;
state = State::BeforeVsAndZwj;
} else {
state = State::Finished;
}
}
State::BeforeVsAndZwj => {
if code_point.is_emoji() {
delete_code_point_count += last_seen_vs_code_point_count + 2;
last_seen_vs_code_point_count = 0;
state = State::BeforeEmoji;
} else {
state = State::Finished;
}
}
State::InTagSequence => {
if code_point.is_tag_spec_char() {
delete_code_point_count += 1;
} else if code_point.is_emoji() {
delete_code_point_count += 1;
state = State::Finished;
} else {
delete_code_point_count = 1;
state = State::Finished;
}
}
State::Finished => {
break;
}
}
}
cursor.set(start);
for _ in 0..delete_code_point_count {
let _ = cursor.prev_codepoint();
}
cursor.pos()
}
/// Calculate resulting offset for a backwards delete.
///
/// This involves complicated logic to handle various special cases that
/// are unique to backspace.
#[allow(clippy::trivially_copy_pass_by_ref)]
pub fn offset_for_delete_backwards(caret_position: usize, text: &impl AsRef<str>) -> usize {
backspace_offset(text.as_ref(), caret_position)
}
#[cfg(test)]
mod tests {
//! These tests originate from <https://github.com/xi-editor/xi-editor/pull/837>,
//! with the logic itself originating from upstream Android.
#[track_caller]
fn assert_delete_backwards(input: &'static str, target: &'static str) {
let result = super::offset_for_delete_backwards(input.len(), &input);
if result != target.len() {
panic!(
"Backspacing got {:?}, expected {:?}. Index: got {result}, expected {target}",
input.get(..result).unwrap_or("[INVALID RESULT INDEX]"),
target
);
}
}
#[track_caller]
fn assert_delete_backwards_seq(targets: &[&'static str]) {
let mut ran = false;
for val in targets.windows(2) {
ran = true;
assert_delete_backwards(val[0], val[1]);
}
if !ran {
panic!("Didn't execute");
}
}
#[test]
#[should_panic(expected = "Backspacing got \"\", expected \"1\"")]
fn assert_delete_backwards_invalid() {
assert_delete_backwards("1", "1");
}
#[test]
fn delete_combining_enclosing_keycaps() {
// Including variation selector-18
assert_delete_backwards("1\u{E0101}\u{20E3}", "");
// multiple COMBINING ENCLOSING KEYCAP
assert_delete_backwards_seq(&["1\u{20E3}\u{20E3}", "1\u{20E3}", ""]);
// Isolated multiple COMBINING ENCLOSING KEYCAP
assert_delete_backwards_seq(&["\u{20E3}\u{20E3}", "\u{20E3}", ""]);
}
#[test]
fn delete_variation_selector_tests() {
// Isolated variation selector
assert_delete_backwards("\u{FE0F}", "");
assert_delete_backwards("\u{E0100}", "");
// Isolated multiple variation selectors
assert_delete_backwards("\u{FE0F}\u{FE0F}", "\u{FE0F}");
assert_delete_backwards("\u{FE0F}\u{E0100}", "\u{FE0F}");
assert_delete_backwards("\u{E0100}\u{FE0F}", "\u{E0100}");
assert_delete_backwards("\u{E0100}\u{E0100}", "\u{E0100}");
// Multiple variation selectors
assert_delete_backwards("#\u{FE0F}\u{FE0F}", "#\u{FE0F}");
assert_delete_backwards("#\u{FE0F}\u{E0100}", "#\u{FE0F}");
assert_delete_backwards("#\u{FE0F}", "");
assert_delete_backwards("#\u{E0100}\u{FE0F}", "#\u{E0100}");
assert_delete_backwards("#\u{E0100}\u{E0100}", "#\u{E0100}");
assert_delete_backwards("#\u{E0100}", "");
}
#[test]
fn delete_emoji_zwj_sequence_tests() {
// U+200D is ZERO WIDTH JOINER
assert_delete_backwards("\u{1F441}\u{200D}\u{1F5E8}", ""); // 👁‍🗨
// U+FE0E is variation selector-15
assert_delete_backwards("\u{1F441}\u{200D}\u{1F5E8}\u{FE0E}", "");
// 👁‍🗨︎
assert_delete_backwards("\u{1F469}\u{200D}\u{1F373}", "");
// 👩‍🍳
assert_delete_backwards("\u{1F487}\u{200D}\u{2640}", "");
// 💇‍♀
assert_delete_backwards("\u{1F487}\u{200D}\u{2640}\u{FE0F}", "");
// 💇‍♀️
assert_delete_backwards(
"\u{1F468}\u{200D}\u{2764}\u{FE0F}\u{200D}\u{1F48B}\u{200D}\u{1F468}",
"",
);
// 👨‍❤️‍💋‍👨
// Emoji modifier can be appended to each emoji.
assert_delete_backwards("\u{1F469}\u{1F3FB}\u{200D}\u{1F4BC}", "");
// 👩🏻‍💼
assert_delete_backwards(
"\u{1F468}\u{1F3FF}\u{200D}\u{2764}\u{FE0F}\u{200D}\u{1F468}\u{1F3FB}",
"",
);
// 👨🏿‍❤️‍👨🏻
// End with ZERO WIDTH JOINER
assert_delete_backwards_seq(&["\u{1F441}\u{200D}", "\u{1F441}", ""]); // 👁‍
// Start with ZERO WIDTH JOINER
assert_delete_backwards_seq(&["\u{200D}\u{1F5E8}", "\u{200D}", ""]);
assert_delete_backwards_seq(&[
"\u{FE0E}\u{200D}\u{1F5E8}",
"\u{FE0E}\u{200D}",
"\u{FE0E}",
"",
]);
// Multiple ZERO WIDTH JOINER
assert_delete_backwards_seq(&[
"\u{1F441}\u{200D}\u{200D}\u{1F5E8}",
"\u{1F441}\u{200D}\u{200D}",
"\u{1F441}\u{200D}",
"\u{1F441}",
"",
]);
// Isolated multiple ZERO WIDTH JOINER
assert_delete_backwards_seq(&["\u{200D}\u{200D}", "\u{200D}", ""]);
}
#[test]
fn delete_flags_tests() {
// Isolated regional indicator symbol
assert_delete_backwards("\u{1F1FA}", "");
// Odd numbered regional indicator symbols
assert_delete_backwards_seq(&["\u{1F1FA}\u{1F1F8}\u{1F1FA}", "\u{1F1FA}\u{1F1F8}", ""]);
// Incomplete sequence. (no tag_term: U+E007E)
assert_delete_backwards_seq(&[
"a\u{1F3F4}\u{E0067}b",
"a\u{1F3F4}\u{E0067}",
"a\u{1F3F4}",
"a",
"",
]);
// No tag_base
assert_delete_backwards_seq(&[
"a\u{E0067}\u{E007F}b",
"a\u{E0067}\u{E007F}",
"a\u{E0067}",
"a",
"",
]);
// Isolated tag chars
assert_delete_backwards_seq(&[
"a\u{E0067}\u{E0067}b",
"a\u{E0067}\u{E0067}",
"a\u{E0067}",
"a",
"",
]);
// Isolated tab term.
assert_delete_backwards_seq(&[
"a\u{E007F}\u{E007F}b",
"a\u{E007F}\u{E007F}",
"a\u{E007F}",
"a",
"",
]);
// Immediate tag_term after tag_base
assert_delete_backwards_seq(&[
"a\u{1F3F4}\u{E007F}\u{1F3F4}\u{E007F}b",
"a\u{1F3F4}\u{E007F}\u{1F3F4}\u{E007F}",
"a\u{1F3F4}\u{E007F}",
"a",
"",
]);
}
#[test]
fn delete_emoji_modifier_tests() {
// U+1F3FB is EMOJI MODIFIER FITZPATRICK TYPE-1-2.
assert_delete_backwards_seq(&["\u{1F466}\u{1F3FB}", ""]);
// Isolated emoji modifier
assert_delete_backwards_seq(&["\u{1F3FB}", ""]);
// Isolated multiple emoji modifier
assert_delete_backwards_seq(&["\u{1F3FB}\u{1F3FB}", "\u{1F3FB}", ""]);
// Multiple emoji modifiers
assert_delete_backwards_seq(&["\u{1F466}\u{1F3FB}\u{1F3FB}", "\u{1F466}\u{1F3FB}", ""]);
}
#[test]
fn delete_mixed_edge_cases_tests() {
// COMBINING ENCLOSING KEYCAP + variation selector
assert_delete_backwards_seq(&["1\u{20E3}\u{FE0F}", "1", ""]);
// Variation selector + COMBINING ENCLOSING KEYCAP
assert_delete_backwards_seq(&["\u{2665}\u{FE0F}\u{20E3}", "\u{2665}\u{FE0F}", ""]);
// COMBINING ENCLOSING KEYCAP + ending with ZERO WIDTH JOINER
assert_delete_backwards_seq(&["1\u{20E3}\u{200D}", "1\u{20E3}", ""]);
// COMBINING ENCLOSING KEYCAP + ZERO WIDTH JOINER
assert_delete_backwards_seq(&[
"1\u{20E3}\u{200D}\u{1F5E8}",
"1\u{20E3}\u{200D}",
"1\u{20E3}",
"",
]);
// Start with ZERO WIDTH JOINER + COMBINING ENCLOSING KEYCAP
assert_delete_backwards_seq(&["\u{200D}\u{20E3}", "\u{200D}", ""]);
// ZERO WIDTH JOINER + COMBINING ENCLOSING KEYCAP
assert_delete_backwards_seq(&[
"\u{1F441}\u{200D}\u{20E3}",
"\u{1F441}\u{200D}",
"\u{1F441}",
"",
]);
// COMBINING ENCLOSING KEYCAP + regional indicator symbol
assert_delete_backwards_seq(&["1\u{20E3}\u{1F1FA}", "1\u{20E3}", ""]);
// Regional indicator symbol + COMBINING ENCLOSING KEYCAP
assert_delete_backwards_seq(&["\u{1F1FA}\u{20E3}", "\u{1F1FA}", ""]);
// COMBINING ENCLOSING KEYCAP + emoji modifier
assert_delete_backwards_seq(&["1\u{20E3}\u{1F3FB}", "1\u{20E3}", ""]);
// Emoji modifier + COMBINING ENCLOSING KEYCAP
assert_delete_backwards_seq(&["\u{1F466}\u{1F3FB}\u{20E3}", "\u{1F466}\u{1F3FB}", ""]);
// Variation selector + end with ZERO WIDTH JOINER
assert_delete_backwards_seq(&["\u{2665}\u{FE0F}\u{200D}", "\u{2665}\u{FE0F}", ""]);
// Variation selector + ZERO WIDTH JOINER
assert_delete_backwards("\u{1F469}\u{200D}\u{2764}\u{FE0F}\u{200D}\u{1F469}", "");
// Start with ZERO WIDTH JOINER + variation selector
assert_delete_backwards("\u{200D}\u{FE0F}", "");
// ZERO WIDTH JOINER + variation selector
assert_delete_backwards_seq(&["\u{1F469}\u{200D}\u{FE0F}", "\u{1F469}", ""]);
// Variation selector + regional indicator symbol
assert_delete_backwards_seq(&["\u{2665}\u{FE0F}\u{1F1FA}", "\u{2665}\u{FE0F}", ""]);
// Regional indicator symbol + variation selector
assert_delete_backwards("\u{1F1FA}\u{FE0F}", "");
// Variation selector + emoji modifier
assert_delete_backwards_seq(&["\u{2665}\u{FE0F}\u{1F3FB}", "\u{2665}\u{FE0F}", ""]);
// Emoji modifier + variation selector
assert_delete_backwards_seq(&["\u{1F466}\u{1F3FB}\u{FE0F}", "\u{1F466}", ""]);
// Start withj ZERO WIDTH JOINER + regional indicator symbol
assert_delete_backwards_seq(&["\u{200D}\u{1F1FA}", "\u{200D}", ""]);
// ZERO WIDTH JOINER + Regional indicator symbol
assert_delete_backwards_seq(&[
"\u{1F469}\u{200D}\u{1F1FA}",
"\u{1F469}\u{200D}",
"\u{1F469}",
"",
]);
// Regional indicator symbol + end with ZERO WIDTH JOINER
assert_delete_backwards_seq(&["\u{1F1FA}\u{200D}", "\u{1F1FA}", ""]);
// Regional indicator symbol + ZERO WIDTH JOINER
assert_delete_backwards("\u{1F1FA}\u{200D}\u{1F469}", "");
// Start with ZERO WIDTH JOINER + emoji modifier
assert_delete_backwards_seq(&["\u{200D}\u{1F3FB}", "\u{200D}", ""]);
// ZERO WIDTH JOINER + emoji modifier
assert_delete_backwards_seq(&[
"\u{1F469}\u{200D}\u{1F3FB}",
"\u{1F469}\u{200D}",
"\u{1F469}",
"",
]);
// Emoji modifier + end with ZERO WIDTH JOINER
assert_delete_backwards_seq(&["\u{1F466}\u{1F3FB}\u{200D}", "\u{1F466}\u{1F3FB}", ""]);
// Regional indicator symbol + Emoji modifier
assert_delete_backwards_seq(&["\u{1F1FA}\u{1F3FB}", "\u{1F1FA}", ""]);
// Emoji modifier + regional indicator symbol
assert_delete_backwards_seq(&["\u{1F466}\u{1F3FB}\u{1F1FA}", "\u{1F466}\u{1F3FB}", ""]);
// RIS + LF
assert_delete_backwards_seq(&["\u{1F1E6}\u{000A}", "\u{1F1E6}", ""]);
}
}

View File

@ -1,319 +0,0 @@
// Copyright 2018 the Xilem Authors and the Druid Authors
// SPDX-License-Identifier: Apache-2.0
use std::ops::{Deref, DerefMut, Range};
use parley::{FontContext, LayoutContext};
use tracing::warn;
use vello::kurbo::Point;
use vello::Scene;
use winit::event::Ime;
use winit::keyboard::{Key, NamedKey};
use crate::event::{PointerButton, PointerState};
use crate::text::selection::{Affinity, Selection};
use crate::text::{offset_for_delete_backwards, Selectable, TextBrush, TextWithSelection};
use crate::{Action, EventCtx, Handled, TextEvent};
/// A region of text which can support editing operations
pub struct TextEditor {
inner: TextWithSelection<String>,
/// The range of the preedit region in the text
preedit_range: Option<Range<usize>>,
}
impl TextEditor {
pub fn new(text: String, text_size: f32) -> Self {
Self {
inner: TextWithSelection::new(text, text_size),
preedit_range: None,
}
}
pub fn reset_preedit(&mut self) {
self.preedit_range = None;
}
/// Rebuild the text.
///
/// See also [`TextLayout::rebuild`](crate::text::TextLayout::rebuild) for more comprehensive docs.
pub fn rebuild(
&mut self,
font_ctx: &mut FontContext,
layout_ctx: &mut LayoutContext<TextBrush>,
) {
self.inner
.rebuild_with_attributes(font_ctx, layout_ctx, |mut builder| {
if let Some(range) = self.preedit_range.as_ref() {
builder.push(
&parley::style::StyleProperty::Underline(true),
range.clone(),
);
}
builder
});
}
pub fn draw(&mut self, scene: &mut Scene, point: impl Into<Point>) {
self.inner.draw(scene, point);
}
pub fn pointer_down(
&mut self,
origin: Point,
state: &PointerState,
button: PointerButton,
) -> bool {
// TODO: If we have a selection and we're hovering over it,
// implement (optional?) click and drag
self.inner.pointer_down(origin, state, button)
}
pub fn text_event(&mut self, ctx: &mut EventCtx, event: &TextEvent) -> Handled {
let inner_handled = self.inner.text_event(event);
if inner_handled.is_handled() {
return inner_handled;
}
match event {
TextEvent::KeyboardKey(event, mods) if event.state.is_pressed() => {
// We don't input actual text when these keys are pressed
if !(mods.control_key() || mods.alt_key() || mods.super_key()) {
match &event.logical_key {
Key::Named(NamedKey::Backspace) => {
let selection = self.inner.selection;
if !selection.is_caret() {
self.text_mut().replace_range(selection.range(), "");
self.inner.selection =
Selection::caret(selection.min(), Affinity::Upstream);
let contents = self.text().clone();
ctx.submit_action(Action::TextChanged(contents));
} else {
// TODO: more specific behavior may sometimes be warranted here
// because whole EGCs are more coarse than what people expect
// to be able to delete individual indic grapheme cluster
// components among other things.
let text = self.text_mut();
let offset = offset_for_delete_backwards(selection.active, text);
self.text_mut().replace_range(offset..selection.active, "");
self.inner.selection =
Selection::caret(offset, selection.active_affinity);
let contents = self.text().clone();
ctx.submit_action(Action::TextChanged(contents));
}
Handled::Yes
}
Key::Named(NamedKey::Delete) => {
let selection = self.inner.selection;
if !selection.is_caret() {
self.text_mut().replace_range(selection.range(), "");
self.inner.selection =
Selection::caret(selection.min(), Affinity::Downstream);
let contents = self.text().clone();
ctx.submit_action(Action::TextChanged(contents));
} else if let Some(offset) =
self.text().next_grapheme_offset(selection.active)
{
self.text_mut().replace_range(selection.min()..offset, "");
self.inner.selection =
Selection::caret(selection.min(), selection.active_affinity);
let contents = self.text().clone();
ctx.submit_action(Action::TextChanged(contents));
}
Handled::Yes
}
Key::Named(NamedKey::Space) => {
let selection = self.inner.selection;
let c = ' ';
self.text_mut()
.replace_range(selection.range(), &c.to_string());
self.inner.selection = Selection::caret(
selection.min() + c.len_utf8(),
// We have just added this character, so we are "affined" with it
Affinity::Downstream,
);
let contents = self.text().clone();
ctx.submit_action(Action::TextChanged(contents));
Handled::Yes
}
Key::Named(NamedKey::Enter) => {
let contents = self.text().clone();
ctx.submit_action(Action::TextEntered(contents));
Handled::Yes
}
Key::Named(_) => Handled::No,
Key::Character(c) => {
self.insert_text(event.text.as_ref().unwrap_or(c), ctx)
}
Key::Unidentified(_) => match event.text.as_ref() {
Some(text) => self.insert_text(text, ctx),
None => Handled::No,
},
Key::Dead(d) => {
warn!("Got dead key {d:?}. Will handle");
Handled::No
}
}
} else if mods.control_key() || mods.super_key()
// TODO: do things differently on mac, rather than capturing both super and control.
{
match &event.logical_key {
Key::Named(NamedKey::Backspace) => {
let selection = self.inner.selection;
if !selection.is_caret() {
self.text_mut().replace_range(selection.range(), "");
self.inner.selection =
Selection::caret(selection.min(), Affinity::Upstream);
}
let offset =
self.text().prev_word_offset(selection.active).unwrap_or(0);
self.text_mut().replace_range(offset..selection.active, "");
self.inner.selection = Selection::caret(offset, Affinity::Upstream);
let contents = self.text().clone();
ctx.submit_action(Action::TextChanged(contents));
Handled::Yes
}
Key::Named(NamedKey::Delete) => {
let selection = self.inner.selection;
if !selection.is_caret() {
self.text_mut().replace_range(selection.range(), "");
self.inner.selection =
Selection::caret(selection.min(), Affinity::Downstream);
} else if let Some(offset) =
self.text().next_word_offset(selection.active)
{
self.text_mut().replace_range(selection.active..offset, "");
self.inner.selection =
Selection::caret(selection.min(), Affinity::Upstream);
}
let contents = self.text().clone();
ctx.submit_action(Action::TextChanged(contents));
Handled::Yes
}
_ => Handled::No,
}
} else {
Handled::No
}
}
TextEvent::KeyboardKey(_, _) => Handled::No,
TextEvent::Ime(ime) => match ime {
Ime::Commit(text) => {
let selection_range = self.selection.range();
self.text_mut().replace_range(selection_range.clone(), text);
self.selection =
Selection::caret(selection_range.start + text.len(), Affinity::Upstream);
let contents = self.text().clone();
ctx.submit_action(Action::TextChanged(contents));
Handled::Yes
}
Ime::Preedit(preedit_string, preedit_sel) => {
if let Some(preedit) = self.preedit_range.clone() {
// TODO: Handle the case where this is the same value, to avoid some potential infinite loops
self.text_mut()
.replace_range(preedit.clone(), preedit_string);
let np = preedit.start..(preedit.start + preedit_string.len());
self.preedit_range = if preedit_string.is_empty() {
None
} else {
Some(np.clone())
};
self.selection = if let Some(pec) = preedit_sel {
Selection::new(np.start + pec.0, np.start + pec.1, Affinity::Upstream)
} else {
Selection::caret(np.end, Affinity::Upstream)
};
} else {
// If we've been sent an event to clear the preedit,
// but there was no existing pre-edit, there's nothing to do
// so we report that the event has been handled
// An empty preedit is sent by some environments when the
// context of a text input has changed, even if the contents
// haven't; this also avoids some potential infinite loops
if preedit_string.is_empty() {
return Handled::Yes;
}
let sr = self.selection.range();
self.text_mut().replace_range(sr.clone(), preedit_string);
let np = sr.start..(sr.start + preedit_string.len());
self.preedit_range = if preedit_string.is_empty() {
None
} else {
Some(np.clone())
};
self.selection = if let Some(pec) = preedit_sel {
Selection::new(np.start + pec.0, np.start + pec.1, Affinity::Upstream)
} else {
Selection::caret(np.start, Affinity::Upstream)
};
}
Handled::Yes
}
Ime::Enabled => {
// Generally this shouldn't happen, but I can't prove it won't.
if let Some(preedit) = self.preedit_range.clone() {
self.text_mut().replace_range(preedit.clone(), "");
self.preedit_range = None;
}
Handled::Yes
}
Ime::Disabled => {
if let Some(preedit) = self.preedit_range.clone() {
self.text_mut().replace_range(preedit.clone(), "");
self.preedit_range = None;
let sm = self.selection.min();
if preedit.contains(&sm) {
self.selection = Selection::caret(preedit.start, Affinity::Upstream);
}
}
Handled::Yes
}
},
TextEvent::ModifierChange(_) => Handled::No,
TextEvent::FocusChange(_) => Handled::No,
}
}
fn insert_text(&mut self, c: &winit::keyboard::SmolStr, ctx: &mut EventCtx) -> Handled {
let selection = self.inner.selection;
self.text_mut().replace_range(selection.range(), c);
self.inner.selection = Selection::caret(
selection.min() + c.len(),
// We have just added this character, so we are "affined" with it
Affinity::Downstream,
);
let contents = self.text().clone();
ctx.submit_action(Action::TextChanged(contents));
Handled::Yes
}
}
impl Deref for TextEditor {
type Target = TextWithSelection<String>;
fn deref(&self) -> &Self::Target {
&self.inner
}
}
// TODO: Being able to call `Self::Target::rebuild` (and `draw`) isn't great.
impl DerefMut for TextEditor {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.inner
}
}
#[cfg(test)]
mod tests {
#[test]
fn replace() {
let mut a = String::from("hello world");
a.replace_range(1..9, "era");
assert_eq!("herald", a);
}
}

672
masonry/src/text/editor.rs Normal file
View File

@ -0,0 +1,672 @@
// Copyright 2020 the Xilem Authors and the Parley Authors
// SPDX-License-Identifier: Apache-2.0
// We need to be careful with contributions to this file, as we want them to get back to Parley.
//! Import of Parley's `PlainEditor` as the version in Parley is insufficient for our needs.
use core::{cmp::PartialEq, default::Default, fmt::Debug};
use accesskit::{Node, NodeId, TreeUpdate};
use parley::layout::LayoutAccessibility;
use parley::{
layout::{
cursor::{Cursor, Selection, VisualMode},
Affinity, Alignment, Layout, Line,
},
style::{Brush, StyleProperty},
FontContext, LayoutContext, Rect,
};
use std::{borrow::ToOwned, string::String, sync::Arc, vec::Vec};
#[derive(Copy, Clone, Debug)]
pub enum ActiveText<'a> {
/// The selection is empty and the cursor is a caret; this is the text of the cluster it is on.
FocusedCluster(Affinity, &'a str),
/// The selection contains this text.
Selection(&'a str),
}
/// Opaque representation of a generation.
///
/// Obtained from [`PlainEditor::generation`].
// Overflow handling: the generations are only compared,
// so wrapping is fine. This could only fail if exactly
// `u32::MAX` generations happen between drawing
// operations. This is implausible and so can be ignored.
#[derive(PartialEq, Eq, Default, Clone, Copy)]
pub struct Generation(u32);
impl Generation {
/// Make it not what it currently is.
pub(crate) fn nudge(&mut self) {
self.0 = self.0.wrapping_add(1);
}
}
/// Basic plain text editor with a single default style.
#[derive(Clone)]
pub struct PlainEditor<T>
where
T: Brush + Clone + Debug + PartialEq + Default,
{
default_style: Arc<[StyleProperty<'static, T>]>,
buffer: String,
layout: Layout<T>,
layout_access: LayoutAccessibility,
selection: Selection,
cursor_mode: VisualMode,
width: Option<f32>,
scale: f32,
// Simple tracking of when the layout needs to be updated
// before it can be used for `Selection` calculations or
// for drawing.
// Not all operations on `PlainEditor` need to operate on a
// clean layout, and not all operations trigger a layout.
layout_dirty: bool,
// TODO: We could avoid redoing the full text layout if linebreaking or
// alignment were unchanged
// linebreak_dirty: bool,
// alignment_dirty: bool,
alignment: Alignment,
generation: Generation,
}
// TODO: When MSRV >= 1.80 we can remove this. Default was not implemented for Arc<[T]> where T: !Default until 1.80
impl<T> Default for PlainEditor<T>
where
T: Brush + Clone + Debug + PartialEq + Default,
{
fn default() -> Self {
Self {
default_style: Arc::new([]),
buffer: Default::default(),
layout: Default::default(),
layout_access: Default::default(),
selection: Default::default(),
cursor_mode: Default::default(),
width: Default::default(),
scale: 1.0,
layout_dirty: Default::default(),
alignment: Alignment::Start,
// We don't use the `default` value to start with, as our consumers
// will choose to use that as their initial value, but will probably need
// to redraw if they haven't already.
generation: Generation(1),
}
}
}
/// The argument passed to the callback of [`PlainEditor::transact`],
/// on which the caller performs operations.
pub struct PlainEditorTxn<'a, T>
where
T: Brush + Clone + Debug + PartialEq + Default,
{
editor: &'a mut PlainEditor<T>,
font_cx: &'a mut FontContext,
layout_cx: &'a mut LayoutContext<T>,
}
impl<T> PlainEditorTxn<'_, T>
where
T: Brush + Clone + Debug + PartialEq + Default,
{
/// Replace the whole text buffer.
pub fn set_text(&mut self, is: &str) {
self.editor.buffer.clear();
self.editor.buffer.push_str(is);
self.editor.layout_dirty = true;
}
/// Set the width of the layout.
pub fn set_width(&mut self, width: Option<f32>) {
self.editor.width = width;
self.editor.layout_dirty = true;
}
/// Set the alignment of the layout.
pub fn set_alignment(&mut self, alignment: Alignment) {
self.editor.alignment = alignment;
self.editor.layout_dirty = true;
}
/// Set the scale for the layout.
pub fn set_scale(&mut self, scale: f32) {
self.editor.scale = scale;
self.editor.layout_dirty = true;
}
/// Set the default style for the layout.
pub fn set_default_style(&mut self, style: Arc<[StyleProperty<'static, T>]>) {
self.editor.default_style = style;
self.editor.layout_dirty = true;
}
/// Insert at cursor, or replace selection.
pub fn insert_or_replace_selection(&mut self, s: &str) {
self.editor
.replace_selection(self.font_cx, self.layout_cx, s);
}
/// Delete the selection.
pub fn delete_selection(&mut self) {
self.insert_or_replace_selection("");
}
/// Delete the selection or the next cluster (typical delete behavior).
pub fn delete(&mut self) {
if self.editor.selection.is_collapsed() {
let range = self.editor.selection.focus().text_range();
if !range.is_empty() {
let start = range.start;
self.editor.buffer.replace_range(range, "");
self.update_layout();
self.editor
.set_selection(self.editor.cursor_at(start).into());
}
} else {
self.delete_selection();
}
}
/// Delete the selection or up to the next word boundary (typical ctrl + delete behavior).
pub fn delete_word(&mut self) {
let start = self.editor.selection.focus().text_range().start;
if self.editor.selection.is_collapsed() {
let end = self
.editor
.cursor_at(start)
.next_word(&self.editor.layout)
.index();
self.editor.buffer.replace_range(start..end, "");
self.update_layout();
self.editor
.set_selection(self.editor.cursor_at(start).into());
} else {
self.delete_selection();
}
}
/// Delete the selection or the previous cluster (typical backspace behavior).
pub fn backdelete(&mut self) {
let end = self.editor.selection.focus().text_range().start;
if self.editor.selection.is_collapsed() {
if let Some(start) = self
.editor
.selection
.focus()
.cluster_path()
.cluster(&self.editor.layout)
.map(|x| {
if self.editor.selection.focus().affinity() == Affinity::Upstream {
Some(x)
} else {
x.previous_logical()
}
})
.and_then(|c| c.map(|x| x.text_range().start))
{
self.editor.buffer.replace_range(start..end, "");
self.update_layout();
self.editor
.set_selection(self.editor.cursor_at(start).into());
}
} else {
self.delete_selection();
}
}
/// Delete the selection or back to the previous word boundary (typical ctrl + backspace behavior).
pub fn backdelete_word(&mut self) {
let end = self.editor.selection.focus().text_range().start;
if self.editor.selection.is_collapsed() {
let start = self
.editor
.selection
.focus()
.previous_word(&self.editor.layout)
.text_range()
.start;
self.editor.buffer.replace_range(start..end, "");
self.update_layout();
self.editor
.set_selection(self.editor.cursor_at(start).into());
} else {
self.delete_selection();
}
}
/// Move the cursor to the cluster boundary nearest this point in the layout.
pub fn move_to_point(&mut self, x: f32, y: f32) {
self.refresh_layout();
self.editor
.set_selection(Selection::from_point(&self.editor.layout, x, y));
}
/// Move the cursor to a byte index.
///
/// No-op if index is not a char boundary.
pub fn move_to_byte(&mut self, index: usize) {
if self.editor.buffer.is_char_boundary(index) {
self.refresh_layout();
self.editor
.set_selection(self.editor.cursor_at(index).into());
}
}
/// Move the cursor to the start of the buffer.
pub fn move_to_text_start(&mut self) {
self.editor.set_selection(self.editor.selection.move_lines(
&self.editor.layout,
isize::MIN,
false,
));
}
/// Move the cursor to the start of the physical line.
pub fn move_to_line_start(&mut self) {
self.editor
.set_selection(self.editor.selection.line_start(&self.editor.layout, false));
}
/// Move the cursor to the end of the buffer.
pub fn move_to_text_end(&mut self) {
self.editor.set_selection(self.editor.selection.move_lines(
&self.editor.layout,
isize::MAX,
false,
));
}
/// Move the cursor to the end of the physical line.
pub fn move_to_line_end(&mut self) {
self.editor
.set_selection(self.editor.selection.line_end(&self.editor.layout, false));
}
/// Move up to the closest physical cluster boundary on the previous line, preserving the horizontal position for repeated movements.
pub fn move_up(&mut self) {
self.editor.set_selection(
self.editor
.selection
.previous_line(&self.editor.layout, false),
);
}
/// Move down to the closest physical cluster boundary on the next line, preserving the horizontal position for repeated movements.
pub fn move_down(&mut self) {
self.editor
.set_selection(self.editor.selection.next_line(&self.editor.layout, false));
}
/// Move to the next cluster left in visual order.
pub fn move_left(&mut self) {
self.editor
.set_selection(self.editor.selection.previous_visual(
&self.editor.layout,
self.editor.cursor_mode,
false,
));
}
/// Move to the next cluster right in visual order.
pub fn move_right(&mut self) {
self.editor.set_selection(self.editor.selection.next_visual(
&self.editor.layout,
self.editor.cursor_mode,
false,
));
}
/// Move to the next word boundary left.
pub fn move_word_left(&mut self) {
self.editor.set_selection(
self.editor
.selection
.previous_word(&self.editor.layout, false),
);
}
/// Move to the next word boundary right.
pub fn move_word_right(&mut self) {
self.editor
.set_selection(self.editor.selection.next_word(&self.editor.layout, false));
}
/// Select the whole buffer.
pub fn select_all(&mut self) {
self.editor.set_selection(
Selection::from_index(&self.editor.layout, 0, Affinity::default()).move_lines(
&self.editor.layout,
isize::MAX,
true,
),
);
}
/// Collapse selection into caret.
pub fn collapse_selection(&mut self) {
self.editor.set_selection(self.editor.selection.collapse());
}
/// Move the selection focus point to the start of the buffer.
pub fn select_to_text_start(&mut self) {
self.editor.set_selection(self.editor.selection.move_lines(
&self.editor.layout,
isize::MIN,
true,
));
}
/// Move the selection focus point to the start of the physical line.
pub fn select_to_line_start(&mut self) {
self.editor
.set_selection(self.editor.selection.line_start(&self.editor.layout, true));
}
/// Move the selection focus point to the end of the buffer.
pub fn select_to_text_end(&mut self) {
self.editor.set_selection(self.editor.selection.move_lines(
&self.editor.layout,
isize::MAX,
true,
));
}
/// Move the selection focus point to the end of the physical line.
pub fn select_to_line_end(&mut self) {
self.editor
.set_selection(self.editor.selection.line_end(&self.editor.layout, true));
}
/// Move the selection focus point up to the nearest cluster boundary on the previous line, preserving the horizontal position for repeated movements.
pub fn select_up(&mut self) {
self.editor.set_selection(
self.editor
.selection
.previous_line(&self.editor.layout, true),
);
}
/// Move the selection focus point down to the nearest cluster boundary on the next line, preserving the horizontal position for repeated movements.
pub fn select_down(&mut self) {
self.editor
.set_selection(self.editor.selection.next_line(&self.editor.layout, true));
}
/// Move the selection focus point to the next cluster left in visual order.
pub fn select_left(&mut self) {
self.editor
.set_selection(self.editor.selection.previous_visual(
&self.editor.layout,
self.editor.cursor_mode,
true,
));
}
/// Move the selection focus point to the next cluster right in visual order.
pub fn select_right(&mut self) {
self.editor.set_selection(self.editor.selection.next_visual(
&self.editor.layout,
self.editor.cursor_mode,
true,
));
}
/// Move the selection focus point to the next word boundary left.
pub fn select_word_left(&mut self) {
self.editor.set_selection(
self.editor
.selection
.previous_word(&self.editor.layout, true),
);
}
/// Move the selection focus point to the next word boundary right.
pub fn select_word_right(&mut self) {
self.editor
.set_selection(self.editor.selection.next_word(&self.editor.layout, true));
}
/// Select the word at the point.
pub fn select_word_at_point(&mut self, x: f32, y: f32) {
self.refresh_layout();
self.editor
.set_selection(Selection::word_from_point(&self.editor.layout, x, y));
}
/// Select the physical line at the point.
pub fn select_line_at_point(&mut self, x: f32, y: f32) {
self.refresh_layout();
let focus = *Selection::from_point(&self.editor.layout, x, y)
.line_start(&self.editor.layout, true)
.focus();
self.editor
.set_selection(Selection::from(focus).line_end(&self.editor.layout, true));
}
/// Move the selection focus point to the cluster boundary closest to point.
pub fn extend_selection_to_point(&mut self, x: f32, y: f32) {
self.refresh_layout();
// FIXME: This is usually the wrong way to handle selection extension for mouse moves, but not a regression.
self.editor.set_selection(
self.editor
.selection
.extend_to_point(&self.editor.layout, x, y),
);
}
/// Move the selection focus point to a byte index.
///
/// No-op if index is not a char boundary.
pub fn extend_selection_to_byte(&mut self, index: usize) {
if self.editor.buffer.is_char_boundary(index) {
self.refresh_layout();
self.editor.set_selection(
self.editor
.selection
.maybe_extend(self.editor.cursor_at(index), true),
);
}
}
/// Select a range of byte indices
///
/// No-op if either index is not a char boundary.
pub fn select_byte_range(&mut self, start: usize, end: usize) {
if self.editor.buffer.is_char_boundary(start) && self.editor.buffer.is_char_boundary(end) {
self.refresh_layout();
self.editor.set_selection(
Selection::from(self.editor.cursor_at(start))
.maybe_extend(self.editor.cursor_at(end), true),
);
}
}
pub fn select_from_accesskit(&mut self, selection: &accesskit::TextSelection) {
self.refresh_layout();
if let Some(selection) = Selection::from_access_selection(
selection,
&self.editor.layout,
&self.editor.layout_access,
) {
self.editor.set_selection(selection);
}
}
fn update_layout(&mut self) {
self.editor.update_layout(self.font_cx, self.layout_cx);
}
fn refresh_layout(&mut self) {
self.editor.refresh_layout(self.font_cx, self.layout_cx);
}
}
impl<T> PlainEditor<T>
where
T: Brush + Clone + Debug + PartialEq + Default,
{
/// Run a series of [`PlainEditorTxn`] methods, updating the layout
/// if necessary.
pub fn transact<R>(
&mut self,
font_cx: &mut FontContext,
layout_cx: &mut LayoutContext<T>,
callback: impl FnOnce(&mut PlainEditorTxn<'_, T>) -> R,
) -> R {
let mut txn = PlainEditorTxn {
editor: self,
font_cx,
layout_cx,
};
let ret = callback(&mut txn);
txn.update_layout();
ret
}
/// Make a cursor at a given byte index
fn cursor_at(&self, index: usize) -> Cursor {
// FIXME: `Selection` should make this easier
if index >= self.buffer.len() {
Cursor::from_index(
&self.layout,
self.buffer.len().saturating_sub(1),
Affinity::Upstream,
)
} else {
Cursor::from_index(&self.layout, index, Affinity::Downstream)
}
}
fn replace_selection(
&mut self,
font_cx: &mut FontContext,
layout_cx: &mut LayoutContext<T>,
s: &str,
) {
let range = self.selection.text_range();
let start = range.start;
if self.selection.is_collapsed() {
self.buffer.insert_str(start, s);
} else {
self.buffer.replace_range(range, s);
}
self.update_layout(font_cx, layout_cx);
self.set_selection(self.cursor_at(start.saturating_add(s.len())).into());
}
/// Update the selection, and nudge the `Generation` if something other than `h_pos` changed.
fn set_selection(&mut self, new_sel: Selection) {
if new_sel.focus() != self.selection.focus() || new_sel.anchor() != self.selection.anchor()
{
self.generation.nudge();
}
self.selection = new_sel;
}
/// Get either the contents of the current selection, or the text of the cluster at the caret.
pub fn active_text(&self) -> ActiveText {
if self.selection.is_collapsed() {
let range = self
.selection
.focus()
.cluster_path()
.cluster(&self.layout)
.map(|c| c.text_range())
.unwrap_or_default();
ActiveText::FocusedCluster(self.selection.focus().affinity(), &self.buffer[range])
} else {
ActiveText::Selection(&self.buffer[self.selection.text_range()])
}
}
/// Get rectangles representing the selected portions of text.
pub fn selection_geometry(&self) -> Vec<Rect> {
self.selection.geometry(&self.layout)
}
/// Get a rectangle representing the current caret cursor position.
pub fn selection_strong_geometry(&self, size: f32) -> Option<Rect> {
self.selection.focus().strong_geometry(&self.layout, size)
}
pub fn selection_weak_geometry(&self, size: f32) -> Option<Rect> {
self.selection.focus().weak_geometry(&self.layout, size)
}
/// Get the lines from the `Layout`.
pub fn lines(&self) -> impl Iterator<Item = Line<T>> + '_ + Clone {
self.layout.lines()
}
/// Borrow the text content of the buffer.
pub fn text(&self) -> &str {
&self.buffer
}
/// Get the current `Generation` of the layout, to decide whether to draw.
///
/// You should store the generation the editor was at when you last drew it, and then redraw
/// when the generation is different (`Generation` is [`PartialEq`], so supports the equality `==` operation).
pub fn generation(&self) -> Generation {
self.generation
}
/// Get the full read-only details from the layout.
pub fn layout(&self) -> &Layout<T> {
&self.layout
}
/// Update the layout if it is dirty.
fn refresh_layout(&mut self, font_cx: &mut FontContext, layout_cx: &mut LayoutContext<T>) {
if self.layout_dirty {
self.update_layout(font_cx, layout_cx);
}
}
/// Update the layout.
fn update_layout(&mut self, font_cx: &mut FontContext, layout_cx: &mut LayoutContext<T>) {
let mut builder = layout_cx.ranged_builder(font_cx, &self.buffer, self.scale);
for prop in self.default_style.iter() {
builder.push_default(prop.to_owned());
}
builder.build_into(&mut self.layout, &self.buffer);
self.layout.break_all_lines(self.width);
self.layout.align(self.width, self.alignment);
self.selection = self.selection.refresh(&self.layout);
self.layout_dirty = false;
self.generation.nudge();
}
pub fn accessibility(
&mut self,
update: &mut TreeUpdate,
node: &mut Node,
next_node_id: impl FnMut() -> NodeId,
x_offset: f64,
y_offset: f64,
) {
self.layout_access.build_nodes(
&self.buffer,
&self.layout,
update,
node,
next_node_id,
x_offset,
y_offset,
);
if let Some(selection) = self
.selection
.to_access_selection(&self.layout, &self.layout_access)
{
node.set_text_selection(selection);
}
node.add_action(accesskit::Action::SetTextSelection);
}
}

View File

@ -10,20 +10,49 @@
//!
//! All of these have the same set of global styling options, and can contain rich text
mod backspace;
mod edit;
mod editor;
mod render_text;
mod selection;
mod text_layout;
pub use backspace::offset_for_delete_backwards;
pub use edit::TextEditor;
use std::{collections::HashMap, mem::Discriminant};
pub use editor::{ActiveText, Generation, PlainEditor, PlainEditorTxn};
pub use render_text::render_text;
pub use selection::{len_utf8_from_first_byte, Selectable, StringCursor, TextWithSelection};
pub use text_layout::{Hinting, LayoutMetrics, TextBrush, TextLayout};
/// A reference counted string slice.
///
/// This is a data-friendly way to represent strings in Masonry. Unlike `String`
/// it cannot be mutated, but unlike `String` it can be cheaply cloned.
pub type ArcStr = std::sync::Arc<str>;
#[derive(Clone, PartialEq, Default, Debug)]
pub struct BrushIndex(pub usize);
pub type StyleProperty = parley::StyleProperty<'static, BrushIndex>;
/// A set of Parley styles.
pub struct StyleSet(HashMap<Discriminant<StyleProperty>, StyleProperty>);
impl StyleSet {
pub fn new(font_size: f32) -> Self {
let mut this = Self(Default::default());
this.insert(StyleProperty::FontSize(font_size));
this
}
pub fn insert(&mut self, style: StyleProperty) -> Option<StyleProperty> {
let discriminant = std::mem::discriminant(&style);
self.0.insert(discriminant, style)
}
pub fn retain(&mut self, mut f: impl FnMut(&StyleProperty) -> bool) {
self.0.retain(|_, v| f(v));
}
pub fn remove(&mut self, property: Discriminant<StyleProperty>) -> Option<StyleProperty> {
self.0.remove(&property)
}
pub fn inner(&self) -> &HashMap<Discriminant<StyleProperty>, StyleProperty> {
&self.0
}
}

View File

@ -3,24 +3,27 @@
//! Helper functions for working with text in Masonry.
use parley::Layout;
use vello::kurbo::{Affine, Line, Rect, Stroke};
use vello::peniko::Fill;
use parley::{Layout, PositionedLayoutItem};
use vello::kurbo::Affine;
use vello::peniko::{Brush, Fill};
use vello::Scene;
use crate::text::TextBrush;
use super::BrushIndex;
/// A function that renders laid out glyphs to a [`Scene`].
pub fn render_text(
scene: &mut Scene,
scratch_scene: &mut Scene,
transform: Affine,
layout: &Layout<TextBrush>,
layout: &Layout<BrushIndex>,
brushes: &[Brush],
// TODO: Should this be part of `BrushIndex`?
hint: bool,
) {
scratch_scene.reset();
for line in layout.lines() {
let metrics = &line.metrics();
for glyph_run in line.glyph_runs() {
for item in line.items() {
let PositionedLayoutItem::GlyphRun(glyph_run) = item else {
continue;
};
let mut x = glyph_run.offset();
let y = glyph_run.baseline();
let run = glyph_run.run();
@ -30,42 +33,16 @@ pub fn render_text(
let glyph_xform = synthesis
.skew()
.map(|angle| Affine::skew(angle.to_radians().tan() as f64, 0.0));
let style = glyph_run.style();
let coords = run
.normalized_coords()
.iter()
.map(|coord| vello::skrifa::instance::NormalizedCoord::from_bits(*coord))
.collect::<Vec<_>>();
let (text_brush, hinting) = match &style.brush {
TextBrush::Normal(text_brush, hinting) => (text_brush, hinting),
TextBrush::Highlight {
text,
fill,
hinting,
} => {
scene.fill(
Fill::EvenOdd,
transform,
fill,
None,
&Rect::from_origin_size(
(
glyph_run.offset() as f64,
// The y coordinate is on the baseline. We want to draw from the top of the line
// (Note that we are in a y-down coordinate system)
(y - metrics.ascent - metrics.leading) as f64,
),
(glyph_run.advance() as f64, metrics.size() as f64),
),
);
(text, hinting)
}
};
scratch_scene
let brush = &brushes[glyph_run.style().brush.0];
scene
.draw_glyphs(font)
.brush(text_brush)
.hint(hinting.should_hint())
.brush(brush)
.hint(hint)
.transform(transform)
.glyph_transform(glyph_xform)
.font_size(font_size)
@ -76,82 +53,13 @@ pub fn render_text(
let gx = x + glyph.x;
let gy = y - glyph.y;
x += glyph.advance;
vello::glyph::Glyph {
vello::Glyph {
id: glyph.id as _,
x: gx,
y: gy,
}
}),
);
if let Some(underline) = &style.underline {
let underline_brush = match &underline.brush {
// Underlines aren't hinted
TextBrush::Normal(text, _) => text,
// It doesn't make sense for an underline to have a highlight colour, so we
// just use the text colour for the colour
TextBrush::Highlight { text, .. } => text,
};
let run_metrics = glyph_run.run().metrics();
let offset = match underline.offset {
Some(offset) => offset,
None => run_metrics.underline_offset,
};
let width = match underline.size {
Some(size) => size,
None => run_metrics.underline_size,
};
// The `offset` is the distance from the baseline to the *top* of the underline
// so we move the line down by half the width
// Remember that we are using a y-down coordinate system
let y = glyph_run.baseline() - offset + width / 2.;
let line = Line::new(
(glyph_run.offset() as f64, y as f64),
((glyph_run.offset() + glyph_run.advance()) as f64, y as f64),
);
scratch_scene.stroke(
&Stroke::new(width.into()),
transform,
underline_brush,
None,
&line,
);
}
if let Some(strikethrough) = &style.strikethrough {
let strikethrough_brush = match &strikethrough.brush {
// Strikethroughs aren't hinted
TextBrush::Normal(text, _) => text,
// It doesn't make sense for an underline to have a highlight colour, so we
// just use the text colour for the colour
TextBrush::Highlight { text, .. } => text,
};
let run_metrics = glyph_run.run().metrics();
let offset = match strikethrough.offset {
Some(offset) => offset,
None => run_metrics.strikethrough_offset,
};
let width = match strikethrough.size {
Some(size) => size,
None => run_metrics.strikethrough_size,
};
// The `offset` is the distance from the baseline to the *top* of the strikethrough
// so we move the line down by half the width
// Remember that we are using a y-down coordinate system
let y = glyph_run.baseline() - offset + width / 2.;
let line = Line::new(
(glyph_run.offset() as f64, y as f64),
((glyph_run.offset() + glyph_run.advance()) as f64, y as f64),
);
scratch_scene.stroke(
&Stroke::new(width.into()),
transform,
strikethrough_brush,
None,
&line,
);
}
}
}
scene.append(scratch_scene, None);
}

View File

@ -1,959 +0,0 @@
// Copyright 2018 the Xilem Authors and the Druid Authors and the Glazier Authors
// SPDX-License-Identifier: Apache-2.0
//! Traits for text editing and a basic String implementation.
use std::borrow::Cow;
use std::ops::{Deref, DerefMut, Range};
use accesskit::{NodeBuilder, TextPosition, TextSelection, TreeUpdate};
use parley::context::RangedBuilder;
use parley::{FontContext, LayoutContext};
use tracing::debug;
use unicode_segmentation::{GraphemeCursor, UnicodeSegmentation};
use vello::kurbo::{Affine, Line, Point, Stroke};
use vello::peniko::{Brush, Color};
use vello::Scene;
use winit::keyboard::NamedKey;
use crate::event::{AccessEvent, PointerButton, PointerState};
use crate::text::{TextBrush, TextLayout};
use crate::{Handled, TextEvent};
pub struct TextWithSelection<T: Selectable> {
text: T,
text_changed: bool,
pub layout: TextLayout,
/// The current selection within this widget
// TODO: Allow multiple selections (i.e. by holding down control)
pub selection: Selection,
pub selection_visible: bool,
highlight_brush: TextBrush,
needs_selection_update: bool,
selecting_with_mouse: bool,
// TODO: Cache cursor line, selection boxes
cursor_line: Option<Line>,
}
impl<T: Selectable> TextWithSelection<T> {
pub fn new(text: T, text_size: f32) -> Self {
Self {
text,
text_changed: false,
layout: TextLayout::new(text_size),
selection: Selection::caret(0, Affinity::Downstream),
selection_visible: false,
needs_selection_update: false,
selecting_with_mouse: false,
cursor_line: None,
highlight_brush: TextBrush::Highlight {
text: Color::WHITE.into(),
fill: Color::LIGHT_BLUE.into(),
hinting: Default::default(),
},
}
}
pub fn text(&self) -> &T {
&self.text
}
pub fn text_mut(&mut self) -> &mut T {
self.text_changed = true;
&mut self.text
}
pub fn set_text(&mut self, text: T) {
self.selection = Selection::caret(0, Affinity::Downstream);
self.needs_selection_update = true;
self.text_changed = true;
self.text = text;
}
pub fn needs_rebuild(&self) -> bool {
self.layout.needs_rebuild() || self.needs_selection_update || self.text_changed
}
pub fn pointer_down(
&mut self,
origin: Point,
state: &PointerState,
button: PointerButton,
) -> bool {
// TODO: work out which button is the primary button?
if button == PointerButton::Primary {
self.selection_visible = true;
self.selecting_with_mouse = true;
self.needs_selection_update = true;
// TODO: Much of this juggling seems unnecessary
let position = Point::new(state.position.x, state.position.y) - origin;
let position = self
.layout
.cursor_for_point(Point::new(position.x, position.y));
tracing::warn!("Got cursor point without getting affinity");
if state.mods.state().shift_key() {
self.selection.active = position.insert_point;
self.selection.active_affinity = Affinity::Downstream;
} else {
self.selection = Selection::caret(position.insert_point, Affinity::Downstream);
}
true
} else {
false
}
}
pub fn pointer_up(&mut self, _origin: Point, _state: &PointerState, button: PointerButton) {
if button == PointerButton::Primary {
self.selecting_with_mouse = false;
}
}
pub fn pointer_move(&mut self, origin: Point, state: &PointerState) -> bool {
if self.selecting_with_mouse {
self.needs_selection_update = true;
let position = Point::new(state.position.x, state.position.y) - origin;
let position = self
.layout
.cursor_for_point(Point::new(position.x, position.y));
tracing::warn!("Got cursor point without getting affinity");
self.selection.active = position.insert_point;
self.selection.active_affinity = Affinity::Downstream;
true
} else {
false
}
}
pub fn text_event(&mut self, event: &TextEvent) -> Handled {
match event {
TextEvent::KeyboardKey(key, mods) if key.state.is_pressed() => {
match shortcut_key(key) {
winit::keyboard::Key::Named(NamedKey::ArrowLeft) => {
if mods.shift_key() {
} else {
let selection = self.selection;
let t = &self.text;
if mods.control_key() {
let offset = t.prev_word_offset(selection.active).unwrap_or(0);
self.selection = Selection::caret(offset, Affinity::Downstream);
} else {
let offset = t.prev_grapheme_offset(selection.active).unwrap_or(0);
self.selection = Selection::caret(offset, Affinity::Downstream);
};
}
Handled::Yes
}
winit::keyboard::Key::Named(NamedKey::ArrowRight) => {
if mods.shift_key() {
// TODO: Expand selection
} else {
let t = &self.text;
let selection = self.selection;
if mods.control_key() {
if let Some(o) = t.next_word_offset(selection.active) {
self.selection = Selection::caret(o, Affinity::Upstream);
}
} else if let Some(o) = t.next_grapheme_offset(selection.active) {
self.selection = Selection::caret(o, Affinity::Upstream);
};
}
Handled::Yes
}
winit::keyboard::Key::Named(_) => Handled::No,
winit::keyboard::Key::Character(chr) => match &*chr {
"a" if mods.control_key() || /* macOS, yes this is a hack */ mods.super_key() =>
{
self.selection =
Selection::new(0, self.text.len(), Affinity::Downstream);
self.needs_selection_update = true;
Handled::Yes
}
"c" if mods.control_key() || mods.super_key() => {
let selection = self.selection;
// TODO: We know this is not the fullest model of copy-paste, and that we should work with the inner text
// e.g. to put HTML code if supported by the rich text kind
if let Some(text) = self.text.slice(selection.min()..selection.max()) {
debug!(r#"Copying "{text}""#);
} else {
debug_panic!("Had invalid selection");
}
Handled::Yes
}
_ => Handled::No,
},
winit::keyboard::Key::Unidentified(_) => Handled::No,
winit::keyboard::Key::Dead(_) => Handled::No,
}
}
TextEvent::KeyboardKey(_, _) => Handled::No,
TextEvent::Ime(_) => Handled::No,
TextEvent::ModifierChange(_) => {
// TODO: What does it mean to "handle" this change?
Handled::No
}
TextEvent::FocusChange(_) => {
// TODO: What does it mean to "handle" this change
// TODO: Set our highlighting colour to a lighter blue if window unfocused
Handled::No
}
}
}
/// Call when this widget becomes focused
pub fn focus_gained(&mut self) {
self.selection_visible = true;
self.needs_selection_update = true;
}
/// Call when another widget becomes focused
pub fn focus_lost(&mut self) {
self.selection_visible = false;
self.selecting_with_mouse = false;
self.needs_selection_update = true;
}
/// Rebuild the text layout.
///
/// See also [`TextLayout::rebuild`] for more comprehensive docs.
pub fn rebuild(
&mut self,
font_ctx: &mut FontContext,
layout_ctx: &mut LayoutContext<TextBrush>,
) {
self.rebuild_with_attributes(font_ctx, layout_ctx, |builder| builder);
}
// Intentionally aliases the method on `TextLayout`
/// Rebuild the text layout, adding attributes to the builder.
///
/// See also [`TextLayout::rebuild_with_attributes`] for more comprehensive docs.
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>,
) {
// In theory, we could be clever here and only rebuild the layout if the
// selected range was previously or currently non-zero size (i.e. there is a selected range)
if self.needs_selection_update || self.layout.needs_rebuild() || self.text_changed {
self.layout.invalidate();
self.layout.rebuild_with_attributes(
font_ctx,
layout_ctx,
self.text.as_ref(),
self.text_changed,
|mut builder| {
if self.selection_visible {
let range = self.selection.range();
if !range.is_empty() {
builder.push(
&parley::style::StyleProperty::Brush(self.highlight_brush.clone()),
range,
);
}
}
attributes(builder)
},
);
self.needs_selection_update = false;
self.text_changed = false;
}
}
pub fn draw(&mut self, scene: &mut Scene, point: impl Into<Point>) {
// TODO: Calculate the location for this in layout lazily?
if self.selection_visible {
self.cursor_line = self
.layout
.caret_line_from_byte_index(self.selection.active);
} else {
self.cursor_line = None;
}
let point: Point = point.into();
if let Some(line) = self.cursor_line {
scene.stroke(
&Stroke::new(2.),
Affine::translate((point.x, point.y)),
&Brush::Solid(Color::WHITE),
None,
&line,
);
}
self.layout.draw(scene, point);
}
fn access_position_from_offset(
&self,
offset: usize,
affinity: Affinity,
) -> Option<TextPosition> {
let text = self.text().as_ref();
debug_assert!(offset <= text.len(), "offset out of range");
for (line_index, line) in self.layout.layout.lines().enumerate() {
let range = line.text_range();
if !(range.contains(&offset)
|| (offset == range.end
&& (affinity == Affinity::Upstream
|| line_index == self.layout.layout.len() - 1)))
{
continue;
}
for (run_index, run) in line.runs().enumerate() {
let range = run.text_range();
if !(range.contains(&offset)
|| (offset == range.end
&& (affinity == Affinity::Upstream
|| (line_index == self.layout.layout.len() - 1
&& run_index == line.len() - 1))))
{
continue;
}
let run_offset = offset - range.start;
let run_path = (line_index, run_index);
let id = *self.layout.access_ids_by_run_path.get(&run_path).unwrap();
let character_lengths =
self.layout.character_lengths_by_access_id.get(&id).unwrap();
let mut length_sum = 0_usize;
for (character_index, length) in character_lengths.iter().copied().enumerate() {
if run_offset == length_sum {
return Some(TextPosition {
node: id,
character_index,
});
}
length_sum += length as usize;
}
return Some(TextPosition {
node: id,
character_index: character_lengths.len(),
});
}
}
debug_panic!(
"offset {} not within the range of any run; text length: {}",
offset,
text.len()
);
None
}
pub fn accessibility(&mut self, update: &mut TreeUpdate, parent_node: &mut NodeBuilder) {
self.layout
.accessibility(self.text.as_ref(), update, parent_node);
let anchor_affinity = if self.selection.anchor == self.selection.active {
self.selection.active_affinity
} else {
Affinity::Downstream
};
let anchor = self.access_position_from_offset(self.selection.anchor, anchor_affinity);
let focus =
self.access_position_from_offset(self.selection.active, self.selection.active_affinity);
if let (Some(anchor), Some(focus)) = (anchor, focus) {
parent_node.set_text_selection(TextSelection { anchor, focus });
}
parent_node.add_action(accesskit::Action::SetTextSelection);
}
fn offset_from_access_position(&self, pos: TextPosition) -> Option<(usize, Affinity)> {
let character_lengths = self.layout.character_lengths_by_access_id.get(&pos.node)?;
if pos.character_index > character_lengths.len() {
return None;
}
let run_path = *self.layout.run_paths_by_access_id.get(&pos.node)?;
let (line_index, run_index) = run_path;
let line = self.layout.layout.get(line_index)?;
let run = line.get(run_index)?;
let offset = run.text_range().start
+ character_lengths[..pos.character_index]
.iter()
.copied()
.map(usize::from)
.sum::<usize>();
let affinity = if pos.character_index == character_lengths.len() && line_index < run.len() {
Affinity::Upstream
} else {
Affinity::Downstream
};
Some((offset, affinity))
}
pub fn set_selection_from_access_event(&mut self, event: &AccessEvent) -> bool {
let Some(accesskit::ActionData::SetTextSelection(access_selection)) = event.data else {
return false;
};
let Some((anchor, _)) = self.offset_from_access_position(access_selection.anchor) else {
return false;
};
let Some((active, active_affinity)) =
self.offset_from_access_position(access_selection.focus)
else {
return false;
};
self.selection = Selection::new(anchor, active, active_affinity);
self.selection_visible = true;
self.needs_selection_update = true;
true
}
}
/// Get the key which should be used for shortcuts from the underlying event
///
/// `key_without_modifiers` is only available on some platforms
fn shortcut_key(key: &winit::event::KeyEvent) -> winit::keyboard::Key {
#[cfg(not(any(target_os = "android", target_os = "ios")))]
{
use winit::platform::modifier_supplement::KeyEventExtModifierSupplement;
key.key_without_modifiers()
}
#[cfg(any(target_os = "android", target_os = "ios"))]
// We think it will be rare that users are using a physical keyboard with Android,
// and so we don't really need to worry *too much* about the text selection shortcuts
key.logical_key.clone()
}
impl<T: Selectable> Deref for TextWithSelection<T> {
type Target = TextLayout;
fn deref(&self) -> &Self::Target {
&self.layout
}
}
// TODO: Being able to call `Self::Target::rebuild` (and `draw`) isn't great.
impl<T: Selectable> DerefMut for TextWithSelection<T> {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.layout
}
}
/// A range of selected text, or a caret.
///
/// A caret is the blinking vertical bar where text is to be inserted. We
/// represent it as a selection with zero length, where `anchor == active`.
/// Indices are always expressed in UTF-8 bytes, and must be between 0 and the
/// document length, inclusive.
///
/// As an example, if the input caret is at the start of the document `hello
/// world`, we would expect both `anchor` and `active` to be `0`. If the user
/// holds shift and presses the right arrow key five times, we would expect the
/// word `hello` to be selected, the `anchor` to still be `0`, and the `active`
/// to now be `5`.
#[derive(Clone, Copy, Debug, PartialEq)]
#[non_exhaustive]
pub struct Selection {
/// The 'anchor' end of the selection.
///
/// This is the end of the selection that stays unchanged while holding
/// shift and pressing the arrow keys.
// TODO: Is usize the right type for these? Is it plausible to be dealing with a
// more than 4gb file on a 32 bit machine?
pub anchor: usize,
/// The 'active' end of the selection.
///
/// This is the end of the selection that moves while holding shift and
/// pressing the arrow keys.
pub active: usize,
/// The affinity of the `active` side of the cursor
///
/// The affinity of `anchor` is entirely based on the affinity of active:
/// 1) If `active` is Upstream
pub active_affinity: Affinity,
/// The saved horizontal position, during vertical movement.
///
/// This should not be set by the IME; it will be tracked and handled by
/// the text field.
pub h_pos: Option<f32>,
}
#[allow(clippy::len_without_is_empty)]
impl Selection {
/// Create a new `Selection` with the provided `anchor` and `active` positions.
///
/// Both positions refer to UTF-8 byte indices in some text.
///
/// If your selection is a caret, you can use [`Selection::caret`] instead.
pub fn new(anchor: usize, active: usize, active_affinity: Affinity) -> Selection {
Selection {
anchor,
active,
h_pos: None,
active_affinity,
}
}
/// Create a new caret (zero-length selection) at the provided UTF-8 byte index.
///
/// `index` must be a grapheme cluster boundary.
pub fn caret(index: usize, affinity: Affinity) -> Selection {
Selection {
anchor: index,
active: index,
h_pos: None,
active_affinity: affinity,
}
}
/// Construct a new selection from this selection, with the provided `h_pos`.
///
/// # Note
///
/// `h_pos` is used to track the *pixel* location of the cursor when moving
/// vertically; lines may have available cursor positions at different
/// positions, and arrowing down and then back up should always result
/// in a cursor at the original starting location; doing this correctly
/// requires tracking this state.
///
/// You *probably* don't need to use this, unless you are implementing a new
/// text field, or otherwise implementing vertical cursor motion, in which
/// case you will want to set this during vertical motion if it is not
/// already set.
pub fn with_h_pos(mut self, h_pos: Option<f32>) -> Self {
self.h_pos = h_pos;
self
}
/// Create a new selection that is guaranteed to be valid for the provided
/// text.
#[must_use = "constrained constructs a new Selection"]
pub fn constrained(mut self, s: &str) -> Self {
let s_len = s.len();
self.anchor = self.anchor.min(s_len);
self.active = self.active.min(s_len);
while !s.is_char_boundary(self.anchor) {
self.anchor += 1;
}
while !s.is_char_boundary(self.active) {
self.active += 1;
}
self
}
/// Return the position of the upstream end of the selection.
///
/// This is end with the lesser byte index.
///
/// Because of bidirectional text, this is not necessarily "left".
pub fn min(&self) -> usize {
usize::min(self.anchor, self.active)
}
/// Return the position of the downstream end of the selection.
///
/// This is the end with the greater byte index.
///
/// Because of bidirectional text, this is not necessarily "right".
pub fn max(&self) -> usize {
usize::max(self.anchor, self.active)
}
/// The sequential range of the document represented by this selection.
///
/// This is the range that would be replaced if text were inserted at this
/// selection.
pub fn range(&self) -> Range<usize> {
self.min()..self.max()
}
/// The length, in bytes of the selected region.
///
/// If the selection is a caret, this is `0`.
pub fn len(&self) -> usize {
if self.anchor > self.active {
self.anchor - self.active
} else {
self.active - self.anchor
}
}
/// Returns `true` if the selection's length is `0`.
pub fn is_caret(&self) -> bool {
self.len() == 0
}
}
/// 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.
///
/// This also has an impact on rich text editing.
/// For example, if the cursor is in a region like `a|1`, where `a` is bold and `1` is not.
/// When editing, if we came from the start of the string, we should assume that the next
/// character will be bold, from the right italic.
#[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,
}
/// Text which can have internal selections
pub trait Selectable: Sized + AsRef<str> + Eq {
/// Get slice of text at range.
fn slice(&self, range: Range<usize>) -> Option<Cow<str>>;
/// Get length of text (in bytes).
fn len(&self) -> usize;
/// Get the previous word offset from the given offset, if it exists.
fn prev_word_offset(&self, offset: usize) -> Option<usize>;
/// Get the next word offset from the given offset, if it exists.
fn next_word_offset(&self, offset: usize) -> Option<usize>;
/// Get the next grapheme offset from the given offset, if it exists.
fn prev_grapheme_offset(&self, offset: usize) -> Option<usize>;
/// Get the next grapheme offset from the given offset, if it exists.
fn next_grapheme_offset(&self, offset: usize) -> Option<usize>;
/// Get the previous codepoint offset from the given offset, if it exists.
fn prev_codepoint_offset(&self, offset: usize) -> Option<usize>;
/// Get the next codepoint offset from the given offset, if it exists.
fn next_codepoint_offset(&self, offset: usize) -> Option<usize>;
/// Get the preceding line break offset from the given offset
fn preceding_line_break(&self, offset: usize) -> usize;
/// Get the next line break offset from the given offset
fn next_line_break(&self, offset: usize) -> usize;
/// Returns `true` if this text has 0 length.
fn is_empty(&self) -> bool;
}
impl<Str: AsRef<str> + Eq> Selectable for Str {
fn slice(&self, range: Range<usize>) -> Option<Cow<str>> {
self.as_ref().get(range).map(Cow::from)
}
fn len(&self) -> usize {
self.as_ref().len()
}
fn prev_grapheme_offset(&self, from: usize) -> Option<usize> {
let mut c = GraphemeCursor::new(from, self.len(), true);
c.prev_boundary(self.as_ref(), 0).unwrap()
}
fn next_grapheme_offset(&self, from: usize) -> Option<usize> {
let mut c = GraphemeCursor::new(from, self.len(), true);
c.next_boundary(self.as_ref(), 0).unwrap()
}
fn prev_codepoint_offset(&self, from: usize) -> Option<usize> {
let mut c = StringCursor::new(self.as_ref(), from).unwrap();
c.prev()
}
fn next_codepoint_offset(&self, from: usize) -> Option<usize> {
let mut c = StringCursor::new(self.as_ref(), from).unwrap();
if c.next().is_some() {
Some(c.pos())
} else {
None
}
}
fn prev_word_offset(&self, from: usize) -> Option<usize> {
let mut offset = from;
let mut passed_alphanumeric = false;
for prev_grapheme in self.as_ref().get(0..from)?.graphemes(true).rev() {
let is_alphanumeric = prev_grapheme.chars().next()?.is_alphanumeric();
if is_alphanumeric {
passed_alphanumeric = true;
} else if passed_alphanumeric {
return Some(offset);
}
offset -= prev_grapheme.len();
}
None
}
fn next_word_offset(&self, from: usize) -> Option<usize> {
let mut offset = from;
let mut passed_alphanumeric = false;
for next_grapheme in self.as_ref().get(from..)?.graphemes(true) {
let is_alphanumeric = next_grapheme.chars().next()?.is_alphanumeric();
if is_alphanumeric {
passed_alphanumeric = true;
} else if passed_alphanumeric {
return Some(offset);
}
offset += next_grapheme.len();
}
Some(self.len())
}
fn is_empty(&self) -> bool {
self.as_ref().is_empty()
}
fn preceding_line_break(&self, from: usize) -> usize {
let mut offset = from;
for byte in self.as_ref().get(0..from).unwrap_or("").bytes().rev() {
if byte == 0x0a {
return offset;
}
offset -= 1;
}
0
}
fn next_line_break(&self, from: usize) -> usize {
let mut offset = from;
for char in self.as_ref().get(from..).unwrap_or("").bytes() {
if char == 0x0a {
return offset;
}
offset += 1;
}
self.len()
}
}
/// A cursor type with helper methods for moving through strings.
#[derive(Debug)]
pub struct StringCursor<'a> {
pub(crate) text: &'a str,
pub(crate) position: usize,
}
impl<'a> StringCursor<'a> {
pub fn new(text: &'a str, position: usize) -> Option<Self> {
let res = Self { text, position };
if res.is_boundary() {
Some(res)
} else {
None
}
}
}
impl<'a> StringCursor<'a> {
/// Set cursor position.
pub(crate) fn set(&mut self, position: usize) {
self.position = position;
}
/// Get cursor position.
pub(crate) fn pos(&self) -> usize {
self.position
}
/// Check if cursor position is at a codepoint boundary.
pub(crate) fn is_boundary(&self) -> bool {
self.text.is_char_boundary(self.position)
}
/// Move cursor to previous codepoint boundary, if it exists.
/// Returns previous codepoint as usize offset, or `None` if this cursor was already at the first boundary.
pub(crate) fn prev(&mut self) -> Option<usize> {
let current_pos = self.pos();
if current_pos == 0 {
None
} else {
let mut len = 1;
while !self.text.is_char_boundary(current_pos - len) {
len += 1;
}
self.set(self.pos() - len);
Some(self.pos())
}
}
/// Move cursor to next codepoint boundary, if it exists.
/// Returns current codepoint as usize offset.
pub(crate) fn next(&mut self) -> Option<usize> {
let current_pos = self.pos();
if current_pos == self.text.len() {
None
} else {
let b = self.text.as_bytes()[current_pos];
self.set(current_pos + len_utf8_from_first_byte(b));
Some(current_pos)
}
}
/// Return codepoint preceding cursor offset and move cursor backward.
pub(crate) fn prev_codepoint(&mut self) -> Option<char> {
if let Some(prev) = self.prev() {
self.text[prev..].chars().next()
} else {
None
}
}
}
pub fn len_utf8_from_first_byte(b: u8) -> usize {
match b {
b if b < 0x80 => 1,
b if b < 0xe0 => 2,
b if b < 0xf0 => 3,
_ => 4,
}
}
// --- MARK: TESTS ---
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn prev_codepoint_offset() {
let a = String::from("a\u{00A1}\u{4E00}\u{1F4A9}");
assert_eq!(Some(6), a.prev_codepoint_offset(10));
assert_eq!(Some(3), a.prev_codepoint_offset(6));
assert_eq!(Some(1), a.prev_codepoint_offset(3));
assert_eq!(Some(0), a.prev_codepoint_offset(1));
assert_eq!(None, a.prev_codepoint_offset(0));
let b = a.slice(1..10).unwrap().to_string();
assert_eq!(Some(5), b.prev_codepoint_offset(9));
assert_eq!(Some(2), b.prev_codepoint_offset(5));
assert_eq!(Some(0), b.prev_codepoint_offset(2));
assert_eq!(None, b.prev_codepoint_offset(0));
}
#[test]
fn next_codepoint_offset() {
let a = String::from("a\u{00A1}\u{4E00}\u{1F4A9}");
assert_eq!(Some(10), a.next_codepoint_offset(6));
assert_eq!(Some(6), a.next_codepoint_offset(3));
assert_eq!(Some(3), a.next_codepoint_offset(1));
assert_eq!(Some(1), a.next_codepoint_offset(0));
assert_eq!(None, a.next_codepoint_offset(10));
let b = a.slice(1..10).unwrap().to_string();
assert_eq!(Some(9), b.next_codepoint_offset(5));
assert_eq!(Some(5), b.next_codepoint_offset(2));
assert_eq!(Some(2), b.next_codepoint_offset(0));
assert_eq!(None, b.next_codepoint_offset(9));
}
#[test]
fn prev_next() {
let input = String::from("abc");
let mut cursor = StringCursor::new(&input, 0).unwrap();
assert_eq!(cursor.next(), Some(0));
assert_eq!(cursor.next(), Some(1));
assert_eq!(cursor.prev(), Some(1));
assert_eq!(cursor.next(), Some(1));
assert_eq!(cursor.next(), Some(2));
}
#[test]
fn prev_grapheme_offset() {
// A with ring, hangul, regional indicator "US"
let a = String::from("A\u{030a}\u{110b}\u{1161}\u{1f1fa}\u{1f1f8}");
assert_eq!(Some(9), a.prev_grapheme_offset(17));
assert_eq!(Some(3), a.prev_grapheme_offset(9));
assert_eq!(Some(0), a.prev_grapheme_offset(3));
assert_eq!(None, a.prev_grapheme_offset(0));
}
#[test]
fn next_grapheme_offset() {
// A with ring, hangul, regional indicator "US"
let a = String::from("A\u{030a}\u{110b}\u{1161}\u{1f1fa}\u{1f1f8}");
assert_eq!(Some(3), a.next_grapheme_offset(0));
assert_eq!(Some(9), a.next_grapheme_offset(3));
assert_eq!(Some(17), a.next_grapheme_offset(9));
assert_eq!(None, a.next_grapheme_offset(17));
}
#[test]
fn prev_word_offset() {
let a = String::from("Technically a word: ৬藏A\u{030a}\u{110b}\u{1161}");
assert_eq!(Some(20), a.prev_word_offset(35));
assert_eq!(Some(20), a.prev_word_offset(27));
assert_eq!(Some(20), a.prev_word_offset(23));
assert_eq!(Some(14), a.prev_word_offset(20));
assert_eq!(Some(14), a.prev_word_offset(19));
assert_eq!(Some(12), a.prev_word_offset(13));
assert_eq!(None, a.prev_word_offset(12));
assert_eq!(None, a.prev_word_offset(11));
assert_eq!(None, a.prev_word_offset(0));
}
#[test]
fn next_word_offset() {
let a = String::from("Technically a word: ৬藏A\u{030a}\u{110b}\u{1161}");
assert_eq!(Some(11), a.next_word_offset(0));
assert_eq!(Some(11), a.next_word_offset(7));
assert_eq!(Some(13), a.next_word_offset(11));
assert_eq!(Some(18), a.next_word_offset(14));
assert_eq!(Some(35), a.next_word_offset(18));
assert_eq!(Some(35), a.next_word_offset(19));
assert_eq!(Some(35), a.next_word_offset(20));
assert_eq!(Some(35), a.next_word_offset(26));
assert_eq!(Some(35), a.next_word_offset(35));
}
#[test]
fn preceding_line_break() {
let a = String::from("Technically\na word:\n ৬藏A\u{030a}\n\u{110b}\u{1161}");
assert_eq!(0, a.preceding_line_break(0));
assert_eq!(0, a.preceding_line_break(11));
assert_eq!(12, a.preceding_line_break(12));
assert_eq!(12, a.preceding_line_break(13));
assert_eq!(20, a.preceding_line_break(21));
assert_eq!(31, a.preceding_line_break(31));
assert_eq!(31, a.preceding_line_break(34));
let b = String::from("Technically a word: ৬藏A\u{030a}\u{110b}\u{1161}");
assert_eq!(0, b.preceding_line_break(0));
assert_eq!(0, b.preceding_line_break(11));
assert_eq!(0, b.preceding_line_break(13));
assert_eq!(0, b.preceding_line_break(21));
}
#[test]
fn next_line_break() {
let a = String::from("Technically\na word:\n ৬藏A\u{030a}\n\u{110b}\u{1161}");
assert_eq!(11, a.next_line_break(0));
assert_eq!(11, a.next_line_break(11));
assert_eq!(19, a.next_line_break(13));
assert_eq!(30, a.next_line_break(21));
assert_eq!(a.len(), a.next_line_break(31));
let b = String::from("Technically a word: ৬藏A\u{030a}\u{110b}\u{1161}");
assert_eq!(b.len(), b.next_line_break(0));
assert_eq!(b.len(), b.next_line_break(11));
assert_eq!(b.len(), b.next_line_break(13));
assert_eq!(b.len(), b.next_line_break(19));
}
}

View File

@ -1,612 +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::collections::{HashMap, HashSet};
use accesskit::{NodeBuilder, NodeId, Role, TreeUpdate};
use parley::context::RangedBuilder;
use parley::fontique::{Style, Weight};
use parley::layout::{Alignment, Cursor};
use parley::style::{FontFamily, FontStack, GenericFamily, StyleProperty};
use parley::{FontContext, Layout, LayoutContext};
use unicode_segmentation::UnicodeSegmentation;
use vello::kurbo::{Affine, Line, Point, Size};
use vello::peniko::{self, Color, Gradient};
use vello::Scene;
use crate::text::render_text;
use crate::WidgetId;
/// 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 {
scale: f32,
brush: TextBrush,
font: FontStack<'static>,
text_size: f32,
weight: Weight,
style: Style,
alignment: Alignment,
max_advance: Option<f32>,
needs_layout: bool,
needs_line_breaks: bool,
pub(crate) layout: Layout<TextBrush>,
scratch_scene: Scene,
// TODO - Add field to check whether text has changed since last layout
// #[cfg(debug_assertions)] last_text_start: String,
// The following two fields maintain a two-way mapping between runs
// and AccessKit node IDs, where each run is identified by its line index
// and run index within that line, or a run path for short. These maps
// are maintained by `TextLayout::accessibility`, which ensures that removed
// runs are removed from the maps on the next accessibility pass.
// `access_ids_by_run_path` is used by both `TextLayout::accessibility` and
// `TextWithSelection::access_position_from_offset`, while
// `run_paths_by_access_id` is used by
// `TextWithSelection::offset_from_access_position`.
pub(crate) access_ids_by_run_path: HashMap<(usize, usize), NodeId>,
pub(crate) run_paths_by_access_id: HashMap<NodeId, (usize, usize)>,
// This map duplicates the character lengths stored in the run nodes.
// This is necessary because this information is needed during the
// access event pass, after the previous tree update has already been
// pushed to AccessKit. AccessKit deliberately doesn't let toolkits access
// the current tree state, because the ideal AccessKit backend would push
// tree updates to assistive technologies and not retain a tree in memory.
// Even if `TextWithSelection` only needed this information when constructing
// the text selection on the parent node, it would still be more efficient
// to duplicate the character lengths here than to pull them from the
// appropriate `Node` in the `Vec` that's going to be added to the
// tree update.
pub(crate) character_lengths_by_access_id: HashMap<NodeId, Box<[u8]>>,
}
/// Whether a section of text should be hinted.
#[derive(Copy, Clone, PartialEq, Eq, Hash, Debug, Default)]
pub enum Hinting {
#[default]
Yes,
No,
}
impl Hinting {
/// Whether the
pub fn should_hint(self) -> bool {
match self {
Hinting::Yes => true,
Hinting::No => false,
}
}
}
/// 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, Hinting),
Highlight {
text: peniko::Brush,
fill: peniko::Brush,
hinting: Hinting,
},
}
impl TextBrush {
pub fn set_hinting(&mut self, hinting: Hinting) {
match self {
TextBrush::Normal(_, should_hint) => *should_hint = hinting,
TextBrush::Highlight {
hinting: should_hint,
..
} => *should_hint = hinting,
}
}
}
impl parley::style::Brush for TextBrush {}
impl From<peniko::Brush> for TextBrush {
fn from(value: peniko::Brush) -> Self {
Self::Normal(value, Hinting::default())
}
}
impl From<Gradient> for TextBrush {
fn from(value: Gradient) -> Self {
Self::Normal(value.into(), Hinting::default())
}
}
impl From<Color> for TextBrush {
fn from(value: Color) -> Self {
Self::Normal(value.into(), Hinting::default())
}
}
// Parley requires their Brush implementations to implement Default
impl Default for TextBrush {
fn default() -> Self {
Self::Normal(Default::default(), Hinting::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 TextLayout {
/// Create a new `TextLayout` object.
pub fn new(text_size: f32) -> Self {
TextLayout {
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(),
needs_layout: true,
needs_line_breaks: true,
layout: Layout::new(),
scratch_scene: Scene::new(),
access_ids_by_run_path: HashMap::new(),
run_paths_by_access_id: HashMap::new(),
character_lengths_by_access_id: HashMap::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.
///
/// This alignment can only be meaningful when a
/// [maximum width](Self::set_max_advance) is provided.
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.
#[must_use = "Has no side effects"]
pub fn needs_rebuild(&self) -> bool {
self.needs_layout || self.needs_line_breaks
}
}
impl TextLayout {
#[track_caller]
fn assert_rebuilt(&self, method: &str) {
if self.needs_layout || self.needs_line_breaks {
if cfg!(debug_assertions) {
// TODO - Include self.last_text_start
#[cfg(debug_assertions)]
panic!("TextLayout::{method} called without rebuilding layout object.");
} else {
tracing::error!("TextLayout::{method} called without rebuilding layout object.",);
};
}
}
/// Returns the inner Parley [`Layout`] value.
pub fn layout(&self) -> &Layout<TextBrush> {
self.assert_rebuilt("layout");
&self.layout
}
/// The size of the region the text should be drawn in,
/// excluding any trailing whitespace if present.
///
/// Should be used for the drawing of non-interactive text (as the
/// trailing whitespace is selectable for interactive text).
///
/// This is not meaningful until [`Self::rebuild`] has been called.
pub fn size(&self) -> Size {
self.assert_rebuilt("size");
Size::new(
self.layout_width(self.layout.width()).into(),
self.layout.height().into(),
)
}
/// The size of the laid-out text, including any trailing whitespace.
///
/// Should be used for the drawing of interactive text.
///
/// 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_width(self.layout.full_width()).into(),
self.layout.height().into(),
)
}
/// If performing layout `max_advance` to calculate text alignment, the only
/// reasonable behaviour is to take up the entire available width.
///
/// The coherent way to have multiple items laid out on the same line is for them to
/// be inside the same text layout object "region". This is currently deferred.
/// As an interim solution, we allow multiple items to be on the same line if the `max_advance` wasn't used
/// (and therefore the text alignment argument is effectively ignored).
fn layout_width(&self, width: f32) -> f32 {
self.max_advance.unwrap_or(width)
}
/// 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 a `Line` suitable for drawing a vertical cursor at that boundary.
///
/// This is not meaningful until [`Self::rebuild`] has been called.
pub fn caret_line_from_byte_index(&self, byte_index: usize) -> Option<Line> {
// TODO - Handle affinity
// For now we give is_leading: true, which means the caret is before
// the character at byte_index, which matches how we interpret character boundaries.
let caret = Cursor::from_position(&self.layout, byte_index, true);
let line = caret.path.line(&self.layout)?;
let line_metrics = line.metrics();
let baseline = line_metrics.baseline + line_metrics.descent;
let line_size = line_metrics.size();
let p1 = (caret.offset as f64, baseline as f64);
let p2 = (caret.offset as f64, (baseline - line_size) as f64);
Some(Line::new(p1, p2))
}
/// 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 text 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.
///
/// The `text_changed` parameter should be set to `true` if the text changed since
/// the last rebuild. Always setting it to true may lead to redundant work, wrongly
/// setting it to false may lead to invalidation bugs.
pub fn rebuild(
&mut self,
font_ctx: &mut FontContext,
layout_ctx: &mut LayoutContext<TextBrush>,
text: &str,
text_changed: bool,
) {
self.rebuild_with_attributes(font_ctx, layout_ctx, text, text_changed, |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>,
text: &str,
text_changed: bool,
attributes: impl for<'b> FnOnce(
RangedBuilder<'b, TextBrush, &'b str>,
) -> RangedBuilder<'b, TextBrush, &'b str>,
) {
// TODO - check against self.last_text_start
if self.needs_layout || text_changed {
self.needs_layout = false;
// Workaround for how parley treats empty lines.
//let text = if !text.is_empty() { text } else { " " };
let mut builder = layout_ctx.ranged_builder(font_ctx, text, 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));
// Currently, this is used for:
// - underlining IME suggestions
// - applying a brush to selected text.
let mut builder = attributes(builder);
builder.build_into(&mut self.layout);
self.needs_line_breaks = true;
}
if self.needs_line_breaks || text_changed {
self.needs_line_breaks = false;
self.layout
.break_all_lines(self.max_advance, self.alignment);
}
}
/// 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();
render_text(
scene,
&mut self.scratch_scene,
Affine::translate((p.x, p.y)),
&self.layout,
);
}
pub fn accessibility(
&mut self,
text: &str,
update: &mut TreeUpdate,
parent_node: &mut NodeBuilder,
) {
self.assert_rebuilt("accessibility");
// Build a set of node IDs for the runs encountered in this pass.
let mut ids = HashSet::<NodeId>::new();
for (line_index, line) in self.layout.lines().enumerate() {
// Defer adding each run node until we reach either the next run
// or the end of the line. That way, we can set relations between
// runs in a line and do anything special that might be required
// for the last run in a line.
let mut last_node: Option<(NodeId, NodeBuilder)> = None;
for (run_index, run) in line.runs().enumerate() {
let run_path = (line_index, run_index);
// If we encountered this same run path in the previous
// accessibility pass, reuse the same AccessKit ID. Otherwise,
// allocate a new one. This enables stable node IDs when merely
// updating the content of existing runs.
let id = self
.access_ids_by_run_path
.get(&run_path)
.copied()
.unwrap_or_else(|| {
let id = NodeId::from(WidgetId::next());
self.access_ids_by_run_path.insert(run_path, id);
self.run_paths_by_access_id.insert(id, run_path);
id
});
ids.insert(id);
let mut node = NodeBuilder::new(Role::InlineTextBox);
if let Some((last_id, mut last_node)) = last_node.take() {
last_node.set_next_on_line(id);
node.set_previous_on_line(last_id);
update.nodes.push((last_id, last_node.build()));
parent_node.push_child(last_id);
}
// TODO: bounding rectangle and character position/width
let run_text = &text[run.text_range()];
node.set_value(run_text);
let mut character_lengths = Vec::new();
let mut word_lengths = Vec::new();
let mut was_at_word_end = false;
let mut last_word_start = 0;
for grapheme in run_text.graphemes(true) {
// The logic for determining word boundaries must match
// that used by `TextWithSelection` when moving by word.
// Note: AccessKit assumes that the end of one word equals
// the start of the next one.
let is_word_char = grapheme.chars().next().unwrap().is_alphanumeric();
if is_word_char && was_at_word_end {
word_lengths.push((character_lengths.len() - last_word_start) as _);
last_word_start = character_lengths.len();
}
was_at_word_end = !is_word_char;
character_lengths.push(grapheme.len() as _);
}
word_lengths.push((character_lengths.len() - last_word_start) as _);
self.character_lengths_by_access_id
.insert(id, character_lengths.clone().into());
node.set_character_lengths(character_lengths);
node.set_word_lengths(word_lengths);
last_node = Some((id, node));
}
if let Some((id, node)) = last_node {
update.nodes.push((id, node.build()));
parent_node.push_child(id);
}
}
// Remove mappings for runs that no longer exist.
let mut ids_to_remove = Vec::<NodeId>::new();
let mut run_paths_to_remove = Vec::<(usize, usize)>::new();
for (access_id, run_path) in self.run_paths_by_access_id.iter() {
if !ids.contains(access_id) {
ids_to_remove.push(*access_id);
run_paths_to_remove.push(*run_path);
}
}
for id in ids_to_remove {
self.run_paths_by_access_id.remove(&id);
self.character_lengths_by_access_id.remove(&id);
}
for run_path in run_paths_to_remove {
self.access_ids_by_run_path.remove(&run_path);
}
}
}
impl std::fmt::Debug for TextLayout {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
f.debug_struct("TextLayout")
.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_non_exhaustive()
}
}
impl Default for TextLayout {
fn default() -> Self {
Self::new(crate::theme::TEXT_SIZE_NORMAL)
}
}

View File

@ -8,7 +8,7 @@
// size constraints to its child means that "aligning" a widget may actually change
// its computed size. See https://github.com/linebender/xilem/issues/378
use accesskit::{NodeBuilder, Role};
use accesskit::{Node, Role};
use smallvec::{smallvec, SmallVec};
use tracing::{trace_span, Span};
use vello::Scene;
@ -142,7 +142,7 @@ impl Widget for Align {
Role::GenericContainer
}
fn accessibility(&mut self, _ctx: &mut AccessCtx, _node: &mut NodeBuilder) {}
fn accessibility(&mut self, _ctx: &mut AccessCtx, _node: &mut Node) {}
fn children_ids(&self) -> SmallVec<[WidgetId; 16]> {
smallvec![self.child.id()]

View File

@ -3,7 +3,7 @@
//! A button widget.
use accesskit::{DefaultActionVerb, NodeBuilder, Role};
use accesskit::{Node, Role};
use smallvec::{smallvec, SmallVec};
use tracing::{trace, trace_span, Span};
use vello::Scene;
@ -53,7 +53,7 @@ impl Button {
/// use masonry::Color;
/// use masonry::widget::{Button, Label};
///
/// let label = Label::new("Increment").with_text_brush(Color::rgb(0.5, 0.5, 0.5));
/// let label = Label::new("Increment").with_brush(Color::rgb(0.5, 0.5, 0.5));
/// let button = Button::from_label(label);
/// ```
pub fn from_label(label: Label) -> Button {
@ -104,7 +104,7 @@ impl Widget for Button {
fn on_access_event(&mut self, ctx: &mut EventCtx, event: &AccessEvent) {
if ctx.target() == ctx.widget_id() {
match event.action {
accesskit::Action::Default => {
accesskit::Action::Click => {
ctx.submit_action(Action::ButtonPressed(PointerButton::Primary));
}
_ => {}
@ -188,16 +188,16 @@ impl Widget for Button {
Role::Button
}
fn accessibility(&mut self, ctx: &mut AccessCtx, node: &mut NodeBuilder) {
fn accessibility(&mut self, ctx: &mut AccessCtx, node: &mut Node) {
// IMPORTANT: We don't want to merge this code in practice, because
// the child label already has a 'name' property.
// This is more of a proof of concept of `get_raw_ref()`.
if false {
let label = ctx.get_raw_ref(&self.label);
let name = label.widget().text().as_ref().to_string();
node.set_name(name);
node.set_value(name);
}
node.set_default_action_verb(DefaultActionVerb::Click);
node.add_action(accesskit::Action::Click);
}
fn children_ids(&self) -> SmallVec<[WidgetId; 16]> {
@ -223,6 +223,7 @@ mod tests {
use super::*;
use crate::assert_render_snapshot;
use crate::testing::{widget_ids, TestHarness, TestWidgetExt};
use crate::text::StyleProperty;
use crate::theme::PRIMARY_LIGHT;
#[test]
@ -248,8 +249,8 @@ mod tests {
fn edit_button() {
let image_1 = {
let label = Label::new("The quick brown fox jumps over the lazy dog")
.with_text_brush(PRIMARY_LIGHT)
.with_text_size(20.0);
.with_brush(PRIMARY_LIGHT)
.with_style(StyleProperty::FontSize(20.0));
let button = Button::from_label(label);
let mut harness = TestHarness::create_with_size(button, Size::new(50.0, 50.0));
@ -267,10 +268,8 @@ mod tests {
Button::set_text(&mut button, "The quick brown fox jumps over the lazy dog");
let mut label = Button::label_mut(&mut button);
Label::set_text_properties(&mut label, |props| {
props.set_brush(PRIMARY_LIGHT);
props.set_text_size(20.0);
});
Label::set_brush(&mut label, PRIMARY_LIGHT);
Label::insert_style(&mut label, StyleProperty::FontSize(20.0));
});
harness.render()

View File

@ -3,7 +3,7 @@
//! A checkbox widget.
use accesskit::{DefaultActionVerb, NodeBuilder, Role, Toggled};
use accesskit::{Node, Role, Toggled};
use smallvec::{smallvec, SmallVec};
use tracing::{trace, trace_span, Span};
use vello::kurbo::{Affine, BezPath, Cap, Join, Size, Stroke};
@ -92,7 +92,7 @@ impl Widget for Checkbox {
fn on_access_event(&mut self, ctx: &mut EventCtx, event: &AccessEvent) {
if ctx.target() == ctx.widget_id() {
match event.action {
accesskit::Action::Default => {
accesskit::Action::Click => {
self.checked = !self.checked;
ctx.submit_action(Action::CheckboxChecked(self.checked));
// Checked state impacts appearance and accessibility node
@ -191,21 +191,20 @@ impl Widget for Checkbox {
Role::CheckBox
}
fn accessibility(&mut self, ctx: &mut AccessCtx, node: &mut NodeBuilder) {
fn accessibility(&mut self, ctx: &mut AccessCtx, node: &mut Node) {
// IMPORTANT: We don't want to merge this code in practice, because
// the child label already has a 'name' property.
// This is more of a proof of concept of `get_raw_ref()`.
if false {
let label = ctx.get_raw_ref(&self.label);
let name = label.widget().text().as_ref().to_string();
node.set_name(name);
node.set_value(name);
}
node.add_action(accesskit::Action::Click);
if self.checked {
node.set_toggled(Toggled::True);
node.set_default_action_verb(DefaultActionVerb::Uncheck);
} else {
node.set_toggled(Toggled::False);
node.set_default_action_verb(DefaultActionVerb::Check);
}
}
@ -230,6 +229,7 @@ impl Widget for Checkbox {
#[cfg(test)]
mod tests {
use insta::assert_debug_snapshot;
use parley::StyleProperty;
use super::*;
use crate::assert_render_snapshot;
@ -270,8 +270,8 @@ mod tests {
let checkbox = Checkbox::from_label(
true,
Label::new("The quick brown fox jumps over the lazy dog")
.with_text_brush(PRIMARY_LIGHT)
.with_text_size(20.0),
.with_brush(PRIMARY_LIGHT)
.with_style(StyleProperty::FontSize(20.0)),
);
let mut harness = TestHarness::create_with_size(checkbox, Size::new(50.0, 50.0));
@ -293,8 +293,8 @@ mod tests {
);
let mut label = Checkbox::label_mut(&mut checkbox);
Label::set_text_brush(&mut label, PRIMARY_LIGHT);
Label::set_text_size(&mut label, 20.0);
Label::set_brush(&mut label, PRIMARY_LIGHT);
Label::insert_style(&mut label, StyleProperty::FontSize(20.0));
});
harness.render()

View File

@ -3,7 +3,7 @@
//! A widget that arranges its children in a one-dimensional array.
use accesskit::{NodeBuilder, Role};
use accesskit::{Node, Role};
use smallvec::SmallVec;
use tracing::{trace_span, Span};
use vello::kurbo::common::FloatExt;
@ -1191,7 +1191,7 @@ impl Widget for Flex {
Role::GenericContainer
}
fn accessibility(&mut self, _ctx: &mut AccessCtx, _node: &mut NodeBuilder) {}
fn accessibility(&mut self, _ctx: &mut AccessCtx, _node: &mut Node) {}
fn children_ids(&self) -> SmallVec<[WidgetId; 16]> {
self.children

View File

@ -1,7 +1,7 @@
// Copyright 2024 the Xilem Authors
// SPDX-License-Identifier: Apache-2.0
use accesskit::{NodeBuilder, Role};
use accesskit::{Node, Role};
use smallvec::SmallVec;
use tracing::{trace_span, Span};
use vello::kurbo::{Affine, Line, Stroke};
@ -300,7 +300,7 @@ impl Widget for Grid {
Role::GenericContainer
}
fn accessibility(&mut self, _ctx: &mut AccessCtx, _node: &mut NodeBuilder) {}
fn accessibility(&mut self, _ctx: &mut AccessCtx, _node: &mut Node) {}
fn children_ids(&self) -> SmallVec<[WidgetId; 16]> {
self.children

View File

@ -4,7 +4,7 @@
//! An Image widget.
//! Please consider using SVG and the SVG widget as it scales much better.
use accesskit::{NodeBuilder, Role};
use accesskit::{Node, Role};
use smallvec::SmallVec;
use tracing::{trace_span, Span};
use vello::kurbo::Affine;
@ -126,7 +126,7 @@ impl Widget for Image {
Role::Image
}
fn accessibility(&mut self, _ctx: &mut AccessCtx, _node: &mut NodeBuilder) {
fn accessibility(&mut self, _ctx: &mut AccessCtx, _node: &mut Node) {
// TODO - Handle alt text and such.
}

View File

@ -3,21 +3,22 @@
//! A label widget.
use accesskit::{NodeBuilder, Role};
use parley::fontique::Weight;
use std::mem::Discriminant;
use accesskit::{Node, NodeId, Role};
use parley::layout::Alignment;
use parley::style::{FontFamily, FontStack};
use parley::{Layout, LayoutAccessibility};
use smallvec::SmallVec;
use tracing::{trace_span, Span};
use vello::kurbo::{Affine, Point, Size};
use vello::peniko::BlendMode;
use vello::kurbo::{Affine, Size};
use vello::peniko::{BlendMode, Brush};
use vello::Scene;
use crate::text::{ArcStr, TextBrush, TextLayout};
use crate::text::{render_text, ArcStr, BrushIndex, StyleProperty, StyleSet};
use crate::widget::WidgetMut;
use crate::{
AccessCtx, AccessEvent, BoxConstraints, EventCtx, LayoutCtx, PaintCtx, PointerEvent, QueryCtx,
RegisterCtx, TextEvent, Update, UpdateCtx, Widget, WidgetId,
theme, AccessCtx, AccessEvent, BoxConstraints, EventCtx, LayoutCtx, PaintCtx, PointerEvent,
QueryCtx, RegisterCtx, TextEvent, Update, UpdateCtx, Widget, WidgetId,
};
/// Added padding between each horizontal edge of the widget
@ -40,127 +41,273 @@ pub enum LineBreaking {
/// This is useful for creating interactive widgets which internally
/// need support for displaying text, such as a button.
pub struct Label {
text_layout: Layout<BrushIndex>,
accessibility: LayoutAccessibility,
text: ArcStr,
text_changed: bool,
text_layout: TextLayout,
styles: StyleSet,
/// Whether `text` or `styles` has been updated since `text_layout` was created.
///
/// If they have, the layout needs to be recreated.
styles_changed: bool,
line_break_mode: LineBreaking,
show_disabled: bool,
brush: TextBrush,
alignment: Alignment,
/// Whether the alignment has changed since the last layout, which would force a re-alignment.
alignment_changed: bool,
/// The value of `max_advance` when this layout was last calculated.
///
/// If it has changed, we need to re-perform line-breaking.
last_max_advance: Option<f32>,
/// The brush for drawing this label's text.
///
/// Requires a new paint if edited whilst `disabled_brush` is not being used.
brush: Brush,
/// The brush to use whilst this widget is disabled.
///
/// When this is `None`, `brush` will be used.
/// Requires a new paint if edited whilst this widget is disabled.
disabled_brush: Option<Brush>,
/// Whether to hint whilst drawing the text.
///
/// Should be disabled whilst an animation involving this label is ongoing.
// TODO: What classes of animations?
hint: bool,
}
// --- MARK: BUILDERS ---
impl Label {
/// Create a new label.
/// Create a new label with the given text.
///
// This is written out fully to appease rust-analyzer.
/// To change the font size, use `with_style`, setting [`StyleProperty::FontSize`](parley::StyleProperty::FontSize).
pub fn new(text: impl Into<ArcStr>) -> Self {
Self {
text_layout: Layout::new(),
accessibility: Default::default(),
text: text.into(),
text_changed: false,
text_layout: TextLayout::new(crate::theme::TEXT_SIZE_NORMAL),
styles: StyleSet::new(theme::TEXT_SIZE_NORMAL),
styles_changed: true,
line_break_mode: LineBreaking::Overflow,
show_disabled: true,
brush: crate::theme::TEXT_COLOR.into(),
alignment: Alignment::Start,
alignment_changed: true,
last_max_advance: None,
brush: theme::TEXT_COLOR.into(),
disabled_brush: Some(theme::DISABLED_TEXT_COLOR.into()),
hint: true,
}
}
/// Get the current text of this label.
///
/// To update the text of an active label, use [`set_text`](Self::set_text).
pub fn text(&self) -> &ArcStr {
&self.text
}
#[doc(alias = "with_text_color")]
pub fn with_text_brush(mut self, brush: impl Into<TextBrush>) -> Self {
self.text_layout.set_brush(brush);
/// Set a style property for the new label.
///
/// Setting [`StyleProperty::Brush`](parley::StyleProperty::Brush) is not supported.
/// Use `with_brush` instead.
///
/// To set a style property on an active label, use [`insert_style`](Self::insert_style).
pub fn with_style(mut self, property: impl Into<StyleProperty>) -> Self {
self.insert_style_inner(property.into());
self
}
#[doc(alias = "with_font_size")]
pub fn with_text_size(mut self, size: f32) -> Self {
self.text_layout.set_text_size(size);
self
}
pub fn with_weight(mut self, weight: Weight) -> Self {
self.text_layout.set_weight(weight);
self
}
pub fn with_text_alignment(mut self, alignment: Alignment) -> Self {
self.text_layout.set_text_alignment(alignment);
self
}
pub fn with_font(mut self, font: FontStack<'static>) -> Self {
self.text_layout.set_font(font);
self
}
pub fn with_font_family(self, font: FontFamily<'static>) -> Self {
self.with_font(FontStack::Single(font))
/// Set a style property for the new label, returning the old value.
///
/// Most users should prefer [`with_style`](Self::with_style) instead.
pub fn try_with_style(
mut self,
property: impl Into<StyleProperty>,
) -> (Self, Option<StyleProperty>) {
let old = self.insert_style_inner(property.into());
(self, old)
}
/// Set how line breaks will be handled by this label.
///
/// To modify this on an active label, use [`set_line_break_mode`](Self::set_line_break_mode).
pub fn with_line_break_mode(mut self, line_break_mode: LineBreaking) -> Self {
self.line_break_mode = line_break_mode;
self
}
/// Create a label with empty text.
pub fn empty() -> Self {
Self::new("")
/// Set the alignment of the text.
///
/// Text alignment might have unexpected results when the label has no horizontal constraints.
/// To modify this on an active label, use [`set_alignment`](Self::set_alignment).
pub fn with_alignment(mut self, alignment: Alignment) -> Self {
self.alignment = alignment;
self
}
/// Set the brush used to paint this label.
///
/// In most cases, this will be the text's color, but gradients and images are also supported.
///
/// To modify this on an active label, use [`set_brush`](Self::set_brush).
#[doc(alias = "with_color")]
pub fn with_brush(mut self, brush: impl Into<Brush>) -> Self {
self.brush = brush.into();
self
}
/// Set the brush which will be used to paint this label whilst it is disabled.
///
/// If this is `None`, the [normal brush](Self::with_brush) will be used.
/// To modify this on an active label, use [`set_disabled_brush`](Self::set_disabled_brush).
#[doc(alias = "with_color")]
pub fn with_disabled_brush(mut self, disabled_brush: impl Into<Option<Brush>>) -> Self {
self.disabled_brush = disabled_brush.into();
self
}
/// Set whether [hinting](https://en.wikipedia.org/wiki/Font_hinting) will be used for this label.
///
/// Hinting is a process where text is drawn "snapped" to pixel boundaries to improve fidelity.
/// The default is true, i.e. hinting is enabled by default.
///
/// This should be set to false if the label will be animated at creation.
/// The kinds of relevant animations include changing variable font parameters,
/// translating or scaling.
/// Failing to do so will likely lead to an unpleasant shimmering effect, as different parts of the
/// text "snap" at different times.
///
/// To modify this on an active label, use [`set_hint`](Self::set_hint).
// TODO: Should we tell each widget if smooth scrolling is ongoing so they can disable their hinting?
// Alternatively, we should automate disabling hinting at the Vello layer when composing.
pub fn with_hint(mut self, hint: bool) -> Self {
self.hint = hint;
self
}
/// Shared logic between `with_style` and `insert_style`
fn insert_style_inner(&mut self, property: StyleProperty) -> Option<StyleProperty> {
if let StyleProperty::Brush(idx @ BrushIndex(1..)) = &property {
debug_panic!(
"Can't set a non-zero brush index ({idx:?}) on a `Label`, as it only supports global styling."
);
}
self.styles.insert(property)
}
}
// --- MARK: WIDGETMUT ---
impl Label {
pub fn set_text_properties<R>(
// Note: These docs are lazy, but also have a decreased likelihood of going out of date.
/// The runtime requivalent of [`with_style`](Self::with_style).
///
/// Setting [`StyleProperty::Brush`](parley::StyleProperty::Brush) is not supported.
/// Use [`set_brush`](Self::set_brush) instead.
pub fn insert_style(
this: &mut WidgetMut<'_, Self>,
f: impl FnOnce(&mut TextLayout) -> R,
) -> R {
let ret = f(&mut this.widget.text_layout);
if this.widget.text_layout.needs_rebuild() {
this.ctx.request_layout();
}
ret
property: impl Into<StyleProperty>,
) -> Option<StyleProperty> {
let old = this.widget.insert_style_inner(property.into());
this.widget.styles_changed = true;
this.ctx.request_layout();
old
}
pub fn set_text(this: &mut WidgetMut<'_, Self>, new_text: impl Into<ArcStr>) {
let new_text = new_text.into();
this.widget.text = new_text;
this.widget.text_changed = true;
/// Keep only the styles for which `f` returns true.
///
/// Styles which are removed return to Parley's default values.
/// In most cases, these are the defaults for this widget.
///
/// Of note, behaviour is unspecified for unsetting the [`FontSize`](parley::StyleProperty::FontSize).
pub fn retain_styles(this: &mut WidgetMut<'_, Self>, f: impl FnMut(&StyleProperty) -> bool) {
this.widget.styles.retain(f);
this.widget.styles_changed = true;
this.ctx.request_layout();
}
#[doc(alias = "set_text_color")]
pub fn set_text_brush(this: &mut WidgetMut<'_, Self>, brush: impl Into<TextBrush>) {
let brush = brush.into();
this.widget.brush = brush;
if !this.ctx.is_disabled() {
let brush = this.widget.brush.clone();
Self::set_text_properties(this, |layout| layout.set_brush(brush));
}
/// Remove the style with the discriminant `property`.
///
/// To get the discriminant requires constructing a valid `StyleProperty` for the
/// the desired property and passing it to [`core::mem::discriminant`].
/// Getting this discriminant is usually possible in a `const` context.
///
/// Styles which are removed return to Parley's default values.
/// In most cases, these are the defaults for this widget.
///
/// Of note, behaviour is unspecified for unsetting the [`FontSize`](parley::StyleProperty::FontSize).
pub fn remove_style(
this: &mut WidgetMut<'_, Self>,
property: Discriminant<StyleProperty>,
) -> Option<StyleProperty> {
let old = this.widget.styles.remove(property);
this.widget.styles_changed = true;
this.ctx.request_layout();
old
}
pub fn set_text_size(this: &mut WidgetMut<'_, Self>, size: f32) {
Self::set_text_properties(this, |layout| layout.set_text_size(size));
}
pub fn set_weight(this: &mut WidgetMut<'_, Self>, weight: Weight) {
Self::set_text_properties(this, |layout| layout.set_weight(weight));
}
pub fn set_alignment(this: &mut WidgetMut<'_, Self>, alignment: Alignment) {
Self::set_text_properties(this, |layout| layout.set_text_alignment(alignment));
}
pub fn set_font(this: &mut WidgetMut<'_, Self>, font_stack: FontStack<'static>) {
Self::set_text_properties(this, |layout| layout.set_font(font_stack));
}
pub fn set_font_family(this: &mut WidgetMut<'_, Self>, family: FontFamily<'static>) {
Self::set_font(this, FontStack::Single(family));
/// Replace the text of this widget.
pub fn set_text(this: &mut WidgetMut<'_, Self>, new_text: impl Into<ArcStr>) {
this.widget.text = new_text.into();
this.widget.styles_changed = true;
this.ctx.request_layout();
}
/// The runtime requivalent of [`with_line_break_mode`](Self::with_line_break_mode).
pub fn set_line_break_mode(this: &mut WidgetMut<'_, Self>, line_break_mode: LineBreaking) {
this.widget.line_break_mode = line_break_mode;
// We don't need to set an internal invalidation, as `max_advance` is always recalculated
this.ctx.request_layout();
}
/// The runtime requivalent of [`with_alignment`](Self::with_alignment).
pub fn set_alignment(this: &mut WidgetMut<'_, Self>, alignment: Alignment) {
this.widget.alignment = alignment;
this.widget.alignment_changed = true;
this.ctx.request_layout();
}
#[doc(alias = "set_color")]
/// The runtime requivalent of [`with_brush`](Self::with_brush).
pub fn set_brush(this: &mut WidgetMut<'_, Self>, brush: impl Into<Brush>) {
let brush = brush.into();
this.widget.brush = brush;
// We need to repaint unless the disabled brush is currently being used.
if this.widget.disabled_brush.is_none() || this.ctx.is_disabled() {
this.ctx.request_paint_only();
}
}
/// The runtime requivalent of [`with_disabled_brush`](Self::with_disabled_brush).
pub fn set_disabled_brush(this: &mut WidgetMut<'_, Self>, brush: impl Into<Option<Brush>>) {
let brush = brush.into();
this.widget.disabled_brush = brush;
if this.ctx.is_disabled() {
this.ctx.request_paint_only();
}
}
/// The runtime requivalent of [`with_hint`](Self::with_hint).
pub fn set_hint(this: &mut WidgetMut<'_, Self>, hint: bool) {
this.widget.hint = hint;
this.ctx.request_paint_only();
}
}
// --- MARK: IMPL WIDGET ---
impl Widget for Label {
fn on_pointer_event(&mut self, _ctx: &mut EventCtx, _event: &PointerEvent) {}
fn accepts_pointer_interaction(&self) -> bool {
false
}
fn on_text_event(&mut self, _ctx: &mut EventCtx, _event: &TextEvent) {}
fn on_access_event(&mut self, _ctx: &mut EventCtx, _event: &AccessEvent) {}
@ -169,44 +316,71 @@ impl Widget for Label {
fn update(&mut self, ctx: &mut UpdateCtx, event: &Update) {
match event {
Update::DisabledChanged(disabled) => {
if self.show_disabled {
if *disabled {
self.text_layout
.set_brush(crate::theme::DISABLED_TEXT_COLOR);
} else {
self.text_layout.set_brush(self.brush.clone());
}
Update::DisabledChanged(_) => {
if self.disabled_brush.is_some() {
ctx.request_paint_only();
}
// TODO: Parley seems to require a relayout when colours change
ctx.request_layout();
}
_ => {}
}
}
fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
// Compute max_advance from box constraints
let max_advance = if self.line_break_mode != LineBreaking::WordWrap {
None
} else if bc.max().width.is_finite() {
let available_width = if bc.max().width.is_finite() {
Some(bc.max().width as f32 - 2. * LABEL_X_PADDING as f32)
} else if bc.min().width.is_sign_negative() {
Some(0.0)
} else {
None
};
self.text_layout.set_max_advance(max_advance);
if self.text_layout.needs_rebuild() || self.text_changed {
let max_advance = if self.line_break_mode == LineBreaking::WordWrap {
available_width
} else {
None
};
let styles_changed = self.styles_changed;
if self.styles_changed {
let (font_ctx, layout_ctx) = ctx.text_contexts();
self.text_layout
.rebuild(font_ctx, layout_ctx, &self.text, self.text_changed);
self.text_changed = false;
// TODO: Should we use a different scale?
let mut builder = layout_ctx.ranged_builder(font_ctx, &self.text, 1.0);
for prop in self.styles.inner().values() {
builder.push_default(prop.to_owned());
}
builder.build_into(&mut self.text_layout, &self.text);
self.styles_changed = false;
}
// We would like to ignore trailing whitespace for a label.
// However, Parley doesn't make that an option when using `max_advance`.
// If we aren't wrapping words, we can safely ignore this, however.
let text_size = self.text_layout.size();
if max_advance != self.last_max_advance || styles_changed {
self.text_layout.break_all_lines(max_advance);
self.last_max_advance = max_advance;
self.alignment_changed = true;
}
let alignment_width = if self.alignment == Alignment::Start {
self.text_layout.width()
} else if let Some(width) = available_width {
// We use the full available space to calculate text alignment and therefore
// determine the widget's current width.
//
// As a special case, we don't do that if the alignment is to the start.
// In theory, we should be passed down how our parent expects us to be aligned;
// however that isn't currently handled.
//
// This does effectively mean that the widget takes up all the available space and
// therefore doesn't play nicely with adjacent widgets unless `Start` alignment is used.
//
// The coherent way to have multiple items laid out on the same line and alignment is for them to
// be inside the same text layout object "region".
width
} else {
// TODO: Warn on the rising edge of entering this state for this widget?
self.text_layout.width()
};
if self.alignment_changed {
self.text_layout
.align(Some(alignment_width), self.alignment);
}
let text_size = Size::new(alignment_width.into(), self.text_layout.height().into());
let label_size = Size {
height: text_size.height,
width: text_size.width + 2. * LABEL_X_PADDING,
@ -215,18 +389,20 @@ impl Widget for Label {
}
fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut Scene) {
if self.text_layout.needs_rebuild() {
debug_panic!(
"Called {name}::paint with invalid layout",
name = self.short_type_name()
);
}
if self.line_break_mode == LineBreaking::Clip {
let clip_rect = ctx.size().to_rect();
scene.push_layer(BlendMode::default(), 1., Affine::IDENTITY, &clip_rect);
}
self.text_layout
.draw(scene, Point::new(LABEL_X_PADDING, 0.0));
let transform = Affine::translate((LABEL_X_PADDING, 0.));
let brush = if ctx.is_disabled() {
self.disabled_brush
.clone()
.unwrap_or_else(|| self.brush.clone())
} else {
self.brush.clone()
};
render_text(scene, transform, &self.text_layout, &[brush], self.hint);
if self.line_break_mode == LineBreaking::Clip {
scene.pop_layer();
@ -237,18 +413,22 @@ impl Widget for Label {
Role::Label
}
fn accessibility(&mut self, _ctx: &mut AccessCtx, node: &mut NodeBuilder) {
node.set_name(self.text().as_ref().to_string());
fn accessibility(&mut self, _ctx: &mut AccessCtx, node: &mut Node) {
self.accessibility.build_nodes(
self.text.as_ref(),
&self.text_layout,
_ctx.tree_update,
node,
|| NodeId::from(WidgetId::next()),
LABEL_X_PADDING,
0.0,
);
}
fn children_ids(&self) -> SmallVec<[WidgetId; 16]> {
SmallVec::new()
}
fn accepts_pointer_interaction(&self) -> bool {
false
}
fn make_trace_span(&self, ctx: &QueryCtx<'_>) -> Span {
trace_span!("Label", id = ctx.widget_id().trace())
}
@ -263,6 +443,7 @@ impl Widget for Label {
mod tests {
use insta::assert_debug_snapshot;
use parley::style::GenericFamily;
use parley::FontFamily;
use super::*;
use crate::assert_render_snapshot;
@ -283,11 +464,11 @@ mod tests {
#[test]
fn styled_label() {
let label = Label::new("The quick brown fox jumps over the lazy dog")
.with_text_brush(PRIMARY_LIGHT)
.with_font_family(FontFamily::Generic(GenericFamily::Monospace))
.with_text_size(20.0)
.with_brush(PRIMARY_LIGHT)
.with_style(FontFamily::Generic(GenericFamily::Monospace))
.with_style(StyleProperty::FontSize(20.0))
.with_line_break_mode(LineBreaking::WordWrap)
.with_text_alignment(Alignment::Middle);
.with_alignment(Alignment::Middle);
let mut harness = TestHarness::create_with_size(label, Size::new(200.0, 200.0));
@ -300,19 +481,20 @@ mod tests {
fn label_alignment_flex() {
fn base_label() -> Label {
Label::new("Hello")
.with_text_size(10.0)
.with_style(StyleProperty::FontSize(10.0))
.with_line_break_mode(LineBreaking::WordWrap)
}
let label1 = base_label().with_text_alignment(Alignment::Start);
let label2 = base_label().with_text_alignment(Alignment::Middle);
let label3 = base_label().with_text_alignment(Alignment::End);
let label4 = base_label().with_text_alignment(Alignment::Start);
let label5 = base_label().with_text_alignment(Alignment::Middle);
let label6 = base_label().with_text_alignment(Alignment::End);
let label1 = base_label().with_alignment(Alignment::Start);
let label2 = base_label().with_alignment(Alignment::Middle);
let label3 = base_label().with_alignment(Alignment::End);
let label4 = base_label().with_alignment(Alignment::Start);
let label5 = base_label().with_alignment(Alignment::Middle);
let label6 = base_label().with_alignment(Alignment::End);
let flex = Flex::column()
.with_flex_child(label1, CrossAxisAlignment::Start)
.with_flex_child(label2, CrossAxisAlignment::Start)
.with_flex_child(label3, CrossAxisAlignment::Start)
// Text alignment start is "overwritten" by CrossAxisAlignment::Center.
.with_flex_child(label4, CrossAxisAlignment::Center)
.with_flex_child(label5, CrossAxisAlignment::Center)
.with_flex_child(label6, CrossAxisAlignment::Center)
@ -361,11 +543,11 @@ mod tests {
fn edit_label() {
let image_1 = {
let label = Label::new("The quick brown fox jumps over the lazy dog")
.with_text_brush(PRIMARY_LIGHT)
.with_font_family(FontFamily::Generic(GenericFamily::Monospace))
.with_text_size(20.0)
.with_brush(PRIMARY_LIGHT)
.with_style(FontFamily::Generic(GenericFamily::Monospace))
.with_style(StyleProperty::FontSize(20.0))
.with_line_break_mode(LineBreaking::WordWrap)
.with_text_alignment(Alignment::Middle);
.with_alignment(Alignment::Middle);
let mut harness = TestHarness::create_with_size(label, Size::new(50.0, 50.0));
@ -374,17 +556,17 @@ mod tests {
let image_2 = {
let label = Label::new("Hello world")
.with_text_brush(PRIMARY_DARK)
.with_text_size(40.0);
.with_brush(PRIMARY_DARK)
.with_style(StyleProperty::FontSize(40.0));
let mut harness = TestHarness::create_with_size(label, Size::new(50.0, 50.0));
harness.edit_root_widget(|mut label| {
let mut label = label.downcast::<Label>();
Label::set_text(&mut label, "The quick brown fox jumps over the lazy dog");
Label::set_text_brush(&mut label, PRIMARY_LIGHT);
Label::set_font_family(&mut label, FontFamily::Generic(GenericFamily::Monospace));
Label::set_text_size(&mut label, 20.0);
Label::set_brush(&mut label, PRIMARY_LIGHT);
Label::insert_style(&mut label, FontFamily::Generic(GenericFamily::Monospace));
Label::insert_style(&mut label, StyleProperty::FontSize(20.0));
Label::set_line_break_mode(&mut label, LineBreaking::WordWrap);
Label::set_alignment(&mut label, Alignment::Middle);
});

View File

@ -5,7 +5,7 @@
use std::ops::Range;
use accesskit::{NodeBuilder, Role};
use accesskit::{Node, Role};
use smallvec::{smallvec, SmallVec};
use tracing::{trace_span, Span};
use vello::kurbo::{Point, Rect, Size, Vec2};
@ -435,7 +435,7 @@ impl<W: Widget> Widget for Portal<W> {
Role::GenericContainer
}
fn accessibility(&mut self, ctx: &mut AccessCtx, node: &mut NodeBuilder) {
fn accessibility(&mut self, ctx: &mut AccessCtx, node: &mut Node) {
// TODO - Double check this code
// Not sure about these values
if false {

View File

@ -3,29 +3,30 @@
//! A progress bar widget.
use accesskit::{NodeBuilder, Role};
use accesskit::{Node, Role};
use smallvec::{smallvec, SmallVec};
use tracing::{trace_span, Span};
use vello::Scene;
use crate::kurbo::Size;
use crate::paint_scene_helpers::{fill_lin_gradient, stroke, UnitPoint};
use crate::text::{ArcStr, TextLayout};
use crate::text::ArcStr;
use crate::widget::WidgetMut;
use crate::{
theme, AccessCtx, AccessEvent, BoxConstraints, EventCtx, LayoutCtx, PaintCtx, Point,
PointerEvent, QueryCtx, RegisterCtx, TextEvent, Update, UpdateCtx, Widget, WidgetId,
};
/// A progress bar
use super::{Label, LineBreaking, WidgetPod};
/// A progress bar.
pub struct ProgressBar {
/// A value in the range `[0, 1]` inclusive, where 0 is 0% and 1 is 100% complete.
///
/// `None` variant can be used to show a progress bar without a percentage.
/// It is also used if an invalid float (outside of [0, 1]) is passed.
progress: Option<f64>,
progress_changed: bool,
label: TextLayout,
label: WidgetPod<Label>,
}
impl ProgressBar {
@ -34,34 +35,12 @@ impl ProgressBar {
/// `progress` is a number between 0 and 1 inclusive. If it is `NaN`, then an
/// indefinite progress bar will be shown.
/// Otherwise, the input will be clamped to [0, 1].
pub fn new(progress: Option<f64>) -> Self {
let mut out = Self::new_indefinite();
out.set_progress_inner(progress);
out
}
fn new_indefinite() -> Self {
Self {
progress: None,
progress_changed: false,
label: TextLayout::new(crate::theme::TEXT_SIZE_NORMAL),
}
}
fn set_progress_inner(&mut self, mut progress: Option<f64>) {
pub fn new(mut progress: Option<f64>) -> Self {
clamp_progress(&mut progress);
// check to see if we can avoid doing work
if self.progress != progress {
self.progress = progress;
self.progress_changed = true;
}
}
fn value(&self) -> ArcStr {
if let Some(value) = self.progress {
format!("{:.0}%", value * 100.).into()
} else {
"".into()
}
let label = WidgetPod::new(
Label::new(Self::value(progress)).with_line_break_mode(LineBreaking::Overflow),
);
Self { progress, label }
}
fn value_accessibility(&self) -> Box<str> {
@ -71,12 +50,26 @@ impl ProgressBar {
"progress unspecified".into()
}
}
fn value(progress: Option<f64>) -> ArcStr {
if let Some(value) = progress {
format!("{:.0}%", value * 100.).into()
} else {
"".into()
}
}
}
// --- MARK: WIDGETMUT ---
impl ProgressBar {
pub fn set_progress(this: &mut WidgetMut<'_, Self>, progress: Option<f64>) {
this.widget.set_progress_inner(progress);
pub fn set_progress(this: &mut WidgetMut<'_, Self>, mut progress: Option<f64>) {
clamp_progress(&mut progress);
let progress_changed = this.widget.progress != progress;
if progress_changed {
this.widget.progress = progress;
let mut label = this.ctx.get_mut(&mut this.widget.label);
Label::set_text(&mut label, Self::value(progress));
}
this.ctx.request_layout();
this.ctx.request_render();
}
@ -103,35 +96,34 @@ impl Widget for ProgressBar {
fn on_access_event(&mut self, _ctx: &mut EventCtx, _event: &AccessEvent) {}
fn register_children(&mut self, _ctx: &mut RegisterCtx) {}
fn register_children(&mut self, ctx: &mut RegisterCtx) {
ctx.register_child(&mut self.label);
}
fn update(&mut self, _ctx: &mut UpdateCtx, _event: &Update) {}
fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
const DEFAULT_WIDTH: f64 = 400.;
if self.label.needs_rebuild() || self.progress_changed {
let (font_ctx, layout_ctx) = ctx.text_contexts();
self.label
.rebuild(font_ctx, layout_ctx, &self.value(), self.progress_changed);
self.progress_changed = false;
}
let label_size = self.label.size();
// TODO: Clearer constraints here
let label_size = ctx.run_layout(&mut self.label, &bc.loosen());
let desired_size = Size::new(
DEFAULT_WIDTH.max(label_size.width),
crate::theme::BASIC_WIDGET_HEIGHT.max(label_size.height),
);
bc.constrain(desired_size)
let final_size = bc.constrain(desired_size);
// center text
let text_pos = Point::new(
((final_size.width - label_size.width) * 0.5).max(0.),
((final_size.height - label_size.height) * 0.5).max(0.),
);
ctx.place_child(&mut self.label, text_pos);
final_size
}
fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut Scene) {
let border_width = 1.;
if self.label.needs_rebuild() {
debug_panic!("Called ProgressBar paint before layout");
}
let rect = ctx
.size()
.to_rect()
@ -165,22 +157,13 @@ impl Widget for ProgressBar {
UnitPoint::BOTTOM,
);
stroke(scene, &progress_rect, theme::BORDER_DARK, border_width);
// center text
let widget_size = ctx.size();
let label_size = self.label.size();
let text_pos = Point::new(
((widget_size.width - label_size.width) * 0.5).max(0.),
((widget_size.height - label_size.height) * 0.5).max(0.),
);
self.label.draw(scene, text_pos);
}
fn accessibility_role(&self) -> Role {
Role::ProgressIndicator
}
fn accessibility(&mut self, _ctx: &mut AccessCtx, node: &mut NodeBuilder) {
fn accessibility(&mut self, _ctx: &mut AccessCtx, node: &mut Node) {
node.set_value(self.value_accessibility());
if let Some(value) = self.progress {
node.set_numeric_value(value * 100.0);
@ -188,7 +171,7 @@ impl Widget for ProgressBar {
}
fn children_ids(&self) -> SmallVec<[WidgetId; 16]> {
smallvec![]
smallvec![self.label.id()]
}
fn make_trace_span(&self, ctx: &QueryCtx<'_>) -> Span {
@ -209,8 +192,6 @@ mod tests {
use crate::assert_render_snapshot;
use crate::testing::{widget_ids, TestHarness, TestWidgetExt};
// TODO - Add WidgetMut test
#[test]
fn indeterminate_progressbar() {
let [progressbar_id] = widget_ids();
@ -271,4 +252,31 @@ mod tests {
assert_debug_snapshot!(harness.root_widget());
assert_render_snapshot!(harness, "100_percent_progressbar");
}
#[test]
fn edit_progressbar() {
let image_1 = {
let bar = ProgressBar::new(Some(0.5));
let mut harness = TestHarness::create_with_size(bar, Size::new(60.0, 20.0));
harness.render()
};
let image_2 = {
let bar = ProgressBar::new(None);
let mut harness = TestHarness::create_with_size(bar, Size::new(60.0, 20.0));
harness.edit_root_widget(|mut label| {
let mut bar = label.downcast::<ProgressBar>();
ProgressBar::set_progress(&mut bar, Some(0.5));
});
harness.render()
};
// We don't use assert_eq because we don't want rich assert
assert!(image_1 == image_2);
}
}

View File

@ -1,20 +1,25 @@
// Copyright 2018 the Xilem Authors and the Druid Authors
// SPDX-License-Identifier: Apache-2.0
use accesskit::{NodeBuilder, Role};
use std::mem::Discriminant;
use std::time::Instant;
use crate::text::{render_text, Generation, PlainEditor};
use accesskit::{Node, NodeId, Role};
use parley::layout::Alignment;
use parley::style::{FontFamily, FontStack};
use smallvec::SmallVec;
use tracing::{trace_span, Span};
use vello::kurbo::{Affine, Point, Size};
use vello::peniko::BlendMode;
use vello::peniko::{BlendMode, Brush, Color, Fill};
use vello::Scene;
use winit::keyboard::{Key, NamedKey};
use crate::text::{ArcStr, TextBrush, TextWithSelection};
use crate::text::{ArcStr, BrushIndex, StyleProperty, StyleSet};
use crate::widget::{LineBreaking, WidgetMut};
use crate::{
AccessCtx, AccessEvent, BoxConstraints, CursorIcon, EventCtx, LayoutCtx, PaintCtx,
PointerEvent, QueryCtx, RegisterCtx, TextEvent, Update, UpdateCtx, Widget, WidgetId,
theme, AccessCtx, AccessEvent, BoxConstraints, CursorIcon, EventCtx, LayoutCtx, PaintCtx,
PointerButton, PointerEvent, QueryCtx, RegisterCtx, TextEvent, Update, UpdateCtx, Widget,
WidgetId,
};
/// Added padding between each horizontal edge of the widget
@ -26,145 +31,323 @@ const PROSE_X_PADDING: f64 = 2.0;
/// but cannot be modified by the user.
///
/// This should be preferred over [`Label`](super::Label) for most
/// immutable text, other than that within
/// immutable text, other than that within other widgets.
pub struct Prose {
// See `Label` for discussion of the choice of text type
text_layout: TextWithSelection<ArcStr>,
editor: PlainEditor<BrushIndex>,
rendered_generation: Generation,
pending_text: Option<ArcStr>,
last_click_time: Option<Instant>,
click_count: u32,
// TODO: Support for links?
//https://github.com/linebender/xilem/issues/360
styles: StyleSet,
/// Whether `styles` has been updated since `text_layout` was updated.
///
/// If they have, the layout needs to be recreated.
styles_changed: bool,
line_break_mode: LineBreaking,
show_disabled: bool,
brush: TextBrush,
alignment: Alignment,
/// Whether the alignment has changed since the last layout, which would force a re-alignment.
alignment_changed: bool,
/// The value of `max_advance` when this layout was last calculated.
///
/// If it has changed, we need to re-perform line-breaking.
last_max_advance: Option<f32>,
/// The brush for drawing this label's text.
///
/// Requires a new paint if edited whilst `disabled_brush` is not being used.
brush: Brush,
/// The brush to use whilst this widget is disabled.
///
/// When this is `None`, `brush` will be used.
/// Requires a new paint if edited whilst this widget is disabled.
disabled_brush: Option<Brush>,
/// Whether to hint whilst drawing the text.
///
/// Should be disabled whilst an animation involving this label is ongoing.
// TODO: What classes of animations?
hint: bool,
}
// --- MARK: BUILDERS ---
impl Prose {
pub fn new(text: impl Into<ArcStr>) -> Self {
let editor = PlainEditor::default();
Prose {
text_layout: TextWithSelection::new(text.into(), crate::theme::TEXT_SIZE_NORMAL),
editor,
rendered_generation: Generation::default(),
pending_text: Some(text.into()),
last_click_time: None,
click_count: 0,
styles: StyleSet::new(theme::TEXT_SIZE_NORMAL),
styles_changed: true,
line_break_mode: LineBreaking::WordWrap,
show_disabled: true,
brush: crate::theme::TEXT_COLOR.into(),
alignment: Alignment::Start,
alignment_changed: true,
last_max_advance: None,
brush: theme::TEXT_COLOR.into(),
disabled_brush: Some(theme::DISABLED_TEXT_COLOR.into()),
hint: true,
}
}
// TODO: Can we reduce code duplication with `Label` widget somehow?
pub fn text(&self) -> &ArcStr {
self.text_layout.text()
/// Get the current text of this label.
///
/// To update the text of an active label, use [`set_text`](Self::set_text).
pub fn text(&self) -> &str {
self.editor.text()
}
#[doc(alias = "with_text_color")]
pub fn with_text_brush(mut self, brush: impl Into<TextBrush>) -> Self {
self.brush = brush.into();
self.text_layout.set_brush(self.brush.clone());
/// Set a style property for the new label.
///
/// Setting [`StyleProperty::Brush`](parley::StyleProperty::Brush) is not supported.
/// Use `with_brush` instead.
///
/// To set a style property on an active label, use [`insert_style`](Self::insert_style).
pub fn with_style(mut self, property: impl Into<StyleProperty>) -> Self {
self.insert_style_inner(property.into());
self
}
#[doc(alias = "with_font_size")]
pub fn with_text_size(mut self, size: f32) -> Self {
self.text_layout.set_text_size(size);
self
}
pub fn with_text_alignment(mut self, alignment: Alignment) -> Self {
self.text_layout.set_text_alignment(alignment);
self
}
pub fn with_font(mut self, font: FontStack<'static>) -> Self {
self.text_layout.set_font(font);
self
}
pub fn with_font_family(self, font: FontFamily<'static>) -> Self {
self.with_font(FontStack::Single(font))
/// Set a style property for the new label, returning the old value.
///
/// Most users should prefer [`with_style`](Self::with_style) instead.
pub fn try_with_style(
mut self,
property: impl Into<StyleProperty>,
) -> (Self, Option<StyleProperty>) {
let old = self.insert_style_inner(property.into());
(self, old)
}
/// Set how line breaks will be handled by this label.
///
/// To modify this on an active label, use [`set_line_break_mode`](Self::set_line_break_mode).
pub fn with_line_break_mode(mut self, line_break_mode: LineBreaking) -> Self {
self.line_break_mode = line_break_mode;
self
}
/// Set the alignment of the text.
///
/// Text alignment might have unexpected results when the label has no horizontal constraints.
/// To modify this on an active label, use [`set_alignment`](Self::set_alignment).
pub fn with_alignment(mut self, alignment: Alignment) -> Self {
self.alignment = alignment;
self
}
/// Set the brush used to paint this label.
///
/// In most cases, this will be the text's color, but gradients and images are also supported.
///
/// To modify this on an active label, use [`set_brush`](Self::set_brush).
#[doc(alias = "with_color")]
pub fn with_brush(mut self, brush: impl Into<Brush>) -> Self {
self.brush = brush.into();
self
}
/// Set the brush which will be used to paint this label whilst it is disabled.
///
/// If this is `None`, the [normal brush](Self::with_brush) will be used.
/// To modify this on an active label, use [`set_disabled_brush`](Self::set_disabled_brush).
#[doc(alias = "with_color")]
pub fn with_disabled_brush(mut self, disabled_brush: impl Into<Option<Brush>>) -> Self {
self.disabled_brush = disabled_brush.into();
self
}
/// Set whether [hinting](https://en.wikipedia.org/wiki/Font_hinting) will be used for this label.
///
/// Hinting is a process where text is drawn "snapped" to pixel boundaries to improve fidelity.
/// The default is true, i.e. hinting is enabled by default.
///
/// This should be set to false if the label will be animated at creation.
/// The kinds of relevant animations include changing variable font parameters,
/// translating or scaling.
/// Failing to do so will likely lead to an unpleasant shimmering effect, as different parts of the
/// text "snap" at different times.
///
/// To modify this on an active label, use [`set_hint`](Self::set_hint).
// TODO: Should we tell each widget if smooth scrolling is ongoing so they can disable their hinting?
// Alternatively, we should automate disabling hinting at the Vello layer when composing.
pub fn with_hint(mut self, hint: bool) -> Self {
self.hint = hint;
self
}
/// Shared logic between `with_style` and `insert_style`
fn insert_style_inner(&mut self, property: StyleProperty) -> Option<StyleProperty> {
if let StyleProperty::Brush(idx @ BrushIndex(1..)) = &property {
debug_panic!(
"Can't set a non-zero brush index ({idx:?}) on a `Label`, as it only supports global styling."
);
}
self.styles.insert(property)
}
}
// --- MARK: WIDGETMUT ---
impl Prose {
pub fn set_text_properties<R>(
this: &mut WidgetMut<'_, Self>,
f: impl FnOnce(&mut TextWithSelection<ArcStr>) -> R,
) -> R {
let ret = f(&mut this.widget.text_layout);
if this.widget.text_layout.needs_rebuild() {
this.ctx.request_layout();
}
ret
}
/// Change the text. If the user currently has a selection in the box, this will delete that selection.
// Note: These docs are lazy, but also have a decreased likelihood of going out of date.
/// The runtime requivalent of [`with_style`](Self::with_style).
///
/// We enforce this to be an `ArcStr` to make the allocation explicit.
pub fn set_text(this: &mut WidgetMut<'_, Self>, new_text: ArcStr) {
if this.ctx.is_focused() {
tracing::info!(
"Called reset_text on a focused `Prose`. This will lose the user's current selection"
);
}
Self::set_text_properties(this, |layout| layout.set_text(new_text));
/// Setting [`StyleProperty::Brush`](parley::StyleProperty::Brush) is not supported.
/// Use [`set_brush`](Self::set_brush) instead.
pub fn insert_style(
this: &mut WidgetMut<'_, Self>,
property: impl Into<StyleProperty>,
) -> Option<StyleProperty> {
let old = this.widget.insert_style_inner(property.into());
this.widget.styles_changed = true;
this.ctx.request_layout();
old
}
#[doc(alias = "set_text_color")]
pub fn set_text_brush(this: &mut WidgetMut<'_, Self>, brush: impl Into<TextBrush>) {
let brush = brush.into();
this.widget.brush = brush;
if !this.ctx.is_disabled() {
let brush = this.widget.brush.clone();
Self::set_text_properties(this, |layout| layout.set_brush(brush));
}
/// Keep only the styles for which `f` returns true.
///
/// Styles which are removed return to Parley's default values.
/// In most cases, these are the defaults for this widget.
///
/// Of note, behaviour is unspecified for unsetting the [`FontSize`](parley::StyleProperty::FontSize).
pub fn retain_styles(this: &mut WidgetMut<'_, Self>, f: impl FnMut(&StyleProperty) -> bool) {
this.widget.styles.retain(f);
this.widget.styles_changed = true;
this.ctx.request_layout();
}
pub fn set_text_size(this: &mut WidgetMut<'_, Self>, size: f32) {
Self::set_text_properties(this, |layout| layout.set_text_size(size));
/// Remove the style with the discriminant `property`.
///
/// To get the discriminant requires constructing a valid `StyleProperty` for the
/// the desired property and passing it to [`core::mem::discriminant`].
/// Getting this discriminant is usually possible in a `const` context.
///
/// Styles which are removed return to Parley's default values.
/// In most cases, these are the defaults for this widget.
///
/// Of note, behaviour is unspecified for unsetting the [`FontSize`](parley::StyleProperty::FontSize).
pub fn remove_style(
this: &mut WidgetMut<'_, Self>,
property: Discriminant<StyleProperty>,
) -> Option<StyleProperty> {
let old = this.widget.styles.remove(property);
this.widget.styles_changed = true;
this.ctx.request_layout();
old
}
pub fn set_alignment(this: &mut WidgetMut<'_, Self>, alignment: Alignment) {
Self::set_text_properties(this, |layout| layout.set_text_alignment(alignment));
}
pub fn set_font(this: &mut WidgetMut<'_, Self>, font_stack: FontStack<'static>) {
Self::set_text_properties(this, |layout| layout.set_font(font_stack));
}
pub fn set_font_family(this: &mut WidgetMut<'_, Self>, family: FontFamily<'static>) {
Self::set_font(this, FontStack::Single(family));
/// Replace the text of this widget.
pub fn set_text(this: &mut WidgetMut<'_, Self>, new_text: impl Into<ArcStr>) {
this.widget.pending_text = Some(new_text.into());
this.ctx.request_layout();
}
/// The runtime requivalent of [`with_line_break_mode`](Self::with_line_break_mode).
pub fn set_line_break_mode(this: &mut WidgetMut<'_, Self>, line_break_mode: LineBreaking) {
this.widget.line_break_mode = line_break_mode;
// We don't need to set an internal invalidation, as `max_advance` is always recalculated
this.ctx.request_layout();
}
/// The runtime requivalent of [`with_alignment`](Self::with_alignment).
pub fn set_alignment(this: &mut WidgetMut<'_, Self>, alignment: Alignment) {
this.widget.alignment = alignment;
this.widget.alignment_changed = true;
this.ctx.request_layout();
}
#[doc(alias = "set_color")]
/// The runtime requivalent of [`with_brush`](Self::with_brush).
pub fn set_brush(this: &mut WidgetMut<'_, Self>, brush: impl Into<Brush>) {
let brush = brush.into();
this.widget.brush = brush;
// We need to repaint unless the disabled brush is currently being used.
if this.widget.disabled_brush.is_none() || this.ctx.is_disabled() {
this.ctx.request_paint_only();
}
}
/// The runtime requivalent of [`with_disabled_brush`](Self::with_disabled_brush).
pub fn set_disabled_brush(this: &mut WidgetMut<'_, Self>, brush: impl Into<Option<Brush>>) {
let brush = brush.into();
this.widget.disabled_brush = brush;
if this.ctx.is_disabled() {
this.ctx.request_paint_only();
}
}
/// The runtime requivalent of [`with_hint`](Self::with_hint).
pub fn set_hint(this: &mut WidgetMut<'_, Self>, hint: bool) {
this.widget.hint = hint;
this.ctx.request_paint_only();
}
}
// --- MARK: IMPL WIDGET ---
impl Widget for Prose {
fn on_pointer_event(&mut self, ctx: &mut EventCtx, event: &PointerEvent) {
if self.pending_text.is_some() {
debug_panic!("`set_text` on `Prose` was called before an event started");
}
let window_origin = ctx.widget_state.window_origin();
let inner_origin = Point::new(window_origin.x + PROSE_X_PADDING, window_origin.y);
match event {
PointerEvent::PointerDown(button, state) => {
if !ctx.is_disabled() {
// TODO: Start tracking currently pressed link?
let made_change = self.text_layout.pointer_down(inner_origin, state, *button);
if made_change {
ctx.request_layout();
ctx.request_focus();
ctx.capture_pointer();
if !ctx.is_disabled() && *button == PointerButton::Primary {
let now = Instant::now();
if let Some(last) = self.last_click_time.take() {
if now.duration_since(last).as_secs_f64() < 0.25 {
self.click_count = (self.click_count + 1) % 4;
} else {
self.click_count = 1;
}
} else {
self.click_count = 1;
}
self.last_click_time = Some(now);
let click_count = self.click_count;
let cursor_pos = Point::new(state.position.x, state.position.y) - inner_origin;
let (fctx, lctx) = ctx.text_contexts();
self.editor.transact(fctx, lctx, |txn| match click_count {
2 => txn.select_word_at_point(cursor_pos.x as f32, cursor_pos.y as f32),
3 => txn.select_line_at_point(cursor_pos.x as f32, cursor_pos.y as f32),
_ => txn.move_to_point(cursor_pos.x as f32, cursor_pos.y as f32),
});
let new_generation = self.editor.generation();
if new_generation != self.rendered_generation {
ctx.request_render();
self.rendered_generation = new_generation;
}
ctx.request_focus();
ctx.capture_pointer();
}
}
PointerEvent::PointerMove(state) => {
if !ctx.is_disabled()
&& ctx.has_pointer_capture()
&& self.text_layout.pointer_move(inner_origin, state)
{
// We might have changed text colours, so we need to re-request a layout
ctx.request_layout();
}
}
PointerEvent::PointerUp(button, state) => {
// TODO: Follow link (if not now dragging ?)
if !ctx.is_disabled() && ctx.has_pointer_capture() {
self.text_layout.pointer_up(inner_origin, state, *button);
let cursor_pos = Point::new(state.position.x, state.position.y) - inner_origin;
let (fctx, lctx) = ctx.text_contexts();
self.editor.transact(fctx, lctx, |txn| {
txn.extend_selection_to_point(cursor_pos.x as f32, cursor_pos.y as f32);
});
let new_generation = self.editor.generation();
if new_generation != self.rendered_generation {
ctx.request_render();
self.rendered_generation = new_generation;
}
}
}
_ => {}
@ -172,23 +355,144 @@ impl Widget for Prose {
}
fn on_text_event(&mut self, ctx: &mut EventCtx, event: &TextEvent) {
// If focused on a link and enter pressed, follow it?
let result = self.text_layout.text_event(event);
if result.is_handled() {
ctx.set_handled();
// TODO: only some handlers need this repaint
ctx.request_layout();
if self.pending_text.is_some() {
debug_panic!("`set_text` on `Prose` was called before an event started");
}
match event {
TextEvent::KeyboardKey(key_event, modifiers_state) => {
if !key_event.state.is_pressed() {
return;
}
#[allow(unused)]
let (shift, action_mod) = (
modifiers_state.shift_key(),
if cfg!(target_os = "macos") {
modifiers_state.super_key()
} else {
modifiers_state.control_key()
},
);
let (fctx, lctx) = ctx.text_contexts();
match &key_event.logical_key {
#[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))]
Key::Character(c) if action_mod && matches!(c.as_str(), "c") => {
// TODO: use clipboard_rs::{Clipboard, ClipboardContext};
match c.to_lowercase().as_str() {
"c" => {
if let crate::text::ActiveText::Selection(_) =
self.editor.active_text()
{
// let cb = ClipboardContext::new().unwrap();
// cb.set_text(text.to_owned()).ok();
}
}
_ => (),
}
}
Key::Character(c) if action_mod && matches!(c.to_lowercase().as_str(), "a") => {
self.editor.transact(fctx, lctx, |txn| {
if shift {
txn.collapse_selection();
} else {
txn.select_all();
}
});
}
Key::Named(NamedKey::ArrowLeft) => self.editor.transact(fctx, lctx, |txn| {
if action_mod {
if shift {
txn.select_word_left();
} else {
txn.move_word_left();
}
} else if shift {
txn.select_left();
} else {
txn.move_left();
}
}),
Key::Named(NamedKey::ArrowRight) => self.editor.transact(fctx, lctx, |txn| {
if action_mod {
if shift {
txn.select_word_right();
} else {
txn.move_word_right();
}
} else if shift {
txn.select_right();
} else {
txn.move_right();
}
}),
Key::Named(NamedKey::ArrowUp) => self.editor.transact(fctx, lctx, |txn| {
if shift {
txn.select_up();
} else {
txn.move_up();
}
}),
Key::Named(NamedKey::ArrowDown) => self.editor.transact(fctx, lctx, |txn| {
if shift {
txn.select_down();
} else {
txn.move_down();
}
}),
Key::Named(NamedKey::Home) => self.editor.transact(fctx, lctx, |txn| {
if action_mod {
if shift {
txn.select_to_text_start();
} else {
txn.move_to_text_start();
}
} else if shift {
txn.select_to_line_start();
} else {
txn.move_to_line_start();
}
}),
Key::Named(NamedKey::End) => self.editor.transact(fctx, lctx, |txn| {
if action_mod {
if shift {
txn.select_to_text_end();
} else {
txn.move_to_text_end();
}
} else if shift {
txn.select_to_line_end();
} else {
txn.move_to_line_end();
}
}),
_ => (),
}
let new_generation = self.editor.generation();
if new_generation != self.rendered_generation {
ctx.request_render();
self.rendered_generation = new_generation;
}
}
// TODO: Set our highlighting colour to a lighter blue as window unfocused
TextEvent::FocusChange(_) => {}
TextEvent::Ime(e) => {
// TODO: Handle the cursor movement things from https://github.com/rust-windowing/winit/pull/3824
tracing::warn!(event = ?e, "Prose doesn't accept IME");
}
TextEvent::ModifierChange(_) => {}
}
}
fn accepts_focus(&self) -> bool {
false
}
fn on_access_event(&mut self, ctx: &mut EventCtx, event: &AccessEvent) {
match event.action {
accesskit::Action::SetTextSelection => {
if self.text_layout.set_selection_from_access_event(event) {
ctx.request_layout();
}
if event.action == accesskit::Action::SetTextSelection {
if let Some(accesskit::ActionData::SetTextSelection(selection)) = &event.data {
let (fctx, lctx) = ctx.text_contexts();
self.editor
.transact(fctx, lctx, |txn| txn.select_from_accesskit(selection));
}
_ => (),
}
}
@ -197,68 +501,95 @@ impl Widget for Prose {
fn update(&mut self, ctx: &mut UpdateCtx, event: &Update) {
match event {
Update::FocusChanged(false) => {
self.text_layout.focus_lost();
ctx.request_layout();
// TODO: Stop focusing on any links
ctx.request_render();
}
Update::FocusChanged(true) => {
// TODO: Focus on first link
ctx.request_render();
}
Update::DisabledChanged(disabled) => {
if self.show_disabled {
if *disabled {
self.text_layout
.set_brush(crate::theme::DISABLED_TEXT_COLOR);
} else {
self.text_layout.set_brush(self.brush.clone());
}
}
// TODO: Parley seems to require a relayout when colours change
ctx.request_layout();
Update::DisabledChanged(_) => {
// We might need to use the disabled brush, and stop displaying the selection.
ctx.request_render();
}
_ => {}
}
}
fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
// Compute max_advance from box constraints
let max_advance = if self.line_break_mode != LineBreaking::WordWrap {
None
} else if bc.max().width.is_finite() {
// TODO: Does Prose have different needs here?
Some(bc.max().width as f32 - 2. * PROSE_X_PADDING as f32)
} else if bc.min().width.is_sign_negative() {
Some(0.0)
} else {
None
};
self.text_layout.set_max_advance(max_advance);
if self.text_layout.needs_rebuild() {
let (font_ctx, layout_ctx) = ctx.text_contexts();
self.text_layout.rebuild(font_ctx, layout_ctx);
}
// We include trailing whitespace for prose, as it can be selected.
let text_size = self.text_layout.full_size();
let label_size = Size {
let (fctx, lctx) = ctx.text_contexts();
let max_advance = self.editor.transact(fctx, lctx, |txn| {
if let Some(pending_text) = self.pending_text.take() {
txn.select_to_text_start();
txn.collapse_selection();
txn.set_text(&pending_text);
}
let available_width = if bc.max().width.is_finite() {
Some(bc.max().width as f32 - 2. * PROSE_X_PADDING as f32)
} else {
None
};
let max_advance = if self.line_break_mode == LineBreaking::WordWrap {
available_width
} else {
None
};
if self.styles_changed {
let style = self.styles.inner().values().cloned().collect();
txn.set_default_style(style);
self.styles_changed = false;
}
if max_advance != self.last_max_advance {
txn.set_width(max_advance);
}
if self.alignment_changed {
txn.set_alignment(self.alignment);
}
max_advance
});
// We can't use the same feature as in label to make the width be minimal when the alignment is Start,
// because we don't have separate control over the alignment width in PlainEditor.
let alignment_width = max_advance.unwrap_or(self.editor.layout().width());
let text_size = Size::new(alignment_width.into(), self.editor.layout().height().into());
let prose_size = Size {
height: text_size.height,
width: text_size.width + 2. * PROSE_X_PADDING,
};
bc.constrain(label_size)
bc.constrain(prose_size)
}
fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut Scene) {
if self.text_layout.needs_rebuild() {
debug_panic!(
"Called {name}::paint with invalid layout",
name = self.short_type_name()
);
}
if self.line_break_mode == LineBreaking::Clip {
let clip_rect = ctx.size().to_rect();
scene.push_layer(BlendMode::default(), 1., Affine::IDENTITY, &clip_rect);
}
self.text_layout
.draw(scene, Point::new(PROSE_X_PADDING, 0.0));
let transform = Affine::translate((PROSE_X_PADDING, 0.));
for rect in self.editor.selection_geometry().iter() {
// TODO: If window not focused, use a different color
// TODO: Make configurable
scene.fill(Fill::NonZero, transform, Color::STEEL_BLUE, None, &rect);
}
if ctx.is_focused() {
if let Some(cursor) = self.editor.selection_strong_geometry(1.5) {
// TODO: Make configurable
scene.fill(Fill::NonZero, transform, Color::WHITE, None, &cursor);
};
if let Some(cursor) = self.editor.selection_weak_geometry(1.5) {
// TODO: Make configurable
scene.fill(Fill::NonZero, transform, Color::LIGHT_GRAY, None, &cursor);
};
}
let brush = if ctx.is_disabled() {
self.disabled_brush
.clone()
.unwrap_or_else(|| self.brush.clone())
} else {
self.brush.clone()
};
// TODO: Is disabling hinting ever right for prose?
render_text(scene, transform, self.editor.layout(), &[brush], self.hint);
if self.line_break_mode == LineBreaking::Clip {
scene.pop_layer();
@ -266,7 +597,6 @@ impl Widget for Prose {
}
fn get_cursor(&self, _ctx: &QueryCtx, _pos: Point) -> CursorIcon {
// TODO: Set cursor if over link
CursorIcon::Text
}
@ -274,9 +604,15 @@ impl Widget for Prose {
Role::Document
}
fn accessibility(&mut self, ctx: &mut AccessCtx, node: &mut NodeBuilder) {
fn accessibility(&mut self, ctx: &mut AccessCtx, node: &mut Node) {
node.set_read_only();
self.text_layout.accessibility(ctx.tree_update, node);
self.editor.accessibility(
ctx.tree_update,
node,
|| NodeId::from(WidgetId::next()),
PROSE_X_PADDING,
0.0,
);
}
fn children_ids(&self) -> SmallVec<[WidgetId; 16]> {
@ -288,14 +624,14 @@ impl Widget for Prose {
}
fn get_debug_text(&self) -> Option<String> {
Some(self.text_layout.text().as_ref().chars().take(100).collect())
Some(self.editor.text().chars().take(100).collect())
}
}
// TODO - Add more tests
#[cfg(test)]
mod tests {
use parley::layout::Alignment;
use parley::{layout::Alignment, StyleProperty};
use vello::kurbo::Size;
use crate::{
@ -311,15 +647,15 @@ mod tests {
fn base_label() -> Prose {
// Trailing whitespace is displayed when laying out prose.
Prose::new("Hello ")
.with_text_size(10.0)
.with_style(StyleProperty::FontSize(10.0))
.with_line_break_mode(LineBreaking::WordWrap)
}
let label1 = base_label().with_text_alignment(Alignment::Start);
let label2 = base_label().with_text_alignment(Alignment::Middle);
let label3 = base_label().with_text_alignment(Alignment::End);
let label4 = base_label().with_text_alignment(Alignment::Start);
let label5 = base_label().with_text_alignment(Alignment::Middle);
let label6 = base_label().with_text_alignment(Alignment::End);
let label1 = base_label().with_alignment(Alignment::Start);
let label2 = base_label().with_alignment(Alignment::Middle);
let label3 = base_label().with_alignment(Alignment::End);
let label4 = base_label().with_alignment(Alignment::Start);
let label5 = base_label().with_alignment(Alignment::Middle);
let label6 = base_label().with_alignment(Alignment::End);
let flex = Flex::column()
.with_flex_child(label1, CrossAxisAlignment::Start)
.with_flex_child(label2, CrossAxisAlignment::Start)

View File

@ -1,7 +1,7 @@
// Copyright 2024 the Xilem Authors
// SPDX-License-Identifier: Apache-2.0
use accesskit::{NodeBuilder, Role};
use accesskit::{Node, Role};
use smallvec::{smallvec, SmallVec};
use tracing::{trace_span, Span};
use vello::kurbo::Point;
@ -59,7 +59,7 @@ impl<W: Widget> Widget for RootWidget<W> {
Role::Window
}
fn accessibility(&mut self, _ctx: &mut AccessCtx, _node: &mut NodeBuilder) {}
fn accessibility(&mut self, _ctx: &mut AccessCtx, _node: &mut Node) {}
fn children_ids(&self) -> SmallVec<[WidgetId; 16]> {
smallvec![self.pod.id()]

View File

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:85468e736552c337e9f5f51600eb0b003a24f6c669bbeac0467ec7630a4078ef
size 6873
oid sha256:2d6aba6b8c39bfd7b3dd64d7909d970d2e548cdea553f77e9792aa26174bc6ed
size 6772

View File

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c4333c177b7c8911446cec6e9c0b3b9ce779fa1b45884845de89ff2cfd0683cd
size 2553
oid sha256:491d4b32093f22c4f4bdcd3799f129c5f8357c5b3286510784b19a2f52fbb130
size 2453

View File

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:cc727ebbf6c35965497d8610193b94ff19bbb7537c4d1308deb02f852e1c30b5
size 19817
oid sha256:d6dfbc29948c5c320f02b6c4d7a1c9679f5a72e053ce287bdf2ec5b5191f536a
size 19698

View File

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:5e198c08997215048dfc4e47ff954af7ff509afc5ef1d8c17977c5d14b7cba0d
size 9801
oid sha256:33d472739acebf5d924ed59c80ff8a386a917c8d3baa1b3c491ad4d43ec24b6e
size 9754

View File

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:11a0972091eedf3fcdbf767dd9bb2da2bec93870baae03459c0b7623f12a14f9
size 2534
oid sha256:a19a2fd637f0d5337f458985fed9484d8130e75f1ed07124ac6e4d570f04207b
size 2433

View File

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:a19a2fd637f0d5337f458985fed9484d8130e75f1ed07124ac6e4d570f04207b
size 2433

View File

@ -3,7 +3,7 @@
#![allow(missing_docs)]
use accesskit::{NodeBuilder, Role};
use accesskit::{Node, Role};
use smallvec::SmallVec;
use tracing::{trace_span, Span};
use vello::kurbo::Rect;
@ -215,7 +215,7 @@ impl Widget for ScrollBar {
Role::ScrollBar
}
fn accessibility(&mut self, _ctx: &mut AccessCtx, _node: &mut NodeBuilder) {
fn accessibility(&mut self, _ctx: &mut AccessCtx, _node: &mut Node) {
// TODO
// Use set_scroll_x/y_min/max?
}

View File

@ -3,7 +3,7 @@
//! A widget with predefined size.
use accesskit::{NodeBuilder, Role};
use accesskit::{Node, Role};
use smallvec::{smallvec, SmallVec};
use tracing::{trace_span, warn, Span};
use vello::kurbo::{Affine, RoundedRectRadii};
@ -517,7 +517,7 @@ impl Widget for SizedBox {
Role::GenericContainer
}
fn accessibility(&mut self, _ctx: &mut AccessCtx, _node: &mut NodeBuilder) {}
fn accessibility(&mut self, _ctx: &mut AccessCtx, _node: &mut Node) {}
fn children_ids(&self) -> SmallVec<[WidgetId; 16]> {
if let Some(child) = &self.child {

View File

@ -1,7 +1,10 @@
---
source: masonry/src/widget/progress_bar.rs
expression: harness.root_widget()
snapshot_kind: text
---
SizedBox(
ProgressBar<0%>,
ProgressBar<0%>(
Label<0%>,
),
)

View File

@ -1,7 +1,10 @@
---
source: masonry/src/widget/progress_bar.rs
expression: harness.root_widget()
snapshot_kind: text
---
SizedBox(
ProgressBar<100%>,
ProgressBar<100%>(
Label<100%>,
),
)

View File

@ -1,7 +1,10 @@
---
source: masonry/src/widget/progress_bar.rs
expression: harness.root_widget()
snapshot_kind: text
---
SizedBox(
ProgressBar<25%>,
ProgressBar<25%>(
Label<25%>,
),
)

View File

@ -1,7 +1,10 @@
---
source: masonry/src/widget/progress_bar.rs
expression: harness.root_widget()
snapshot_kind: text
---
SizedBox(
ProgressBar<50%>,
ProgressBar<50%>(
Label<50%>,
),
)

View File

@ -1,7 +1,10 @@
---
source: masonry/src/widget/progress_bar.rs
expression: harness.root_widget()
snapshot_kind: text
---
SizedBox(
ProgressBar<75%>,
ProgressBar<75%>(
Label<75%>,
),
)

View File

@ -1,7 +1,10 @@
---
source: masonry/src/widget/progress_bar.rs
expression: harness.root_widget()
snapshot_kind: text
---
SizedBox(
ProgressBar<progress unspecified>,
ProgressBar<progress unspecified>(
Label<>,
),
)

View File

@ -5,7 +5,7 @@
use std::f64::consts::PI;
use accesskit::{NodeBuilder, Role};
use accesskit::{Node, Role};
use smallvec::SmallVec;
use tracing::{trace_span, Span};
use vello::kurbo::{Affine, Cap, Line, Stroke};
@ -143,7 +143,7 @@ impl Widget for Spinner {
Role::Unknown
}
fn accessibility(&mut self, _ctx: &mut AccessCtx, _node: &mut NodeBuilder) {}
fn accessibility(&mut self, _ctx: &mut AccessCtx, _node: &mut Node) {}
fn children_ids(&self) -> SmallVec<[WidgetId; 16]> {
SmallVec::new()

View File

@ -3,7 +3,7 @@
//! A widget which splits an area in two, with a settable ratio, and optional draggable resizing.
use accesskit::{NodeBuilder, Role};
use accesskit::{Node, Role};
use smallvec::{smallvec, SmallVec};
use tracing::{trace_span, warn, Span};
use vello::Scene;
@ -534,7 +534,7 @@ impl Widget for Split {
Role::Splitter
}
fn accessibility(&mut self, _ctx: &mut AccessCtx, _node: &mut NodeBuilder) {}
fn accessibility(&mut self, _ctx: &mut AccessCtx, _node: &mut Node) {}
fn children_ids(&self) -> SmallVec<[WidgetId; 16]> {
smallvec![self.child1.id(), self.child2.id()]

View File

@ -1,24 +1,28 @@
// Copyright 2018 the Xilem Authors and the Druid Authors
// SPDX-License-Identifier: Apache-2.0
use accesskit::{NodeBuilder, Role};
use std::mem::Discriminant;
use std::time::Instant;
use crate::text::{render_text, Generation, PlainEditor};
use accesskit::{Node, NodeId, Role};
use parley::layout::Alignment;
use parley::style::{FontFamily, FontStack};
use smallvec::SmallVec;
use tracing::{trace_span, Span};
use vello::kurbo::{Affine, Point, Size, Stroke};
use vello::peniko::{BlendMode, Color};
use vello::kurbo::{Affine, Insets, Point, Size, Stroke};
use vello::peniko::{BlendMode, Brush, Color, Fill};
use vello::Scene;
use winit::event::Ime;
use winit::keyboard::{Key, NamedKey};
use crate::text::{TextBrush, TextEditor, TextWithSelection};
use crate::text::{ArcStr, BrushIndex, StyleProperty, StyleSet};
use crate::widget::{LineBreaking, WidgetMut};
use crate::{
AccessCtx, AccessEvent, BoxConstraints, CursorIcon, EventCtx, LayoutCtx, PaintCtx,
PointerEvent, QueryCtx, RegisterCtx, TextEvent, Update, UpdateCtx, Widget, WidgetId,
theme, AccessCtx, AccessEvent, BoxConstraints, CursorIcon, EventCtx, LayoutCtx, PaintCtx,
PointerButton, PointerEvent, QueryCtx, RegisterCtx, TextEvent, Update, UpdateCtx, Widget,
WidgetId,
};
const TEXTBOX_PADDING: f64 = 3.0;
const TEXTBOX_PADDING: f64 = 5.0;
/// HACK: A "margin" which is placed around the outside of all textboxes, ensuring that
/// they do not fill the entire width of the window.
///
@ -27,164 +31,334 @@ const TEXTBOX_PADDING: f64 = 3.0;
///
/// In theory, this should be proper margin/padding in the parent widget, but that hasn't been
/// designed.
const TEXTBOX_MARGIN: f64 = 8.0;
const TEXTBOX_X_MARGIN: f64 = 6.0;
/// The fallback minimum width for a textbox with infinite provided maximum width.
const INFINITE_TEXTBOX_WIDTH: f64 = 400.0;
const INFINITE_TEXTBOX_WIDTH: f32 = 400.0;
/// The textbox widget is a widget which shows text which can be edited by the user
///
/// For immutable text [`Prose`](super::Prose) should be preferred
// TODO: RichTextBox 👀
pub struct Textbox {
// We hardcode the underlying storage type as `String`.
// We might change this to a rope based structure at some point.
// If you need a text box which uses a different text type, you should
// create a custom widget
editor: TextEditor,
editor: PlainEditor<BrushIndex>,
rendered_generation: Generation,
pending_text: Option<ArcStr>,
last_click_time: Option<Instant>,
click_count: u32,
// TODO: Support for links?
//https://github.com/linebender/xilem/issues/360
styles: StyleSet,
/// Whether `styles` has been updated since `text_layout` was updated.
///
/// If they have, the layout needs to be recreated.
styles_changed: bool,
line_break_mode: LineBreaking,
show_disabled: bool,
brush: TextBrush,
alignment: Alignment,
/// Whether the alignment has changed since the last layout, which would force a re-alignment.
alignment_changed: bool,
/// The value of `max_advance` when this layout was last calculated.
///
/// If it has changed, we need to re-perform line-breaking.
last_max_advance: Option<f32>,
/// The brush for drawing this label's text.
///
/// Requires a new paint if edited whilst `disabled_brush` is not being used.
brush: Brush,
/// The brush to use whilst this widget is disabled.
///
/// When this is `None`, `brush` will be used.
/// Requires a new paint if edited whilst this widget is disabled.
disabled_brush: Option<Brush>,
/// Whether to hint whilst drawing the text.
///
/// Should be disabled whilst an animation involving this label is ongoing.
// TODO: What classes of animations?
hint: bool,
}
// --- MARK: BUILDERS ---
impl Textbox {
pub fn new(initial_text: impl Into<String>) -> Self {
pub fn new(text: impl Into<ArcStr>) -> Self {
let editor = PlainEditor::default();
Textbox {
editor: TextEditor::new(initial_text.into(), crate::theme::TEXT_SIZE_NORMAL),
editor,
rendered_generation: Generation::default(),
pending_text: Some(text.into()),
last_click_time: None,
click_count: 0,
styles: StyleSet::new(theme::TEXT_SIZE_NORMAL),
styles_changed: true,
line_break_mode: LineBreaking::WordWrap,
show_disabled: true,
brush: crate::theme::TEXT_COLOR.into(),
alignment: Alignment::Start,
alignment_changed: true,
last_max_advance: None,
brush: theme::TEXT_COLOR.into(),
disabled_brush: Some(theme::DISABLED_TEXT_COLOR.into()),
hint: true,
}
}
// TODO: Can we reduce code duplication with `Label` widget somehow?
/// Get the current text of this label.
///
/// To update the text of an active label, use [`set_text`](Self::set_text).
pub fn text(&self) -> &str {
self.editor.text()
}
#[doc(alias = "with_text_color")]
pub fn with_text_brush(mut self, brush: impl Into<TextBrush>) -> Self {
self.brush = brush.into();
self.editor.set_brush(self.brush.clone());
/// Set a style property for the new label.
///
/// Setting [`StyleProperty::Brush`](parley::StyleProperty::Brush) is not supported.
/// Use `with_brush` instead.
///
/// To set a style property on an active label, use [`insert_style`](Self::insert_style).
pub fn with_style(mut self, property: impl Into<StyleProperty>) -> Self {
self.insert_style_inner(property.into());
self
}
pub fn with_text_size(mut self, size: f32) -> Self {
self.editor.set_text_size(size);
self
}
pub fn with_text_alignment(mut self, alignment: Alignment) -> Self {
self.editor.set_text_alignment(alignment);
self
}
pub fn with_font(mut self, font: FontStack<'static>) -> Self {
self.editor.set_font(font);
self
}
pub fn with_font_family(self, font: FontFamily<'static>) -> Self {
self.with_font(FontStack::Single(font))
/// Set a style property for the new label, returning the old value.
///
/// Most users should prefer [`with_style`](Self::with_style) instead.
pub fn try_with_style(
mut self,
property: impl Into<StyleProperty>,
) -> (Self, Option<StyleProperty>) {
let old = self.insert_style_inner(property.into());
(self, old)
}
/// Set how line breaks will be handled by this label.
///
/// To modify this on an active label, use [`set_line_break_mode`](Self::set_line_break_mode).
pub fn with_line_break_mode(mut self, line_break_mode: LineBreaking) -> Self {
self.line_break_mode = line_break_mode;
self
}
/// Set the alignment of the text.
///
/// Text alignment might have unexpected results when the label has no horizontal constraints.
/// To modify this on an active label, use [`set_alignment`](Self::set_alignment).
pub fn with_alignment(mut self, alignment: Alignment) -> Self {
self.alignment = alignment;
self
}
/// Set the brush used to paint this label.
///
/// In most cases, this will be the text's color, but gradients and images are also supported.
///
/// To modify this on an active label, use [`set_brush`](Self::set_brush).
#[doc(alias = "with_color")]
pub fn with_brush(mut self, brush: impl Into<Brush>) -> Self {
self.brush = brush.into();
self
}
/// Set the brush which will be used to paint this label whilst it is disabled.
///
/// If this is `None`, the [normal brush](Self::with_brush) will be used.
/// To modify this on an active label, use [`set_disabled_brush`](Self::set_disabled_brush).
#[doc(alias = "with_color")]
pub fn with_disabled_brush(mut self, disabled_brush: impl Into<Option<Brush>>) -> Self {
self.disabled_brush = disabled_brush.into();
self
}
/// Set whether [hinting](https://en.wikipedia.org/wiki/Font_hinting) will be used for this label.
///
/// Hinting is a process where text is drawn "snapped" to pixel boundaries to improve fidelity.
/// The default is true, i.e. hinting is enabled by default.
///
/// This should be set to false if the label will be animated at creation.
/// The kinds of relevant animations include changing variable font parameters,
/// translating or scaling.
/// Failing to do so will likely lead to an unpleasant shimmering effect, as different parts of the
/// text "snap" at different times.
///
/// To modify this on an active label, use [`set_hint`](Self::set_hint).
// TODO: Should we tell each widget if smooth scrolling is ongoing so they can disable their hinting?
// Alternatively, we should automate disabling hinting at the Vello layer when composing.
pub fn with_hint(mut self, hint: bool) -> Self {
self.hint = hint;
self
}
/// Shared logic between `with_style` and `insert_style`
fn insert_style_inner(&mut self, property: StyleProperty) -> Option<StyleProperty> {
if let StyleProperty::Brush(idx @ BrushIndex(1..)) = &property {
debug_panic!(
"Can't set a non-zero brush index ({idx:?}) on a `Label`, as it only supports global styling."
);
}
self.styles.insert(property)
}
}
// --- MARK: WIDGETMUT ---
impl Textbox {
pub fn set_text_properties<R>(
// Note: These docs are lazy, but also have a decreased likelihood of going out of date.
/// The runtime requivalent of [`with_style`](Self::with_style).
///
/// Setting [`StyleProperty::Brush`](parley::StyleProperty::Brush) is not supported.
/// Use [`set_brush`](Self::set_brush) instead.
pub fn insert_style(
this: &mut WidgetMut<'_, Self>,
f: impl FnOnce(&mut TextWithSelection<String>) -> R,
) -> R {
let ret = f(&mut this.widget.editor);
if this.widget.editor.needs_rebuild() {
this.ctx.request_layout();
}
ret
property: impl Into<StyleProperty>,
) -> Option<StyleProperty> {
let old = this.widget.insert_style_inner(property.into());
this.widget.styles_changed = true;
this.ctx.request_layout();
old
}
/// Reset the contents of the text box.
/// Keep only the styles for which `f` returns true.
///
/// Styles which are removed return to Parley's default values.
/// In most cases, these are the defaults for this widget.
///
/// Of note, behaviour is unspecified for unsetting the [`FontSize`](parley::StyleProperty::FontSize).
pub fn retain_styles(this: &mut WidgetMut<'_, Self>, f: impl FnMut(&StyleProperty) -> bool) {
this.widget.styles.retain(f);
this.widget.styles_changed = true;
this.ctx.request_layout();
}
/// Remove the style with the discriminant `property`.
///
/// To get the discriminant requires constructing a valid `StyleProperty` for the
/// the desired property and passing it to [`core::mem::discriminant`].
/// Getting this discriminant is usually possible in a `const` context.
///
/// Styles which are removed return to Parley's default values.
/// In most cases, these are the defaults for this widget.
///
/// Of note, behaviour is unspecified for unsetting the [`FontSize`](parley::StyleProperty::FontSize).
pub fn remove_style(
this: &mut WidgetMut<'_, Self>,
property: Discriminant<StyleProperty>,
) -> Option<StyleProperty> {
let old = this.widget.styles.remove(property);
this.widget.styles_changed = true;
this.ctx.request_layout();
old
}
/// This is likely to be disruptive if the user is focused on this widget,
/// and so should be avoided if possible.
// FIXME - it's not clear whether this is the right behaviour, or if there even
// is one.
// TODO: Create a method which sets the text and the cursor selection to be used if focused?
pub fn reset_text(this: &mut WidgetMut<'_, Self>, new_text: String) {
if this.ctx.is_focused() {
tracing::warn!(
"Called reset_text on a focused `Textbox`. This will lose the user's current selection and cursor"
);
}
this.widget.editor.reset_preedit();
Self::set_text_properties(this, |layout| layout.set_text(new_text));
pub fn reset_text(this: &mut WidgetMut<'_, Self>, new_text: impl Into<ArcStr>) {
this.widget.pending_text = Some(new_text.into());
this.ctx.request_layout();
}
#[doc(alias = "set_text_color")]
pub fn set_text_brush(this: &mut WidgetMut<'_, Self>, brush: impl Into<TextBrush>) {
let brush = brush.into();
this.widget.brush = brush;
if !this.ctx.is_disabled() {
let brush = this.widget.brush.clone();
Self::set_text_properties(this, |layout| layout.set_brush(brush));
}
}
pub fn set_text_size(this: &mut WidgetMut<'_, Self>, size: f32) {
Self::set_text_properties(this, |layout| layout.set_text_size(size));
}
pub fn set_alignment(this: &mut WidgetMut<'_, Self>, alignment: Alignment) {
Self::set_text_properties(this, |layout| layout.set_text_alignment(alignment));
}
pub fn set_font(this: &mut WidgetMut<'_, Self>, font_stack: FontStack<'static>) {
Self::set_text_properties(this, |layout| layout.set_font(font_stack));
}
pub fn set_font_family(this: &mut WidgetMut<'_, Self>, family: FontFamily<'static>) {
Self::set_font(this, FontStack::Single(family));
}
/// The runtime requivalent of [`with_line_break_mode`](Self::with_line_break_mode).
pub fn set_line_break_mode(this: &mut WidgetMut<'_, Self>, line_break_mode: LineBreaking) {
this.widget.line_break_mode = line_break_mode;
this.ctx.request_render();
// We don't need to set an internal invalidation, as `max_advance` is always recalculated
this.ctx.request_layout();
}
/// The runtime requivalent of [`with_alignment`](Self::with_alignment).
pub fn set_alignment(this: &mut WidgetMut<'_, Self>, alignment: Alignment) {
this.widget.alignment = alignment;
this.widget.alignment_changed = true;
this.ctx.request_layout();
}
#[doc(alias = "set_color")]
/// The runtime requivalent of [`with_brush`](Self::with_brush).
pub fn set_brush(this: &mut WidgetMut<'_, Self>, brush: impl Into<Brush>) {
let brush = brush.into();
this.widget.brush = brush;
// We need to repaint unless the disabled brush is currently being used.
if this.widget.disabled_brush.is_none() || this.ctx.is_disabled() {
this.ctx.request_paint_only();
}
}
/// The runtime requivalent of [`with_disabled_brush`](Self::with_disabled_brush).
pub fn set_disabled_brush(this: &mut WidgetMut<'_, Self>, brush: impl Into<Option<Brush>>) {
let brush = brush.into();
this.widget.disabled_brush = brush;
if this.ctx.is_disabled() {
this.ctx.request_paint_only();
}
}
/// The runtime requivalent of [`with_hint`](Self::with_hint).
pub fn set_hint(this: &mut WidgetMut<'_, Self>, hint: bool) {
this.widget.hint = hint;
this.ctx.request_paint_only();
}
}
// --- MARK: IMPL WIDGET ---
impl Widget for Textbox {
fn on_pointer_event(&mut self, ctx: &mut EventCtx, event: &PointerEvent) {
if self.pending_text.is_some() {
debug_panic!("`set_text` on `Prose` was called before an event started");
}
let window_origin = ctx.widget_state.window_origin();
let inner_origin = Point::new(
window_origin.x + TEXTBOX_PADDING,
window_origin.y + TEXTBOX_PADDING,
window_origin.x + TEXTBOX_X_MARGIN + TEXTBOX_PADDING,
window_origin.y,
);
match event {
PointerEvent::PointerDown(button, state) => {
if !ctx.is_disabled() {
// TODO: Start tracking currently pressed link?
let made_change = self.editor.pointer_down(inner_origin, state, *button);
if made_change {
ctx.request_layout();
ctx.request_render();
ctx.request_focus();
ctx.capture_pointer();
if !ctx.is_disabled() && *button == PointerButton::Primary {
let now = Instant::now();
if let Some(last) = self.last_click_time.take() {
if now.duration_since(last).as_secs_f64() < 0.25 {
self.click_count = (self.click_count + 1) % 4;
} else {
self.click_count = 1;
}
} else {
self.click_count = 1;
}
self.last_click_time = Some(now);
let click_count = self.click_count;
let cursor_pos = Point::new(state.position.x, state.position.y) - inner_origin;
let (fctx, lctx) = ctx.text_contexts();
self.editor.transact(fctx, lctx, |txn| match click_count {
2 => txn.select_word_at_point(cursor_pos.x as f32, cursor_pos.y as f32),
3 => txn.select_line_at_point(cursor_pos.x as f32, cursor_pos.y as f32),
_ => txn.move_to_point(cursor_pos.x as f32, cursor_pos.y as f32),
});
let new_generation = self.editor.generation();
if new_generation != self.rendered_generation {
ctx.request_render();
self.rendered_generation = new_generation;
}
ctx.request_focus();
ctx.capture_pointer();
}
}
PointerEvent::PointerMove(state) => {
if !ctx.is_disabled()
&& ctx.has_pointer_capture()
&& self.editor.pointer_move(inner_origin, state)
{
// We might have changed text colours, so we need to re-request a layout
ctx.request_layout();
ctx.request_render();
}
}
PointerEvent::PointerUp(button, state) => {
// TODO: Follow link (if not now dragging ?)
if !ctx.is_disabled() && ctx.has_pointer_capture() {
self.editor.pointer_up(inner_origin, state, *button);
let cursor_pos = Point::new(state.position.x, state.position.y) - inner_origin;
let (fctx, lctx) = ctx.text_contexts();
self.editor.transact(fctx, lctx, |txn| {
txn.extend_selection_to_point(cursor_pos.x as f32, cursor_pos.y as f32);
});
let new_generation = self.editor.generation();
if new_generation != self.rendered_generation {
ctx.request_render();
self.rendered_generation = new_generation;
}
}
}
_ => {}
@ -192,32 +366,196 @@ impl Widget for Textbox {
}
fn on_text_event(&mut self, ctx: &mut EventCtx, event: &TextEvent) {
let result = self.editor.text_event(ctx, event);
if result.is_handled() {
// Some platforms will send a lot of spurious Preedit events.
// We only want to request a scroll on user input.
if !matches!(event, TextEvent::Ime(Ime::Preedit(preedit, ..)) if preedit.is_empty()) {
// TODO - Use request_scroll_to with cursor rect
ctx.request_scroll_to_this();
if self.pending_text.take().is_some() {
debug_panic!("`set_text` on `Prose` was called before an event started");
}
match event {
TextEvent::KeyboardKey(key_event, modifiers_state) => {
if !key_event.state.is_pressed() {
return;
}
#[allow(unused)]
let (shift, action_mod) = (
modifiers_state.shift_key(),
if cfg!(target_os = "macos") {
modifiers_state.super_key()
} else {
modifiers_state.control_key()
},
);
let (fctx, lctx) = ctx.text_contexts();
// Ideally we'd use key_without_modifiers, but that's broken
match &key_event.logical_key {
#[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))]
Key::Character(c) if action_mod && matches!(c.as_str(), "c" | "x" | "v") => {
// TODO: use clipboard_rs::{Clipboard, ClipboardContext};
match c.to_lowercase().as_str() {
"c" => {
if let crate::text::ActiveText::Selection(_) =
self.editor.active_text()
{
// let cb = ClipboardContext::new().unwrap();
// cb.set_text(text.to_owned()).ok();
}
}
"x" => {
// if let crate::text::ActiveText::Selection(text) = self.editor.active_text() {
// let cb = ClipboardContext::new().unwrap();
// cb.set_text(text.to_owned()).ok();
// self.editor.transact(fcx, lcx, |txn| txn.delete_selection());
// }
}
"v" => {
// let cb = ClipboardContext::new().unwrap();
// let text = cb.get_text().unwrap_or_default();
// self.editor.transact(fcx, lcx, |txn| txn.insert_or_replace_selection(&text));
}
_ => (),
}
}
Key::Character(c) if action_mod && matches!(c.to_lowercase().as_str(), "a") => {
self.editor.transact(fctx, lctx, |txn| {
if shift {
txn.collapse_selection();
} else {
txn.select_all();
}
});
}
Key::Named(NamedKey::ArrowLeft) => self.editor.transact(fctx, lctx, |txn| {
if action_mod {
if shift {
txn.select_word_left();
} else {
txn.move_word_left();
}
} else if shift {
txn.select_left();
} else {
txn.move_left();
}
}),
Key::Named(NamedKey::ArrowRight) => self.editor.transact(fctx, lctx, |txn| {
if action_mod {
if shift {
txn.select_word_right();
} else {
txn.move_word_right();
}
} else if shift {
txn.select_right();
} else {
txn.move_right();
}
}),
Key::Named(NamedKey::ArrowUp) => self.editor.transact(fctx, lctx, |txn| {
if shift {
txn.select_up();
} else {
txn.move_up();
}
}),
Key::Named(NamedKey::ArrowDown) => self.editor.transact(fctx, lctx, |txn| {
if shift {
txn.select_down();
} else {
txn.move_down();
}
}),
Key::Named(NamedKey::Home) => self.editor.transact(fctx, lctx, |txn| {
if action_mod {
if shift {
txn.select_to_text_start();
} else {
txn.move_to_text_start();
}
} else if shift {
txn.select_to_line_start();
} else {
txn.move_to_line_start();
}
}),
Key::Named(NamedKey::End) => self.editor.transact(fctx, lctx, |txn| {
if action_mod {
if shift {
txn.select_to_text_end();
} else {
txn.move_to_text_end();
}
} else if shift {
txn.select_to_line_end();
} else {
txn.move_to_line_end();
}
}),
Key::Named(NamedKey::Delete) => self.editor.transact(fctx, lctx, |txn| {
if action_mod {
txn.delete_word();
} else {
txn.delete();
}
}),
Key::Named(NamedKey::Backspace) => self.editor.transact(fctx, lctx, |txn| {
if action_mod {
txn.backdelete_word();
} else {
txn.backdelete();
}
}),
Key::Named(NamedKey::Enter) => {
ctx.submit_action(crate::Action::TextEntered(self.text().to_string()));
return;
// let (fctx, lctx) = ctx.text_contexts();
// self.editor
// .transact(fctx, lctx, |txn| txn.insert_or_replace_selection("\n"));
}
Key::Named(NamedKey::Space) => {
self.editor
.transact(fctx, lctx, |txn| txn.insert_or_replace_selection(" "));
}
_ => match &key_event.text {
Some(text) => {
self.editor
.transact(fctx, lctx, |txn| txn.insert_or_replace_selection(text));
}
None => {}
},
}
let new_generation = self.editor.generation();
if new_generation != self.rendered_generation {
ctx.submit_action(crate::Action::TextChanged(self.text().to_string()));
// TODO: For all the non-text-input actions
ctx.request_layout();
self.rendered_generation = new_generation;
}
}
ctx.set_handled();
// TODO: only some handlers need this repaint
ctx.request_layout();
ctx.request_render();
// TODO: Set our highlighting colour to a lighter blue as window unfocused
TextEvent::FocusChange(_) => {}
TextEvent::Ime(e) => {
// TODO: Handle the cursor movement things from https://github.com/rust-windowing/winit/pull/3824
tracing::warn!(event = ?e, "Prose doesn't accept IME");
}
TextEvent::ModifierChange(_) => {}
}
}
fn accepts_focus(&self) -> bool {
false
}
fn accepts_text_input(&self) -> bool {
// TODO: Flip back to true.
false
}
fn on_access_event(&mut self, ctx: &mut EventCtx, event: &AccessEvent) {
match event.action {
accesskit::Action::SetTextSelection => {
if self.editor.set_selection_from_access_event(event) {
ctx.request_layout();
}
if event.action == accesskit::Action::SetTextSelection {
if let Some(accesskit::ActionData::SetTextSelection(selection)) = &event.data {
let (fctx, lctx) = ctx.text_contexts();
self.editor
.transact(fctx, lctx, |txn| txn.select_from_accesskit(selection));
}
_ => (),
}
// TODO - Handle accesskit::Action::ReplaceSelectedText
// TODO - Handle accesskit::Action::SetValue
}
fn register_children(&mut self, _ctx: &mut RegisterCtx) {}
@ -225,80 +563,112 @@ impl Widget for Textbox {
fn update(&mut self, ctx: &mut UpdateCtx, event: &Update) {
match event {
Update::FocusChanged(false) => {
self.editor.focus_lost();
ctx.request_layout();
ctx.request_render();
}
Update::FocusChanged(true) => {
self.editor.focus_gained();
ctx.request_layout();
ctx.request_render();
}
Update::DisabledChanged(disabled) => {
if self.show_disabled {
if *disabled {
self.editor.set_brush(crate::theme::DISABLED_TEXT_COLOR);
} else {
self.editor.set_brush(self.brush.clone());
}
}
// TODO: Parley seems to require a relayout when colours change
ctx.request_layout();
Update::DisabledChanged(_) => {
// We might need to use the disabled brush, and stop displaying the selection.
ctx.request_render();
}
_ => {}
}
}
fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
// Compute max_advance from box constraints
let max_advance = if self.line_break_mode != LineBreaking::WordWrap {
None
} else if bc.max().width.is_finite() {
Some((bc.max().width - 2. * TEXTBOX_PADDING - 2. * TEXTBOX_MARGIN) as f32)
} else if bc.min().width.is_sign_negative() {
Some(0.0)
} else {
None
};
self.editor.set_max_advance(max_advance);
if self.editor.needs_rebuild() {
let (font_ctx, layout_ctx) = ctx.text_contexts();
self.editor.rebuild(font_ctx, layout_ctx);
let (fctx, lctx) = ctx.text_contexts();
let available_width = self.editor.transact(fctx, lctx, |txn| {
if let Some(pending_text) = self.pending_text.take() {
txn.select_to_text_start();
txn.collapse_selection();
txn.set_text(&pending_text);
}
let available_width = if bc.max().width.is_finite() {
Some((bc.max().width - 2. * TEXTBOX_X_MARGIN - 2. * TEXTBOX_PADDING) as f32)
} else {
None
};
let max_advance = if self.line_break_mode == LineBreaking::WordWrap {
available_width
} else {
None
};
if self.styles_changed {
let style = self.styles.inner().values().cloned().collect();
txn.set_default_style(style);
self.styles_changed = false;
}
if max_advance != self.last_max_advance {
txn.set_width(max_advance);
}
if self.alignment_changed {
txn.set_alignment(self.alignment);
}
max_advance
});
let new_generation = self.editor.generation();
if new_generation != self.rendered_generation {
self.rendered_generation = new_generation;
}
let text_size = self.editor.size();
let width = if bc.max().width.is_finite() {
// If we have a finite width, chop off the margin
bc.max().width - 2. * TEXTBOX_MARGIN
} else {
// If we're drawing based on the width of the text instead, request proper padding
text_size.width.max(INFINITE_TEXTBOX_WIDTH) + 2. * TEXTBOX_PADDING
};
let label_size = Size {
let text_width = available_width
.unwrap_or(self.editor.layout().full_width())
.max(
INFINITE_TEXTBOX_WIDTH.min(bc.max().width as f32)
- (2. * TEXTBOX_PADDING + 2. * TEXTBOX_X_MARGIN) as f32,
);
let text_size = Size::new(text_width.into(), self.editor.layout().height().into());
let textbox_size = Size {
height: text_size.height + 2. * TEXTBOX_PADDING,
// TODO: Better heuristic here?
width,
width: text_size.width + 2. * TEXTBOX_PADDING + 2. * TEXTBOX_X_MARGIN,
};
bc.constrain(label_size)
bc.constrain(textbox_size)
}
fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut Scene) {
if self.editor.needs_rebuild() {
debug_panic!(
"Called {name}::paint with invalid layout",
name = self.short_type_name()
);
}
if self.line_break_mode == LineBreaking::Clip {
let clip_rect = ctx.size().to_rect();
scene.push_layer(BlendMode::default(), 1., Affine::IDENTITY, &clip_rect);
}
self.editor
.draw(scene, Point::new(TEXTBOX_PADDING, TEXTBOX_PADDING));
let transform = Affine::translate((TEXTBOX_PADDING + TEXTBOX_X_MARGIN, TEXTBOX_PADDING));
for rect in self.editor.selection_geometry().iter() {
// TODO: If window not focused, use a different color
// TODO: Make configurable
scene.fill(Fill::NonZero, transform, Color::STEEL_BLUE, None, &rect);
}
if ctx.is_focused() {
if let Some(cursor) = self.editor.selection_strong_geometry(1.5) {
// TODO: Make configurable
scene.fill(Fill::NonZero, transform, Color::WHITE, None, &cursor);
};
if let Some(cursor) = self.editor.selection_weak_geometry(1.5) {
// TODO: Make configurable
scene.fill(Fill::NonZero, transform, Color::LIGHT_GRAY, None, &cursor);
};
}
let brush = if ctx.is_disabled() {
self.disabled_brush
.clone()
.unwrap_or_else(|| self.brush.clone())
} else {
self.brush.clone()
};
// TODO: Is disabling hinting ever right for textbox?
render_text(scene, transform, self.editor.layout(), &[brush], self.hint);
if self.line_break_mode == LineBreaking::Clip {
scene.pop_layer();
}
let size = ctx.size();
let outline_rect = size.to_rect().inset(1.0);
let outline_rect = size
.to_rect()
.inset(Insets::uniform_xy(-TEXTBOX_X_MARGIN - 1.0, -1.0));
scene.stroke(
&Stroke::new(1.0),
Affine::IDENTITY,
@ -316,22 +686,20 @@ impl Widget for Textbox {
Role::TextInput
}
fn accessibility(&mut self, ctx: &mut AccessCtx, node: &mut NodeBuilder) {
self.editor.accessibility(ctx.tree_update, node);
fn accessibility(&mut self, ctx: &mut AccessCtx, node: &mut Node) {
self.editor.accessibility(
ctx.tree_update,
node,
|| NodeId::from(WidgetId::next()),
TEXTBOX_X_MARGIN + TEXTBOX_PADDING,
TEXTBOX_PADDING,
);
}
fn children_ids(&self) -> SmallVec<[WidgetId; 16]> {
SmallVec::new()
}
fn accepts_focus(&self) -> bool {
true
}
fn accepts_text_input(&self) -> bool {
true
}
fn make_trace_span(&self, ctx: &QueryCtx<'_>) -> Span {
trace_span!("Textbox", id = ctx.widget_id().trace())
}
@ -341,4 +709,45 @@ impl Widget for Textbox {
}
}
// TODO - Add tests
// TODO - Add more tests
#[cfg(test)]
mod tests {
use parley::{layout::Alignment, StyleProperty};
use vello::kurbo::Size;
use crate::{
assert_render_snapshot,
testing::TestHarness,
widget::{CrossAxisAlignment, Flex, LineBreaking, Prose},
};
#[test]
/// A wrapping prose's alignment should be respected, regardkess of
/// its parent's alignment.
fn prose_alignment_flex() {
fn base_label() -> Prose {
// Trailing whitespace is displayed when laying out prose.
Prose::new("Hello ")
.with_style(StyleProperty::FontSize(10.0))
.with_line_break_mode(LineBreaking::WordWrap)
}
let label1 = base_label().with_alignment(Alignment::Start);
let label2 = base_label().with_alignment(Alignment::Middle);
let label3 = base_label().with_alignment(Alignment::End);
let label4 = base_label().with_alignment(Alignment::Start);
let label5 = base_label().with_alignment(Alignment::Middle);
let label6 = base_label().with_alignment(Alignment::End);
let flex = Flex::column()
.with_flex_child(label1, CrossAxisAlignment::Start)
.with_flex_child(label2, CrossAxisAlignment::Start)
.with_flex_child(label3, CrossAxisAlignment::Start)
.with_flex_child(label4, CrossAxisAlignment::Center)
.with_flex_child(label5, CrossAxisAlignment::Center)
.with_flex_child(label6, CrossAxisAlignment::Center)
.gap(0.0);
let mut harness = TestHarness::create_with_size(flex, Size::new(80.0, 80.0));
assert_render_snapshot!(harness, "prose_alignment_flex");
}
}

View File

@ -5,25 +5,22 @@
use std::cmp::Ordering;
use accesskit::{NodeBuilder, Role};
use accesskit::{Node, Role};
use parley::fontique::Weight;
use parley::layout::Alignment;
use parley::style::{FontFamily, FontStack};
use smallvec::SmallVec;
use parley::StyleProperty;
use smallvec::{smallvec, SmallVec};
use tracing::{trace_span, Span};
use vello::kurbo::{Affine, Point, Size};
use vello::peniko::BlendMode;
use vello::kurbo::{Point, Size};
use vello::Scene;
use crate::text::{ArcStr, Hinting, TextBrush, TextLayout};
use crate::widget::{LineBreaking, WidgetMut};
use crate::text::ArcStr;
use crate::widget::WidgetMut;
use crate::{
AccessCtx, AccessEvent, BoxConstraints, EventCtx, LayoutCtx, PaintCtx, PointerEvent, QueryCtx,
RegisterCtx, TextEvent, Update, UpdateCtx, Widget, WidgetId,
};
// added padding between the edges of the widget and the text.
pub(super) const LABEL_X_PADDING: f64 = 2.0;
use super::{Label, WidgetPod};
/// An `f32` value which can move towards a target value at a linear rate over time.
#[derive(Clone, Debug)]
@ -48,11 +45,6 @@ impl AnimatedF32 {
}
}
/// Is this animation finished?
pub fn is_completed(&self) -> bool {
self.target == self.value
}
/// Move this value to the `target` over `over_millis` milliseconds.
/// Might change the current value, if `over_millis` is zero.
///
@ -131,146 +123,49 @@ impl AnimationStatus {
}
}
// TODO: Make this a wrapper (around `Label`?)
/// A widget displaying non-editable text, with a variable [weight](parley::style::FontWeight).
pub struct VariableLabel {
text: ArcStr,
text_changed: bool,
text_layout: TextLayout,
line_break_mode: LineBreaking,
show_disabled: bool,
brush: TextBrush,
label: WidgetPod<Label>,
weight: AnimatedF32,
}
// --- MARK: BUILDERS ---
impl VariableLabel {
/// Create a new label.
/// Create a new variable label from the given text.
pub fn new(text: impl Into<ArcStr>) -> Self {
Self::from_label_pod(WidgetPod::new(Label::new(text)))
}
pub fn from_label(label: Label) -> Self {
Self::from_label_pod(WidgetPod::new(label))
}
pub fn from_label_pod(label: WidgetPod<Label>) -> Self {
Self {
text: text.into(),
text_changed: false,
text_layout: TextLayout::new(crate::theme::TEXT_SIZE_NORMAL),
line_break_mode: LineBreaking::Overflow,
show_disabled: true,
brush: crate::theme::TEXT_COLOR.into(),
label,
weight: AnimatedF32::stable(Weight::NORMAL.value()),
}
}
pub fn text(&self) -> &ArcStr {
&self.text
}
#[doc(alias = "with_text_color")]
pub fn with_text_brush(mut self, brush: impl Into<TextBrush>) -> Self {
self.text_layout.set_brush(brush);
self
}
#[doc(alias = "with_font_size")]
pub fn with_text_size(mut self, size: f32) -> Self {
self.text_layout.set_text_size(size);
self
}
pub fn with_text_alignment(mut self, alignment: Alignment) -> Self {
self.text_layout.set_text_alignment(alignment);
self
}
pub fn with_font(mut self, font: FontStack<'static>) -> Self {
self.text_layout.set_font(font);
self
}
pub fn with_font_family(self, font: FontFamily<'static>) -> Self {
self.with_font(FontStack::Single(font))
}
pub fn with_line_break_mode(mut self, line_break_mode: LineBreaking) -> Self {
self.line_break_mode = line_break_mode;
self
}
/// Set the initial font weight for this text.
pub fn with_initial_weight(mut self, weight: f32) -> Self {
self.weight = AnimatedF32::stable(weight);
self
}
/// Create a label with empty text.
pub fn empty() -> Self {
Self::new("")
}
fn brush(&self, disabled: bool) -> TextBrush {
if disabled {
crate::theme::DISABLED_TEXT_COLOR.into()
} else {
let mut brush = self.brush.clone();
if !self.weight.is_completed() {
brush.set_hinting(Hinting::No);
}
// N.B. if hinting is No externally, we don't want to overwrite it to yes.
brush
}
}
}
// --- MARK: WIDGETMUT ---
impl VariableLabel {
/// Set a property on the underlying text.
///
/// This cannot be used to set attributes.
pub fn set_text_properties<R>(
this: &mut WidgetMut<'_, Self>,
f: impl FnOnce(&mut TextLayout) -> R,
) -> R {
let ret = f(&mut this.widget.text_layout);
if this.widget.text_layout.needs_rebuild() {
this.ctx.request_layout();
}
ret
/// Get the underlying label for this widget.
pub fn label_mut<'t>(this: &'t mut WidgetMut<'_, Self>) -> WidgetMut<'t, Label> {
this.ctx.get_mut(&mut this.widget.label)
}
/// Modify the underlying text.
/// Set the text of this label.
pub fn set_text(this: &mut WidgetMut<'_, Self>, new_text: impl Into<ArcStr>) {
let new_text = new_text.into();
this.widget.text = new_text;
this.widget.text_changed = true;
this.ctx.request_layout();
Label::set_text(&mut Self::label_mut(this), new_text);
}
#[doc(alias = "set_text_color")]
/// Set the brush of the text, normally used for the colour.
pub fn set_text_brush(this: &mut WidgetMut<'_, Self>, brush: impl Into<TextBrush>) {
let brush = brush.into();
this.widget.brush = brush;
if !this.ctx.is_disabled() {
this.widget.text_layout.invalidate();
this.ctx.request_layout();
}
}
/// Set the font size for this text.
pub fn set_text_size(this: &mut WidgetMut<'_, Self>, size: f32) {
Self::set_text_properties(this, |layout| layout.set_text_size(size));
}
/// Set the text alignment of the contained text
pub fn set_alignment(this: &mut WidgetMut<'_, Self>, alignment: Alignment) {
Self::set_text_properties(this, |layout| layout.set_text_alignment(alignment));
}
/// Set the font (potentially with fallbacks) which will be used for this text.
pub fn set_font(this: &mut WidgetMut<'_, Self>, font_stack: FontStack<'static>) {
Self::set_text_properties(this, |layout| layout.set_font(font_stack));
}
/// A helper method to use a single font family.
pub fn set_font_family(this: &mut WidgetMut<'_, Self>, family: FontFamily<'static>) {
Self::set_font(this, FontStack::Single(family));
}
/// How to handle overflowing lines.
pub fn set_line_break_mode(this: &mut WidgetMut<'_, Self>, line_break_mode: LineBreaking) {
this.widget.line_break_mode = line_break_mode;
this.ctx.request_layout();
}
/// Set the weight which this font will target.
pub fn set_target_weight(this: &mut WidgetMut<'_, Self>, target: f32, over_millis: f32) {
this.widget.weight.move_to(target, over_millis);
@ -281,143 +176,66 @@ impl VariableLabel {
// --- MARK: IMPL WIDGET ---
impl Widget for VariableLabel {
fn on_pointer_event(&mut self, _ctx: &mut EventCtx, event: &PointerEvent) {
match event {
PointerEvent::PointerMove(_point) => {
// TODO: Set cursor if over link
}
PointerEvent::PointerDown(_button, _state) => {
// TODO: Start tracking currently pressed
// (i.e. don't press)
}
PointerEvent::PointerUp(_button, _state) => {
// TODO: Follow link (if not now dragging ?)
}
_ => {}
}
fn on_pointer_event(&mut self, _ctx: &mut EventCtx, _event: &PointerEvent) {}
fn accepts_pointer_interaction(&self) -> bool {
false
}
fn on_text_event(&mut self, _ctx: &mut EventCtx, _event: &TextEvent) {
// If focused on a link and enter pressed, follow it?
// TODO: This sure looks like each link needs its own widget, although I guess the challenge there is
// that the bounding boxes can go e.g. across line boundaries?
}
fn on_text_event(&mut self, _ctx: &mut EventCtx, _event: &TextEvent) {}
fn on_access_event(&mut self, _ctx: &mut EventCtx, _event: &AccessEvent) {}
fn update(&mut self, _ctx: &mut UpdateCtx, _event: &Update) {}
fn register_children(&mut self, ctx: &mut RegisterCtx) {
ctx.register_child(&mut self.label);
}
fn on_anim_frame(&mut self, ctx: &mut UpdateCtx, interval: u64) {
let millis = (interval as f64 / 1_000_000.) as f32;
let result = self.weight.advance(millis);
self.text_layout.invalidate();
let new_weight = self.weight.value;
// The ergonomics of child widgets are quite bad - ideally, this wouldn't need a mutate pass, since we
// can set the required invalidation anyway.
ctx.mutate_later(&mut self.label, move |mut label| {
// TODO: Should this be configurable?
if result.is_completed() {
Label::set_hint(&mut label, true);
} else {
Label::set_hint(&mut label, false);
}
Label::insert_style(
&mut label,
StyleProperty::FontWeight(Weight::new(new_weight)),
);
});
if !result.is_completed() {
ctx.request_anim_frame();
}
ctx.request_layout();
}
fn register_children(&mut self, _ctx: &mut RegisterCtx) {}
fn update(&mut self, ctx: &mut UpdateCtx, event: &Update) {
match event {
Update::FocusChanged(_) => {
// TODO: Focus on first link
}
Update::DisabledChanged(disabled) => {
if self.show_disabled {
if *disabled {
self.text_layout
.set_brush(crate::theme::DISABLED_TEXT_COLOR);
} else {
self.text_layout.set_brush(self.brush.clone());
}
}
// TODO: Parley seems to require a relayout when colours change
ctx.request_layout();
}
_ => {}
}
}
fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
// Compute max_advance from box constraints
let max_advance = if self.line_break_mode != LineBreaking::WordWrap {
None
} else if bc.max().width.is_finite() {
Some(bc.max().width as f32 - 2. * LABEL_X_PADDING as f32)
} else if bc.min().width.is_sign_negative() {
Some(0.0)
} else {
None
};
self.text_layout.set_max_advance(max_advance);
if self.text_layout.needs_rebuild() {
self.text_layout
.set_brush(self.brush(ctx.widget_state.is_disabled));
let (font_ctx, layout_ctx) = ctx.text_contexts();
self.text_layout.rebuild_with_attributes(
font_ctx,
layout_ctx,
&self.text,
self.text_changed,
|mut builder| {
builder.push_default(&parley::style::StyleProperty::FontWeight(Weight::new(
self.weight.value,
)));
// builder.push_default(&parley::style::StyleProperty::FontVariations(
// parley::style::FontSettings::List(&[]),
// ));
builder
},
);
self.text_changed = false;
}
// We ignore trailing whitespace for a label
let text_size = self.text_layout.size();
let label_size = Size {
height: text_size.height,
width: text_size.width + 2. * LABEL_X_PADDING,
};
bc.constrain(label_size)
let size = ctx.run_layout(&mut self.label, bc);
ctx.place_child(&mut self.label, Point::ORIGIN);
size
}
fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut Scene) {
if self.text_layout.needs_rebuild() {
debug_panic!(
"Called {name}::paint with invalid layout",
name = self.short_type_name()
);
}
if self.line_break_mode == LineBreaking::Clip {
let clip_rect = ctx.size().to_rect();
scene.push_layer(BlendMode::default(), 1., Affine::IDENTITY, &clip_rect);
}
self.text_layout
.draw(scene, Point::new(LABEL_X_PADDING, 0.0));
if self.line_break_mode == LineBreaking::Clip {
scene.pop_layer();
}
}
fn paint(&mut self, _ctx: &mut PaintCtx, _scene: &mut Scene) {}
fn accessibility_role(&self) -> Role {
Role::Label
Role::GenericContainer
}
fn accessibility(&mut self, _ctx: &mut AccessCtx, node: &mut NodeBuilder) {
node.set_name(self.text().as_ref().to_string());
}
fn accessibility(&mut self, _ctx: &mut AccessCtx, _node: &mut Node) {}
fn children_ids(&self) -> SmallVec<[WidgetId; 16]> {
SmallVec::new()
smallvec![self.label.id()]
}
fn make_trace_span(&self, ctx: &QueryCtx<'_>) -> Span {
trace_span!("VariableLabel", id = ctx.widget_id().trace())
}
fn get_debug_text(&self) -> Option<String> {
Some(self.text.to_string())
}
}
// --- MARK: TESTS ---

View File

@ -7,7 +7,7 @@ use std::num::NonZeroU64;
use std::ops::{Deref, DerefMut};
use std::sync::atomic::{AtomicU64, Ordering};
use accesskit::{NodeBuilder, Role};
use accesskit::{Node, Role};
use cursor_icon::CursorIcon;
use smallvec::SmallVec;
use tracing::field::DisplayValue;
@ -162,7 +162,7 @@ pub trait Widget: AsAny {
fn accessibility_role(&self) -> Role;
fn accessibility(&mut self, ctx: &mut AccessCtx, node: &mut NodeBuilder);
fn accessibility(&mut self, ctx: &mut AccessCtx, node: &mut Node);
/// Return ids of this widget's children.
///
@ -452,7 +452,7 @@ impl Widget for Box<dyn Widget> {
self.deref().accessibility_role()
}
fn accessibility(&mut self, ctx: &mut AccessCtx, node: &mut NodeBuilder) {
fn accessibility(&mut self, ctx: &mut AccessCtx, node: &mut Node) {
self.deref_mut().accessibility(ctx, node);
}

View File

@ -7,7 +7,6 @@
use std::time::Duration;
use masonry::parley::fontique::Weight;
use masonry::parley::style::{FontFamily, FontStack};
use time::error::IndeterminateOffset;
use time::macros::format_description;
use time::{OffsetDateTime, UtcOffset};
@ -138,7 +137,7 @@ impl TimeZone {
)
.text_size(48.)
// Use the roboto flex we have just loaded.
.with_font(FontStack::List(&[FontFamily::Named("Roboto Flex")]))
.with_font("Roboto Flex")
.target_weight(data.weight, 400.),
FlexSpacer::Flex(1.0),
(data.local_now().date() != date_time_in_self.date()).then(|| {

View File

@ -1,7 +1,7 @@
// Copyright 2024 the Xilem Authors
// SPDX-License-Identifier: Apache-2.0
use accesskit::{NodeBuilder, Role};
use accesskit::{Node, Role};
use masonry::widget::WidgetMut;
use masonry::{
AccessCtx, AccessEvent, BoxConstraints, EventCtx, LayoutCtx, PaintCtx, Point, PointerEvent,
@ -94,7 +94,7 @@ impl Widget for DynWidget {
Role::GenericContainer
}
fn accessibility(&mut self, _ctx: &mut AccessCtx, _node: &mut NodeBuilder) {}
fn accessibility(&mut self, _ctx: &mut AccessCtx, _node: &mut Node) {}
fn children_ids(&self) -> SmallVec<[WidgetId; 16]> {
smallvec![self.inner.id()]

View File

@ -3,7 +3,7 @@
//! Statically typed alternatives to the type-erased [`AnyWidgetView`](`crate::any_view::AnyWidgetView`).
use accesskit::{NodeBuilder, Role};
use accesskit::{Node, Role};
use masonry::{
AccessCtx, AccessEvent, BoxConstraints, EventCtx, LayoutCtx, PaintCtx, Point, PointerEvent,
RegisterCtx, Size, TextEvent, Widget, WidgetId, WidgetPod,
@ -245,7 +245,7 @@ impl<
Role::GenericContainer
}
fn accessibility(&mut self, _ctx: &mut AccessCtx, _node: &mut NodeBuilder) {}
fn accessibility(&mut self, _ctx: &mut AccessCtx, _node: &mut Node) {}
fn children_ids(&self) -> SmallVec<[WidgetId; 16]> {
match self {

View File

@ -1,8 +1,10 @@
// Copyright 2024 the Xilem Authors
// SPDX-License-Identifier: Apache-2.0
use masonry::text::{ArcStr, TextBrush};
use masonry::parley::FontStack;
use masonry::text::{ArcStr, StyleProperty};
use masonry::widget;
use vello::peniko::Brush;
use crate::core::{DynMessage, Mut, ViewMarker};
use crate::{Color, MessageResult, Pod, TextAlignment, TextWeight, View, ViewCtx, ViewId};
@ -14,6 +16,7 @@ pub fn label(label: impl Into<ArcStr>) -> Label {
alignment: TextAlignment::default(),
text_size: masonry::theme::TEXT_SIZE_NORMAL,
weight: TextWeight::NORMAL,
font: FontStack::List(std::borrow::Cow::Borrowed(&[])),
}
}
@ -21,16 +24,17 @@ pub fn label(label: impl Into<ArcStr>) -> Label {
pub struct Label {
label: ArcStr,
text_brush: TextBrush,
alignment: TextAlignment,
text_size: f32,
weight: TextWeight,
// TODO: add more attributes of `masonry::widget::Label`
// Public for variable_label as a semi-interims state.
pub(in crate::view) text_brush: Brush,
pub(in crate::view) alignment: TextAlignment,
pub(in crate::view) text_size: f32,
pub(in crate::view) weight: TextWeight,
pub(in crate::view) font: FontStack<'static>, // TODO: add more attributes of `masonry::widget::Label`
}
impl Label {
#[doc(alias = "color")]
pub fn brush(mut self, brush: impl Into<TextBrush>) -> Self {
pub fn brush(mut self, brush: impl Into<Brush>) -> Self {
self.text_brush = brush.into();
self
}
@ -50,6 +54,15 @@ impl Label {
self.weight = weight;
self
}
/// Set the [font stack](FontStack) this label will use.
///
/// A font stack allows for providing fallbacks. If there is no matching font
/// for a character, a system font will be used (if the system fonts are enabled).
pub fn with_font(mut self, font: impl Into<FontStack<'static>>) -> Self {
self.font = font.into();
self
}
}
impl ViewMarker for Label {}
@ -60,10 +73,11 @@ impl<State, Action> View<State, Action, ViewCtx> for Label {
fn build(&self, ctx: &mut ViewCtx) -> (Self::Element, Self::ViewState) {
let widget_pod = ctx.new_pod(
widget::Label::new(self.label.clone())
.with_text_brush(self.text_brush.clone())
.with_text_alignment(self.alignment)
.with_text_size(self.text_size)
.with_weight(self.weight),
.with_brush(self.text_brush.clone())
.with_alignment(self.alignment)
.with_style(StyleProperty::FontSize(self.text_size))
.with_style(StyleProperty::FontWeight(self.weight))
.with_style(StyleProperty::FontStack(self.font.clone())),
);
(widget_pod, ())
}
@ -79,16 +93,19 @@ impl<State, Action> View<State, Action, ViewCtx> for Label {
widget::Label::set_text(&mut element, self.label.clone());
}
if prev.text_brush != self.text_brush {
widget::Label::set_text_brush(&mut element, self.text_brush.clone());
widget::Label::set_brush(&mut element, self.text_brush.clone());
}
if prev.alignment != self.alignment {
widget::Label::set_alignment(&mut element, self.alignment);
}
if prev.text_size != self.text_size {
widget::Label::set_text_size(&mut element, self.text_size);
widget::Label::insert_style(&mut element, StyleProperty::FontSize(self.text_size));
}
if prev.weight != self.weight {
widget::Label::set_weight(&mut element, self.weight);
widget::Label::insert_style(&mut element, StyleProperty::FontWeight(self.weight));
}
if prev.font != self.font {
widget::Label::insert_style(&mut element, StyleProperty::FontStack(self.font.clone()));
}
}

View File

@ -1,8 +1,9 @@
// Copyright 2024 the Xilem Authors
// SPDX-License-Identifier: Apache-2.0
use masonry::text::{ArcStr, TextBrush};
use masonry::text::{ArcStr, StyleProperty};
use masonry::widget::{self, LineBreaking};
use vello::peniko::Brush;
use crate::core::{DynMessage, Mut, ViewMarker};
use crate::{Color, MessageResult, Pod, TextAlignment, View, ViewCtx, ViewId};
@ -31,7 +32,7 @@ pub fn inline_prose(content: impl Into<ArcStr>) -> Prose {
pub struct Prose {
content: ArcStr,
text_brush: TextBrush,
text_brush: Brush,
alignment: TextAlignment,
text_size: f32,
line_break_mode: LineBreaking,
@ -41,7 +42,7 @@ pub struct Prose {
impl Prose {
#[doc(alias = "color")]
pub fn brush(mut self, brush: impl Into<TextBrush>) -> Self {
pub fn brush(mut self, brush: impl Into<Brush>) -> Self {
self.text_brush = brush.into();
self
}
@ -70,9 +71,9 @@ impl<State, Action> View<State, Action, ViewCtx> for Prose {
fn build(&self, ctx: &mut ViewCtx) -> (Self::Element, Self::ViewState) {
let widget_pod = ctx.new_pod(
widget::Prose::new(self.content.clone())
.with_text_brush(self.text_brush.clone())
.with_text_alignment(self.alignment)
.with_text_size(self.text_size)
.with_brush(self.text_brush.clone())
.with_alignment(self.alignment)
.with_style(StyleProperty::FontSize(self.text_size))
.with_line_break_mode(self.line_break_mode),
);
(widget_pod, ())
@ -89,13 +90,13 @@ impl<State, Action> View<State, Action, ViewCtx> for Prose {
widget::Prose::set_text(&mut element, self.content.clone());
}
if prev.text_brush != self.text_brush {
widget::Prose::set_text_brush(&mut element, self.text_brush.clone());
widget::Prose::set_brush(&mut element, self.text_brush.clone());
}
if prev.alignment != self.alignment {
widget::Prose::set_alignment(&mut element, self.alignment);
}
if prev.text_size != self.text_size {
widget::Prose::set_text_size(&mut element, self.text_size);
widget::Prose::insert_style(&mut element, StyleProperty::FontSize(self.text_size));
}
if prev.line_break_mode != self.line_break_mode {
widget::Prose::set_line_break_mode(&mut element, self.line_break_mode);

View File

@ -1,8 +1,8 @@
// Copyright 2024 the Xilem Authors
// SPDX-License-Identifier: Apache-2.0
use masonry::text::TextBrush;
use masonry::widget;
use vello::peniko::Brush;
use crate::core::{DynMessage, Mut, View, ViewMarker};
use crate::{Color, MessageResult, Pod, TextAlignment, ViewCtx, ViewId};
@ -33,7 +33,7 @@ pub struct Textbox<State, Action> {
contents: String,
on_changed: Callback<State, Action>,
on_enter: Option<Callback<State, Action>>,
text_brush: TextBrush,
text_brush: Brush,
alignment: TextAlignment,
disabled: bool,
// TODO: add more attributes of `masonry::widget::Label`
@ -41,7 +41,7 @@ pub struct Textbox<State, Action> {
impl<State, Action> Textbox<State, Action> {
#[doc(alias = "color")]
pub fn brush(mut self, color: impl Into<TextBrush>) -> Self {
pub fn brush(mut self, color: impl Into<Brush>) -> Self {
self.text_brush = color.into();
self
}
@ -74,8 +74,8 @@ impl<State: 'static, Action: 'static> View<State, Action, ViewCtx> for Textbox<S
ctx.with_leaf_action_widget(|ctx| {
ctx.new_pod(
widget::Textbox::new(self.contents.clone())
.with_text_brush(self.text_brush.clone())
.with_text_alignment(self.alignment),
.with_brush(self.text_brush.clone())
.with_alignment(self.alignment),
)
})
}
@ -99,7 +99,7 @@ impl<State: 'static, Action: 'static> View<State, Action, ViewCtx> for Textbox<S
}
if prev.text_brush != self.text_brush {
widget::Textbox::set_text_brush(&mut element, self.text_brush.clone());
widget::Textbox::set_brush(&mut element, self.text_brush.clone());
}
if prev.alignment != self.alignment {
widget::Textbox::set_alignment(&mut element, self.alignment);

View File

@ -2,57 +2,34 @@
// SPDX-License-Identifier: Apache-2.0
use masonry::parley::fontique::Weight;
use masonry::parley::style::{FontFamily, FontStack, GenericFamily};
use masonry::text::{ArcStr, TextBrush};
use masonry::widget;
use masonry::parley::style::FontStack;
use masonry::text::ArcStr;
use masonry::{widget, TextAlignment};
use vello::peniko::Brush;
use xilem_core::ViewPathTracker;
use crate::core::{DynMessage, Mut, ViewMarker};
use crate::{Color, MessageResult, Pod, TextAlignment, View, ViewCtx, ViewId};
use crate::{MessageResult, Pod, TextWeight, View, ViewCtx, ViewId};
use super::{label, Label};
/// A view for displaying non-editable text, with a variable [weight](masonry::parley::style::FontWeight).
pub fn variable_label(label: impl Into<ArcStr>) -> VariableLabel {
pub fn variable_label(text: impl Into<ArcStr>) -> VariableLabel {
VariableLabel {
label: label.into(),
text_brush: Color::WHITE.into(),
alignment: TextAlignment::default(),
text_size: masonry::theme::TEXT_SIZE_NORMAL,
label: label(text),
target_weight: Weight::NORMAL,
over_millis: 0.,
font: FontStack::Single(FontFamily::Generic(GenericFamily::SystemUi)),
}
}
#[must_use = "View values do nothing unless provided to Xilem."]
pub struct VariableLabel {
label: ArcStr,
text_brush: TextBrush,
alignment: TextAlignment,
text_size: f32,
label: Label,
target_weight: Weight,
over_millis: f32,
font: FontStack<'static>,
// TODO: add more attributes of `masonry::widget::Label`
}
impl VariableLabel {
#[doc(alias = "color")]
pub fn brush(mut self, brush: impl Into<TextBrush>) -> Self {
self.text_brush = brush.into();
self
}
pub fn alignment(mut self, alignment: TextAlignment) -> Self {
self.alignment = alignment;
self
}
#[doc(alias = "font_size")]
pub fn text_size(mut self, text_size: f32) -> Self {
self.text_size = text_size;
self
}
/// Set the weight this label will target.
///
/// If this change is animated, it will occur over `over_millis` milliseconds.
@ -78,32 +55,49 @@ impl VariableLabel {
/// A font stack allows for providing fallbacks. If there is no matching font
/// for a character, a system font will be used (if the system fonts are enabled).
///
/// This currently requires a `FontStack<'static>`, because it is stored in
/// the view, and Parley doesn't support an owned or `Arc` based `FontStack`.
/// In most cases, a fontstack value can be static-promoted, but otherwise
/// you will currently have to [leak](String::leak) a value and manually keep
/// the value.
///
/// This should be a font stack with variable font support,
/// although non-variable fonts will work, just without the smooth animation support.
pub fn with_font(mut self, font: FontStack<'static>) -> Self {
self.font = font;
pub fn with_font(mut self, font: impl Into<FontStack<'static>>) -> Self {
self.label.font = font.into();
self
}
#[doc(alias = "color")]
pub fn brush(mut self, brush: impl Into<Brush>) -> Self {
self.label.text_brush = brush.into();
self
}
pub fn alignment(mut self, alignment: TextAlignment) -> Self {
self.label.alignment = alignment;
self
}
#[doc(alias = "font_size")]
pub fn text_size(mut self, text_size: f32) -> Self {
self.label.text_size = text_size;
self
}
pub fn weight(mut self, weight: TextWeight) -> Self {
self.label.weight = weight;
self
}
}
impl VariableLabel {}
impl ViewMarker for VariableLabel {}
impl<State, Action> View<State, Action, ViewCtx> for VariableLabel {
type Element = Pod<widget::VariableLabel>;
type ViewState = ();
fn build(&self, ctx: &mut ViewCtx) -> (Self::Element, Self::ViewState) {
let (label, ()) = ctx.with_id(ViewId::new(0), |ctx| {
View::<State, Action, _, _>::build(&self.label, ctx)
});
let widget_pod = ctx.new_pod(
widget::VariableLabel::new(self.label.clone())
.with_text_brush(self.text_brush.clone())
.with_text_alignment(self.alignment)
.with_font(self.font)
.with_text_size(self.text_size)
widget::VariableLabel::from_label_pod(label.inner)
.with_initial_weight(self.target_weight.value()),
);
(widget_pod, ())
@ -113,21 +107,19 @@ impl<State, Action> View<State, Action, ViewCtx> for VariableLabel {
&self,
prev: &Self,
(): &mut Self::ViewState,
_ctx: &mut ViewCtx,
ctx: &mut ViewCtx,
mut element: Mut<Self::Element>,
) {
if prev.label != self.label {
widget::VariableLabel::set_text(&mut element, self.label.clone());
}
if prev.text_brush != self.text_brush {
widget::VariableLabel::set_text_brush(&mut element, self.text_brush.clone());
}
if prev.alignment != self.alignment {
widget::VariableLabel::set_alignment(&mut element, self.alignment);
}
if prev.text_size != self.text_size {
widget::VariableLabel::set_text_size(&mut element, self.text_size);
}
ctx.with_id(ViewId::new(0), |ctx| {
View::<State, Action, _, _>::rebuild(
&self.label,
&prev.label,
&mut (),
ctx,
widget::VariableLabel::label_mut(&mut element),
);
});
if prev.target_weight != self.target_weight {
widget::VariableLabel::set_target_weight(
&mut element,
@ -135,11 +127,6 @@ impl<State, Action> View<State, Action, ViewCtx> for VariableLabel {
self.over_millis,
);
}
// First perform a fast filter, then perform a full comparison if that suggests a possible change.
let fonts_eq = fonts_eq_fastpath(prev.font, self.font) || prev.font == self.font;
if !fonts_eq {
widget::VariableLabel::set_font(&mut element, self.font);
}
}
fn teardown(&self, (): &mut Self::ViewState, _: &mut ViewCtx, _: Mut<Self::Element>) {}
@ -147,32 +134,16 @@ impl<State, Action> View<State, Action, ViewCtx> for VariableLabel {
fn message(
&self,
(): &mut Self::ViewState,
_id_path: &[ViewId],
id_path: &[ViewId],
message: DynMessage,
_app_state: &mut State,
app_state: &mut State,
) -> MessageResult<Action> {
tracing::error!("Message arrived in Label::message, but Label doesn't consume any messages, this is a bug");
MessageResult::Stale(message)
}
}
/// Because all the `FontStack`s we use are 'static, we expect the value to never change.
///
/// Because of this, we compare the inner pointer value first.
/// This function has false negatives, but no false positives.
///
/// It should be used with a secondary direct comparison using `==`
/// if it returns false. If the value does change, this is potentially more expensive.
fn fonts_eq_fastpath(lhs: FontStack<'static>, rhs: FontStack<'static>) -> bool {
match (lhs, rhs) {
(FontStack::Source(lhs), FontStack::Source(rhs)) => {
// Slices/strs are properly compared by length
core::ptr::eq(lhs.as_ptr(), rhs.as_ptr())
}
(FontStack::Single(FontFamily::Named(lhs)), FontStack::Single(FontFamily::Named(rhs))) => {
core::ptr::eq(lhs.as_ptr(), rhs.as_ptr())
}
(FontStack::List(lhs), FontStack::List(rhs)) => core::ptr::eq(lhs.as_ptr(), rhs.as_ptr()),
_ => false,
if let Some((first, remainder)) = id_path.split_first() {
assert_eq!(first.routing_id(), 0);
self.label.message(&mut (), remainder, message, app_state)
} else {
tracing::error!("Message arrived in Label::message, but Label doesn't consume any messages, this is a bug");
MessageResult::Stale(message)
}
}
}