diff --git a/AGENTS.md b/AGENTS.md index ee54a23c..98efc14b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -109,7 +109,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..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 @@ -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,302 @@ 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) { + // 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 && this.isBelowLastCell(grid, event.y.toInt())) { + 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 +349,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 +361,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 +387,8 @@ class DashGridDragListener( else -> null } + // --- Shared helpers --------------------------------------------------------- + private fun itemAt(grid: GridView, position: Int): DashItem? { if (position == AdapterView.INVALID_POSITION) { return null @@ -163,12 +397,39 @@ 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 + 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 +444,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..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 @@ -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,79 @@ 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) { + init { adapter = GridAdapter(context, ArrayList(items)) } + + // 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.items()?.count ?: 0 + override fun getFirstVisiblePosition(): Int = 0 + + override fun getChildAt(index: Int): View? { + 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 = adapter.getItem(index) } } - override fun pointToPosition(x: Int, y: Int): Int = this.pos + 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 < adapter.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) + } + + /** 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 = @@ -78,14 +135,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 +161,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 +170,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 +191,117 @@ 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_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)) + } + } + + @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_DROP, 10f, 10f, zeta)) + 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)) } @@ -193,10 +316,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 +336,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 +357,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)) } }