Merge remote-tracking branch 'origin/staging-render' into develop

This commit is contained in:
Andrey Antukh
2026-02-12 11:00:56 +01:00
52 changed files with 11804 additions and 633 deletions

View File

@@ -0,0 +1,217 @@
# Text Editor Architecture
## Overview (Simplified)
```mermaid
flowchart TB
subgraph Browser["Browser / DOM"]
CE[contenteditable]
Events[DOM Events]
end
subgraph CLJS["ClojureScript"]
InputHandler[text_editor_input.cljs]
Bindings[text_editor.cljs]
ContentCache[(content cache)]
end
subgraph WASM["WASM Boundary"]
FFI["_text_editor_* functions"]
end
subgraph Rust["Rust"]
subgraph StateModule["state/text_editor.rs"]
TES[TextEditorState]
Selection[TextSelection]
Cursor[TextCursor]
end
subgraph WASMImpl["wasm/text_editor.rs"]
StateOps[start / stop]
CursorOps[cursor / selection]
EditOps[insert / delete]
ExportOps[export content]
end
subgraph RenderMod["render/text_editor.rs"]
RenderOverlay[render_overlay]
end
Shapes[(ShapesPool)]
end
subgraph Skia["Skia"]
Canvas[Canvas]
Paragraph[Paragraph layout]
end
%% Flow
CE --> Events
Events --> InputHandler
InputHandler --> Bindings
Bindings --> FFI
FFI --> StateOps & CursorOps & EditOps & ExportOps
StateOps --> TES
CursorOps --> TES
EditOps --> TES
EditOps --> Shapes
ExportOps --> Shapes
TES --> Selection --> Cursor
RenderOverlay --> TES
RenderOverlay --> Shapes
Shapes --> Paragraph
RenderOverlay --> Canvas
Paragraph --> Canvas
ExportOps --> ContentCache
ContentCache --> InputHandler
```
---
## Detailed Architecture
```mermaid
flowchart TB
subgraph Browser["Browser / DOM"]
CE[contenteditable element]
KeyEvents[keydown / keyup]
MouseEvents[mousedown / mousemove]
IME[compositionstart / end]
end
subgraph CLJS["ClojureScript Layer"]
subgraph InputMod["text_editor_input.cljs"]
EventHandler[Event Handler]
BlinkLoop[RAF Blink Loop]
SyncFn[sync-content!]
end
subgraph BindingsMod["text_editor.cljs"]
direction TB
StartStop[start / stop]
CursorFns[set-cursor / move]
SelectFns[select-all / extend]
EditFns[insert / delete]
ExportFns[export-content]
StyleFns[apply-style]
end
ContentCache[(shape-text-contents<br/>atom)]
end
subgraph WASM["WASM Boundary"]
direction TB
FFI_State["_text_editor_start<br/>_text_editor_stop<br/>_text_editor_is_active"]
FFI_Cursor["_text_editor_set_cursor_from_point<br/>_text_editor_move_cursor<br/>_text_editor_select_all"]
FFI_Edit["_text_editor_insert_text<br/>_text_editor_delete_backward<br/>_text_editor_insert_paragraph"]
FFI_Query["_text_editor_export_content<br/>_text_editor_get_selection<br/>_text_editor_poll_event"]
FFI_Render["_text_editor_render_overlay<br/>_text_editor_update_blink"]
end
subgraph Rust["Rust Layer"]
subgraph StateMod["state/text_editor.rs"]
TES[TextEditorState]
Selection[TextSelection]
Cursor[TextCursor]
Events[EditorEvent queue]
end
subgraph WASMMod["wasm/text_editor.rs"]
direction TB
WStateOps[State ops]
WCursorOps[Cursor ops]
WEditOps[Edit ops]
WQueryOps[Query ops]
end
subgraph RenderMod["render/text_editor.rs"]
RenderOverlay[render_overlay]
RenderCursor[render_cursor]
RenderSelection[render_selection]
end
Shapes[(ShapesPool<br/>TextContent)]
end
subgraph Skia["Skia"]
Canvas[Canvas]
SkParagraph[textlayout::Paragraph]
TextBoxes[get_rects_for_range]
end
%% Browser to CLJS
CE --> KeyEvents & MouseEvents & IME
KeyEvents --> EventHandler
MouseEvents --> EventHandler
IME --> EventHandler
%% CLJS internal
EventHandler --> StartStop & CursorFns & EditFns & SelectFns
BlinkLoop --> FFI_Render
SyncFn --> ExportFns
ExportFns --> ContentCache
ContentCache --> SyncFn
StyleFns --> ContentCache
%% CLJS to WASM
StartStop --> FFI_State
CursorFns --> FFI_Cursor
SelectFns --> FFI_Cursor
EditFns --> FFI_Edit
ExportFns --> FFI_Query
%% WASM to Rust impl
FFI_State --> WStateOps
FFI_Cursor --> WCursorOps
FFI_Edit --> WEditOps
FFI_Query --> WQueryOps
FFI_Render --> RenderOverlay
%% Rust internal
WStateOps --> TES
WCursorOps --> TES
WEditOps --> TES
WEditOps --> Shapes
WQueryOps --> TES
WQueryOps --> Shapes
TES --> Selection
Selection --> Cursor
TES --> Events
%% Render flow
RenderOverlay --> RenderCursor & RenderSelection
RenderCursor --> TES
RenderSelection --> TES
RenderCursor --> Shapes
RenderSelection --> Shapes
%% Skia
Shapes --> SkParagraph
SkParagraph --> TextBoxes
RenderCursor --> Canvas
RenderSelection --> Canvas
```
---
## Key Files
| Layer | File | Purpose |
|-------|------|---------|
| DOM | - | contenteditable captures keyboard/IME input |
| CLJS | `text_editor_input.cljs` | Event handling, blink loop, content sync |
| CLJS | `text_editor.cljs` | WASM bindings, content cache, style application |
| Rust | `state/text_editor.rs` | TextEditorState, TextSelection, TextCursor |
| Rust | `wasm/text_editor.rs` | WASM exported functions |
| Rust | `render/text_editor.rs` | Cursor & selection overlay rendering |
## Data Flow
1. **Input**: DOM events → ClojureScript handler → WASM function → Rust state
2. **Edit**: Rust modifies TextContent in ShapesPool → triggers layout
3. **Sync**: Export content → merge with cached styles → update shape
4. **Render**: RAF loop → render_overlay → Skia draws cursor/selection

View File

@@ -301,11 +301,7 @@ pub extern "C" fn set_view_end() {
#[cfg(feature = "profile-macros")]
{
let total_time = performance::get_time() - unsafe { VIEW_INTERACTION_START };
performance::console_log!(
"[PERF] view_interaction (zoom_changed={}): {}ms",
zoom_changed,
total_time
);
performance::console_log!("[PERF] view_interaction: {}ms", total_time);
}
});
}

View File

