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/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..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 @@ -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,9 @@ 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 (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 new file mode 100644 index 00000000..2706a76f --- /dev/null +++ b/app/src/main/java/be/robinj/distrohopper/desktop/launcher/ClippingHorizontalScrollView.kt @@ -0,0 +1,29 @@ +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). 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, + attrs: AttributeSet? = null, +) : HorizontalScrollView(context, attrs) { + 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) + 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..d1c4394d --- /dev/null +++ b/app/src/main/java/be/robinj/distrohopper/desktop/launcher/ClippingScrollView.kt @@ -0,0 +1,32 @@ +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). 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, + attrs: AttributeSet? = null, +) : ScrollView(context, attrs) { + 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) + 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..0c2225ec --- /dev/null +++ b/app/src/main/java/be/robinj/distrohopper/desktop/launcher/LauncherIconGrid.kt @@ -0,0 +1,194 @@ +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.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 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. */ + 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, 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 + * 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. + * + * The edge is smallestScreenWidthDp × density — the same stable anchor the slot count uses — + * rather than raw display metrics, whose min(width, height) can drift under configuration + * changes the activity handles without recreating (rotation with 3-button nav, a long-axis + * multi-window resize). Everything this px value depends on (smallestScreenSize, density, + * theme) forces a recreate when it changes, so a computed size can never go stale. + */ + @JvmStatic + fun launcherInteriorPx(context: Context): Int { + val res = context.resources + val dm = res.displayMetrics + val shortEdgePx = (res.configuration.smallestScreenWidthDp * dm.density).toInt() + val theme = DependencyContainer.of(context).themeManager.current + + 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(1, 0) + margins.getDimensionPixelSize(3, 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 + } + } + + 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. */ + @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..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 @@ -22,8 +24,13 @@ 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, 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", 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.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/ClippingScrollViewTest.kt b/app/src/test/java/be/robinj/distrohopper/desktop/launcher/ClippingScrollViewTest.kt new file mode 100644 index 00000000..022dc333 --- /dev/null +++ b/app/src/test/java/be/robinj/distrohopper/desktop/launcher/ClippingScrollViewTest.kt @@ -0,0 +1,119 @@ +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) + } + } + + /* + * Scroll views measure their child with an UNSPECIFIED spec along the scroll axis, which + * ignores LayoutParams sizes — in the real launcher the content length comes from summing + * the icon children. Minimum sizes ARE honoured under UNSPECIFIED, so the fakes use those. + */ + private fun child(width: Int = 5000, height: Int = 5000): LinearLayout { + val child = LinearLayout(this.context) + child.layoutParams = ViewGroup.LayoutParams(width, height) + child.minimumWidth = width + child.minimumHeight = height + 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) + val content = this.child() + view.addView(content) + + for (avail in intArrayOf(slot - 1, slot, slot * 3, slot * 4 + slot / 2, 2000)) { + view.measure(this.exactly(avail), this.exactly(slot * 2)) + assertEquals("precondition: content must overflow", 5000, content.measuredWidth) + 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) + val content = this.child() + view.addView(content) + + for (avail in intArrayOf(slot, slot * 5 - 1, slot * 6, 1777)) { + view.measure(this.exactly(slot * 2), this.exactly(avail)) + assertEquals("precondition: content must overflow", 5000, content.measuredHeight) + 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) + } + + /** + * While the content FITS, the measure is left untouched even when it is not a whole slot + * multiple: no partial icon is possible without overflow, and PinnedAppsBar's per-desktop + * morph measures the bar to a fractional length — flooring it would snap the smooth + * auto-sizing resize (GNOME) in whole-icon steps. + */ + @Test fun contentThatFitsIsNotClipped() { + this.setPreset(LauncherIconGrid.DEFAULT_PRESET) + val slot = LauncherIconGrid.iconSizePx(this.context) + val fractional = slot * 2 + slot / 3 // mid-morph bar length: 2⅓ slots + + val horizontal = ClippingHorizontalScrollView(this.context) + val hChild = this.child(width = fractional, height = slot) + horizontal.addView(hChild) + horizontal.measure(this.exactly(slot * 5), this.exactly(slot * 2)) + assertEquals("content must really measure fractional", fractional, hChild.measuredWidth) + assertEquals("viewport untouched when content fits", slot * 5, horizontal.measuredWidth) + + val vertical = ClippingScrollView(this.context) + val vChild = this.child(width = slot, height = fractional) + vertical.addView(vChild) + vertical.measure(this.exactly(slot * 2), this.exactly(slot * 5)) + assertEquals("content must really measure fractional", fractional, vChild.measuredHeight) + assertEquals("viewport untouched when content fits", slot * 5, vertical.measuredHeight) + } +} 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..21177e80 --- /dev/null +++ b/app/src/test/java/be/robinj/distrohopper/desktop/launcher/LauncherIconGridTest.kt @@ -0,0 +1,268 @@ +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)) + } + + /** 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) ------------------- + // These assert the bare-screen ideal (interior = short edge). A theme's margins + // and 9-patch insets shave a few dp more; exactlyCountSlotsFitEveryTheme covers + // that the exact-fit division still holds there. + + /** 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)) + } + + /** + * The interior derives from smallestScreenWidthDp × density — the same stable anchor the + * count uses — not raw window metrics: every configuration input the size depends on + * (smallestScreenSize, density, theme) recreates the activity when it changes (they are not + * in the manifest's configChanges), so a computed size can never go stale under a handled + * screenSize change. + */ + @Test @Config(qualifiers = "sw400dp-w400dp-h800dp") fun interiorAnchoredToSmallestWidth() { + val context = this.ctx() + this.setTheme(context, "default") // no margins, dynamic background: interior == bare edge + val density = context.resources.displayMetrics.density + assertEquals((400 * density).toInt(), LauncherIconGrid.launcherInteriorPx(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. The landscape case is the discriminating one: there screenWidthDp (800) differs + * from smallestScreenWidthDp (400), so a count() bound to the wrong Configuration field + * fails loudly; the preconditions pin the config Robolectric actually produced. + */ + @Test @Config(qualifiers = "sw400dp-w400dp-h800dp-port") fun countAnchoredToShortestEdgePortrait() { + val context = this.ctx() + assertEquals(400, context.resources.configuration.smallestScreenWidthDp) + assertEquals(LauncherIconGrid.countForPreset(400, LauncherIconGrid.preset(context)), + LauncherIconGrid.count(context)) + } + + @Test @Config(qualifiers = "sw400dp-w800dp-h400dp-land") fun countAnchoredToShortestEdgeLandscape() { + val context = this.ctx() + val config = context.resources.configuration + assertEquals(400, config.smallestScreenWidthDp) + assertEquals("landscape config must make the long edge the width", 800, config.screenWidthDp) + 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..480df518 100644 --- a/app/src/test/java/be/robinj/distrohopper/home/ReactivePrefsTest.kt +++ b/app/src/test/java/be/robinj/distrohopper/home/ReactivePrefsTest.kt @@ -8,9 +8,11 @@ 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 +import org.junit.Assert.assertNotEquals import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -42,20 +44,28 @@ class ReactivePrefsTest { } } - @Test fun launcherIconWidthResizesThePanelCloseButton() { + @Test fun launcherIconPresetResizesThePanelCloseButton() { + var widthBefore = 0 this.scenario.onActivity { activity -> + widthBefore = activity.findViewById(R.id.ibPanelDashClose).layoutParams.width + // "Huge" (0) resolves to a different slot count than the default on the + // test screen (see the precondition below), so the width 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() + val sw = activity.resources.configuration.smallestScreenWidthDp + assertNotEquals("test screen must distinguish Huge from Default", + LauncherIconGrid.countForPreset(sw, 0), + LauncherIconGrid.countForPreset(sw, LauncherIconGrid.DEFAULT_PRESET)) - assertEquals(expected, - activity.findViewById(R.id.ibPanelDashClose).layoutParams.width) + val width = activity.findViewById(R.id.ibPanelDashClose).layoutParams.width + assertEquals(LauncherIconGrid.iconSizePx(activity), width) + // Guards against the startup sizing masking a dead reactive path. + assertNotEquals(widthBefore, width) } }