mirror of
https://github.com/penpot/penpot.git
synced 2026-11-05 01:20:38 +00:00
Keep bounded content and selection history within each editing session. Group committed compositions and styled insertions, omit IME previews, and reset history when external content changes the editing baseline. Route keyboard, workspace menu and browser history input to the editor. Restore UTF-16 selections through a new WASM binding, preserving styles, readings and paragraph settings without reaching earlier canvas edits. Verify 1,587 frontend tests, 671 renderer tests and both WASM builds. Add four editable showcase examples and verify them after reopening. AI-assisted-by: GPT-6
1388 lines
48 KiB
Rust
1388 lines
48 KiB
Rust
use macros::{wasm_error, ToJs};
|
|
|
|
use crate::globals::{get_render_state, get_text_editor_state};
|
|
use crate::math::{Matrix, Point};
|
|
use crate::mem;
|
|
use crate::render::text_editor::range_origin;
|
|
use crate::shapes::{TextAlign, TextContent, TextPositionWithAffinity, Type, VerticalAlign};
|
|
use crate::state::{State, TextEditorEvent, TextEditorState};
|
|
use crate::utils::uuid_from_u32_quartet;
|
|
use crate::utils::uuid_to_u32_quartet;
|
|
use crate::uuid::Uuid;
|
|
use crate::wasm::fills::RawFillData;
|
|
use crate::wasm::text::{
|
|
helpers as text_helpers, RawTextAlign, RawTextDecoration, RawTextDirection, RawTextTransform,
|
|
};
|
|
use crate::with_state;
|
|
use skia_safe::Color;
|
|
|
|
#[derive(PartialEq, ToJs)]
|
|
#[repr(u8)]
|
|
#[allow(dead_code)]
|
|
pub enum CursorDirection {
|
|
Backward = 0,
|
|
Forward = 1,
|
|
LineBefore = 2,
|
|
LineAfter = 3,
|
|
LineStart = 4,
|
|
LineEnd = 5,
|
|
}
|
|
|
|
// ============================================================================
|
|
// STATE MANAGEMENT
|
|
// ============================================================================
|
|
|
|
/// Apply the editor theme. When `invert` is true the caret is painted with a
|
|
/// Difference blend mode (pass white as `cursor_color` to always show the
|
|
/// inverted color of the background); otherwise it is painted with the given
|
|
/// solid `cursor_color`.
|
|
#[no_mangle]
|
|
pub extern "C" fn text_editor_apply_theme(selection_color: u32, cursor_color: u32, invert: bool) {
|
|
// NOTE: In the future could be interesting to fill al this data from
|
|
// a structure pointer.
|
|
get_text_editor_state().theme.selection_color = Color::new(selection_color);
|
|
get_text_editor_state().theme.cursor_color = Color::new(cursor_color);
|
|
get_text_editor_state().theme.cursor_invert = invert;
|
|
}
|
|
|
|
#[no_mangle]
|
|
pub extern "C" fn text_editor_focus(a: u32, b: u32, c: u32, d: u32) -> bool {
|
|
with_state!(state, {
|
|
let shape_id = uuid_from_u32_quartet(a, b, c, d);
|
|
|
|
let Some(shape) = state.shapes.get(&shape_id) else {
|
|
return false;
|
|
};
|
|
|
|
if !matches!(shape.shape_type, Type::Text(_)) {
|
|
return false;
|
|
}
|
|
|
|
get_text_editor_state().focus(shape_id);
|
|
true
|
|
})
|
|
}
|
|
|
|
#[no_mangle]
|
|
pub extern "C" fn text_editor_blur() -> bool {
|
|
if !get_text_editor_state().has_focus {
|
|
return false;
|
|
}
|
|
get_text_editor_state().blur();
|
|
true
|
|
}
|
|
|
|
#[no_mangle]
|
|
pub extern "C" fn text_editor_dispose() -> bool {
|
|
get_text_editor_state().dispose();
|
|
true
|
|
}
|
|
|
|
#[no_mangle]
|
|
pub extern "C" fn text_editor_has_selection() -> bool {
|
|
get_text_editor_state().selection.is_selection()
|
|
}
|
|
|
|
#[no_mangle]
|
|
pub extern "C" fn text_editor_has_focus() -> bool {
|
|
get_text_editor_state().has_focus
|
|
}
|
|
|
|
#[no_mangle]
|
|
pub extern "C" fn text_editor_has_focus_with_id(a: u32, b: u32, c: u32, d: u32) -> bool {
|
|
let shape_id = uuid_from_u32_quartet(a, b, c, d);
|
|
let Some(active_shape_id) = get_text_editor_state().active_shape_id else {
|
|
return false;
|
|
};
|
|
get_text_editor_state().has_focus && active_shape_id == shape_id
|
|
}
|
|
|
|
#[no_mangle]
|
|
pub extern "C" fn text_editor_get_active_shape_id(buffer_ptr: *mut u32) {
|
|
if let Some(shape_id) = get_text_editor_state().active_shape_id {
|
|
let (a, b, c, d) = uuid_to_u32_quartet(&shape_id);
|
|
unsafe {
|
|
*buffer_ptr = a;
|
|
*buffer_ptr.add(1) = b;
|
|
*buffer_ptr.add(2) = c;
|
|
*buffer_ptr.add(3) = d;
|
|
}
|
|
}
|
|
}
|
|
|
|
#[no_mangle]
|
|
pub extern "C" fn text_editor_select_all() -> bool {
|
|
with_state!(state, {
|
|
if !get_text_editor_state().has_focus {
|
|
return false;
|
|
}
|
|
|
|
let Some(shape_id) = get_text_editor_state().active_shape_id else {
|
|
return false;
|
|
};
|
|
|
|
let Some(shape) = state.shapes.get(&shape_id) else {
|
|
return false;
|
|
};
|
|
|
|
let Type::Text(text_content) = &shape.shape_type else {
|
|
return false;
|
|
};
|
|
get_text_editor_state().select_all(text_content)
|
|
})
|
|
}
|
|
|
|
fn with_active_text_at_point<F>(x: f32, y: f32, apply: F)
|
|
where
|
|
F: FnOnce(&mut TextEditorState, &TextContent, &TextPositionWithAffinity),
|
|
{
|
|
with_state!(state, {
|
|
if !get_text_editor_state().has_focus {
|
|
return;
|
|
}
|
|
|
|
let Some(shape_id) = get_text_editor_state().active_shape_id else {
|
|
return;
|
|
};
|
|
|
|
let Some(shape) = state.shapes.get(&shape_id) else {
|
|
return;
|
|
};
|
|
|
|
let Type::Text(text_content) = &shape.shape_type else {
|
|
return;
|
|
};
|
|
|
|
let point = Point::new(x, y);
|
|
if let Some(position) = text_content.get_caret_position_from_shape_coords(
|
|
&point,
|
|
&shape.selrect(),
|
|
shape.vertical_align(),
|
|
) {
|
|
apply(get_text_editor_state(), text_content, &position);
|
|
}
|
|
})
|
|
}
|
|
|
|
#[no_mangle]
|
|
pub extern "C" fn text_editor_select_word_boundary(x: f32, y: f32) {
|
|
with_active_text_at_point(x, y, |editor, text_content, position| {
|
|
editor.select_word_boundary(text_content, position)
|
|
})
|
|
}
|
|
|
|
#[no_mangle]
|
|
pub extern "C" fn text_editor_select_paragraph(x: f32, y: f32) {
|
|
// A drag that produced a range must survive the trailing click; a jitter
|
|
// that left the caret collapsed must not suppress the paragraph select.
|
|
let editor = get_text_editor_state();
|
|
if editor.is_click_event_skipped && editor.selection.is_selection() {
|
|
editor.is_click_event_skipped = false;
|
|
return;
|
|
}
|
|
|
|
with_active_text_at_point(x, y, |editor, text_content, position| {
|
|
editor.select_paragraph(text_content, position)
|
|
})
|
|
}
|
|
|
|
#[no_mangle]
|
|
pub extern "C" fn text_editor_poll_event() -> u8 {
|
|
get_text_editor_state().poll_event() as u8
|
|
}
|
|
|
|
// ============================================================================
|
|
// SELECTION MANAGEMENT
|
|
// ============================================================================
|
|
|
|
#[no_mangle]
|
|
pub extern "C" fn text_editor_pointer_down(x: f32, y: f32) {
|
|
with_state!(state, {
|
|
if !get_text_editor_state().has_focus {
|
|
return;
|
|
}
|
|
let Some(shape_id) = get_text_editor_state().active_shape_id else {
|
|
return;
|
|
};
|
|
let Some(shape) = state.shapes.get(&shape_id) else {
|
|
return;
|
|
};
|
|
let Type::Text(text_content) = &shape.shape_type else {
|
|
return;
|
|
};
|
|
let point = Point::new(x, y);
|
|
get_text_editor_state().start_pointer_selection();
|
|
if let Some(position) = text_content.get_caret_position_from_shape_coords(
|
|
&point,
|
|
&shape.selrect(),
|
|
shape.vertical_align(),
|
|
) {
|
|
get_text_editor_state().place_caret_from_position(text_content, &position);
|
|
}
|
|
});
|
|
}
|
|
|
|
/// Like `text_editor_pointer_down`, but keeps the current anchor and moves the
|
|
/// focus to the pointer instead of collapsing the caret there (Shift+click).
|
|
#[no_mangle]
|
|
pub extern "C" fn text_editor_pointer_down_extend(x: f32, y: f32) {
|
|
with_state!(state, {
|
|
if !get_text_editor_state().has_focus {
|
|
return;
|
|
}
|
|
let Some(shape_id) = get_text_editor_state().active_shape_id else {
|
|
return;
|
|
};
|
|
let Some(shape) = state.shapes.get(&shape_id) else {
|
|
return;
|
|
};
|
|
let Type::Text(text_content) = &shape.shape_type else {
|
|
return;
|
|
};
|
|
let point = Point::new(x, y);
|
|
get_text_editor_state().start_pointer_selection();
|
|
if let Some(position) = text_content.get_caret_position_from_shape_coords(
|
|
&point,
|
|
&shape.selrect(),
|
|
shape.vertical_align(),
|
|
) {
|
|
get_text_editor_state().extend_selection_from_position(&position);
|
|
// The click after pointerup would collapse the caret and drop the
|
|
// selection we just extended.
|
|
get_text_editor_state().is_click_event_skipped = true;
|
|
get_text_editor_state().update_styles(text_content);
|
|
}
|
|
});
|
|
}
|
|
|
|
#[no_mangle]
|
|
pub extern "C" fn text_editor_pointer_move(x: f32, y: f32) {
|
|
with_state!(state, {
|
|
if !get_text_editor_state().has_focus {
|
|
return;
|
|
}
|
|
|
|
let point = Point::new(x, y);
|
|
let Some(shape_id) = get_text_editor_state().active_shape_id else {
|
|
return;
|
|
};
|
|
|
|
let Some(shape) = state.shapes.get(&shape_id) else {
|
|
return;
|
|
};
|
|
|
|
if !get_text_editor_state().is_pointer_selection_active {
|
|
return;
|
|
}
|
|
|
|
let Type::Text(text_content) = &shape.shape_type else {
|
|
return;
|
|
};
|
|
|
|
if let Some(position) = text_content.get_caret_position_from_shape_coords(
|
|
&point,
|
|
&shape.selrect(),
|
|
shape.vertical_align(),
|
|
) {
|
|
get_text_editor_state().extend_selection_from_position(&position);
|
|
// We need this flag to prevent handling the click behavior
|
|
// just after a pointerup event.
|
|
get_text_editor_state().is_click_event_skipped = true;
|
|
get_text_editor_state().update_styles(text_content);
|
|
}
|
|
});
|
|
}
|
|
|
|
#[no_mangle]
|
|
pub extern "C" fn text_editor_pointer_up(x: f32, y: f32) {
|
|
with_state!(state, {
|
|
if !get_text_editor_state().has_focus {
|
|
return;
|
|
}
|
|
let point = Point::new(x, y);
|
|
let Some(shape_id) = get_text_editor_state().active_shape_id else {
|
|
return;
|
|
};
|
|
let Some(shape) = state.shapes.get(&shape_id) else {
|
|
return;
|
|
};
|
|
if !get_text_editor_state().is_pointer_selection_active {
|
|
return;
|
|
}
|
|
let Type::Text(text_content) = &shape.shape_type else {
|
|
return;
|
|
};
|
|
if let Some(position) = text_content.get_caret_position_from_shape_coords(
|
|
&point,
|
|
&shape.selrect(),
|
|
shape.vertical_align(),
|
|
) {
|
|
get_text_editor_state().extend_selection_from_position(&position);
|
|
get_text_editor_state().update_styles(text_content);
|
|
}
|
|
get_text_editor_state().stop_pointer_selection();
|
|
});
|
|
}
|
|
|
|
#[no_mangle]
|
|
pub extern "C" fn text_editor_set_cursor_from_offset(x: f32, y: f32) {
|
|
with_state!(state, {
|
|
// We need this flag to prevent handling the click behavior
|
|
// just after a pointerup event.
|
|
if get_text_editor_state().is_click_event_skipped {
|
|
get_text_editor_state().is_click_event_skipped = false;
|
|
return;
|
|
}
|
|
|
|
if !get_text_editor_state().has_focus {
|
|
return;
|
|
}
|
|
|
|
let point = Point::new(x, y);
|
|
let Some(shape_id) = get_text_editor_state().active_shape_id else {
|
|
return;
|
|
};
|
|
|
|
let Some(shape) = state.shapes.get(&shape_id) else {
|
|
return;
|
|
};
|
|
|
|
let Type::Text(text_content) = &shape.shape_type else {
|
|
return;
|
|
};
|
|
|
|
if let Some(position) = text_content.get_caret_position_from_shape_coords(
|
|
&point,
|
|
&shape.selrect(),
|
|
shape.vertical_align(),
|
|
) {
|
|
get_text_editor_state().place_caret_from_position(text_content, &position);
|
|
}
|
|
});
|
|
}
|
|
|
|
#[no_mangle]
|
|
pub extern "C" fn text_editor_set_cursor_from_point(x: f32, y: f32) {
|
|
with_state!(state, {
|
|
if !get_text_editor_state().has_focus {
|
|
return;
|
|
}
|
|
|
|
let view_matrix: Matrix = get_render_state().viewbox.get_matrix();
|
|
let point = Point::new(x, y);
|
|
let Some(shape_id) = get_text_editor_state().active_shape_id else {
|
|
return;
|
|
};
|
|
let Some(shape) = state.shapes.get(&shape_id) else {
|
|
return;
|
|
};
|
|
let shape_matrix = shape.get_matrix();
|
|
let Type::Text(text_content) = &shape.shape_type else {
|
|
return;
|
|
};
|
|
if let Some(position) = text_content.get_caret_position_from_screen_coords(
|
|
&point,
|
|
&view_matrix,
|
|
&shape_matrix,
|
|
&shape.selrect(),
|
|
shape.vertical_align(),
|
|
) {
|
|
get_text_editor_state().place_caret_from_position(text_content, &position);
|
|
}
|
|
});
|
|
}
|
|
|
|
// ============================================================================
|
|
// TEXT OPERATIONS
|
|
// ============================================================================
|
|
|
|
fn text_content_for_edit(state: &mut State, shape_id: Uuid) -> Option<&mut TextContent> {
|
|
if !matches!(state.shapes.get_raw(&shape_id)?.shape_type, Type::Text(_)) {
|
|
return None;
|
|
}
|
|
// Capture old tile coverage before changing the preview, then drop the
|
|
// shape snapshot and bounds so new glyphs and erased previews get indexed.
|
|
state.touch_shape(shape_id);
|
|
state.shapes.invalidate_shape_extrect(&shape_id);
|
|
let shape = state.shapes.get_mut(&shape_id)?;
|
|
match &mut shape.shape_type {
|
|
Type::Text(content) => Some(content),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
#[no_mangle]
|
|
#[wasm_error]
|
|
pub extern "C" fn text_editor_composition_start() -> Result<()> {
|
|
if !get_text_editor_state().has_focus {
|
|
return Ok(());
|
|
}
|
|
get_text_editor_state().composition.start();
|
|
Ok(())
|
|
}
|
|
|
|
#[no_mangle]
|
|
#[wasm_error]
|
|
pub extern "C" fn text_editor_composition_end() -> Result<()> {
|
|
let bytes = crate::mem::bytes_or_empty();
|
|
let text = match String::from_utf8(bytes) {
|
|
Ok(text) => text,
|
|
Err(_) => return Ok(()),
|
|
};
|
|
|
|
with_state!(state, {
|
|
if !get_text_editor_state().has_focus {
|
|
return Ok(());
|
|
}
|
|
|
|
let Some(shape_id) = get_text_editor_state().active_shape_id else {
|
|
return Ok(());
|
|
};
|
|
|
|
let Some(text_content) = text_content_for_edit(state, shape_id) else {
|
|
return Ok(());
|
|
};
|
|
|
|
get_text_editor_state().composition.update(&text);
|
|
|
|
let selection = get_text_editor_state()
|
|
.composition
|
|
.get_selection(&get_text_editor_state().selection);
|
|
text_helpers::delete_selection_range(text_content, &selection);
|
|
|
|
// Insert at the start of the replaced range (the pre-composition
|
|
// selection or the previous preview), not at a possibly stale
|
|
// selection focus, and advance the caret past the committed text.
|
|
let cursor = selection.start();
|
|
if let Some(new_cursor) =
|
|
text_helpers::insert_text_with_newlines(text_content, &cursor, &text)
|
|
{
|
|
get_text_editor_state().selection.set_caret(new_cursor);
|
|
} else {
|
|
get_text_editor_state().selection.set_caret(cursor);
|
|
}
|
|
|
|
text_content.layout.clear();
|
|
|
|
get_text_editor_state().reset_blink();
|
|
get_text_editor_state().push_event(crate::state::TextEditorEvent::ContentChanged);
|
|
get_text_editor_state().push_event(crate::state::TextEditorEvent::NeedsLayout);
|
|
|
|
get_text_editor_state().composition.end();
|
|
});
|
|
|
|
crate::mem::free_bytes()?;
|
|
Ok(())
|
|
}
|
|
|
|
#[no_mangle]
|
|
#[wasm_error]
|
|
pub extern "C" fn text_editor_composition_update() -> Result<()> {
|
|
let bytes = crate::mem::bytes_or_empty();
|
|
let text = match String::from_utf8(bytes) {
|
|
Ok(text) => text,
|
|
Err(_) => return Ok(()),
|
|
};
|
|
|
|
with_state!(state, {
|
|
if !get_text_editor_state().has_focus {
|
|
return Ok(());
|
|
}
|
|
|
|
let Some(shape_id) = get_text_editor_state().active_shape_id else {
|
|
return Ok(());
|
|
};
|
|
|
|
let Some(text_content) = text_content_for_edit(state, shape_id) else {
|
|
return Ok(());
|
|
};
|
|
|
|
get_text_editor_state().composition.update(&text);
|
|
|
|
let selection = get_text_editor_state()
|
|
.composition
|
|
.get_selection(&get_text_editor_state().selection);
|
|
text_helpers::delete_selection_range(text_content, &selection);
|
|
|
|
// The preview always starts where the replaced range did; the caret
|
|
// goes after it, as in a native text field.
|
|
let cursor = selection.start();
|
|
get_text_editor_state().composition.start = Some(cursor);
|
|
let caret =
|
|
text_helpers::insert_text_with_newlines(text_content, &cursor, &text).unwrap_or(cursor);
|
|
get_text_editor_state().selection.set_caret(caret);
|
|
|
|
text_content.layout.clear();
|
|
|
|
get_text_editor_state().reset_blink();
|
|
get_text_editor_state().push_event(crate::state::TextEditorEvent::ContentChanged);
|
|
get_text_editor_state().push_event(crate::state::TextEditorEvent::NeedsLayout);
|
|
});
|
|
|
|
crate::mem::free_bytes()?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Place the caret `offset` characters into the composition preview, where
|
|
/// the IME's own cursor is (clause conversion, cursor moves inside the
|
|
/// preedit). Clamped to the preview; ignored outside a composition.
|
|
#[no_mangle]
|
|
pub extern "C" fn text_editor_set_composition_cursor(offset: u32) {
|
|
let editor_state = get_text_editor_state();
|
|
if !editor_state.has_focus || !editor_state.composition.is_composing {
|
|
return;
|
|
}
|
|
let Some(start) = editor_state.composition.start else {
|
|
return;
|
|
};
|
|
let offset = (offset as usize).min(editor_state.composition.current.chars().count());
|
|
editor_state
|
|
.selection
|
|
.set_caret(TextPositionWithAffinity::new_downstream_affinity(
|
|
start.paragraph,
|
|
start.offset + offset,
|
|
));
|
|
editor_state.reset_blink();
|
|
}
|
|
|
|
#[no_mangle]
|
|
#[wasm_error]
|
|
pub extern "C" fn text_editor_toggle_overtype_mode() -> Result<()> {
|
|
get_text_editor_state().toggle_overtype_mode();
|
|
Ok(())
|
|
}
|
|
|
|
// FIXME: Review if all the return Ok(()) should be Err instead.
|
|
#[no_mangle]
|
|
#[wasm_error]
|
|
pub extern "C" fn text_editor_insert_text() -> Result<()> {
|
|
let bytes = crate::mem::bytes();
|
|
let text = match String::from_utf8(bytes) {
|
|
Ok(text) => text,
|
|
Err(_) => return Ok(()),
|
|
};
|
|
|
|
with_state!(state, {
|
|
if !get_text_editor_state().has_focus {
|
|
return Ok(());
|
|
}
|
|
|
|
let Some(shape_id) = get_text_editor_state().active_shape_id else {
|
|
return Ok(());
|
|
};
|
|
|
|
let Some(text_content) = text_content_for_edit(state, shape_id) else {
|
|
return Ok(());
|
|
};
|
|
|
|
let selection = get_text_editor_state().selection;
|
|
|
|
if selection.is_selection() {
|
|
text_helpers::delete_selection_range(text_content, &selection);
|
|
let start = selection.start();
|
|
get_text_editor_state().selection.set_caret(start);
|
|
}
|
|
|
|
let cursor = get_text_editor_state().selection.focus;
|
|
if !get_text_editor_state().is_overtype_mode {
|
|
if let Some(new_cursor) =
|
|
text_helpers::insert_text_with_newlines(text_content, &cursor, &text)
|
|
{
|
|
get_text_editor_state().selection.set_caret(new_cursor);
|
|
}
|
|
} else if let Some(new_cursor) =
|
|
text_helpers::replace_text_with_newlines(text_content, &cursor, &text)
|
|
{
|
|
get_text_editor_state().selection.set_caret(new_cursor);
|
|
}
|
|
|
|
text_content.layout.clear();
|
|
|
|
get_text_editor_state().reset_blink();
|
|
get_text_editor_state().push_event(TextEditorEvent::ContentChanged);
|
|
get_text_editor_state().push_event(TextEditorEvent::NeedsLayout);
|
|
});
|
|
|
|
crate::mem::free_bytes()?;
|
|
Ok(())
|
|
}
|
|
|
|
#[no_mangle]
|
|
pub extern "C" fn text_editor_delete_backward(word_boundary: bool) {
|
|
with_state!(state, {
|
|
if !get_text_editor_state().has_focus {
|
|
return;
|
|
}
|
|
|
|
let Some(shape_id) = get_text_editor_state().active_shape_id else {
|
|
return;
|
|
};
|
|
|
|
let Some(text_content) = text_content_for_edit(state, shape_id) else {
|
|
return;
|
|
};
|
|
|
|
get_text_editor_state().delete_backward(text_content, word_boundary);
|
|
});
|
|
}
|
|
|
|
#[no_mangle]
|
|
pub extern "C" fn text_editor_delete_forward(word_boundary: bool) {
|
|
with_state!(state, {
|
|
if !get_text_editor_state().has_focus {
|
|
return;
|
|
}
|
|
|
|
let Some(shape_id) = get_text_editor_state().active_shape_id else {
|
|
return;
|
|
};
|
|
|
|
let Some(text_content) = text_content_for_edit(state, shape_id) else {
|
|
return;
|
|
};
|
|
|
|
get_text_editor_state().delete_forward(text_content, word_boundary);
|
|
});
|
|
}
|
|
|
|
#[no_mangle]
|
|
pub extern "C" fn text_editor_insert_paragraph() {
|
|
with_state!(state, {
|
|
if !get_text_editor_state().has_focus {
|
|
return;
|
|
}
|
|
|
|
let Some(shape_id) = get_text_editor_state().active_shape_id else {
|
|
return;
|
|
};
|
|
|
|
let Some(text_content) = text_content_for_edit(state, shape_id) else {
|
|
return;
|
|
};
|
|
|
|
get_text_editor_state().insert_paragraph(text_content);
|
|
});
|
|
}
|
|
|
|
// ============================================================================
|
|
// NAVIGATION
|
|
// ============================================================================
|
|
|
|
#[no_mangle]
|
|
pub extern "C" fn text_editor_move_cursor(
|
|
direction: CursorDirection,
|
|
word_boundary: bool,
|
|
extend_selection: bool,
|
|
) {
|
|
with_state!(state, {
|
|
if !get_text_editor_state().has_focus {
|
|
return;
|
|
}
|
|
|
|
let Some(shape_id) = get_text_editor_state().active_shape_id else {
|
|
return;
|
|
};
|
|
|
|
let Some(shape) = state.shapes.get(&shape_id) else {
|
|
return;
|
|
};
|
|
|
|
let Type::Text(text_content) = &shape.shape_type else {
|
|
return;
|
|
};
|
|
|
|
get_text_editor_state().move_cursor(
|
|
text_content,
|
|
direction,
|
|
word_boundary,
|
|
extend_selection,
|
|
);
|
|
});
|
|
}
|
|
|
|
// ============================================================================
|
|
// RENDERING & EXPORT
|
|
// ============================================================================
|
|
|
|
/// Caret rectangle in page coordinates (the unrotated selrect space of the
|
|
/// text overlay), regardless of the blink phase. The frontend keeps the IME
|
|
/// capture surface on it.
|
|
#[no_mangle]
|
|
pub extern "C" fn text_editor_get_cursor_rect() -> *mut u8 {
|
|
with_state!(state, {
|
|
let editor_state = get_text_editor_state();
|
|
if !editor_state.has_focus {
|
|
return std::ptr::null_mut();
|
|
}
|
|
|
|
let Some(shape_id) = editor_state.active_shape_id else {
|
|
return std::ptr::null_mut();
|
|
};
|
|
|
|
// A preview update clears the layout; rebuild it so the rect is
|
|
// current before the next render.
|
|
update_text_layout_if_needed(state, shape_id);
|
|
|
|
let Some(shape) = state.shapes.get(&shape_id) else {
|
|
return std::ptr::null_mut();
|
|
};
|
|
|
|
let Type::Text(text_content) = &shape.shape_type else {
|
|
return std::ptr::null_mut();
|
|
};
|
|
|
|
let Some(rect) = crate::render::text_editor::cursor_rect(editor_state, text_content, shape)
|
|
else {
|
|
return std::ptr::null_mut();
|
|
};
|
|
|
|
let selrect = shape.selrect();
|
|
let mut bytes = vec![0u8; 16];
|
|
bytes[0..4].copy_from_slice(&(selrect.x() + rect.left()).to_le_bytes());
|
|
bytes[4..8].copy_from_slice(&(selrect.y() + rect.top()).to_le_bytes());
|
|
bytes[8..12].copy_from_slice(&rect.width().to_le_bytes());
|
|
bytes[12..16].copy_from_slice(&rect.height().to_le_bytes());
|
|
mem::write_bytes(bytes)
|
|
})
|
|
}
|
|
|
|
#[no_mangle]
|
|
pub extern "C" fn text_editor_get_current_styles() -> *mut u8 {
|
|
with_state!(state, {
|
|
if !get_text_editor_state().has_focus {
|
|
return std::ptr::null_mut();
|
|
}
|
|
|
|
let Some(shape_id) = get_text_editor_state().active_shape_id else {
|
|
return std::ptr::null_mut();
|
|
};
|
|
|
|
let Some(shape) = state.shapes.get(&shape_id) else {
|
|
return std::ptr::null_mut();
|
|
};
|
|
|
|
let Type::Text(_text_content) = &shape.shape_type else {
|
|
return std::ptr::null_mut();
|
|
};
|
|
|
|
let styles = &get_text_editor_state().current_styles;
|
|
|
|
let vertical_align = match styles.vertical_align {
|
|
VerticalAlign::Top => 0_u32,
|
|
VerticalAlign::Center => 1_u32,
|
|
VerticalAlign::Bottom => 2_u32,
|
|
};
|
|
|
|
let text_align = styles
|
|
.text_align
|
|
.value()
|
|
.as_ref()
|
|
.map(|value| match value {
|
|
TextAlign::Left => RawTextAlign::Left as u32,
|
|
TextAlign::Start => RawTextAlign::Left as u32,
|
|
TextAlign::Center => RawTextAlign::Center as u32,
|
|
TextAlign::Right => RawTextAlign::Right as u32,
|
|
TextAlign::End => RawTextAlign::Right as u32,
|
|
TextAlign::Justify => RawTextAlign::Justify as u32,
|
|
})
|
|
.unwrap_or(0);
|
|
|
|
let text_direction = styles
|
|
.text_direction
|
|
.value()
|
|
.as_ref()
|
|
.map(|value| match value {
|
|
skia_safe::textlayout::TextDirection::LTR => RawTextDirection::Ltr as u32,
|
|
skia_safe::textlayout::TextDirection::RTL => RawTextDirection::Rtl as u32,
|
|
})
|
|
.unwrap_or(0);
|
|
|
|
let text_decoration = styles
|
|
.text_decoration
|
|
.value()
|
|
.as_ref()
|
|
.map(|value| {
|
|
if *value == skia_safe::textlayout::TextDecoration::UNDERLINE {
|
|
RawTextDecoration::Underline as u32
|
|
} else if *value == skia_safe::textlayout::TextDecoration::LINE_THROUGH {
|
|
RawTextDecoration::LineThrough as u32
|
|
} else if *value == skia_safe::textlayout::TextDecoration::OVERLINE {
|
|
RawTextDecoration::Overline as u32
|
|
} else {
|
|
RawTextDecoration::None as u32
|
|
}
|
|
})
|
|
.unwrap_or(RawTextDecoration::None as u32);
|
|
|
|
let text_transform = styles
|
|
.text_transform
|
|
.value()
|
|
.as_ref()
|
|
.map(|value| match value {
|
|
crate::shapes::TextTransform::Uppercase => RawTextTransform::Uppercase as u32,
|
|
crate::shapes::TextTransform::Lowercase => RawTextTransform::Lowercase as u32,
|
|
crate::shapes::TextTransform::Capitalize => RawTextTransform::Capitalize as u32,
|
|
})
|
|
.unwrap_or(RawTextTransform::None as u32);
|
|
|
|
let font_family_id = styles
|
|
.font_family_id
|
|
.value()
|
|
.as_ref()
|
|
.map(|value| {
|
|
let (a, b, c, d) = uuid_to_u32_quartet(value);
|
|
[a, b, c, d]
|
|
})
|
|
.unwrap_or_default();
|
|
|
|
let font_style = styles
|
|
.font_style
|
|
.value()
|
|
.as_ref()
|
|
.map(|value| *value as u32)
|
|
.unwrap_or_default();
|
|
|
|
let font_size = styles.font_size.value().unwrap_or(0.0);
|
|
let font_weight = styles.font_weight.value().unwrap_or(0);
|
|
let line_height = styles.line_height.value().unwrap_or(0.0);
|
|
let letter_spacing = styles.letter_spacing.value().unwrap_or(0.0);
|
|
|
|
let mut font_variant_id = [0_u32; 4];
|
|
if let Some(value) = styles.font_variant_id.value().as_ref() {
|
|
let (a, b, c, d) = uuid_to_u32_quartet(value);
|
|
font_variant_id = [a, b, c, d];
|
|
}
|
|
|
|
let mut fill_bytes = Vec::new();
|
|
let fill_multiple = styles.fills_are_multiple;
|
|
let mut fill_count: u32 = 0;
|
|
for fill in &styles.fills {
|
|
if let Ok(raw_fill) = RawFillData::try_from(fill) {
|
|
fill_bytes
|
|
.extend_from_slice(&<[u8; std::mem::size_of::<RawFillData>()]>::from(raw_fill));
|
|
fill_count += 1;
|
|
}
|
|
}
|
|
|
|
// Layout: 56-byte fixed header + fixed values + serialized fills.
|
|
let mut bytes = Vec::with_capacity(132 + fill_bytes.len());
|
|
|
|
// Header data (multiple-states) // offset // index
|
|
bytes.extend_from_slice(&vertical_align.to_le_bytes()); // 0 // 0
|
|
bytes.extend_from_slice(&(*styles.text_align.state() as u32).to_le_bytes()); // 4 // 1
|
|
bytes.extend_from_slice(&(*styles.text_direction.state() as u32).to_le_bytes()); // 8 // 2
|
|
bytes.extend_from_slice(&(*styles.text_decoration.state() as u32).to_le_bytes()); // 12 // 3
|
|
bytes.extend_from_slice(&(*styles.text_transform.state() as u32).to_le_bytes()); // 16 // 4
|
|
bytes.extend_from_slice(&(*styles.font_family_id.state() as u32).to_le_bytes()); // 20 // 5
|
|
bytes.extend_from_slice(&(*styles.font_size.state() as u32).to_le_bytes()); // 24 // 6
|
|
bytes.extend_from_slice(&(*styles.font_weight.state() as u32).to_le_bytes()); // 28 // 7
|
|
bytes.extend_from_slice(&(*styles.font_variant_id.state() as u32).to_le_bytes()); // 32 // 8
|
|
bytes.extend_from_slice(&(*styles.line_height.state() as u32).to_le_bytes()); // 36 // 9
|
|
bytes.extend_from_slice(&(*styles.letter_spacing.state() as u32).to_le_bytes()); // 40 // 10
|
|
bytes.extend_from_slice(&(*styles.font_style.state() as u32).to_le_bytes()); // 44 // 11
|
|
bytes.extend_from_slice(&fill_count.to_le_bytes()); // 48 // 12
|
|
bytes.extend_from_slice(&(fill_multiple as u32).to_le_bytes()); // 52 // 13
|
|
|
|
// Value section.
|
|
bytes.extend_from_slice(&text_align.to_le_bytes()); // 56 // 14
|
|
bytes.extend_from_slice(&text_direction.to_le_bytes()); // 60 // 15
|
|
bytes.extend_from_slice(&text_decoration.to_le_bytes()); // 64 // 16
|
|
bytes.extend_from_slice(&text_transform.to_le_bytes()); // 68 // 17
|
|
bytes.extend_from_slice(&font_family_id[0].to_le_bytes()); // 72 // 18
|
|
bytes.extend_from_slice(&font_family_id[1].to_le_bytes()); // 76 // 19
|
|
bytes.extend_from_slice(&font_family_id[2].to_le_bytes()); // 80 // 20
|
|
bytes.extend_from_slice(&font_family_id[3].to_le_bytes()); // 84 // 21
|
|
bytes.extend_from_slice(&font_style.to_le_bytes()); // 88 // 22
|
|
bytes.extend_from_slice(&font_size.to_le_bytes()); // 92 // 23
|
|
bytes.extend_from_slice(&font_weight.to_le_bytes()); // 96 // 24
|
|
bytes.extend_from_slice(&font_variant_id[0].to_le_bytes()); // 100 // 25
|
|
bytes.extend_from_slice(&font_variant_id[1].to_le_bytes()); // 104 // 26
|
|
bytes.extend_from_slice(&font_variant_id[2].to_le_bytes()); // 108 // 27
|
|
bytes.extend_from_slice(&font_variant_id[3].to_le_bytes()); // 112 // 28
|
|
bytes.extend_from_slice(&line_height.to_le_bytes()); // 116 // 29
|
|
bytes.extend_from_slice(&letter_spacing.to_le_bytes()); // 120 // 30
|
|
bytes.extend_from_slice(&fill_bytes); // 124
|
|
|
|
mem::write_bytes(bytes)
|
|
})
|
|
}
|
|
|
|
#[no_mangle]
|
|
pub extern "C" fn text_editor_update_blink(timestamp_ms: f32) {
|
|
get_text_editor_state().update_blink(timestamp_ms);
|
|
}
|
|
|
|
/// Refresh a text shape's layout if the editor marked it dirty, so the
|
|
/// caret/selection overlay is measured against up-to-date glyph geometry.
|
|
fn update_text_layout_if_needed(state: &mut State, shape_id: Uuid) {
|
|
let Some(shape) = state.shapes.get_mut(&shape_id) else {
|
|
return;
|
|
};
|
|
|
|
let selrect = shape.selrect();
|
|
|
|
let Type::Text(text_content) = &mut shape.shape_type else {
|
|
return;
|
|
};
|
|
|
|
if text_content.needs_update_layout() {
|
|
text_content.update_layout(selrect);
|
|
}
|
|
}
|
|
|
|
/// Repaint the caret/selection over the last fully rendered frame.
|
|
///
|
|
/// Re-composes Target from the Backbuffer (which still holds the last complete
|
|
/// render); the compose step draws the editor overlay itself.
|
|
///
|
|
/// This exists because the caret blink must erase the previous caret, which
|
|
/// means restoring the pixels underneath it. Doing that via `render_from_cache`
|
|
/// rebuilds the frame from the document atlas, and that atlas is capped at
|
|
/// scale <= 1.0 — on a zoomed-in view it gets blitted heavily upscaled, so the
|
|
/// blink alternates between the crisp render and a softer approximation, which
|
|
/// reads as a flash. Reusing the Backbuffer is pixel-identical at any zoom.
|
|
#[no_mangle]
|
|
pub extern "C" fn text_editor_render_caret() {
|
|
with_state!(state, {
|
|
let Some(shape_id) = get_text_editor_state().active_shape_id else {
|
|
return;
|
|
};
|
|
|
|
update_text_layout_if_needed(state, shape_id);
|
|
get_render_state().present_frame(&state.shapes);
|
|
});
|
|
}
|
|
|
|
#[no_mangle]
|
|
pub extern "C" fn text_editor_export_content() -> *mut u8 {
|
|
with_state!(state, {
|
|
if !get_text_editor_state().has_focus {
|
|
return std::ptr::null_mut();
|
|
}
|
|
|
|
let Some(shape_id) = get_text_editor_state().active_shape_id else {
|
|
return std::ptr::null_mut();
|
|
};
|
|
|
|
let Some(shape) = state.shapes.get_mut(&shape_id) else {
|
|
return std::ptr::null_mut();
|
|
};
|
|
|
|
let Type::Text(text_content) = &mut shape.shape_type else {
|
|
return std::ptr::null_mut();
|
|
};
|
|
|
|
let mut json_parts: Vec<String> = Vec::new();
|
|
for para in text_content.paragraphs() {
|
|
let mut span_parts: Vec<String> = Vec::new();
|
|
for span in para.children() {
|
|
let escaped_text = span
|
|
.text
|
|
.replace('\\', "\\\\")
|
|
.replace('"', "\\\"")
|
|
.replace('\n', "\\n")
|
|
.replace('\r', "\\r")
|
|
.replace('\t', "\\t");
|
|
span_parts.push(format!(
|
|
"{{\"pp\":{},\"p\":{},\"s\":{},\"t\":\"{}\"}}",
|
|
para.source_position(),
|
|
span.paragraph_position,
|
|
span.span_position,
|
|
escaped_text
|
|
));
|
|
}
|
|
json_parts.push(format!("[{}]", span_parts.join(",")));
|
|
}
|
|
let json = format!("[{}]", json_parts.join(","));
|
|
|
|
// The host rebuilds its content tree out of this JSON, so the current
|
|
// positions are what the next call has to report against.
|
|
text_content.reset_span_positions();
|
|
|
|
let mut bytes = json.into_bytes();
|
|
bytes.push(0);
|
|
crate::mem::write_bytes(bytes)
|
|
})
|
|
}
|
|
|
|
#[no_mangle]
|
|
pub extern "C" fn text_editor_export_selection() -> *mut u8 {
|
|
use std::ptr;
|
|
with_state!(state, {
|
|
if !get_text_editor_state().has_focus {
|
|
return ptr::null_mut();
|
|
}
|
|
let Some(shape_id) = get_text_editor_state().active_shape_id else {
|
|
return ptr::null_mut();
|
|
};
|
|
let Some(shape) = state.shapes.get(&shape_id) else {
|
|
return ptr::null_mut();
|
|
};
|
|
let Type::Text(text_content) = &shape.shape_type else {
|
|
return ptr::null_mut();
|
|
};
|
|
let selection = &get_text_editor_state().selection;
|
|
let start = selection.start();
|
|
let end = selection.end();
|
|
let paragraphs = text_content.paragraphs();
|
|
let mut result = String::new();
|
|
let end_paragraph = end.paragraph.min(paragraphs.len().saturating_sub(1)) + 1;
|
|
for (para_idx, _) in paragraphs
|
|
.iter()
|
|
.enumerate()
|
|
.take(end_paragraph)
|
|
.skip(start.paragraph)
|
|
{
|
|
let para = ¶graphs[para_idx];
|
|
let mut para_text = String::new();
|
|
let para_char_count: usize = para
|
|
.children()
|
|
.iter()
|
|
.map(|span| span.text.chars().count())
|
|
.sum();
|
|
let range_start = if para_idx == start.paragraph {
|
|
start.offset
|
|
} else {
|
|
0
|
|
};
|
|
let range_end = if para_idx == end.paragraph {
|
|
end.offset
|
|
} else {
|
|
para_char_count
|
|
};
|
|
if range_start < range_end {
|
|
let mut char_pos = 0;
|
|
for span in para.children() {
|
|
let span_len = span.text.chars().count();
|
|
let span_start = char_pos;
|
|
let span_end = char_pos + span_len;
|
|
let sel_start = range_start.max(span_start);
|
|
let sel_end = range_end.min(span_end);
|
|
if sel_start < sel_end {
|
|
let rel_start = sel_start - span_start;
|
|
let rel_end = sel_end - span_start;
|
|
let text: String = span
|
|
.text
|
|
.chars()
|
|
.skip(rel_start)
|
|
.take(rel_end - rel_start)
|
|
.collect();
|
|
para_text.push_str(&text);
|
|
}
|
|
char_pos += span_len;
|
|
}
|
|
}
|
|
if para_idx > start.paragraph {
|
|
result.push('\n');
|
|
}
|
|
result.push_str(¶_text);
|
|
}
|
|
let mut bytes = result.into_bytes();
|
|
bytes.push(0);
|
|
crate::mem::write_bytes(bytes)
|
|
})
|
|
}
|
|
|
|
#[no_mangle]
|
|
pub extern "C" fn text_editor_get_selection(buffer_ptr: *mut u32) -> bool {
|
|
with_state!(state, {
|
|
if get_text_editor_state().active_shape_id.is_none() {
|
|
return false;
|
|
}
|
|
|
|
let sel = get_text_editor_state().selection;
|
|
|
|
// The frontend indexes these offsets into JS strings, which are UTF-16.
|
|
let (anchor_offset, focus_offset) = match get_text_editor_state()
|
|
.active_shape_id
|
|
.and_then(|shape_id| state.shapes.get(&shape_id))
|
|
.map(|shape| &shape.shape_type)
|
|
{
|
|
Some(Type::Text(text_content)) => {
|
|
let paragraphs = text_content.paragraphs();
|
|
let to_utf16 = |position: TextPositionWithAffinity| {
|
|
paragraphs
|
|
.get(position.paragraph)
|
|
.map(|para| para.char_offset_to_utf16(position.offset))
|
|
.unwrap_or(position.offset)
|
|
};
|
|
(to_utf16(sel.anchor), to_utf16(sel.focus))
|
|
}
|
|
_ => (sel.anchor.offset, sel.focus.offset),
|
|
};
|
|
|
|
unsafe {
|
|
*buffer_ptr = sel.anchor.paragraph as u32;
|
|
*buffer_ptr.add(1) = anchor_offset as u32;
|
|
*buffer_ptr.add(2) = sel.focus.paragraph as u32;
|
|
*buffer_ptr.add(3) = focus_offset as u32;
|
|
}
|
|
true
|
|
})
|
|
}
|
|
|
|
/// Writes where the text between the UTF-16 offsets starts in the text shape
|
|
/// (x, y, shape-local) and its box size (width, height); false when no text.
|
|
#[no_mangle]
|
|
#[wasm_error]
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub extern "C" fn text_get_range_origin(
|
|
a: u32,
|
|
b: u32,
|
|
c: u32,
|
|
d: u32,
|
|
start_paragraph: u32,
|
|
start_offset: u32,
|
|
end_paragraph: u32,
|
|
end_offset: u32,
|
|
buffer_ptr: *mut f32,
|
|
) -> Result<bool> {
|
|
with_state!(state, {
|
|
let Some(shape) = state.shapes.get(&uuid_from_u32_quartet(a, b, c, d)) else {
|
|
return Ok(false);
|
|
};
|
|
let Type::Text(text_content) = &shape.shape_type else {
|
|
return Ok(false);
|
|
};
|
|
|
|
let paragraphs = text_content.paragraphs();
|
|
let to_char = |paragraph: u32, offset: u32| {
|
|
let paragraph = paragraph as usize;
|
|
let offset = paragraphs
|
|
.get(paragraph)
|
|
.map(|para| para.utf16_offset_to_char(offset as usize))
|
|
.unwrap_or(offset as usize);
|
|
(paragraph, offset)
|
|
};
|
|
let Some(origin) = range_origin(
|
|
to_char(start_paragraph, start_offset),
|
|
to_char(end_paragraph, end_offset),
|
|
text_content,
|
|
shape,
|
|
) else {
|
|
return Ok(false);
|
|
};
|
|
let selrect = shape.selrect();
|
|
|
|
unsafe {
|
|
*buffer_ptr = origin.x;
|
|
*buffer_ptr.add(1) = origin.y;
|
|
*buffer_ptr.add(2) = selrect.width();
|
|
*buffer_ptr.add(3) = selrect.height();
|
|
}
|
|
Ok(true)
|
|
})
|
|
}
|
|
|
|
fn restore_editor_selection(
|
|
editor: &mut TextEditorState,
|
|
content: &TextContent,
|
|
anchor_para: u32,
|
|
anchor_offset: u32,
|
|
focus_para: u32,
|
|
focus_offset: u32,
|
|
) -> bool {
|
|
let paragraphs = content.paragraphs();
|
|
if paragraphs.is_empty() {
|
|
return false;
|
|
}
|
|
let position = |paragraph: u32, offset: u32| {
|
|
let paragraph = (paragraph as usize).min(paragraphs.len() - 1);
|
|
let mut utf16 = 0;
|
|
let offset = paragraphs[paragraph]
|
|
.children()
|
|
.iter()
|
|
.flat_map(|span| span.text.chars())
|
|
.take_while(|ch| {
|
|
utf16 += ch.len_utf16();
|
|
utf16 <= offset as usize
|
|
})
|
|
.count();
|
|
TextPositionWithAffinity::new_downstream_affinity(paragraph, offset)
|
|
};
|
|
editor.selection.anchor = position(anchor_para, anchor_offset);
|
|
editor.selection.focus = position(focus_para, focus_offset);
|
|
editor.update_styles(content);
|
|
editor.reset_blink();
|
|
editor.push_event(TextEditorEvent::SelectionChanged);
|
|
true
|
|
}
|
|
|
|
/// Restore the host's UTF-16 selection after restoring a history entry.
|
|
#[no_mangle]
|
|
pub extern "C" fn text_editor_restore_selection(
|
|
anchor_para: u32,
|
|
anchor_offset: u32,
|
|
focus_para: u32,
|
|
focus_offset: u32,
|
|
) -> bool {
|
|
with_state!(state, {
|
|
let editor = get_text_editor_state();
|
|
if !editor.has_focus || editor.composition.is_composing {
|
|
return false;
|
|
}
|
|
let Some(shape) = editor.active_shape_id.and_then(|id| state.shapes.get(&id)) else {
|
|
return false;
|
|
};
|
|
let Type::Text(content) = &shape.shape_type else {
|
|
return false;
|
|
};
|
|
restore_editor_selection(
|
|
editor,
|
|
content,
|
|
anchor_para,
|
|
anchor_offset,
|
|
focus_para,
|
|
focus_offset,
|
|
)
|
|
})
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::shapes::{Group, GrowType, Paragraph, TextDirection, TextSpan, WritingMode};
|
|
use crate::tiles;
|
|
|
|
#[test]
|
|
fn history_selection_restores_utf16_ranges_without_splitting_surrogates() {
|
|
let mut resources = crate::render::RenderResources::try_new_headless().expect("resources");
|
|
let _guard = crate::globals::TestRenderResourcesGuard::install(&mut resources);
|
|
let (mut state, id) = preview_state(GrowType::Fixed, WritingMode::VerticalRl);
|
|
let content = text_content_for_edit(&mut state, id).unwrap();
|
|
content.paragraphs_mut()[0].children_mut()[0].text = "A😀한B".into();
|
|
let mut editor = TextEditorState::new();
|
|
assert!(restore_editor_selection(&mut editor, content, 0, 1, 0, 3));
|
|
assert_eq!(editor.selection.anchor.offset, 1);
|
|
assert_eq!(editor.selection.focus.offset, 2);
|
|
assert!(restore_editor_selection(&mut editor, content, 0, 2, 9, 99));
|
|
assert_eq!(editor.selection.anchor.offset, 1);
|
|
assert_eq!(editor.selection.focus.paragraph, 0);
|
|
assert_eq!(editor.selection.focus.offset, 4);
|
|
}
|
|
|
|
fn preview_state(grow_type: GrowType, writing_mode: WritingMode) -> (State, Uuid) {
|
|
let id = Uuid::new_v4();
|
|
let mut state = State::new();
|
|
let shape = state.shapes.add_shape(id);
|
|
shape.set_selrect(250.0, 250.0, 274.0, 310.0);
|
|
let mut content = TextContent::new(shape.selrect(), grow_type);
|
|
let span = TextSpan {
|
|
text: "あ".to_string(),
|
|
font_size: 20.0,
|
|
..TextSpan::default()
|
|
};
|
|
let mut paragraph = Paragraph::new(
|
|
TextAlign::Left,
|
|
TextDirection::LTR,
|
|
None,
|
|
None,
|
|
1.2,
|
|
0.0,
|
|
vec![span],
|
|
);
|
|
paragraph.set_writing_mode(writing_mode);
|
|
content.add_paragraph(paragraph);
|
|
content.update_layout(shape.selrect());
|
|
shape.set_shape_type(Type::Text(content));
|
|
(state, id)
|
|
}
|
|
|
|
fn replace_preview(state: &mut State, id: Uuid, editor: &mut TextEditorState, text: &str) {
|
|
let content = text_content_for_edit(state, id).expect("text shape");
|
|
editor.composition.update(text);
|
|
let selection = editor.composition.get_selection(&editor.selection);
|
|
text_helpers::delete_selection_range(content, &selection);
|
|
let cursor = selection.start();
|
|
editor.composition.start = Some(cursor);
|
|
let caret =
|
|
text_helpers::insert_text_with_newlines(content, &cursor, text).unwrap_or(cursor);
|
|
editor.selection.set_caret(caret);
|
|
content.layout.clear();
|
|
update_text_layout_if_needed(state, id);
|
|
}
|
|
|
|
#[test]
|
|
fn composition_preview_reindexes_tiles_as_text_grows_and_shrinks() {
|
|
let mut resources = crate::render::RenderResources::try_new_headless().expect("resources");
|
|
let _guard = crate::globals::TestRenderResourcesGuard::install(&mut resources);
|
|
let long_preview = "あいうえおかきくけこさしすせそたちつてとなにぬねの".repeat(4);
|
|
for grow_type in [GrowType::AutoWidth, GrowType::AutoHeight] {
|
|
for writing_mode in [WritingMode::HorizontalTb, WritingMode::VerticalRl] {
|
|
let (mut state, id) = preview_state(grow_type, writing_mode);
|
|
let original = state.shapes.get(&id).unwrap().extrect(&state.shapes, 1.0);
|
|
let mut editor = TextEditorState::new();
|
|
editor.composition.start();
|
|
for text in [long_preview.as_str(), "日本語", ""] {
|
|
replace_preview(&mut state, id, &mut editor, text);
|
|
let shape = state.shapes.get(&id).unwrap();
|
|
let mut uncached = shape.clone();
|
|
uncached.invalidate_extrect();
|
|
let expected = uncached.extrect(&state.shapes, 1.0);
|
|
let actual = shape.extrect(&state.shapes, 1.0);
|
|
assert_eq!(
|
|
actual, expected,
|
|
"{grow_type:?} {writing_mode:?} preview {text}"
|
|
);
|
|
let current_tiles = tiles::get_tiles_for_rect(actual, tiles::TILE_SIZE);
|
|
let original_tiles = tiles::get_tiles_for_rect(original, tiles::TILE_SIZE);
|
|
if text.chars().count() > 20 {
|
|
assert_ne!(current_tiles, original_tiles, "preview reaches new tiles");
|
|
} else if text.is_empty() {
|
|
assert_eq!(
|
|
current_tiles, original_tiles,
|
|
"cancellation restores coverage"
|
|
);
|
|
}
|
|
}
|
|
assert_eq!(
|
|
state.shapes.get(&id).unwrap().extrect(&state.shapes, 1.0),
|
|
original
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn composition_preview_updates_cached_group_bounds() {
|
|
let mut resources = crate::render::RenderResources::try_new_headless().expect("resources");
|
|
let _guard = crate::globals::TestRenderResourcesGuard::install(&mut resources);
|
|
let (mut state, id) = preview_state(GrowType::AutoWidth, WritingMode::HorizontalTb);
|
|
let parent_id = Uuid::new_v4();
|
|
let parent = state.shapes.add_shape(parent_id);
|
|
parent.set_shape_type(Type::Group(Group::default()));
|
|
parent.clip_content = false;
|
|
parent.children.push(id);
|
|
state.shapes.get_mut(&id).unwrap().set_parent(parent_id);
|
|
let original = state
|
|
.shapes
|
|
.get(&parent_id)
|
|
.unwrap()
|
|
.extrect(&state.shapes, 1.0);
|
|
let mut editor = TextEditorState::new();
|
|
editor.composition.start();
|
|
replace_preview(&mut state, id, &mut editor, "あいうえおかきくけこ");
|
|
let parent = state.shapes.get(&parent_id).unwrap();
|
|
assert!(parent.extrect(&state.shapes, 1.0).right() > original.right());
|
|
}
|
|
|
|
#[test]
|
|
fn composition_preview_updates_modified_shape_snapshot() {
|
|
let mut resources = crate::render::RenderResources::try_new_headless().expect("resources");
|
|
let _guard = crate::globals::TestRenderResourcesGuard::install(&mut resources);
|
|
let (mut state, id) = preview_state(GrowType::AutoWidth, WritingMode::HorizontalTb);
|
|
state
|
|
.shapes
|
|
.set_modifiers(std::collections::HashMap::from([(
|
|
id,
|
|
Matrix::translate((20.0, 30.0)),
|
|
)]));
|
|
let original = state.shapes.get(&id).unwrap().extrect(&state.shapes, 1.0);
|
|
let mut editor = TextEditorState::new();
|
|
editor.composition.start();
|
|
replace_preview(&mut state, id, &mut editor, "あいうえおかきくけこ");
|
|
let shape = state.shapes.get(&id).unwrap();
|
|
assert!(shape.extrect(&state.shapes, 1.0).right() > original.right());
|
|
}
|
|
}
|