From fe984954dacebc10bccf70f5ee81e1161c24dae3 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 15 Jun 2026 09:40:37 +0000 Subject: [PATCH 1/5] Rework pinned icon size into device-adaptive presets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the raw dp "Pinned icon size" slider (LAUNCHERICON_WIDTH, the opaque (48 + value) * density formula) with five presets — Huge, Large, Default, Small, Tiny — whose pixel size is computed at runtime and never stored. The preset maps to a whole slot count derived from the screen alone (smallestScreenWidthDp + a target physical icon size), so the same preset yields the same count on every theme and the default adapts to the device instead of being calibrated to one phone. The icon size is then chosen so exactly that many slots tile the launcher's usable interior on the screen's shortest edge — subtracting the theme's launcher margins and its launcher background 9-patch insets — so no icon is cut off and no sliver of an extra one shows. The launcher scroll viewports are floored to a whole multiple of the slot size to enforce that. The BFB counts as one of the slots. New LauncherIconGrid mirrors DashGrid: clamping the count (not the size) keeps icons within sane dp bounds while preserving the exact division. No migration — a new preference key is read, so existing installs land on "Default". Covered by LauncherIconGridTest (adaptive count, exact fit per theme, cross-theme count consistency, physical-size sanity, clamping and viewport clipping edge cases) and an updated ReactivePrefsTest. https://claude.ai/code/session_01Hg7YREsBsCX87ZsrRf6MNw --- .../be/robinj/distrohopper/HomeActivity.java | 4 +- .../desktop/launcher/AppLauncher.java | 10 +- .../launcher/ClippingHorizontalScrollView.kt | 25 ++ .../desktop/launcher/ClippingScrollView.kt | 27 ++ .../desktop/launcher/LauncherIconGrid.kt | 170 ++++++++++++ .../distrohopper/home/CustomiseModeUi.kt | 23 +- .../distrohopper/home/HomeStateBinder.kt | 18 +- .../robinj/distrohopper/home/HomeViewModel.kt | 10 +- .../distrohopper/home/LauncherBarBinder.kt | 9 +- .../distrohopper/preferences/Preference.kt | 8 +- app/src/main/res/layout/activity_home.xml | 8 +- .../res/layout/activity_home_customise.xml | 9 +- app/src/main/res/values/strings.xml | 10 +- .../desktop/launcher/LauncherIconGridTest.kt | 256 ++++++++++++++++++ .../distrohopper/home/ReactivePrefsTest.kt | 11 +- 15 files changed, 553 insertions(+), 45 deletions(-) create mode 100644 app/src/main/java/be/robinj/distrohopper/desktop/launcher/ClippingHorizontalScrollView.kt create mode 100644 app/src/main/java/be/robinj/distrohopper/desktop/launcher/ClippingScrollView.kt create mode 100644 app/src/main/java/be/robinj/distrohopper/desktop/launcher/LauncherIconGrid.kt create mode 100644 app/src/test/java/be/robinj/distrohopper/desktop/launcher/LauncherIconGridTest.kt diff --git a/app/src/main/java/be/robinj/distrohopper/HomeActivity.java b/app/src/main/java/be/robinj/distrohopper/HomeActivity.java index b0cc86c4..ef049e45 100644 --- a/app/src/main/java/be/robinj/distrohopper/HomeActivity.java +++ b/app/src/main/java/be/robinj/distrohopper/HomeActivity.java @@ -81,6 +81,7 @@ import be.robinj.distrohopper.desktop.launcher.DashCrossSurfaceController; import be.robinj.distrohopper.desktop.launcher.DashEdgeDragListener; import be.robinj.distrohopper.desktop.launcher.LauncherDragListener; +import be.robinj.distrohopper.desktop.launcher.LauncherIconGrid; import be.robinj.distrohopper.desktop.launcher.TrashDragListener; import be.robinj.distrohopper.desktop.launcher.service.LauncherService; import be.robinj.distrohopper.widgets.DesktopAppHost; @@ -292,11 +293,10 @@ else if (HomeActivity.this.dash.isOpen ()) // Process panel user preferences // Themes should probably handle this? // final Resources res = this.getResources (); - final float density = res.getDisplayMetrics ().density; this.edgeController.applyPanelEdge(Location.of(prefs.getInt(Preference.PANEL_EDGE.getName(), res.getInteger(this.theme.panel_location)))); - int ibDashClose_width = (int) ((float) (48 + prefs.getInt (Preference.LAUNCHERICON_WIDTH.getName(), 36)) * density); + int ibDashClose_width = LauncherIconGrid.iconSizePx (this); LinearLayout.LayoutParams ibDashClose_layoutParams = new LinearLayout.LayoutParams (ibDashClose_width, LinearLayout.LayoutParams.MATCH_PARENT); ibPanelDashClose.setLayoutParams (ibDashClose_layoutParams); diff --git a/app/src/main/java/be/robinj/distrohopper/desktop/launcher/AppLauncher.java b/app/src/main/java/be/robinj/distrohopper/desktop/launcher/AppLauncher.java index 0f80d570..a67eb61c 100644 --- a/app/src/main/java/be/robinj/distrohopper/desktop/launcher/AppLauncher.java +++ b/app/src/main/java/be/robinj/distrohopper/desktop/launcher/AppLauncher.java @@ -1,7 +1,6 @@ package be.robinj.distrohopper.desktop.launcher; import android.content.Context; -import android.content.SharedPreferences; import android.graphics.drawable.GradientDrawable; import android.util.AttributeSet; import android.view.View; @@ -15,8 +14,6 @@ import be.robinj.distrohopper.DependencyContainer; import be.robinj.distrohopper.theme.Theme; import be.robinj.distrohopper.R; -import be.robinj.distrohopper.preferences.Preference; -import be.robinj.distrohopper.preferences.Preferences; /** * Created by robin on 8/20/14. @@ -46,11 +43,8 @@ public AppLauncher (Context context, App app) @Override public void init () { - float density = this.getResources ().getDisplayMetrics ().density; - - SharedPreferences prefs = Preferences.getSharedPreferences(this.getContext(), Preferences.PREFERENCES); - int width = (int) ((float) (48 + prefs.getInt (Preference.LAUNCHERICON_WIDTH.getName(), 36)) * density); - int height = width - (int) (4F * density); + int width = LauncherIconGrid.iconSizePx (this.getContext ()); + int height = LauncherIconGrid.iconHeightPx (this.getContext ()); LinearLayout.LayoutParams layoutParams = new LinearLayout.LayoutParams (width, height); diff --git a/app/src/main/java/be/robinj/distrohopper/desktop/launcher/ClippingHorizontalScrollView.kt b/app/src/main/java/be/robinj/distrohopper/desktop/launcher/ClippingHorizontalScrollView.kt new file mode 100644 index 00000000..14463cd7 --- /dev/null +++ b/app/src/main/java/be/robinj/distrohopper/desktop/launcher/ClippingHorizontalScrollView.kt @@ -0,0 +1,25 @@ +package be.robinj.distrohopper.desktop.launcher + +import android.content.Context +import android.util.AttributeSet +import android.widget.HorizontalScrollView + +/** + * The horizontal launcher scroll viewport (top/bottom edges — the screen's shortest edge on a + * portrait phone). Its width is floored to a whole multiple of the current pinned-icon slot + * size so a partial icon can never peek past the visible run; see + * [LauncherIconGrid.viewportClipPx] and the vertical counterpart [ClippingScrollView]. + */ +class ClippingHorizontalScrollView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, +) : HorizontalScrollView(context, attrs) { + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec) + + val clipped = LauncherIconGrid.viewportClipPx( + this.measuredWidth, LauncherIconGrid.iconSizePx(this.context)) + if (clipped != this.measuredWidth) + this.setMeasuredDimension(clipped, this.measuredHeight) + } +} diff --git a/app/src/main/java/be/robinj/distrohopper/desktop/launcher/ClippingScrollView.kt b/app/src/main/java/be/robinj/distrohopper/desktop/launcher/ClippingScrollView.kt new file mode 100644 index 00000000..495a59d0 --- /dev/null +++ b/app/src/main/java/be/robinj/distrohopper/desktop/launcher/ClippingScrollView.kt @@ -0,0 +1,27 @@ +package be.robinj.distrohopper.desktop.launcher + +import android.content.Context +import android.util.AttributeSet +import android.widget.ScrollView + +/** + * The vertical launcher scroll viewport (left/right edges). Its height is floored to a whole + * multiple of the current pinned-icon slot size, so a partial icon can never peek past the + * visible run when there are more pinned/running apps than fit — see + * [LauncherIconGrid.viewportClipPx]. The trailing remainder (< one slot) sits inside the + * launcher background. The size is unchanged when nothing needs clipping or before a slot size + * is known. + */ +class ClippingScrollView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, +) : ScrollView(context, attrs) { + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec) + + val clipped = LauncherIconGrid.viewportClipPx( + this.measuredHeight, LauncherIconGrid.iconHeightPx(this.context)) + if (clipped != this.measuredHeight) + this.setMeasuredDimension(this.measuredWidth, clipped) + } +} diff --git a/app/src/main/java/be/robinj/distrohopper/desktop/launcher/LauncherIconGrid.kt b/app/src/main/java/be/robinj/distrohopper/desktop/launcher/LauncherIconGrid.kt new file mode 100644 index 00000000..5723b9c3 --- /dev/null +++ b/app/src/main/java/be/robinj/distrohopper/desktop/launcher/LauncherIconGrid.kt @@ -0,0 +1,170 @@ +package be.robinj.distrohopper.desktop.launcher + +import android.content.Context +import android.graphics.Rect +import androidx.core.content.ContextCompat +import be.robinj.distrohopper.DependencyContainer +import be.robinj.distrohopper.preferences.Preference +import be.robinj.distrohopper.preferences.Preferences +import be.robinj.distrohopper.theme.Location +import kotlin.math.ceil +import kotlin.math.max +import kotlin.math.min +import kotlin.math.roundToInt + +/** + * Pure maths (plus thin runtime resolvers) for the launcher's pinned-icon size — the + * counterpart to [be.robinj.distrohopper.desktop.dash.DashGrid] for the dock. + * + * The user picks one of five presets ([PRESET_COUNT]); from that we derive, at runtime, how + * many icon slots fit along the launcher when it sits on the screen's SHORTEST edge, and the + * per-slot pixel size — nothing is stored but the preset index. + * + * Two guarantees drive the design: + * + * 1. The slot count for a preset is computed from the SCREEN alone + * ([countForPreset] off [smallestScreenWidthDp]), so it is a whole number and identical on + * every theme: the preset that means "6 icons" on a given screen yields 6 on GNOME, + * elementary, MATE, … The BFB counts as one of those slots. + * 2. The default adapts to the device. A preset targets a comfortable *physical* size + * ([TARGET_ICON_DP]); the count — not the size — is what flexes with screen size, so icons + * stay roughly the same physical size on a small phone, a Galaxy S24, or a tablet (like the + * dash grid). Presets are ± steps around that device default. + * + * The size is then chosen so exactly [count] slots tile the launcher's usable interior of the + * CURRENT theme ([launcherInteriorPx] subtracts the theme's launcher margins and its + * launcher-background 9-patch insets), so no icon is cut off and no sliver of an extra one + * shows. There is deliberately no separate size clamp: clamping the COUNT to + * `[minCount, maxCount]` already keeps the size within `[MIN_ICON_DP, MAX_ICON_DP]` dp while + * preserving the exact division. + */ +object LauncherIconGrid { + /** Target physical icon size (dp) used to pick the device's default slot count. */ + const val TARGET_ICON_DP = 84 + /** Icons never get smaller than this (touch-target floor) — sets the MAX slot count. */ + const val MIN_ICON_DP = 48 + /** Icons never get larger than this (lets "Huge" be large) — sets the MIN slot count. */ + const val MAX_ICON_DP = 160 + /** Number of presets: Huge · Large · Default · Small · Tiny. */ + const val PRESET_COUNT = 5 + /** Preset index of the device default ("Default", the middle of [PRESET_COUNT]). */ + const val DEFAULT_PRESET = 2 + + // --- Pure count maths (device-adaptive, theme-independent) ----------------- + + /** Fewest slots offered on a screen [shortEdgeDp] dp wide (icons capped at [MAX_ICON_DP]). */ + @JvmStatic + fun minCount(shortEdgeDp: Int): Int = + max(2, ceil(shortEdgeDp.toDouble() / MAX_ICON_DP).toInt()) + + /** Most slots offered on a screen [shortEdgeDp] dp wide (icons floored at [MIN_ICON_DP]). */ + @JvmStatic + fun maxCount(shortEdgeDp: Int): Int = max(minCount(shortEdgeDp), shortEdgeDp / MIN_ICON_DP) + + /** The device-adaptive default slot count (the middle preset) for [shortEdgeDp]. */ + @JvmStatic + fun defaultCount(shortEdgeDp: Int): Int = + (shortEdgeDp.toDouble() / TARGET_ICON_DP).roundToInt() + .coerceIn(minCount(shortEdgeDp), maxCount(shortEdgeDp)) + + /** + * The slot count for [presetIndex] (0 = Huge … [PRESET_COUNT]-1 = Tiny): an offset from the + * device default, clamped to the screen's `[minCount, maxCount]`. Fewer slots = bigger icons. + */ + @JvmStatic + fun countForPreset(shortEdgeDp: Int, presetIndex: Int): Int { + val offset = presetIndex.coerceIn(0, PRESET_COUNT - 1) - DEFAULT_PRESET + return (defaultCount(shortEdgeDp) + offset) + .coerceIn(minCount(shortEdgeDp), maxCount(shortEdgeDp)) + } + + // --- Pure size maths (exact fit) ------------------------------------------- + + /** + * Per-slot size in px so that exactly [n] slots tile [interiorPx]. Floors, so + * `n * size <= interiorPx` always — no slot is ever cut off. + */ + @JvmStatic + fun iconSizePx(interiorPx: Int, n: Int): Int = + if (n <= 0) 0 else interiorPx.coerceAtLeast(0) / n + + /** Icon view height: the running-strip trims 4dp off the square (see [AppLauncher.init]). */ + @JvmStatic + fun iconHeightPx(sizePx: Int, density: Float): Int = + (sizePx - (4F * density).toInt()).coerceAtLeast(0) + + /** + * The largest whole multiple of [sizePx] that fits within [availPx] — the length to clip a + * launcher scroll viewport to, so a partial pinned icon can never peek past the visible run. + */ + @JvmStatic + fun viewportClipPx(availPx: Int, sizePx: Int): Int = + if (sizePx <= 0) availPx else (availPx / sizePx) * sizePx + + // --- Runtime resolvers ----------------------------------------------------- + + /** The stored preset index, clamped to `0..PRESET_COUNT-1`; defaults to [DEFAULT_PRESET]. */ + @JvmStatic + fun preset(context: Context): Int = + Preferences.getSharedPreferences(context) + .getInt(Preference.LAUNCHER_ICON_PRESET.getName(), DEFAULT_PRESET) + .coerceIn(0, PRESET_COUNT - 1) + + /** Slot count for the current screen + stored preset (the whole number that must fit). */ + @JvmStatic + fun count(context: Context): Int = + countForPreset(context.resources.configuration.smallestScreenWidthDp, preset(context)) + + /** Slot count for the current screen and a specific [presetIndex] (for the customise hint). */ + @JvmStatic + fun countForPreset(context: Context, presetIndex: Int): Int = + countForPreset(context.resources.configuration.smallestScreenWidthDp, presetIndex) + + /** + * The usable along-edge length (px) of the launcher placed on the screen's SHORTEST edge: + * the shortest screen edge minus the current theme's launcher margins and the launcher + * background's 9-patch content insets. The shortest-edge launcher is a horizontal bar, so we + * take the left+right insets of the theme's bottom launcher background; computing from this + * fixed hypothetical (never the live container) keeps the icon size stable across rotation + * and identical wherever the launcher is actually docked. + */ + @JvmStatic + fun launcherInteriorPx(context: Context): Int { + val res = context.resources + val dm = res.displayMetrics + val shortEdgePx = min(dm.widthPixels, dm.heightPixels) + val theme = DependencyContainer.of(context).themeManager.current + + // Theme launcher_margin: 4-item array [top, right, bottom, left]; horizontal bar => the + // two horizontal ends. Every theme uses symmetric margins, but sum both ends regardless. + val margins = res.obtainTypedArray(theme.launcher_margin) + val marginPx = margins.getDimensionPixelSize(Location.RIGHT.n - 1, 0) + + margins.getDimensionPixelSize(Location.LEFT.n - 1, 0) + margins.recycle() + + // Launcher background 9-patch insets. Dynamic (solid-colour) backgrounds have none. + var paddingPx = 0 + if (! res.getBoolean(theme.launcher_background_dynamic)) { + val backgrounds = res.obtainTypedArray(theme.launcher_background) + val drawableRes = backgrounds.getResourceId(Location.BOTTOM.n, 0) + backgrounds.recycle() + if (drawableRes != 0) { + val rect = Rect() + if (ContextCompat.getDrawable(context, drawableRes)?.getPadding(rect) == true) + paddingPx = rect.left + rect.right + } + } + + return (shortEdgePx - marginPx - paddingPx).coerceAtLeast(0) + } + + /** The per-slot icon size (px) for the current screen, theme and stored preset. */ + @JvmStatic + fun iconSizePx(context: Context): Int = + iconSizePx(launcherInteriorPx(context), count(context)) + + /** The icon view height (px) for the current screen, theme and stored preset. */ + @JvmStatic + fun iconHeightPx(context: Context): Int = + iconHeightPx(iconSizePx(context), context.resources.displayMetrics.density) +} diff --git a/app/src/main/java/be/robinj/distrohopper/home/CustomiseModeUi.kt b/app/src/main/java/be/robinj/distrohopper/home/CustomiseModeUi.kt index 5d792373..92c9388c 100644 --- a/app/src/main/java/be/robinj/distrohopper/home/CustomiseModeUi.kt +++ b/app/src/main/java/be/robinj/distrohopper/home/CustomiseModeUi.kt @@ -15,6 +15,7 @@ import be.robinj.distrohopper.HomeActivity import be.robinj.distrohopper.R import be.robinj.distrohopper.ViewFinder import be.robinj.distrohopper.desktop.dash.DashGrid +import be.robinj.distrohopper.desktop.launcher.LauncherIconGrid import be.robinj.distrohopper.preferences.BfbLocation import be.robinj.distrohopper.preferences.Preference import be.robinj.distrohopper.preferences.Preferences @@ -48,13 +49,20 @@ class CustomiseModeUi( llDashContent.visibility = View.GONE llDashCustomise.visibility = View.VISIBLE - // Launcher Icon Size // Writing the preference is enough: HomeStateBinder - // observes it and re-initialises the launcher icons // + // Pinned Icon Size // Five presets (Huge…Tiny); the actual pixel size is + // computed at runtime by LauncherIconGrid so the chosen number of slots + // fits the launcher on the screen's shortest edge. Writing the preference + // is enough: HomeStateBinder observes it and re-inits the launcher icons // + val launcherIconLabels = res.getStringArray(R.array.launcher_icon_presets) + val launcherIconHint = this.viewFinder.get(R.id.tvCustomiseLauncherIconHint) val sbCustomiseLauncherIconSize = this.viewFinder.get(R.id.sbCustomiseLauncherIconSize) - sbCustomiseLauncherIconSize.progress = prefs.getInt(Preference.LAUNCHERICON_WIDTH.getName(), 36) + sbCustomiseLauncherIconSize.max = LauncherIconGrid.PRESET_COUNT - 1 + sbCustomiseLauncherIconSize.progress = LauncherIconGrid.preset(this.activity) + this.updateLauncherIconHint(launcherIconHint, launcherIconLabels, sbCustomiseLauncherIconSize.progress) sbCustomiseLauncherIconSize.setOnSeekBarChangeListener( object : SeekBar.OnSeekBarChangeListener { override fun onProgressChanged(seekBar: SeekBar, i: Int, b: Boolean) { + this@CustomiseModeUi.updateLauncherIconHint(launcherIconHint, launcherIconLabels, i) if (b) this.update(i) // see onStopTrackingTouch: ignore non-user (state-restore) changes } @@ -65,7 +73,7 @@ class CustomiseModeUi( } private fun update(value: Int) { - prefsEdit.putInt(Preference.LAUNCHERICON_WIDTH.getName(), value) + prefsEdit.putInt(Preference.LAUNCHER_ICON_PRESET.getName(), value) prefsEdit.commit() } }) @@ -206,6 +214,13 @@ class CustomiseModeUi( } } + /** Updates the pinned-icon preset hint: the preset's label and how many slots it fits. */ + private fun updateLauncherIconHint(hint: TextView, labels: Array, presetIndex: Int) { + val label = labels.getOrElse(presetIndex.coerceIn(0, labels.size - 1)) { "" } + val count = LauncherIconGrid.countForPreset(this.activity, presetIndex) + hint.text = this.activity.getString(R.string.launcher_icon_hint, label, count) + } + /** Re-renders the grid-size hint; called on rotation while customising. */ fun refreshDashGridHint() { this.dashGridHint?.let { this.updateDashGridHint(it) } diff --git a/app/src/main/java/be/robinj/distrohopper/home/HomeStateBinder.kt b/app/src/main/java/be/robinj/distrohopper/home/HomeStateBinder.kt index 4d32c43d..0e72cabf 100644 --- a/app/src/main/java/be/robinj/distrohopper/home/HomeStateBinder.kt +++ b/app/src/main/java/be/robinj/distrohopper/home/HomeStateBinder.kt @@ -7,6 +7,7 @@ import androidx.lifecycle.repeatOnLifecycle import be.robinj.distrohopper.HomeActivity import be.robinj.distrohopper.R import be.robinj.distrohopper.desktop.launcher.AppLauncher +import be.robinj.distrohopper.desktop.launcher.LauncherIconGrid import kotlinx.coroutines.launch /** @@ -62,8 +63,8 @@ object HomeStateBinder { } this.launch { - viewModel.launcherIconWidth.collect { width -> - applyLauncherIconWidth(activity, width) + viewModel.launcherIconPreset.collect { + applyLauncherIconSize(activity) } } @@ -85,15 +86,15 @@ object HomeStateBinder { /** * Resizes the panel's dash-close button and re-initialises every launcher - * icon with the new width preference. Used to live inside the customise - * seekbar's listener; now any writer of the preference gets it applied. + * icon for the current pinned-icon size preset (the pixel size is recomputed + * by [LauncherIconGrid]). Used to live inside the customise seekbar's + * listener; now any writer of the preference gets it applied. */ - private fun applyLauncherIconWidth(activity: HomeActivity, width: Int) { - val density = activity.resources.displayMetrics.density + private fun applyLauncherIconSize(activity: HomeActivity) { val viewFinder = activity.viewFinder val ibDashClose_layoutParams = LinearLayout.LayoutParams( - ((48 + width).toFloat() * density).toInt(), LinearLayout.LayoutParams.MATCH_PARENT) + LauncherIconGrid.iconSizePx(activity), LinearLayout.LayoutParams.MATCH_PARENT) viewFinder.get(R.id.ibPanelDashClose).layoutParams = ibDashClose_layoutParams @@ -110,5 +111,8 @@ object HomeStateBinder { viewFinder.get(R.id.lalTrash).init() viewFinder.get(R.id.lalPreferences).init() + + // The slot size changed, so the whole-slot scroll clip must re-measure. + viewFinder.get(R.id.llLauncher).requestLayout() } } diff --git a/app/src/main/java/be/robinj/distrohopper/home/HomeViewModel.kt b/app/src/main/java/be/robinj/distrohopper/home/HomeViewModel.kt index dc16b354..fb4c48db 100644 --- a/app/src/main/java/be/robinj/distrohopper/home/HomeViewModel.kt +++ b/app/src/main/java/be/robinj/distrohopper/home/HomeViewModel.kt @@ -3,6 +3,7 @@ package be.robinj.distrohopper.home import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import be.robinj.distrohopper.DependencyContainer +import be.robinj.distrohopper.desktop.launcher.LauncherIconGrid import be.robinj.distrohopper.preferences.Preference import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow @@ -24,9 +25,12 @@ class HomeViewModel( * Preferences that apply live, without recreating the activity. Theme and * edge changes still recreate: they do wholesale view-tree surgery. */ - val launcherIconWidth: Flow = container.prefs - .valueFlow(Preference.LAUNCHERICON_WIDTH) { - it.getInt(Preference.LAUNCHERICON_WIDTH.getName(), 36) + // Emits whenever the pinned-icon size preset changes; the actual pixel size is recomputed + // from the screen and theme by LauncherIconGrid at apply time, so the emitted value is only + // a change trigger. + val launcherIconPreset: Flow = container.prefs + .valueFlow(Preference.LAUNCHER_ICON_PRESET) { + it.getInt(Preference.LAUNCHER_ICON_PRESET.getName(), LauncherIconGrid.DEFAULT_PRESET) } // Emits whenever the dash grid column preference changes; the actual count // is recomputed from the screen by DashGrid at apply time, so the emitted diff --git a/app/src/main/java/be/robinj/distrohopper/home/LauncherBarBinder.kt b/app/src/main/java/be/robinj/distrohopper/home/LauncherBarBinder.kt index 2d2c05dd..905295db 100644 --- a/app/src/main/java/be/robinj/distrohopper/home/LauncherBarBinder.kt +++ b/app/src/main/java/be/robinj/distrohopper/home/LauncherBarBinder.kt @@ -12,7 +12,6 @@ import be.robinj.distrohopper.AppManager import be.robinj.distrohopper.DependencyContainer import be.robinj.distrohopper.HomeActivity import be.robinj.distrohopper.R -import be.robinj.distrohopper.preferences.Preference import be.robinj.distrohopper.preferences.Preferences import be.robinj.distrohopper.desktop.dash.FolderPopup import be.robinj.distrohopper.desktop.dash.ProfilePagerAdapter @@ -24,6 +23,7 @@ import be.robinj.distrohopper.desktop.launcher.AppLauncherClickListener import be.robinj.distrohopper.desktop.launcher.AppLauncherLongClickListener import be.robinj.distrohopper.desktop.launcher.LauncherDragPayload import be.robinj.distrohopper.desktop.launcher.LauncherFolderView +import be.robinj.distrohopper.desktop.launcher.LauncherIconGrid import be.robinj.distrohopper.desktop.launcher.LauncherItem import be.robinj.distrohopper.desktop.launcher.PinnedAppsBar import be.robinj.distrohopper.desktop.launcher.RunningAppLauncher @@ -230,11 +230,8 @@ class LauncherBarBinder(private val appManager: AppManager) { } } - val density = this.activity.resources.displayMetrics.density - val iconWidth = Preferences.getSharedPreferences(this.activity) - .getInt(Preference.LAUNCHERICON_WIDTH.getName(), 36) - val width = (48 + iconWidth) * density - return if (this.morphVertical) width - 4F * density else width + return if (this.morphVertical) LauncherIconGrid.iconHeightPx(this.activity).toFloat() + else LauncherIconGrid.iconSizePx(this.activity).toFloat() } private fun animationsEnabled(): Boolean = diff --git a/app/src/main/java/be/robinj/distrohopper/preferences/Preference.kt b/app/src/main/java/be/robinj/distrohopper/preferences/Preference.kt index cbe71bd2..08176535 100644 --- a/app/src/main/java/be/robinj/distrohopper/preferences/Preference.kt +++ b/app/src/main/java/be/robinj/distrohopper/preferences/Preference.kt @@ -22,8 +22,12 @@ enum class Preference( LAUNCHER_SHOW_RUNNING_APPS("launcher_running_show"), /** Whether pinned launcher apps are shared globally or kept per desktop. */ LAUNCHER_APP_PIN_MODE("launcher_app_pin_mode", "desktop"), - /** Launcher icon size (the customise-mode slider value). */ - LAUNCHERICON_WIDTH("launchericon_width"), + /** + * Pinned-icon size preset (index 0 = Huge … 4 = Tiny, default 2 = "Default"). The pixel + * size is computed at runtime from this; see [be.robinj.distrohopper.desktop.launcher.LauncherIconGrid]. + * A new key (the old `launchericon_width` raw-dp value is intentionally not migrated). + */ + LAUNCHER_ICON_PRESET("launcher_icon_preset", 2), /** Whether the accessibility-based launcher service is enabled. */ LAUNCHERSERVICE_ENABLED("launcherservice_enabled"), /** Whether dash search also queries the (slower) full set of lenses. */ diff --git a/app/src/main/res/layout/activity_home.xml b/app/src/main/res/layout/activity_home.xml index f70ac819..5ba56e7e 100644 --- a/app/src/main/res/layout/activity_home.xml +++ b/app/src/main/res/layout/activity_home.xml @@ -158,7 +158,7 @@ custom:applauncher_special="true" /> - - + - - + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1256b6b8..d3aa2b65 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -42,7 +42,15 @@ Gestures Appearance Pinned icon size - How large icons for pinned apps should be. The slider goes from 48dip to 120dip, with the default (right in the middle) being 84dip. + How large pinned app icons are. Bigger icons mean fewer fit along the launcher without scrolling; the default adapts to your screen. + %1$s · %2$d icons + + Huge + Large + Default + Small + Tiny + Dash grid size How many app icons fit across the Dash. Fewer columns means larger icons; the layout flips automatically between portrait and landscape. Search results use the same grid. %1$d × %2$d diff --git a/app/src/test/java/be/robinj/distrohopper/desktop/launcher/LauncherIconGridTest.kt b/app/src/test/java/be/robinj/distrohopper/desktop/launcher/LauncherIconGridTest.kt new file mode 100644 index 00000000..e38fb5c9 --- /dev/null +++ b/app/src/test/java/be/robinj/distrohopper/desktop/launcher/LauncherIconGridTest.kt @@ -0,0 +1,256 @@ +package be.robinj.distrohopper.desktop.launcher + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import be.robinj.distrohopper.DependencyContainer +import be.robinj.distrohopper.preferences.Preference +import be.robinj.distrohopper.theme.ThemeRegistry +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * The pinned-icon preset maths: a preset maps to a whole, theme-independent slot count derived + * from the screen, and the icon size is then chosen so exactly that many slots tile the + * launcher interior — no cut-off, no sliver. See [LauncherIconGrid]. + */ +@RunWith(RobolectricTestRunner::class) +class LauncherIconGridTest { + + // --- Device-adaptive count ------------------------------------------------- + + /** The S24-class screen the default was tuned on reproduces the hand-picked 3·4·5·6·7. */ + @Test fun s24ClassYieldsDefaultFiveAndPresetsThreeToSeven() { + val sw = 384 + assertEquals(5, LauncherIconGrid.defaultCount(sw)) + assertEquals( + listOf(3, 4, 5, 6, 7), + (0 until LauncherIconGrid.PRESET_COUNT).map { LauncherIconGrid.countForPreset(sw, it) }) + } + + /** The default count adapts to screen size (smaller phone → fewer, tablet → many). */ + @Test fun defaultCountAdaptsToScreen() { + assertEquals(4, LauncherIconGrid.defaultCount(320)) + assertEquals(5, LauncherIconGrid.defaultCount(384)) + assertEquals(10, LauncherIconGrid.defaultCount(800)) + assertEquals(14, LauncherIconGrid.defaultCount(1200)) + } + + /** The default preset is always the device default count. */ + @Test fun defaultPresetIsTheDeviceDefaultCount() { + for (sw in intArrayOf(220, 320, 384, 600, 800, 1200)) + assertEquals(LauncherIconGrid.defaultCount(sw), + LauncherIconGrid.countForPreset(sw, LauncherIconGrid.DEFAULT_PRESET)) + } + + /** Presets are ± offsets from the default; more icons toward "Tiny". */ + @Test fun presetsStepAroundTheDefault() { + val sw = 384 + val base = LauncherIconGrid.defaultCount(sw) + for (i in 0 until LauncherIconGrid.PRESET_COUNT) { + val expected = (base + (i - LauncherIconGrid.DEFAULT_PRESET)) + .coerceIn(LauncherIconGrid.minCount(sw), LauncherIconGrid.maxCount(sw)) + assertEquals(expected, LauncherIconGrid.countForPreset(sw, i)) + } + } + + /** Count never drops below the cap-derived minimum or above the floor-derived maximum. */ + @Test fun countStaysWithinScreenRange() { + for (sw in intArrayOf(180, 200, 320, 384, 600, 800, 1200, 2000)) { + val lo = LauncherIconGrid.minCount(sw) + val hi = LauncherIconGrid.maxCount(sw) + assertTrue(lo in 2..hi) + for (i in -3..LauncherIconGrid.PRESET_COUNT + 3) + assertTrue(LauncherIconGrid.countForPreset(sw, i) in lo..hi) + } + } + + /** On a tiny screen the range is narrow, so the extreme presets collapse rather than crash. */ + @Test fun narrowRangeCollapsesPresetEnds() { + val sw = 200 // minCount 2, maxCount 4 + assertEquals(2, LauncherIconGrid.minCount(sw)) + assertEquals(4, LauncherIconGrid.maxCount(sw)) + val counts = (0 until LauncherIconGrid.PRESET_COUNT).map { LauncherIconGrid.countForPreset(sw, it) } + assertEquals(listOf(2, 2, 2, 3, 4), counts) + } + + /** Out-of-range preset indices are clamped, not thrown. */ + @Test fun presetIndexIsClamped() { + val sw = 384 + assertEquals(LauncherIconGrid.countForPreset(sw, 0), LauncherIconGrid.countForPreset(sw, -5)) + assertEquals( + LauncherIconGrid.countForPreset(sw, LauncherIconGrid.PRESET_COUNT - 1), + LauncherIconGrid.countForPreset(sw, 99)) + } + + // --- Exact fit ------------------------------------------------------------- + + /** Exactly [n] slots fit the interior: n·size ≤ interior, and an (n+1)th never does. */ + @Test fun nSlotsFitExactlyAndNoMore() { + for (interior in intArrayOf(720, 1000, 1080, 1234, 1440, 2000)) + for (n in 2..12) { + val size = LauncherIconGrid.iconSizePx(interior, n) + assertTrue("n*size must fit", n * size <= interior) + assertTrue("no (n+1)th slot fits", interior - n * size < size || size == 0) + } + } + + @Test fun iconSizeGuardsDegenerateInputs() { + assertEquals(0, LauncherIconGrid.iconSizePx(1080, 0)) + assertEquals(0, LauncherIconGrid.iconSizePx(1080, -3)) + assertEquals(0, LauncherIconGrid.iconSizePx(-50, 5)) + } + + /** Fewer slots (toward "Huge") never give a smaller icon than more slots (toward "Tiny"). */ + @Test fun fewerSlotsMeanBiggerIcons() { + val interior = 1080 + val sizes = (0 until LauncherIconGrid.PRESET_COUNT).map { + LauncherIconGrid.iconSizePx(interior, LauncherIconGrid.countForPreset(384, it)) + } + for (i in 1 until sizes.size) assertTrue(sizes[i] <= sizes[i - 1]) + } + + // --- Physical-size consistency (the point of the rework) ------------------- + + /** The default icon stays a comfortable PHYSICAL size across devices — not screen-proportional. */ + @Test fun defaultIconSizeIsPhysicallyConsistent() { + for (sw in intArrayOf(320, 384, 600, 800, 1200)) { + // interior ≈ sw px at density 1 ⇒ sizePx is the size in dp. + val sizeDp = LauncherIconGrid.iconSizePx(sw, LauncherIconGrid.defaultCount(sw)) + assertTrue("$sw → ${sizeDp}dp within bounds", + sizeDp in LauncherIconGrid.MIN_ICON_DP..LauncherIconGrid.MAX_ICON_DP) + assertTrue("$sw → ${sizeDp}dp near target", + sizeDp in 60..110) // never the old shortEdge/5 extremes (64dp .. 160dp) + } + } + + /** Clamping the COUNT keeps every preset's size within the dp bounds on any screen. */ + @Test fun everyPresetSizeStaysWithinDpBounds() { + for (sw in intArrayOf(220, 320, 384, 600, 800, 1200, 2000)) + for (i in 0 until LauncherIconGrid.PRESET_COUNT) { + val sizeDp = LauncherIconGrid.iconSizePx(sw, LauncherIconGrid.countForPreset(sw, i)) + assertTrue("$sw preset $i → ${sizeDp}dp", + sizeDp in LauncherIconGrid.MIN_ICON_DP..LauncherIconGrid.MAX_ICON_DP) + } + } + + // --- Height + viewport clip ------------------------------------------------ + + @Test fun iconHeightIsSizeMinusFourDp() { + assertEquals(216 - 12, LauncherIconGrid.iconHeightPx(216, 3F)) + assertEquals(84 - 4, LauncherIconGrid.iconHeightPx(84, 1F)) + assertEquals(0, LauncherIconGrid.iconHeightPx(2, 3F)) // never negative + } + + @Test fun viewportClipIsLargestWholeMultiple() { + assertEquals(864, LauncherIconGrid.viewportClipPx(1000, 216)) // 4 whole slots + assertEquals(864, LauncherIconGrid.viewportClipPx(864, 216)) // already a multiple + assertEquals(0, LauncherIconGrid.viewportClipPx(100, 216)) // not even one slot + } + + @Test fun viewportClipGuardsZeroSize() { + assertEquals(1000, LauncherIconGrid.viewportClipPx(1000, 0)) + assertEquals(1000, LauncherIconGrid.viewportClipPx(1000, -4)) + } + + @Test fun clippedViewportShowsOnlyWholeSlots() { + for (avail in intArrayOf(500, 999, 1000, 1080, 1441)) + for (size in intArrayOf(48, 64, 91, 216)) { + val clipped = LauncherIconGrid.viewportClipPx(avail, size) + assertTrue(clipped <= avail) + assertEquals(0, clipped % size) + } + } + + // --- Context resolvers (Robolectric) --------------------------------------- + + private fun ctx(): Context = ApplicationProvider.getApplicationContext() + + private fun setPreset(context: Context, index: Int) { + DependencyContainer.of(context).prefs.edit { + putInt(Preference.LAUNCHER_ICON_PRESET.getName(), index) + } + } + + private fun setTheme(context: Context, name: String) { + DependencyContainer.of(context).prefs.edit { + putString(Preference.THEME.getName(), name) + } + } + + @Test fun presetDefaultsToDefaultIndexWhenUnset() { + assertEquals(LauncherIconGrid.DEFAULT_PRESET, LauncherIconGrid.preset(this.ctx())) + } + + @Test fun presetIsClampedWhenStoredOutOfRange() { + val context = this.ctx() + this.setPreset(context, 99) + assertEquals(LauncherIconGrid.PRESET_COUNT - 1, LauncherIconGrid.preset(context)) + this.setPreset(context, -7) + assertEquals(0, LauncherIconGrid.preset(context)) + } + + /** The same preset yields the same whole slot count on every theme (guarantee #1). */ + @Test fun countIsIdenticalAcrossAllThemes() { + val context = this.ctx() + this.setPreset(context, 3) + val counts = ThemeRegistry.themes.keys.map { + this.setTheme(context, it) + LauncherIconGrid.count(context) + } + assertEquals(1, counts.distinct().size) + } + + /** For every theme, exactly `count` slots fill the resolved interior — none cut off, none extra. */ + @Test fun exactlyCountSlotsFitEveryTheme() { + val context = this.ctx() + this.setPreset(context, LauncherIconGrid.DEFAULT_PRESET) + for (name in ThemeRegistry.themes.keys) { + this.setTheme(context, name) + val interior = LauncherIconGrid.launcherInteriorPx(context) + val count = LauncherIconGrid.count(context) + val size = LauncherIconGrid.iconSizePx(context) + assertTrue("$name: positive size", size > 0) + assertTrue("$name: count fits", count * size <= interior) + assertTrue("$name: no extra slot", interior - count * size < size) + } + } + + /** A theme with launcher margins has a smaller interior than the margin-less default. */ + @Test fun themeMarginsShrinkTheInterior() { + val context = this.ctx() + this.setTheme(context, "default") // 0dp launcher margin + val noMargin = LauncherIconGrid.launcherInteriorPx(context) + this.setTheme(context, "elementary") // 8dp launcher margin + val withMargin = LauncherIconGrid.launcherInteriorPx(context) + assertTrue("elementary ($withMargin) < default ($noMargin)", withMargin < noMargin) + } + + @Test fun iconHeightContextIsSizeMinusFourDp() { + val context = this.ctx() + val density = context.resources.displayMetrics.density + assertEquals( + LauncherIconGrid.iconSizePx(context) - (4F * density).toInt(), + LauncherIconGrid.iconHeightPx(context)) + } + + /* + * Anchored to the shortest screen edge: the same device portrait or landscape (w/h swapped, + * smallest-width 400dp either way) yields the same count — so the icon size never jumps on + * rotation. Both orientations must agree with the pure countForPreset(400, …). + */ + @Test @Config(qualifiers = "sw400dp-w400dp-h800dp") fun countAnchoredToShortestEdgePortrait() { + val context = this.ctx() + assertEquals(LauncherIconGrid.countForPreset(400, LauncherIconGrid.preset(context)), + LauncherIconGrid.count(context)) + } + + @Test @Config(qualifiers = "sw400dp-w800dp-h400dp") fun countAnchoredToShortestEdgeLandscape() { + val context = this.ctx() + assertEquals(LauncherIconGrid.countForPreset(400, LauncherIconGrid.preset(context)), + LauncherIconGrid.count(context)) + } +} diff --git a/app/src/test/java/be/robinj/distrohopper/home/ReactivePrefsTest.kt b/app/src/test/java/be/robinj/distrohopper/home/ReactivePrefsTest.kt index bb5e0e88..88373367 100644 --- a/app/src/test/java/be/robinj/distrohopper/home/ReactivePrefsTest.kt +++ b/app/src/test/java/be/robinj/distrohopper/home/ReactivePrefsTest.kt @@ -8,6 +8,7 @@ import be.robinj.distrohopper.DependencyContainer import be.robinj.distrohopper.HomeActivity import be.robinj.distrohopper.R import be.robinj.distrohopper.desktop.dash.DashGrid +import be.robinj.distrohopper.desktop.launcher.LauncherIconGrid import be.robinj.distrohopper.preferences.Preference import org.junit.After import org.junit.Assert.assertEquals @@ -42,19 +43,17 @@ class ReactivePrefsTest { } } - @Test fun launcherIconWidthResizesThePanelCloseButton() { + @Test fun launcherIconPresetResizesThePanelCloseButton() { this.scenario.onActivity { activity -> + // "Huge" (0) differs from the default preset, so the size must change. DependencyContainer.of(activity).prefs.edit { - putInt(Preference.LAUNCHERICON_WIDTH.getName(), 52) + putInt(Preference.LAUNCHER_ICON_PRESET.getName(), 0) } } ActivityTestSupport.drainTasks() this.scenario.onActivity { activity -> - val density = activity.resources.displayMetrics.density - val expected = ((48 + 52).toFloat() * density).toInt() - - assertEquals(expected, + assertEquals(LauncherIconGrid.iconSizePx(activity), activity.findViewById(R.id.ibPanelDashClose).layoutParams.width) } } From c1389b08aab7440b8bcf9e43fdbc0124433b43e4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 25 Jun 2026 21:57:17 +0000 Subject: [PATCH 2/5] Add real-measure tests for the launcher viewport clip Exercise the actual framework measure pass on ClippingScrollView / ClippingHorizontalScrollView to confirm the viewport floors to a whole multiple of the icon slot (the no-sliver guarantee), beyond the pure viewportClipPx arithmetic. https://claude.ai/code/session_01Hg7YREsBsCX87ZsrRf6MNw --- .../launcher/ClippingScrollViewTest.kt | 83 +++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 app/src/test/java/be/robinj/distrohopper/desktop/launcher/ClippingScrollViewTest.kt diff --git a/app/src/test/java/be/robinj/distrohopper/desktop/launcher/ClippingScrollViewTest.kt b/app/src/test/java/be/robinj/distrohopper/desktop/launcher/ClippingScrollViewTest.kt new file mode 100644 index 00000000..b4ff0f1b --- /dev/null +++ b/app/src/test/java/be/robinj/distrohopper/desktop/launcher/ClippingScrollViewTest.kt @@ -0,0 +1,83 @@ +package be.robinj.distrohopper.desktop.launcher + +import android.view.View +import android.view.ViewGroup +import android.widget.LinearLayout +import androidx.test.core.app.ApplicationProvider +import be.robinj.distrohopper.DependencyContainer +import be.robinj.distrohopper.preferences.Preference +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * Real measure-pass checks that the launcher scroll viewports floor themselves to whole icon + * slots (the no-sliver guarantee), exercising the actual framework layout — not just the pure + * [LauncherIconGrid.viewportClipPx] arithmetic. + */ +@RunWith(RobolectricTestRunner::class) +class ClippingScrollViewTest { + private val context = ApplicationProvider.getApplicationContext() + + private fun setPreset(index: Int) { + DependencyContainer.of(this.context).prefs.edit { + putInt(Preference.LAUNCHER_ICON_PRESET.getName(), index) + } + } + + private fun child(): LinearLayout { + // One oversized child so the viewport — not the content — is the limiting dimension. + val child = LinearLayout(this.context) + child.layoutParams = ViewGroup.LayoutParams(5000, 5000) + return child + } + + private fun exactly(px: Int) = View.MeasureSpec.makeMeasureSpec(px, View.MeasureSpec.EXACTLY) + + @Test fun horizontalViewportFloorsToWholeSlots() { + this.setPreset(LauncherIconGrid.DEFAULT_PRESET) + val slot = LauncherIconGrid.iconSizePx(this.context) + assertTrue("precondition: positive slot", slot > 0) + + val view = ClippingHorizontalScrollView(this.context) + view.addView(this.child()) + + for (avail in intArrayOf(slot - 1, slot, slot * 3, slot * 4 + slot / 2, 2000)) { + view.measure(this.exactly(avail), this.exactly(slot * 2)) + val w = view.measuredWidth + assertTrue("clip must not exceed available ($w <= $avail)", w <= avail) + assertEquals("clip must be a whole multiple of the slot", 0, w % slot) + assertEquals("largest whole multiple", (avail / slot) * slot, w) + } + } + + @Test fun verticalViewportFloorsToWholeSlots() { + this.setPreset(LauncherIconGrid.DEFAULT_PRESET) + val slot = LauncherIconGrid.iconHeightPx(this.context) // vertical slots advance by height + assertTrue("precondition: positive slot", slot > 0) + + val view = ClippingScrollView(this.context) + view.addView(this.child()) + + for (avail in intArrayOf(slot, slot * 5 - 1, slot * 6, 1777)) { + view.measure(this.exactly(slot * 2), this.exactly(avail)) + val h = view.measuredHeight + assertTrue("clip must not exceed available ($h <= $avail)", h <= avail) + assertEquals("clip must be a whole multiple of the slot", 0, h % slot) + assertEquals("largest whole multiple", (avail / slot) * slot, h) + } + } + + /** A viewport smaller than one slot collapses to zero rather than showing a partial icon. */ + @Test fun viewportSmallerThanOneSlotShowsNothing() { + this.setPreset(LauncherIconGrid.DEFAULT_PRESET) + val slot = LauncherIconGrid.iconSizePx(this.context) + + val view = ClippingHorizontalScrollView(this.context) + view.addView(this.child()) + view.measure(this.exactly(slot - 1), this.exactly(slot)) + assertEquals(0, view.measuredWidth) + } +} From 2aa77caabb019154f21b351856e323d837b77603 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 23:21:01 +0000 Subject: [PATCH 3/5] Address self-review findings on the pinned-icon preset rework MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Clip the launcher scroll viewports only when the content actually overflows: without overflow no partial icon is possible, and PinnedAppsBar measures to a FRACTIONAL length during the per-desktop swipe morph so an auto-sizing launcher resizes smoothly — flooring that snapped the animation in whole-icon steps. - Memoise LauncherIconGrid's interior resolution (theme margins + launcher-background 9-patch insets): it is reached from onMeasure, which runs per frame during the morph, and previously inflated a 9-patch drawable and two TypedArrays on every call. - Resolve the icon size once in AppLauncher.init() and derive the height from it instead of running the full resolution twice. - Single-source the preset default (Preference.LAUNCHER_ICON_PRESET now uses LauncherIconGrid.DEFAULT_PRESET; the customise seekbar's XML progress/max, always overwritten by CustomiseModeUi, are dropped) and index the launcher_margin array with plain literals matching LauncherEdgeController's convention. - Strengthen the tests: assert the panel-close button actually changes size (not just matches the formula), pin explicit -port/-land qualifiers with preconditions on the rotation-stability tests, drop a mirror test that recomputed the implementation's own formula, and cover the new fits-without-overflow (no clip) behaviour. - Update AGENTS.md per its maintenance rule (LauncherIconGrid section, renamed ViewModel flow). https://claude.ai/code/session_01Hg7YREsBsCX87ZsrRf6MNw --- AGENTS.md | 18 ++++++++- .../desktop/launcher/AppLauncher.java | 3 +- .../launcher/ClippingHorizontalScrollView.kt | 10 +++-- .../desktop/launcher/ClippingScrollView.kt | 17 ++++++--- .../desktop/launcher/LauncherIconGrid.kt | 37 ++++++++++++++----- .../distrohopper/preferences/Preference.kt | 11 ++++-- .../res/layout/activity_home_customise.xml | 4 +- .../launcher/ClippingScrollViewTest.kt | 26 +++++++++++++ .../desktop/launcher/LauncherIconGridTest.kt | 26 ++++++------- .../distrohopper/home/ReactivePrefsTest.kt | 17 +++++++-- 10 files changed, 125 insertions(+), 44 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a60c089d..cd6d8921 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -140,6 +140,19 @@ etc/ — design assets (SVG/XCF sources, the apps grid's last laid-out viewport (`LauncherBarBinder.dashGridViewport`, captured while the grid is visible, since it is GONE while customising) so the row count reflects the real theme/orientation, not the full screen. + Pinned-icon sizing is the launcher counterpart, owned by + `desktop/launcher/LauncherIconGrid`: the user picks one of five presets + (`LAUNCHER_ICON_PRESET`, Huge…Tiny, middle = adaptive default), which maps + to a whole slot count derived from `smallestScreenWidthDp` alone (so the + count is identical on every theme; the BFB counts as a slot), and the icon + size is computed at runtime so exactly that many slots tile the launcher's + usable interior on the screen's shortest edge (theme `launcher_margin` and + the launcher background's 9-patch insets subtracted; nothing but the preset + index is stored). `AppLauncher.init()` reads it; when pinned/running apps + overflow, `ClippingScrollView`/`ClippingHorizontalScrollView` floor the + scroll viewport to whole slots so no partial icon peeks (they leave the + measure alone while everything fits, preserving `PinnedAppsBar`'s + fractional morph measure). The tab indicator is chosen per theme via the `profile_indicator` integer (`theme/ProfileIndicatorStyle`, like `dash_animation`): `UNITY_RIBBON` (Unity/Default) puts per-profile glyphs in the always- @@ -189,8 +202,9 @@ etc/ — design assets (SVG/XCF sources, state of record. Customise mode lives as a `MutableStateFlow` on the `DependencyContainer` (not the ViewModel) because `App.launch()` checks it with only a Context. The ViewModel also exposes preference flows - that apply live without recreating the activity (panel opacity, - launcher/dash icon widths, show-running-apps) — the customise-mode + that apply live without recreating the activity (panel opacity, the + pinned-icon size preset, dash grid columns, show-running-apps) — the + customise-mode seekbars only write the preference and the binder applies it. Theme and launcher/panel edge changes still recreate the activity (wholesale view-tree surgery). Widgets are always enabled (the diff --git a/app/src/main/java/be/robinj/distrohopper/desktop/launcher/AppLauncher.java b/app/src/main/java/be/robinj/distrohopper/desktop/launcher/AppLauncher.java index a67eb61c..f552b3f3 100644 --- a/app/src/main/java/be/robinj/distrohopper/desktop/launcher/AppLauncher.java +++ b/app/src/main/java/be/robinj/distrohopper/desktop/launcher/AppLauncher.java @@ -44,7 +44,8 @@ public AppLauncher (Context context, App app) public void init () { int width = LauncherIconGrid.iconSizePx (this.getContext ()); - int height = LauncherIconGrid.iconHeightPx (this.getContext ()); + int height = LauncherIconGrid.iconHeightPx (width, + this.getResources ().getDisplayMetrics ().density); LinearLayout.LayoutParams layoutParams = new LinearLayout.LayoutParams (width, height); diff --git a/app/src/main/java/be/robinj/distrohopper/desktop/launcher/ClippingHorizontalScrollView.kt b/app/src/main/java/be/robinj/distrohopper/desktop/launcher/ClippingHorizontalScrollView.kt index 14463cd7..2706a76f 100644 --- a/app/src/main/java/be/robinj/distrohopper/desktop/launcher/ClippingHorizontalScrollView.kt +++ b/app/src/main/java/be/robinj/distrohopper/desktop/launcher/ClippingHorizontalScrollView.kt @@ -6,9 +6,10 @@ import android.widget.HorizontalScrollView /** * The horizontal launcher scroll viewport (top/bottom edges — the screen's shortest edge on a - * portrait phone). Its width is floored to a whole multiple of the current pinned-icon slot - * size so a partial icon can never peek past the visible run; see - * [LauncherIconGrid.viewportClipPx] and the vertical counterpart [ClippingScrollView]. + * portrait phone). When the content overflows (scrolling would kick in), its width is floored + * to a whole multiple of the current pinned-icon slot size so a partial icon can never peek + * past the visible run; while everything fits the measure is left untouched — see the + * vertical counterpart [ClippingScrollView] for why (fractional morph measure). */ class ClippingHorizontalScrollView @JvmOverloads constructor( context: Context, @@ -17,6 +18,9 @@ class ClippingHorizontalScrollView @JvmOverloads constructor( override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { super.onMeasure(widthMeasureSpec, heightMeasureSpec) + val content = this.getChildAt(0)?.measuredWidth ?: 0 + if (content <= this.measuredWidth) return + val clipped = LauncherIconGrid.viewportClipPx( this.measuredWidth, LauncherIconGrid.iconSizePx(this.context)) if (clipped != this.measuredWidth) diff --git a/app/src/main/java/be/robinj/distrohopper/desktop/launcher/ClippingScrollView.kt b/app/src/main/java/be/robinj/distrohopper/desktop/launcher/ClippingScrollView.kt index 495a59d0..d1c4394d 100644 --- a/app/src/main/java/be/robinj/distrohopper/desktop/launcher/ClippingScrollView.kt +++ b/app/src/main/java/be/robinj/distrohopper/desktop/launcher/ClippingScrollView.kt @@ -5,12 +5,14 @@ import android.util.AttributeSet import android.widget.ScrollView /** - * The vertical launcher scroll viewport (left/right edges). Its height is floored to a whole - * multiple of the current pinned-icon slot size, so a partial icon can never peek past the - * visible run when there are more pinned/running apps than fit — see - * [LauncherIconGrid.viewportClipPx]. The trailing remainder (< one slot) sits inside the - * launcher background. The size is unchanged when nothing needs clipping or before a slot size - * is known. + * The vertical launcher scroll viewport (left/right edges). When there are more pinned/running + * apps than fit — i.e. only when the content actually overflows and scrolling kicks in — its + * height is floored to a whole multiple of the current pinned-icon slot size, so a partial + * icon can never peek past the visible run; see [LauncherIconGrid.viewportClipPx]. The + * trailing remainder (< one slot) sits inside the launcher background. While everything fits + * the measure is left untouched: no partial icon is possible then, and [PinnedAppsBar]'s + * per-desktop swipe morph measures the bar to a FRACTIONAL length so an auto-sizing launcher + * resizes smoothly — flooring that would snap the animation in whole-icon steps. */ class ClippingScrollView @JvmOverloads constructor( context: Context, @@ -19,6 +21,9 @@ class ClippingScrollView @JvmOverloads constructor( override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { super.onMeasure(widthMeasureSpec, heightMeasureSpec) + val content = this.getChildAt(0)?.measuredHeight ?: 0 + if (content <= this.measuredHeight) return + val clipped = LauncherIconGrid.viewportClipPx( this.measuredHeight, LauncherIconGrid.iconHeightPx(this.context)) if (clipped != this.measuredHeight) diff --git a/app/src/main/java/be/robinj/distrohopper/desktop/launcher/LauncherIconGrid.kt b/app/src/main/java/be/robinj/distrohopper/desktop/launcher/LauncherIconGrid.kt index 5723b9c3..22582442 100644 --- a/app/src/main/java/be/robinj/distrohopper/desktop/launcher/LauncherIconGrid.kt +++ b/app/src/main/java/be/robinj/distrohopper/desktop/launcher/LauncherIconGrid.kt @@ -35,8 +35,9 @@ import kotlin.math.roundToInt * CURRENT theme ([launcherInteriorPx] subtracts the theme's launcher margins and its * launcher-background 9-patch insets), so no icon is cut off and no sliver of an extra one * shows. There is deliberately no separate size clamp: clamping the COUNT to - * `[minCount, maxCount]` already keeps the size within `[MIN_ICON_DP, MAX_ICON_DP]` dp while - * preserving the exact division. + * `[minCount, maxCount]` already keeps the size within `[MIN_ICON_DP, MAX_ICON_DP]` dp for the + * bare screen edge (a theme's margins/insets can shave a few dp more) while preserving the + * exact division. */ object LauncherIconGrid { /** Target physical icon size (dp) used to pick the device's default slot count. */ @@ -112,14 +113,23 @@ object LauncherIconGrid { /** Slot count for the current screen + stored preset (the whole number that must fit). */ @JvmStatic - fun count(context: Context): Int = - countForPreset(context.resources.configuration.smallestScreenWidthDp, preset(context)) + fun count(context: Context): Int = countForPreset(context, preset(context)) /** Slot count for the current screen and a specific [presetIndex] (for the customise hint). */ @JvmStatic fun countForPreset(context: Context, presetIndex: Int): Int = countForPreset(context.resources.configuration.smallestScreenWidthDp, presetIndex) + /** + * Memoises the resolved interior: the resolution below inflates the theme's launcher + * background (a 9-patch bitmap) and two TypedArrays, and it is called from the clipping + * viewports' onMeasure — which runs per FRAME during the per-desktop swipe morph + * ([PinnedAppsBar] requests layout on every morph update). The key captures everything the + * result depends on; UI-thread only, like all view sizing. + */ + private var interiorCacheKey: Triple? = null + private var interiorCachePx = 0 + /** * The usable along-edge length (px) of the launcher placed on the screen's SHORTEST edge: * the shortest screen edge minus the current theme's launcher margins and the launcher @@ -135,11 +145,16 @@ object LauncherIconGrid { val shortEdgePx = min(dm.widthPixels, dm.heightPixels) val theme = DependencyContainer.of(context).themeManager.current - // Theme launcher_margin: 4-item array [top, right, bottom, left]; horizontal bar => the - // two horizontal ends. Every theme uses symmetric margins, but sum both ends regardless. + val cacheKey = Triple(theme.getName(), shortEdgePx, dm.densityDpi) + if (cacheKey == this.interiorCacheKey) { + return this.interiorCachePx + } + + // Theme launcher_margin: 4-item array [top, right, bottom, left]; a horizontal bar + // loses the two horizontal ends (indices 1 and 3, matching LauncherEdgeController's + // convention). Every theme uses symmetric margins, but sum both ends regardless. val margins = res.obtainTypedArray(theme.launcher_margin) - val marginPx = margins.getDimensionPixelSize(Location.RIGHT.n - 1, 0) + - margins.getDimensionPixelSize(Location.LEFT.n - 1, 0) + val marginPx = margins.getDimensionPixelSize(1, 0) + margins.getDimensionPixelSize(3, 0) margins.recycle() // Launcher background 9-patch insets. Dynamic (solid-colour) backgrounds have none. @@ -155,7 +170,11 @@ object LauncherIconGrid { } } - return (shortEdgePx - marginPx - paddingPx).coerceAtLeast(0) + val interior = (shortEdgePx - marginPx - paddingPx).coerceAtLeast(0) + this.interiorCacheKey = cacheKey + this.interiorCachePx = interior + + return interior } /** The per-slot icon size (px) for the current screen, theme and stored preset. */ diff --git a/app/src/main/java/be/robinj/distrohopper/preferences/Preference.kt b/app/src/main/java/be/robinj/distrohopper/preferences/Preference.kt index 08176535..0e99647a 100644 --- a/app/src/main/java/be/robinj/distrohopper/preferences/Preference.kt +++ b/app/src/main/java/be/robinj/distrohopper/preferences/Preference.kt @@ -1,5 +1,7 @@ package be.robinj.distrohopper.preferences +import be.robinj.distrohopper.desktop.launcher.LauncherIconGrid + /** * Every persisted setting, paired with its SharedPreferences key (and, where it * has one, a default value and a parent toggle that gates it). The key is what's @@ -23,11 +25,12 @@ enum class Preference( /** Whether pinned launcher apps are shared globally or kept per desktop. */ LAUNCHER_APP_PIN_MODE("launcher_app_pin_mode", "desktop"), /** - * Pinned-icon size preset (index 0 = Huge … 4 = Tiny, default 2 = "Default"). The pixel - * size is computed at runtime from this; see [be.robinj.distrohopper.desktop.launcher.LauncherIconGrid]. - * A new key (the old `launchericon_width` raw-dp value is intentionally not migrated). + * Pinned-icon size preset (index 0 = Huge … 4 = Tiny, defaulting to the middle, "Default"). + * The pixel size is computed at runtime from this; see + * [be.robinj.distrohopper.desktop.launcher.LauncherIconGrid]. A new key (the old + * `launchericon_width` raw-dp value is intentionally not migrated). */ - LAUNCHER_ICON_PRESET("launcher_icon_preset", 2), + LAUNCHER_ICON_PRESET("launcher_icon_preset", LauncherIconGrid.DEFAULT_PRESET), /** Whether the accessibility-based launcher service is enabled. */ LAUNCHERSERVICE_ENABLED("launcherservice_enabled"), /** Whether dash search also queries the (slower) full set of lenses. */ diff --git a/app/src/main/res/layout/activity_home_customise.xml b/app/src/main/res/layout/activity_home_customise.xml index 43783175..eac99a1a 100644 --- a/app/src/main/res/layout/activity_home_customise.xml +++ b/app/src/main/res/layout/activity_home_customise.xml @@ -30,9 +30,9 @@ android:id="@+id/sbCustomiseLauncherIconSize" android:layout_width="match_parent" android:layout_height="wrap_content" - android:progress="2" - android:max="4" android:saveEnabled="false" />