diff --git a/AGENTS.md b/AGENTS.md index 27ae2d64..6ddfe9ac 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`, 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. + - **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`. Starting any dash drag lowers the soft keyboard (`AppLauncherLongClickListener.hideKeyboard`), since the dash search field may have raised it — most relevant when dragging a search result, where the keyboard would otherwise cover the drop targets. When a drag lingers near the grid's top or bottom edge, `desktop/dash/DashEdgeScroller` auto-scrolls it (the platform list drag-scroll pattern: `ACTION_DRAG_LOCATION` only fires while the pointer moves, so a self-reposting Handler tick does the scrolling, accelerating the deeper into the edge zone the finger sits) so an app can be dropped onto a row that isn't currently on screen; the scroll stops on drop / exit / end. `DashGridDragListener` lazily owns one scroller per page grid and drives it from `onDrag`. Because the finger is still while auto-scroll runs, each scroll step also re-resolves the drag at the last pointer position (the row under a stationary finger changes as content scrolls), so a release without further movement acts on the cell now under the finger, not the pre-scroll one — the loose/folder path re-resolves its cached reorder/fold preview, and the member path re-resolves its armed hover/highlight (a member drop recomputes the target cell, but the fold only fires while `armedPosition` still matches, so the arm has to follow the scroll too). `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/AppLauncherLongClickListener.java b/app/src/main/java/be/robinj/distrohopper/desktop/dash/AppLauncherLongClickListener.java index 383fd3bd..c02b84f5 100644 --- a/app/src/main/java/be/robinj/distrohopper/desktop/dash/AppLauncherLongClickListener.java +++ b/app/src/main/java/be/robinj/distrohopper/desktop/dash/AppLauncherLongClickListener.java @@ -1,7 +1,9 @@ package be.robinj.distrohopper.desktop.dash; import android.content.ClipData; +import android.content.Context; import android.view.View; +import android.view.inputmethod.InputMethodManager; import android.widget.AdapterView; import be.robinj.distrohopper.App; @@ -55,6 +57,10 @@ else if (tag instanceof DashItem.FolderItem) */ private void startFolderDrag (View view, String folderId) { + // The dash search field may hold the keyboard open; a drag means the user + // is done typing, so get it out of the way of the drop targets. // + hideKeyboard (this.parent); + View source = view.isAttachedToWindow () ? view : this.parent.getWindow ().getDecorView (); @@ -74,6 +80,11 @@ public static void startAppDrag (View view, App app) { AppManager appManager = app.getAppManager (); + // The dash search field may hold the keyboard open (e.g. dragging a + // search result); a drag means the user is done typing, so lower it so + // it doesn't cover the launcher bar or the lower dash rows. // + hideKeyboard (appManager.getContext ()); + // The pressed view may have been detached by the time the long press // fires — lens results are re-rendered as the slower lenses stream // their results in. A drag can only start from an attached view, so @@ -114,4 +125,14 @@ public static void startAppDrag (View view, App app) appManager.startedDraggingDashApp (app); } } + + /** Lowers the soft keyboard (raised by the dash search field), if it is up. */ + private static void hideKeyboard (final HomeActivity activity) + { + final InputMethodManager imm = + (InputMethodManager) activity.getSystemService (Context.INPUT_METHOD_SERVICE); + if (imm != null) + imm.hideSoftInputFromWindow ( + activity.getWindow ().getDecorView ().getWindowToken (), 0); + } } diff --git a/app/src/main/java/be/robinj/distrohopper/desktop/dash/DashEdgeScroller.kt b/app/src/main/java/be/robinj/distrohopper/desktop/dash/DashEdgeScroller.kt new file mode 100644 index 00000000..d53fb85d --- /dev/null +++ b/app/src/main/java/be/robinj/distrohopper/desktop/dash/DashEdgeScroller.kt @@ -0,0 +1,102 @@ +package be.robinj.distrohopper.desktop.dash + +import android.os.Handler +import android.os.Looper +import android.widget.GridView + +/** + * Auto-scrolls the dash grid while a drag hovers near its top or bottom edge, so + * an app can be dragged onto a row that isn't currently on screen. Mirrors the + * platform's built-in list drag-scroll: the finger only has to rest in an edge + * zone; a self-repeating tick keeps scrolling (accelerating the deeper into the + * zone the pointer sits) until the pointer leaves the zone, the grid can't + * scroll any further, or the drag ends. + * + * Android only delivers [android.view.DragEvent.ACTION_DRAG_LOCATION] while the + * pointer *moves*, so edge-scroll can't be driven by drag events alone — a + * stationary finger in the edge zone gets no more events. Hence the ticking + * runnable: [onDrag] just sets the current velocity from the pointer position; + * the tick does the scrolling frame by frame until [stop]. + * + * That same "no event while still" property means the drop target would go + * stale: the cell under a stationary finger changes as rows scroll past it. So + * after each scroll step the tick calls [onScrolled], letting the owner + * re-resolve the target against the newly-revealed rows — otherwise a release + * without further movement would land on the pre-scroll target. + */ +class DashEdgeScroller( + private val grid: GridView, + private val onScrolled: () -> Unit, +) { + private val handler = Handler(Looper.getMainLooper()) + + /** Signed pixels to scroll per frame: <0 up (toward the start), >0 down. */ + private var velocityPx = 0 + private var ticking = false + + private val tick = object : Runnable { + override fun run() { + val v = this@DashEdgeScroller.velocityPx + // Stop once out of the zone, or the grid can't scroll any further. // + if (v == 0 || !this@DashEdgeScroller.grid.canScrollList(if (v < 0) -1 else 1)) { + this@DashEdgeScroller.ticking = false + return + } + this@DashEdgeScroller.grid.scrollListBy(v) + this@DashEdgeScroller.onScrolled() + this@DashEdgeScroller.handler.postDelayed(this, FRAME_MS) + } + } + + /** Updates the auto-scroll from the drag's vertical position within the grid. */ + fun onDrag(y: Float) { + this.velocityPx = velocityFor(y, this.grid.height) + if (this.velocityPx != 0 && !this.ticking) { + this.ticking = true + this.handler.post(this.tick) + } + } + + /** Halts any auto-scroll (drag left the grid, dropped, or ended). */ + fun stop() { + this.velocityPx = 0 + this.ticking = false + this.handler.removeCallbacks(this.tick) + } + + companion object { + /** Fraction of the grid's height, at each end, that triggers auto-scroll. */ + private const val EDGE_FRACTION = 0.15f + + /** Roughly one frame at 60Hz, so scrolling looks continuous. */ + private const val FRAME_MS = 16L + + private const val MIN_STEP_PX = 6 + private const val MAX_STEP_PX = 30 + + /** + * The per-frame scroll velocity for a pointer at [y] in a grid of [height]: + * 0 through the middle, ramping from [MIN_STEP_PX] at a zone's inner edge to + * [MAX_STEP_PX] at the grid's edge, negative (scroll up) at the top and + * positive (scroll down) at the bottom. Pure geometry, exposed for testing. + */ + internal fun velocityFor(y: Float, height: Int): Int { + val zone = height * EDGE_FRACTION + if (zone <= 0f) { + return 0 + } + + return when { + y < zone -> -stepFor((zone - y) / zone) + y > height - zone -> stepFor((y - (height - zone)) / zone) + else -> 0 + } + } + + /** Maps a 0..1 depth into the edge zone to a scroll step in pixels. */ + private fun stepFor(depth: Float): Int { + val d = depth.coerceIn(0f, 1f) + return (MIN_STEP_PX + (MAX_STEP_PX - MIN_STEP_PX) * d).toInt() + } + } +} 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 1c9e6b64..9f64f1be 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 @@ -42,9 +42,17 @@ class DashGridDragListener( private val handler = Handler(Looper.getMainLooper()) private val reflowInterpolator = DecelerateInterpolator() + /** Auto-scrolls the grid when a drag lingers near its top/bottom edge. */ + private var edgeScroller: DashEdgeScroller? = null + /** The last drag position, so an auto-scroll can re-resolve the target there. */ + private var lastX = 0f + private var lastY = 0f + // --- Folder-member extraction (pause-to-fold, no reorder preview) --- private var hoverPosition = AdapterView.INVALID_POSITION private var armedPosition = AdapterView.INVALID_POSITION + /** The extracted member being dragged, so an auto-scroll can re-resolve its hover. */ + private var memberApp: App? = null // --- Loose app / folder reorder preview --- /** The dragged item's [stableKey], or null when this drag isn't previewed. */ @@ -68,33 +76,43 @@ class DashGridDragListener( val claim = this.draggedApp(event) != null || event.localState is DashDragPayload if (claim) { this.resetPreview() // never carry state over from a previous drag // + this.memberApp = (event.localState as? DashDragPayload.FolderMemberDrag)?.app this.setupPreview(grid, event) } return claim } - DragEvent.ACTION_DRAG_LOCATION -> + DragEvent.ACTION_DRAG_LOCATION -> { + this.lastX = event.x + this.lastY = event.y + this.edgeScrollerFor(grid).onDrag(event.y) if (this.isMemberDrag(event)) { - this.onMemberLocation(grid, event) + this.onMemberLocation(grid, event.x, event.y) } else { - this.resolveAndPreview(grid, event) + this.resolveAndPreview(grid, event.x, event.y) } + } - DragEvent.ACTION_DROP -> + DragEvent.ACTION_DROP -> { + this.edgeScroller?.stop() if (this.isMemberDrag(event)) { this.onMemberDrop(grid, event) } else { this.onPreviewDrop(event) } + } - DragEvent.ACTION_DRAG_EXITED -> + DragEvent.ACTION_DRAG_EXITED -> { + this.edgeScroller?.stop() if (this.isMemberDrag(event)) { this.clearHover() } else { this.setFold(grid, null) // keep the gap; only drop the fold ring // } + } DragEvent.ACTION_DRAG_ENDED -> { + this.edgeScroller?.stop() this.clearHover() val hadPreview = this.draggedKey != null val adapter = grid.adapter as? GridAdapter @@ -120,6 +138,22 @@ class DashGridDragListener( private fun isMemberDrag(event: DragEvent): Boolean = event.localState is DashDragPayload.FolderMemberDrag + /** The lazily-created edge scroller for this page's grid. */ + private fun edgeScrollerFor(grid: GridView): DashEdgeScroller = + this.edgeScroller ?: DashEdgeScroller(grid) { + // A row scrolled under a still finger: re-resolve at the last pointer + // position so a release without further movement acts on the cell now + // under the finger, not the pre-scroll one. Both paths need this — the + // loose/folder preview caches previewIndex/foldTargetKey, and the member + // gesture caches its armed hover/highlight (onMemberDrop recomputes the + // target cell, but the fold only fires when armedPosition still matches). + if (this.draggedKey != null) { + this.resolveAndPreview(grid, this.lastX, this.lastY) + } else if (this.memberApp != null) { + this.onMemberLocation(grid, this.lastX, this.lastY) + } + }.also { this.edgeScroller = it } + /** * 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 @@ -153,13 +187,14 @@ class DashGridDragListener( * 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) { + private fun resolveAndPreview(grid: GridView, x: Float, y: Float) { if (this.draggedKey == null) { return } val custom = FolderPopup.customOrderingEnabled(this.activity) - val x = event.x.toInt() - val position = grid.pointToPosition(x, event.y.toInt()) + val px = x.toInt() + val py = y.toInt() + val position = grid.pointToPosition(px, py) if (position == AdapterView.INVALID_POSITION) { // No cell under the pointer. Only the empty area *below* the last @@ -168,14 +203,14 @@ class DashGridDragListener( // 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())) { + if (custom && this.isBelowLastCell(grid, py)) { this.previewInsertAt(grid, this.baseItems.size) } return } val over = this.itemAt(grid, position) - val frac = this.horizontalFraction(grid, position, x) + val frac = this.horizontalFraction(grid, position, px) val draggedIsApp = this.draggedItem is DashItem.AppItem val foldable = draggedIsApp && over != null && over.stableKey != this.draggedKey && (over is DashItem.FolderItem || over is DashItem.AppItem) @@ -284,6 +319,7 @@ class DashGridDragListener( this.baseItems = emptyList() this.previewIndex = 0 this.foldTargetKey = null + this.memberApp = null } // --- Reflow animation ------------------------------------------------------- @@ -335,8 +371,8 @@ class DashGridDragListener( // --- 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()) + private fun onMemberLocation(grid: GridView, x: Float, y: Float) { + val position = grid.pointToPosition(x.toInt(), y.toInt()) if (position == this.hoverPosition) { return } @@ -346,7 +382,7 @@ class DashGridDragListener( this.clearHighlight() this.armedPosition = AdapterView.INVALID_POSITION - val dragged = this.draggedApp(event) ?: return + val dragged = this.memberApp ?: return val target = this.itemAt(grid, position) ?: return val canFold = when (target) { diff --git a/app/src/test/java/be/robinj/distrohopper/desktop/dash/DashEdgeScrollerTest.kt b/app/src/test/java/be/robinj/distrohopper/desktop/dash/DashEdgeScrollerTest.kt new file mode 100644 index 00000000..3ae6c17d --- /dev/null +++ b/app/src/test/java/be/robinj/distrohopper/desktop/dash/DashEdgeScrollerTest.kt @@ -0,0 +1,67 @@ +package be.robinj.distrohopper.desktop.dash + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The edge-scroll velocity geometry: where in the grid a drag triggers an + * auto-scroll, and how fast. The ticking that actually drives the scroll is thin + * glue over a Handler; the interesting, testable part is [DashEdgeScroller.velocityFor], + * a pure mapping from pointer position to a per-frame scroll step. + * + * With EDGE_FRACTION = 0.15, a 1000px grid has a 150px zone at each end: y < 150 + * scrolls up, y > 850 scrolls down, and everything between rests still. + */ +class DashEdgeScrollerTest { + private companion object { + const val H = 1000 + } + + @Test fun theMiddleOfTheGridDoesNotScroll() { + assertEquals(0, DashEdgeScroller.velocityFor(500f, H)) + assertEquals(0, DashEdgeScroller.velocityFor(H * 0.5f, H)) + } + + @Test fun theZoneBoundariesAreInclusiveOfTheStillArea() { + // y == zone (150) and y == height - zone (850) are the first still pixels. // + assertEquals(0, DashEdgeScroller.velocityFor(150f, H)) + assertEquals(0, DashEdgeScroller.velocityFor(850f, H)) + } + + @Test fun theTopZoneScrollsUp() { + assertTrue("near the top edge scrolls up (negative)", + DashEdgeScroller.velocityFor(10f, H) < 0) + } + + @Test fun theBottomZoneScrollsDown() { + assertTrue("near the bottom edge scrolls down (positive)", + DashEdgeScroller.velocityFor(H - 10f, H) > 0) + } + + @Test fun theVeryEdgesScrollAtTheMaximumStep() { + // At the exact edges the depth is 1, so the step is MAX_STEP_PX (30). // + assertEquals(-30, DashEdgeScroller.velocityFor(0f, H)) + assertEquals(30, DashEdgeScroller.velocityFor(H.toFloat(), H)) + } + + @Test fun scrollAcceleratesTowardsTheEdge() { + // Deeper into the top zone (smaller y) must scroll faster. // + val shallow = DashEdgeScroller.velocityFor(140f, H) + val deep = DashEdgeScroller.velocityFor(20f, H) + assertTrue("both scroll up", shallow < 0 && deep < 0) + assertTrue("deeper is faster", kotlin.math.abs(deep) > kotlin.math.abs(shallow)) + + // Symmetrically at the bottom. // + val shallowBottom = DashEdgeScroller.velocityFor(860f, H) + val deepBottom = DashEdgeScroller.velocityFor(980f, H) + assertTrue("both scroll down", shallowBottom > 0 && deepBottom > 0) + assertTrue("deeper is faster", deepBottom > shallowBottom) + } + + @Test fun aZeroHeightGridNeverScrolls() { + // Before layout the grid has no height; there is nothing to scroll. // + assertEquals(0, DashEdgeScroller.velocityFor(0f, 0)) + assertEquals(0, DashEdgeScroller.velocityFor(500f, 0)) + } +}