Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,9 @@ dependencies {
implementation(libs.androidx.work)
implementation(libs.androidx.work.ktx)
implementation(libs.google.material)
// Metrics carousel (ADFA-5487). Already on the classpath transitively; declared so the
// compile-time use in MetricsCarouselAdapter does not depend on another library's graph.
implementation(libs.androidx.viewpager2)
implementation(libs.google.flexbox)
implementation(libs.libsu.core)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,6 @@ import androidx.annotation.GravityInt
import androidx.annotation.RequiresApi
import androidx.annotation.UiThread
import androidx.appcompat.app.ActionBarDrawerToggle
import androidx.collection.MutableIntIntMap
import androidx.core.content.ContextCompat
import androidx.core.content.IntentCompat
import androidx.core.graphics.Insets
Expand All @@ -67,11 +66,7 @@ import androidx.fragment.app.FragmentManager
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import com.github.mikephil.charting.components.AxisBase
import com.github.mikephil.charting.data.Entry
import com.github.mikephil.charting.data.LineData
import com.github.mikephil.charting.data.LineDataSet
import com.github.mikephil.charting.formatter.IAxisValueFormatter
import androidx.viewpager2.widget.ViewPager2
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_COLLAPSED
import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_HIDDEN
Expand Down Expand Up @@ -124,6 +119,9 @@ import com.itsaky.androidide.tasks.cancelIfActive
import com.itsaky.androidide.tasks.mainThreadHandler
import com.itsaky.androidide.ui.CodeEditorView
import com.itsaky.androidide.ui.ContentTranslatingDrawerLayout
import com.itsaky.androidide.ui.MemoryUsageChartRenderer
import com.itsaky.androidide.ui.MetricsCarouselAdapter
import com.itsaky.androidide.ui.MetricsPage
import com.itsaky.androidide.ui.SwipeRevealLayout
import com.itsaky.androidide.uidesigner.UIDesignerActivity
import com.itsaky.androidide.utils.ActionMenuUtils.showPopupWindow
Expand All @@ -145,7 +143,6 @@ import com.itsaky.androidide.utils.flashMessage
import com.itsaky.androidide.utils.getOrStoreInitialPadding
import com.itsaky.androidide.utils.isAtLeastR
import com.itsaky.androidide.utils.isDeepLinkTargetOfOpenProject
import com.itsaky.androidide.utils.resolveAttr
import com.itsaky.androidide.viewmodel.ApkInstallationViewModel
import com.itsaky.androidide.viewmodel.AppLogsCoordinator
import com.itsaky.androidide.viewmodel.AppLogsViewModel
Expand Down Expand Up @@ -173,7 +170,6 @@ import rikka.shizuku.Shizuku
import java.io.File
import kotlin.math.abs
import kotlin.math.roundToInt
import kotlin.math.roundToLong

/**
* Base class for EditorActivity which handles most of the view related things.
Expand All @@ -192,7 +188,12 @@ abstract class BaseEditorActivity :
private var drawerToggle: ActionBarDrawerToggle? = null
private var bottomSheetCallback: BottomSheetBehavior.BottomSheetCallback? = null
protected val memoryUsageWatcher = MemoryUsageWatcher()
protected val pidToDatasetIdxMap = MutableIntIntMap(initialCapacity = 3)
private var metricsPageCallback: ViewPager2.OnPageChangeCallback? = null
private val memUsageChartRenderer =
MemoryUsageChartRenderer(
usagesProvider = memoryUsageWatcher::getMemoryUsages,
lineColorFor = Companion::getMemUsageLineColorFor,
)

private val fileManagerViewModel by viewModels<FileManagerViewModel>()
private var feedbackButtonManager: FeedbackButtonManager? = null
Expand Down Expand Up @@ -315,45 +316,7 @@ abstract class BaseEditorActivity :

private val memoryUsageListener =
MemoryUsageWatcher.MemoryUsageListener { memoryUsage ->
var dataChanged = false
memoryUsage.forEachValue { proc ->
_binding?.memUsageView?.chart?.apply {
val dataset =
(
data.getDataSetByIndex(
pidToDatasetIdxMap.getOrDefault(
proc.pid,
-1,
),
) as LineDataSet?
)
?: run {
log.error(
"No dataset found for process: {}: {}",
proc.pid,
proc.pname,
)
return@forEachValue
}

dataset.entries.mapIndexed { index, entry ->
entry.y =
(proc.usageHistory[index] / (1024.0 * 1024.0)).toFloat()
}

dataset.label = "%s - %.2fMB".format(proc.pname, dataset.entries.last().y)
dataset.notifyDataSetChanged()
dataChanged = true
}
}

if (dataChanged) {
_binding?.memUsageView?.chart?.apply {
data.notifyDataChanged()
notifyDataSetChanged()
invalidate()
}
}
memUsageChartRenderer.onUsagesChanged(memoryUsage)
}

private val shizukuBinderReceivedListener =
Expand All @@ -363,10 +326,6 @@ abstract class BaseEditorActivity :

private var isImeVisible = false
private var contentCardRealHeight: Int? = null
private val editorSurfaceContainerBackground by lazy {
resolveAttr(R.attr.colorSurfaceDim)
}

private var isDebuggerStarting = false
@UiThread set(value) {
field = value
Expand Down Expand Up @@ -480,7 +439,26 @@ abstract class BaseEditorActivity :
companion object {
const val DEBUGGER_SERVICE_STOP_DELAY_MS: Long = 60 * 1000

/**
* The plot colour for a watched process.
*
* On the companion rather than the activity: a bound reference to an activity method is
* handed to the renderer, which the carousel adapter holds, so any path that misses the
* adapter teardown would keep the whole editor reachable. Nothing here needs an activity.
*
* An unrecognised name falls back rather than throwing. The renderer now reaches this from
* the once-a-second sample listener and from RecyclerView's bind pass, so a name nobody
* added a colour for would take the editor down from a timer callback or mid-layout.
*/
@JvmStatic
fun getMemUsageLineColorFor(proc: MemoryUsageWatcher.ProcessMemoryInfo): Int =
when (proc.pname) {
PROC_IDE -> Color.BLUE
PROC_GRADLE_TOOLING -> Color.RED
PROC_GRADLE_DAEMON -> Color.GREEN
else -> Color.GRAY
}

protected val PROC_IDE = "IDE"

@JvmStatic
Expand Down Expand Up @@ -559,6 +537,12 @@ abstract class BaseEditorActivity :
fullscreenManager?.destroy()
fullscreenManager = null

metricsPageCallback?.let { callback ->
_binding?.memUsageView?.metricsPager?.unregisterOnPageChangeCallback(callback)
}
metricsPageCallback = null
_binding?.memUsageView?.metricsPager?.adapter = null
memUsageChartRenderer.detach()
_binding = null

if (isDestroying) {
Expand Down Expand Up @@ -899,7 +883,7 @@ abstract class BaseEditorActivity :
)
feedbackButtonManager?.setupDraggableFab()

setupMemUsageChart()
setupMetricsCarousel()
watchMemory()
observeFileOperations()

Expand Down Expand Up @@ -1000,36 +984,45 @@ abstract class BaseEditorActivity :
content.editorAppBarLayout.updatePadding(top = topInset)
}

