Skip to content

Conversation

@faogustavo
Copy link
Collaborator

@faogustavo faogustavo commented Nov 19, 2025

  • Add LazyTable component for large datasets
  • Create OverflowBox utility for smart cell content overflow on hover
  • Provide themed TableViewCell and TableViewHeader components with IntelliJ theme integration
  • Added theme classes based on the IJ table

Note

This PR includes the platform/jewel/docs/lazy-table.md file. It's a base documentation of features and how to implement them to help during review
My plan was to use it mainly to support review, but I can put more effort into it if we want to store it somewhere

Note

Most of the work was imported from another branch/fork that had an implementation of it almost complete.

This PR only addresses a few remaining items and adapts the code to Jewel code-style/standards and update to match proposal from the ticket comments

Original Patch File

Evidences

Case Gif
Simple Matrix Screen Recording 2025-11-19 at 15 15 47
Selection Handling Screen Recording 2025-11-19 at 15 16 06
Movable Columns/Rows Screen Recording 2025-11-19 at 15 16 31
Selection, Moving content, Resizing Content Screen Recording 2025-11-19 at 15 17 14

Release notes

New features

  • Added LazyTable
    • We've added a new experimental component (LazyTable) to render table content in a lazy format
    • This component is still experimental and it's evolving, so changes on the API may happen
    • Supported features:
      • Lazy data loading
      • Bi-directional scroll support
      • Fixed/Pinned Columns/Rows
      • Draggable Columns/Rows
      • Resizable Columns/Rows
      • Selection management
  • Added cell components to be used alongside with LazyTable to customize the Look-and-feel
    • Automatically hooks to IJ theme using the LaF bridge
    • Pre-defined theme for stand-alone
    • Automatic support for Selection, Resize and Drag
    • Components:
      • TableViewCell
      • TableViewHeaderCell
      • SimpleTextTableViewCell
      • SimpleTextTableViewHeaderCell

Note

Introduces an experimental LazyTable (2D lazy grid) with selection/drag/resize, themed table cells/styles, OverflowBox, scrollbar adapter overloads, docs, and showcase samples.

  • Foundation (LazyTable):
    • New org.jetbrains.jewel.foundation.lazy.table.*: 2D lazy layout, pinned rows/columns, bi‑directional scrolling, cache-window prefetch, animate scroll, selection managers, drag & drop (rows/columns), resize hooks, scrollbar adapters (rememberTable*ScrollbarAdapter).
    • New OverflowBox for hover‑triggered content overflow.
    • Selection/drag infra: lazy.selectable and lazy.draggable utilities.
  • UI Components:
    • New themed table cells: TableViewCell, TableViewHeaderCell, SimpleTextTableViewCell, SimpleTextTableViewHeaderCell with resize handles, selection/drag integration.
    • New TableCellState, table styling (TableStyle, TableColors, TableMetrics, TableCellColors) and JewelTheme.tableStyle.
    • Scrollbar now supports adapter-provided overloads for vertical/horizontal.
  • Int UI / LaF bridge:
    • IntUI defaults for table styles; LaF bridge maps IJ table colors to TableStyle.
  • Samples:
    • Showcase: new Tables demos (simple, selectable, draggable, interactable, all‑features), menu integration, and icons.
  • Docs:
    • Add platform/jewel/docs/lazy-table.md with usage and feature guide.
  • Build/Deps:
    • Add androidx.collection dependency; API dumps updated.

Written by Cursor Bugbot for commit 97df4e2. This will update automatically on new commits. Configure here.

@m-asadullah
Copy link

m-asadullah commented Nov 21, 2025

Caution

After merging this #3289 then this #3312 cause merge conflict on

platform/jewel/ui/src/main/kotlin/org/jetbrains/jewel/ui/component/Scrollbar.kt

@rock3r
Copy link
Collaborator

rock3r commented Nov 21, 2025

That's fine, this PR isn't going to be merged soon. Gustavo will rebase.

@morki
Copy link

morki commented Nov 22, 2025

I tried it and it looks very impressive, but one thing is very bad and it is performance. On pretty medium harware on linux it feels very very laggy. 🙁

Anyway thank you for this awesome work 🙂

@rock3r
Copy link
Collaborator

rock3r commented Nov 27, 2025

@faogustavo any hope you can do a quick pass with a profiler to see what's going on?

@faogustavo
Copy link
Collaborator Author

@faogustavo any hope you can do a quick pass with a profiler to see what's going on?

So, I've run it and found a few issues that could be improved. The biggest problem was happening in the measurement/placement phase.

These are the fixes I've applied (which had significant performance improvement):

  • Replaced HashMap<Int, Int> with MutableIntIntMap to prevent boxing/unboxing overhead
  • Caching getRowHeight and getColumnWidth calls
  • Only calling remeasurement?.forceRemeasure() once per frame
    • This was getting called based on a threshold and was triggering multiple times for the same frame
Before After
image image

Please let me know if you still feel the component is laggy, and I can perform a more thorough review.


override suspend fun snapToLine(lineIndex: Int, scrollOffset: Int) {
scrollState.scrollToColumn(lineIndex, scrollOffset)
}
Copy link

