diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b21c2d872f..d57f254ba4 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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) diff --git a/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt index d1957709da..5e00e7a010 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt @@ -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 @@ -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 @@ -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 @@ -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 @@ -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. @@ -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() private var feedbackButtonManager: FeedbackButtonManager? = null @@ -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 = @@ -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 @@ -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 @@ -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) { @@ -899,7 +883,7 @@ abstract class BaseEditorActivity : ) feedbackButtonManager?.setupDraggableFab() - setupMemUsageChart() + setupMetricsCarousel() watchMemory() observeFileOperations() @@ -1000,36 +984,45 @@ abstract class BaseEditorActivity : content.editorAppBarLayout.updatePadding(top = topInset) } - memUsageView.chart.updateLayoutParams { - 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 + } + } + + 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() { @@ -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 @@ -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 } @@ -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) { diff --git a/app/src/main/java/com/itsaky/androidide/activities/editor/ProjectHandlerActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/editor/ProjectHandlerActivity.kt index 26ed2966e3..afc6707577 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/editor/ProjectHandlerActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/editor/ProjectHandlerActivity.kt @@ -716,7 +716,11 @@ abstract class ProjectHandlerActivity : BaseEditorActivity() { service.startToolingServer { pid -> memoryUsageWatcher.watchProcess(pid, PROC_GRADLE_TOOLING) - resetMemUsageChart() + // The callback arrives on the tooling server's own thread, and the renderer is + // @UiThread: rebuild() clears and repopulates a non-thread-safe pid map that the + // once-a-second sample listener reads on the main thread, so racing it can plot one + // process's samples on another's line or throw out of the entry loop. + runOnUiThread { resetMemUsageChart() } service.metadata().whenComplete { metadata, err -> if (metadata == null || err != null) { @@ -731,7 +735,8 @@ abstract class ProjectHandlerActivity : BaseEditorActivity() { metadata.pid, ) memoryUsageWatcher.watchProcess(metadata.pid, PROC_GRADLE_TOOLING) - resetMemUsageChart() + // A CompletableFuture completion thread, for the same reason as above. + runOnUiThread { resetMemUsageChart() } } } diff --git a/app/src/main/java/com/itsaky/androidide/ui/MemoryUsageChartRenderer.kt b/app/src/main/java/com/itsaky/androidide/ui/MemoryUsageChartRenderer.kt new file mode 100644 index 0000000000..8d348944a7 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/ui/MemoryUsageChartRenderer.kt @@ -0,0 +1,232 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.ui + +import androidx.annotation.UiThread +import androidx.collection.IntObjectMap +import androidx.collection.MutableIntIntMap +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 com.itsaky.androidide.R +import com.itsaky.androidide.utils.MemoryUsageWatcher +import com.itsaky.androidide.utils.MemoryUsageWatcher.ProcessMemoryInfo +import com.itsaky.androidide.utils.ShiftedLongArray +import com.itsaky.androidide.utils.resolveAttr +import kotlin.math.roundToLong + +/** + * Renders [MemoryUsageWatcher] samples into a [SafeLineChart]. + * + * The chart view is attached and detached independently of the data: [MemoryUsageWatcher] owns the + * per-process [ProcessMemoryInfo.usageHistory] ring buffers, so this renderer holds no sample state + * of its own and can rebuild a complete chart from [usagesProvider] at any time. That is what makes + * the chart safe to host in a recycling container (ADFA-5487's metrics carousel): a chart view that + * is created long after watching began still shows the full history, and one that is recycled away + * loses nothing. + * + * All methods must be called on the UI thread. MPAndroidChart is not thread-safe; see [SafeLineChart]. + * + * @param usagesProvider Supplies the currently watched processes, newest state each call. + * @param lineColorFor Supplies the plot line color for a process. + */ +class MemoryUsageChartRenderer( + private val usagesProvider: () -> Array, + private val lineColorFor: (ProcessMemoryInfo) -> Int, +) { + private var chart: SafeLineChart? = null + + /** + * Maps a watched pid to its dataset index in the attached chart's [LineData]. Empty whenever no + * chart is attached. + */ + private val pidToDatasetIdx = MutableIntIntMap(initialCapacity = 3) + + /** + * Attaches [chart], applies the static chart configuration, and renders the full current + * history. Replaces any previously attached chart. + */ + @UiThread + fun attach(chart: SafeLineChart) { + this.chart = chart + configure(chart) + rebuild() + } + + /** + * Detaches the current chart. Sample history is unaffected; a later [attach] renders it in full. + */ + @UiThread + fun detach() { + chart = null + pidToDatasetIdx.clear() + } + + /** + * Detaches [chart] only if it is the currently attached one. Use from a recycling container, + * where the replacement view can be bound before the view it replaces is recycled. + */ + @UiThread + fun detachIfAttached(chart: SafeLineChart) { + if (this.chart === chart) { + detach() + } + } + + /** + * 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 + fun rebuild() { + val chart = this.chart ?: return + val processes = usagesProvider() + + pidToDatasetIdx.clear() + + val datasets = + Array(processes.size) { index -> + val proc = processes[index] + pidToDatasetIdx[proc.pid] = index + + LineDataSet( + List(proc.usageHistory.size) { entryIdx -> + Entry(entryIdx.toFloat(), proc.usageHistory.megabytesAt(entryIdx)) + }, + proc.pname, + ).apply { + color = lineColorFor(proc) + setDrawIcons(false) + setDrawCircles(false) + setDrawCircleHole(false) + setDrawValues(false) + formLineWidth = 1f + formSize = 15f + isHighlightEnabled = false + label = labelFor(proc.pname, entries.lastOrNull()?.y ?: 0f) + } + } + + val bgColor = chart.context.resolveAttr(R.attr.colorSurfaceDim) + val textColor = chart.context.resolveAttr(R.attr.colorOnSurface) + + chart.apply { + data = LineData(*datasets) + axisRight.textColor = textColor + axisLeft.textColor = textColor + legend.textColor = textColor + + data.setValueTextColor(textColor) + setBackgroundColor(bgColor) + setGridBackgroundColor(bgColor) + notifyDataSetChanged() + invalidate() + } + } + + /** + * Renders a fresh set of samples into the attached chart, mutating the existing entries in place. + * + * Falls back to [rebuild] when [memoryUsage] no longer matches the datasets the chart was built + * with -- a process started or stopped being watched, or the chart was attached before this pid + * existed. The in-place path is the common one: it mutates the existing entries rather than + * rebuilding the datasets, which is what matters because this runs once a second for the + * lifetime of the editor. It is not allocation-free -- each series reformats its legend label + * every tick -- so do not add work here on the assumption that it is. + */ + @UiThread + fun onUsagesChanged(memoryUsage: IntObjectMap) { + val chart = this.chart ?: return + + if (memoryUsage.size != pidToDatasetIdx.size) { + rebuild() + return + } + + var dataChanged = false + memoryUsage.forEachValue { proc -> + val datasetIdx = pidToDatasetIdx.getOrDefault(proc.pid, -1) + val dataset = chart.data?.getDataSetByIndex(datasetIdx) as LineDataSet? + if (dataset == null) { + // The chart's datasets no longer describe the watched processes. Rebuild rather than + // dropping this process's samples on the floor, as the previous code did. + rebuild() + return + } + + for (index in dataset.entries.indices) { + dataset.entries[index].y = proc.usageHistory.megabytesAt(index) + } + + dataset.label = labelFor(proc.pname, dataset.entries.lastOrNull()?.y ?: 0f) + dataset.notifyDataSetChanged() + dataChanged = true + } + + if (dataChanged) { + chart.apply { + data.notifyDataChanged() + notifyDataSetChanged() + invalidate() + } + } + } + + /** + * Applies the configuration that does not depend on the samples. Idempotent. + */ + private fun configure(chart: SafeLineChart) { + chart.apply { + val colorAccent = context.resolveAttr(R.attr.colorAccent) + + isDragEnabled = false + description.isEnabled = false + xAxis.axisLineColor = colorAccent + axisRight.axisLineColor = colorAccent + + setPinchZoom(false) + setBackgroundColor(context.resolveAttr(R.attr.colorSurfaceDim)) + setDrawGridBackground(true) + setScaleEnabled(true) + + axisLeft.isEnabled = false + axisRight.valueFormatter = + object : IAxisValueFormatter { + override fun getFormattedValue( + value: Float, + axis: AxisBase?, + ): String = "%dMB".format(value.roundToLong()) + } + } + } + + private fun labelFor( + pname: String, + megabytes: Float, + ): String = "%s - %.2fMB".format(pname, megabytes) +} + +internal const val BYTES_PER_MEGABYTE = 1024.0 * 1024.0 + +/** + * The sample at [index] in megabytes. [MemoryUsageWatcher] stores bytes. + */ +private fun ShiftedLongArray.megabytesAt(index: Int): Float = (this[index] / BYTES_PER_MEGABYTE).toFloat() diff --git a/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselAdapter.kt b/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselAdapter.kt new file mode 100644 index 0000000000..4b602584fc --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselAdapter.kt @@ -0,0 +1,140 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.ui + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.recyclerview.widget.RecyclerView +import com.itsaky.androidide.R + +/** + * A page of the editor's metrics carousel. + * + * @property title Names the page. Shown below the carousel, and the only cue to which page is + * showing, so every page needs one. + */ +sealed interface MetricsPage { + @get:StringRes val title: Int + + /** The live memory-usage chart, rendered by [MemoryUsageChartRenderer]. */ + data class MemoryChart( + @StringRes override val title: Int, + ) : MetricsPage + + /** A static image. Placeholder page until real metrics exist to show alongside memory. */ + data class Image( + @DrawableRes val drawable: Int, + @StringRes val description: Int, + @StringRes override val title: Int, + ) : MetricsPage +} + +/** + * Backs the editor's horizontally swipeable carousel of metric displays. + * + * [pages] is a constructor argument rather than a hardcoded list so that new displays -- a network + * traffic chart, or pages contributed by plugins -- can be added without touching this class. + * + * The chart page holds no sample state of its own: [chartRenderer] is attached when the page binds + * and detached when it is recycled, and rebuilds the full history from [MemoryUsageChartRenderer]'s + * watcher each time. Swiping away from the chart and back therefore loses nothing. + */ +class MetricsCarouselAdapter( + private val pages: List, + private val chartRenderer: MemoryUsageChartRenderer, +) : RecyclerView.Adapter() { + sealed class PageViewHolder( + view: View, + ) : RecyclerView.ViewHolder(view) { + class MemoryChart( + val chart: SafeLineChart, + ) : PageViewHolder(chart) + + class Image( + val image: ImageView, + ) : PageViewHolder(image) + } + + override fun getItemCount(): Int = pages.size + + override fun getItemViewType(position: Int): Int = + when (pages[position]) { + is MetricsPage.MemoryChart -> VIEW_TYPE_MEMORY_CHART + is MetricsPage.Image -> VIEW_TYPE_IMAGE + } + + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int, + ): PageViewHolder { + val inflater = LayoutInflater.from(parent.context) + return when (viewType) { + VIEW_TYPE_MEMORY_CHART -> { + PageViewHolder.MemoryChart( + inflater.inflate(R.layout.item_metrics_memory_chart, parent, false) as SafeLineChart, + ) + } + + VIEW_TYPE_IMAGE -> { + PageViewHolder.Image( + inflater.inflate(R.layout.item_metrics_image, parent, false) as ImageView, + ) + } + + else -> { + throw IllegalArgumentException("Unknown metrics page view type: $viewType") + } + } + } + + override fun onBindViewHolder( + holder: PageViewHolder, + position: Int, + ) { + when (val page = pages[position]) { + is MetricsPage.MemoryChart -> { + chartRenderer.attach((holder as PageViewHolder.MemoryChart).chart) + } + + is MetricsPage.Image -> { + (holder as PageViewHolder.Image).image.apply { + setImageResource(page.drawable) + contentDescription = context.getString(page.description) + } + } + } + } + + override fun onViewRecycled(holder: PageViewHolder) { + if (holder is PageViewHolder.MemoryChart) { + // Only if this holder's chart is still the attached one: a rebind can create the + // replacement before RecyclerView recycles the view it replaced, and detaching then + // would drop the new chart instead of the old. + chartRenderer.detachIfAttached(holder.chart) + } + } + + private companion object { + const val VIEW_TYPE_MEMORY_CHART = 0 + const val VIEW_TYPE_IMAGE = 1 + } +} diff --git a/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselLayout.kt b/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselLayout.kt new file mode 100644 index 0000000000..79dd92c872 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselLayout.kt @@ -0,0 +1,56 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.ui + +import android.content.Context +import android.util.AttributeSet +import android.view.MotionEvent +import androidx.constraintlayout.widget.ConstraintLayout + +/** + * Host for the editor's metrics carousel, which claims horizontal gestures that begin inside it. + * + * The carousel pages with a horizontal swipe, but a left-to-right swipe elsewhere in the editor + * opens the navigation drawer -- documented behaviour, shown in the editor's own onboarding text. + * Without this, the carousel could only page forwards. Asking every ancestor not to intercept, for + * the rest of the gesture, hands horizontal drags that start in this strip to [ViewPager2] and + * leaves the drawer gesture untouched everywhere else. + * + * This covers ancestors that intercept through the view hierarchy. The editor also runs an + * activity-level [android.view.GestureDetector] from `dispatchTouchEvent`, which never calls + * `onInterceptTouchEvent` and so cannot be stopped this way; `BaseEditorActivity` excludes this + * view's bounds there instead, the same way it already excludes the bottom-sheet tab strip. + * + * The vertical reveal drag is unaffected: `SwipeRevealLayout` only captures a vertical drag whose + * touch-down landed in its configured drag handle (the editor app bar), never in this strip. + */ +class MetricsCarouselLayout + @JvmOverloads + constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0, + ) : ConstraintLayout(context, attrs, defStyleAttr) { + override fun onInterceptTouchEvent(ev: MotionEvent): Boolean { + if (ev.actionMasked == MotionEvent.ACTION_DOWN) { + // Cleared by the framework on the next ACTION_DOWN, so this lasts exactly one gesture. + parent?.requestDisallowInterceptTouchEvent(true) + } + return super.onInterceptTouchEvent(ev) + } + } diff --git a/app/src/main/java/com/itsaky/androidide/ui/SwipeRevealLayout.kt b/app/src/main/java/com/itsaky/androidide/ui/SwipeRevealLayout.kt index c3888d672c..86a0ee2503 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/SwipeRevealLayout.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/SwipeRevealLayout.kt @@ -26,388 +26,372 @@ import android.view.ViewGroup import androidx.annotation.CallSuper import androidx.annotation.FloatRange import androidx.annotation.IdRes +import androidx.core.content.withStyledAttributes import androidx.customview.widget.ViewDragHelper import com.google.android.material.shape.MaterialShapeDrawable import com.itsaky.androidide.R import kotlin.math.max import kotlin.math.min -import androidx.core.content.withStyledAttributes /** * A layout which can be dragged vertically to reveal a hidden content. * * @author Akash Yadav */ -open class SwipeRevealLayout @JvmOverloads constructor( - context: Context, - attrs: AttributeSet? = null, - defStyleAttr: Int = 0, - defStyleRes: Int = 0, -) : ViewGroup(context, attrs, defStyleAttr, defStyleRes) { - - /** - * Interface for listening to drag events. - */ - interface OnDragListener { - - /** - * Called when the drag state changes. - */ - fun onDragStateChanged(swipeRevealLayout: SwipeRevealLayout, state: Int) - - /** - * Called when the drag progress changes. - */ - fun onDragProgress(swipeRevealLayout: SwipeRevealLayout, progress: Float) - } - - private val leftDragHelper: ViewDragHelper - private val rightDragHelper: ViewDragHelper - - private var leftDragProgress = 0f - private var rightDragProgress = 0f - private var isVerticalDragEnabled = true - - private var isDownInDragHandle = false - /** - * Whether the most recent touch-down landed within the configured drag handle. The vertical - * drag-to-reveal gesture is only captured when this is `true`, so that scroll gestures starting - * in the middle of the overlapping content (e.g. the editor) are not stolen. - */ - private val dragHandleLocation = IntArray(2) - - init { - leftDragHelper = ViewDragHelper.create(this, 1f, LeftDragCallback()) - rightDragHelper = ViewDragHelper.create(this, 1f, RightDragCallback()) - } - - private val dragHelperCallback = object : ViewDragHelper.Callback() { - override fun tryCaptureView(child: View, pointerId: Int): Boolean { - return isVerticalDragEnabled && isDownInDragHandle && child === overlappingContent - } - - override fun onViewPositionChanged(changedView: View, left: Int, top: Int, dx: Int, dy: Int) { - draggingViewTop = top - onDragProgress(min(1f, top.toFloat() / dragHeightMax.toFloat())) - } - - override fun getViewVerticalDragRange(child: View): Int { - return if (isVerticalDragEnabled) dragHeightMax else 0 - } - - override fun getOrderedChildIndex(index: Int): Int { - return OVERLAPPING_CONTENT_INDEX - } - - override fun clampViewPositionVertical(child: View, top: Int, dy: Int): Int { - return min(max(top, paddingTop), dragHeightMax) - } - - override fun onViewDragStateChanged(state: Int) { - if (state == draggingState) { - return - } - - if (isDragging && state == ViewDragHelper.STATE_IDLE) { - isOpen = draggingViewTop >= dragHeightMax - } - - onDragStateChanged(state) - } - - override fun onViewReleased(releasedChild: View, xvel: Float, yvel: Float) { - if (draggingViewTop == 0) { - isOpen = false - return - } - - if (draggingViewTop >= dragHeightMax) { - isOpen = true - return - } - - // whether the view should settle to open or close - val settleDestY = if (yvel > AUTO_OPEN_VELOCITY_LIM || draggingViewTop > dragHeightMax / 2) { - dragHeightMax - } else { - paddingTop - } - - if (dragHelper.settleCapturedViewAt(0, settleDestY)) { - this@SwipeRevealLayout.postInvalidateOnAnimation() - } - } - } - - private val hiddenContent: View - get() = getChildAt(HIDDEN_CONTENT_INDEX)!! - - private val overlappingContent: View - get() = getChildAt(OVERLAPPING_CONTENT_INDEX)!! - - private var draggingState = -1 - private var draggingViewTop = 0 - private val dragHeightMax - get() = hiddenContent.height - - private lateinit var dragHelper: ViewDragHelper - - /** - * Whether the view is currently in 'dragging' state. - */ - val isDragging: Boolean - get() = draggingState == ViewDragHelper.STATE_DRAGGING || - draggingState == ViewDragHelper.STATE_SETTLING - - /** - * The ID of the view which will be dragged to reveal the content. - */ - @IdRes - var dragHandleViewId = 0 - - /** - * The current drag progress. - */ - @FloatRange(from = 0.0, to = 1.0) - var dragProgress = 0.0f - private set - - /** - * Listener for drag events. - */ - var dragListener: OnDragListener? = null - - /** - * Whether the view is open. - */ - var isOpen = false - protected set - - companion object { - - private const val HIDDEN_CONTENT_INDEX = 0 - private const val OVERLAPPING_CONTENT_INDEX = 1 - - @Suppress("UNUSED") - const val STATE_IDLE = ViewDragHelper.STATE_IDLE - - @Suppress("UNUSED") - const val STATE_DRAGGING = ViewDragHelper.STATE_DRAGGING - - @Suppress("UNUSED") - const val STATE_SETTLING = ViewDragHelper.STATE_SETTLING - - const val AUTO_OPEN_VELOCITY_LIM = 800.0 - } - - init { - if (attrs != null) { - context.withStyledAttributes( - attrs, R.styleable.SwipeRevealLayout, - defStyleAttr, defStyleRes - ) { - dragHandleViewId = getResourceId( - R.styleable.SwipeRevealLayout_dragHandle, - dragHandleViewId - ) - } - } - } - - override fun onFinishInflate() { - super.onFinishInflate() - this.dragHelper = ViewDragHelper.create(this, dragHelperCallback) - this.isOpen = false - - check(childCount == 2) { - "SwipeRevealLayout must have exactly two children; the hidden content and the overlapping content" - } - } - - override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { - measureChildren(widthMeasureSpec, heightMeasureSpec) - - val maxWidth = MeasureSpec.getSize(widthMeasureSpec) - val maxHeight = MeasureSpec.getSize(heightMeasureSpec) - - setMeasuredDimension(resolveSizeAndState(maxWidth, widthMeasureSpec, 0), - resolveSizeAndState(maxHeight, heightMeasureSpec, 0)) - } - - override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) { - hiddenContent.layout(0, paddingTop, r, paddingTop + hiddenContent.measuredHeight) - - val olapTop = paddingTop + (hiddenContent.height * dragProgress).toInt() - // Ensure overlappingContent extends to bottom to fill available space - overlappingContent.layout(0, olapTop, r, b) - } - - override fun onInterceptTouchEvent(ev: MotionEvent): Boolean { - val action = ev.actionMasked - if (action == MotionEvent.ACTION_CANCEL || action == MotionEvent.ACTION_UP) { - leftDragHelper.cancel() - rightDragHelper.cancel() - dragHelper.cancel() - return false - } - if (action == MotionEvent.ACTION_DOWN) { - isDownInDragHandle = isTouchInDragHandle(ev) - } - val isLeft = leftDragHelper.shouldInterceptTouchEvent(ev) - val isRight = rightDragHelper.shouldInterceptTouchEvent(ev) - val isVertical = dragHelper.shouldInterceptTouchEvent(ev) - return isLeft || isRight || isVertical - } - - @SuppressLint("ClickableViewAccessibility") - override fun onTouchEvent(event: MotionEvent): Boolean { - leftDragHelper.processTouchEvent(event) - rightDragHelper.processTouchEvent(event) - dragHelper.processTouchEvent(event) - return true - } - - /** - * Returns whether the given touch event falls within the bounds of the configured drag handle - * (see [dragHandleViewId] / the `app:dragHandle` attribute). When no drag handle is configured, - * the whole overlapping content acts as the handle (legacy behavior). - */ - private fun isTouchInDragHandle(ev: MotionEvent): Boolean { - if (dragHandleViewId == 0) { - return true - } - - val handle = findViewById(dragHandleViewId) - if (handle == null || handle.visibility != VISIBLE) { - return false - } - - handle.getLocationOnScreen(dragHandleLocation) - val left = dragHandleLocation[0] - val top = dragHandleLocation[1] - val right = left + handle.width - val bottom = top + handle.height - val x = ev.rawX - val y = ev.rawY - return x >= left && x <= right && y >= top && y <= bottom - } - - override fun computeScroll() { - if (leftDragHelper.continueSettling(true) or rightDragHelper.continueSettling(true) or dragHelper.continueSettling(true)) { - postInvalidateOnAnimation() - } - } - - /** - * Internal callback. Invoked when the drag state changes. - */ - @CallSuper - protected open fun onDragStateChanged(state: Int) { - draggingState = state - dragListener?.onDragStateChanged(this, state) - } - - /** - * Internal callback. Invoked when the drag progress changes. - */ - @CallSuper - protected open fun onDragProgress(progress: Float) { - if (dragProgress == progress) { - return - } - - dragProgress = progress - applyDragProgress(progress) - dragListener?.onDragProgress(this, progress) - } - - /** - * Applies the drag progress to the content. - */ - protected open fun applyDragProgress(progress: Float) { - val min = 0.97f - val max = 1f - val scale = min + (max - min) * (1 - progress) - overlappingContent.scaleX = scale - overlappingContent.scaleY = scale - (overlappingContent.background as? MaterialShapeDrawable?)?.interpolation = progress - } - - /** - * Toggles the state of the view. - */ - fun toggleState(isOpen: Boolean) { - if (isOpen) { - open() - } else { - close() - } - } - - /** - * Opens the view. - */ - fun open() { - if (isOpen) { - return - } - smoothSlideTo(1f) - } - - /** - * Closes the view. - */ - fun close() { - if (!isOpen) { - return - } - - smoothSlideTo(0f) - } - - fun setVerticalDragEnabled(enabled: Boolean) { - this.isVerticalDragEnabled = enabled - if (!enabled && isOpen) { - close() - } - } - - private fun smoothSlideTo(offset: Float) { - val y = paddingTop + offset * dragHeightMax - if (dragHelper.smoothSlideViewTo(overlappingContent, overlappingContent.left, y.toInt())) { - postInvalidateOnAnimation() - } - } - - private inner class LeftDragCallback : ViewDragHelper.Callback() { - override fun tryCaptureView(child: View, pointerId: Int): Boolean { - return child.id == R.id.drawer_sidebar // Your left drawer ID - } - - override fun onViewPositionChanged(changedView: View, left: Int, top: Int, dx: Int, dy: Int) { - leftDragProgress = left.toFloat() / changedView.width - dragListener?.onDragProgress(this@SwipeRevealLayout, leftDragProgress) - invalidate() - } - - override fun clampViewPositionHorizontal(child: View, left: Int, dx: Int): Int { - return max(0, min(left, width - child.width)) - } - } - - private inner class RightDragCallback : ViewDragHelper.Callback() { - override fun tryCaptureView(child: View, pointerId: Int): Boolean { - return true//child.id == R.id.right_drawer_sidebar - } - - override fun onViewPositionChanged(changedView: View, left: Int, top: Int, dx: Int, dy: Int) { - rightDragProgress = (width - left).toFloat() / changedView.width - dragListener?.onDragProgress(this@SwipeRevealLayout, rightDragProgress) - invalidate() - } - - override fun clampViewPositionHorizontal(child: View, left: Int, dx: Int): Int { - return max(width - child.width, min(left, width)) - } - } -} \ No newline at end of file +open class SwipeRevealLayout + @JvmOverloads + constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0, + defStyleRes: Int = 0, + ) : ViewGroup(context, attrs, defStyleAttr, defStyleRes) { + /** + * Interface for listening to drag events. + */ + interface OnDragListener { + /** + * Called when the drag state changes. + */ + fun onDragStateChanged( + swipeRevealLayout: SwipeRevealLayout, + state: Int, + ) + + /** + * Called when the drag progress changes. + */ + fun onDragProgress( + swipeRevealLayout: SwipeRevealLayout, + progress: Float, + ) + } + + private var isVerticalDragEnabled = true + + /** + * Whether the most recent touch-down landed within the configured drag handle. The vertical + * drag-to-reveal gesture is only captured when this is `true`, so that scroll gestures starting + * in the middle of the overlapping content (e.g. the editor) are not stolen. + */ + private var isDownInDragHandle = false + + /** Scratch for [View.getLocationOnScreen] while testing a touch against the handle's bounds. */ + private val dragHandleLocation = IntArray(2) + + private val dragHelperCallback = + object : ViewDragHelper.Callback() { + override fun tryCaptureView( + child: View, + pointerId: Int, + ): Boolean = isVerticalDragEnabled && isDownInDragHandle && child === overlappingContent + + override fun onViewPositionChanged( + changedView: View, + left: Int, + top: Int, + dx: Int, + dy: Int, + ) { + draggingViewTop = top + onDragProgress(min(1f, top.toFloat() / dragHeightMax.toFloat())) + } + + override fun getViewVerticalDragRange(child: View): Int = if (isVerticalDragEnabled) dragHeightMax else 0 + + override fun getOrderedChildIndex(index: Int): Int = OVERLAPPING_CONTENT_INDEX + + override fun clampViewPositionVertical( + child: View, + top: Int, + dy: Int, + ): Int = min(max(top, paddingTop), dragHeightMax) + + override fun onViewDragStateChanged(state: Int) { + if (state == draggingState) { + return + } + + if (isDragging && state == ViewDragHelper.STATE_IDLE) { + isOpen = draggingViewTop >= dragHeightMax + } + + onDragStateChanged(state) + } + + override fun onViewReleased( + releasedChild: View, + xvel: Float, + yvel: Float, + ) { + if (draggingViewTop == 0) { + isOpen = false + return + } + + if (draggingViewTop >= dragHeightMax) { + isOpen = true + return + } + + // whether the view should settle to open or close + val settleDestY = + if (yvel > AUTO_OPEN_VELOCITY_LIM || draggingViewTop > dragHeightMax / 2) { + dragHeightMax + } else { + paddingTop + } + + if (dragHelper.settleCapturedViewAt(0, settleDestY)) { + this@SwipeRevealLayout.postInvalidateOnAnimation() + } + } + } + + private val hiddenContent: View + get() = getChildAt(HIDDEN_CONTENT_INDEX)!! + + private val overlappingContent: View + get() = getChildAt(OVERLAPPING_CONTENT_INDEX)!! + + private var draggingState = -1 + private var draggingViewTop = 0 + private val dragHeightMax + get() = hiddenContent.height + + private lateinit var dragHelper: ViewDragHelper + + /** + * Whether the view is currently in 'dragging' state. + */ + val isDragging: Boolean + get() = + draggingState == ViewDragHelper.STATE_DRAGGING || + draggingState == ViewDragHelper.STATE_SETTLING + + /** + * The ID of the view which will be dragged to reveal the content. + */ + @IdRes + var dragHandleViewId = 0 + + /** + * The current drag progress. + */ + @FloatRange(from = 0.0, to = 1.0) + var dragProgress = 0.0f + private set + + /** + * Listener for drag events. + */ + var dragListener: OnDragListener? = null + + /** + * Whether the view is open. + */ + var isOpen = false + protected set + + companion object { + private const val HIDDEN_CONTENT_INDEX = 0 + private const val OVERLAPPING_CONTENT_INDEX = 1 + + @Suppress("UNUSED") + const val STATE_IDLE = ViewDragHelper.STATE_IDLE + + @Suppress("UNUSED") + const val STATE_DRAGGING = ViewDragHelper.STATE_DRAGGING + + @Suppress("UNUSED") + const val STATE_SETTLING = ViewDragHelper.STATE_SETTLING + + const val AUTO_OPEN_VELOCITY_LIM = 800.0 + } + + init { + if (attrs != null) { + context.withStyledAttributes( + attrs, + R.styleable.SwipeRevealLayout, + defStyleAttr, + defStyleRes, + ) { + dragHandleViewId = + getResourceId( + R.styleable.SwipeRevealLayout_dragHandle, + dragHandleViewId, + ) + } + } + } + + override fun onFinishInflate() { + super.onFinishInflate() + this.dragHelper = ViewDragHelper.create(this, dragHelperCallback) + this.isOpen = false + + check(childCount == 2) { + "SwipeRevealLayout must have exactly two children; the hidden content and the overlapping content" + } + } + + override fun onMeasure( + widthMeasureSpec: Int, + heightMeasureSpec: Int, + ) { + measureChildren(widthMeasureSpec, heightMeasureSpec) + + val maxWidth = MeasureSpec.getSize(widthMeasureSpec) + val maxHeight = MeasureSpec.getSize(heightMeasureSpec) + + setMeasuredDimension( + resolveSizeAndState(maxWidth, widthMeasureSpec, 0), + resolveSizeAndState(maxHeight, heightMeasureSpec, 0), + ) + } + + override fun onLayout( + changed: Boolean, + l: Int, + t: Int, + r: Int, + b: Int, + ) { + hiddenContent.layout(0, paddingTop, r, paddingTop + hiddenContent.measuredHeight) + + val olapTop = paddingTop + (hiddenContent.height * dragProgress).toInt() + // Ensure overlappingContent extends to bottom to fill available space + overlappingContent.layout(0, olapTop, r, b) + } + + override fun onInterceptTouchEvent(ev: MotionEvent): Boolean { + val action = ev.actionMasked + if (action == MotionEvent.ACTION_CANCEL || action == MotionEvent.ACTION_UP) { + dragHelper.cancel() + return false + } + if (action == MotionEvent.ACTION_DOWN) { + isDownInDragHandle = isTouchInDragHandle(ev) + } + return dragHelper.shouldInterceptTouchEvent(ev) + } + + @SuppressLint("ClickableViewAccessibility") + override fun onTouchEvent(event: MotionEvent): Boolean { + dragHelper.processTouchEvent(event) + return true + } + + /** + * Returns whether the given touch event falls within the bounds of the configured drag handle + * (see [dragHandleViewId] / the `app:dragHandle` attribute). When no drag handle is configured, + * the whole overlapping content acts as the handle (legacy behavior). + */ + private fun isTouchInDragHandle(ev: MotionEvent): Boolean { + if (dragHandleViewId == 0) { + return true + } + + val handle = findViewById(dragHandleViewId) + if (handle == null || handle.visibility != VISIBLE) { + return false + } + + handle.getLocationOnScreen(dragHandleLocation) + val left = dragHandleLocation[0] + val top = dragHandleLocation[1] + val right = left + handle.width + val bottom = top + handle.height + val x = ev.rawX + val y = ev.rawY + return x >= left && x <= right && y >= top && y <= bottom + } + + override fun computeScroll() { + if (dragHelper.continueSettling(true)) { + postInvalidateOnAnimation() + } + } + + /** + * Internal callback. Invoked when the drag state changes. + */ + @CallSuper + protected open fun onDragStateChanged(state: Int) { + draggingState = state + dragListener?.onDragStateChanged(this, state) + } + + /** + * Internal callback. Invoked when the drag progress changes. + */ + @CallSuper + protected open fun onDragProgress(progress: Float) { + if (dragProgress == progress) { + return + } + + dragProgress = progress + applyDragProgress(progress) + dragListener?.onDragProgress(this, progress) + } + + /** + * Applies the drag progress to the content. + */ + protected open fun applyDragProgress(progress: Float) { + val min = 0.97f + val max = 1f + val scale = min + (max - min) * (1 - progress) + overlappingContent.scaleX = scale + overlappingContent.scaleY = scale + (overlappingContent.background as? MaterialShapeDrawable?)?.interpolation = progress + } + + /** + * Toggles the state of the view. + */ + fun toggleState(isOpen: Boolean) { + if (isOpen) { + open() + } else { + close() + } + } + + /** + * Opens the view. + */ + fun open() { + if (isOpen) { + return + } + smoothSlideTo(1f) + } + + /** + * Closes the view. + */ + fun close() { + if (!isOpen) { + return + } + + smoothSlideTo(0f) + } + + fun setVerticalDragEnabled(enabled: Boolean) { + this.isVerticalDragEnabled = enabled + if (!enabled && isOpen) { + close() + } + } + + private fun smoothSlideTo(offset: Float) { + val y = paddingTop + offset * dragHeightMax + if (dragHelper.smoothSlideViewTo(overlappingContent, overlappingContent.left, y.toInt())) { + postInvalidateOnAnimation() + } + } + } diff --git a/app/src/main/res/layout/item_metrics_image.xml b/app/src/main/res/layout/item_metrics_image.xml new file mode 100644 index 0000000000..4d8617b328 --- /dev/null +++ b/app/src/main/res/layout/item_metrics_image.xml @@ -0,0 +1,14 @@ + + + diff --git a/app/src/main/res/layout/item_metrics_memory_chart.xml b/app/src/main/res/layout/item_metrics_memory_chart.xml new file mode 100644 index 0000000000..d6eaaa40ab --- /dev/null +++ b/app/src/main/res/layout/item_metrics_memory_chart.xml @@ -0,0 +1,13 @@ + + + diff --git a/app/src/main/res/layout/layout_mem_usage.xml b/app/src/main/res/layout/layout_mem_usage.xml index 92888099bc..e78b5f9dc9 100644 --- a/app/src/main/res/layout/layout_mem_usage.xml +++ b/app/src/main/res/layout/layout_mem_usage.xml @@ -1,33 +1,42 @@ - + - + + - - + + - \ No newline at end of file + + + diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml index e39716e1cf..c3bbde87e7 100644 --- a/app/src/main/res/values/dimens.xml +++ b/app/src/main/res/values/dimens.xml @@ -1,33 +1,26 @@ - + - 200dp - 28dp + 248dp + 16dp + 4dp + 16dp + 28dp - 28dp - 8dp - 24dp - 32sp - 16sp - 12sp + 28dp + 8dp + 24dp + 32sp + 16sp + 12sp - - 44dp - 64dp - 6dp - \ No newline at end of file + + 44dp + 64dp + 6dp + diff --git a/app/src/test/java/com/itsaky/androidide/ui/MemoryUsageChartRendererTest.kt b/app/src/test/java/com/itsaky/androidide/ui/MemoryUsageChartRendererTest.kt new file mode 100644 index 0000000000..5d1c8bab2a --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/ui/MemoryUsageChartRendererTest.kt @@ -0,0 +1,192 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.ui + +import android.graphics.Color +import androidx.collection.MutableIntObjectMap +import androidx.test.core.app.ApplicationProvider +import com.github.mikephil.charting.data.LineDataSet +import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.utils.MemoryUsageWatcher +import com.itsaky.androidide.utils.MemoryUsageWatcher.ProcessMemoryInfo +import com.itsaky.androidide.utils.MutableShiftedLongArray +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * Pins the properties ADFA-5487's metrics carousel relies on: the renderer holds no sample state, so + * a chart attached at any time shows the complete history, and a change to the watched process set + * is picked up rather than dropped. + */ +@RunWith(RobolectricTestRunner::class) +class MemoryUsageChartRendererTest { + private val context = ApplicationProvider.getApplicationContext() + + private fun chart() = SafeLineChart(context) + + private fun renderer(processes: () -> Array) = + MemoryUsageChartRenderer( + usagesProvider = processes, + lineColorFor = { Color.BLUE }, + ) + + /** A process whose history ramps from [firstMegabytes] by 1MB per sample. */ + private fun proc( + pid: Int, + pname: String, + firstMegabytes: Long, + ) = ProcessMemoryInfo( + pid, + pname, + MutableShiftedLongArray(MemoryUsageWatcher.MAX_USAGE_ENTRIES) { (firstMegabytes + it) * BYTES_PER_MB }, + ) + + private fun datasetFor( + chart: SafeLineChart, + index: Int, + ) = chart.data.getDataSetByIndex(index) as LineDataSet + + @Test + fun `attach renders the complete existing history, not a flat line`() { + val processes = arrayOf(proc(pid = 1, pname = "IDE", firstMegabytes = 100)) + val chart = chart() + + renderer { processes }.attach(chart) + + val dataset = datasetFor(chart, 0) + assertThat(dataset.entryCount).isEqualTo(MemoryUsageWatcher.MAX_USAGE_ENTRIES) + // The old resetMemUsageChart() seeded every entry with 0f and waited a tick for real values; + // a carousel page attached mid-session would have shown that flat line. + assertThat(dataset.entries.map { it.y }).doesNotContain(0f) + assertThat(dataset.entries.first().y).isEqualTo(100f) + assertThat(dataset.entries.last().y).isEqualTo((100 + MemoryUsageWatcher.MAX_USAGE_ENTRIES - 1).toFloat()) + assertThat(dataset.label).isEqualTo("IDE - %.2fMB".format(dataset.entries.last().y)) + } + + @Test + fun `onUsagesChanged updates entries in place without replacing the datasets`() { + val processes = arrayOf(proc(pid = 1, pname = "IDE", firstMegabytes = 100)) + val chart = chart() + val renderer = renderer { processes } + renderer.attach(chart) + + val datasetBefore = datasetFor(chart, 0) + val entryBefore = datasetBefore.entries.first() + + val updated = proc(pid = 1, pname = "IDE", firstMegabytes = 200) + renderer.onUsagesChanged(MutableIntObjectMap().apply { put(1, updated) }) + + // Same dataset and same Entry objects, new values: this path runs once a second for the + // lifetime of the editor, so it must not allocate. + assertThat(datasetFor(chart, 0)).isSameInstanceAs(datasetBefore) + assertThat(datasetBefore.entries.first()).isSameInstanceAs(entryBefore) + assertThat(entryBefore.y).isEqualTo(200f) + } + + @Test + fun `onUsagesChanged rebuilds when a process starts being watched`() { + var processes = arrayOf(proc(pid = 1, pname = "IDE", firstMegabytes = 100)) + val chart = chart() + val renderer = renderer { processes } + renderer.attach(chart) + + assertThat(chart.data.dataSetCount).isEqualTo(1) + + // Gradle Tooling starts up. The old code looked the new pid up in a map that only reset() + // populated, logged "No dataset found for process", and dropped its samples. + val gradle = proc(pid = 2, pname = "Gradle Tooling", firstMegabytes = 300) + processes = arrayOf(processes[0], gradle) + renderer.onUsagesChanged( + MutableIntObjectMap().apply { + put(1, processes[0]) + put(2, gradle) + }, + ) + + assertThat(chart.data.dataSetCount).isEqualTo(2) + assertThat(datasetFor(chart, 1).label).startsWith("Gradle Tooling - ") + assertThat(datasetFor(chart, 1).entries.first().y).isEqualTo(300f) + } + + @Test + fun `onUsagesChanged after detach is a no-op`() { + var processes = arrayOf(proc(pid = 1, pname = "IDE", firstMegabytes = 100)) + val renderer = renderer { processes } + val detached = chart() + renderer.attach(detached) + val before = datasetFor(detached, 0).entries.map { it.y } + + renderer.detach() + + // Different samples, so a renderer that kept writing would visibly change the chart. + processes = arrayOf(proc(pid = 1, pname = "IDE", firstMegabytes = 900)) + renderer.onUsagesChanged( + MutableIntObjectMap().apply { put(1, processes[0]) }, + ) + + // A recycled carousel page must not keep the renderer writing into a dead view. Asserting + // only that the call does not throw pinned nothing: it would not have thrown anyway. + assertThat(datasetFor(detached, 0).entries.map { it.y }).isEqualTo(before) + } + + @Test + fun `a swapped process rebuilds rather than plotting its samples on another line`() { + // The count stays the same and a pid changes -- what a tooling-server pid correction does. + // The suite covered only the count-changed path, and correctness here rested on + // getDataSetByIndex(-1) happening to return null. + val first = proc(pid = 1, pname = "IDE", firstMegabytes = 100) + var processes = arrayOf(first) + val renderer = renderer { processes } + val chart = chart() + renderer.attach(chart) + + val replacement = proc(pid = 2, pname = "Gradle Tooling", firstMegabytes = 700) + processes = arrayOf(replacement) + renderer.onUsagesChanged( + MutableIntObjectMap().apply { put(2, replacement) }, + ) + + assertThat(chart.data.dataSetCount).isEqualTo(1) + assertThat(datasetFor(chart, 0).label).startsWith("Gradle Tooling - ") + assertThat(datasetFor(chart, 0).entries.first().y).isEqualTo(700f) + } + + @Test + fun `attach after detach renders the history into the new chart`() { + val processes = arrayOf(proc(pid = 1, pname = "IDE", firstMegabytes = 100)) + val renderer = renderer { processes } + renderer.attach(chart()) + renderer.detach() + + val rebound = chart() + renderer.attach(rebound) + + assertThat(datasetFor(rebound, 0).entryCount).isEqualTo(MemoryUsageWatcher.MAX_USAGE_ENTRIES) + assertThat(datasetFor(rebound, 0).entries.first().y).isEqualTo(100f) + } + + private companion object { + /** + * The production constant, not a copy of it. With its own literal the test verified its + * own arithmetic: change the renderer to decimal megabytes and every assertion still + * passed because both sides had stopped agreeing. + */ + val BYTES_PER_MB = BYTES_PER_MEGABYTE.toLong() + } +} diff --git a/resources/src/main/res/values/strings.xml b/resources/src/main/res/values/strings.xml index ca8fe80b79..927526c1ab 100644 --- a/resources/src/main/res/values/strings.xml +++ b/resources/src/main/res/values/strings.xml @@ -1673,4 +1673,11 @@ Dock to editor Close + Memory usage chart + Memory usage + + Code on the Go logo + +