memUsageView.chart.updateLayoutParams<ViewGroup.MarginLayoutParams> {
topMargin = (insetsTop * progress).roundToInt()
}
}
}

private fun setupMemUsageChart() {
binding.memUsageView.chart.apply {
val colorAccent = resolveAttr(R.attr.colorAccent)
// translationY, not a margin: this runs on every frame of the reveal drag, and a
// margin change calls requestLayout, which now re-measures a ViewPager2, its
// RecyclerView and every attached page rather than the single chart view it used to.
// The visual result is identical for a pure vertical offset.
memUsageView.metricsPager.translationY = insetsTop * progress

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.

@davidschachterADFA MEDIUM — translationY here is not equivalent to the topMargin it replaced.

The comment above says "The visual result is identical for a pure vertical offset", but the two views are constrained differently:

  • the old chart was 0dp high with bottom_toBottomOf="parent", so a top margin shrank it from the top and its bottom edge stayed put;
  • the pager is 0dp with bottom_toTopOf="@id/metrics_title", so a translation moves the whole view down.

With the reveal open (progress == 1), the bottom insetsTop px — status-bar height, ~40dp on a Pixel 6 Pro — slides under the metrics_title TextView, which is drawn after the pager and so paints over it, and past the fixed-height MetricsCarouselLayout, where clipChildren cuts it off. Concretely: open the memory panel and the chart's x-axis labels sit behind the page title or are clipped away.

If the per-frame requestLayout is the concern, offsetting with padding, or reapplying the margin only at drag end, gets the cheap drag without changing the resting geometry.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Confirmed, and the geometry argument is exactly right: the pager is top_toTopOf="parent" / bottom_toTopOf="@id/metrics_title", so its bottom edge is the title's top edge, and any positive translationY puts that much of the chart — the x-axis band — under a TextView declared after it and therefore drawn over it. "The visual result is identical for a pure vertical offset" is only true for a view whose bottom is free, which the old chart was and the pager is not. My comment asserted equivalence it had not earned.

One thing to know before you spend more time on it: this is already reverted one PR up. 567667773 on ADFA-5486 (#1785) puts the margin back —

metricsCarousel.pager?.updateLayoutParams<ViewGroup.MarginLayoutParams> {
    topMargin = (insetsTop * progress).roundToInt()
}

— which is the second of the two remedies you suggested. So the top of the stack is correct and only this PR's own diff carries the translationY.

That leaves a merge-order dependency I should state plainly rather than leave implicit: if #1784 lands on stage before #1785, stage carries this for that window. Say the word and I'll drop the translationY change from this PR instead, so it is sound on its own — it costs a rebase of the ten branches above it, which is why I am asking rather than doing.

}
}

private fun setupMetricsCarousel() {
val pages =
listOf(
// The memory chart is the default page (ADFA-5487). The logo is a placeholder second
// page until there is a real second metric; the network-traffic chart replaces it.
MetricsPage.MemoryChart(title = string.metrics_title_memory),
MetricsPage.Image(
drawable = R.drawable.cogo_brand_mark,
description = string.metrics_carousel_brand_mark,
// The product's own name, from the one place it is defined.
title = string.app_name,
),
)

isDragEnabled = false
description.isEnabled = false
xAxis.axisLineColor = colorAccent
axisRight.axisLineColor = colorAccent
binding.memUsageView.metricsPager.adapter = MetricsCarouselAdapter(pages, memUsageChartRenderer)

setPinchZoom(false)
setBackgroundColor(editorSurfaceContainerBackground)
setDrawGridBackground(true)
setScaleEnabled(true)
val showTitleFor = { position: Int ->
pages.getOrNull(position)?.let { page ->
binding.memUsageView.metricsTitle.setText(page.title)
}
}

axisLeft.isEnabled = false
axisRight.valueFormatter =
object :
IAxisValueFormatter {
override fun getFormattedValue(
value: Float,
axis: AxisBase?,
): String = "%dMB".format(value.roundToLong())
metricsPageCallback =
object : ViewPager2.OnPageChangeCallback() {
override fun onPageSelected(position: Int) {
showTitleFor(position)
}
}
}.also { binding.memUsageView.metricsPager.registerOnPageChangeCallback(it) }

// onPageSelected does not fire for the page the carousel opens on.
showTitleFor(binding.memUsageView.metricsPager.currentItem)
}

private fun watchMemory() {
Expand All @@ -1038,56 +1031,14 @@ abstract class BaseEditorActivity :
resetMemUsageChart()
}

/**
* Rebuilds the memory chart for the currently watched processes. Call after starting or stopping
* watching a process.
*/
protected fun resetMemUsageChart() {
val processes = memoryUsageWatcher.getMemoryUsages()
val datasets =
Array(processes.size) { index ->
LineDataSet(
List(MemoryUsageWatcher.MAX_USAGE_ENTRIES) { Entry(it.toFloat(), 0f) },
processes[index].pname,
)
}

val bgColor = editorSurfaceContainerBackground
val textColor = resolveAttr(R.attr.colorOnSurface)

for ((index, proc) in processes.withIndex()) {
val dataset = datasets[index]
dataset.color = getMemUsageLineColorFor(proc)
dataset.setDrawIcons(false)
dataset.setDrawCircles(false)
dataset.setDrawCircleHole(false)
dataset.setDrawValues(false)
dataset.formLineWidth = 1f
dataset.formSize = 15f
dataset.isHighlightEnabled = false
pidToDatasetIdxMap[proc.pid] = index
}

binding.memUsageView.chart.setBackgroundColor(bgColor)

binding.memUsageView.chart.apply {
data = LineData(*datasets)
axisRight.textColor = textColor
axisLeft.textColor = textColor
legend.textColor = textColor

data.setValueTextColor(textColor)
setBackgroundColor(bgColor)
setGridBackgroundColor(bgColor)
notifyDataSetChanged()
invalidate()
}
memUsageChartRenderer.rebuild()
}

private fun getMemUsageLineColorFor(proc: MemoryUsageWatcher.ProcessMemoryInfo): Int =
when (proc.pname) {
PROC_IDE -> Color.BLUE
PROC_GRADLE_TOOLING -> Color.RED
PROC_GRADLE_DAEMON -> Color.GREEN
else -> throw IllegalArgumentException("Unknown process: $proc")
}

override fun onPause() {
super.onPause()
memoryUsageWatcher.listener = null
Expand Down Expand Up @@ -1885,8 +1836,12 @@ abstract class BaseEditorActivity :

// Filter out diagonal flings so only an intentional right swipe opens the drawer.
// A horizontal fling that started on the bottom-sheet tab strip is the user
// scrolling tabs, not asking for the drawer.
if (isDrawerOpenFling && !isTouchOnBottomSheetTabs(e1)) {
// scrolling tabs, not asking for the drawer; one that started on the metrics
// carousel is the user paging it backwards.
if (isDrawerOpenFling &&
!isTouchOnBottomSheetTabs(e1) &&
!isTouchOnMetricsCarousel(e1)
) {
binding.editorDrawerLayout.openDrawer(GravityCompat.START)
return true
}
Expand All @@ -1908,9 +1863,50 @@ abstract class BaseEditorActivity :

private fun isTouchOnBottomSheetTabs(ev: MotionEvent): Boolean {
val tabs = contentOrNull?.bottomSheet?.binding?.tabs ?: return false
val rect = Rect()
if (!tabs.getGlobalVisibleRect(rect)) return false
return rect.contains(ev.rawX.toInt(), ev.rawY.toInt())
return containsTouch(tabs, ev)
}

private fun isTouchOnMetricsCarousel(ev: MotionEvent): Boolean {
val binding = _binding ?: return false

// A left-to-right fling pages the carousel *backwards*, so there is nothing for it to do
// on the first page -- which is the page the carousel opens on. Excluding the strip
// regardless left the documented right-swipe drawer gesture dead over the whole panel
// while doing nothing in its place.
if (binding.memUsageView.metricsPager.currentItem <= 0) {
return false
}

// The carousel is laid out at the top of the reveal even while the content card covers it,
// and siblings do not clip each other, so getGlobalVisibleRect reports it visible either
// way. Without this check the drawer gesture would be dead over the top of a closed editor.
if (binding.swipeReveal.dragProgress <= 0f) {
return false
}

// The pager, not the whole strip: the title and its row are not something the carousel
// pages from, and MetricsCarouselLayout has already walled that row off from every
// ancestor, so a fling there would otherwise be swallowed twice over.
return containsTouch(binding.memUsageView.metricsPager, ev)
}

private fun containsTouch(
view: View,
ev: MotionEvent,
): Boolean {
if (!view.isShown) return false

// getLocationOnScreen, not getGlobalVisibleRect: the latter reports window coordinates --
// ViewRootImpl intersects with the window and never offsets by its position on screen --
// while rawX/rawY are screen coordinates. In split-screen or freeform the window origin is
// not zero, so the two disagree and the hit test lands somewhere else entirely.
// SwipeRevealLayout.isTouchInDragHandle already uses this idiom.
val location = IntArray(2)
view.getLocationOnScreen(location)
val x = ev.rawX.toInt()
val y = ev.rawY.toInt()
return x >= location[0] && x < location[0] + view.width &&
y >= location[1] && y < location[1] + view.height
}

private fun showTooltip(tag: String) {
Expand Down
Loading
Loading