Add opt-in deferred layout rebuilds to PlainEditor - #729
Conversation
1b59d68 to
e39e113
Compare
|
For triage, when you say:
Do you have an actual use case in an application you're developing where this is becoming a bottleneck? This change is somewhat complicated to reason about, although my gut reaction is that the core conceit is correct (that the editing operations which mutate the text based on externally provided indices don't need a fully-updated text). It seems like it would be easy to get this wrong, and having two code paths here is concerning to me. It isn't clear to me what advantage you think there is in having this mode be optional... As we start to be more careful about Grapheme clusters (see e.g. #715), this who editing code is likely to change somewhat. As such, I'd be inclined to defer this until more stuff in that direction lands. |
e39e113 to
38923da
Compare
Every mutation marks the layout dirty and places provisional byte-offset cursors, which the next rebuild's selection refresh snaps to cluster boundaries. With deferral off (the default) every operation flushes the rebuild before returning, preserving existing behavior; `set_defer_layout(true)` skips that flush so a batch of edits performs a single rebuild at the next read. Cluster/word-boundary deletes refresh the layout before reading it, which also closes a latent stale-read when they follow `set_text`.
38923da to
e18c8a4
Compare
|
Yes, I have a real app and need for this shape - not necessarily on its own, but it becomes essential when paired with #730 to make text and styles atomic. The reason for having it optional is just to not change any existing code/behavior while adding a way to achieve atomic text+styles updates. However I have now reshaped the PR to use the deferred mode as the only code path and made eager mode a simple wrapper for deferred+flush |
|
It is helpful context that you believe the styling change is incoherent without this (personally, I can't really see why that would be the case, but that's by-the-by.
Changing the formulation to a different once with not at all clear semantic differences (is there any semantic difference? If not, why do it?) without any explanation of what that helps with does not make for a good review experience. I'm quite quickly reaching the end of my patience to review this change. My overall feedback (echoing that which I know you've received in Vello...) is that:
I think that we're likely to change our LLM policy to disallow LLM-generated PR descriptions. |
|
I'm sorry my PRs cause too much review burden, this is not my intention. I thought it's clear that my latest change addresses your My PRs are driven by real world usage and I'm just trying to add something useful to the libraries I use. I'm always trying to keep the changes minimal to avoid large refactors or behavior changes, which sometimes is good, and sometimes not desirable. However, I can't know which approach is best for every change because I don't have the full mental model of the entire codebase and the goals and roadmaps of the maintainers. Even when we remove the LLM from the picture, adding more human thought is not always possible without knowing the entire project end-to-end and its history and roadmap and refactor plans. I'm always reviewing the PRs myself before opening and I do understand these changes, and they are also always validated in my app before opening. I'm happy to change any of this according to your feedback and your vision, but I can't possibly know everything beforehand. The reason for making it optional was to avoid a refactor of public API, but I see now that was not a good reason. Anyway, concrete answers for your questions:
Example: text is
I'm not opening a new PR to not create more burden, but I believe changing it like that would be the proper way to handle this. It changes the public API, but looks like this is what you prefer. Let me know if you want to see this as a separate PR, and base 729 on top of it. diff --git a/parley/src/editing/editor.rs b/parley/src/editing/editor.rs
--- a/parley/src/editing/editor.rs
+++ b/parley/src/editing/editor.rs
@@ -797,6 +797,41 @@ (PlainEditorDriver, after `layout()`)
+ /// Get rectangles, and their corresponding line indices, representing the selected portions of
+ /// text, refreshing the layout as needed.
+ pub fn selection_geometry(&mut self) -> Vec<(BoundingBox, usize)> {
+ self.refresh_layout();
+ // We do not check `show_cursor` here, as the IME handling code collapses the
+ // selection to a caret in that case.
+ self.editor.selection.geometry(&self.editor.layout)
+ }
+
+ /// Invoke a callback with each rectangle representing the selected portions of text, and the
+ /// indices of the lines to which they belong, refreshing the layout as needed.
+ pub fn selection_geometry_with(&mut self, f: impl FnMut(BoundingBox, usize)) {
+ self.refresh_layout();
+ // We do not check `show_cursor` here, as the IME handling code collapses the
+ // selection to a caret in that case.
+ self.editor.selection.geometry_with(&self.editor.layout, f);
+ }
+
+ /// Get a rectangle representing the current caret cursor position, refreshing the layout as
+ /// needed.
+ ///
+ /// There is not always a caret. For example, the IME may have indicated the caret should be
+ /// hidden.
+ pub fn cursor_geometry(&mut self, size: f32) -> Option<BoundingBox> {
+ self.refresh_layout();
+ self.editor.cursor_geometry(size)
+ }
+
+ /// Get a rectangle bounding the text the user is currently editing, refreshing the layout as
+ /// needed (see [`PlainEditor::ime_cursor_area`]).
+ pub fn ime_cursor_area(&mut self) -> BoundingBox {
+ self.refresh_layout();
+ self.editor.ime_cursor_area_unchecked()
+ }
+
// --- MARK: Internal helpers---
@@ -858,26 +893,43 @@ (PlainEditor readers)
/// Get rectangles, and their corresponding line indices, representing the selected portions of
/// text.
- pub fn selection_geometry(&self) -> Vec<(BoundingBox, usize)> {
+ ///
+ /// Returns `None` while the layout is dirty, as the geometry would be computed against a
+ /// stale layout; refresh the layout first, or use
+ /// [the driver's equivalent](PlainEditorDriver::selection_geometry).
+ pub fn selection_geometry(&self) -> Option<Vec<(BoundingBox, usize)>> {
+ if self.layout_dirty {
+ return None;
+ }
// We do not check `self.show_cursor` here, as the IME handling code collapses the
// selection to a caret in that case.
- self.selection.geometry(&self.layout)
+ Some(self.selection.geometry(&self.layout))
}
/// Invoke a callback with each rectangle representing the selected portions of text, and the
/// indices of the lines to which they belong.
- pub fn selection_geometry_with(&self, f: impl FnMut(BoundingBox, usize)) {
+ ///
+ /// Returns `false` without invoking the callback while the layout is dirty, as the geometry
+ /// would be computed against a stale layout; refresh the layout first, or use
+ /// [the driver's equivalent](PlainEditorDriver::selection_geometry_with).
+ pub fn selection_geometry_with(&self, f: impl FnMut(BoundingBox, usize)) -> bool {
+ if self.layout_dirty {
+ return false;
+ }
// We do not check `self.show_cursor` here, as the IME handling code collapses the
// selection to a caret in that case.
self.selection.geometry_with(&self.layout, f);
+ true
}
/// Get a rectangle representing the current caret cursor position.
///
/// There is not always a caret. For example, the IME may have indicated the caret should be
- /// hidden.
+ /// hidden. `None` is also returned while the layout is dirty, as the geometry would be
+ /// computed against a stale layout; refresh the layout first, or use
+ /// [the driver's equivalent](PlainEditorDriver::cursor_geometry).
pub fn cursor_geometry(&self, size: f32) -> Option<BoundingBox> {
- self.show_cursor
+ (!self.layout_dirty && self.show_cursor)
.then(|| self.selection.focus().geometry(&self.layout, size))
}
@@ -886,7 +938,17 @@
/// This is useful for suggesting an exclusion area to the platform for, e.g., IME candidate
/// box placement. This bounds the area of the preedit text if present, otherwise it bounds the
/// selection on the focused line.
- pub fn ime_cursor_area(&self) -> BoundingBox {
+ ///
+ /// Returns `None` while the layout is dirty, as the geometry would be computed against a
+ /// stale layout; refresh the layout first, or use
+ /// [the driver's equivalent](PlainEditorDriver::ime_cursor_area).
+ pub fn ime_cursor_area(&self) -> Option<BoundingBox> {
+ (!self.layout_dirty).then(|| self.ime_cursor_area_unchecked())
+ }
+
+ /// Get a rectangle bounding the text the user is currently editing, assuming that the layout
+ /// is valid (see [`ime_cursor_area`](Self::ime_cursor_area)).
+ fn ime_cursor_area_unchecked(&self) -> BoundingBox {
let (area, focus) = if let Some(preedit_range) = &self.compose {If there's anything else I can do to make your job easier, please let me know. I'm also happy to talk directly on Zulip if needed. |
I don't have an understanding of an app setup where this could happen. At the point where you do the edit, you have an exclusive reference to the editor, so it isn't clear to me why you can't just do the edit, then apply the recomputed styles immediately. Like, I do agree that having an extra rebuild in that case is not ideal, but you seem to be treating this PR as if it's semantically a blocking concern, which it isn't clear to me that this is. Ultimately, what's happened here is that:
Additionally, all of this context is hidden within a lot of "filler" content from the LLM (I don't care to know that "Default behavior is unchanged." as the top level thing, I need to know why you thought that behaviour would be changed, and why you evaluated that not changing that behaviour was more important than the added complexity). Ultimately, I do agree that we need to make changes here, but unfortunately I don't think it's a suitable use of my time for me to be the one digging into that. But I hope you recognise that there are ways that you could have packaged this PR/bug you have identified which would have taken much less time to interact with. The actual important part of this which needs reckoning with is "how do we handle selection_geometry et al. requiring an up-to-date layout"; if you'd made that the focus of the PR description, and hadn't made the decision to mask the issue by making this optional, it's likely that I would have had time to give the quick answer (we need to change the API, probably to just match |
LLMs aside, this is surprising to me, as it is an important thing for me to know, and I would write this myself in 100% human written PR as well. From my personal experience of writing software for the past 20 years, any behavior or API changes are at very top of the things I care about, because once enough people use certain API or rely on behavior - any change to it requires careful consideration. In my view "Default behavior is unchanged" is something that makes the review easier, not harder. But of course, personal opinions vary and that's fine. In any case, I agree that this PR was badly executed and I will make sure to create better ones in the future. I just feel like the communication here could be better, instead of arguing over LLM. Would it at least make you feel better about your time to know that I've been supporting linebender by sponsoring Raph $200/mo since 2022? I know this doesn't entitle me to any kind of support, but at least it shows I care about the work you're doing. |
|
Thank you for your support to Raph (I also recognise a lot of other important faces in your GitHub sponsors - thank you for those as well). My choice of focusing on 'default behaviour is unchanged' was an attempt to highlight that the actual important part is 'what behaviour changes if the option is enabled'. For a refactor PR, something like 'behaviour is unchanged' definitely is helpful. |
Every mutating
PlainEditoroperation (insert, delete, IME compose/commit) rebuilds the full layout before returning. Hosts that apply several operations in one frame — replaying a batch of input events, multi-step IME commits, programmatic multi-part edits — pay one full relayout per operation, even though only the final layout is ever read or drawn.This PR adds an opt-in mode that defers the rebuild to the next read:
delete,delete_word,backdelete,backdelete_word— cluster and word boundaries) now callrefresh_layout()first. Besides making them correct mid-batch, this closes a latent stale-read: today these methods read the old layout if called right afterset_text.try_layout()returningNonewhile dirty,&selfgeometry readers (cursor_geometry,selection_geometry,ime_cursor_area) returning last-built geometry mid-batch, and theGenerationnudge moving to rebuild time are the observable differences — all documented onset_defer_layout— which is why the mode is opt-in rather than the new default.Tests cover: a batch performing a single rebuild with correct text and selection, an emoji-cluster backspace mid-batch (fresh-layout boundary read), provisional-cursor clamping for mid-cluster IME offsets, byte-wise deletes matching eager mode, and a mixed op sequence (moves, word deletes, IME compose/commit) asserting eager and deferred editors end in identical states.
This PR was generated by Claude