Skip to content

Improve layout performance of folded paragraphs#1318

Open
Col-E wants to merge 4 commits into
FXMisc:masterfrom
Col-E:master
Open

Improve layout performance of folded paragraphs#1318
Col-E wants to merge 4 commits into
FXMisc:masterfrom
Col-E:master

Conversation

@Col-E

@Col-E Col-E commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Addressing #1317 - Description + video in the linked issue.

Comment thread richtextfx/src/main/java/org/fxmisc/richtext/GenericStyledArea.java
Comment thread richtextfx/src/main/java/org/fxmisc/richtext/GenericStyledArea.java Outdated
private int getVirtualParagraphIndex(int allParagraphIndex) {
int virtualIndex = virtualFlowParagraphs.getViewIndex(allParagraphIndex);
if ( virtualIndex < 0 ) {
throw new IllegalArgumentException("Paragraph " + allParagraphIndex + " is folded");

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's a bit radical to throw an exception in that case, ins't there a better way?

@Col-E Col-E Jul 17, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Its what I saw a number of other places doing for negative values (like in Paragraph for negative positions).

I don't know if there's a great alternative. Clamping to 0 seems like it could lead to silent unintended behavior. Using Optional seems like it would just kick the issue to the call site plus is pointless object allocation / wrapping. In some places defaulting to 0 may work, but in others it may be wrong.

Also this method is used by getCell(int index) which is effectively @NonNull. We have to provide some valid value here that maps to a realized cell.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can make visibleParToAllParIndex and allParToVisibleParIndex not throw, still don't think I can here.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was wrong on this one, negative index is an invalid input, and it is right to throw it there and elsewhere. You cant ignore my comment (it's like indexing a non-existing index in an array, it's not allowed).

Comment thread richtextfx/src/main/java/org/fxmisc/richtext/GenericStyledArea.java
Comment thread richtextfx/src/main/java/org/fxmisc/richtext/GenericStyledArea.java
Comment thread richtextfx/src/main/java/org/fxmisc/richtext/GenericStyledArea.java
Comment thread richtextfx/src/main/java/org/fxmisc/richtext/GenericStyledArea.java
Comment thread richtextfx/src/main/java/org/fxmisc/richtext/GenericStyledArea.java Outdated

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lots of public methods were modified but not tested (for instance lineIndex, but that is not the only one).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pushed up a number of additional tests for folding, lmk if there's anything I missed

@Symeon94 Symeon94 Jul 18, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Your change touches a lot of some central class, so I hope you'll forgive me for insisting on this test part. I have listed many methods which are impacted and which it would be proper to test the behaviour in the circumstances of a fold.
Here is the list I have (I removed those methods which I have seen covered already):

  • removeSelection(Selection)
  • hit(double, double)
  • lineIndex(int, int)
  • getCharacterBoundsOnScreen(int, int)
  • getParagraphBoundsOnScreen(int)
  • getCaretBoundsOnScreen(T)
  • showParagraphInViewport(int)
  • showParagraphAtBottom(int)
  • showParagraphRegion(int, Bounds)
  • showParagraphAtCenter(int)
  • getCurrentLineStartInParargraph()
  • getCurrentLineEndInParargraph()
  • caretSelectionBind.paragraphIndexProperty().addListener( this::skipOverFoldedParagraphs ); (this one is impacted, but it's through private method).
  • double computePrefHeight(double) (that's a protected one)
  • updateIndex(int)
  • next/previous lines move is impacted by private method changes
  • getSelectionBoundsOnScreen is used in defining the bounds, not sure it can be tested.

Not sure all of them can be easily tested, but some are simpler to cover.

@Col-E Col-E Jul 19, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pushed up some additional tests, I believe everything is now covered directly or indirectly in cases like computePrefHeight and skipOverFoldedParagraphs

Method Covering test
removeSelection(Selection) MultipleCaretSelectionTests.removing_selection_spanning_folded_paragraph_works
hit(double, double) FoldedParagraphTests.hit_on_visible_paragraph_after_folded_paragraph_uses_source_index
lineIndex(int, int) FoldedParagraphTests.line_index_works_for_visible_paragraph_and_rejects_folded_paragraph
getCharacterBoundsOnScreen(int, int) FoldedParagraphTests.character_bounds_are_empty_for_folded_paragraph
getParagraphBoundsOnScreen(int) FoldedParagraphTests.paragraph_bounds_are_empty_for_folded_paragraph_and_present_for_visible_paragraph
getCaretBoundsOnScreen(T) FoldedParagraphTests.caret_bounds_are_empty_for_caret_in_folded_paragraph
showParagraphInViewport(int) FoldedParagraphTests.show_paragraph_in_viewport_resolves_a_folded_target_to_a_visible_paragraph
showParagraphAtBottom(int) ParagraphIndexMappingTests.showing_a_folded_paragraph_at_the_end_uses_the_last_visible_paragraph
showParagraphRegion(int, Bounds) FoldedParagraphTests.show_paragraph_region_resolves_a_folded_target_to_a_visible_paragraph
showParagraphAtCenter(int) FoldedParagraphTests.show_paragraph_at_center_resolves_a_folded_target_to_a_visible_paragraph
getCurrentLineStartInParargraph() FoldedParagraphTests.line_index_works_for_visible_paragraph_and_rejects_folded_paragraph
getCurrentLineEndInParargraph() FoldedParagraphTests.line_index_works_for_visible_paragraph_and_rejects_folded_paragraph
skipOverFoldedParagraphs listener FoldedParagraphTests.navigation_skips_folded_paragraphs_in_both_directions
computePrefHeight(double) FoldedParagraphTests.auto_height_excludes_folded_paragraphs
updateIndex(int) Indirectly covered by FoldedParagraphTests.hit_on_visible_paragraph_after_folded_paragraph_uses_source_index and the paragraph-index mapping tests
Next/previous over folded paragraphs FoldedParagraphTests.navigation_skips_folded_paragraphs_in_both_directions
getSelectionBoundsOnScreen Indirectly covered by MultipleCaretSelectionTests.removing_selection_spanning_folded_paragraph_works, via selection.getSelectionBounds()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for taking the time to implement additional tests.

I have a comment before reviewing the changes fully, it came to me while looking at the first test from the new test class. It caught my attention, for the new code is throwing an exception if area.linedIndex(1,0) is called on folded paragraph. It appeared to me that it could depart from the existing behaviour. I therefor tried to run the new test class on the existing code and 7 out of 11 new tests failed on existing code (I didn’t try the ones from the other classes). This likely means the modifications changed the library’s behaviour. Someone could, for instance, be calling a lineIndex on a folded paragraph and discover its code starts throwing exceptions around.

The purpose of the overall change was to fix the jitter issue due to performance, which is an internal implementation problem. The change shouldn’t be visible to the end users.

@Col-E Col-E Jul 20, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lineIndex relies on the real ParagraphBox since it calls into the ParagraphText (A real TextFlow with FX layout handling) to query for line wrapping. With the changes its not possible to do this since those scene nodes no longer get created while their containing paragraph is folded.

I don't think there will be a perfect "invisible" change here. However, I think that for this case rather than an exception throw, falling back to 0 for folded lines can be a reasonable expectation. The text isn't visible since its folded, so what line wrapping is there to be done, right?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pushed up a change modeling this fallback handling and updated the first test in FoldedParagraphTests to show the non-throwing behavior.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I understand there might be areas where changes compared to previous behaviour could be acceptable and even unavoidable. For instance getParagraphBoundsOnScreen is present only if the cell is visible, and it seems that your change on purpose made the cell invisible. However, we should be transparent about what these changes are and why they are there. I’ll review the test and see if I see any additional point to raise.

Comment thread richtextfx/src/main/java/org/fxmisc/richtext/GenericStyledArea.java
"Visible paragraphs' last index is [%s] but visibleParIndex was [%s]",
getVisibleParagraphs().size() - 1, visibleParIndex)
);
if (visibleParIndex < 0 || (visibleParIndex > 0 && visibleParIndex >= getVisibleParagraphs().size())) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This condition look strange (you kept previous code though, so was there an issue before?):

  • 0 for some reason isIsgnored (if there is no visible paragraph it will go through)
  • If 0 being ignored is an error, it can be simplified to visibleParIndex <0 || visibleParIndex >= getVisibleParagraphs().size()

It might be another issue to open (I need to look into it), no fix on this PR

getVisibleParagraphs().size() - 1, visibleParIndex)
);
if (visibleParIndex < 0 || (visibleParIndex > 0 && visibleParIndex >= getVisibleParagraphs().size())) {
return -1;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You are changing an existing behaviour: It used to throw an exception and now it isn't.

I think your change make sense and should be an improvement, but we cannot assume all implementation will agree.

return getCell(paragraphIndex).getCurrentLineIndex(columnPosition);
return getCellIfVisible(paragraphIndex)
.map(c -> c.getNode().getCurrentLineIndex(columnPosition))
.orElse(0);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suspect a change of behaviour again, if getCell used to throw an exception (which I suspect), your new implementation will return 0. If you really are out of bound on this one, I think you should indeed have IndexOutOfBounds (as there used to be).

Where there shouldn't be an exception is for cell that are collapsed (where you proposed to return the first line of the collapsed entry).

Also, I see something else: your UT if I recall test collapse on line 0, which is not good because it's also the default value here, you should probably cover a case != 0.

Comment on lines +85 to +90
int start = area.getAbsolutePosition(0, 0);
assertTrue(area.getCharacterBoundsOnScreen(start, start + 1).isPresent());
start = area.getAbsolutePosition(1, 0);
assertFalse(area.getCharacterBoundsOnScreen(start, start + 1).isPresent());
start = area.getAbsolutePosition(2, 0);
assertTrue(area.getCharacterBoundsOnScreen(start, start + 1).isPresent());

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do you limit your check on the first char? Why not loop on values that you know are there or not
Here it's [5;8) that are not visible, hence just do this to cover all cases:

Suggested change
int start = area.getAbsolutePosition(0, 0);
assertTrue(area.getCharacterBoundsOnScreen(start, start + 1).isPresent());
start = area.getAbsolutePosition(1, 0);
assertFalse(area.getCharacterBoundsOnScreen(start, start + 1).isPresent());
start = area.getAbsolutePosition(2, 0);
assertTrue(area.getCharacterBoundsOnScreen(start, start + 1).isPresent());
for(int i = 0 ; i < 12 ; i++) {
assertEquals(i < 5 || i >= 8, area.getCharacterBoundsOnScreen(i, i+ 1).isPresent());
}

// create a caret in the folded paragraph, which should not have bounds on screen.
CaretNode caret = new CaretNode("folded caret", area, area.getAbsolutePosition(1, 0));
interact(() -> {
assertFalse(area.getCaretBoundsOnScreen(caret).isPresent());

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are you sure the bounds must not be present? (previous behaviour returned true) The caret must be somewhere, I would expect that it would be at the end of the folded line or somewhere else.
Isn't there are risk that if moved the caret might dissapear when moving on the folded line?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have noticed a common pattern in all the tests you are making: they only cover fold on index = 0. I think you should reconsider the cases, as folding on index = 0 is an edge case. What happens when there is a line before the fold is never covered in these different cases present here.
Optimally we should test fold at the start, in the middle and at the end.

});

// getting/creating graphics for the folded paragraph throws an exception, since it is not visible.
assertThrows(IllegalArgumentException.class, () -> area.getParagraphGraphic(1));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Previous behaviour was sending null when there was no graphic set (and it was sending null for folded). Why not send null instead of throwing an exception?

Comment on lines +119 to +120
assertThrows(IllegalArgumentException.class, () -> area.getParagraphGraphic(1));
assertThrows(IllegalArgumentException.class, () -> area.recreateParagraphGraphic(1));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you are not testing for 0 and 2?

assertEquals(Optional.empty(), area.allParToVisibleParIndex(area.getParagraphs().size()));

assertEquals(-1, area.visibleParToAllParIndex(-1));
assertEquals(-1, area.visibleParToAllParIndex(Integer.MAX_VALUE));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both threw IllegalArgumentException before, which made sort of sense, as these are not valid values.
Now it's behaving differently

});

