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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 16 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions app/src/main/java/be/robinj/distrohopper/HomeActivity.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);

Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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.
Expand Down Expand Up @@ -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);

Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
@@ -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<String, Int, Int>? = 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)
}
23 changes: 19 additions & 4 deletions app/src/main/java/be/robinj/distrohopper/home/CustomiseModeUi.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<TextView>(R.id.tvCustomiseLauncherIconHint)
val sbCustomiseLauncherIconSize = this.viewFinder.get<SeekBar>(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) {
[email protected](launcherIconHint, launcherIconLabels, i)
if (b) this.update(i) // see onStopTrackingTouch: ignore non-user (state-restore) changes
}

Expand All @@ -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()
}
})
Expand Down Expand Up @@ -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<String>, 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) }
Expand Down
Loading
Loading