perf: limit composer selection tile repaints

This commit is contained in:
Tomás Romero committed 2026-09-30 22:11:27 +00:00
1 parent 95a4b8a6c6
commit 381e44088a
1 file changed
+310 -6
+310 -6
View File
@@ -374,7 +374,6 @@ impl CosmicComposeEditor {
self.editor
.set_cursor(if collapse_to_start { start } else { end });
self.editor.set_selection(Selection::None);
self.tiles.clear();
return Some(self.selection_from_editor());
} else {
self.editor.set_selection(Selection::None);
@@ -472,10 +471,10 @@ impl CosmicComposeEditor {
if self.rendered_selection == Some((selection.start, selection.end)) {
return;
}
let had_visible_selection = self
let previous = self
.rendered_selection
.is_some_and(|(start, end)| start != end);
let has_visible_selection = selection.start != selection.end;
.and_then(|offsets| self.selection_cursor_bounds(offsets));
let current = self.selection_cursor_bounds((selection.start, selection.end));
let (anchor_offset, cursor_offset) = if self.layout_preedit_text.is_empty() {
(selection.start, selection.end)
} else {
@@ -494,11 +493,79 @@ impl CosmicComposeEditor {
Selection::Normal(anchor)
});
self.rendered_selection = Some((selection.start, selection.end));
if had_visible_selection || has_visible_selection {
self.tiles.clear();
if previous.is_some() || current.is_some() {
self.invalidate_selection_tiles([previous, current]);
}
}
fn selection_cursor_bounds(&self, (start, end): (i32, i32)) -> Option<(Cursor, Cursor)> {
if start == end || !self.layout_preedit_text.is_empty() {
return None;
}
let start = global_to_cursor(&self.visual_text, start);
let end = global_to_cursor(&self.visual_text, end);
(start != end).then_some((start.min(end), start.max(end)))
}
fn invalidate_selection_tiles(&mut self, bounds: [Option<(Cursor, Cursor)>; 2]) {
let Some(layout) = self.layout_key else {
self.tiles.clear();
return;
};
let tile_height = (TILE_HEIGHT_LOGICAL * f32::from_bits(layout.scale_bits)).ceil();
let mut extents: [Option<(f32, f32)>; 2] = [None, None];
let last_line = bounds
.into_iter()
.flatten()
.map(|(_, end)| end.line)
.max()
.unwrap_or(0);
self.editor.with_buffer(|buffer| {
for run in buffer.layout_runs() {
if run.line_i > last_line {
break;
}
for (bound, extent) in bounds.into_iter().zip(extents.iter_mut()) {
let Some((start, end)) = bound else {
continue;
};
if run.line_i < start.line || run.line_i > end.line {
continue;
}
let top = run.line_top;
let bottom = run.line_top + run.line_height;
*extent = Some(match *extent {
Some((first, last)) => (first.min(top), last.max(bottom)),
None => (top, bottom),
});
}
}
});
if tile_height <= 0.0
|| !tile_height.is_finite()
|| bounds
.into_iter()
.zip(extents.iter())
.any(|(bound, extent)| bound.is_some() && extent.is_none())
|| extents
.iter()
.flatten()
.any(|(top, bottom)| !top.is_finite() || !bottom.is_finite())
{
self.tiles.clear();
return;
}
let ranges = extents.map(|extent| {
extent.map(|(top, bottom)| {
let first = ((top.max(0.0) / tile_height).floor() as u32).saturating_sub(1);
let last = ((bottom.max(0.0) / tile_height).floor() as u32).saturating_add(1);
first..=last
})
});
self.tiles
.retain(|index, _| !ranges.iter().flatten().any(|range| range.contains(index)));
}
fn selection_from_editor(&self) -> ComposeSelection {
let cursor = self
.visual_to_document_offset(cursor_to_global(&self.visual_text, self.editor.cursor()));
@@ -979,6 +1046,243 @@ mod tests {
assert_eq!(pixel, [100, 50, 25, 128]);
}
#[test]
fn selection_repaints_nearby_tiles_and_matches_full_render() {
let text = (0..160)
.map(|line| format!("Line {line} with some draft text\n"))
.collect::<String>();
let start = text.find("Line 95").unwrap() as i32;
let end = start + "Line 95 with".len() as i32;
let style = ComposeEditorStyle {
text: Color::from_rgb_u8(30, 30, 30),
link: Color::from_rgb_u8(0, 90, 180),
selection: Color::from_argb_u8(140, 60, 120, 220),
selected_text: Color::from_rgb_u8(255, 255, 255),
};
for scale in [1.0, 1.5, 2.0] {
let mut document = RichComposeDocument::default();
document.synchronize(&text, 0, 0);
let mut surface = CosmicComposeEditor::default();
let initial = surface.render(
&document,
document.selection(),
520.0,
900.0,
1600.0,
scale,
style,
"",
);
let selection = document.update_selection(start, end);
let updated =
surface.render(&document, selection, 520.0, 900.0, 1600.0, scale, style, "");
let mut fresh = CosmicComposeEditor::default();
let expected =
fresh.render(&document, selection, 520.0, 900.0, 1600.0, scale, style, "");
assert_eq!(initial.tiles.len(), updated.tiles.len());
assert_eq!(updated.tiles.len(), expected.tiles.len());
let mut preserved = 0;
let mut repainted = 0;
for ((before, after), full) in initial
.tiles
.iter()
.zip(&updated.tiles)
.zip(&expected.tiles)
{
assert_eq!(after.y, full.y);
let before_buffer = before.image.to_rgba8_premultiplied().unwrap();
let after_buffer = after.image.to_rgba8_premultiplied().unwrap();
let full_buffer = full.image.to_rgba8_premultiplied().unwrap();
assert_eq!(
after_buffer.as_bytes(),
full_buffer.as_bytes(),
"scale={scale}, tile_y={}",
after.y
);
if std::ptr::eq(before_buffer.as_bytes(), after_buffer.as_bytes()) {
preserved += 1;
} else {
repainted += 1;
}
}
assert!(
preserved > 0,
"scale={scale}: unrelated tiles were repainted"
);
assert!(
repainted > 0,
"scale={scale}: selection tiles were not repainted"
);
// The navigation handler changes Cosmic Text before the document
// selection is published back to the surface.
let next = surface
.handle_navigation(
&document, selection, 520.0, scale, style, "", "\u{f702}", false, false, false,
false,
)
.unwrap();
let collapsed = document.update_selection(next.start, next.end);
let after_collapse =
surface.render(&document, collapsed, 520.0, 900.0, 1600.0, scale, style, "");
let mut fresh = CosmicComposeEditor::default();
let full_collapse =
fresh.render(&document, collapsed, 520.0, 900.0, 1600.0, scale, style, "");
let mut preserved_after_collapse = 0;
for (after, full) in after_collapse.tiles.iter().zip(&full_collapse.tiles) {
assert_eq!(after.y, full.y);
assert_eq!(
after.image.to_rgba8_premultiplied().unwrap().as_bytes(),
full.image.to_rgba8_premultiplied().unwrap().as_bytes(),
"scale={scale}, collapsed tile_y={}",
after.y
);
}
for (before, after) in updated.tiles.iter().zip(&after_collapse.tiles) {
let before = before.image.to_rgba8_premultiplied().unwrap();
let after = after.image.to_rgba8_premultiplied().unwrap();
if std::ptr::eq(before.as_bytes(), after.as_bytes()) {
preserved_after_collapse += 1;
}
}
assert!(
preserved_after_collapse > 0,
"scale={scale}: collapse cleared all tiles"
);
let far_end = text.find("Line 120").unwrap() as i32 + "Line 120 with".len() as i32;
let reverse_multiline = document.update_selection(far_end, start);
let after_drag = surface.render(
&document,
reverse_multiline,
520.0,
900.0,
1600.0,
scale,
style,
"",
);
let mut fresh = CosmicComposeEditor::default();
let full_drag = fresh.render(
&document,
reverse_multiline,
520.0,
900.0,
1600.0,
scale,
style,
"",
);
for (after, full) in after_drag.tiles.iter().zip(&full_drag.tiles) {
assert_eq!(after.y, full.y);
assert_eq!(
after.image.to_rgba8_premultiplied().unwrap().as_bytes(),
full.image.to_rgba8_premultiplied().unwrap().as_bytes(),
"scale={scale}, drag tile_y={}",
after.y
);
}
}
}
#[test]
fn wrapped_selection_tiles_match_full_render() {
let text = "wideword ".repeat(450);
let style = ComposeEditorStyle {
text: Color::from_rgb_u8(30, 30, 30),
link: Color::from_rgb_u8(0, 90, 180),
selection: Color::from_argb_u8(140, 60, 120, 220),
selected_text: Color::from_rgb_u8(255, 255, 255),
};
for scale in [1.0, 2.0] {
let mut document = RichComposeDocument::default();
document.synchronize(&text, 0, 0);
let mut surface = CosmicComposeEditor::default();
surface.render(
&document,
document.selection(),
280.0,
600.0,
350.0,
scale,
style,
"",
);
let selection = document.update_selection(1500, 1600);
let updated =
surface.render(&document, selection, 280.0, 600.0, 350.0, scale, style, "");
let mut fresh = CosmicComposeEditor::default();
let expected =
fresh.render(&document, selection, 280.0, 600.0, 350.0, scale, style, "");
assert_eq!(updated.tiles.len(), expected.tiles.len());
for (after, full) in updated.tiles.iter().zip(&expected.tiles) {
assert_eq!(after.y, full.y);
assert_eq!(
after.image.to_rgba8_premultiplied().unwrap().as_bytes(),
full.image.to_rgba8_premultiplied().unwrap().as_bytes(),
"scale={scale}, wrapped tile_y={}",
after.y
);
}
}
}
#[test]
fn mixed_direction_selection_and_preedit_tiles_match_full_render() {
let text = (0..50)
.map(|line| format!("Row {line}: café e\u{301} العربية 👩‍💻 text that wraps\n"))
.collect::<String>();
let start = text.find("Row 6:").unwrap() as i32;
let end = text.find("Row 12:").unwrap() as i32;
let style = ComposeEditorStyle {
text: Color::from_rgb_u8(30, 30, 30),
link: Color::from_rgb_u8(0, 90, 180),
selection: Color::from_argb_u8(140, 60, 120, 220),
selected_text: Color::from_rgb_u8(255, 255, 255),
};
for scale in [1.0, 1.5, 2.0] {
let mut document = RichComposeDocument::default();
document.synchronize(&text, 0, 0);
let mut surface = CosmicComposeEditor::default();
surface.render(
&document,
document.selection(),
280.0,
700.0,
128.0,
scale,
style,
"",
);
for (anchor, cursor, preedit) in [
(start, end, ""),
(end, start, ""),
(start, start, ""),
(start, start, "世界"),
(start, end, ""),
(end, end, ""),
] {
let selection = document.update_selection(anchor, cursor);
let actual = surface.render(
&document, selection, 280.0, 700.0, 128.0, scale, style, preedit,
);
let expected = CosmicComposeEditor::default().render(
&document, selection, 280.0, 700.0, 128.0, scale, style, preedit,
);
assert_eq!(actual.tiles.len(), expected.tiles.len());
for (actual, expected) in actual.tiles.iter().zip(&expected.tiles) {
assert_eq!(actual.y, expected.y);
assert_eq!(
actual.image.to_rgba8_premultiplied().unwrap().as_bytes(),
expected.image.to_rgba8_premultiplied().unwrap().as_bytes(),
"scale={scale} anchor={anchor} cursor={cursor} preedit={preedit:?} tile_y={}",
actual.y,
);
}
}
}
}
#[test]
fn combined_styles_render_without_changing_caret_text_offsets() {
let mut document = RichComposeDocument::default();