@@ -10,6 +10,7 @@ mod shadows;
mod strokes;
mod surfaces;
pub mod text;
pub mod text_editor;
mod ui;
use skia_safe::{self as skia, Matrix, RRect, Rect};
@@ -22,7 +23,7 @@ pub use surfaces::{SurfaceId, Surfaces};
use crate::performance;
use crate::shapes::{
all_with_ancestors, Blur, BlurType, Corners, Fill, Shadow, Shape, SolidColor, Type,
all_with_ancestors, Blur, BlurType, Corners, Fill, Shadow, Shape, SolidColor, Stroke, Type,
};
use crate::state::{ShapesPoolMutRef, ShapesPoolRef};
use crate::tiles::{self, PendingTiles, TileRect};
@@ -33,8 +34,9 @@ use crate::wapi;
pub use fonts::*;
pub use images::*;
// This is the extra are used for tile rendering.
const VIEWPORT_INTEREST_AREA_THRESHOLD: i32 = 2;
// This is the extra area used for tile rendering (tiles beyond viewport).
// Higher values pre-render more tiles, reducing empty squares during pan but using more memory.
const VIEWPORT_INTEREST_AREA_THRESHOLD: i32 = 3;
const MAX_BLOCKING_TIME_MS: i32 = 32;
const NODE_BATCH_THRESHOLD: i32 = 3;
@@ -697,20 +699,17 @@ impl RenderState {
canvas.translate(translation);
});
for fill in shape.fills().rev() {
fills::render(self, shape, fill, antialias, SurfaceId::Current);
}
fills::render(self, shape, &shape.fills, antialias, SurfaceId::Current);
for stroke in shape.visible_strokes().rev() {
strokes::render(
self,
shape,
stroke,
Some(SurfaceId::Current),
None,
antialias,
);
}
// Pass strokes in natural order; stroke merging handles top-most ordering internally.
let visible_strokes: Vec<&Stroke> = shape.visible_strokes().collect();
strokes::render(
self,
shape,
&visible_strokes,
Some(SurfaceId::Current),
antialias,
);
self.surfaces.apply_mut(SurfaceId::Current as u32, |s| {
s.canvas().restore();
@@ -1014,33 +1013,35 @@ impl RenderState {
{
if let Some(fills_to_render) = self.nested_fills.last() {
let fills_to_render = fills_to_render.clone();
for fill in fills_to_render.iter() {
fills::render(self, shape, fill, antialias, fills_surface_id);
}
fills::render(self, shape, &fills_to_render, antialias, fills_surface_id);
}
} else {
for fill in shape.fills().rev() {
fills::render(self, shape, fill, antialias, fills_surface_id);
}
fills::render(self, shape, &shape.fills, antialias, fills_surface_id);
}
for stroke in shape.visible_strokes().rev() {
// Skip stroke rendering for clipped frames - they are drawn in render_shape_exit
// over the children. Drawing twice would cause incorrect opacity blending.
let skip_strokes = matches!(shape.shape_type, Type::Frame(_)) && shape.clip_content;
if !skip_strokes {
// Pass strokes in natural order; stroke merging handles top-most ordering internally.
let visible_strokes: Vec<&Stroke> = shape.visible_strokes().collect();
strokes::render(
self,
shape,
stroke,
&visible_strokes,
Some(strokes_surface_id),
None,
antialias,
);
if !fast_mode {
shadows::render_stroke_inner_shadows(
self,
shape,
stroke,
antialias,
innershadows_surface_id,
);
for stroke in &visible_strokes {
shadows::render_stroke_inner_shadows(
self,
shape,
stroke,
antialias,
innershadows_surface_id,
);
}
}
}
@@ -1240,8 +1241,6 @@ impl RenderState {
if self.render_in_progress {
if tree.len() != 0 {
self.render_shape_tree_partial(base_object, tree, timestamp, true)?;
} else {
println!("Empty tree");
}
self.flush_and_submit();
@@ -1264,8 +1263,6 @@ impl RenderState {
) -> Result<(), String> {
if tree.len() != 0 {
self.render_shape_tree_partial(base_object, tree, timestamp, false)?;
} else {
println!("Empty tree");
}
self.flush_and_submit();
@@ -1402,6 +1399,10 @@ impl RenderState {
element_strokes.to_mut().clear_fills();
element_strokes.to_mut().clear_shadows();
element_strokes.to_mut().clip_content = false;
// Frame blur is applied at the save_layer level - avoid double blur on the stroke paint
if Self::frame_clip_layer_blur(element).is_some() {
element_strokes.to_mut().set_blur(None);
}
self.render_shape(
&element_strokes,
clip_bounds,
@@ -1551,6 +1552,11 @@ impl RenderState {
plain_shape_mut.clear_shadows();
plain_shape_mut.blur = None;
// Shadow rendering uses a single render_shape call with no render_shape_exit,
// so strokes must be drawn here. Disable clip_content to avoid skip_strokes
// (which defers strokes to render_shape_exit for clipped frames).
plain_shape_mut.clip_content = false;
let Some(drop_filter) = transformed_shadow.get_drop_shadow_filter() else {
return;
};
@@ -1660,6 +1666,158 @@ impl RenderState {
}
}
/// Renders element drop shadows to DropShadows surface and composites to Current.
/// Used for both normal shadow rendering and pre-layer rendering (frame_clip_layer_blur).
#[allow(clippy::too_many_arguments)]
fn render_element_drop_shadows_and_composite(
&mut self,
element: &Shape,
tree: ShapesPoolRef,
extrect: &mut Option<Rect>,
clip_bounds: Option<ClipStack>,
scale: f32,
translation: (f32, f32),
node_render_state: &NodeRenderState,
) {
let element_extrect = extrect.get_or_insert_with(|| element.extrect(tree, scale));
let inherited_layer_blur = match element.shape_type {
Type::Frame(_) | Type::Group(_) => element.blur,
_ => None,
};
for shadow in element.drop_shadows_visible() {
let paint = skia::Paint::default();
let layer_rec = skia::canvas::SaveLayerRec::default().paint(&paint);
self.surfaces
.canvas(SurfaceId::DropShadows)
.save_layer(&layer_rec);
self.render_drop_black_shadow(
element,
element_extrect,
shadow,
clip_bounds.clone(),
scale,
translation,
None,
);
if !matches!(element.shape_type, Type::Bool(_)) {
for shadow_shape_id in element.children.iter() {
let Some(shadow_shape) = tree.get(shadow_shape_id) else {
continue;
};
if shadow_shape.hidden {
continue;
}
let nested_clip_bounds =
node_render_state.get_nested_shadow_clip_bounds(element, shadow);
if !matches!(shadow_shape.shape_type, Type::Text(_)) {
self.render_drop_black_shadow(
shadow_shape,
&shadow_shape.extrect(tree, scale),
shadow,
nested_clip_bounds,
scale,
translation,
inherited_layer_blur,
);
} else {
let paint = skia::Paint::default();
let layer_rec = skia::canvas::SaveLayerRec::default().paint(&paint);
self.surfaces
.canvas(SurfaceId::DropShadows)
.save_layer(&layer_rec);
self.surfaces
.canvas(SurfaceId::DropShadows)
.scale((scale, scale));
self.surfaces
.canvas(SurfaceId::DropShadows)
.translate(translation);
let mut transformed_shadow: Cow<Shadow> = Cow::Borrowed(shadow);
transformed_shadow.to_mut().color = skia::Color::BLACK;
transformed_shadow.to_mut().blur = transformed_shadow.blur * scale;
transformed_shadow.to_mut().spread = transformed_shadow.spread * scale;
let mut new_shadow_paint = skia::Paint::default();
new_shadow_paint
.set_image_filter(transformed_shadow.get_drop_shadow_filter());
new_shadow_paint.set_blend_mode(skia::BlendMode::SrcOver);
self.with_nested_blurs_suppressed(|state| {
state.render_shape(
shadow_shape,
nested_clip_bounds,
SurfaceId::DropShadows,
SurfaceId::DropShadows,
SurfaceId::DropShadows,
SurfaceId::DropShadows,
true,
None,
Some(vec![new_shadow_paint.clone()]),
);
});
self.surfaces.canvas(SurfaceId::DropShadows).restore();
}
}
}
let mut paint = skia::Paint::default();
paint.set_color(shadow.color);
paint.set_blend_mode(skia::BlendMode::SrcIn);
self.surfaces
.canvas(SurfaceId::DropShadows)
.draw_paint(&paint);
self.surfaces.canvas(SurfaceId::DropShadows).restore();
}
if let Some(clips) = clip_bounds.as_ref() {
let antialias = element.should_use_antialias(scale);
self.surfaces.canvas(SurfaceId::Current).save();
for (bounds, corners, transform) in clips.iter() {
let mut total_matrix = Matrix::new_identity();
total_matrix.pre_scale((scale, scale), None);
total_matrix.pre_translate((translation.0, translation.1));
total_matrix.pre_concat(transform);
self.surfaces
.canvas(SurfaceId::Current)
.concat(&total_matrix);
if let Some(corners) = corners {
let rrect = RRect::new_rect_radii(*bounds, corners);
self.surfaces.canvas(SurfaceId::Current).clip_rrect(
rrect,
skia::ClipOp::Intersect,
antialias,
);
} else {
self.surfaces.canvas(SurfaceId::Current).clip_rect(
*bounds,
skia::ClipOp::Intersect,
antialias,
);
}
self.surfaces
.canvas(SurfaceId::Current)
.concat(&total_matrix.invert().unwrap_or_default());
}
self.surfaces
.draw_into(SurfaceId::DropShadows, SurfaceId::Current, None);
self.surfaces.canvas(SurfaceId::Current).restore();
} else {
self.surfaces
.draw_into(SurfaceId::DropShadows, SurfaceId::Current, None);
}
self.surfaces
.canvas(SurfaceId::DropShadows)
.clear(skia::Color::TRANSPARENT);
}
pub fn render_shape_tree_partial_uncached(
&mut self,
tree: ShapesPoolRef,
@@ -1742,6 +1900,33 @@ impl RenderState {
// If a container was flattened, it doesn't affect children visually, so we skip
// the expensive enter/exit operations and process children directly
if !element.can_flatten() {
// Enter focus early so shadow_before_layer can run (it needs focus_mode.is_active())
self.focus_mode.enter(&element.id);
// For frames with layer blur, render shadow BEFORE the layer so it doesn't get
// the layer blur (which would make it more diffused than without clipping)
let shadow_before_layer = !node_render_state.is_root()
&& self.focus_mode.is_active()
&& !self.options.is_fast_mode()
&& !matches!(element.shape_type, Type::Text(_))
&& Self::frame_clip_layer_blur(element).is_some()
&& element.drop_shadows_visible().next().is_some();
if shadow_before_layer {
let translation = self
.surfaces
.get_render_context_translation(self.render_area, scale);
self.render_element_drop_shadows_and_composite(
element,
tree,
&mut extrect,
clip_bounds.clone(),
scale,
translation,
&node_render_state,
);
}
self.render_shape_enter(element, mask);
}
@@ -1753,180 +1938,25 @@ impl RenderState {
// Skip expensive drop shadow rendering in fast mode (during pan/zoom)
let skip_shadows = self.options.is_fast_mode();
// Skip shadow block when already rendered before the layer (frame_clip_layer_blur)
let shadows_already_rendered = Self::frame_clip_layer_blur(element).is_some();
// For text shapes, render drop shadow using text rendering logic
if !skip_shadows && !matches!(element.shape_type, Type::Text(_)) {
// Shadow rendering technique: Two-pass approach for proper opacity handling
//
// The shadow rendering uses a two-pass technique to ensure that overlapping
// shadow areas maintain correct opacity without unwanted darkening:
//
// 1. First pass: Render shadow shape in pure black (alpha channel preserved)
// - This creates the shadow silhouette with proper alpha gradients
// - The black color acts as a mask for the final shadow color
//
// 2. Second pass: Apply actual shadow color using SrcIn blend mode
// - SrcIn preserves the alpha channel from the black shadow
// - Only the color channels are replaced, maintaining transparency
// - This prevents overlapping shadows from accumulating opacity
//
// This approach is essential for complex shapes with transparency where
// multiple shadow areas might overlap, ensuring visual consistency.
let inherited_layer_blur = match element.shape_type {
Type::Frame(_) | Type::Group(_) => element.blur,
_ => None,
};
for shadow in element.drop_shadows_visible() {
let paint = skia::Paint::default();
let layer_rec = skia::canvas::SaveLayerRec::default().paint(&paint);
self.surfaces
.canvas(SurfaceId::DropShadows)
.save_layer(&layer_rec);
// First pass: Render shadow in black to establish alpha mask
let element_extrect =
extrect.get_or_insert_with(|| element.extrect(tree, scale));
self.render_drop_black_shadow(
element,
element_extrect,
shadow,
clip_bounds.clone(),
scale,
translation,
None,
);
if !matches!(element.shape_type, Type::Bool(_)) {
// Nested shapes shadowing - apply black shadow to child shapes too
for shadow_shape_id in element.children.iter() {
let Some(shadow_shape) = tree.get(shadow_shape_id) else {
continue;
};
if shadow_shape.hidden {
continue;
}
let clip_bounds = node_render_state
.get_nested_shadow_clip_bounds(element, shadow);
if !matches!(shadow_shape.shape_type, Type::Text(_)) {
self.render_drop_black_shadow(
shadow_shape,
&shadow_shape.extrect(tree, scale),
shadow,
clip_bounds,
scale,
translation,
inherited_layer_blur,
);
} else {
let paint = skia::Paint::default();
let layer_rec =
skia::canvas::SaveLayerRec::default().paint(&paint);
self.surfaces
.canvas(SurfaceId::DropShadows)
.save_layer(&layer_rec);
self.surfaces
.canvas(SurfaceId::DropShadows)
.scale((scale, scale));
self.surfaces
.canvas(SurfaceId::DropShadows)
.translate(translation);
let mut transformed_shadow: Cow<Shadow> = Cow::Borrowed(shadow);
transformed_shadow.to_mut().color = skia::Color::BLACK;
transformed_shadow.to_mut().blur =
transformed_shadow.blur * scale;
transformed_shadow.to_mut().spread =
transformed_shadow.spread * scale;
let mut new_shadow_paint = skia::Paint::default();
new_shadow_paint.set_image_filter(
transformed_shadow.get_drop_shadow_filter(),
);
new_shadow_paint.set_blend_mode(skia::BlendMode::SrcOver);
self.with_nested_blurs_suppressed(|state| {
state.render_shape(
shadow_shape,
clip_bounds,
SurfaceId::DropShadows,
SurfaceId::DropShadows,
SurfaceId::DropShadows,
SurfaceId::DropShadows,
true,
None,
Some(vec![new_shadow_paint.clone()]),
);
});
self.surfaces.canvas(SurfaceId::DropShadows).restore();
}
}
}
// Second pass: Apply actual shadow color using SrcIn blend mode
// This preserves the alpha channel from the black shadow while
// replacing only the color channels, preventing opacity accumulation
let mut paint = skia::Paint::default();
paint.set_color(shadow.color);
paint.set_blend_mode(skia::BlendMode::SrcIn);
self.surfaces
.canvas(SurfaceId::DropShadows)
.draw_paint(&paint);
self.surfaces.canvas(SurfaceId::DropShadows).restore();
}
if !skip_shadows
&& !shadows_already_rendered
&& !matches!(element.shape_type, Type::Text(_))
{
self.render_element_drop_shadows_and_composite(
element,
tree,
&mut extrect,
clip_bounds.clone(),
scale,
translation,
&node_render_state,
);
}
if let Some(clips) = clip_bounds.as_ref() {
let antialias = element.should_use_antialias(scale);
self.surfaces.canvas(SurfaceId::Current).save();
for (bounds, corners, transform) in clips.iter() {
let mut total_matrix = Matrix::new_identity();
total_matrix.pre_scale((scale, scale), None);
total_matrix.pre_translate((translation.0, translation.1));
total_matrix.pre_concat(transform);
self.surfaces
.canvas(SurfaceId::Current)
.concat(&total_matrix);
if let Some(corners) = corners {
let rrect = RRect::new_rect_radii(*bounds, corners);
self.surfaces.canvas(SurfaceId::Current).clip_rrect(
rrect,
skia::ClipOp::Intersect,
antialias,
);
} else {
self.surfaces.canvas(SurfaceId::Current).clip_rect(
*bounds,
skia::ClipOp::Intersect,
antialias,
);
}
self.surfaces
.canvas(SurfaceId::Current)
.concat(&total_matrix.invert().unwrap_or_default());
}
self.surfaces
.draw_into(SurfaceId::DropShadows, SurfaceId::Current, None);
self.surfaces.canvas(SurfaceId::Current).restore();
} else {
self.surfaces
.draw_into(SurfaceId::DropShadows, SurfaceId::Current, None);
}
self.surfaces
.canvas(SurfaceId::DropShadows)
.clear(skia::Color::TRANSPARENT);
self.render_shape(
element,
clip_bounds.clone(),
@@ -2063,8 +2093,13 @@ impl RenderState {
}
} else {
performance::begin_measure!("render_shape_tree::uncached");
// Only allow stopping (yielding) if the current tile is NOT visible.
// This ensures all visible tiles render synchronously before showing,
// eliminating empty squares during zoom. Interest-area tiles can still yield.
let tile_is_visible = self.tile_viewbox.is_visible(&current_tile);
let can_stop = allow_stop && !tile_is_visible;
let (is_empty, early_return) =
self.render_shape_tree_partial_uncached(tree, timestamp, allow_stop)?;
self.render_shape_tree_partial_uncached(tree, timestamp, can_stop)?;
if early_return {
return Ok(());
@@ -2189,17 +2224,20 @@ impl RenderState {
* Given a shape, check the indexes and update it's location in the tile set
* returns the tiles that have changed in the process.
*/
pub fn update_shape_tiles(&mut self, shape: &Shape, tree: ShapesPoolRef) -> Vec<tiles::Tile> {
pub fn update_shape_tiles(
&mut self,
shape: &Shape,
tree: ShapesPoolRef,
) -> HashSet<tiles::Tile> {
let TileRect(rsx, rsy, rex, rey) = self.get_tiles_for_shape(shape, tree);
let old_tiles = self
// Collect old tiles to avoid borrow conflict with remove_shape_at
let old_tiles: Vec<_> = self
.tiles
.get_tiles_of(shape.id)
.map_or(Vec::new(), |tiles| tiles.iter().copied().collect());
.map_or(Vec::new(), |t| t.iter().copied().collect());
let new_tiles = (rsx..=rex).flat_map(|x| (rsy..=rey).map(move |y| tiles::Tile::from(x, y)));
let mut result = HashSet::<tiles::Tile>::new();
let mut result = HashSet::<tiles::Tile>::with_capacity(old_tiles.len());
// First, remove the shape from all tiles where it was previously located
for tile in old_tiles {
@@ -2208,12 +2246,66 @@ impl RenderState {
}
// Then, add the shape to the new tiles
for tile in new_tiles {
for tile in (rsx..=rex).flat_map(|x| (rsy..=rey).map(move |y| tiles::Tile::from(x, y))) {
self.tiles.add_shape_at(tile, shape.id);
result.insert(tile);
}
result.iter().copied().collect()
result
}
/*
* Incremental version of update_shape_tiles for pan/zoom operations.
* Updates the tile index and returns ONLY tiles that need cache invalidation.
*
* During pan operations, shapes don't move in world coordinates. The interest
* area (viewport) moves, which changes which tiles we track in the index, but
* tiles that were already cached don't need re-rendering just because the
* viewport moved.
*
* This function:
* 1. Updates the tile index (adds/removes shapes from tiles based on interest area)
* 2. Returns empty vec for cache invalidation (pan doesn't change tile content)
*
* Tile cache invalidation only happens when shapes actually move or change,
* which is handled by rebuild_touched_tiles, not during pan/zoom.
*/
pub fn update_shape_tiles_incremental(
&mut self,
shape: &Shape,
tree: ShapesPoolRef,
) -> Vec<tiles::Tile> {
let TileRect(rsx, rsy, rex, rey) = self.get_tiles_for_shape(shape, tree);
let old_tiles: HashSet<tiles::Tile> = self
.tiles
.get_tiles_of(shape.id)
.map_or(HashSet::new(), |tiles| tiles.iter().copied().collect());
let new_tiles: HashSet<tiles::Tile> = (rsx..=rex)
.flat_map(|x| (rsy..=rey).map(move |y| tiles::Tile::from(x, y)))
.collect();
// Tiles where shape is being removed from index (left interest area)
let removed: Vec<_> = old_tiles.difference(&new_tiles).copied().collect();
// Tiles where shape is being added to index (entered interest area)
let added: Vec<_> = new_tiles.difference(&old_tiles).copied().collect();
// Update the index: remove from old tiles
for tile in &removed {
self.tiles.remove_shape_at(*tile, shape.id);
}
// Update the index: add to new tiles
for tile in &added {
self.tiles.add_shape_at(*tile, shape.id);
}
// Don't invalidate cache for pan/zoom - the tile content hasn't changed,
// only the interest area moved. Tiles that were cached are still valid.
// New tiles that entered the interest area will be rendered fresh since
// they weren't in the cache anyway.
Vec::new()
}
/*
@@ -2239,12 +2331,22 @@ impl RenderState {
pub fn rebuild_tiles_shallow(&mut self, tree: ShapesPoolRef) {
performance::begin_measure!("rebuild_tiles_shallow");
let mut all_tiles = HashSet::<tiles::Tile>::new();
// Check if zoom changed - if so, we need full cache invalidation
// because tiles are rendered at specific zoom levels
let zoom_changed = self.zoom_changed();
let mut tiles_to_invalidate = HashSet::<tiles::Tile>::new();
let mut nodes = vec![Uuid::nil()];
while let Some(shape_id) = nodes.pop() {
if let Some(shape) = tree.get(&shape_id) {
if shape_id != Uuid::nil() {
all_tiles.extend(self.update_shape_tiles(shape, tree));
if zoom_changed {
// Zoom changed: use full update that tracks all affected tiles
tiles_to_invalidate.extend(self.update_shape_tiles(shape, tree));
} else {
// Pan only: use incremental update that preserves valid cached tiles
self.update_shape_tiles_incremental(shape, tree);
}
} else {
// We only need to rebuild tiles from the first level.
for child_id in shape.children_ids_iter(false) {
@@ -2256,9 +2358,6 @@ impl RenderState {
// Invalidate changed tiles - old content stays visible until new tiles render
self.surfaces.remove_cached_tiles(self.background_color);
for tile in all_tiles {
self.remove_cached_tile(tile);
}
performance::end_measure!("rebuild_tiles_shallow");
}
@@ -2307,7 +2406,7 @@ impl RenderState {
let mut all_tiles = HashSet::<tiles::Tile>::new();
let ids = self.touched_ids.clone();
let ids = std::mem::take(&mut self.touched_ids);
for shape_id in ids.iter() {
if let Some(shape) = tree.get(shape_id) {
@@ -2322,8 +2421,6 @@ impl RenderState {
self.remove_cached_tile(tile);
}
self.clean_touched();
performance::end_measure!("rebuild_touched_tiles");
}
@@ -2380,6 +2477,7 @@ impl RenderState {
self.touched_ids.insert(uuid);
}
#[allow(dead_code)]
pub fn clean_touched(&mut self) {
self.touched_ids.clear();
}

View File

@@ -2,7 +2,7 @@ use skia_safe::{self as skia, Paint, RRect};
use super::{filters, RenderState, SurfaceId};
use crate::render::get_source_rect;
use crate::shapes::{Fill, Frame, ImageFill, Rect, Shape, Type};
use crate::shapes::{merge_fills, Fill, Frame, ImageFill, Rect, Shape, Type};
fn draw_image_fill(
render_state: &mut RenderState,
@@ -92,6 +92,76 @@ fn draw_image_fill(
* This SHOULD be the only public function in this module.
*/
pub fn render(
render_state: &mut RenderState,
shape: &Shape,
fills: &[Fill],
antialias: bool,
surface_id: SurfaceId,
) {
if fills.is_empty() {
return;
}
// Image fills use draw_image_fill which needs render_state for GPU images
// and sampling options that get_fill_shader (used by merge_fills) lacks.
let has_image_fills = fills.iter().any(|f| matches!(f, Fill::Image(_)));
if has_image_fills {
for fill in fills.iter().rev() {
render_single_fill(render_state, shape, fill, antialias, surface_id);
}
return;
}
let mut paint = merge_fills(fills, shape.selrect);
paint.set_anti_alias(antialias);
if let Some(image_filter) = shape.image_filter(1.) {
let bounds = image_filter.compute_fast_bounds(shape.selrect);
if filters::render_with_filter_surface(
render_state,
bounds,
surface_id,
|state, temp_surface| {
let mut filtered_paint = paint.clone();
filtered_paint.set_image_filter(image_filter.clone());
draw_fill_to_surface(state, shape, temp_surface, &filtered_paint);
},
) {
return;
} else {
paint.set_image_filter(image_filter);
}
}
draw_fill_to_surface(render_state, shape, surface_id, &paint);
}
/// Draws a single paint (with a merged shader) to the appropriate surface
/// based on the shape type.
fn draw_fill_to_surface(
render_state: &mut RenderState,
shape: &Shape,
surface_id: SurfaceId,
paint: &Paint,
) {
match &shape.shape_type {
Type::Rect(_) | Type::Frame(_) => {
render_state.surfaces.draw_rect_to(surface_id, shape, paint);
}
Type::Circle => {
render_state
.surfaces
.draw_circle_to(surface_id, shape, paint);
}
Type::Path(_) | Type::Bool(_) => {
render_state.surfaces.draw_path_to(surface_id, shape, paint);
}
Type::Group(_) => {}
_ => unreachable!("This shape should not have fills"),
}
}
fn render_single_fill(
render_state: &mut RenderState,
shape: &Shape,
fill: &Fill,
@@ -108,7 +178,14 @@ pub fn render(
|state, temp_surface| {
let mut filtered_paint = paint.clone();
filtered_paint.set_image_filter(image_filter.clone());
draw_fill_to_surface(state, shape, fill, antialias, temp_surface, &filtered_paint);
draw_single_fill_to_surface(
state,
shape,
fill,
antialias,
temp_surface,
&filtered_paint,
);
},
) {
return;
@@ -117,10 +194,10 @@ pub fn render(
}
}
draw_fill_to_surface(render_state, shape, fill, antialias, surface_id, &paint);
draw_single_fill_to_surface(render_state, shape, fill, antialias, surface_id, &paint);
}
fn draw_fill_to_surface(
fn draw_single_fill_to_surface(
render_state: &mut RenderState,
shape: &Shape,
fill: &Fill,
@@ -153,8 +230,6 @@ fn draw_fill_to_surface(
(_, Type::Group(_)) => {
// Groups can have fills but they propagate them to their children
}
(_, _) => {
unreachable!("This shape should not have fills")
}
_ => unreachable!("This shape should not have fills"),
}
}

View File

@@ -40,7 +40,7 @@ pub fn render_stroke_inner_shadows(
if !shape.has_fills() {
for shadow in shape.inner_shadows_visible() {
let filter = shadow.get_inner_shadow_filter();
strokes::render(
strokes::render_single(
render_state,
shape,
stroke,

View File

@@ -1,7 +1,7 @@
use crate::math::{Matrix, Point, Rect};
use crate::shapes::{
Corners, Fill, ImageFill, Path, Shape, Stroke, StrokeCap, StrokeKind, SvgAttrs, Type,
merge_fills, Corners, Fill, ImageFill, Path, Shape, Stroke, StrokeCap, StrokeKind, Type,
};
use skia_safe::{self as skia, ImageFilter, RRect};
@@ -9,32 +9,28 @@ use super::{filters, RenderState, SurfaceId};
use crate::render::filters::compose_filters;
use crate::render::{get_dest_rect, get_source_rect};
// FIXME: See if we can simplify these arguments
#[allow(clippy::too_many_arguments)]
fn draw_stroke_on_rect(
canvas: &skia::Canvas,
stroke: &Stroke,
rect: &Rect,
selrect: &Rect,
corners: &Option<Corners>,
svg_attrs: Option<&SvgAttrs>,
paint: &skia::Paint,
scale: f32,
shadow: Option<&ImageFilter>,
blur: Option<&ImageFilter>,
antialias: bool,
) {
// Draw the different kind of strokes for a rect is straightforward, we just need apply a stroke to:
// - The same rect if it's a center stroke
// - A bigger rect if it's an outer stroke
// - A smaller rect if it's an outer stroke
let stroke_rect = stroke.aligned_rect(rect, scale);
let mut paint = stroke.to_paint(selrect, svg_attrs, antialias);
let mut paint = paint.clone();
// Apply both blur and shadow filters if present, composing them if necessary.
let filter = compose_filters(blur, shadow);
paint.set_image_filter(filter);
match corners {
// By default just draw the rect. Only dotted inner/outer strokes need
// clipping to prevent the dotted pattern from appearing in wrong areas.
let draw_stroke = || match corners {
Some(radii) => {
let radii = stroke.outer_corners(radii);
let rrect = RRect::new_rect_radii(stroke_rect, &radii);
@@ -43,34 +39,58 @@ fn draw_stroke_on_rect(
None => {
canvas.draw_rect(stroke_rect, &paint);
}
};
if let Some(clip_op) = stroke.clip_op() {
let layer_rec = skia::canvas::SaveLayerRec::default().paint(&paint);
canvas.save_layer(&layer_rec);
match corners {
Some(radii) => {
let rrect = RRect::new_rect_radii(*rect, radii);
canvas.clip_rrect(rrect, clip_op, antialias);
}
None => {
canvas.clip_rect(*rect, clip_op, antialias);
}
}
draw_stroke();
canvas.restore();
} else {
draw_stroke();
}
}
// FIXME: See if we can simplify these arguments
#[allow(clippy::too_many_arguments)]
fn draw_stroke_on_circle(
canvas: &skia::Canvas,
stroke: &Stroke,
rect: &Rect,
selrect: &Rect,
svg_attrs: Option<&SvgAttrs>,
paint: &skia::Paint,
scale: f32,
shadow: Option<&ImageFilter>,
blur: Option<&ImageFilter>,
antialias: bool,
) {
// Draw the different kind of strokes for an oval is straightforward, we just need apply a stroke to:
// - The same oval if it's a center stroke
// - A bigger oval if it's an outer stroke
// - A smaller oval if it's an outer stroke
let stroke_rect = stroke.aligned_rect(rect, scale);
let mut paint = stroke.to_paint(selrect, svg_attrs, antialias);
let mut paint = paint.clone();
// Apply both blur and shadow filters if present, composing them if necessary.
let filter = compose_filters(blur, shadow);
paint.set_image_filter(filter);
canvas.draw_oval(stroke_rect, &paint);
// By default just draw the circle. Only dotted inner/outer strokes need
// clipping to prevent the dotted pattern from appearing in wrong areas.
if let Some(clip_op) = stroke.clip_op() {
let layer_rec = skia::canvas::SaveLayerRec::default().paint(&paint);
canvas.save_layer(&layer_rec);
let mut clip_path = skia::Path::new();
clip_path.add_oval(rect, None);
canvas.clip_path(&clip_path, clip_op, antialias);
canvas.draw_oval(stroke_rect, &paint);
canvas.restore();
} else {
canvas.draw_oval(stroke_rect, &paint);
}
}
fn draw_outer_stroke_path(
@@ -122,15 +142,13 @@ fn draw_inner_stroke_path(
}
// For outer stroke we draw a center stroke (with double width) and use another path with blend mode clear to remove the inner stroke added
// FIXME: See if we can simplify these arguments
#[allow(clippy::too_many_arguments)]
pub fn draw_stroke_on_path(
fn draw_stroke_on_path(
canvas: &skia::Canvas,
stroke: &Stroke,
path: &Path,
selrect: &Rect,
paint: &skia::Paint,
path_transform: Option<&Matrix>,
svg_attrs: Option<&SvgAttrs>,
shadow: Option<&ImageFilter>,
blur: Option<&ImageFilter>,
antialias: bool,
@@ -140,31 +158,28 @@ pub fn draw_stroke_on_path(
let is_open = path.is_open();
let mut paint: skia_safe::Handle<_> =
stroke.to_stroked_paint(is_open, selrect, svg_attrs, antialias);
let mut draw_paint = paint.clone();
let filter = compose_filters(blur, shadow);
paint.set_image_filter(filter);
draw_paint.set_image_filter(filter);
match stroke.render_kind(is_open) {
StrokeKind::Inner => {
draw_inner_stroke_path(canvas, &skia_path, &paint, blur, antialias);
draw_inner_stroke_path(canvas, &skia_path, &draw_paint, blur, antialias);
}
StrokeKind::Center => {
canvas.draw_path(&skia_path, &paint);
canvas.draw_path(&skia_path, &draw_paint);
}
StrokeKind::Outer => {
draw_outer_stroke_path(canvas, &skia_path, &paint, blur, antialias);
draw_outer_stroke_path(canvas, &skia_path, &draw_paint, blur, antialias);
}
}
handle_stroke_caps(
&mut skia_path,
stroke,
selrect,
canvas,
is_open,
svg_attrs,
paint,
blur,
antialias,
);
@@ -207,17 +222,15 @@ fn handle_stroke_cap(
}
}
// FIXME: See if we can simplify these arguments
#[allow(clippy::too_many_arguments)]
fn handle_stroke_caps(
path: &mut skia::Path,
stroke: &Stroke,
selrect: &Rect,
canvas: &skia::Canvas,
is_open: bool,
svg_attrs: Option<&SvgAttrs>,
paint: &skia::Paint,
blur: Option<&ImageFilter>,
antialias: bool,
_antialias: bool,
) {
let mut points = vec![Point::default(); path.count_points()];
path.get_points(&mut points);
@@ -230,7 +243,7 @@ fn handle_stroke_caps(
let first_point = points.first().unwrap();
let last_point = points.last().unwrap();
let mut paint_stroke = stroke.to_stroked_paint(is_open, selrect, svg_attrs, antialias);
let mut paint_stroke = paint.clone();
if let Some(filter) = blur {
paint_stroke.set_image_filter(filter.clone());
@@ -405,30 +418,25 @@ fn draw_image_stroke_in_container(
match &shape.shape_type {
shape_type @ (Type::Rect(_) | Type::Frame(_)) => {
let paint = stroke.to_paint(&outer_rect, svg_attrs, antialias);
draw_stroke_on_rect(
canvas,
stroke,
container,
&outer_rect,
&shape_type.corners(),
svg_attrs,
&paint,
scale,
None,
None,
antialias,
);
}
Type::Circle => draw_stroke_on_circle(
canvas,
stroke,
container,
&outer_rect,
svg_attrs,
scale,
None,
None,
antialias,
),
Type::Circle => {
let paint = stroke.to_paint(&outer_rect, svg_attrs, antialias);
draw_stroke_on_circle(
canvas, stroke, container, &paint, scale, None, None, antialias,
);
}
shape_type @ (Type::Path(_) | Type::Bool(_)) => {
if let Some(p) = shape_type.path() {
@@ -446,21 +454,21 @@ fn draw_image_stroke_in_container(
}
}
let is_open = p.is_open();
let mut paint = stroke.to_stroked_paint(is_open, &outer_rect, svg_attrs, antialias);
let paint = stroke.to_stroked_paint(is_open, &outer_rect, svg_attrs, antialias);
canvas.draw_path(&path, &paint);
if stroke.render_kind(is_open) == StrokeKind::Outer {
// Small extra inner stroke to overlap with the fill
// and avoid unnecesary artifacts.
paint.set_stroke_width(1. / scale);
canvas.draw_path(&path, &paint);
let mut thin_paint = paint.clone();
thin_paint.set_stroke_width(1. / scale);
canvas.draw_path(&path, &thin_paint);
}
handle_stroke_caps(
&mut path,
stroke,
&outer_rect,
canvas,
is_open,
svg_attrs,
&paint,
shape.image_filter(1.).as_ref(),
antialias,
);
@@ -509,8 +517,230 @@ fn draw_image_stroke_in_container(
canvas.restore();
}
#[allow(clippy::too_many_arguments)]
/// Renders all strokes for a shape. Merges strokes that share the same
/// geometry (kind, width, style, caps) into a single draw call to avoid
/// anti-aliasing edge bleed between them.
pub fn render(
render_state: &mut RenderState,
shape: &Shape,
strokes: &[&Stroke],
surface_id: Option<SurfaceId>,
antialias: bool,
) {
if strokes.is_empty() {
return;
}
let has_image_fills = strokes.iter().any(|s| matches!(s.fill, Fill::Image(_)));
let can_merge = !has_image_fills && strokes.len() > 1 && strokes_share_geometry(strokes);
if !can_merge {
// When blur is active, render all strokes into a single offscreen surface
// and apply blur once to the composite. This prevents blur from making
// edges semi-transparent and revealing strokes underneath.
if let Some(image_filter) = shape.image_filter(1.) {
let mut content_bounds = shape.selrect;
let max_margin = strokes
.iter()
.map(|s| s.bounds_width(shape.is_open()))
.fold(0.0f32, f32::max);
if max_margin > 0.0 {
content_bounds.inset((-max_margin, -max_margin));
}
let max_cap = strokes
.iter()
.map(|s| s.cap_bounds_margin())
.fold(0.0f32, f32::max);
if max_cap > 0.0 {
content_bounds.inset((-max_cap, -max_cap));
}
let bounds = image_filter.compute_fast_bounds(content_bounds);
let target = surface_id.unwrap_or(SurfaceId::Strokes);
if filters::render_with_filter_surface(
render_state,
bounds,
target,
|state, temp_surface| {
// Use save_layer with the blur filter so it applies once
// to the composite of all strokes, not per-stroke.
let canvas = state.surfaces.canvas(temp_surface);
let mut blur_paint = skia::Paint::default();
blur_paint.set_image_filter(image_filter.clone());
let layer_rec = skia::canvas::SaveLayerRec::default().paint(&blur_paint);
canvas.save_layer(&layer_rec);
for stroke in strokes.iter().rev() {
// bypass_filter=true prevents each stroke from creating
// its own filter surface. The blur on the paint inside
// draw functions is harmless — it composes with the
// layer's filter but the layer filter is the dominant one.
render_single_internal(
state,
shape,
stroke,
Some(temp_surface),
None,
antialias,
true,
true,
);
}
state.surfaces.canvas(temp_surface).restore();
},
) {
return;
}
}
// No blur or filter surface unavailable — draw strokes individually.
for stroke in strokes.iter().rev() {
render_single(render_state, shape, stroke, surface_id, None, antialias);
}
return;
}
render_merged(render_state, shape, strokes, surface_id, antialias, false);
}
fn strokes_share_geometry(strokes: &[&Stroke]) -> bool {
strokes.windows(2).all(|pair| {
pair[0].kind == pair[1].kind
&& pair[0].width == pair[1].width
&& pair[0].style == pair[1].style
&& pair[0].cap_start == pair[1].cap_start
&& pair[0].cap_end == pair[1].cap_end
})
}
fn render_merged(
render_state: &mut RenderState,
shape: &Shape,
strokes: &[&Stroke],
surface_id: Option<SurfaceId>,
antialias: bool,
bypass_filter: bool,
) {
let representative = *strokes
.last()
.expect("render_merged expects at least one stroke");
let blur_filter = if bypass_filter {
None
} else {
shape.image_filter(1.)
};
// Handle blur filter
if !bypass_filter {
if let Some(image_filter) = blur_filter.clone() {
let mut content_bounds = shape.selrect;
let stroke_margin = representative.bounds_width(shape.is_open());
if stroke_margin > 0.0 {
content_bounds.inset((-stroke_margin, -stroke_margin));
}
let cap_margin = representative.cap_bounds_margin();
if cap_margin > 0.0 {
content_bounds.inset((-cap_margin, -cap_margin));
}
let bounds = image_filter.compute_fast_bounds(content_bounds);
let target = surface_id.unwrap_or(SurfaceId::Strokes);
if filters::render_with_filter_surface(
render_state,
bounds,
target,
|state, temp_surface| {
let blur_filter = image_filter.clone();
state.surfaces.apply_mut(temp_surface as u32, |surface| {
let canvas = surface.canvas();
let mut blur_paint = skia::Paint::default();
blur_paint.set_image_filter(blur_filter.clone());
let layer_rec = skia::canvas::SaveLayerRec::default().paint(&blur_paint);
canvas.save_layer(&layer_rec);
});
render_merged(state, shape, strokes, Some(temp_surface), antialias, true);
state.surfaces.apply_mut(temp_surface as u32, |surface| {
surface.canvas().restore();
});
},
) {
return;
}
}
}
// `merge_fills` puts fills[0] on top (each new fill goes under the accumulated shader
// via SrcOver), matching the non-merged path where strokes[0] is drawn last (on top).
let fills: Vec<Fill> = strokes.iter().map(|s| s.fill.clone()).collect();
let merged = merge_fills(&fills, shape.selrect);
let scale = render_state.get_scale();
let target_surface = surface_id.unwrap_or(SurfaceId::Strokes);
let canvas = render_state.surfaces.canvas_and_mark_dirty(target_surface);
let selrect = shape.selrect;
let svg_attrs = shape.svg_attrs.as_ref();
let path_transform = shape.to_path_transform();
match &shape.shape_type {
shape_type @ (Type::Rect(_) | Type::Frame(_)) => {
let mut paint = representative.to_paint(&selrect, svg_attrs, antialias);
paint.set_shader(merged.shader());
draw_stroke_on_rect(
canvas,
representative,
&selrect,
&shape_type.corners(),
&paint,
scale,
None,
blur_filter.as_ref(),
antialias,
);
}
Type::Circle => {
let mut paint = representative.to_paint(&selrect, svg_attrs, antialias);
paint.set_shader(merged.shader());
draw_stroke_on_circle(
canvas,
representative,
&selrect,
&paint,
scale,
None,
blur_filter.as_ref(),
antialias,
);
}
Type::Text(_) => {}
shape_type @ (Type::Path(_) | Type::Bool(_)) => {
if let Some(path) = shape_type.path() {
let is_open = path.is_open();
let mut paint =
representative.to_stroked_paint(is_open, &selrect, svg_attrs, antialias);
paint.set_shader(merged.shader());
draw_stroke_on_path(
canvas,
representative,
path,
&paint,
path_transform.as_ref(),
None,
blur_filter.as_ref(),
antialias,
);
}
}
_ => unreachable!("This shape should not have strokes"),
}
}
/// Renders a single stroke. Used by the shadow module which needs per-stroke
/// shadow filters.
#[allow(clippy::too_many_arguments)]
pub fn render_single(
render_state: &mut RenderState,
shape: &Shape,
stroke: &Stroke,
@@ -518,7 +748,7 @@ pub fn render(
shadow: Option<&ImageFilter>,
antialias: bool,
) {
render_internal(
render_single_internal(
render_state,
shape,
stroke,
@@ -526,34 +756,12 @@ pub fn render(
shadow,
antialias,
false,
false,
);
}
/// Internal function to render a stroke with support for offscreen blur rendering.
///
/// # Parameters
/// - `render_state`: The rendering state containing surfaces and context.
/// - `shape`: The shape to render the stroke for.
/// - `stroke`: The stroke configuration (width, fill, style, etc.).
/// - `surface_id`: Optional target surface ID. Defaults to `SurfaceId::Strokes` if `None`.
/// - `shadow`: Optional shadow filter to apply to the stroke.
/// - `antialias`: Whether to use antialiasing for rendering.
/// - `bypass_filter`:
/// - If `false`, attempts to use offscreen filter surface for blur effects.
/// - If `true`, renders directly to the target surface (used for recursive calls to avoid infinite loops when rendering into the filter surface).
///
/// # Behavior
/// When `bypass_filter` is `false` and the shape has a blur filter:
/// 1. Calculates bounds including stroke width and cap margins.
/// 2. Attempts to render into an offscreen filter surface at unscaled coordinates.
/// 3. If successful, composites the result back to the target surface and returns early.
/// 4. If the offscreen render fails or `bypass_filter` is `true`, renders directly to the target
/// surface using the appropriate drawing function for the shape type.
///
/// The recursive call with `bypass_filter=true` ensures that when rendering into the filter
/// surface, we don't attempt to create another filter surface, avoiding infinite recursion.
#[allow(clippy::too_many_arguments)]
fn render_internal(
fn render_single_internal(
render_state: &mut RenderState,
shape: &Shape,
stroke: &Stroke,
@@ -561,10 +769,10 @@ fn render_internal(
shadow: Option<&ImageFilter>,
antialias: bool,
bypass_filter: bool,
skip_blur: bool,
) {
if !bypass_filter {
if let Some(image_filter) = shape.image_filter(1.) {
// We have to calculate the bounds considering the stroke and the cap margins.
let mut content_bounds = shape.selrect;
let stroke_margin = stroke.bounds_width(shape.is_open());
if stroke_margin > 0.0 {
@@ -582,7 +790,7 @@ fn render_internal(
bounds,
target,
|state, temp_surface| {
render_internal(
render_single_internal(
state,
shape,
stroke,
@@ -590,6 +798,7 @@ fn render_internal(
shadow,
antialias,
true,
true,
);
},
) {
@@ -605,6 +814,12 @@ fn render_internal(
let path_transform = shape.to_path_transform();
let svg_attrs = shape.svg_attrs.as_ref();
let blur = if skip_blur {
None
} else {
shape.image_filter(1.)
};
if !matches!(shape.shape_type, Type::Text(_))
&& shadow.is_none()
&& matches!(stroke.fill, Fill::Image(_))
@@ -622,42 +837,45 @@ fn render_internal(
} else {
match &shape.shape_type {
shape_type @ (Type::Rect(_) | Type::Frame(_)) => {
let paint = stroke.to_paint(&selrect, svg_attrs, antialias);
draw_stroke_on_rect(
canvas,
stroke,
&selrect,
&selrect,
&shape_type.corners(),
svg_attrs,
&paint,
scale,
shadow,
shape.image_filter(1.).as_ref(),
blur.as_ref(),
antialias,
);
}
Type::Circle => {
let paint = stroke.to_paint(&selrect, svg_attrs, antialias);
draw_stroke_on_circle(
canvas,
stroke,
&selrect,
&paint,
scale,
shadow,
blur.as_ref(),
antialias,
);
}
Type::Circle => draw_stroke_on_circle(
canvas,
stroke,
&selrect,
&selrect,
svg_attrs,
scale,
shadow,
shape.image_filter(1.).as_ref(),
antialias,
),
Type::Text(_) => {}
shape_type @ (Type::Path(_) | Type::Bool(_)) => {
if let Some(path) = shape_type.path() {
let is_open = path.is_open();
let paint = stroke.to_stroked_paint(is_open, &selrect, svg_attrs, antialias);
draw_stroke_on_path(
canvas,
stroke,
path,
&selrect,
&paint,
path_transform.as_ref(),
svg_attrs,
shadow,
shape.image_filter(1.).as_ref(),
blur.as_ref(),
antialias,
);
}

View File

@@ -0,0 +1,240 @@
use crate::shapes::{Shape, TextContent, Type, VerticalAlign};
use crate::state::{TextEditorState, TextSelection};
use skia_safe::textlayout::{RectHeightStyle, RectWidthStyle};
use skia_safe::{BlendMode, Canvas, Matrix, Paint, Rect};
pub fn render_overlay(
canvas: &Canvas,
editor_state: &TextEditorState,
shape: &Shape,
transform: &Matrix,
) {
if !editor_state.is_active {
return;
}
let Type::Text(text_content) = &shape.shape_type else {
return;
};
canvas.save();
canvas.concat(transform);
if editor_state.selection.is_selection() {
render_selection(canvas, editor_state, text_content, shape);
}
if editor_state.cursor_visible {
render_cursor(canvas, editor_state, text_content, shape);
}
canvas.restore();
}
fn render_cursor(
canvas: &Canvas,
editor_state: &TextEditorState,
text_content: &TextContent,
shape: &Shape,
) {
let Some(rect) = calculate_cursor_rect(editor_state, text_content, shape) else {
return;
};
let mut paint = Paint::default();
paint.set_color(editor_state.theme.cursor_color);
paint.set_anti_alias(true);
canvas.draw_rect(rect, &paint);
}
fn render_selection(
canvas: &Canvas,
editor_state: &TextEditorState,
text_content: &TextContent,
shape: &Shape,
) {
let selection = &editor_state.selection;
let rects = calculate_selection_rects(selection, text_content, shape);
if rects.is_empty() {
return;
}
let mut paint = Paint::default();
paint.set_blend_mode(BlendMode::Multiply);
paint.set_color(editor_state.theme.selection_color);
paint.set_anti_alias(true);
for rect in rects {
canvas.draw_rect(rect, &paint);
}
}
fn vertical_align_offset(
shape: &Shape,
layout_paragraphs: &[&skia_safe::textlayout::Paragraph],
) -> f32 {
let total_height: f32 = layout_paragraphs.iter().map(|p| p.height()).sum();
match shape.vertical_align() {
VerticalAlign::Center => (shape.selrect().height() - total_height) / 2.0,
VerticalAlign::Bottom => shape.selrect().height() - total_height,
_ => 0.0,
}
}
fn calculate_cursor_rect(
editor_state: &TextEditorState,
text_content: &TextContent,
shape: &Shape,
) -> Option<Rect> {
let cursor = editor_state.selection.focus;
let paragraphs = text_content.paragraphs();
if cursor.paragraph >= paragraphs.len() {
return None;
}
let layout_paragraphs: Vec<_> = text_content.layout.paragraphs.iter().flatten().collect();
if cursor.paragraph >= layout_paragraphs.len() {
return None;
}
let selrect = shape.selrect();
let mut y_offset = vertical_align_offset(shape, &layout_paragraphs);
for (idx, laid_out_para) in layout_paragraphs.iter().enumerate() {
if idx == cursor.paragraph {
let char_pos = cursor.char_offset;
// For cursor, we get a zero-width range at the position
// We need to handle edge cases:
// - At start of paragraph: use position 0
// - At end of paragraph: use last position
let para = &paragraphs[cursor.paragraph];
let para_char_count: usize = para
.children()
.iter()
.map(|span| span.text.chars().count())
.sum();
let (cursor_x, cursor_height) = if para_char_count == 0 {
// Empty paragraph - use default height
(0.0, laid_out_para.height())
} else if char_pos == 0 {
let rects = laid_out_para.get_rects_for_range(
0..1,
RectHeightStyle::Max,
RectWidthStyle::Tight,
);
if !rects.is_empty() {
(rects[0].rect.left(), rects[0].rect.height())
} else {
(0.0, laid_out_para.height())
}
} else if char_pos >= para_char_count {
let rects = laid_out_para.get_rects_for_range(
para_char_count.saturating_sub(1)..para_char_count,
RectHeightStyle::Max,
RectWidthStyle::Tight,
);
if !rects.is_empty() {
(rects[0].rect.right(), rects[0].rect.height())
} else {
(laid_out_para.longest_line(), laid_out_para.height())
}
} else {
let rects = laid_out_para.get_rects_for_range(
char_pos..char_pos + 1,
RectHeightStyle::Max,
RectWidthStyle::Tight,
);
if !rects.is_empty() {
(rects[0].rect.left(), rects[0].rect.height())
} else {
// Fallback: use glyph position
let pos = laid_out_para.get_glyph_position_at_coordinate((0.0, 0.0));
(pos.position as f32, laid_out_para.height())
}
};
return Some(Rect::from_xywh(
selrect.x() + cursor_x,
selrect.y() + y_offset,
editor_state.theme.cursor_width,
cursor_height,
));
}
y_offset += laid_out_para.height();
}
None
}
fn calculate_selection_rects(
selection: &TextSelection,
text_content: &TextContent,
shape: &Shape,
) -> Vec<Rect> {
let mut rects = Vec::new();
let start = selection.start();
let end = selection.end();
let paragraphs = text_content.paragraphs();
let layout_paragraphs: Vec<_> = text_content.layout.paragraphs.iter().flatten().collect();
let selrect = shape.selrect();
let mut y_offset = vertical_align_offset(shape, &layout_paragraphs);
for (para_idx, laid_out_para) in layout_paragraphs.iter().enumerate() {
let para_height = laid_out_para.height();
// Check if this paragraph is in selection range
if para_idx < start.paragraph || para_idx > end.paragraph {
y_offset += para_height;
continue;
}
// Calculate character range for this paragraph
let para = &paragraphs[para_idx];
let para_char_count: usize = para
.children()
.iter()
.map(|span| span.text.chars().count())
.sum();
let range_start = if para_idx == start.paragraph {
start.char_offset
} else {
0
};
let range_end = if para_idx == end.paragraph {
end.char_offset
} else {
para_char_count
};
if range_start < range_end {
use skia_safe::textlayout::{RectHeightStyle, RectWidthStyle};
let text_boxes = laid_out_para.get_rects_for_range(
range_start..range_end,
RectHeightStyle::Max,
RectWidthStyle::Tight,
);
for text_box in text_boxes {
let r = text_box.rect;
rects.push(Rect::from_xywh(
selrect.x() + r.left(),
selrect.y() + y_offset + r.top(),
r.width(),
r.height(),
));
}
}
y_offset += para_height;
}
rects
}

View File

@@ -620,6 +620,7 @@ impl Shape {
(added, removed)
}
#[allow(dead_code)]
pub fn fills(&self) -> std::slice::Iter<'_, Fill> {
self.fills.iter()
}
@@ -1119,6 +1120,28 @@ impl Shape {
}
}
/// Returns children in forward (non-reversed) order - useful for layout calculations
pub fn children_ids_iter_forward(
&self,
include_hidden: bool,
) -> Box<dyn Iterator<Item = &Uuid> + '_> {
if include_hidden {
return Box::new(self.children.iter());
}
if let Type::Bool(_) = self.shape_type {
Box::new([].iter())
} else if let Type::Group(group) = self.shape_type {
if group.masked {
Box::new(self.children.iter().skip(1))
} else {
Box::new(self.children.iter())
}
} else {
Box::new(self.children.iter())
}
}
pub fn all_children(
&self,
shapes: ShapesPoolRef,

View File

@@ -241,10 +241,14 @@ pub fn merge_fills(fills: &[Fill], bounding_box: Rect) -> skia::Paint {
if let Some(shader) = shader {
combined_shader = match combined_shader {
// Use SrcOver and treat the newly encountered fill as the source (top),
// overlaying it over the previously composed shader (destination/bottom).
// This avoids edge bleed from underlying fills when anti-aliasing causes
// fractional coverage at shape boundaries.
Some(existing_shader) => Some(skia::shaders::blend(
skia::Blender::mode(skia::BlendMode::DstOver),
existing_shader,
skia::Blender::mode(skia::BlendMode::SrcOver),
shader,
existing_shader,
)),
None => Some(shader),
};

View File

@@ -300,7 +300,20 @@ fn propagate_reflow(
Type::Frame(Frame {
layout: Some(_), ..
}) => {
layout_reflows.insert(*id);
let mut skip_reflow = false;
if shape.is_layout_horizontal_fill() || shape.is_layout_vertical_fill() {
if let Some(parent_id) = shape.parent_id {
if parent_id != Uuid::nil() && !reflown.contains(&parent_id) {
// If this is a fill layout but the parent has not been reflown yet
// we wait for the next iteration for reflow
skip_reflow = true;
}
}
}
if !skip_reflow {
layout_reflows.insert(*id);
}
}
Type::Group(Group { masked: true }) => {
let children_ids = shape.children_ids(true);
@@ -417,28 +430,26 @@ pub fn propagate_modifiers(
}
}
}
let mut layout_reflows_vec: Vec<Uuid> = layout_reflows.into_iter().collect();
// We sort the reflows so they are process first the ones that are more
// deep in the tree structure. This way we can be sure that the children layouts
// are already reflowed.
// We sort the reflows so they are processed deepest-first in the
// tree structure. This way we can be sure that the children layouts
// are already reflowed before their parents.
let mut layout_reflows_vec: Vec<Uuid> =
std::mem::take(&mut layout_reflows).into_iter().collect();
layout_reflows_vec.sort_unstable_by(|id_a, id_b| {
let da = shapes.get_depth(id_a);
let db = shapes.get_depth(id_b);
db.cmp(&da)
});
let mut bounds_temp = bounds.clone();
for id in layout_reflows_vec.iter() {
for id in &layout_reflows_vec {
if reflown.contains(id) {
continue;
}
reflow_shape(id, state, &mut reflown, &mut entries, &mut bounds_temp);
reflow_shape(id, state, &mut reflown, &mut entries, &mut bounds);
}
layout_reflows = HashSet::new();
}
#[allow(dead_code)]
modifiers
.iter()
.map(|(key, val)| TransformEntry::from_input(*key, *val))

View File

@@ -184,15 +184,18 @@ fn initialize_tracks(
) -> Vec<TrackData> {
let mut tracks = Vec::<TrackData>::new();
let mut current_track = TrackData::default();
let mut children = shape.children_ids(true);
let mut first = true;
if flex_data.is_reverse() {
children.reverse();
}
// When is_reverse() is true, we need forward order (children_ids_iter_forward).
// When is_reverse() is false, we need reversed order (children_ids_iter).
let children_iter: Box<dyn Iterator<Item = Uuid>> = if flex_data.is_reverse() {
Box::new(shape.children_ids_iter_forward(true).copied())
} else {
Box::new(shape.children_ids_iter(true).copied())
};
for child_id in children.iter() {
let Some(child) = shapes.get(child_id) else {
for child_id in children_iter {
let Some(child) = shapes.get(&child_id) else {
continue;
};
@@ -293,7 +296,7 @@ fn distribute_fill_main_space(layout_axis: &LayoutAxis, tracks: &mut [TrackData]
track.main_size += delta;
if (child.main_size - child.max_main_size).abs() < MIN_SIZE {
to_resize_children.remove(i);
to_resize_children.swap_remove(i);
}
}
}
@@ -330,7 +333,7 @@ fn distribute_fill_across_space(layout_axis: &LayoutAxis, tracks: &mut [TrackDat
left_space -= delta;
if (track.across_size - track.max_across_size).abs() < MIN_SIZE {
to_resize_tracks.remove(i);
to_resize_tracks.swap_remove(i);
}
}
}

View File

@@ -6,7 +6,7 @@ use crate::shapes::{
};
use crate::state::ShapesPoolRef;
use crate::uuid::Uuid;
use std::collections::{HashMap, VecDeque};
use std::collections::{HashMap, HashSet, VecDeque};
use super::common::GetBounds;
@@ -537,7 +537,7 @@ fn cell_bounds(
pub fn create_cell_data<'a>(
layout_bounds: &Bounds,
children: &[Uuid],
children: &HashSet<Uuid>,
shapes: ShapesPoolRef<'a>,
cells: &Vec<GridCell>,
column_tracks: &[TrackData],
@@ -614,7 +614,7 @@ pub fn grid_cell_data<'a>(
let bounds = &mut HashMap::<Uuid, Bounds>::new();
let layout_bounds = shape.bounds();
let children = shape.children_ids(false);
let children: HashSet<Uuid> = shape.children_ids_iter(false).copied().collect();
let column_tracks = calculate_tracks(
true,
@@ -707,7 +707,7 @@ pub fn reflow_grid_layout(
) -> VecDeque<Modifier> {
let mut result = VecDeque::new();
let layout_bounds = bounds.find(shape);
let children = shape.children_ids(true);
let children: HashSet<Uuid> = shape.children_ids_iter(true).copied().collect();
let column_tracks = calculate_tracks(
true,

View File

@@ -119,6 +119,19 @@ impl Stroke {
self.width *= value;
}
/// Returns the clip operation for dotted inner/outer strokes.
/// Returns `None` when no clipping is needed (center or non-dotted).
pub fn clip_op(&self) -> Option<skia::ClipOp> {
if self.style != StrokeStyle::Dotted || self.kind == StrokeKind::Center {
return None;
}
match self.kind {
StrokeKind::Inner => Some(skia::ClipOp::Intersect),
StrokeKind::Outer => Some(skia::ClipOp::Difference),
StrokeKind::Center => None,
}
}
pub fn delta(&self) -> f32 {
match self.kind {
StrokeKind::Inner => 0.,
@@ -128,20 +141,28 @@ impl Stroke {
}
pub fn outer_rect(&self, rect: &Rect) -> Rect {
match self.kind {
StrokeKind::Inner => Rect::from_xywh(
rect.left + (self.width / 2.),
rect.top + (self.width / 2.),
rect.width() - self.width,
rect.height() - self.width,
),
StrokeKind::Center => Rect::from_xywh(rect.left, rect.top, rect.width(), rect.height()),
StrokeKind::Outer => Rect::from_xywh(
rect.left - (self.width / 2.),
rect.top - (self.width / 2.),
rect.width() + self.width,
rect.height() + self.width,
),
match (self.kind, self.style) {
(StrokeKind::Inner, StrokeStyle::Dotted) | (StrokeKind::Outer, StrokeStyle::Dotted) => {
// Boundary so circles center on it and semicircles match after clipping
*rect
}
_ => match self.kind {
StrokeKind::Inner => Rect::from_xywh(
rect.left + (self.width / 2.),
rect.top + (self.width / 2.),
rect.width() - self.width,
rect.height() - self.width,
),
StrokeKind::Center => {
Rect::from_xywh(rect.left, rect.top, rect.width(), rect.height())
}
StrokeKind::Outer => Rect::from_xywh(
rect.left - (self.width / 2.),
rect.top - (self.width / 2.),
rect.width() + self.width,
rect.height() + self.width,
),
},
}
}
@@ -155,6 +176,11 @@ impl Stroke {
}
pub fn outer_corners(&self, corners: &Corners) -> Corners {
if matches!(self.style, StrokeStyle::Dotted | StrokeStyle::Dashed) {
// Path at boundary so no corner offset
return *corners;
}
let offset = match self.kind {
StrokeKind::Center => 0.0,
StrokeKind::Inner => -self.width / 2.0,

View File

@@ -116,6 +116,7 @@ impl TextContentSize {
pub struct TextPositionWithAffinity {
pub position_with_affinity: PositionWithAffinity,
pub paragraph: i32,
#[allow(dead_code)]
pub span: i32,
pub offset: i32,
}
@@ -316,6 +317,10 @@ impl TextContent {
&self.paragraphs
}
pub fn paragraphs_mut(&mut self) -> &mut Vec<Paragraph> {
&mut self.paragraphs
}
pub fn width(&self) -> f32 {
self.size.width
}
@@ -428,8 +433,16 @@ impl TextContent {
let end_y = offset_y + layout_paragraph.height();
// We only test against paragraphs that can contain the current y
// coordinate.
if point.y > start_y && point.y < end_y {
// coordinate. Use >= for start and handle zero-height paragraphs.
let paragraph_height = layout_paragraph.height();
let matches = if paragraph_height > 0.0 {
point.y >= start_y && point.y < end_y
} else {
// For zero-height paragraphs (empty lines), match if we're at the start position
point.y >= start_y && point.y <= start_y + 1.0
};
if matches {
let position_with_affinity =
layout_paragraph.get_glyph_position_at_coordinate(*point);
if let Some(paragraph) = self.paragraphs().get(paragraph_index as usize) {
@@ -438,18 +451,37 @@ impl TextContent {
// in which span we are.
let mut computed_position = 0;
let mut span_offset = 0;
for span in paragraph.children() {
span_index += 1;
let length = span.text.len();
let start_position = computed_position;
let end_position = computed_position + length;
let current_position = position_with_affinity.position as usize;
if start_position <= current_position && end_position >= current_position {
span_offset = position_with_affinity.position - start_position as i32;
break;
// If paragraph has no spans, default to span 0, offset 0
if paragraph.children().is_empty() {
span_index = 0;
span_offset = 0;
} else {
for span in paragraph.children() {
span_index += 1;
let length = span.text.chars().count();
let start_position = computed_position;
let end_position = computed_position + length;
let current_position = position_with_affinity.position as usize;
// Handle empty spans: if the span is empty and current position
// matches the start, this is the right span
if length == 0 && current_position == start_position {
span_offset = 0;
break;
}
if start_position <= current_position
&& end_position >= current_position
{
span_offset =
position_with_affinity.position - start_position as i32;
break;
}
computed_position += length;
}
computed_position += length;
}
return Some(TextPositionWithAffinity::new(
position_with_affinity,
paragraph_index,
@@ -460,6 +492,26 @@ impl TextContent {
}
offset_y += layout_paragraph.height();
}
// Handle completely empty text shapes: if there are no paragraphs or all paragraphs
// are empty, and the click is within the text shape bounds, return a default position
if (self.paragraphs().is_empty() || self.layout.paragraphs.is_empty())
&& self.bounds.contains(*point)
{
// Create a default position at the start of the text
use skia_safe::textlayout::Affinity;
let default_position = PositionWithAffinity {
position: 0,
affinity: Affinity::Downstream,
};
return Some(TextPositionWithAffinity::new(
default_position,
0, // paragraph 0
0, // span 0
0, // offset 0
));
}
None
}
@@ -838,6 +890,10 @@ impl Paragraph {
&self.children
}
pub fn children_mut(&mut self) -> &mut Vec<TextSpan> {
&mut self.children
}
#[allow(dead_code)]
fn add_span(&mut self, span: TextSpan) {
self.children.push(span);
@@ -847,6 +903,26 @@ impl Paragraph {
self.line_height
}
pub fn letter_spacing(&self) -> f32 {
self.letter_spacing
}
pub fn text_align(&self) -> TextAlign {
self.text_align
}
pub fn text_direction(&self) -> TextDirection {
self.text_direction
}
pub fn text_decoration(&self) -> Option<TextDecoration> {
self.text_decoration
}
pub fn text_transform(&self) -> Option<TextTransform> {
self.text_transform
}
pub fn paragraph_to_style(&self) -> ParagraphStyle {
let mut style = ParagraphStyle::default();
@@ -1228,14 +1304,21 @@ pub fn calculate_text_layout_data(
let current_y = para_layout.y;
let text_paragraph = text_paragraphs.get(paragraph_index);
if let Some(text_para) = text_paragraph {
let mut span_ranges: Vec<(usize, usize, usize)> = vec![];
let mut span_ranges: Vec<(usize, usize, usize, String, String)> = vec![];
let mut cur = 0;
for (span_index, span) in text_para.children().iter().enumerate() {
let text: String = span.apply_text_transform();
span_ranges.push((cur, cur + text.len(), span_index));
cur += text.len();
let transformed_text: String = span.apply_text_transform();
let original_text = span.text.clone();
let text = transformed_text.clone();
let text_len = text.len();
span_ranges.push((cur, cur + text_len, span_index, text, original_text));
cur += text_len;
}
for (start, end, span_index) in span_ranges {
for (start, end, span_index, transformed_text, original_text) in span_ranges {
// Skip empty spans to avoid invalid rect calculations
if start >= end {
continue;
}
let rects = para_layout.paragraph.get_rects_for_range(
start..end,
RectHeightStyle::Tight,
@@ -1245,22 +1328,43 @@ pub fn calculate_text_layout_data(
let direction = textbox.direct;
let mut rect = textbox.rect;
let cy = rect.top + rect.height() / 2.0;
let start_pos = para_layout
// Get byte positions from Skia's transformed text layout
let glyph_start = para_layout
.paragraph
.get_glyph_position_at_coordinate((rect.left + 0.1, cy))
.position as usize;
let end_pos = para_layout
let glyph_end = para_layout
.paragraph
.get_glyph_position_at_coordinate((rect.right - 0.1, cy))
.position as usize;
let start_pos = start_pos.saturating_sub(start);
let end_pos = end_pos.saturating_sub(start);
// Convert to byte positions relative to this span
let byte_start = glyph_start.saturating_sub(start);
let byte_end = glyph_end.saturating_sub(start);
// Convert byte positions to character positions in ORIGINAL text
// This handles multi-byte UTF-8 and text transform differences
let char_start = transformed_text
.char_indices()
.position(|(i, _)| i >= byte_start)
.unwrap_or(0);
let char_end = transformed_text
.char_indices()
.position(|(i, _)| i >= byte_end)
.unwrap_or_else(|| transformed_text.chars().count());
// Clamp to original text length for safety
let original_char_count = original_text.chars().count();
let final_start = char_start.min(original_char_count);
let final_end = char_end.min(original_char_count);
rect.offset((x, current_y));
position_data.push(PositionData {
paragraph: paragraph_index as u32,
span: span_index as u32,
start_pos: start_pos as u32,
end_pos: end_pos as u32,
start_pos: final_start as u32,
end_pos: final_end as u32,
x: rect.x(),
y: rect.y(),
width: rect.width(),

View File

@@ -1,9 +1,226 @@
#![allow(dead_code)]
use crate::shapes::TextPositionWithAffinity;
use crate::uuid::Uuid;
use skia_safe::Color;
/// TODO: Now this is just a tuple with 2 i32 working
/// as indices (paragraph and span).
/// Cursor position within text content.
/// Uses character offsets for precise positioning.
#[derive(Debug, PartialEq, Eq, Clone, Copy, Default)]
pub struct TextCursor {
pub paragraph: usize,
pub char_offset: usize,
}
impl TextCursor {
pub fn new(paragraph: usize, char_offset: usize) -> Self {
Self {
paragraph,
char_offset,
}
}
pub fn zero() -> Self {
Self {
paragraph: 0,
char_offset: 0,
}
}
}
#[derive(Debug, Clone, Copy, Default)]
pub struct TextSelection {
pub anchor: TextCursor,
pub focus: TextCursor,
}
impl TextSelection {
pub fn new() -> Self {
Self::default()
}
pub fn from_cursor(cursor: TextCursor) -> Self {
Self {
anchor: cursor,
focus: cursor,
}
}
pub fn is_collapsed(&self) -> bool {
self.anchor == self.focus
}
pub fn is_selection(&self) -> bool {
!self.is_collapsed()
}
pub fn set_caret(&mut self, cursor: TextCursor) {
self.anchor = cursor;
self.focus = cursor;
}
pub fn extend_to(&mut self, cursor: TextCursor) {
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) -> TextCursor {
if self.anchor.paragraph < self.focus.paragraph {
self.anchor
} else if self.anchor.paragraph > self.focus.paragraph {
self.focus
} else if self.anchor.char_offset <= self.focus.char_offset {
self.anchor
} else {
self.focus
}
}
pub fn end(&self) -> TextCursor {
if self.anchor.paragraph > self.focus.paragraph {
self.anchor
} else if self.anchor.paragraph < self.focus.paragraph {
self.focus
} else if self.anchor.char_offset >= self.focus.char_offset {
self.anchor
} else {
self.focus
}
}
}
/// Events that the text editor can emit for frontend synchronization
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u8)]
pub enum EditorEvent {
None = 0,
ContentChanged = 1,
SelectionChanged = 2,
NeedsLayout = 3,
}
/// FIXME: It should be better to get these constants from the frontend through the API.
const SELECTION_COLOR: Color = Color::from_argb(255, 0, 209, 184);
const CURSOR_WIDTH: f32 = 1.5;
const CURSOR_COLOR: Color = Color::BLACK;
const CURSOR_BLINK_INTERVAL_MS: f64 = 530.0;
pub struct TextEditorTheme {
pub selection_color: Color,
pub cursor_width: f32,
pub cursor_color: Color,
}
pub struct TextEditorState {
pub theme: TextEditorTheme,
pub selection: TextSelection,
pub is_active: bool,
pub active_shape_id: Option<Uuid>,
pub cursor_visible: bool,
pub last_blink_time: f64,
pending_events: Vec<EditorEvent>,
}
impl TextEditorState {
pub fn new() -> Self {
Self {
theme: TextEditorTheme {
selection_color: SELECTION_COLOR,
cursor_width: CURSOR_WIDTH,
cursor_color: CURSOR_COLOR,
},
selection: TextSelection::new(),
is_active: false,
active_shape_id: None,
cursor_visible: true,
last_blink_time: 0.0,
pending_events: Vec::new(),
}
}
pub fn start(&mut self, shape_id: Uuid) {
self.is_active = true;
self.active_shape_id = Some(shape_id);
self.cursor_visible = true;
self.last_blink_time = 0.0;
self.selection = TextSelection::new();
self.pending_events.clear();
}
pub fn stop(&mut self) {
self.is_active = false;
self.active_shape_id = None;
self.cursor_visible = false;
self.pending_events.clear();
}
pub fn set_caret_from_position(&mut self, position: TextPositionWithAffinity) {
let cursor = TextCursor::new(position.paragraph as usize, position.offset as usize);
self.selection.set_caret(cursor);
self.reset_blink();
self.push_event(EditorEvent::SelectionChanged);
}
pub fn extend_selection_from_position(&mut self, position: TextPositionWithAffinity) {
let cursor = TextCursor::new(position.paragraph as usize, position.offset as usize);
self.selection.extend_to(cursor);
self.reset_blink();
self.push_event(EditorEvent::SelectionChanged);
}
pub fn update_blink(&mut self, timestamp_ms: f64) {
if !self.is_active {
return;
}
if self.last_blink_time == 0.0 {
self.last_blink_time = timestamp_ms;
self.cursor_visible = true;
return;
}
let elapsed = timestamp_ms - self.last_blink_time;
if elapsed >= CURSOR_BLINK_INTERVAL_MS {
self.cursor_visible = !self.cursor_visible;
self.last_blink_time = timestamp_ms;
}
}
pub fn reset_blink(&mut self) {
self.cursor_visible = true;
self.last_blink_time = 0.0;
}
pub fn push_event(&mut self, event: EditorEvent) {
if self.pending_events.last() != Some(&event) {
self.pending_events.push(event);
}
}
pub fn poll_event(&mut self) -> EditorEvent {
self.pending_events.pop().unwrap_or(EditorEvent::None)
}
pub fn has_pending_events(&self) -> bool {
!self.pending_events.is_empty()
}
pub fn set_caret_position_from(
&mut self,
text_position_with_affinity: TextPositionWithAffinity,
) {
self.set_caret_from_position(text_position_with_affinity);
}
}
/// TODO: Remove legacy code
#[derive(Debug, PartialEq, Clone, Copy)]
pub struct TextNodePosition {
pub paragraph: i32,
@@ -15,89 +232,7 @@ impl TextNodePosition {
Self { paragraph, span }
}
#[allow(dead_code)]
pub fn is_invalid(&self) -> bool {
self.paragraph < 0 || self.span < 0
}
}
pub struct TextPosition {
node: Option<TextNodePosition>,
offset: i32,
}
impl TextPosition {
pub fn new() -> Self {
Self {
node: None,
offset: -1,
}
}
pub fn set(&mut self, node: Option<TextNodePosition>, offset: i32) {
self.node = node;
self.offset = offset;
}
}
pub struct TextSelection {
focus: TextPosition,
anchor: TextPosition,
}
impl TextSelection {
pub fn new() -> Self {
Self {
focus: TextPosition::new(),
anchor: TextPosition::new(),
}
}
#[allow(dead_code)]
pub fn is_caret(&self) -> bool {
self.focus.node == self.anchor.node && self.focus.offset == self.anchor.offset
}
#[allow(dead_code)]
pub fn is_selection(&self) -> bool {
!self.is_caret()
}
pub fn set_focus(&mut self, node: Option<TextNodePosition>, offset: i32) {
self.focus.set(node, offset);
}
pub fn set_anchor(&mut self, node: Option<TextNodePosition>, offset: i32) {
self.anchor.set(node, offset);
}
pub fn set(&mut self, node: Option<TextNodePosition>, offset: i32) {
self.set_focus(node, offset);
self.set_anchor(node, offset);
}
}
pub struct TextEditorState {
selection: TextSelection,
}
impl TextEditorState {
pub fn new() -> Self {
Self {
selection: TextSelection::new(),
}
}
pub fn set_caret_position_from(
&mut self,
text_position_with_affinity: TextPositionWithAffinity,
) {
self.selection.set(
Some(TextNodePosition::new(
text_position_with_affinity.paragraph,
text_position_with_affinity.span,
)),
text_position_with_affinity.offset,
);
}
}

View File

@@ -209,16 +209,19 @@ impl PendingTiles {
}
}
pub fn update(&mut self, tile_viewbox: &TileViewbox, surfaces: &Surfaces) {
self.list.clear();
let columns = tile_viewbox.interest_rect.width();
let rows = tile_viewbox.interest_rect.height();
// Generate tiles in spiral order from center
fn generate_spiral(rect: &TileRect) -> Vec<Tile> {
let columns = rect.width();
let rows = rect.height();
let total = columns * rows;
let mut cx = tile_viewbox.interest_rect.center_x();
let mut cy = tile_viewbox.interest_rect.center_y();
if total <= 0 {
return Vec::new();
}
let mut result = Vec::with_capacity(total as usize);
let mut cx = rect.center_x();
let mut cy = rect.center_y();
let ratio = (columns as f32 / rows as f32).ceil() as i32;
@@ -228,7 +231,7 @@ impl PendingTiles {
let mut direction = 0;
let mut current = 0;
self.list.push(Tile(cx, cy));
result.push(Tile(cx, cy));
while current < total {
match direction {
0 => cx += 1,
@@ -238,7 +241,7 @@ impl PendingTiles {
_ => unreachable!("Invalid direction"),
}
self.list.push(Tile(cx, cy));
result.push(Tile(cx, cy));
direction_current += 1;
let direction_total = if direction % 2 == 0 {
@@ -258,18 +261,44 @@ impl PendingTiles {
}
current += 1;
}
self.list.reverse();
result.reverse();
result
}
// Create a new list where the cached tiles go first
let iter1 = self
.list
.iter()
.filter(|t| surfaces.has_cached_tile_surface(**t));
let iter2 = self
.list
.iter()
.filter(|t| !surfaces.has_cached_tile_surface(**t));
self.list = iter1.chain(iter2).copied().collect();
pub fn update(&mut self, tile_viewbox: &TileViewbox, surfaces: &Surfaces) {
self.list.clear();
// Generate spiral for the interest area (viewport + margin)
let spiral = Self::generate_spiral(&tile_viewbox.interest_rect);
// Partition tiles into 4 priority groups (highest priority = processed last due to pop()):
// 1. visible + cached (fastest - just blit from cache)
// 2. visible + uncached (user sees these, render next)
// 3. interest + cached (pre-rendered area, blit from cache)
// 4. interest + uncached (lowest priority - background pre-render)
let mut visible_cached = Vec::new();
let mut visible_uncached = Vec::new();
let mut interest_cached = Vec::new();
let mut interest_uncached = Vec::new();
for tile in spiral {
let is_visible = tile_viewbox.visible_rect.contains(&tile);
let is_cached = surfaces.has_cached_tile_surface(tile);
match (is_visible, is_cached) {
(true, true) => visible_cached.push(tile),
(true, false) => visible_uncached.push(tile),
(false, true) => interest_cached.push(tile),
(false, false) => interest_uncached.push(tile),
}
}
// Build final list with lowest priority first (they get popped last)
// Order: interest_uncached, interest_cached, visible_uncached, visible_cached
self.list.extend(interest_uncached);
self.list.extend(interest_cached);
self.list.extend(visible_uncached);
self.list.extend(visible_cached);
}
pub fn pop(&mut self) -> Option<Tile> {

View File

@@ -9,3 +9,4 @@ pub mod shapes;
pub mod strokes;
pub mod svg_attrs;
pub mod text;
pub mod text_editor;

File diff suppressed because it is too large Load Diff