Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
0984cb5
feat(metrics): add long-press help to the metrics carousel (ADFA-5510)
davidschachterADFA Sep 6, 2026
fae5848
Merge branch 'feature/ADFA-5499-power-chart' into feature/ADFA-5510-c…
davidschachterADFA Sep 6, 2026
eb98d74
docs(metrics): re-attach the rebuild KDoc the help tag displaced (ADF…
davidschachterADFA Sep 6, 2026
d442b36
Merge branch 'feature/ADFA-5499-power-chart' into feature/ADFA-5510-c…
davidschachterADFA Sep 6, 2026
be9873b
Merge branch 'feature/ADFA-5499-power-chart' into feature/ADFA-5510-c…
davidschachterADFA Sep 6, 2026
0464fbe
Merge branch 'feature/ADFA-5486-chart-improvements' into feature/ADFA…
davidschachterADFA Sep 6, 2026
379b91c
fix(metrics): keep the undocked message's help, and stop the target l…
davidschachterADFA Sep 6, 2026
93bee8c
ADFA-5510: address review findings on the carousel's help
davidschachterADFA Sep 6, 2026
d05b32c
Merge branch 'feature/ADFA-5499-power-chart' into feature/ADFA-5510-c…
davidschachterADFA Sep 6, 2026
90ee074
ADFA-5510: let the end arrows answer a long press, without losing the…
davidschachterADFA Sep 6, 2026
f31ebd8
Merge branch 'feature/ADFA-5499-power-chart' into feature/ADFA-5510-c…
davidschachterADFA Sep 6, 2026
3dc11db
Merge branch 'feature/ADFA-5499-power-chart' into feature/ADFA-5510-c…
davidschachterADFA Sep 6, 2026
fcff821
ADFA-5510: give the new adapter test's renderer a help tag
davidschachterADFA Sep 6, 2026
43d922b
Merge branch 'feature/ADFA-5499-power-chart' into feature/ADFA-5510-c…
davidschachterADFA Sep 6, 2026
21dbe68
Merge branch 'feature/ADFA-5499-power-chart' into feature/ADFA-5510-c…
davidschachterADFA Sep 6, 2026
f9b39e2
ADFA-5510: give the window's close control a tag of its own
davidschachterADFA Sep 6, 2026
914ebbc
Merge branch 'feature/ADFA-5499-power-chart' into feature/ADFA-5510-c…
davidschachterADFA Sep 6, 2026
ae6736a
Merge branch 'feature/ADFA-5499-power-chart' into feature/ADFA-5510-c…
davidschachterADFA Sep 7, 2026
498f3d4
ADFA-5510: sweep the last setOnLongClickListener(null) site
davidschachterADFA Sep 7, 2026
68f052a
Merge branch 'feature/ADFA-5499-power-chart' into feature/ADFA-5510-c…
davidschachterADFA Sep 7, 2026
6ec7f88
Merge branch 'feature/ADFA-5499-power-chart' into feature/ADFA-5510-c…
davidschachterADFA Sep 7, 2026
a8225f2
ADFA-5510: keep the legend out of the axis tap band
davidschachterADFA Sep 7, 2026
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
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import com.itsaky.androidide.idetooltips.TooltipCategory
import com.itsaky.androidide.idetooltips.TooltipManager
import com.itsaky.androidide.idetooltips.TooltipTag


object ChromeControlTooltips {
val handler: (ChromeControl, View) -> Unit = { control, anchor ->
tagFor(control)?.let { tag ->
Expand All @@ -20,6 +19,6 @@ object ChromeControlTooltips {
ChromeControl.MINIMIZE -> TooltipTag.WINDOW_MINIMIZE
ChromeControl.MAXIMIZE -> TooltipTag.WINDOW_MAXIMIZE
ChromeControl.DOCK -> TooltipTag.WINDOW_DOCK
ChromeControl.CLOSE -> TooltipTag.WINDOW_UNDOCK
ChromeControl.CLOSE -> TooltipTag.WINDOW_CLOSE
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import android.view.View
import android.view.ViewGroup
import android.view.inputmethod.InputMethodManager
import com.itsaky.androidide.databinding.LayoutMemUsageBinding
import com.itsaky.androidide.floating.model.ChromeControl
import com.itsaky.androidide.floating.model.DockableContent
import com.itsaky.androidide.floating.window.FloatingWindowHost
import com.itsaky.androidide.ui.MetricsCarouselController
Expand All @@ -49,6 +50,17 @@ class MetricsCarouselDockableContent(
) : DockableContent {
override val id: String = ID

/**
* The window chrome's own help, the same handler the editor and plugin tabs install.
*
* Without it the undocked carousel was the one floating window whose minimize, maximize and
* dock controls answered no long press -- and the dock control is the only way back, so it is
* the one that most needs explaining. ADFA-5510 wired help to everything inside the carousel
* and missed the frame around it.
*/
override val onChromeControlLongPress: (ChromeControl, View) -> Unit =
ChromeControlTooltips.handler

override fun onCreateView(
context: Context,
host: FloatingWindowHost,
Expand Down
13 changes: 7 additions & 6 deletions app/src/main/java/com/itsaky/androidide/ui/EditorBottomSheet.kt
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ import com.itsaky.androidide.tasks.runOnUiThread
import com.itsaky.androidide.utils.DiagnosticsFormatter
import com.itsaky.androidide.utils.IntentUtils.shareFile
import com.itsaky.androidide.utils.Symbols.forFile
import com.itsaky.androidide.utils.clearLongPressHelp
import com.itsaky.androidide.utils.dpToPx
import com.itsaky.androidide.utils.flashError
import com.itsaky.androidide.utils.flashSuccess
Expand Down Expand Up @@ -332,17 +333,17 @@ class EditorBottomSheet

binding.tabs.clearOnTabSelectedListeners()
binding.shareOutputAction.setOnClickListener(null)
binding.shareOutputAction.setOnLongClickListener(null)
binding.shareOutputAction.clearLongPressHelp()
binding.clearOutputAction.setOnClickListener(null)
binding.clearOutputAction.setOnLongClickListener(null)
binding.clearOutputAction.clearLongPressHelp()
binding.searchOutputAction.setOnClickListener(null)
binding.searchOutputAction.setOnLongClickListener(null)
binding.searchOutputAction.clearLongPressHelp()
binding.filterOutputAction.setOnClickListener(null)
binding.filterOutputAction.setOnLongClickListener(null)
binding.filterOutputAction.clearLongPressHelp()
binding.wordWrapOutputAction.setOnClickListener(null)
binding.wordWrapOutputAction.setOnLongClickListener(null)
binding.wordWrapOutputAction.clearLongPressHelp()
binding.viewOptionsOutputAction.setOnClickListener(null)
binding.viewOptionsOutputAction.setOnLongClickListener(null)
binding.viewOptionsOutputAction.clearLongPressHelp()
binding.copyDiagnosticsFab.setOnClickListener(null)
binding.headerContainer.setOnClickListener(null)
removeOnLayoutChangeListener(fabLayoutChangeListener)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import com.github.mikephil.charting.data.LineData
import com.github.mikephil.charting.data.LineDataSet
import com.github.mikephil.charting.formatter.IAxisValueFormatter
import com.itsaky.androidide.R
import com.itsaky.androidide.idetooltips.TooltipTag
import com.itsaky.androidide.utils.MemoryUsageWatcher
import com.itsaky.androidide.utils.MemoryUsageWatcher.ProcessMemoryInfo
import com.itsaky.androidide.utils.MetricsAnnotationStore
Expand Down Expand Up @@ -70,11 +71,14 @@ class MemoryUsageChartRenderer(
pidToDatasetIdx.clear()
}

override val helpTag: String = TooltipTag.CAROUSEL_CHART_MEMORY

/**
* Rebuilds the chart's datasets from scratch for the currently watched processes, rendering each
* process's complete [ProcessMemoryInfo.usageHistory]. Call when the set of watched processes
* changes; [onUsagesChanged] calls it on its own when it detects such a change.
*/

@UiThread
override fun rebuild() {
val chart = this.chart ?: return
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,19 @@ import android.view.ViewGroup
import android.widget.ArrayAdapter
import android.widget.Toast
import androidx.annotation.UiThread
import androidx.annotation.VisibleForTesting
import androidx.appcompat.app.AlertDialog
import androidx.core.view.AccessibilityDelegateCompat
import androidx.core.view.ViewCompat
import androidx.core.view.accessibility.AccessibilityNodeInfoCompat
import androidx.core.view.isVisible
import androidx.core.widget.ImageViewCompat
import androidx.viewpager2.widget.ViewPager2
import com.itsaky.androidide.R
import com.itsaky.androidide.app.configuration.IDEBuildConfigProvider
import com.itsaky.androidide.databinding.LayoutMemUsageBinding
import com.itsaky.androidide.floating.window.OverlayDialogs
import com.itsaky.androidide.idetooltips.TooltipTag
import com.itsaky.androidide.resources.R.string
import com.itsaky.androidide.utils.DialogUtils
import com.itsaky.androidide.utils.IntentUtils
Expand All @@ -44,6 +50,9 @@ import com.itsaky.androidide.utils.MetricsSamplingRates
import com.itsaky.androidide.utils.MetricsSnapshot
import com.itsaky.androidide.utils.NetworkUsageWatcher
import com.itsaky.androidide.utils.PowerUsageWatcher
import com.itsaky.androidide.utils.clearLongPressHelp
import com.itsaky.androidide.utils.displayTooltipOnLongPress
import com.itsaky.androidide.utils.showIdeCategoryTooltipIfPresent
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
Expand Down Expand Up @@ -250,13 +259,61 @@ class MetricsCarouselController(
// annoying.
binding.metricsPrevious.setOnClickListener { step(-1) }
binding.metricsNext.setOnClickListener { step(1) }
// After the click listeners, which set isClickable themselves.
ViewCompat.setAccessibilityDelegate(binding.metricsPrevious, arrowAccessibilityDelegate)
ViewCompat.setAccessibilityDelegate(binding.metricsNext, arrowAccessibilityDelegate)
updateArrows(binding.metricsPager.currentItem)

wireHelp(binding)
Comment thread
coderabbitai[bot] marked this conversation as resolved.

memoryUsageWatcher.listener = memoryListener
networkUsageWatcher.listener = networkListener
powerUsageWatcher.listener = powerListener
}

/**
* Gives every control in the strip its long-press help (ADFA-5510).
*
* Here rather than at each host, because this runs for the docked strip and for the floating
* window alike -- the window's own chrome already carries the `window-*` tags, and the carousel
* inside it is this same controller.
*
* The charts are absent from this list on purpose: MPAndroidChart swallows the touch events a
* view-level long press would need, so each renderer answers through the chart's gesture
* listener instead.
*/
@UiThread
private fun wireHelp(binding: LayoutMemUsageBinding) {
val context = binding.root.context
helpTargets(binding).forEach { (view, tag) ->
view.displayTooltipOnLongPress(context, tag)
}
}

/**
* Every control that answers a long press, and the tag it answers with.
*
* One list drives the wiring, the unwiring and the test, because three hand-maintained copies
* is how a control added later gets help on binding and keeps a stale listener after unbinding.
*
* The charts are absent on purpose: MPAndroidChart swallows the touch events a view-level long
* press needs, so each renderer answers through the chart's own gesture listener instead.
*/
@VisibleForTesting
internal fun helpTargets(binding: LayoutMemUsageBinding): List<Pair<View, String>> =
listOf(
// The strip itself, for the gaps its children do not cover.
binding.root to TooltipTag.CAROUSEL_PANEL,
binding.metricsTitle to TooltipTag.CAROUSEL_TITLE,
binding.metricsPrevious to TooltipTag.CAROUSEL_PREVIOUS,
binding.metricsNext to TooltipTag.CAROUSEL_NEXT,
binding.metricsSnapshot to TooltipTag.CAROUSEL_SNAPSHOT,
binding.metricsBattery to TooltipTag.CAROUSEL_BATTERY,
// Wired even though it is only visible while undocked: the message is the one control
// that outlives unbind(), so its help must not be torn down with the rest.
binding.metricsUndockedMessage to TooltipTag.CAROUSEL_UNDOCKED,
)

/**
* Stops feeding the carousel and releases the bound views. Sampling is unaffected -- the
* watchers keep their history, so re-binding shows it in full.
Expand All @@ -277,8 +334,19 @@ class MetricsCarouselController(
networkRenderer.onXAxisTap = null
powerRenderer.onXAxisTap = null
binding?.metricsSnapshot?.setOnClickListener(null)
binding?.let { bound ->
helpTargets(bound)
// All but the undocked message: that view becomes visible *because* the carousel
// unbound, so clearing its listener here left the one control the user can still
// reach with no help at all.
.filterNot { (view, _) -> view === bound.metricsUndockedMessage }
.map { (view, _) -> view }
.forEach(View::clearLongPressHelp)
}
binding?.metricsPrevious?.setOnClickListener(null)
binding?.metricsNext?.setOnClickListener(null)
binding?.metricsPrevious?.let { ViewCompat.setAccessibilityDelegate(it, null) }
binding?.metricsNext?.let { ViewCompat.setAccessibilityDelegate(it, null) }
pageCallback?.let { binding?.metricsPager?.unregisterOnPageChangeCallback(it) }
pageCallback = null

Expand Down Expand Up @@ -330,10 +398,30 @@ class MetricsCarouselController(
@UiThread
private fun updateArrows(position: Int) {
val binding = this.binding ?: return
binding.metricsPrevious.isEnabled = position > 0
binding.metricsNext.isEnabled = position < pages.lastIndex
binding.metricsPrevious.alpha = if (position > 0) 1f else DISABLED_ARROW_ALPHA
binding.metricsNext.alpha = if (position < pages.lastIndex) 1f else DISABLED_ARROW_ALPHA
setPagingAvailable(binding.metricsPrevious, available = position > 0)
setPagingAvailable(binding.metricsNext, available = position < pages.lastIndex)
}

/**
* Marks an arrow as leading somewhere, or not.
*
* Deliberately not `isEnabled`. A disabled View still consumes a touch and then drops it
* without calling any listener, so a long press on the arrow at either end of the carousel
* showed no tooltip -- and that is the arrow whose greying-out a user is likeliest to ask
* about. [isClickable] is the narrower statement and the true one: the arrow does not answer a
* tap, but it does answer a long press. [step] clamps anyway, so a tap on a dimmed arrow was
* already a no-op.
*
* Alpha alone would have lost the state for anyone who cannot see it, since a screen reader
* reads a node's flags rather than its opacity. [arrowAccessibilityDelegate] puts it back.
*/
@UiThread
private fun setPagingAvailable(
arrow: View,
available: Boolean,
) {
arrow.alpha = if (available) 1f else DIMMED_ARROW_ALPHA
arrow.isClickable = available
}

/**
Expand Down Expand Up @@ -430,13 +518,23 @@ class MetricsCarouselController(
// No setMessage: an AlertDialog shows either a message or a list, never both, and
// the message silently wins. The unavailable entries carry the explanation instead.
.setNegativeButton(string.cancel) { dismissable, _ -> dismissable.dismiss() }
// A dialog has no free surface to long-press, so help is a button here rather than a
// gesture. It does not dismiss: the point is to read it and then choose a rate.
.setNeutralButton(string.help, null)
.create()

// Not builder.show(): while the carousel is floating, `context` is the overlay window's
// context, which carries no activity token -- adding an ordinary application window
// against it throws BadTokenException. OverlayDialogs raises the dialog to the overlay
// window type first, which also puts it above the floating windows instead of behind them.
OverlayDialogs.show(dialog)

// After show: an AlertDialog has no buttons to reach before then.
dialog.getButton(AlertDialog.BUTTON_NEUTRAL)?.setOnClickListener { helpAnchor ->
// No haptic: this is a plain tap, and the default buzz is the platform's long-press
// feedback, which would mis-signal what the user just did.
showIdeCategoryTooltipIfPresent(context, helpAnchor, TooltipTag.CAROUSEL_RATE, playHapticFeedback = false)
}
}

/**
Expand Down Expand Up @@ -593,7 +691,27 @@ class MetricsCarouselController(
else -> null
}

const val DISABLED_ARROW_ALPHA = 0.35f
const val DIMMED_ARROW_ALPHA = 0.35f

/**
* Reports an arrow that leads nowhere as disabled, and as offering no tap.
*
* The views stay touch-enabled so they can still answer a long press with their tooltip
* (see [setPagingAvailable]); without this, TalkBack would offer "double-tap to activate"
* on an arrow that does nothing, and give no hint that the carousel has an end. Reads
* [View.isClickable] rather than holding its own copy, so there is one source of truth.
*/
val arrowAccessibilityDelegate =
object : AccessibilityDelegateCompat() {
override fun onInitializeAccessibilityNodeInfo(
host: View,
info: AccessibilityNodeInfoCompat,
) {
super.onInitializeAccessibilityNodeInfo(host, info)
info.isEnabled = host.isClickable
info.isClickable = host.isClickable
}
}

/** Dims a rate this device cannot offer, so the list shows what the hardware costs. */
const val UNAVAILABLE_RATE_ALPHA = 0.4f
Expand Down
64 changes: 62 additions & 2 deletions app/src/main/java/com/itsaky/androidide/ui/MetricsChartRenderer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,10 @@ import com.github.mikephil.charting.formatter.IAxisValueFormatter
import com.github.mikephil.charting.listener.ChartTouchListener
import com.github.mikephil.charting.listener.OnChartGestureListener
import com.itsaky.androidide.R
import com.itsaky.androidide.idetooltips.TooltipTag
import com.itsaky.androidide.utils.MetricsAnnotationStore
import com.itsaky.androidide.utils.resolveAttr
import com.itsaky.androidide.utils.showIdeCategoryTooltipIfPresent
import kotlin.math.ceil
import kotlin.math.floor
import kotlin.math.roundToLong
Expand Down Expand Up @@ -66,6 +68,56 @@ abstract class MetricsChartRenderer(
*/
var onXAxisTap: (() -> Unit)? = null

/**
* The help tag for this page's plot, shown on a long press (ADFA-5510).
*
* Routed through the chart's own gesture listener rather than [android.view.View.setOnLongClickListener]:
* MPAndroidChart's `BarLineChartBase.onTouchEvent` hands the event to its touch listener and
* never calls `super`, so the framework's long-press detection never runs and a view listener
* would be installed, look wired, and never fire.
*/
protected abstract val helpTag: String

/**
* The help tag for a long press at [y], or `null` if this page has none.
*
* Separated from showing the tooltip so it can be tested: TooltipManager reads the docs
* database from device storage in its static initialiser and cannot be loaded off-device.
*/
@VisibleForTesting
internal fun helpTagAt(y: Float): String? {
// The axis band answers for the sampling rate, the plot for the metric itself, matching
// where a tap goes.
return if (isOnAxisBand(y)) TooltipTag.CAROUSEL_AXIS_TIME else helpTag
}

/**
* Whether [y] landed on the x axis band rather than in the plot.
*
* One predicate, because the tap that opens the sampling-rate chooser and the long press that
* explains it have to agree on where that band is: written twice, they can drift apart and the
* tooltip then describes a control the tap no longer reaches.
*
* Bounded below, not just above. Everything under the plot used to count, and the legend lives
* there too -- MPAndroidChart aligns it to the bottom by default, under the axis labels. So
* tapping the legend, which is the one thing in a chart a reader expects to be tappable, opened
* the sampling-rate chooser; picking a rate there clears every buffer, and the user loses the
* history they were looking at for an action they did not ask for.
*
* The band stops at the legend's top edge, and is never narrower than one axis label, so a
* legend that measures larger than expected cannot squeeze the rate chooser out of reach.
*/
private fun isOnAxisBand(y: Float): Boolean {
val chart = this.chart ?: return false
val top = chart.viewPortHandler.contentBottom()
val legend = chart.legend
// What the chart reserves for the legend at the bottom: its measured height plus the
// offset it keeps above itself. Both are pixels, as MPAndroidChart stores them.
val reservedForLegend = if (legend.isEnabled) legend.mNeededHeight + legend.yOffset else 0f
val bottom = maxOf(chart.height - reservedForLegend, top + chart.xAxis.textSize)
return y >= top && y < bottom
}

/**
* Whether the user has pinched this chart.
*
Expand Down Expand Up @@ -288,7 +340,7 @@ abstract class MetricsChartRenderer(
) : OnChartGestureListener {
override fun onChartSingleTapped(me: MotionEvent?) {
val y = me?.y ?: return
if (y >= chart.viewPortHandler.contentBottom()) {
if (isOnAxisBand(y)) {
onXAxisTap?.invoke()
}
}
Expand All @@ -303,7 +355,15 @@ abstract class MetricsChartRenderer(
lastPerformedGesture: ChartTouchListener.ChartGesture?,
) = Unit

override fun onChartLongPressed(me: MotionEvent?) = Unit
override fun onChartLongPressed(me: MotionEvent?) {
val y = me?.y ?: return
val tag = helpTagAt(y) ?: return
// Haptic feedback left at its default, unlike every view-based help site, which
// passes false. Those rely on View.performLongClick buzzing for them;
// BarLineChartBase.onTouchEvent never calls super, so the framework's long press --
// and its feedback -- never runs here and this is the only thing that provides it.
showIdeCategoryTooltipIfPresent(chart.context, chart, tag)
}

override fun onChartDoubleTapped(me: MotionEvent?) = Unit

Expand Down
Loading
Loading