From c7e3a1a24ea48be35df62160813a1373c918d1f0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 09:17:44 +0000 Subject: [PATCH 1/3] Give the dash a live "make space" reorder preview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dragging apps to reorder the dash under the custom sort order previously had no live feedback: the icons stayed put and only jumped into place on drop, and fold-vs-reorder was distinguished by a dwell timer. This makes the dash mirror the launcher bar's reorder preview. The dragged loose app or whole folder now becomes an empty placeholder cell (GridAdapter.setHiddenKey, keyed by DashItem.stableKey) and the distinction between reordering and foldering is spatial, like LauncherDragListener: over a cell's centre (the 0.3-0.7 horizontal band) rings it for a folder create/add and freezes the gap; over a cell's edge or the space between cells opens a reorder gap there (custom order only). The other icons flow around the gap as the finger moves — the listener reorders the adapter's items live as a preview over the untouched model and animates each cell from its old slot to its new one with a pre-draw translate, so they slide rather than jump. The drop commits the previewed order through DashLayoutRepository.moveItem. Folder-member extraction keeps its simpler pause-to-fold gesture. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01D594DCEMgKkkceo5McSkKU --- AGENTS.md | 2 +- .../desktop/dash/DashGridDragListener.kt | 388 +++++++++++++++--- .../distrohopper/desktop/dash/DashItem.kt | 13 + .../desktop/dash/GridAdapter.java | 33 ++ .../desktop/dash/DashGridDragListenerTest.kt | 198 ++++++--- 5 files changed, 509 insertions(+), 125 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 3f0bf53f..27ae2d64 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -85,7 +85,7 @@ etc/ — design assets (SVG/XCF sources, desktops for uninstall). - `App`, `Application`, `AppComparatorAlphabetical` — app model classes. `App` usually wraps a PackageManager `ResolveInfo`, but can also wrap a `LauncherActivityInfo` for apps in another profile (see Profiles below), or represent DistroHopper-owned internal shortcuts that live only in the dash (currently the settings shortcut) and launch by explicit in-app intent rather than a public launcher component. Internal shortcut intents must not set `FLAG_ACTIVITY_NEW_TASK` (the target shares the home task's affinity, so it would only bring the home task to the front); the settings shortcut launches via `startActivityForResult` so `HomeActivity.onActivityResult` can handle the Customise UI result. - **Dash sort order**: `AppRepository.sort()` orders the installed list by the `preferences/AppSortOrder` chosen in settings (`APP_SORT_ORDER` = `alphabetical`|`recent`|`most_used`|`custom`, default `alphabetical`; unknown → alphabetical). `AppComparators.forOrder` builds the comparator; the two usage-based orders sort by score descending with `AppComparatorAlphabetical` as the secondary key (so a tied group stays alphabetical), and `custom` degrades to alphabetical there (the manual arrangement is applied by `DashLayoutRepository`, not by a comparator). The `custom` order is developer-gated for now (needs more testing before general availability): `PreferencesActivity` only offers it while developer mode is on and resets a stored `custom` back to alphabetical when it isn't. The usage data lives in `AppUsageStats` — per-app launch count and last-launched timestamp keyed by `App.profileScopedKey`, in its own `"app_usage"` SharedPreferences file (kept out of the main "prefs" so a launch doesn't trip its change listeners). `App.launch()` records every real launch there (internal shortcuts excluded). The order is read fresh on each `sort()`, so changing it in settings takes effect when home reloads on leaving the preferences screen. - - **Dash folders & custom order**: the dash grid renders `DashItem`s (an `AppItem` or a `FolderItem`) built by `DashLayoutRepository` from the installed list plus the persisted folders/manual order (`DashLayoutStorage`, the `"dash_layout"` SharedPreferences file, partitioned per profile). `AppManager` owns it (`getDashLayout`, `loadDashLayout` after `loadPinnedApps`, `dashLayoutChanged` to refresh; `remove` reconciles it). Folders are unnamed, hold ≥2 apps (a folder dropped to 1 dissolves), are capped at 9 (`DashLayoutRepository.MAX_FOLDER_APPS`, a 3x3 grid — see `folder/FolderGrid`), and render via `folder/FolderIconDrawable` (a mini-grid icon). `DashComparators` orders items: a folder ranks by its highest-scoring member, and in `alphabetical` mode folders group ahead of loose apps. Tapping a folder opens `FolderPopup`. Dragging within the dash is `desktop/dash/DashGridDragListener` (pause over another app to fold — the `dash_folder_drop_indicator` ring — drop on a folder to add, reorder under `custom` only, drag a folder member onto the dash to pull it out); a loose app keeps passing its `App` as the drag's local state (so the launcher still pins it), while folders/members carry a `desktop/dash/DashDragPayload`. `TrashDragListener` deletes a dropped folder (members return loose). The dash no longer auto-closes when a drag starts (`LauncherBarBinder.startedDragging` keeps it open and the BFB visible); the cross-surface drag instead closes the dash when the drag hovers the launcher or panel and re-opens it when it hovers a BFB while the dash is closed, so an app can move dash↔launcher↔desktop in one drag. `desktop/launcher/DashCrossSurfaceController` resolves the intent **by dash state**: while the dash is open, hovering the launcher (reported from `LauncherDragListener`) or the panel (`DashEdgeDragListener` on `llPanel`) closes it; while it is closed, hovering a BFB (`DashEdgeDragListener` on `lalBfb` / `tvPanelBfb`) re-opens it. A BFB only ever *opens* (**open precedence**): it sits inside its bar, so a hover registers both an open- and a close-target — counting that as a close would make the BFB a toggle and the dash would flicker across the BFB↔bar boundary, so while open the close fires only when a close-target is hovered and no BFB is, and opening drops the close-target that rode in with the BFB hover (so leaving the BFB doesn't slam it shut). Enter/exit are edge-triggered (no per-LOCATION action) so a hover resolves once without oscillating; the change is posted + debounced. Dropping while the dash is open means dropping **into** the dash: `WidgetsContainer_DragListener.dropIntoDash` removes the app from its source surface (unpin a dock pin / take a desktop app off the grid / leave a desktop folder) so it returns to just the app drawer, instead of letting the drop fall through onto the desktop behind. + - **Dash folders & custom order**: the dash grid renders `DashItem`s (an `AppItem` or a `FolderItem`) built by `DashLayoutRepository` from the installed list plus the persisted folders/manual order (`DashLayoutStorage`, the `"dash_layout"` SharedPreferences file, partitioned per profile). `AppManager` owns it (`getDashLayout`, `loadDashLayout` after `loadPinnedApps`, `dashLayoutChanged` to refresh; `remove` reconciles it). Folders are unnamed, hold ≥2 apps (a folder dropped to 1 dissolves), are capped at 9 (`DashLayoutRepository.MAX_FOLDER_APPS`, a 3x3 grid — see `folder/FolderGrid`), and render via `folder/FolderIconDrawable` (a mini-grid icon). `DashComparators` orders items: a folder ranks by its highest-scoring member, and in `alphabetical` mode folders group ahead of loose apps. Tapping a folder opens `FolderPopup`. Dragging within the dash is `desktop/dash/DashGridDragListener`, which mirrors the launcher bar's live reorder preview: a dragged loose app or whole folder becomes an empty placeholder cell (its `GridAdapter` hidden via `setHiddenKey`, keyed by `DashItem.stableKey`) and the fold-vs-reorder distinction is **spatial**, like `LauncherDragListener` — over a cell's centre (the 0.3–0.7 horizontal band) rings it (`dash_folder_drop_indicator`) for a folder create/add and freezes the gap, while over a cell's edge / the space between cells opens a reorder gap there (`custom` order only). The other icons flow around the gap as the finger moves: the listener reorders the adapter's items live (a preview over `baseItems`, the model untouched until drop) and animates each cell from its old slot to its new one via a pre-draw translate (`ACTION_DROP` commits the previewed order through `DashLayoutRepository.moveItem`). Folder-member extraction keeps the simpler **pause-to-fold** gesture (dwell to arm a fold, otherwise drop on the dash to pull the member out loose). A loose app keeps passing its `App` as the drag's local state (so the launcher still pins it), while folders/members carry a `desktop/dash/DashDragPayload`. `TrashDragListener` deletes a dropped folder (members return loose). The dash no longer auto-closes when a drag starts (`LauncherBarBinder.startedDragging` keeps it open and the BFB visible); the cross-surface drag instead closes the dash when the drag hovers the launcher or panel and re-opens it when it hovers a BFB while the dash is closed, so an app can move dash↔launcher↔desktop in one drag. `desktop/launcher/DashCrossSurfaceController` resolves the intent **by dash state**: while the dash is open, hovering the launcher (reported from `LauncherDragListener`) or the panel (`DashEdgeDragListener` on `llPanel`) closes it; while it is closed, hovering a BFB (`DashEdgeDragListener` on `lalBfb` / `tvPanelBfb`) re-opens it. A BFB only ever *opens* (**open precedence**): it sits inside its bar, so a hover registers both an open- and a close-target — counting that as a close would make the BFB a toggle and the dash would flicker across the BFB↔bar boundary, so while open the close fires only when a close-target is hovered and no BFB is, and opening drops the close-target that rode in with the BFB hover (so leaving the BFB doesn't slam it shut). Enter/exit are edge-triggered (no per-LOCATION action) so a hover resolves once without oscillating; the change is posted + debounced. Dropping while the dash is open means dropping **into** the dash: `WidgetsContainer_DragListener.dropIntoDash` removes the app from its source surface (unpin a dock pin / take a desktop app off the grid / leave a desktop folder) so it returns to just the app drawer, instead of letting the drop fall through onto the desktop behind. - **Launcher folders**: `LauncherLayoutRepository` (+ `LauncherLayoutStorage`, the `"launcher_layout"` file) layers folders over the **per-desktop** pinned apps, the counterpart to the dash's `DashLayoutRepository`. It stores only folder **membership** — the bar's order is the pinned order itself (`AppRepository.pinnedOn`), with a folder rendered (once) at its first member's position — so reordering the bar reorders the pinned model (`AppRepository.reorderPinned`, fed by `LauncherBarBinder.flattenBarKeys`) and the floating launcher service / persistence stay in step. `AppManager` owns it (`getLauncherLayout`, `loadLauncherLayout` after `loadPinnedApps`, `launcherLayoutChanged`; `remove` reconciles it). The bar renders `LauncherItem`s (app or `LauncherFolderView` mini-grid); `LauncherBarBinder.refreshPinnedView`/`buildMorph` are item-based and the per-desktop morph (`LauncherMorph`, now generic) fades folders in/out (a folder id is unique to one desktop). Drag uses the existing placeholder reorder generalised to views (`draggedPinnedItemOver`), with `AppLauncherDragListener` adding a dwell-to-fold (`foldDraggedOnto`: create/add, toast when full) and folder drags carrying a `LauncherDragPayload`; `TrashDragListener` **deletes a launcher folder and unpins its members** (unlike the dash, where they return loose). Tapping a launcher folder opens the shared `FolderPopup` (with a `LauncherDragPayload.FolderMemberDrag` payload); long-pressing a member there drags it **out** of the folder, behaving exactly like dragging the pin itself — `LauncherDragListener` opens a placeholder via `LauncherBarBinder.startedDraggingLauncherFolderMember` so it reorders/folds normally, and because a folder only *groups* already-pinned apps the app is **not** re-pinned but **ungrouped** (`removeFromFolder`) on a committing drop (cancel leaves it in the folder; `flattenBarKeysExtracting` keeps the dragged occurrence at the drop slot). `FolderPopup` (shared with the dash) is an **in-activity overlay** — added to the activity's `content`, NOT a `PopupWindow` — so the extract drag originates in the same window as its drop targets and carries its local state to them (a cross-window / `DRAG_FLAG_GLOBAL` drag arrives with a *null* local state, which the listeners need); this mirrors `DesktopFolderOverlay`. A launcher folder member can also be dropped on the **desktop** (`WidgetsContainer_DragListener.Drag.LauncherFolderMember`): it is placed there, then ungrouped and unpinned off the bar, so it leaves the launcher — like dropping a dock pin on the desktop. - **Desktop folders** (apps only): a folder is a 2×2 `widgets/DesktopFolderView` on the 8×8 grid whose apps are packed 1×1 on the 3×3 `folder/FolderGrid` for storage (`widgets/DesktopFolderLayout` + `DesktopFolderCell`, `withApp` fit-or-null, ≥2 apps, ≤9; persisted in the shared `"desktop_layout"` file via `DesktopLayoutStorage`, alongside the desktop widgets and pinned apps). The folder view itself is unnamed (the `FolderIconDrawable` mini-grid, no label), but the opened overlay shows each member app's label (it inflates the shared `widget_dash_applauncher` icon-over-label cell, like the dash/launcher popups — not a bare icon). `widgets/DesktopFolderHost` mirrors `DesktopAppHost` (create from two desktop apps, two `addApp` overloads — a loose `DesktopAppView` removed off the grid, or an `App` coming straight from another surface — both toast-on-full, deleteFolder removes apps, moveTo, restore/persist, per-page, uninstall reconcile) and is the third desktop owner in `home/Desktops`; it `restore()`s after widgets + desktop apps so its 2×2 avoids occupied cells, and `WidgetsContainer.collectOccupied` includes folder cells (+ `findViewAtCell` for hit-testing). Tapping a folder opens `widgets/DesktopFolderOverlay` — an **in-activity** overlay (not a PopupWindow) so the extract drag originates in the activity window and carries its local state to the drop targets. The overlay lays its apps out with the adaptive `FolderGrid.columns`/`rows` mapping (NOT the stored packed col/row), the **same** layout the `FolderIconDrawable` preview uses, so an opened folder matches its icon (4 apps read as a 2×2, not a row-major 3+1). All three folder popovers share `folder/FolderOverlay` for their chrome: a dim, GPU-blurred (`RenderEffect`, minSdk 31, no wallpaper bitmap) backdrop with the grid opened **centred over the tapped icon** (clamped on-screen) by a scale+fade animation that grows from it. The desktop's loose-XOR-in-folder invariant is also enforced on restore (a folder member that is *also* a loose desktop app is dropped — loose wins — via `DesktopAppHost.viewForKey`) and live (a cross-surface drop that lands an app loose on the desktop calls `DesktopFolderHost.dropFromFolders`). Drag wiring is in `widgets/WidgetsContainer_DragListener` (`Drag.DesktopFolder` reposition; dwell a desktop app over another app/folder folds — app→app creates **at the target's cell** (the stationary app dragged onto), app→folder adds; an **incoming** app — a `Drag.IncomingApp` from the dash/search or off the launcher bar — dwelt over a folder adds straight into it via `addApp(folderId, app)`, the drop ring showing as for any fold, then the launcher origin is cleared like the loose-drop path); `TrashDragListener` deletes a desktop folder (its apps gone). Long-pressing a member in the overlay extracts it (a `DesktopFolderMemberDrag`): dropped on the desktop it lands at the drop cell (`DesktopFolderHost.removeMember` clears/shrinks the folder *before* placing the extracted app, so the still-present 2×2 can't bump it and a dissolved folder's other member — returned near the folder's old cell — can't take the drop spot), dropped on the trash it is deleted (`deleteMember`). - **Profiles (work profile support)**: `Profiles` wraps the profile diff --git a/app/src/main/java/be/robinj/distrohopper/desktop/dash/DashGridDragListener.kt b/app/src/main/java/be/robinj/distrohopper/desktop/dash/DashGridDragListener.kt index 0f1ae267..cfca917c 100644 --- a/app/src/main/java/be/robinj/distrohopper/desktop/dash/DashGridDragListener.kt +++ b/app/src/main/java/be/robinj/distrohopper/desktop/dash/DashGridDragListener.kt @@ -5,6 +5,8 @@ import android.os.Looper import android.os.UserHandle import android.view.DragEvent import android.view.View +import android.view.ViewTreeObserver +import android.view.animation.DecelerateInterpolator import android.widget.AdapterView import android.widget.GridView import android.widget.Toast @@ -15,14 +17,22 @@ import be.robinj.distrohopper.R import be.robinj.distrohopper.home.LauncherBarBinder /** - * Handles dragging within the dash grid: folder creation (pause an app over - * another app), adding to a folder (drop on it), reordering (custom order only) - * and extracting a folder member (drop a member on the dash to pull it out). + * Handles dragging within the dash grid: reordering (custom order only), folder + * creation / adding, and extracting a folder member. + * + * A loose dash app and a whole folder are dragged with a *live* preview that + * mirrors the launcher bar: the dragged cell becomes an empty placeholder (the + * gap the drop will land in) and the other icons flow around it as the finger + * moves, so the user can see exactly where the app will drop. The distinction + * between reordering and foldering is spatial, like the launcher: over a cell's + * centre rings it for a folder create/add (the gap freezes where it is); over a + * cell's edge (or the space between cells) opens a gap there to reorder into. * * A loose dash app's drag carries its [App] as local state (so dropping on the * launcher still pins it); folders and folder members carry a [DashDragPayload]. - * The listener claims the drag at [DragEvent.ACTION_DRAG_STARTED] only for these - * dash kinds, leaving widget/other drags to their own listeners. + * Folder-member extraction keeps its simpler pause-to-fold gesture. The listener + * claims the drag at [DragEvent.ACTION_DRAG_STARTED] only for these dash kinds, + * leaving widget/other drags to their own listeners. */ class DashGridDragListener( private val activity: HomeActivity, @@ -30,34 +40,298 @@ class DashGridDragListener( private val profile: UserHandle?, ) : View.OnDragListener { private val handler = Handler(Looper.getMainLooper()) + private val reflowInterpolator = DecelerateInterpolator() + + // --- Folder-member extraction (pause-to-fold, no reorder preview) --- private var hoverPosition = AdapterView.INVALID_POSITION private var armedPosition = AdapterView.INVALID_POSITION + + // --- Loose app / folder reorder preview --- + /** The dragged item's [stableKey], or null when this drag isn't previewed. */ + private var draggedKey: String? = null + /** The dragged item itself, re-inserted into the preview at [previewIndex]. */ + private var draggedItem: DashItem? = null + /** The display order with the dragged item removed (the model never changes mid-drag). */ + private var baseItems: List = emptyList() + /** Where the dragged placeholder currently sits within [baseItems]. */ + private var previewIndex = 0 + /** The item ringed for a fold, or null when a reorder gap is showing instead. */ + private var foldTargetKey: String? = null + private var highlighted: View? = null override fun onDrag(view: View, event: DragEvent): Boolean { val grid = view as? GridView ?: return false when (event.action) { - DragEvent.ACTION_DRAG_STARTED -> return this.draggedApp(event) != null || - event.localState is DashDragPayload + DragEvent.ACTION_DRAG_STARTED -> { + val claim = this.draggedApp(event) != null || event.localState is DashDragPayload + if (claim) { + this.resetPreview() // never carry state over from a previous drag // + this.setupPreview(grid, event) + } + return claim + } - DragEvent.ACTION_DRAG_LOCATION -> this.onLocation(grid, event) + DragEvent.ACTION_DRAG_LOCATION -> + if (this.isMemberDrag(event)) { + this.onMemberLocation(grid, event) + } else { + this.resolveAndPreview(grid, event) + } - DragEvent.ACTION_DROP -> this.onDrop(grid, event) + DragEvent.ACTION_DROP -> + if (this.isMemberDrag(event)) { + this.onMemberDrop(grid, event) + } else { + this.onPreviewDrop(event) + } - DragEvent.ACTION_DRAG_EXITED -> this.clearHover() + DragEvent.ACTION_DRAG_EXITED -> + if (this.isMemberDrag(event)) { + this.clearHover() + } else { + this.setFold(grid, null) // keep the gap; only drop the fold ring // + } DragEvent.ACTION_DRAG_ENDED -> { this.clearHover() + val hadPreview = this.draggedKey != null + val adapter = grid.adapter as? GridAdapter // Must not mutate views during ENDED dispatch (see AGENTS.md). // - grid.post { LauncherBarBinder.stoppedDragging(this.activity) } + grid.post { + if (hadPreview) { + // Restore the app and, for a cancelled (uncommitted) drag, the + // model's order — the adapter may still hold the preview. // + adapter?.setHiddenKey(null) + this.appManager.dashLayoutChanged() + } + this.resetPreview() + LauncherBarBinder.stoppedDragging(this.activity) + } } } return true } - private fun onLocation(grid: GridView, event: DragEvent) { + // --- Loose app / folder reorder preview ------------------------------------- + + private fun isMemberDrag(event: DragEvent): Boolean = + event.localState is DashDragPayload.FolderMemberDrag + + /** + * Sets up the reorder preview for a loose app or a whole folder: the dragged + * cell becomes an empty placeholder and the rest of the grid is captured as + * the base order the placeholder slides through. A no-op for member drags and + * for a grid whose page doesn't hold the dragged item (another profile). + */ + private fun setupPreview(grid: GridView, event: DragEvent) { + val key = this.draggedKeyOf(event) ?: return + val items = this.appManager.dashLayout.dashItems(this.profile) + val index = items.indexOfFirst { it.stableKey == key } + if (index < 0) { + return // this page doesn't hold the dragged item // + } + + this.draggedItem = items[index] + this.draggedKey = key + this.baseItems = items.filterIndexed { i, _ -> i != index } + this.previewIndex = index + this.foldTargetKey = null + (grid.adapter as? GridAdapter)?.setHiddenKey(key) + } + + /** The [stableKey] of the item a preview-capable drag is carrying, else null. */ + private fun draggedKeyOf(event: DragEvent): String? = when (val state = event.localState) { + is App -> DashItem.AppItem(state).stableKey + is DashDragPayload.FolderDrag -> "folder:" + state.folderId + else -> null + } + + /** + * Resolves the drag over the grid into a fold (over a cell's centre) or a + * reorder gap (over an edge / between cells) and previews it. + */ + private fun resolveAndPreview(grid: GridView, event: DragEvent) { + if (this.draggedKey == null) { + return + } + val custom = FolderPopup.customOrderingEnabled(this.activity) + val x = event.x.toInt() + val position = grid.pointToPosition(x, event.y.toInt()) + + if (position == AdapterView.INVALID_POSITION) { + // Past the last icon / empty area: preview an append (custom order only). + this.setFold(grid, null) + if (custom) { + this.previewInsertAt(grid, this.baseItems.size) + } + return + } + + val over = this.itemAt(grid, position) + val frac = this.horizontalFraction(grid, position, x) + val draggedIsApp = this.draggedItem is DashItem.AppItem + val foldable = draggedIsApp && over != null && over.stableKey != this.draggedKey && + (over is DashItem.FolderItem || over is DashItem.AppItem) + + if (foldable && frac in FOLD_ZONE_LO..FOLD_ZONE_HI) { + // Over a cell's centre: ring it and freeze the gap (drop = fold). // + this.setFold(grid, over!!.stableKey) + return + } + + this.setFold(grid, null) + if (!custom || (over != null && over.stableKey == this.draggedKey)) { + return // reorder is custom-only; and never past our own gap // + } + + val baseIndex = if (over == null) { + this.baseItems.size + } else { + this.baseItems.indexOfFirst { it.stableKey == over.stableKey } + } + if (baseIndex < 0) { + return + } + val insertIndex = (baseIndex + if (frac >= 0.5f) 1 else 0).coerceIn(0, this.baseItems.size) + this.previewInsertAt(grid, insertIndex) + } + + /** Moves the placeholder to [index] within the base order, flowing the icons around it. */ + private fun previewInsertAt(grid: GridView, index: Int) { + if (index == this.previewIndex) { + return + } + val item = this.draggedItem ?: return + val adapter = grid.adapter as? GridAdapter ?: return + + val before = this.captureChildBounds(grid) + this.previewIndex = index + val preview = ArrayList(this.baseItems) + preview.add(index, item) + adapter.setItems(preview) + this.animateReflow(grid, before) + } + + private fun onPreviewDrop(event: DragEvent) { + val fold = this.foldTargetKey + val custom = FolderPopup.customOrderingEnabled(this.activity) + + when (val state = event.localState) { + is App -> + if (fold != null) { + this.foldOnto(state, fold) + } else if (custom) { + this.commitMove(DashItem.AppItem(state).stableKey) + } + + is DashDragPayload.FolderDrag -> + // Folders only reposition (custom order); they never enter folders. // + if (custom) { + this.commitMove("folder:" + state.folderId) + } + } + + this.appManager.dashLayoutChanged() + } + + /** Commits the previewed reorder: move the item to where its placeholder rests. */ + private fun commitMove(key: String) { + val items = this.appManager.dashLayout.dashItems(this.profile) + val from = items.indexOfFirst { it.stableKey == key } + if (from < 0) { + return + } + this.appManager.dashLayout.moveItem( + this.profile, from, this.previewIndex.coerceIn(0, items.size - 1)) + } + + /** Folds the dragged app onto the target: create a folder, or add to one. */ + private fun foldOnto(app: App, targetKey: String) { + val target = this.appManager.dashLayout.dashItems(this.profile) + .firstOrNull { it.stableKey == targetKey } + val layout = this.appManager.dashLayout + + if (target is DashItem.AppItem && target.app != app) { + layout.createFolder(app, target.app) + } else if (target is DashItem.FolderItem) { + if (!layout.addToFolder(target.folder.id, app)) { + Toast.makeText(this.activity, R.string.folder_full, Toast.LENGTH_SHORT).show() + } + } + } + + private fun setFold(grid: GridView, key: String?) { + if (this.foldTargetKey == key) { + return + } + this.foldTargetKey = key + this.clearHighlight() + if (key != null) { + this.highlightKey(grid, key) + } + } + + private fun resetPreview() { + this.draggedKey = null + this.draggedItem = null + this.baseItems = emptyList() + this.previewIndex = 0 + this.foldTargetKey = null + } + + // --- Reflow animation ------------------------------------------------------- + + /** Records each visible cell's position (keyed by item) before a reorder. */ + private fun captureChildBounds(grid: GridView): HashMap { + val bounds = HashMap() + for (i in 0 until grid.childCount) { + val child = grid.getChildAt(i) + val key = (child.tag as? DashItem)?.stableKey ?: continue + bounds[key] = intArrayOf(child.left, child.top) + } + return bounds + } + + /** + * After the reorder relays out the grid, translates each cell from its old + * position to its new one and animates the offset away — so the icons appear + * to slide into place rather than jumping (the same "make space" feel the + * launcher bar gets for free from its LayoutTransition). + */ + private fun animateReflow(grid: GridView, before: Map) { + grid.viewTreeObserver.addOnPreDrawListener(object : ViewTreeObserver.OnPreDrawListener { + override fun onPreDraw(): Boolean { + grid.viewTreeObserver.removeOnPreDrawListener(this) + for (i in 0 until grid.childCount) { + val child = grid.getChildAt(i) + val key = (child.tag as? DashItem)?.stableKey ?: continue + if (key == this@DashGridDragListener.draggedKey) { + continue // the invisible placeholder // + } + val old = before[key] ?: continue + val dx = old[0] - child.left + val dy = old[1] - child.top + if (dx == 0 && dy == 0) { + continue + } + child.translationX = dx.toFloat() + child.translationY = dy.toFloat() + child.animate().translationX(0F).translationY(0F) + .setDuration(REFLOW_MS) + .setInterpolator(this@DashGridDragListener.reflowInterpolator) + .start() + } + return true + } + }) + } + + // --- Folder-member extraction (unchanged pause-to-fold gesture) ------------- + + private fun onMemberLocation(grid: GridView, event: DragEvent) { val position = grid.pointToPosition(event.x.toInt(), event.y.toInt()) if (position == this.hoverPosition) { return @@ -71,8 +345,6 @@ class DashGridDragListener( val dragged = this.draggedApp(event) ?: return val target = this.itemAt(grid, position) ?: return - // Pausing an app over a different app (or any app over a folder) arms a - // folder create/add; a quick pass instead reorders (custom order only). val canFold = when (target) { is DashItem.FolderItem -> true is DashItem.AppItem -> target.app != dragged @@ -85,69 +357,25 @@ class DashGridDragListener( } } - private fun onDrop(grid: GridView, event: DragEvent) { + private fun onMemberDrop(grid: GridView, event: DragEvent) { val position = grid.pointToPosition(event.x.toInt(), event.y.toInt()) val armed = this.armedPosition == position && position != AdapterView.INVALID_POSITION val target = this.itemAt(grid, position) val layout = this.appManager.dashLayout + val state = event.localState as? DashDragPayload.FolderMemberDrag ?: return - when (val state = event.localState) { - is App -> this.dropApp(state, target, armed, position) - - is DashDragPayload.FolderMemberDrag -> { - // Pull the member out, then fold/add if it landed on another item. - layout.removeFromFolder(state.folderId, state.app.profileScopedKey) - if (armed) { - this.dropApp(state.app, target, true, position) - } - this.appManager.dashLayoutChanged() - } - - is DashDragPayload.FolderDrag -> { - // Folders only reposition (custom order); they never enter folders. - if (FolderPopup.customOrderingEnabled(this.activity) && - position != AdapterView.INVALID_POSITION) { - this.reorderTo(grid, state.folderId, position) - } - } - } - } - - private fun dropApp(app: App, target: DashItem?, armed: Boolean, position: Int) { - val layout = this.appManager.dashLayout - - if (armed && target is DashItem.AppItem && target.app != app) { - layout.createFolder(app, target.app) + // Pull the member out, then fold/add if it landed on another item. // + layout.removeFromFolder(state.folderId, state.app.profileScopedKey) + if (armed && target is DashItem.AppItem && target.app != state.app) { + layout.createFolder(state.app, target.app) } else if (armed && target is DashItem.FolderItem) { - if (!layout.addToFolder(target.folder.id, app)) { + if (!layout.addToFolder(target.folder.id, state.app)) { Toast.makeText(this.activity, R.string.folder_full, Toast.LENGTH_SHORT).show() } - } else if (FolderPopup.customOrderingEnabled(this.activity) && - position != AdapterView.INVALID_POSITION) { - this.moveAppTo(app, position) } - this.appManager.dashLayoutChanged() } - /** Reorders a loose app to the dropped grid position (custom order). */ - private fun moveAppTo(app: App, toPosition: Int) { - val items = this.appManager.dashLayout.dashItems(this.profile) - val from = items.indexOfFirst { it is DashItem.AppItem && it.app == app } - if (from >= 0) { - this.appManager.dashLayout.moveItem(this.profile, from, toPosition) - } - } - - private fun reorderTo(grid: GridView, folderId: String, toPosition: Int) { - val items = this.appManager.dashLayout.dashItems(this.profile) - val from = items.indexOfFirst { it is DashItem.FolderItem && it.folder.id == folderId } - if (from >= 0) { - this.appManager.dashLayout.moveItem(this.profile, from, toPosition) - this.appManager.dashLayoutChanged() - } - } - /** The dragged loose/extracted app, or null when the drag is a whole folder. */ private fun draggedApp(event: DragEvent): App? = when (val state = event.localState) { is App -> state @@ -155,6 +383,8 @@ class DashGridDragListener( else -> null } + // --- Shared helpers --------------------------------------------------------- + private fun itemAt(grid: GridView, position: Int): DashItem? { if (position == AdapterView.INVALID_POSITION) { return null @@ -163,12 +393,33 @@ class DashGridDragListener( return grid.adapter?.getItem(position) as? DashItem } + /** The drag's horizontal position within the cell at [position] (0..1); 0.5 if unknown. */ + private fun horizontalFraction(grid: GridView, position: Int, x: Int): Float { + val child = grid.getChildAt(position - grid.firstVisiblePosition) ?: return 0.5f + if (child.width <= 0) { + return 0.5f + } + + return ((x - child.left).toFloat() / child.width).coerceIn(0f, 1f) + } + private fun highlight(grid: GridView, position: Int) { val child = grid.getChildAt(position - grid.firstVisiblePosition) ?: return child.setBackgroundResource(R.drawable.dash_folder_drop_indicator) this.highlighted = child } + private fun highlightKey(grid: GridView, key: String) { + for (i in 0 until grid.childCount) { + val child = grid.getChildAt(i) + if ((child.tag as? DashItem)?.stableKey == key) { + child.setBackgroundResource(R.drawable.dash_folder_drop_indicator) + this.highlighted = child + return + } + } + } + private fun clearHighlight() { this.highlighted?.background = null this.highlighted = null @@ -183,5 +434,12 @@ class DashGridDragListener( companion object { private const val FOLDER_DWELL_MS = 550L + + /** How long the icons take to slide into place after a reorder gap moves. */ + private const val REFLOW_MS = 180L + + /** Fraction of a cell's width, centred, that reads as "fold onto this cell". */ + private const val FOLD_ZONE_LO = 0.3f + private const val FOLD_ZONE_HI = 0.7f } } diff --git a/app/src/main/java/be/robinj/distrohopper/desktop/dash/DashItem.kt b/app/src/main/java/be/robinj/distrohopper/desktop/dash/DashItem.kt index 9236c0db..d0f54140 100644 --- a/app/src/main/java/be/robinj/distrohopper/desktop/dash/DashItem.kt +++ b/app/src/main/java/be/robinj/distrohopper/desktop/dash/DashItem.kt @@ -14,3 +14,16 @@ sealed interface DashItem { /** [apps] is the folder's resolved, installed members in order (always ≥2 when rendered). */ data class FolderItem(val folder: Folder, val apps: List) : DashItem } + +/** + * A stable string identity for a dash item, used to match a cell to itself + * across a drag-reorder preview (so the grid's reflow animation can translate + * each icon from its old slot to its new one) and to mark the dragged cell as + * an empty placeholder. App and folder ids are prefixed so they can never + * collide (mirrors [be.robinj.distrohopper.DashLayoutRepository]'s scheme). + */ +val DashItem.stableKey: String + get() = when (this) { + is DashItem.AppItem -> "app:" + this.app.profileScopedKey + is DashItem.FolderItem -> "folder:" + this.folder.id + } diff --git a/app/src/main/java/be/robinj/distrohopper/desktop/dash/GridAdapter.java b/app/src/main/java/be/robinj/distrohopper/desktop/dash/GridAdapter.java index efdfc705..f07d9160 100644 --- a/app/src/main/java/be/robinj/distrohopper/desktop/dash/GridAdapter.java +++ b/app/src/main/java/be/robinj/distrohopper/desktop/dash/GridAdapter.java @@ -32,10 +32,37 @@ * Created by robin on 8/21/14. */ public class GridAdapter extends ArrayAdapter { + /** + * The stable key ({@code DashItem.stableKey}) of the item being dragged, or + * null when no drag is in progress. Its cell is rendered as an empty + * placeholder (INVISIBLE) so it reads as the gap the drop will land in — + * mirroring the launcher bar's invisible-placeholder reorder preview. + */ + private String hiddenKey = null; + public GridAdapter(final Context context, final List items) { super (context, R.layout.widget_dash_applauncher, items); } + /** Marks (or, with null, unmarks) the dragged item's cell as an empty gap. */ + public void setHiddenKey (final String key) + { + if ((this.hiddenKey == null) ? (key == null) : this.hiddenKey.equals (key)) + return; + + this.hiddenKey = key; + this.notifyDataSetChanged (); + } + + /** Replaces the display order in place, e.g. to preview a drag-reorder. */ + public void setItems (final List items) + { + this.setNotifyOnChange (false); + this.clear (); + this.addAll (items); + this.notifyDataSetChanged (); + } + @Override public View getView (int position, View view, ViewGroup parent) { @@ -78,6 +105,12 @@ public View getView (int position, View view, ViewGroup parent) view.setScaleX (1); view.setScaleY (1); + // The dragged item's cell shows as an empty gap (the drop target preview); + // every other cell is visible (a recycled view may still be hidden) // + view.setVisibility ( + (this.hiddenKey != null && this.hiddenKey.equals (DashItemKt.getStableKey (item))) + ? View.INVISIBLE : View.VISIBLE); + view.setTag (item); return view; diff --git a/app/src/test/java/be/robinj/distrohopper/desktop/dash/DashGridDragListenerTest.kt b/app/src/test/java/be/robinj/distrohopper/desktop/dash/DashGridDragListenerTest.kt index ae7b4e36..49716bff 100644 --- a/app/src/test/java/be/robinj/distrohopper/desktop/dash/DashGridDragListenerTest.kt +++ b/app/src/test/java/be/robinj/distrohopper/desktop/dash/DashGridDragListenerTest.kt @@ -3,9 +3,7 @@ package be.robinj.distrohopper.desktop.dash import android.content.Context import android.view.DragEvent import android.view.View -import android.view.ViewGroup import android.widget.AdapterView -import android.widget.BaseAdapter import android.widget.GridView import androidx.test.core.app.ActivityScenario import be.robinj.distrohopper.ActivityTestSupport @@ -23,16 +21,19 @@ import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.LooperMode -import org.robolectric.shadows.ShadowLooper /** - * The dash grid's drag listener: the folder-create-on-dwell, drop-into-folder, - * custom-order reposition and folder-member-extraction state machine. The folder - * model itself is covered by [be.robinj.distrohopper.DashLayoutRepositoryTest]; - * this verifies the listener glue drives it correctly from DragEvents. + * The dash grid's drag listener: the spatial reorder-preview / fold-on-centre + * state machine for loose apps and folders, plus the folder-member-extraction + * gesture. The folder + custom-order model itself is covered by + * [be.robinj.distrohopper.DashLayoutRepositoryTest]; this verifies the listener + * glue drives it correctly from DragEvents. * - * The grid is a [FixedGrid] whose `pointToPosition` is pinned to a known cell so - * the coordinate-driven branches are deterministic without Robolectric layout. + * The grid is a [GeoGrid] with a real [GridAdapter] and a deterministic cell + * geometry (fixed column width / row height), so a drag can be aimed at a cell's + * centre (a fold) or its edge (a reorder gap) without relying on Robolectric to + * lay a GridView out. A drag is delivered as the real event sequence — STARTED + * (which arms the preview), LOCATION (which resolves fold vs reorder), then DROP. */ @RunWith(RobolectricTestRunner::class) @LooperMode(LooperMode.Mode.LEGACY) @@ -42,23 +43,65 @@ class DashGridDragListenerTest { @Before fun setUp() { scenario = ActivityTestSupport.launchHome() } @After fun tearDown() { scenario.close() } - /** A grid whose adapter serves a known item list and whose hit-test is fixed. */ - private class FixedGrid( - context: Context, - private val items: List, - private val pos: Int, - ) : GridView(context) { - init { - adapter = object : BaseAdapter() { - override fun getCount() = items.size - override fun getItem(position: Int): Any = items[position] - override fun getItemId(position: Int) = position.toLong() - override fun getView(position: Int, convertView: View?, parent: ViewGroup?): View = - convertView ?: View(context) + private companion object { + const val COLS = 3 + const val CELL = 100 + } + + /** + * A grid with a real [GridAdapter] and a fixed COLS-wide layout of CELL-sized + * cells, so `pointToPosition` and each child's bounds are known: a point can + * be aimed at a cell's centre or edge to pick the fold / reorder branch. The + * children are stubbed on demand (positioned from the adapter's current order) + * since Robolectric doesn't lay a GridView out. + */ + private class GeoGrid(context: Context, items: List) : GridView(context) { + val gridAdapter = GridAdapter(context, ArrayList(items)) + + init { adapter = this.gridAdapter } + + override fun getChildCount(): Int = this.gridAdapter.count + override fun getFirstVisiblePosition(): Int = 0 + + override fun getChildAt(index: Int): View? { + if (index < 0 || index >= this.gridAdapter.count) return null + val col = index % COLS + val row = index / COLS + return View(context).apply { + layout(col * CELL, row * CELL, col * CELL + CELL, row * CELL + CELL) + tag = this@GeoGrid.gridAdapter.getItem(index) } } - override fun pointToPosition(x: Int, y: Int): Int = this.pos + override fun pointToPosition(x: Int, y: Int): Int { + if (x < 0 || y < 0) return AdapterView.INVALID_POSITION + val col = x / CELL + val row = y / CELL + if (col >= COLS) return AdapterView.INVALID_POSITION + val pos = row * COLS + col + return if (pos < this.gridAdapter.count) pos else AdapterView.INVALID_POSITION + } + + /** Screen point at the centre of the cell currently showing [position] (a fold). */ + fun centreOf(position: Int): Pair { + val col = position % COLS + val row = position / COLS + return (col * CELL + CELL / 2f) to (row * CELL + CELL / 2f) + } + + /** Screen point near the left edge of [position]'s cell (a reorder gap before it). */ + fun leftEdgeOf(position: Int): Pair { + val col = position % COLS + val row = position / COLS + return (col * CELL + 5f) to (row * CELL + CELL / 2f) + } + + /** Screen point near the right edge of [position]'s cell (a reorder gap after it). */ + fun rightEdgeOf(position: Int): Pair { + val col = position % COLS + val row = position / COLS + return (col * CELL + CELL - 5f) to (row * CELL + CELL / 2f) + } } private fun appItems(layout: DashLayoutRepository): List = @@ -78,14 +121,18 @@ class DashGridDragListenerTest { private fun setCustomOrder(activity: HomeActivity) = Preferences.getSharedPreferences(activity).edit().putString("app_sort_order", "custom").commit() - private fun drainDelayed() = ShadowLooper.runUiThreadTasksIncludingDelayedTasks() + private fun indexOf(layout: DashLayoutRepository, label: String): Int = + layout.dashItems(null).indexOfFirst { it is DashItem.AppItem && it.app.label == label } + + private fun folderIndex(layout: DashLayoutRepository): Int = + layout.dashItems(null).indexOfFirst { it is DashItem.FolderItem } // --- ACTION_DRAG_STARTED: which drags this listener claims --- @Test fun dragStartedClaimsLooseAppAndFolderPayloadDrags() { scenario.onActivity { activity -> val listener = DashGridDragListener(activity, activity.appManager, null) - val grid = FixedGrid(activity, emptyList(), AdapterView.INVALID_POSITION) + val grid = GeoGrid(activity, activity.appManager.dashLayout.dashItems(null)) val anApp = this.app(activity.appManager.dashLayout, "Alpha") assertTrue("a loose app drag is claimed", @@ -100,7 +147,7 @@ class DashGridDragListenerTest { @Test fun dragStartedIgnoresForeignDrags() { scenario.onActivity { activity -> val listener = DashGridDragListener(activity, activity.appManager, null) - val grid = FixedGrid(activity, emptyList(), AdapterView.INVALID_POSITION) + val grid = GeoGrid(activity, activity.appManager.dashLayout.dashItems(null)) assertFalse("no local state (e.g. a widget drag) is left to other listeners", listener.onDrag(grid, DragEvents.obtain(DragEvent.ACTION_DRAG_STARTED, localState = null))) @@ -109,21 +156,20 @@ class DashGridDragListenerTest { } } - // --- Folder create / add --- + // --- Folder create / add (drop over a cell's centre) --- - @Test fun dwellingOverAnotherAppAndDroppingCreatesAFolder() { + @Test fun droppingOverAnotherAppsCentreCreatesAFolder() { scenario.onActivity { activity -> val appManager = activity.appManager val layout = appManager.dashLayout - val items = layout.dashItems(null) val alpha = this.app(layout, "Alpha") - val betaIndex = items.indexOfFirst { it is DashItem.AppItem && it.app.label == "Beta" } - val grid = FixedGrid(activity, items, betaIndex) + val grid = GeoGrid(activity, layout.dashItems(null)) + val (x, y) = grid.centreOf(this.indexOf(layout, "Beta")) val listener = DashGridDragListener(activity, appManager, null) - listener.onDrag(grid, DragEvents.obtain(DragEvent.ACTION_DRAG_LOCATION, 10f, 10f, alpha)) - this.drainDelayed() // fire the dwell timer that arms the fold - listener.onDrag(grid, DragEvents.obtain(DragEvent.ACTION_DROP, 10f, 10f, alpha)) + listener.onDrag(grid, DragEvents.obtain(DragEvent.ACTION_DRAG_STARTED, localState = alpha)) + listener.onDrag(grid, DragEvents.obtain(DragEvent.ACTION_DRAG_LOCATION, x, y, alpha)) + listener.onDrag(grid, DragEvents.obtain(DragEvent.ACTION_DROP, x, y, alpha)) val folders = layout.dashItems(null).filterIsInstance() assertEquals(1, folders.size) @@ -131,54 +177,78 @@ class DashGridDragListenerTest { } } - @Test fun droppingOnAFolderAfterDwellAddsTheAppToIt() { + @Test fun droppingOverAFoldersCentreAddsTheAppToIt() { scenario.onActivity { activity -> val appManager = activity.appManager val layout = appManager.dashLayout layout.createFolder(this.app(layout, "Alpha"), this.app(layout, "Beta")) val gamma = this.app(layout, "Gamma") - val items = layout.dashItems(null) - val folderIndex = items.indexOfFirst { it is DashItem.FolderItem } - val grid = FixedGrid(activity, items, folderIndex) + val grid = GeoGrid(activity, layout.dashItems(null)) + val (x, y) = grid.centreOf(this.folderIndex(layout)) val listener = DashGridDragListener(activity, appManager, null) - listener.onDrag(grid, DragEvents.obtain(DragEvent.ACTION_DRAG_LOCATION, 10f, 10f, gamma)) - this.drainDelayed() - listener.onDrag(grid, DragEvents.obtain(DragEvent.ACTION_DROP, 10f, 10f, gamma)) + listener.onDrag(grid, DragEvents.obtain(DragEvent.ACTION_DRAG_STARTED, localState = gamma)) + listener.onDrag(grid, DragEvents.obtain(DragEvent.ACTION_DRAG_LOCATION, x, y, gamma)) + listener.onDrag(grid, DragEvents.obtain(DragEvent.ACTION_DROP, x, y, gamma)) val folder = layout.dashItems(null).filterIsInstance().single() assertTrue(folder.apps.map { it.label }.containsAll(listOf("Alpha", "Beta", "Gamma"))) } } + @Test fun draggingOverAnAppsEdgeDoesNotFoldButReorders() { + scenario.onActivity { activity -> + val appManager = activity.appManager + val layout = appManager.dashLayout + this.setCustomOrder(activity) + val settings = this.app(layout, "Settings") + val grid = GeoGrid(activity, layout.dashItems(null)) + val (x, y) = grid.leftEdgeOf(0) // the front cell's edge is a reorder gap, not a fold + val listener = DashGridDragListener(activity, appManager, null) + + listener.onDrag(grid, DragEvents.obtain(DragEvent.ACTION_DRAG_STARTED, localState = settings)) + listener.onDrag(grid, DragEvents.obtain(DragEvent.ACTION_DRAG_LOCATION, x, y, settings)) + listener.onDrag(grid, DragEvents.obtain(DragEvent.ACTION_DROP, x, y, settings)) + + assertTrue("no folder is created by an edge drop", + layout.dashItems(null).none { it is DashItem.FolderItem }) + assertEquals("Settings", (layout.dashItems(null).first() as DashItem.AppItem).app.label) + } + } + // --- Reorder (custom order only) --- - @Test fun droppingALooseAppReordersItInCustomOrder() { + @Test fun draggingToACellsEdgeReordersInCustomOrder() { scenario.onActivity { activity -> val appManager = activity.appManager val layout = appManager.dashLayout this.setCustomOrder(activity) val settings = this.app(layout, "Settings") - val grid = FixedGrid(activity, layout.dashItems(null), 0) // drop at the front + val grid = GeoGrid(activity, layout.dashItems(null)) + val (x, y) = grid.leftEdgeOf(0) // open a gap before the first cell val listener = DashGridDragListener(activity, appManager, null) - // No dwell, so this is a reorder rather than a fold. - listener.onDrag(grid, DragEvents.obtain(DragEvent.ACTION_DROP, 10f, 10f, settings)) + listener.onDrag(grid, DragEvents.obtain(DragEvent.ACTION_DRAG_STARTED, localState = settings)) + listener.onDrag(grid, DragEvents.obtain(DragEvent.ACTION_DRAG_LOCATION, x, y, settings)) + listener.onDrag(grid, DragEvents.obtain(DragEvent.ACTION_DROP, x, y, settings)) assertEquals("Settings", (layout.dashItems(null).first() as DashItem.AppItem).app.label) } } - @Test fun droppingALooseAppDoesNotReorderWhenNotInCustomOrder() { + @Test fun draggingToACellsEdgeDoesNotReorderWhenNotInCustomOrder() { scenario.onActivity { activity -> val appManager = activity.appManager val layout = appManager.dashLayout // default alphabetical order val before = this.labels(layout) val zeta = this.app(layout, "Zeta") - val grid = FixedGrid(activity, layout.dashItems(null), 0) + val grid = GeoGrid(activity, layout.dashItems(null)) + val (x, y) = grid.leftEdgeOf(0) val listener = DashGridDragListener(activity, appManager, null) - listener.onDrag(grid, DragEvents.obtain(DragEvent.ACTION_DROP, 10f, 10f, zeta)) + listener.onDrag(grid, DragEvents.obtain(DragEvent.ACTION_DRAG_STARTED, localState = zeta)) + listener.onDrag(grid, DragEvents.obtain(DragEvent.ACTION_DRAG_LOCATION, x, y, zeta)) + listener.onDrag(grid, DragEvents.obtain(DragEvent.ACTION_DROP, x, y, zeta)) assertEquals(before, this.labels(layout)) } @@ -193,10 +263,10 @@ class DashGridDragListenerTest { val alpha = this.app(layout, "Alpha") val folderId = layout.createFolder(alpha, this.app(layout, "Beta"))!! // Drop onto empty space (no cell under the pointer): a plain extraction. - val grid = FixedGrid(activity, layout.dashItems(null), AdapterView.INVALID_POSITION) + val grid = GeoGrid(activity, layout.dashItems(null)) val listener = DashGridDragListener(activity, appManager, null) - listener.onDrag(grid, DragEvents.obtain(DragEvent.ACTION_DROP, + listener.onDrag(grid, DragEvents.obtain(DragEvent.ACTION_DROP, -1f, -1f, localState = DashDragPayload.FolderMemberDrag(folderId, alpha))) // Removing a member down to one dissolves the folder: both apps loose. @@ -213,15 +283,19 @@ class DashGridDragListenerTest { val layout = appManager.dashLayout this.setCustomOrder(activity) val folderId = layout.createFolder(this.app(layout, "Alpha"), this.app(layout, "Beta"))!! - val items = layout.dashItems(null) - val targetPos = items.size - 1 // send the folder to the end - val grid = FixedGrid(activity, items, targetPos) + val targetPos = layout.dashItems(null).size - 1 // send the folder to the end + val grid = GeoGrid(activity, layout.dashItems(null)) + val (x, y) = grid.rightEdgeOf(targetPos) // a gap after the last cell val listener = DashGridDragListener(activity, appManager, null) - listener.onDrag(grid, DragEvents.obtain(DragEvent.ACTION_DROP, + listener.onDrag(grid, DragEvents.obtain(DragEvent.ACTION_DRAG_STARTED, + localState = DashDragPayload.FolderDrag(folderId))) + listener.onDrag(grid, DragEvents.obtain(DragEvent.ACTION_DRAG_LOCATION, x, y, + localState = DashDragPayload.FolderDrag(folderId))) + listener.onDrag(grid, DragEvents.obtain(DragEvent.ACTION_DROP, x, y, localState = DashDragPayload.FolderDrag(folderId))) - assertEquals(targetPos, layout.dashItems(null).indexOfFirst { it is DashItem.FolderItem }) + assertEquals(targetPos, this.folderIndex(layout)) } } @@ -230,14 +304,20 @@ class DashGridDragListenerTest { val appManager = activity.appManager val layout = appManager.dashLayout // alphabetical: folder sorts first val folderId = layout.createFolder(this.app(layout, "Alpha"), this.app(layout, "Beta"))!! - val before = layout.dashItems(null).indexOfFirst { it is DashItem.FolderItem } - val grid = FixedGrid(activity, layout.dashItems(null), layout.dashItems(null).size - 1) + val before = this.folderIndex(layout) + val targetPos = layout.dashItems(null).size - 1 + val grid = GeoGrid(activity, layout.dashItems(null)) + val (x, y) = grid.rightEdgeOf(targetPos) val listener = DashGridDragListener(activity, appManager, null) - listener.onDrag(grid, DragEvents.obtain(DragEvent.ACTION_DROP, + listener.onDrag(grid, DragEvents.obtain(DragEvent.ACTION_DRAG_STARTED, + localState = DashDragPayload.FolderDrag(folderId))) + listener.onDrag(grid, DragEvents.obtain(DragEvent.ACTION_DRAG_LOCATION, x, y, + localState = DashDragPayload.FolderDrag(folderId))) + listener.onDrag(grid, DragEvents.obtain(DragEvent.ACTION_DROP, x, y, localState = DashDragPayload.FolderDrag(folderId))) - assertEquals(before, layout.dashItems(null).indexOfFirst { it is DashItem.FolderItem }) + assertEquals(before, this.folderIndex(layout)) } } From 9408fc2d2014f3713f02b76ea9849a046152a8e1 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 09:30:51 +0000 Subject: [PATCH 2/3] Fix test grid NPE: read adapter null-safely during super-construction The GeoGrid test harness dereferenced its own `gridAdapter` field from getChildCount()/getChildAt()/pointToPosition() overrides, but the GridView super-constructor invokes those before the subclass field is initialised, so every GeoGrid-based test threw an NPE at construction. Route the overrides through the null-safe getAdapter() instead. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01D594DCEMgKkkceo5McSkKU --- .../desktop/dash/DashGridDragListenerTest.kt | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/app/src/test/java/be/robinj/distrohopper/desktop/dash/DashGridDragListenerTest.kt b/app/src/test/java/be/robinj/distrohopper/desktop/dash/DashGridDragListenerTest.kt index 49716bff..3f878c62 100644 --- a/app/src/test/java/be/robinj/distrohopper/desktop/dash/DashGridDragListenerTest.kt +++ b/app/src/test/java/be/robinj/distrohopper/desktop/dash/DashGridDragListenerTest.kt @@ -56,30 +56,34 @@ class DashGridDragListenerTest { * since Robolectric doesn't lay a GridView out. */ private class GeoGrid(context: Context, items: List) : GridView(context) { - val gridAdapter = GridAdapter(context, ArrayList(items)) + init { adapter = GridAdapter(context, ArrayList(items)) } - init { adapter = this.gridAdapter } + // Read through getAdapter() (null-safe): the GridView super-constructor + // calls the overrides below before any field of this subclass is set. + private fun items(): GridAdapter? = this.adapter as? GridAdapter - override fun getChildCount(): Int = this.gridAdapter.count + override fun getChildCount(): Int = this.items()?.count ?: 0 override fun getFirstVisiblePosition(): Int = 0 override fun getChildAt(index: Int): View? { - if (index < 0 || index >= this.gridAdapter.count) return null + val adapter = this.items() ?: return null + if (index < 0 || index >= adapter.count) return null val col = index % COLS val row = index / COLS return View(context).apply { layout(col * CELL, row * CELL, col * CELL + CELL, row * CELL + CELL) - tag = this@GeoGrid.gridAdapter.getItem(index) + tag = adapter.getItem(index) } } override fun pointToPosition(x: Int, y: Int): Int { + val adapter = this.items() ?: return AdapterView.INVALID_POSITION if (x < 0 || y < 0) return AdapterView.INVALID_POSITION val col = x / CELL val row = y / CELL if (col >= COLS) return AdapterView.INVALID_POSITION val pos = row * COLS + col - return if (pos < this.gridAdapter.count) pos else AdapterView.INVALID_POSITION + return if (pos < adapter.count) pos else AdapterView.INVALID_POSITION } /** Screen point at the centre of the cell currently showing [position] (a fold). */ From f9dc16171d91f1b8ef56ee428dd11b760113937f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 09:35:17 +0000 Subject: [PATCH 3/3] Only append to the end when the drag is below the last dash cell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A dash reorder treated every INVALID_POSITION under the pointer as the empty area after the last icon and moved the item to the end. But the dash grid reserves the overlaid profile title as top padding, so dragging or dropping in that padding above the first row also has no cell under the pointer — which wrongly appended the item and corrupted the manual order. Restrict the append to points genuinely below the last laid-out cell. Reported by Codex review on the PR. Adds tests for both the append and the padding-guard cases. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01D594DCEMgKkkceo5McSkKU --- .../desktop/dash/DashGridDragListener.kt | 14 +++++- .../desktop/dash/DashGridDragListenerTest.kt | 49 +++++++++++++++++++ 2 files changed, 61 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/be/robinj/distrohopper/desktop/dash/DashGridDragListener.kt b/app/src/main/java/be/robinj/distrohopper/desktop/dash/DashGridDragListener.kt index cfca917c..1c9e6b64 100644 --- a/app/src/main/java/be/robinj/distrohopper/desktop/dash/DashGridDragListener.kt +++ b/app/src/main/java/be/robinj/distrohopper/desktop/dash/DashGridDragListener.kt @@ -162,9 +162,13 @@ class DashGridDragListener( val position = grid.pointToPosition(x, event.y.toInt()) if (position == AdapterView.INVALID_POSITION) { - // Past the last icon / empty area: preview an append (custom order only). + // No cell under the pointer. Only the empty area *below* the last + // laid-out cell is a genuine append; other invalid spots — the profile + // title padding reserved above the first row, or the gaps beside cells — + // must NOT move the gap, or a hover/drop there would jump the item to + // the end and corrupt the manual order. this.setFold(grid, null) - if (custom) { + if (custom && this.isBelowLastCell(grid, event.y.toInt())) { this.previewInsertAt(grid, this.baseItems.size) } return @@ -393,6 +397,12 @@ class DashGridDragListener( return grid.adapter?.getItem(position) as? DashItem } + /** Whether [y] (grid-relative) is past the bottom of the last laid-out cell. */ + private fun isBelowLastCell(grid: GridView, y: Int): Boolean { + val last = grid.getChildAt(grid.childCount - 1) ?: return false + return y > last.bottom + } + /** The drag's horizontal position within the cell at [position] (0..1); 0.5 if unknown. */ private fun horizontalFraction(grid: GridView, position: Int, x: Int): Float { val child = grid.getChildAt(position - grid.firstVisiblePosition) ?: return 0.5f diff --git a/app/src/test/java/be/robinj/distrohopper/desktop/dash/DashGridDragListenerTest.kt b/app/src/test/java/be/robinj/distrohopper/desktop/dash/DashGridDragListenerTest.kt index 3f878c62..a9dc3156 100644 --- a/app/src/test/java/be/robinj/distrohopper/desktop/dash/DashGridDragListenerTest.kt +++ b/app/src/test/java/be/robinj/distrohopper/desktop/dash/DashGridDragListenerTest.kt @@ -106,6 +106,16 @@ class DashGridDragListenerTest { val row = position / COLS return (col * CELL + CELL - 5f) to (row * CELL + CELL / 2f) } + + /** Screen point in the empty area below the last laid-out cell (a genuine append). */ + fun belowLastCell(): Pair { + val count = this.items()?.count ?: 0 + val lastRow = if (count == 0) 0 else (count - 1) / COLS + return 5f to ((lastRow + 1) * CELL + 5f) + } + + /** An invalid point that is NOT below the content (like the reserved title padding). */ + fun invalidNearTop(): Pair = (COLS * CELL + 5f) to 5f } private fun appItems(layout: DashLayoutRepository): List = @@ -258,6 +268,45 @@ class DashGridDragListenerTest { } } + @Test fun draggingBelowTheLastCellAppendsToTheEndInCustomOrder() { + scenario.onActivity { activity -> + val appManager = activity.appManager + val layout = appManager.dashLayout + this.setCustomOrder(activity) + val alpha = this.app(layout, "Alpha") // alphabetically first + val grid = GeoGrid(activity, layout.dashItems(null)) + val (x, y) = grid.belowLastCell() + val listener = DashGridDragListener(activity, appManager, null) + + listener.onDrag(grid, DragEvents.obtain(DragEvent.ACTION_DRAG_STARTED, localState = alpha)) + listener.onDrag(grid, DragEvents.obtain(DragEvent.ACTION_DRAG_LOCATION, x, y, alpha)) + listener.onDrag(grid, DragEvents.obtain(DragEvent.ACTION_DROP, x, y, alpha)) + + assertEquals("Alpha", (layout.dashItems(null).last() as DashItem.AppItem).app.label) + } + } + + @Test fun draggingIntoInvalidPaddingAboveTheGridDoesNotAppendToTheEnd() { + scenario.onActivity { activity -> + val appManager = activity.appManager + val layout = appManager.dashLayout + this.setCustomOrder(activity) + val before = this.labels(layout) + val alpha = this.app(layout, "Alpha") + val grid = GeoGrid(activity, layout.dashItems(null)) + // An invalid point that isn't below the last cell (the profile-title + // padding above the first row) must not move the item to the end. + val (x, y) = grid.invalidNearTop() + val listener = DashGridDragListener(activity, appManager, null) + + listener.onDrag(grid, DragEvents.obtain(DragEvent.ACTION_DRAG_STARTED, localState = alpha)) + listener.onDrag(grid, DragEvents.obtain(DragEvent.ACTION_DRAG_LOCATION, x, y, alpha)) + listener.onDrag(grid, DragEvents.obtain(DragEvent.ACTION_DROP, x, y, alpha)) + + assertEquals(before, this.labels(layout)) + } + } + // --- Folder members --- @Test fun droppingAFolderMemberOnTheDashExtractsIt() {