Choose a reason for hiding this comment

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

Bug: Scrollbar snap uses relative index without adding pinned offset

The snapToLine methods in both LazyTableHorizontalScrollbarAdapter and LazyTableVerticalScrollbarAdapter pass a relative index (0-based for floating items) directly to scrollToColumn/scrollToRow, which expect absolute indices. The scrollbar adapter's totalLineCount() returns columns - pinnedColumns (or rows - pinnedRows), so its calculated indices are relative to floating items. When the user drags the scrollbar far enough to trigger snapTo, passing index 0 should scroll to column pinnedColumns, not column 0. The measurement code clamps indices below pinnedColumns to pinnedColumns and discards the scroll offset, resulting in incorrect scroll positions. The fix requires adding pinnedColumns/pinnedRows to the lineIndex before calling the scroll methods.

Additional Locations (1)

Fix in Cursor Fix in Web

@morki
Copy link

morki commented Dec 3, 2025

@faogustavo I tried it and congratulations to the speedup and interactive components, very nice :)

But it is still very "laggy" to use, maybe it is just linux and something with SW rendering, but it worse with bigger window size.

showcase.mp4

@rock3r
Copy link
Collaborator

rock3r commented Dec 3, 2025

Given how Compose rendering works right now, it's probably to be expected that a full screen window would have a lower frame rate. You can probably also see the progress bars page lagging when you go fullscreen

@faogustavo
Copy link
Collaborator Author

faogustavo commented Dec 4, 2025

Pushed a few more fixes to improve scroll behavior for lager data sets (tables with more than 300 visible cells also had some lag even on MacOS).

A few of the fixes I've implemented are:

  • LazyTableMeasuredItemProvider - Increasing cached cells
  • Added a throttle to prevent more than 60 requests to remeasure per sec - Limiting to 60fps was benefitial on both lower and upper end devices with higher amount of data (lower end had the most significant difference)
  • LazyTableMeasuredItem, measureLazyTable, LazyTableState - Improving scroll to prevent recomposition when it's not needed. It mimics the LazyList implementation. Check the copyWithScrollDeltaWithoutRemeasure and ObservableScopeInvalidator

Here are a few results I was able to gather using JFR:

What Change Percent
Duration (milliseconds) 28277 → 13730 -51.4%
Total CPU Samples 7291 → 982 -86.5%
LazyTable Samples 149 → 35 -76.5%
getAndMeasure Samples 39 → 5 -87.1%
TableViewCellImpl Samples 97 → 3 -96.9%
Object Allocations 121153 → 56247 -53.5%

Something you can notice is that the time to run the test cases has reduced drastically with less frame jank. Also worth mentioning that this is only happening for tables rendering ~300 cells (which are the cases in the samples). A more realistic scenario will probably have larger cells and less of them showing on screen (which may help with the performance).

You can check it by applying the following patch file and running the .sh scripts:

  • Patch:
  • Command to collect info
    • ./scripts/run_table_performance_tests.sh --jfr --test "TableScrollBenchmark,TablePerformanceTest" --duration 60 --runs 10
  • Command to compare
    • ./scripts/compare_performance.sh analysis/profiling_results/${run_1} analysis/profiling_results/${run_2}
  • To compare the before and after:
    • commit 092f094 to be the baseline
    • last commit from this branch as "after"

I'll probably work on it a bit more to improve a bit more (I think there is still space for improvements), so if you notice that it still freezes, please let me know. Including the JRF files in the report will be a big plus to help understand the issues.

@morki
Copy link

morki commented Dec 5, 2025

Nice job! I tried and it still feels laggy (i mean normal sized tables, not the extreme example matrix in first example).

But i think maybe it is only perceived laggines now, because of the "buggy" behavior of the fixed column while scrolling:

Screencast.From.2025-12-05.15-08-47.mp4

@rock3r
Copy link
Collaborator

rock3r commented Dec 5, 2025

@morki thanks for keeping an eye on this PR. It definitely looks like the pinned column scroll is buggy, and needs to be fixed

@faogustavo
Copy link
Collaborator Author

faogustavo commented Dec 5, 2025

That's definetly a bug and related to the improvements I made yesterday. I think it's not getting applied to the fixed colums/rows. I'm already looking at it.

Thanks for catching


That was a quick fix. The branch already has the fix for it, but I'm still investigating other issues

@morki
Copy link

morki commented Dec 5, 2025

That was fast fix :) another bug i spotted is with selection when scrolling:

Screencast.From.2025-12-05.15-35-17.mp4

The main issue is at the end of the video, the beginning is just me trying to reproduce it. The selection jumps to stay in viewport and it feels unintuitive and only happens sometimes.

(scrollOffsetCoerced / averageVisibleLineSizeWithSpacing)
.toInt()
.coerceAtLeast(0)
.coerceAtMost(totalLineCount() - 1)
Copy link

Choose a reason for hiding this comment

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

Bug: Scrollbar crashes when snapping on empty table

