mirror of https://github.com/linebender/xilem
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:
parent
30cb5fb6a6
commit
10dc9d171c
File diff suppressed because it is too large
Load Diff
16
Cargo.toml
16
Cargo.toml
|
@ -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"
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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}'."),
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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![
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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.
|
||||
///
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
@ -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) {}
|
||||
|
||||
// ...
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
});
|
||||
|
|
|
@ -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>,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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}", ""]);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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()]
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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.
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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()]
|
||||
|
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:85468e736552c337e9f5f51600eb0b003a24f6c669bbeac0467ec7630a4078ef
|
||||
size 6873
|
||||
oid sha256:2d6aba6b8c39bfd7b3dd64d7909d970d2e548cdea553f77e9792aa26174bc6ed
|
||||
size 6772
|
||||
|
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:c4333c177b7c8911446cec6e9c0b3b9ce779fa1b45884845de89ff2cfd0683cd
|
||||
size 2553
|
||||
oid sha256:491d4b32093f22c4f4bdcd3799f129c5f8357c5b3286510784b19a2f52fbb130
|
||||
size 2453
|
||||
|
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:cc727ebbf6c35965497d8610193b94ff19bbb7537c4d1308deb02f852e1c30b5
|
||||
size 19817
|
||||
oid sha256:d6dfbc29948c5c320f02b6c4d7a1c9679f5a72e053ce287bdf2ec5b5191f536a
|
||||
size 19698
|
||||
|
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:5e198c08997215048dfc4e47ff954af7ff509afc5ef1d8c17977c5d14b7cba0d
|
||||
size 9801
|
||||
oid sha256:33d472739acebf5d924ed59c80ff8a386a917c8d3baa1b3c491ad4d43ec24b6e
|
||||
size 9754
|
||||
|
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:11a0972091eedf3fcdbf767dd9bb2da2bec93870baae03459c0b7623f12a14f9
|
||||
size 2534
|
||||
oid sha256:a19a2fd637f0d5337f458985fed9484d8130e75f1ed07124ac6e4d570f04207b
|
||||
size 2433
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:a19a2fd637f0d5337f458985fed9484d8130e75f1ed07124ac6e4d570f04207b
|
||||
size 2433
|
|
@ -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?
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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%>,
|
||||
),
|
||||
)
|
||||
|
|
|
@ -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%>,
|
||||
),
|
||||
)
|
||||
|
|
|
@ -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%>,
|
||||
),
|
||||
)
|
||||
|
|
|
@ -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%>,
|
||||
),
|
||||
)
|
||||
|
|
|
@ -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%>,
|
||||
),
|
||||
)
|
||||
|
|
|
@ -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<>,
|
||||
),
|
||||
)
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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()]
|
||||
|
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 ---
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
@ -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(|| {
|
||||
|
|
|
@ -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()]
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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()));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue