Skip to content

Commit 8fbaf9f

Browse files
committed
feat: improve text selection performance and safety
1 parent c4c95bf commit 8fbaf9f

5 files changed

Lines changed: 133 additions & 22 deletions

File tree

litehtml-sys/csrc/litehtml_c.cpp

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1260,6 +1260,30 @@ void lh_element_get_inline_box_at(lh_element_t* el, int index, lh_position_t* po
12601260
*pos = to_c(box);
12611261
}
12621262

1263+
void lh_element_get_inline_boxes(lh_element_t* el, lh_inline_box_callback cb, void* ctx)
1264+
{
1265+
if (!el || !cb) return;
1266+
auto* elem = reinterpret_cast<litehtml::element*>(el);
1267+
auto ri = elem->get_render_item();
1268+
if (!ri) return;
1269+
1270+
litehtml::position::vector boxes;
1271+
ri->get_inline_boxes(boxes);
1272+
if (boxes.empty()) return;
1273+
1274+
float ox, oy;
1275+
compute_ri_offset(ri, ox, oy);
1276+
1277+
for (const auto& box_ : boxes)
1278+
{
1279+
litehtml::position abs = box_;
1280+
abs.x += ox;
1281+
abs.y += oy;
1282+
lh_position_t pos = to_c(abs);
1283+
cb(&pos, ctx);
1284+
}
1285+
}
1286+
12631287
int lh_element_get_text_align(lh_element_t* el)
12641288
{
12651289
if (!el) return 0;

litehtml-sys/csrc/litehtml_c.h

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -376,6 +376,11 @@ int lh_element_get_inline_boxes_count(lh_element_t* el);
376376
/* Get the i-th inline box in absolute document coordinates. */
377377
void lh_element_get_inline_box_at(lh_element_t* el, int index, lh_position_t* pos);
378378

379+
/* Get all inline boxes in one call via callback. Avoids recomputing boxes N+1 times.
380+
The callback receives each box in absolute document coordinates plus a user context. */
381+
typedef void (*lh_inline_box_callback)(const lh_position_t* pos, void* ctx);
382+
void lh_element_get_inline_boxes(lh_element_t* el, lh_inline_box_callback cb, void* ctx);
383+
379384
/* Get the computed text-align value (0=left, 1=right, 2=center, 3=justify). */
380385
int lh_element_get_text_align(lh_element_t* el);
381386

litehtml/examples/render.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ fn main() {
8080
}
8181
};
8282

83-
let mut selection = Selection::new();
83+
let mut selection = Selection::for_document(&doc);
8484
let mut selection_rects: Vec<Position> = Vec::new();
8585
let mut mouse_was_down = false;
8686
let mut drag_origin: Option<(f32, f32)> = None;

litehtml/src/lib.rs

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1788,9 +1788,30 @@ impl<'a> Element<'a> {
17881788
}
17891789

17901790
/// All per-line inline boxes in absolute document coordinates.
1791+
///
1792+
/// Uses a single C call with a callback to avoid recomputing boxes N+1 times
1793+
/// (which the count+index pattern would do).
17911794
pub fn inline_boxes(&self) -> Vec<Position> {
1792-
let count = self.inline_boxes_count();
1793-
(0..count).filter_map(|i| self.inline_box_at(i)).collect()
1795+
unsafe extern "C" fn collect_box(
1796+
pos: *const sys::lh_position_t,
1797+
ctx: *mut std::ffi::c_void,
1798+
) {
1799+
if pos.is_null() || ctx.is_null() {
1800+
return;
1801+
}
1802+
let out = &mut *(ctx as *mut Vec<Position>);
1803+
out.push(Position::from(*pos));
1804+
}
1805+
1806+
let mut result: Vec<Position> = Vec::new();
1807+
unsafe {
1808+
sys::lh_element_get_inline_boxes(
1809+
self.ptr,
1810+
Some(collect_box),
1811+
&mut result as *mut Vec<Position> as *mut std::ffi::c_void,
1812+
);
1813+
}
1814+
result
17941815
}
17951816

17961817
/// Computed text-align: 0=left, 1=right, 2=center, 3=justify.

litehtml/src/selection.rs

Lines changed: 80 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -53,27 +53,50 @@ impl SelectionEndpoint {
5353
}
5454
}
5555

56+
/// Cached result of a document-order comparison between two elements.
57+
#[derive(Clone)]
58+
struct OrderCache {
59+
a: *mut crate::sys::lh_element_t,
60+
b: *mut crate::sys::lh_element_t,
61+
a_before_b: bool,
62+
}
63+
5664
/// Text selection state for a litehtml document.
5765
///
58-
/// Holds the start and end endpoints of a selection, plus cached highlight
59-
/// rectangles. Endpoints contain raw element pointers valid while the parent
60-
/// `Document` is alive — the caller must ensure the document outlives this struct.
61-
pub struct Selection {
66+
/// The `'doc` lifetime ties this selection to its parent [`Document`], preventing
67+
/// use-after-free if the document is dropped while the selection holds element
68+
/// pointers. Use [`Selection::for_document`] to create a lifetime-bound selection.
69+
pub struct Selection<'doc> {
6270
start: Option<SelectionEndpoint>,
6371
end: Option<SelectionEndpoint>,
6472
rectangles: Vec<Position>,
73+
order_cache: Option<OrderCache>,
74+
_doc: PhantomData<&'doc ()>,
6575
}
6676

67-
impl Selection {
68-
/// Create an empty (inactive) selection.
77+
impl<'doc> Selection<'doc> {
78+
/// Create an empty (inactive) selection without lifetime enforcement.
79+
///
80+
/// Prefer [`Selection::for_document`] to tie the selection lifetime to
81+
/// a specific document, preventing use-after-free at compile time.
6982
pub fn new() -> Self {
7083
Self {
7184
start: None,
7285
end: None,
7386
rectangles: Vec::new(),
87+
order_cache: None,
88+
_doc: PhantomData,
7489
}
7590
}
7691

92+
/// Create an empty selection tied to a document's lifetime.
93+
///
94+
/// The returned `Selection` cannot outlive `doc`, enforced by the compiler.
95+
/// The document is NOT borrowed persistently — only the lifetime is captured.
96+
pub fn for_document(_doc: &'doc Document<'_>) -> Self {
97+
Self::new()
98+
}
99+
77100
/// Begin a selection at document coordinates `(x, y)`.
78101
///
79102
/// `measure_text` should return the pixel width of a string rendered with
@@ -119,6 +142,7 @@ impl Selection {
119142
self.start = None;
120143
self.end = None;
121144
self.rectangles.clear();
145+
self.order_cache = None;
122146
}
123147

124148
/// Returns `true` if there is an active selection with both start and end.
@@ -133,8 +157,8 @@ impl Selection {
133157
let start = self.start.as_ref()?;
134158
let end = self.end.as_ref()?;
135159

136-
// Normalize into document order
137-
let (first, second) = normalize_endpoints(start, end);
160+
// Normalize into document order (use cache if available)
161+
let (first, second) = normalize_endpoints(start, end, &self.order_cache);
138162
let first_el = first.element();
139163
let second_el = second.element();
140164

@@ -183,8 +207,24 @@ impl Selection {
183207
_ => return,
184208
};
185209

210+
// Update order cache if endpoints changed
211+
if start.element != end.element {
212+
let needs_update = self
213+
.order_cache
214+
.as_ref()
215+
.map_or(true, |c| c.a != start.element || c.b != end.element);
216+
if needs_update {
217+
let a_before_b = is_before(&start.element(), &end.element());
218+
self.order_cache = Some(OrderCache {
219+
a: start.element,
220+
b: end.element,
221+
a_before_b,
222+
});
223+
}
224+
}
225+
186226
// Normalize into document order
187-
let (first, second) = normalize_endpoints(start, end);
227+
let (first, second) = normalize_endpoints(start, end, &self.order_cache);
188228

189229
if first.element == second.element {
190230
let el = first.element();
@@ -234,7 +274,7 @@ impl Selection {
234274
}
235275
}
236276

237-
impl Default for Selection {
277+
impl Default for Selection<'_> {
238278
fn default() -> Self {
239279
Self::new()
240280
}
@@ -261,20 +301,29 @@ fn is_before(a: &Element<'_>, b: &Element<'_>) -> bool {
261301
}
262302

263303
/// Normalize user-order endpoints into document order: returns (first, second).
304+
///
305+
/// Uses the cached order result when available to avoid repeated DOM walks.
264306
fn normalize_endpoints<'a>(
265307
a: &'a SelectionEndpoint,
266308
b: &'a SelectionEndpoint,
309+
cache: &Option<OrderCache>,
267310
) -> (&'a SelectionEndpoint, &'a SelectionEndpoint) {
268311
if a.element == b.element {
269312
if a.char_index <= b.char_index {
270313
(a, b)
271314
} else {
272315
(b, a)
273316
}
274-
} else if is_before(&a.element(), &b.element()) {
275-
(a, b)
276317
} else {
277-
(b, a)
318+
let a_before_b = cache
319+
.as_ref()
320+
.filter(|c| c.a == a.element && c.b == b.element)
321+
.map_or_else(|| is_before(&a.element(), &b.element()), |c| c.a_before_b);
322+
if a_before_b {
323+
(a, b)
324+
} else {
325+
(b, a)
326+
}
278327
}
279328
}
280329

@@ -346,6 +395,8 @@ fn hit_test_char(
346395

347396
/// Find which character index corresponds to pixel offset `target_x` within
348397
/// the given text rendered with `font`.
398+
///
399+
/// Builds the prefix string incrementally to avoid O(n) allocations per call.
349400
fn find_char_at_x(
350401
measure_text: &MeasureTextFn<'_>,
351402
text: &str,
@@ -356,21 +407,23 @@ fn find_char_at_x(
356407
return 0;
357408
}
358409

359-
let chars: Vec<char> = text.chars().collect();
410+
let mut prefix = String::with_capacity(text.len());
360411
let mut prev_width = 0.0f32;
412+
let mut count = 0;
361413

362-
for i in 0..chars.len() {
363-
let prefix: String = chars[..=i].iter().collect();
414+
for ch in text.chars() {
415+
prefix.push(ch);
416+
count += 1;
364417
let width = measure_text(&prefix, font);
365418
let midpoint = (prev_width + width) / 2.0;
366419

367420
if target_x < midpoint {
368-
return i;
421+
return count - 1;
369422
}
370423
prev_width = width;
371424
}
372425

373-
chars.len()
426+
count
374427
}
375428

376429
// ---------------------------------------------------------------------------
@@ -482,12 +535,18 @@ fn closest_text_leaf<'a>(el: &Element<'a>, target_x: f32, target_y: f32) -> Opti
482535
.map(|(el, _)| el)
483536
}
484537

538+
/// Maximum ancestor levels to traverse before giving up.
539+
/// Prevents infinite loops on malformed DOMs.
540+
const MAX_TREE_DEPTH: usize = 256;
541+
485542
/// Walk to the next text leaf after `el`, stopping before `stop`.
486543
/// Walks up to the parent, then to the next sibling, then descends.
544+
/// Gives up after [`MAX_TREE_DEPTH`] ancestor levels to guard against
545+
/// malformed or extremely deep DOMs.
487546
fn next_text_leaf<'a>(el: &Element<'a>, stop: &Element<'a>) -> Option<Element<'a>> {
488547
let mut current_ptr = el.as_ptr();
489548

490-
loop {
549+
for _ in 0..MAX_TREE_DEPTH {
491550
let current = Element {
492551
ptr: current_ptr,
493552
_phantom: PhantomData,
@@ -525,6 +584,8 @@ fn next_text_leaf<'a>(el: &Element<'a>, stop: &Element<'a>) -> Option<Element<'a
525584
// No more siblings at this level, walk up
526585
current_ptr = parent.as_ptr();
527586
}
587+
588+
None
528589
}
529590

530591
// ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)