When the table is empty (totalLineCount() returns 0), the snapTo function computes a line index by calling coerceAtMost(totalLineCount() - 1) which evaluates to coerceAtMost(-1). Since the value is already coerced to at least 0, this results in index = -1. The subsequent call to snapToLine(-1, offset) leads to scrollToRow(-1, ...) or scrollToColumn(-1, ...), which eventually fails the require(row >= 0f && column >= 0f) check in LazyTableScrollPosition.update(), throwing an exception. This can occur if scrollTo is invoked when the scroll distance exceeds the viewport size on an empty table.

Fix in Cursor Fix in Web

@daaria-s
Copy link
Collaborator

daaria-s commented Dec 8, 2025

Saw that the cursor is blinking during scrolling (changes to a different pointer type). Not sure whether it's okay or not, but didn't see that in your videos

Screen.Recording.2025-12-08.at.17.57.26.mov

@faogustavo
Copy link
Collaborator Author

@morki I'm almost wrapping up a new batch of improvements that should fix that issue. Let me know if you still see that happening.

The changed I've made are adding support for LookAhead and support Prefetch (default to prefetch 4 items) and created CacheWindow similar to LazyList/Grid components. Both should help in the performance and the feeling of "lag".


@daaria-s this seems to be related to the "drag handle" that allow resise the table. I'll check if I can maybe disable it during scroll.

@morki
Copy link

morki commented Dec 9, 2025

I cannot reproduce the selection bug anymore, but now i cannot drag the columns :)

@faogustavo
Copy link
Collaborator Author

I cannot reproduce the selection bug anymore, but now i cannot drag the columns :)

I'm sorry about that, I was checking if remembering the modifiers would help to reduce memory usage and forgot to revert 🤦‍♂️

The branch is correct now

@morki
Copy link

morki commented Dec 9, 2025

Every iteration it feels a bit faster, very good job :) Still somewhat laggy but better :)

One more observation - it feels more laggy scrolling top then bottom (I scroll the same speed using mouse dragging on scrollbar). Maybe prefetching only in one direction?

Screencast.From.2025-12-09.17-04-09.mp4

@faogustavo
Copy link
Collaborator Author

faogustavo commented Dec 9, 2025

One more observation - it feels more laggy scrolling top then bottom (I scroll the same speed using mouse dragging on scrollbar). Maybe prefetching only in one direction?

I'll take a second look at the prefetch logic, but I think I is keeping a window of 4 items before and after in the state by default. Thanks for the hint :)


The problem was actually something else. One of the logics to optimize scroll was not using the abs() values in a check, therefore it was only working in one way 😅

It should be good now. I'm also testing to replace the 'HashMap's used on cache with the 'androidx.collection.LruCache'. The benchmark tests are running (they take ~1h). I'll check the results tomorrow 🤞

@faogustavo
Copy link
Collaborator Author

faogustavo commented Dec 10, 2025

LRU caches helped, but it has a slight bigger memory footprint (however, IMO, it worth when thinking about the performance). I also pushed some more guard-rails for parameters and fixed some types that were incorrect (ints using float types).


Also pushed more changes to the benchmark gist with changes to include more checks (GC times, CPU usage, Heap Summary, etc.)

.then(modifier),
contentAlignment = contentAlignment,
) {
rememberOverflowBoxScope(isOverflowVisible, this).content()
Copy link

Choose a reason for hiding this comment

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

Bug: OverflowBox isOverflowing reports incorrect value when content fits

The OverflowBoxScope.isOverflowing value passed to the content scope reflects isOverflowVisible (whether the hover delay elapsed), not whether content actually overflows the constraints. The actual overflow check (predictWidth > constraintWith or predictHeight > constraintHeight) happens during measurement but isn't propagated to the scope. This causes isOverflowing to be true when a user hovers over content that fits within constraints, making any visual overflow indicators (shadows, fades) appear incorrectly.

Fix in Cursor Fix in Web

- Add LazyTable component for large datasets
- Create OverflowBox utility for smart cell content overflow on hover
- Provide themed TableViewCell and TableViewHeader components with IntelliJ theme integration
- Added theme classes based on IJ table

Co-authored-by: James Rose <[email protected]>

// Trick for overflow layout: compensate layout alignment by offsetting half of the extra space.
val xOffset = if (overflowingX) (predictWidth - constraintWith) / 2 else 0
val yOffset = if (overflowingY) (predictHeight - constraintHeight) / 2 else 0
Copy link

Choose a reason for hiding this comment

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

Bug: OverflowBox offset calculation uses intrinsic size instead of measured size

The xOffset and yOffset calculations use predictWidth and predictHeight (intrinsic sizes from lines 73-74), but the layout reports placements.width and placements.height (measured sizes from line 97). When these differ—which can happen with content that fills available space—the centering calculation produces incorrect offsets. For example, if intrinsic width is 200px, constraint is 100px, and measured width is 150px, the offset would be 50px based on intrinsic size, but this doesn't properly center a 150px measured child. The offset calculation should use placements.width and placements.height instead of predictWidth and predictHeight.

Fix in Cursor Fix in Web

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants