Skip to content

Commit ba793c1

Browse files
authored
Merge pull request #2948 from square/when-i-open-up-a-tab-in-shark
Name a tab as it opens, let it grow in, and wrap the strip
2 parents 9808ef1 + 8b6feda commit ba793c1

5 files changed

Lines changed: 246 additions & 58 deletions

File tree

shark/shark-explorer/shark-explorer-app/src/main/java/shark/explorer/app/HeapDumpExplorer.kt

Lines changed: 35 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ import shark.explorer.ObjectDominator
5252
import shark.explorer.ObjectList
5353
import shark.explorer.ObjectListFilter
5454
import shark.explorer.Place
55+
import shark.explorer.PresentedCell
5556
import shark.explorer.RadialLayout
5657
import shark.explorer.RadialPresentation
5758
import shark.explorer.RootPath
@@ -180,6 +181,24 @@ internal fun HeapDumpExplorer(
180181
)
181182
}
182183

184+
// What each tab is called, for the tabs the window couldn't name itself. A label is cheap enough to draw
185+
// on every rectangle of the map, so naming a strip of them is one small read — but it is still a read,
186+
// and reads queue on the heap dump's one thread.
187+
//
188+
// Which is why this goes in ahead of the effect that lays the view out: opening a tab asks for both, and
189+
// the layout is the larger by orders of magnitude. Named after it, a tab would show its placeholder for
190+
// as long as laying the tree out takes, which on a real dump is what someone sees as a flicker.
191+
val unnamedPlaces = tabs.tabs.map { it.place }.filter { it.title == null && it !in placeTitles }
192+
LaunchedEffect(session, unnamedPlaces) {
193+
if (unnamedPlaces.isEmpty()) {
194+
return@LaunchedEffect
195+
}
196+
val named = session.read("what to call ${unnamedPlaces.size} tabs") { explorer ->
197+
unnamedPlaces.associateWith { explorer.tree.titleOf(it) }
198+
}
199+
placeTitles = placeTitles + named
200+
}
201+
183202
// Resizing, going to an object and switching shape all lay the tree out again, which reads the heap dump
184203
// for every visible label. All of it ends up here, on the heap dump's thread. Keyed on the request rather
185204
// than on the place, so that typing into a list doesn't lay the map out again and the map of the tab
@@ -287,20 +306,6 @@ internal fun HeapDumpExplorer(
287306
session.describing(hoveredPlace) { hoveredDetails = it }
288307
}
289308

290-
// What each tab is called. A label is cheap enough to draw on every rectangle of the map, so naming a
291-
// strip of them is one small read — but it is still a read, which is why a tab opened in the background
292-
// is named a beat after it appears rather than not at all.
293-
val unnamedPlaces = tabs.tabs.map { it.place }.filter { it.title == null && it !in placeTitles }
294-
LaunchedEffect(session, unnamedPlaces) {
295-
if (unnamedPlaces.isEmpty()) {
296-
return@LaunchedEffect
297-
}
298-
val named = session.read("what to call ${unnamedPlaces.size} tabs") { explorer ->
299-
unnamedPlaces.associateWith { explorer.tree.titleOf(it) }
300-
}
301-
placeTitles = placeTitles + named
302-
}
303-
304309
// The panel shows the bitmap it describes as big as the panel is wide, so its pixels are read again at
305310
// that size: a treemap rectangle is a couple of hundred pixels across and this is four times that.
306311
var describedBitmap: ImageBitmap? by remember { mutableStateOf(null) }
@@ -409,6 +414,12 @@ internal fun HeapDumpExplorer(
409414
* does the same thing" has to mean to be true.
410415
*/
411416
val open: (Place, OpenIn) -> Unit = { destination, openIn ->
417+
// Named before the tab exists, from the view the click came from: the read that names tabs is a beat
418+
// behind however small it is, and a tab that opens under a placeholder is one whose title, and width,
419+
// change as you watch. Everything a view draws is named already, which is most of what is ever clicked.
420+
view.presentation.cells.titleOf(destination)?.let { title ->
421+
placeTitles = placeTitles + (destination to title)
422+
}
412423
tabs = when (openIn) {
413424
OpenIn.CURRENT_TAB -> tabs.goTo(destination)
414425
OpenIn.NEW_TAB -> tabs.open(destination, inBackground = true)
@@ -1102,6 +1113,16 @@ internal sealed interface ViewPresentation {
11021113
data class Radial(val presentation: RadialPresentation) : ViewPresentation
11031114

11041115
data class Stack(val presentation: StackPresentation) : ViewPresentation
1116+
1117+
/**
1118+
* What was drawn and what it was named, whatever shape it came out as: every place a click on the view
1119+
* leads to, already named. See [titleOf].
1120+
*/
1121+
val cells: List<PresentedCell<*>> get() = when (this) {
1122+
is Treemap -> presentation.cells
1123+
is Radial -> presentation.cells
1124+
is Stack -> presentation.cells
1125+
}
11051126
}
11061127

11071128
/** What a laid out view amounts to, for the log: one that drew nothing at all says so here. */
Lines changed: 106 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,27 @@
11
package shark.explorer.app
22

3+
import androidx.compose.animation.AnimatedVisibility
4+
import androidx.compose.animation.core.MutableTransitionState
5+
import androidx.compose.animation.core.tween
6+
import androidx.compose.animation.expandHorizontally
7+
import androidx.compose.animation.fadeIn
38
import androidx.compose.foundation.ExperimentalFoundationApi
49
import androidx.compose.foundation.PointerMatcher
510
import androidx.compose.foundation.background
611
import androidx.compose.foundation.clickable
7-
import androidx.compose.foundation.horizontalScroll
812
import androidx.compose.foundation.layout.Arrangement
13+
import androidx.compose.foundation.layout.FlowRow
914
import androidx.compose.foundation.layout.Row
1015
import androidx.compose.foundation.layout.fillMaxWidth
1116
import androidx.compose.foundation.layout.padding
1217
import androidx.compose.foundation.layout.widthIn
1318
import androidx.compose.foundation.onClick
14-
import androidx.compose.foundation.rememberScrollState
1519
import androidx.compose.foundation.selection.selectable
1620
import androidx.compose.material3.MaterialTheme
1721
import androidx.compose.material3.Text
1822
import androidx.compose.runtime.Composable
23+
import androidx.compose.runtime.key
24+
import androidx.compose.runtime.remember
1925
import androidx.compose.ui.Alignment
2026
import androidx.compose.ui.Modifier
2127
import androidx.compose.ui.input.pointer.PointerButton
@@ -27,16 +33,18 @@ import shark.explorer.Place
2733
import shark.explorer.Tabs
2834

2935
/**
30-
* The tabs open in this window, left to right, as a strip under the bar that opens them.
36+
* The tabs open in this window, left to right and wrapping onto a line of its own once a line is full, as
37+
* a strip under the bar that opens them.
3138
*
3239
* Every tab is closeable, the last one included: a window with no tab still holds the heap dump it spent
3340
* seconds reading, and the bar above is one click from a tab again. See [Tabs].
3441
*
35-
* The strip scrolls rather than shrinking its tabs to nothing, because what a tab is called — which class,
42+
* Neither shrinking its tabs nor scrolling them off the edge, because what a tab is called — which class,
3643
* and which instance of it — is the whole of how a strip of a dozen instances of one class is one you can
37-
* pick out of.
44+
* pick out of, and a tab you have to go looking for is one you may as well not have opened. So the strip
45+
* grows down into the window instead: what it costs is the view's height, and only for someone who has
46+
* opened enough tabs to be reading across them anyway.
3847
*/
39-
@OptIn(ExperimentalFoundationApi::class)
4048
@Composable
4149
internal fun TabStrip(
4250
tabs: Tabs,
@@ -49,53 +57,99 @@ internal fun TabStrip(
4957
if (tabs.tabs.isEmpty()) {
5058
return
5159
}
52-
Row(
53-
modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(horizontal = 4.dp),
60+
FlowRow(
61+
modifier.fillMaxWidth().padding(horizontal = 4.dp),
5462
horizontalArrangement = Arrangement.spacedBy(2.dp),
55-
verticalAlignment = Alignment.Bottom
63+
verticalArrangement = Arrangement.spacedBy(2.dp),
64+
itemVerticalAlignment = Alignment.Bottom
5665
) {
5766
tabs.tabs.forEach { tab ->
58-
val isSelected = tab.id == tabs.selectedId
59-
Row(
60-
Modifier
61-
.background(
62-
if (isSelected) {
63-
MaterialTheme.colorScheme.surface
64-
} else {
65-
MaterialTheme.colorScheme.surfaceVariant
66-
}
67-
)
68-
// Middle clicking a tab closes it, which is the other half of middle clicking opening one.
69-
.onClick(matcher = PointerMatcher.mouse(PointerButton.Tertiary)) { onClose(tab.id) }
70-
// Selectable rather than clickable, because a strip of these is a set with one of them on: it
71-
// is also what tells a tab apart from the button and the chain row that lead to the same place.
72-
.selectable(selected = isSelected, role = Role.Tab) { onSelect(tab.id) }
73-
.padding(start = 8.dp, end = 4.dp, top = 4.dp, bottom = 4.dp)
74-
.widthIn(max = MAX_TAB_WIDTH),
75-
horizontalArrangement = Arrangement.spacedBy(4.dp),
76-
verticalAlignment = Alignment.CenterVertically
77-
) {
78-
Text(
79-
titleOf(tab.place),
80-
style = MaterialTheme.typography.bodySmall,
81-
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal,
82-
maxLines = 1,
83-
overflow = TextOverflow.Ellipsis,
84-
modifier = Modifier.weight(1f, fill = false)
85-
)
86-
// Its own click target rather than a modifier on the tab, so that closing a tab is never
87-
// selecting it first: a strip where closing the fourth tab shows you the fourth tab is a strip
88-
// that fights back.
89-
Text(
90-
CLOSE_TAB,
91-
Modifier.clickable { onClose(tab.id) }.padding(horizontal = 2.dp),
92-
style = MaterialTheme.typography.bodySmall
67+
// Keyed on the tab rather than on where it is in the strip, because a tab opens *beside* the one it
68+
// was opened from: without this, inserting one in the middle would hand its state to the tab that
69+
// used to be there, and the one that grew in would be whichever ended up last.
70+
key(tab.id) {
71+
TabView(
72+
title = titleOf(tab.place),
73+
isSelected = tab.id == tabs.selectedId,
74+
onSelect = { onSelect(tab.id) },
75+
onClose = { onClose(tab.id) }
9376
)
9477
}
9578
}
9679
}
9780
}
9881

82+
/**
83+
* One tab, growing into the strip as it opens.
84+
*
85+
* Widening from nothing rather than appearing at full width, the way a browser does it, because a tab
86+
* opened in the background is one nothing else on screen announces: what says a middle click did anything
87+
* at all is the strip moving over to make room. It also puts the tab where it went — beside the one it was
88+
* opened from, which is not where a strip that pops a tab in reads as having put it.
89+
*
90+
* Closing is not animated, deliberately: a strip that holds a tab open for a moment after it was closed is
91+
* one where the tab under the pointer isn't the tab a second click closes.
92+
*/
93+
@OptIn(ExperimentalFoundationApi::class)
94+
@Composable
95+
private fun TabView(
96+
title: String,
97+
isSelected: Boolean,
98+
onSelect: () -> Unit,
99+
onClose: () -> Unit
100+
) {
101+
// Starting closed and opening on the first composition, which is what makes this animate on the way in:
102+
// a tab that was already open when the strip drew it has nothing to animate.
103+
val opening = remember { MutableTransitionState(false) }
104+
opening.targetState = true
105+
AnimatedVisibility(
106+
visibleState = opening,
107+
// From the start edge, so the tab holds the spot it was inserted at and pushes the ones after it along,
108+
// rather than sliding in from under its neighbour.
109+
enter = expandHorizontally(
110+
animationSpec = tween(TAB_OPEN_MILLIS),
111+
expandFrom = Alignment.Start
112+
) + fadeIn(tween(TAB_OPEN_MILLIS))
113+
) {
114+
Row(
115+
Modifier
116+
.background(
117+
if (isSelected) {
118+
MaterialTheme.colorScheme.surface
119+
} else {
120+
MaterialTheme.colorScheme.surfaceVariant
121+
}
122+
)
123+
// Middle clicking a tab closes it, which is the other half of middle clicking opening one.
124+
.onClick(matcher = PointerMatcher.mouse(PointerButton.Tertiary)) { onClose() }
125+
// Selectable rather than clickable, because a strip of these is a set with one of them on: it
126+
// is also what tells a tab apart from the button and the chain row that lead to the same place.
127+
.selectable(selected = isSelected, role = Role.Tab) { onSelect() }
128+
.padding(start = 8.dp, end = 4.dp, top = 4.dp, bottom = 4.dp)
129+
.widthIn(max = MAX_TAB_WIDTH),
130+
horizontalArrangement = Arrangement.spacedBy(4.dp),
131+
verticalAlignment = Alignment.CenterVertically
132+
) {
133+
Text(
134+
title,
135+
style = MaterialTheme.typography.bodySmall,
136+
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal,
137+
maxLines = 1,
138+
overflow = TextOverflow.Ellipsis,
139+
modifier = Modifier.weight(1f, fill = false)
140+
)
141+
// Its own click target rather than a modifier on the tab, so that closing a tab is never
142+
// selecting it first: a strip where closing the fourth tab shows you the fourth tab is a strip
143+
// that fights back.
144+
Text(
145+
CLOSE_TAB,
146+
Modifier.clickable { onClose() }.padding(horizontal = 2.dp),
147+
style = MaterialTheme.typography.bodySmall
148+
)
149+
}
150+
}
151+
}
152+
99153
/**
100154
* Shown where a tab would be once the last one has been closed.
101155
*
@@ -110,3 +164,11 @@ internal const val CLOSE_TAB = "✕"
110164

111165
/** Wide enough for a class name and an address, short enough that ten tabs are all still on the strip. */
112166
private val MAX_TAB_WIDTH = 220.dp
167+
168+
/**
169+
* How long a tab takes to grow into the strip.
170+
*
171+
* Long enough to be read as the strip making room, short enough that a middle click and the tab being
172+
* there are one thing: a tab you have to wait for is one you would rather had just appeared.
173+
*/
174+
private const val TAB_OPEN_MILLIS = 150

shark/shark-explorer/shark-explorer-app/src/test/java/shark/explorer/app/TabStripTest.kt

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,25 @@ class TabStripTest {
7777
}
7878
}
7979

80+
@Test fun `tabs that no longer fit across the window go on a line of their own`() {
81+
explorerUiTest {
82+
openHeapDump()
83+
84+
repeat(TABS_PAST_ONE_LINE) { screenButton(Place.OBJECTS_LABEL).performClick() }
85+
86+
waitUntil(timeoutMillis = OPEN_TIMEOUT_MILLIS) {
87+
tabs().fetchSemanticsNodes().size == TABS_PAST_ONE_LINE + 1
88+
}
89+
// Every tab still readable and still on screen, rather than squeezed to nothing or scrolled off the
90+
// edge: which class and which instance of it is the whole of what a tab is for, and a tab you have
91+
// to go looking for is one you may as well not have opened.
92+
waitForIdle()
93+
val bounds = tabs().fetchSemanticsNodes().map { it.boundsInRoot }
94+
assertThat(bounds.map { it.top }.distinct()).hasSizeGreaterThan(1)
95+
assertThat(bounds).allMatch { it.right <= WINDOW_WIDTH.value }
96+
}
97+
}
98+
8099
@Test fun `clicking an object inside a tab moves that tab rather than opening one`() {
81100
explorerUiTest {
82101
openHeapDump()
@@ -103,6 +122,23 @@ class TabStripTest {
103122
}
104123
}
105124

125+
@Test fun `a tab opened from the view is named without reading the heap dump for it`() {
126+
explorerUiTest {
127+
openHeapDump()
128+
// The window's first tab is named by a read of its own, there being no view yet to have drawn it.
129+
val readsBefore = logged.count { it.startsWith(NAMING_READ) }
130+
131+
middleClickTheArray()
132+
133+
waitUntil(timeoutMillis = OPEN_TIMEOUT_MILLIS) { tabs().fetchSemanticsNodes().size == 2 }
134+
tab(tabTitleOfTheArray()).assertIsDisplayed()
135+
// Named from the rectangle that was clicked, which the view labelled when it laid the tree out. A
136+
// read to name it would land a beat after the tab is on the strip, however small a read it is, and
137+
// the placeholder it replaces is what someone watching sees as the tab flickering.
138+
assertThat(logged.filter { it.startsWith(NAMING_READ) }).hasSize(readsBefore)
139+
}
140+
}
141+
106142
@Test fun `middle clicking an object opens it in a tab behind the one being read`() {
107143
explorerUiTest {
108144
openHeapDump()
@@ -202,6 +238,16 @@ class TabStripTest {
202238
/** Opening a heap dump and laying the tree out both happen on another thread. */
203239
private const val OPEN_TIMEOUT_MILLIS = 10_000L
204240

241+
/** What the window logs when it has to read the heap dump to find out what to call a tab. */
242+
private const val NAMING_READ = "Reading what to call"
243+
244+
/**
245+
* Enough tabs off the bar that they can't all fit across a window, whose width a UI test fixes at
246+
* [WINDOW_WIDTH]. A tab named after the object list is one of the narrower ones there is, so this is
247+
* comfortably past what a line holds rather than exactly it.
248+
*/
249+
private const val TABS_PAST_ONE_LINE = 20
250+
205251
/** An `adb` that answers as if nothing were plugged in, so no test here reaches a real device. */
206252
private val NO_DEVICE_ADB = Adb { AdbOutput(exitCode = 0, text = "List of devices attached\n") }
207253
}

shark/shark-explorer/shark-explorer-core/src/main/java/shark/explorer/Place.kt

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,3 +146,36 @@ fun HeapDominatorTreemap.titleOf(place: Place): String = when (place) {
146146
is Place.Leaks -> place.title
147147
is Place.Starred -> place.title
148148
}
149+
150+
/**
151+
* What a tab opened at [place] is called, from the name a view already drew there, or null for a place
152+
* none of these cells stands for.
153+
*
154+
* The same answer [titleOf] gives, and not a heap dump read: a cell was named when the view was laid out,
155+
* so a tab opened by clicking one can be named as it opens instead of a beat later. A beat later is a tab
156+
* whose title, and with it its width, change in front of whoever opened it — which is a flicker, and every
157+
* cell of every view is one a click opens a tab at.
158+
*/
159+
fun List<PresentedCell<*>>.titleOf(place: Place): String? =
160+
firstOrNull { Place.of(it.cell) == place }?.title()
161+
162+
/**
163+
* What a tab open where this cell leads is called.
164+
*
165+
* A cell is labelled with a class name and nothing else, because that is all a rectangle has room for. A
166+
* tab has room for which instance of it this is, and needs it — see [titleOf].
167+
*/
168+
private fun PresentedCell<*>.title(): String {
169+
val place = Place.of(cell)
170+
// Only one object of the heap dump has an address to be named by: a pile of them is named by how many
171+
// it holds, and the whole heap dump is no object at all.
172+
return if (
173+
place is Place.Object &&
174+
content is CellContent.Object &&
175+
place.objectId != HeapDominatorTreemap.ROOT_OBJECT_ID
176+
) {
177+
"$label ${hexObjectId(place.objectId)}"
178+
} else {
179+
label
180+
}
181+
}

0 commit comments

Comments
 (0)