assertEquals(1, area.getParagraphLinesCount(0));
assertEquals(0, area.getParagraphLinesCount(1));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's probably the most dangerous change, it used to return 1 and the method has no mention of visible index. If anyone does any logic calculation on this, it will change the outcome.

@Symeon94

Copy link
Copy Markdown
Collaborator

I just finished reviewing, I wanted to point the method changes I have seen.

These have changed but should be ok (mostly they refer to visibility, so it's normal it would change), maybe it should be documented somewhere still:

getParagraphBoundsOnScreen
getVisibleParagraphBoundsOnScreen
getCharacterBoundsOnScreen
showParagraphInViewport

This one the change could be risky if the caller supposed that empty() is the end of the visible lines (now empty() could be folded):
allParToVisibleParIndex

These three I'm not sure it is advisable to change:

getCaretBoundsOnScreen -> Caret needs still to get bounds, even on folded
getParagraphGraphic -> Maybe return null instead of empty
getParagraphLinesCount -> I wrote a comment where I have seen it, the issue is that any existing logic using this one might have not considered that it was referring to visible lines (and there is no reference in the name that let us think that).

@Col-E

Col-E commented Jul 20, 2026

Copy link
Copy Markdown
Contributor Author

I've been trying to leave comments but github is not playing nice... Its putting comments in the "review mode" where you bulk comment then approve, despite me not ever starting a review. Then it just orphans the comments from the threads I was replying to so now they're floating without any proper context 😫

image

@Symeon94

Copy link
Copy Markdown
Collaborator

@Col-E I cannot answer on your last comment, so I put it here. I didn't create this library, so as far as exception is concerned I'm like you left to guess.
My original comment was on your new addition of exception. During review I saw how it was done elsewhere in this code and I thought a bit about it. It seems to me that it makes sense to throw exception for invalid indexing (like you would get from referencing a non-existing reference in an array). That would mean -1 and >= size/length. So your original exception made sense (and was aligned with the existing code). My apologies for commenting there.

Optional.empty() or -1 should be kept for valid input that returns non-existing.

What I'd like to avoid is to change existing behaviour if not absolutely necessary (that also means exceptions). As this might impact library users. For visible cells it is most of the time intrinsically related to your change. So, there changing the behaviour is mostly acceptable (I have commented on those where I thought it is either not as clear or wrong).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Scrolling past large collapsed paragraphs can lead to slight visual stutter

2 participants