mirror of
https://github.com/penpot/penpot.git
synced 2026-11-05 01:20:38 +00:00
Lay out horizontal and vertical Japanese text with kinsoku, ruby, emphasis, warichu, tate-chu-yoko and proportional font features. Carry the layout through glyph paths, SVG export and text selection. Update the Rust and ClojureScript binary formats together, including the editor bridge and headless exporter. Resolve CJK fallback fonts and add renderer tests and font fixtures.
1244 lines
40 KiB
Rust
1244 lines
40 KiB
Rust
#![allow(dead_code)]
|
|
|
|
use macros::ToJs;
|
|
|
|
use crate::shapes::{
|
|
Fill, FontStyle, TextAlign, TextContent, TextDecoration, TextDirection,
|
|
TextPositionWithAffinity, TextTransform, VerticalAlign,
|
|
};
|
|
use crate::uuid::Uuid;
|
|
use crate::wasm::text::helpers::{self as text_helpers, find_text_span_at_offset};
|
|
use crate::wasm::text_editor::CursorDirection;
|
|
use skia_safe::Color;
|
|
|
|
#[derive(Debug, Clone, Copy, Default)]
|
|
pub struct TextSelection {
|
|
pub anchor: TextPositionWithAffinity,
|
|
pub focus: TextPositionWithAffinity,
|
|
}
|
|
|
|
impl TextSelection {
|
|
pub fn new() -> Self {
|
|
Self::default()
|
|
}
|
|
|
|
pub fn from_position_with_affinity(position: TextPositionWithAffinity) -> Self {
|
|
Self {
|
|
anchor: position,
|
|
focus: position,
|
|
}
|
|
}
|
|
|
|
pub fn is_collapsed(&self) -> bool {
|
|
self.anchor == self.focus
|
|
}
|
|
|
|
pub fn is_selection(&self) -> bool {
|
|
!self.is_collapsed()
|
|
}
|
|
|
|
pub fn reset(&mut self) {
|
|
self.anchor.reset();
|
|
self.focus.reset();
|
|
}
|
|
|
|
pub fn set_caret(&mut self, cursor: TextPositionWithAffinity) {
|
|
self.anchor = cursor;
|
|
self.focus = cursor;
|
|
}
|
|
|
|
pub fn extend_to(&mut self, cursor: TextPositionWithAffinity) {
|
|
self.focus = cursor;
|
|
}
|
|
|
|
pub fn collapse_to_focus(&mut self) {
|
|
self.anchor = self.focus;
|
|
}
|
|
|
|
pub fn collapse_to_anchor(&mut self) {
|
|
self.focus = self.anchor;
|
|
}
|
|
|
|
pub fn start(&self) -> TextPositionWithAffinity {
|
|
if self.anchor.paragraph < self.focus.paragraph {
|
|
self.anchor
|
|
} else if self.anchor.paragraph > self.focus.paragraph {
|
|
self.focus
|
|
} else if self.anchor.offset <= self.focus.offset {
|
|
self.anchor
|
|
} else {
|
|
self.focus
|
|
}
|
|
}
|
|
|
|
pub fn end(&self) -> TextPositionWithAffinity {
|
|
if self.anchor.paragraph > self.focus.paragraph {
|
|
self.anchor
|
|
} else if self.anchor.paragraph < self.focus.paragraph {
|
|
self.focus
|
|
} else if self.anchor.offset >= self.focus.offset {
|
|
self.anchor
|
|
} else {
|
|
self.focus
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Events that the text editor can emit for frontend synchronization
|
|
/// FIXME: the serialization should be in the wasm module
|
|
#[repr(u8)]
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, ToJs)]
|
|
pub enum TextEditorEvent {
|
|
None = 0,
|
|
ContentChanged = 1,
|
|
SelectionChanged = 2,
|
|
StylesChanged = 3,
|
|
NeedsLayout = 4,
|
|
}
|
|
|
|
/// FIXME: It should be better to get these constants from the frontend through the API.
|
|
const SELECTION_COLOR: Color = Color::from_argb(127, 0, 209, 184);
|
|
const CURSOR_COLOR: Color = Color::WHITE;
|
|
const CURSOR_WIDTH: f32 = 1.0;
|
|
const CURSOR_BLINK_INTERVAL_MS: f32 = 530.0;
|
|
|
|
#[derive(Debug)]
|
|
pub struct TextEditorStyles {
|
|
pub vertical_align: VerticalAlign,
|
|
pub text_align: Multiple<TextAlign>, // Multiple
|
|
pub text_direction: Multiple<TextDirection>, // Multiple
|
|
pub text_decoration: Multiple<TextDecoration>,
|
|
pub text_transform: Multiple<TextTransform>,
|
|
// The font family is decomposed into its independent parts so the family
|
|
// dropdown shows a single family even when the selection mixes variants of
|
|
// the same family (e.g. regular + bold italic). The id identifies the
|
|
// family; weight is tracked by `font_weight`; only the style is left here.
|
|
pub font_family_id: Multiple<Uuid>,
|
|
pub font_style: Multiple<FontStyle>,
|
|
pub font_size: Multiple<f32>,
|
|
pub font_weight: Multiple<i32>,
|
|
pub font_variant_id: Multiple<Uuid>,
|
|
pub line_height: Multiple<f32>,
|
|
pub letter_spacing: Multiple<f32>,
|
|
pub fills_are_multiple: bool,
|
|
pub fills: Vec<Fill>,
|
|
}
|
|
|
|
// FIXME: the serialization should be in the wasm module
|
|
#[derive(Debug, Clone, Copy, PartialEq, ToJs)]
|
|
#[repr(u8)]
|
|
pub enum MultipleState {
|
|
Undefined = 0,
|
|
Single = 1,
|
|
Multiple = 2,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct Multiple<T> {
|
|
state: MultipleState,
|
|
value: Option<T>,
|
|
}
|
|
|
|
impl<T> Multiple<T> {
|
|
pub fn empty() -> Self {
|
|
Self {
|
|
state: MultipleState::Undefined,
|
|
value: None,
|
|
}
|
|
}
|
|
|
|
pub fn new(state: MultipleState, value: Option<T>) -> Self {
|
|
Self { state, value }
|
|
}
|
|
|
|
pub fn state(&self) -> &MultipleState {
|
|
&self.state
|
|
}
|
|
|
|
pub fn value(&self) -> &Option<T> {
|
|
&self.value
|
|
}
|
|
|
|
pub fn is_undefined(&self) -> bool {
|
|
self.state == MultipleState::Undefined
|
|
}
|
|
|
|
pub fn is_multiple(&self) -> bool {
|
|
self.state == MultipleState::Multiple
|
|
}
|
|
|
|
pub fn is_single(&self) -> bool {
|
|
self.state == MultipleState::Single
|
|
}
|
|
|
|
pub fn is_single_some(&self) -> bool {
|
|
!self.is_undefined() && self.value.is_some()
|
|
}
|
|
|
|
pub fn is_single_none(&self) -> bool {
|
|
!self.is_undefined() && self.value.is_none()
|
|
}
|
|
|
|
pub fn reset(&mut self) {
|
|
self.state = MultipleState::Undefined;
|
|
self.value = None;
|
|
}
|
|
|
|
pub fn set(&mut self, value: Option<T>) {
|
|
if self.state == MultipleState::Undefined || self.state == MultipleState::Multiple {
|
|
self.state = MultipleState::Single;
|
|
}
|
|
self.value = value;
|
|
}
|
|
|
|
pub fn set_single(&mut self, value: Option<T>) {
|
|
self.state = MultipleState::Single;
|
|
self.value = value;
|
|
}
|
|
|
|
pub fn set_multiple(&mut self) {
|
|
self.state = MultipleState::Multiple;
|
|
self.value = None;
|
|
}
|
|
|
|
pub fn merge(&mut self, value: Option<T>) -> bool
|
|
where
|
|
T: PartialEq,
|
|
{
|
|
if self.state == MultipleState::Multiple {
|
|
return false;
|
|
}
|
|
|
|
if self.state == MultipleState::Undefined {
|
|
self.set_single(value);
|
|
return true;
|
|
}
|
|
|
|
if self.value.as_ref() != value.as_ref() {
|
|
self.set_multiple();
|
|
return false;
|
|
}
|
|
|
|
self.value = value;
|
|
true
|
|
}
|
|
}
|
|
|
|
impl TextEditorStyles {
|
|
pub fn new() -> Self {
|
|
Self {
|
|
vertical_align: VerticalAlign::Top,
|
|
text_align: Multiple::empty(),
|
|
text_direction: Multiple::empty(),
|
|
text_decoration: Multiple::empty(),
|
|
text_transform: Multiple::empty(),
|
|
font_family_id: Multiple::empty(),
|
|
font_style: Multiple::empty(),
|
|
font_size: Multiple::empty(),
|
|
font_weight: Multiple::empty(),
|
|
font_variant_id: Multiple::empty(),
|
|
line_height: Multiple::empty(),
|
|
letter_spacing: Multiple::empty(),
|
|
fills_are_multiple: false,
|
|
fills: Vec::new(),
|
|
}
|
|
}
|
|
|
|
pub fn reset(&mut self) {
|
|
self.text_align.reset();
|
|
self.text_direction.reset();
|
|
self.text_decoration.reset();
|
|
self.text_transform.reset();
|
|
self.font_family_id.reset();
|
|
self.font_style.reset();
|
|
self.font_size.reset();
|
|
self.font_weight.reset();
|
|
self.font_variant_id.reset();
|
|
self.line_height.reset();
|
|
self.letter_spacing.reset();
|
|
self.fills_are_multiple = false;
|
|
self.fills.clear();
|
|
}
|
|
}
|
|
|
|
impl Default for TextEditorStyles {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
pub struct TextEditorTheme {
|
|
pub selection_color: Color,
|
|
pub cursor_color: Color,
|
|
pub cursor_width: f32,
|
|
/// When true the caret is painted with a Difference blend mode, so it shows
|
|
/// as the inverted color of whatever is behind it. Used as the default when
|
|
/// the text has no solid fill whose color the caret can match.
|
|
pub cursor_invert: bool,
|
|
}
|
|
|
|
pub struct TextComposition {
|
|
pub previous: String,
|
|
pub current: String,
|
|
pub is_composing: bool,
|
|
/// Preview start: each update replaces `[start, start + previous]`.
|
|
pub start: Option<TextPositionWithAffinity>,
|
|
}
|
|
|
|
impl TextComposition {
|
|
pub fn new() -> Self {
|
|
Self {
|
|
previous: String::new(),
|
|
current: String::new(),
|
|
is_composing: false,
|
|
start: None,
|
|
}
|
|
}
|
|
|
|
pub fn start(&mut self) -> bool {
|
|
if self.is_composing {
|
|
return false;
|
|
}
|
|
self.is_composing = true;
|
|
self.previous = String::new();
|
|
self.current = String::new();
|
|
self.start = None;
|
|
true
|
|
}
|
|
|
|
pub fn update(&mut self, text: &str) -> bool {
|
|
if !self.is_composing {
|
|
self.is_composing = true;
|
|
}
|
|
self.previous = self.current.clone();
|
|
self.current = text.to_owned();
|
|
true
|
|
}
|
|
|
|
pub fn end(&mut self) -> bool {
|
|
if !self.is_composing {
|
|
return false;
|
|
}
|
|
self.is_composing = false;
|
|
self.start = None;
|
|
true
|
|
}
|
|
|
|
/// Range the next preview replaces: the previous preview, or `selection`
|
|
/// (the pre-composition selection) when there is none yet.
|
|
pub fn get_selection(&self, selection: &TextSelection) -> TextSelection {
|
|
let Some(start) = self.start.filter(|_| !self.previous.is_empty()) else {
|
|
return *selection;
|
|
};
|
|
|
|
let anchor = TextPositionWithAffinity::new_downstream_affinity(
|
|
start.paragraph,
|
|
start.offset + self.previous.chars().count(),
|
|
);
|
|
|
|
TextSelection {
|
|
anchor,
|
|
focus: start,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Default for TextComposition {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
pub struct TextEditorState {
|
|
pub theme: TextEditorTheme,
|
|
pub selection: TextSelection,
|
|
pub composition: TextComposition,
|
|
pub has_focus: bool,
|
|
// This property indicates that we've started
|
|
// selecting something with the pointer.
|
|
pub is_pointer_selection_active: bool,
|
|
pub is_click_event_skipped: bool,
|
|
pub is_overtype_mode: bool,
|
|
pub active_shape_id: Option<Uuid>,
|
|
pub cursor_visible: bool,
|
|
pub last_blink_time_ms: f32,
|
|
pub current_styles: TextEditorStyles,
|
|
pending_events: Vec<TextEditorEvent>,
|
|
}
|
|
|
|
impl TextEditorState {
|
|
pub fn new() -> Self {
|
|
Self {
|
|
theme: TextEditorTheme {
|
|
selection_color: SELECTION_COLOR,
|
|
cursor_color: CURSOR_COLOR,
|
|
cursor_width: CURSOR_WIDTH,
|
|
cursor_invert: true,
|
|
},
|
|
selection: TextSelection::new(),
|
|
composition: TextComposition::new(),
|
|
has_focus: false,
|
|
is_pointer_selection_active: false,
|
|
is_click_event_skipped: false,
|
|
is_overtype_mode: false,
|
|
active_shape_id: None,
|
|
cursor_visible: true,
|
|
last_blink_time_ms: 0.0,
|
|
pending_events: Vec::new(),
|
|
current_styles: TextEditorStyles::new(),
|
|
}
|
|
}
|
|
|
|
pub fn focus(&mut self, shape_id: Uuid) {
|
|
let same_shape = self.active_shape_id == Some(shape_id);
|
|
|
|
self.has_focus = true;
|
|
self.active_shape_id = Some(shape_id);
|
|
self.cursor_visible = true;
|
|
self.last_blink_time_ms = 0.0;
|
|
if !same_shape {
|
|
self.selection.reset();
|
|
}
|
|
self.is_pointer_selection_active = false;
|
|
self.is_overtype_mode = false;
|
|
self.pending_events.clear();
|
|
}
|
|
|
|
pub fn blur(&mut self) {
|
|
self.has_focus = false;
|
|
// self.active_shape_id = None;
|
|
self.cursor_visible = false;
|
|
self.last_blink_time_ms = 0.0;
|
|
// self.selection.reset();
|
|
self.is_pointer_selection_active = false;
|
|
self.is_overtype_mode = false;
|
|
self.pending_events.clear();
|
|
}
|
|
|
|
pub fn dispose(&mut self) {
|
|
self.has_focus = false;
|
|
self.active_shape_id = None;
|
|
self.cursor_visible = false;
|
|
self.last_blink_time_ms = 0.0;
|
|
self.selection.reset();
|
|
self.is_pointer_selection_active = false;
|
|
self.is_overtype_mode = false;
|
|
self.pending_events.clear();
|
|
}
|
|
|
|
pub fn start_pointer_selection(&mut self) -> bool {
|
|
if self.is_pointer_selection_active {
|
|
return false;
|
|
}
|
|
self.is_pointer_selection_active = true;
|
|
true
|
|
}
|
|
|
|
pub fn stop_pointer_selection(&mut self) -> bool {
|
|
if !self.is_pointer_selection_active {
|
|
return false;
|
|
}
|
|
self.is_pointer_selection_active = false;
|
|
true
|
|
}
|
|
|
|
fn select_range(
|
|
&mut self,
|
|
text_content: &TextContent,
|
|
start: &TextPositionWithAffinity,
|
|
end: &TextPositionWithAffinity,
|
|
) {
|
|
self.is_pointer_selection_active = false;
|
|
self.is_click_event_skipped = false;
|
|
self.set_caret_from_position(start);
|
|
self.extend_selection_from_position(end);
|
|
self.update_styles(text_content);
|
|
self.reset_blink();
|
|
self.push_event(TextEditorEvent::SelectionChanged);
|
|
}
|
|
|
|
pub fn select_all(&mut self, text_content: &TextContent) -> bool {
|
|
let num_paragraphs = text_content.paragraphs().len().saturating_sub(1);
|
|
let Some(last_paragraph) = text_content.paragraphs().last() else {
|
|
return false;
|
|
};
|
|
let Some(_last_text_span) = last_paragraph.children().last() else {
|
|
return false;
|
|
};
|
|
// Offsets are counted in characters, not bytes.
|
|
let offset = text_helpers::paragraph_char_count(last_paragraph);
|
|
self.select_range(
|
|
text_content,
|
|
&TextPositionWithAffinity::empty(),
|
|
&TextPositionWithAffinity::new_upstream_affinity(num_paragraphs, offset),
|
|
);
|
|
|
|
true
|
|
}
|
|
|
|
pub fn select_word_boundary(
|
|
&mut self,
|
|
text_content: &TextContent,
|
|
position: &TextPositionWithAffinity,
|
|
) {
|
|
let paragraphs = text_content.paragraphs();
|
|
if paragraphs.is_empty() || position.paragraph >= paragraphs.len() {
|
|
return;
|
|
}
|
|
|
|
let paragraph = ¶graphs[position.paragraph];
|
|
let paragraph_text: String = paragraph
|
|
.children()
|
|
.iter()
|
|
.map(|span| span.text.as_str())
|
|
.collect();
|
|
|
|
let chars: Vec<char> = paragraph_text.chars().collect();
|
|
if chars.is_empty() {
|
|
self.set_caret_from_position(&TextPositionWithAffinity::new_downstream_affinity(
|
|
position.paragraph,
|
|
0,
|
|
));
|
|
self.update_styles(text_content);
|
|
self.reset_blink();
|
|
self.push_event(TextEditorEvent::SelectionChanged);
|
|
return;
|
|
}
|
|
|
|
let mut offset = position.offset.min(chars.len());
|
|
|
|
if offset == chars.len() {
|
|
offset = offset.saturating_sub(1);
|
|
} else if !text_helpers::is_word_char(chars[offset])
|
|
&& offset > 0
|
|
&& text_helpers::is_word_char(chars[offset - 1])
|
|
{
|
|
offset -= 1;
|
|
}
|
|
|
|
if !text_helpers::is_word_char(chars[offset]) {
|
|
self.set_caret_from_position(&TextPositionWithAffinity::new_downstream_affinity(
|
|
position.paragraph,
|
|
position.offset.min(chars.len()),
|
|
));
|
|
self.update_styles(text_content);
|
|
self.reset_blink();
|
|
self.push_event(TextEditorEvent::SelectionChanged);
|
|
return;
|
|
}
|
|
|
|
let mut start = offset;
|
|
while start > 0 && text_helpers::is_word_char(chars[start - 1]) {
|
|
start -= 1;
|
|
}
|
|
|
|
let mut end = offset + 1;
|
|
while end < chars.len() && text_helpers::is_word_char(chars[end]) {
|
|
end += 1;
|
|
}
|
|
|
|
self.select_range(
|
|
text_content,
|
|
&TextPositionWithAffinity::new_downstream_affinity(position.paragraph, start),
|
|
&TextPositionWithAffinity::new_downstream_affinity(position.paragraph, end),
|
|
);
|
|
}
|
|
|
|
pub fn select_paragraph(
|
|
&mut self,
|
|
text_content: &TextContent,
|
|
position: &TextPositionWithAffinity,
|
|
) {
|
|
let paragraphs = text_content.paragraphs();
|
|
if paragraphs.is_empty() || position.paragraph >= paragraphs.len() {
|
|
return;
|
|
}
|
|
|
|
// Offsets are counted in characters, not bytes.
|
|
let offset = text_helpers::paragraph_char_count(¶graphs[position.paragraph]);
|
|
|
|
self.select_range(
|
|
text_content,
|
|
&TextPositionWithAffinity::new_downstream_affinity(position.paragraph, 0),
|
|
&TextPositionWithAffinity::new_upstream_affinity(position.paragraph, offset),
|
|
);
|
|
}
|
|
|
|
pub fn set_caret_from_position(&mut self, position: &TextPositionWithAffinity) {
|
|
self.selection.set_caret(*position);
|
|
// Restart the blink so the caret is solid right after it is placed,
|
|
// instead of keeping whatever phase it had (which can toggle off at the
|
|
// moment of the click and read as a flash). Mirrors the keyboard paths
|
|
// (`move_cursor`, `select_all`) which already reset the blink.
|
|
self.reset_blink();
|
|
self.push_event(TextEditorEvent::SelectionChanged);
|
|
}
|
|
|
|
/// Places the caret and refreshes `current_styles` (emits `StylesChanged`).
|
|
pub fn place_caret_from_position(
|
|
&mut self,
|
|
text_content: &TextContent,
|
|
position: &TextPositionWithAffinity,
|
|
) {
|
|
self.set_caret_from_position(position);
|
|
self.update_styles(text_content);
|
|
}
|
|
|
|
pub fn extend_selection_from_position(&mut self, position: &TextPositionWithAffinity) {
|
|
self.selection.extend_to(*position);
|
|
self.reset_blink();
|
|
self.push_event(TextEditorEvent::SelectionChanged);
|
|
}
|
|
|
|
pub fn set_overtype_mode(&mut self, overtype_mode: bool) {
|
|
self.is_overtype_mode = overtype_mode;
|
|
}
|
|
|
|
pub fn toggle_overtype_mode(&mut self) {
|
|
self.set_overtype_mode(!self.is_overtype_mode);
|
|
}
|
|
|
|
fn update_styles_from_selection(&mut self, text_content: &TextContent) -> bool {
|
|
let paragraphs = text_content.paragraphs();
|
|
if paragraphs.is_empty() {
|
|
return false;
|
|
}
|
|
|
|
let start = self.selection.start();
|
|
if start.paragraph >= paragraphs.len() {
|
|
return false;
|
|
}
|
|
|
|
let end = self.selection.end();
|
|
let end_paragraph = end.paragraph.min(paragraphs.len() - 1);
|
|
|
|
self.current_styles.reset();
|
|
let mut has_selected_content = false;
|
|
for (para_idx, paragraph) in paragraphs
|
|
.iter()
|
|
.enumerate()
|
|
.take(end_paragraph + 1)
|
|
.skip(start.paragraph)
|
|
{
|
|
let paragraph_char_count: usize = paragraph
|
|
.children()
|
|
.iter()
|
|
.map(|span| span.text.chars().count())
|
|
.sum();
|
|
|
|
let range_start = if para_idx == start.paragraph {
|
|
start.offset.min(paragraph_char_count)
|
|
} else {
|
|
0
|
|
};
|
|
|
|
let range_end = if para_idx == end.paragraph {
|
|
end.offset.min(paragraph_char_count)
|
|
} else {
|
|
paragraph_char_count
|
|
};
|
|
|
|
if range_start >= range_end {
|
|
continue;
|
|
}
|
|
|
|
has_selected_content = true;
|
|
self.current_styles
|
|
.text_align
|
|
.merge(Some(paragraph.text_align()));
|
|
|
|
let mut char_pos = 0;
|
|
for span in paragraph.children() {
|
|
let span_len = span.text.chars().count();
|
|
let span_start = char_pos;
|
|
let span_end = char_pos + span_len;
|
|
char_pos += span_len;
|
|
|
|
let selected_start = range_start.max(span_start);
|
|
let selected_end = range_end.min(span_end);
|
|
if selected_start >= selected_end {
|
|
continue;
|
|
}
|
|
|
|
self.current_styles
|
|
.text_direction
|
|
.merge(Some(span.text_direction));
|
|
self.current_styles
|
|
.text_decoration
|
|
.merge(span.text_decoration);
|
|
self.current_styles
|
|
.text_transform
|
|
.merge(span.text_transform);
|
|
self.current_styles
|
|
.font_family_id
|
|
.merge(Some(span.font_family.id()));
|
|
self.current_styles
|
|
.font_style
|
|
.merge(Some(span.font_family.style()));
|
|
self.current_styles.font_size.merge(Some(span.font_size));
|
|
self.current_styles
|
|
.font_weight
|
|
.merge(Some(span.font_weight));
|
|
self.current_styles
|
|
.font_variant_id
|
|
.merge(Some(span.font_variant_id));
|
|
self.current_styles
|
|
.line_height
|
|
.merge(Some(span.line_height));
|
|
self.current_styles
|
|
.letter_spacing
|
|
.merge(Some(span.letter_spacing));
|
|
|
|
if self.current_styles.fills.is_empty() {
|
|
self.current_styles.fills.append(&mut span.fills.clone());
|
|
} else if self.current_styles.fills != span.fills {
|
|
self.current_styles.fills_are_multiple = true;
|
|
self.current_styles.fills.append(&mut span.fills.clone());
|
|
}
|
|
}
|
|
}
|
|
|
|
has_selected_content
|
|
}
|
|
|
|
fn update_styles_from_caret(&mut self, text_content: &TextContent) -> bool {
|
|
let focus = self.selection.focus;
|
|
let paragraphs = text_content.paragraphs();
|
|
let Some(current_paragraph) = paragraphs.get(focus.paragraph) else {
|
|
return false;
|
|
};
|
|
let current_offset = focus.offset;
|
|
let current_text_span = find_text_span_at_offset(current_paragraph, current_offset);
|
|
|
|
self.current_styles.reset();
|
|
self.current_styles
|
|
.text_align
|
|
.set_single(Some(current_paragraph.text_align()));
|
|
if let Some((text_span_index, _)) = current_text_span {
|
|
if let Some(text_span) = current_paragraph.children().get(text_span_index) {
|
|
self.current_styles
|
|
.text_direction
|
|
.set_single(Some(text_span.text_direction));
|
|
self.current_styles
|
|
.text_decoration
|
|
.set_single(text_span.text_decoration);
|
|
self.current_styles
|
|
.text_transform
|
|
.set_single(text_span.text_transform);
|
|
self.current_styles
|
|
.font_family_id
|
|
.set_single(Some(text_span.font_family.id()));
|
|
self.current_styles
|
|
.font_style
|
|
.set_single(Some(text_span.font_family.style()));
|
|
self.current_styles
|
|
.font_size
|
|
.set_single(Some(text_span.font_size));
|
|
self.current_styles
|
|
.font_weight
|
|
.set_single(Some(text_span.font_weight));
|
|
self.current_styles
|
|
.font_variant_id
|
|
.set_single(Some(text_span.font_variant_id));
|
|
self.current_styles
|
|
.line_height
|
|
.set_single(Some(text_span.line_height));
|
|
self.current_styles
|
|
.letter_spacing
|
|
.set_single(Some(text_span.letter_spacing));
|
|
self.current_styles.fills = text_span.fills.clone();
|
|
}
|
|
} else {
|
|
self.current_styles
|
|
.line_height
|
|
.set_single(Some(current_paragraph.line_height()));
|
|
self.current_styles
|
|
.letter_spacing
|
|
.set_single(Some(current_paragraph.letter_spacing()));
|
|
}
|
|
true
|
|
}
|
|
|
|
pub fn update_styles(&mut self, text_content: &TextContent) -> bool {
|
|
if self.selection.is_selection() {
|
|
let styles_were_updated = self.update_styles_from_selection(text_content);
|
|
if styles_were_updated {
|
|
self.push_event(TextEditorEvent::StylesChanged);
|
|
}
|
|
return styles_were_updated;
|
|
}
|
|
// It is a caret.
|
|
let styles_were_updated = self.update_styles_from_caret(text_content);
|
|
if styles_were_updated {
|
|
self.push_event(TextEditorEvent::StylesChanged);
|
|
}
|
|
styles_were_updated
|
|
}
|
|
|
|
pub fn update_blink(&mut self, timestamp_ms: f32) {
|
|
if !self.has_focus {
|
|
return;
|
|
}
|
|
|
|
if self.last_blink_time_ms == 0.0 {
|
|
self.last_blink_time_ms = timestamp_ms;
|
|
self.cursor_visible = true;
|
|
return;
|
|
}
|
|
|
|
let elapsed = timestamp_ms - self.last_blink_time_ms;
|
|
if elapsed >= CURSOR_BLINK_INTERVAL_MS {
|
|
self.cursor_visible = !self.cursor_visible;
|
|
self.last_blink_time_ms = timestamp_ms;
|
|
}
|
|
}
|
|
|
|
pub fn reset_blink(&mut self) {
|
|
self.cursor_visible = true;
|
|
self.last_blink_time_ms = 0.0;
|
|
}
|
|
|
|
pub fn push_event(&mut self, event: TextEditorEvent) {
|
|
if self.pending_events.last() != Some(&event) {
|
|
self.pending_events.push(event);
|
|
}
|
|
}
|
|
|
|
pub fn poll_event(&mut self) -> TextEditorEvent {
|
|
self.pending_events.pop().unwrap_or(TextEditorEvent::None)
|
|
}
|
|
|
|
pub fn has_pending_events(&self) -> bool {
|
|
!self.pending_events.is_empty()
|
|
}
|
|
|
|
pub fn delete_backward(&mut self, text_content: &mut TextContent, word_boundary: bool) {
|
|
if self.selection.is_selection() {
|
|
text_helpers::delete_selection_range(text_content, &self.selection);
|
|
let start = self.selection.start();
|
|
let clamped = text_helpers::clamp_cursor(start, text_content.paragraphs());
|
|
self.selection.set_caret(clamped);
|
|
} else if word_boundary {
|
|
let cursor = self.selection.focus;
|
|
if let Some(new_cursor) = text_helpers::delete_word_before(text_content, &cursor) {
|
|
self.selection.set_caret(new_cursor);
|
|
}
|
|
} else {
|
|
let cursor = self.selection.focus;
|
|
if let Some(new_cursor) = text_helpers::delete_char_before(text_content, &cursor) {
|
|
self.selection.set_caret(new_cursor);
|
|
}
|
|
}
|
|
|
|
text_content.layout.clear();
|
|
|
|
self.reset_blink();
|
|
self.push_event(TextEditorEvent::ContentChanged);
|
|
self.push_event(TextEditorEvent::NeedsLayout);
|
|
}
|
|
|
|
pub fn delete_forward(&mut self, text_content: &mut TextContent, word_boundary: bool) {
|
|
if self.selection.is_selection() {
|
|
text_helpers::delete_selection_range(text_content, &self.selection);
|
|
let start = self.selection.start();
|
|
let clamped = text_helpers::clamp_cursor(start, text_content.paragraphs());
|
|
self.selection.set_caret(clamped);
|
|
} else if word_boundary {
|
|
let cursor = self.selection.focus;
|
|
text_helpers::delete_word_after(text_content, &cursor);
|
|
let clamped = text_helpers::clamp_cursor(cursor, text_content.paragraphs());
|
|
self.selection.set_caret(clamped);
|
|
} else {
|
|
let cursor = self.selection.focus;
|
|
text_helpers::delete_char_after(text_content, &cursor);
|
|
let clamped = text_helpers::clamp_cursor(cursor, text_content.paragraphs());
|
|
self.selection.set_caret(clamped);
|
|
}
|
|
|
|
text_content.layout.clear();
|
|
|
|
self.reset_blink();
|
|
self.push_event(TextEditorEvent::ContentChanged);
|
|
self.push_event(TextEditorEvent::NeedsLayout);
|
|
}
|
|
|
|
pub fn insert_paragraph(&mut self, text_content: &mut TextContent) {
|
|
if self.selection.is_selection() {
|
|
text_helpers::delete_selection_range(text_content, &self.selection);
|
|
let start = self.selection.start();
|
|
self.selection.set_caret(start);
|
|
}
|
|
|
|
let cursor = self.selection.focus;
|
|
if text_helpers::split_paragraph_at_cursor(text_content, &cursor) {
|
|
let new_cursor =
|
|
TextPositionWithAffinity::new_downstream_affinity(cursor.paragraph + 1, 0);
|
|
self.selection.set_caret(new_cursor);
|
|
}
|
|
|
|
text_content.layout.clear();
|
|
|
|
self.reset_blink();
|
|
self.push_event(TextEditorEvent::ContentChanged);
|
|
self.push_event(TextEditorEvent::NeedsLayout);
|
|
}
|
|
|
|
pub fn move_cursor(
|
|
&mut self,
|
|
text_content: &TextContent,
|
|
direction: CursorDirection,
|
|
word_boundary: bool,
|
|
extend_selection: bool,
|
|
) -> bool {
|
|
let paragraphs = text_content.paragraphs();
|
|
if paragraphs.is_empty() {
|
|
return false;
|
|
}
|
|
|
|
// With a range selected, Left/Right collapse to that edge and stop.
|
|
// Extending (Shift) keeps the normal move-from-focus path below.
|
|
if self.selection.is_selection() && !extend_selection {
|
|
let edge = match direction {
|
|
CursorDirection::Backward => Some(self.selection.start()),
|
|
CursorDirection::Forward => Some(self.selection.end()),
|
|
_ => None,
|
|
};
|
|
if let Some(caret) = edge {
|
|
self.selection.set_caret(caret);
|
|
self.update_styles(text_content);
|
|
self.reset_blink();
|
|
self.push_event(TextEditorEvent::SelectionChanged);
|
|
return true;
|
|
}
|
|
}
|
|
|
|
let focus = self.selection.focus;
|
|
|
|
// Get the text direction of the span at the current cursor position
|
|
let text_span_text_direction = if focus.paragraph < paragraphs.len() {
|
|
text_helpers::get_text_span_text_direction_at_offset(
|
|
¶graphs[focus.paragraph],
|
|
focus.offset,
|
|
)
|
|
} else {
|
|
TextDirection::LTR
|
|
};
|
|
|
|
// In vertical-rl, Up/Down move by character along the column and
|
|
// Left/Right cross columns (columns advance right-to-left).
|
|
let is_vertical = text_content.is_vertical();
|
|
let direction = if is_vertical {
|
|
match direction {
|
|
CursorDirection::Backward => CursorDirection::LineAfter, // Left -> next column
|
|
CursorDirection::Forward => CursorDirection::LineBefore, // Right -> prev column
|
|
CursorDirection::LineBefore => CursorDirection::Backward, // Up -> prev char
|
|
CursorDirection::LineAfter => CursorDirection::Forward, // Down -> next char
|
|
other => other,
|
|
}
|
|
} else {
|
|
direction
|
|
};
|
|
|
|
// For horizontal navigation, swap Backward/Forward when in RTL text
|
|
let adjusted_direction = if !is_vertical && text_span_text_direction == TextDirection::RTL {
|
|
match direction {
|
|
CursorDirection::Backward => CursorDirection::Forward,
|
|
CursorDirection::Forward => CursorDirection::Backward,
|
|
other => other,
|
|
}
|
|
} else {
|
|
direction
|
|
};
|
|
|
|
let new_cursor = match adjusted_direction {
|
|
CursorDirection::Backward => {
|
|
text_helpers::move_cursor_backward(&focus, paragraphs, word_boundary)
|
|
}
|
|
CursorDirection::Forward => {
|
|
text_helpers::move_cursor_forward(&focus, paragraphs, word_boundary)
|
|
}
|
|
CursorDirection::LineBefore => {
|
|
text_helpers::move_cursor_up(&focus, paragraphs, text_content)
|
|
}
|
|
CursorDirection::LineAfter => {
|
|
text_helpers::move_cursor_down(&focus, paragraphs, text_content)
|
|
}
|
|
CursorDirection::LineStart if word_boundary => TextPositionWithAffinity::empty(),
|
|
CursorDirection::LineEnd if word_boundary => text_helpers::move_cursor_line_end(
|
|
&TextPositionWithAffinity::new_downstream_affinity(paragraphs.len() - 1, 0),
|
|
paragraphs,
|
|
),
|
|
CursorDirection::LineStart => text_helpers::move_cursor_line_start(&focus, paragraphs),
|
|
CursorDirection::LineEnd => text_helpers::move_cursor_line_end(&focus, paragraphs),
|
|
};
|
|
|
|
if extend_selection {
|
|
self.selection.extend_to(new_cursor);
|
|
} else {
|
|
self.selection.set_caret(new_cursor);
|
|
}
|
|
|
|
self.update_styles(text_content);
|
|
|
|
self.reset_blink();
|
|
self.push_event(TextEditorEvent::SelectionChanged);
|
|
true
|
|
}
|
|
}
|
|
|
|
impl Default for TextEditorState {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
fn is_word_char(c: char) -> bool {
|
|
c.is_alphanumeric() || c == '_'
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::math::Rect;
|
|
use crate::shapes::{
|
|
Fill, FontFamily, FontStyle, GrowType, Paragraph, SolidColor, TextAlign, TextSpan,
|
|
};
|
|
use skia_safe::Color;
|
|
|
|
fn span(text: &str, font_size: f32, fill: Fill) -> TextSpan {
|
|
TextSpan::new(
|
|
text.to_string(),
|
|
FontFamily::new(Uuid::nil(), 400, FontStyle::Normal),
|
|
font_size,
|
|
1.2,
|
|
0.0,
|
|
None,
|
|
None,
|
|
TextDirection::LTR,
|
|
400,
|
|
Uuid::nil(),
|
|
vec![fill],
|
|
)
|
|
}
|
|
|
|
fn hello_world_content() -> TextContent {
|
|
let black = Fill::Solid(SolidColor(Color::BLACK));
|
|
let red = Fill::Solid(SolidColor(Color::RED));
|
|
let mut content =
|
|
TextContent::new(Rect::from_xywh(0.0, 0.0, 200.0, 100.0), GrowType::Fixed);
|
|
content.add_paragraph(Paragraph::new(
|
|
TextAlign::Left,
|
|
TextDirection::LTR,
|
|
None,
|
|
None,
|
|
1.2,
|
|
0.0,
|
|
vec![span("Hello ", 24.0, black), span("world", 40.0, red)],
|
|
));
|
|
content
|
|
}
|
|
|
|
fn content(text: &str) -> TextContent {
|
|
let mut content =
|
|
TextContent::new(Rect::from_xywh(0.0, 0.0, 200.0, 100.0), GrowType::Fixed);
|
|
content.add_paragraph(Paragraph::new(
|
|
TextAlign::Left,
|
|
TextDirection::LTR,
|
|
None,
|
|
None,
|
|
1.2,
|
|
0.0,
|
|
vec![span(text, 14.0, Fill::Solid(SolidColor(Color::BLACK)))],
|
|
));
|
|
content
|
|
}
|
|
|
|
fn drain_events(editor: &mut TextEditorState) -> Vec<TextEditorEvent> {
|
|
let mut events = Vec::new();
|
|
loop {
|
|
let ev = editor.poll_event();
|
|
if ev == TextEditorEvent::None {
|
|
break;
|
|
}
|
|
events.push(ev);
|
|
}
|
|
events
|
|
}
|
|
|
|
fn pos(offset: usize) -> TextPositionWithAffinity {
|
|
TextPositionWithAffinity::new_downstream_affinity(0, offset)
|
|
}
|
|
|
|
#[test]
|
|
fn set_caret_from_position_alone_does_not_emit_styles_changed() {
|
|
let content = hello_world_content();
|
|
let mut editor = TextEditorState::new();
|
|
editor.place_caret_from_position(
|
|
&content,
|
|
&TextPositionWithAffinity::new_downstream_affinity(0, 1),
|
|
);
|
|
drain_events(&mut editor);
|
|
|
|
editor.set_caret_from_position(&TextPositionWithAffinity::new_downstream_affinity(0, 8));
|
|
let events = drain_events(&mut editor);
|
|
|
|
assert!(!events.contains(&TextEditorEvent::StylesChanged));
|
|
assert_eq!(editor.current_styles.font_size.value(), &Some(24.0));
|
|
}
|
|
|
|
#[test]
|
|
fn place_caret_from_position_syncs_styles_and_emits_styles_changed() {
|
|
let content = hello_world_content();
|
|
let mut editor = TextEditorState::new();
|
|
editor.place_caret_from_position(
|
|
&content,
|
|
&TextPositionWithAffinity::new_downstream_affinity(0, 1),
|
|
);
|
|
drain_events(&mut editor);
|
|
assert_eq!(editor.current_styles.font_size.value(), &Some(24.0));
|
|
|
|
// Offset 8 is inside the second span ("world").
|
|
editor.place_caret_from_position(
|
|
&content,
|
|
&TextPositionWithAffinity::new_downstream_affinity(0, 8),
|
|
);
|
|
let events = drain_events(&mut editor);
|
|
|
|
assert!(events.contains(&TextEditorEvent::StylesChanged));
|
|
assert_eq!(editor.current_styles.font_size.value(), &Some(40.0));
|
|
assert_eq!(
|
|
editor.current_styles.fills,
|
|
vec![Fill::Solid(SolidColor(Color::RED))]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn place_caret_from_position_clears_mixed_selection_styles() {
|
|
let content = hello_world_content();
|
|
let mut editor = TextEditorState::new();
|
|
editor.select_all(&content);
|
|
assert!(editor.current_styles.font_size.is_multiple());
|
|
assert!(editor.current_styles.fills_are_multiple);
|
|
drain_events(&mut editor);
|
|
|
|
editor.place_caret_from_position(
|
|
&content,
|
|
&TextPositionWithAffinity::new_downstream_affinity(0, 8),
|
|
);
|
|
let events = drain_events(&mut editor);
|
|
|
|
assert!(events.contains(&TextEditorEvent::StylesChanged));
|
|
assert_eq!(editor.current_styles.font_size.value(), &Some(40.0));
|
|
assert!(!editor.current_styles.fills_are_multiple);
|
|
assert_eq!(
|
|
editor.current_styles.fills,
|
|
vec![Fill::Solid(SolidColor(Color::RED))]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn move_backward_with_selection_collapses_to_start() {
|
|
// "Third paragraph" — select "aph" at the end, then Left.
|
|
let content = content("Third paragraph");
|
|
let mut editor = TextEditorState::new();
|
|
editor.selection.set_caret(pos(12));
|
|
editor.selection.extend_to(pos(15));
|
|
|
|
editor.move_cursor(&content, CursorDirection::Backward, false, false);
|
|
|
|
assert!(editor.selection.is_collapsed());
|
|
assert_eq!(editor.selection.focus.offset, 12);
|
|
}
|
|
|
|
#[test]
|
|
fn move_forward_with_selection_collapses_to_end() {
|
|
// Caret between "parag" and "raph", Shift+Left twice selects "ag", then Right.
|
|
let content = content("Third paragraph");
|
|
let mut editor = TextEditorState::new();
|
|
editor.selection.set_caret(pos(11));
|
|
editor.selection.extend_to(pos(9));
|
|
|
|
editor.move_cursor(&content, CursorDirection::Forward, false, false);
|
|
|
|
assert!(editor.selection.is_collapsed());
|
|
assert_eq!(editor.selection.focus.offset, 11);
|
|
}
|
|
|
|
#[test]
|
|
fn move_backward_with_full_selection_collapses_to_document_start() {
|
|
let content = content("Hello");
|
|
let mut editor = TextEditorState::new();
|
|
editor.select_all(&content);
|
|
|
|
editor.move_cursor(&content, CursorDirection::Backward, false, false);
|
|
|
|
assert!(editor.selection.is_collapsed());
|
|
assert_eq!(editor.selection.focus.offset, 0);
|
|
}
|
|
|
|
#[test]
|
|
fn move_forward_with_full_selection_collapses_to_document_end() {
|
|
let content = content("Hello");
|
|
let mut editor = TextEditorState::new();
|
|
editor.select_all(&content);
|
|
|
|
editor.move_cursor(&content, CursorDirection::Forward, false, false);
|
|
|
|
assert!(editor.selection.is_collapsed());
|
|
assert_eq!(editor.selection.focus.offset, 5);
|
|
}
|
|
|
|
#[test]
|
|
fn move_backward_while_extending_still_moves_from_focus() {
|
|
let content = content("Third paragraph");
|
|
let mut editor = TextEditorState::new();
|
|
editor.selection.set_caret(pos(15));
|
|
editor.selection.extend_to(pos(12));
|
|
|
|
editor.move_cursor(&content, CursorDirection::Backward, false, true);
|
|
|
|
assert!(editor.selection.is_selection());
|
|
assert_eq!(editor.selection.anchor.offset, 15);
|
|
assert_eq!(editor.selection.focus.offset, 11);
|
|
}
|
|
|
|
#[test]
|
|
fn move_backward_with_collapsed_caret_still_moves() {
|
|
let content = content("Hello");
|
|
let mut editor = TextEditorState::new();
|
|
editor.selection.set_caret(pos(3));
|
|
|
|
editor.move_cursor(&content, CursorDirection::Backward, false, false);
|
|
|
|
assert!(editor.selection.is_collapsed());
|
|
assert_eq!(editor.selection.focus.offset, 2);
|
|
}
|
|
|
|
fn caret(paragraph: usize, offset: usize) -> TextSelection {
|
|
TextSelection::from_position_with_affinity(
|
|
TextPositionWithAffinity::new_downstream_affinity(paragraph, offset),
|
|
)
|
|
}
|
|
|
|
#[test]
|
|
fn composition_replaces_the_previous_preview_wherever_the_caret_is() {
|
|
let mut composition = TextComposition::new();
|
|
composition.start();
|
|
|
|
// First update: the pre-composition caret is the replaced range.
|
|
composition.update("に");
|
|
let replaced = composition.get_selection(&caret(0, 2));
|
|
assert_eq!((replaced.start().offset, replaced.end().offset), (2, 2));
|
|
composition.start = Some(replaced.start());
|
|
|
|
// The caret sits after the preview; the next update still replaces
|
|
// the preview itself.
|
|
composition.update("にほ");
|
|
let replaced = composition.get_selection(&caret(0, 3));
|
|
assert_eq!((replaced.start().offset, replaced.end().offset), (2, 3));
|
|
|
|
composition.end();
|
|
assert!(composition.start.is_none());
|
|
}
|
|
}
|