mirror of
https://github.com/penpot/penpot.git
synced 2026-02-12 14:42:56 +00:00
Merge remote-tracking branch 'origin/staging-render' into develop
This commit is contained in:
217
render-wasm/docs/text_editor.md
Normal file
217
render-wasm/docs/text_editor.md
Normal 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
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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(¤t_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();
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
240
render-wasm/src/render/text_editor.rs
Normal file
240
render-wasm/src/render/text_editor.rs
Normal 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 = ¶graphs[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 = ¶graphs[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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -9,3 +9,4 @@ pub mod shapes;
|
||||
pub mod strokes;
|
||||
pub mod svg_attrs;
|
||||
pub mod text;
|
||||
pub mod text_editor;
|
||||
|
||||
1341
render-wasm/src/wasm/text_editor.rs
Normal file
1341
render-wasm/src/wasm/text_editor.rs
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user