Improve layout performance of folded paragraphs#1318
Conversation
| private int getVirtualParagraphIndex(int allParagraphIndex) { | ||
| int virtualIndex = virtualFlowParagraphs.getViewIndex(allParagraphIndex); | ||
| if ( virtualIndex < 0 ) { | ||
| throw new IllegalArgumentException("Paragraph " + allParagraphIndex + " is folded"); |
There was a problem hiding this comment.
It's a bit radical to throw an exception in that case, ins't there a better way?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
I can make visibleParToAllParIndex and allParToVisibleParIndex not throw, still don't think I can here.
There was a problem hiding this comment.
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).
There was a problem hiding this comment.
Lots of public methods were modified but not tested (for instance lineIndex, but that is not the only one).
There was a problem hiding this comment.
Pushed up a number of additional tests for folding, lmk if there's anything I missed
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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() |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
Pushed up a change modeling this fallback handling and updated the first test in FoldedParagraphTests to show the non-throwing behavior.
There was a problem hiding this comment.
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.
| "Visible paragraphs' last index is [%s] but visibleParIndex was [%s]", | ||
| getVisibleParagraphs().size() - 1, visibleParIndex) | ||
| ); | ||
| if (visibleParIndex < 0 || (visibleParIndex > 0 && visibleParIndex >= getVisibleParagraphs().size())) { |
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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.
| 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()); |
There was a problem hiding this comment.
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:
| 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()); |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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)); |
There was a problem hiding this comment.
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?
| assertThrows(IllegalArgumentException.class, () -> area.getParagraphGraphic(1)); | ||
| assertThrows(IllegalArgumentException.class, () -> area.recreateParagraphGraphic(1)); |
There was a problem hiding this comment.
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)); |
There was a problem hiding this comment.
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)); |
There was a problem hiding this comment.
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.
|
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 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): These three I'm not sure it is advisable to change: getCaretBoundsOnScreen -> Caret needs still to get bounds, even on folded |
|
@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. 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). |

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