From 6d1efca256bcc8dfdc46517916be76393bc857de Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 23:42:03 +0000 Subject: [PATCH 1/2] Show an app-info drop target instead of the trash for dash icon drags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dragging an app icon from the dash to the trash did nothing (the dash always shows every installed app, so there is nothing to delete) — it was effectively a cancel. The launcher bar now shows an "app info" button in the trash's place while an app icon (loose or launcher-pinned, including dash search results) is dragged from the dash; dropping the icon on it opens the system's App info screen for that app (via LauncherApps for apps in another profile). Dash folders and folder members keep the trash, where dropping still has a purpose (delete the folder / leave it). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01GP3qoGWaw2SXDWy4sQuy1m --- AGENTS.md | 2 +- .../main/java/be/robinj/distrohopper/App.java | 30 +++++++ .../be/robinj/distrohopper/HomeActivity.java | 5 ++ .../dash/AppLauncherLongClickListener.java | 9 ++ .../desktop/launcher/AppInfoDragListener.java | 78 +++++++++++++++++ .../distrohopper/home/HomeStateBinder.kt | 1 + .../distrohopper/home/LauncherBarBinder.kt | 26 +++++- .../home/WallpaperColourApplier.kt | 1 + .../main/res/drawable/launcher_appinfo.xml | 12 +++ app/src/main/res/layout/activity_home.xml | 9 ++ .../launcher/AppInfoDragListenerTest.kt | 85 +++++++++++++++++++ .../home/LauncherBarBinderTest.kt | 20 +++++ 12 files changed, 276 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/be/robinj/distrohopper/desktop/launcher/AppInfoDragListener.java create mode 100644 app/src/main/res/drawable/launcher_appinfo.xml create mode 100644 app/src/test/java/be/robinj/distrohopper/desktop/launcher/AppInfoDragListenerTest.kt diff --git a/AGENTS.md b/AGENTS.md index 98e34e6f..b30a255f 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` (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). Dragging an app *icon* from the dash (loose or launcher-pinned; also dash search results) shows the **app-info target** (`lalAppInfo`, `desktop/launcher/AppInfoDragListener`) in the trash's place — the dash always shows every installed app, so there is nothing to delete; dropping the icon there opens the system's App info screen (`App.openAppInfo()`, via LauncherApps for other-profile apps). The dash long-click listener marks the drag via `LauncherBarBinder.dragStartedFromDashApp()`, sticky until `stoppedDragging`; dash folders/folder members keep the trash (delete / leave folder). 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/App.java b/app/src/main/java/be/robinj/distrohopper/App.java index f55de9b7..21795245 100644 --- a/app/src/main/java/be/robinj/distrohopper/App.java +++ b/app/src/main/java/be/robinj/distrohopper/App.java @@ -218,6 +218,36 @@ else if (this.user != null) { } } + /** + * Opens the system's App info screen for this app. Apps in another profile + * need LauncherApps (a regular Settings intent would show the personal + * profile's copy); personal-profile apps use the plain details intent so + * this also works for packages without a launcher activity entry. + */ + public void openAppInfo () + { + try { + if (this.user != null) { + final LauncherApps launcherApps = + (LauncherApps) this.context.getSystemService (Context.LAUNCHER_APPS_SERVICE); + launcherApps.startAppDetailsActivity ( + new ComponentName (this.packageName, this.activityName), + this.user, null, null); + } else { + final Intent intent = new Intent ( + android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS, + android.net.Uri.fromParts ("package", this.packageName, null)); + intent.addFlags (Intent.FLAG_ACTIVITY_NEW_TASK); + this.context.startActivity (intent); + } + } catch (final Exception ex) { + final String errorMessage = format ("Failed to open app info for %s: %s.", + this.packageName, ex.getClass ().getSimpleName ()); + Log.getInstance ().e ("App", errorMessage); + Toast.makeText (this.context, errorMessage, Toast.LENGTH_SHORT).show (); + } + } + // Track app launches for the usage-based dash sort orders, recorded only // after the start call is accepted so a failed launch (e.g. a stale package // entry, a disabled activity, or a locked work profile) can't push a diff --git a/app/src/main/java/be/robinj/distrohopper/HomeActivity.java b/app/src/main/java/be/robinj/distrohopper/HomeActivity.java index ef049e45..427042a7 100644 --- a/app/src/main/java/be/robinj/distrohopper/HomeActivity.java +++ b/app/src/main/java/be/robinj/distrohopper/HomeActivity.java @@ -191,6 +191,7 @@ protected void onCreate (Bundle savedInstanceState) final be.robinj.distrohopper.desktop.launcher.AppLauncher lalBfb = this.viewFinder.get(llBfbSpinnerWrapper, R.id.lalBfb); final be.robinj.distrohopper.desktop.launcher.AppLauncher lalPreferences = this.viewFinder.get(llLauncher, R.id.lalPreferences); final be.robinj.distrohopper.desktop.launcher.AppLauncher lalTrash = this.viewFinder.get(llLauncher, R.id.lalTrash); + final be.robinj.distrohopper.desktop.launcher.AppLauncher lalAppInfo = this.viewFinder.get(llLauncher, R.id.lalAppInfo); this.llDash = this.viewFinder.get(llLauncherAndDashContainer, R.id.llDash); final Wallpaper wpWallpaper = this.viewFinder.get(R.id.wpWallpaper); final LinearLayout llPanel = this.viewFinder.get(R.id.llPanel); @@ -290,6 +291,7 @@ else if (HomeActivity.this.dash.isOpen ()) lalSpinner.init (); lalPreferences.init (); lalTrash.init (); + lalAppInfo.init (); // Process panel user preferences // Themes should probably handle this? // final Resources res = this.getResources (); @@ -360,6 +362,9 @@ public void endTransition(LayoutTransition t, ViewGroup container, // Attached here rather than once apps are loaded: widgets are draggable // (and droppable on the trash) as soon as they are restored below // lalTrash.setOnDragListener (new TrashDragListener (this)); + // Shown in the trash's place while an app icon is dragged from the dash: + // dropping it there opens the system's App info screen for it // + lalAppInfo.setOnDragListener (new be.robinj.distrohopper.desktop.launcher.AppInfoDragListener (this)); // Cross-surface drag: hovering the launcher or panel closes the dash to // reveal the desktop, while hovering either BFB (the launcher's, or the 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..318e2877 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 @@ -101,7 +101,13 @@ public static void startAppDrag (View view, App app) // without an active drag would leave the bar stuck, as no // ACTION_DRAG_ENDED will ever restore it // if (source.startDragAndDrop (data, new View.DragShadowBuilder (view), payload, 0)) + { + // A dash app icon: the bar shows the "app info" drop target in + // the trash's place (there is nothing to delete — the dash + // always shows every installed app) // + LauncherBarBinder.dragStartedFromDashApp (); appManager.startedDraggingPinnedApp (app); + } } else { @@ -111,7 +117,10 @@ public static void startAppDrag (View view, App app) ClipData data = ClipData.newPlainText ("dash", "dash"); if (source.startDragAndDrop (data, new View.DragShadowBuilder (view), app, 0)) + { + LauncherBarBinder.dragStartedFromDashApp (); appManager.startedDraggingDashApp (app); + } } } } diff --git a/app/src/main/java/be/robinj/distrohopper/desktop/launcher/AppInfoDragListener.java b/app/src/main/java/be/robinj/distrohopper/desktop/launcher/AppInfoDragListener.java new file mode 100644 index 00000000..32c1873a --- /dev/null +++ b/app/src/main/java/be/robinj/distrohopper/desktop/launcher/AppInfoDragListener.java @@ -0,0 +1,78 @@ +package be.robinj.distrohopper.desktop.launcher; + +import android.graphics.Color; +import android.view.DragEvent; +import android.view.View; +import android.view.ViewGroup; + +import be.robinj.distrohopper.App; +import be.robinj.distrohopper.ExceptionHandler; +import be.robinj.distrohopper.HomeActivity; +import be.robinj.distrohopper.home.LauncherBarBinder; + +/** + * The launcher bar's "app info" drop target, shown in the trash's place while + * an app icon is dragged from the dash (where the trash would have nothing to + * delete — the dash always shows every installed app). Dropping the icon here + * opens the system's App info screen for it. + */ +public class AppInfoDragListener implements ViewGroup.OnDragListener +{ + private final HomeActivity activity; + private int colour = -1; + + public AppInfoDragListener (final HomeActivity activity) + { + this.activity = activity; + } + + @Override + public boolean onDrag (View view, DragEvent event) + { + try + { + AppLauncher lalAppInfo = (AppLauncher) view; + if (this.colour == -1) + this.colour = lalAppInfo.getColour (); + + switch (event.getAction ()) + { + case DragEvent.ACTION_DRAG_ENTERED: + lalAppInfo.setColour (Color.rgb (40, 120, 255)); + break; + case DragEvent.ACTION_DROP: // Falls through // + final App app = draggedApp (event); + if (app != null) + app.openAppInfo (); + + LauncherBarBinder.stoppedDragging (this.activity); + case DragEvent.ACTION_DRAG_EXITED: + lalAppInfo.setColour (this.colour); + break; + } + } + catch (Exception ex) + { + ExceptionHandler exh = new ExceptionHandler (ex); + exh.show (this.activity); + } + + return true; + } + + /** + * The app behind a dash icon drag: a plain {@link App} (not pinned yet) or a + * {@link LauncherDragPayload.PinnedAppDrag} (already on the launcher bar). + * Anything else (folders, widgets, ...) never shows this target; ignore it. + */ + private static App draggedApp (final DragEvent event) + { + final Object localState = event.getLocalState (); + if (localState instanceof App) + return (App) localState; + else if (localState instanceof LauncherDragPayload.PinnedAppDrag) + return ((LauncherDragPayload.PinnedAppDrag) localState).getApp (); + + return null; + } +} diff --git a/app/src/main/java/be/robinj/distrohopper/home/HomeStateBinder.kt b/app/src/main/java/be/robinj/distrohopper/home/HomeStateBinder.kt index 0e72cabf..8a27ddcf 100644 --- a/app/src/main/java/be/robinj/distrohopper/home/HomeStateBinder.kt +++ b/app/src/main/java/be/robinj/distrohopper/home/HomeStateBinder.kt @@ -110,6 +110,7 @@ object HomeStateBinder { (llLauncherRunningApps.getChildAt(i) as AppLauncher).init() viewFinder.get(R.id.lalTrash).init() + viewFinder.get(R.id.lalAppInfo).init() viewFinder.get(R.id.lalPreferences).init() // The slot size changed, so the whole-slot scroll clip must re-measure. diff --git a/app/src/main/java/be/robinj/distrohopper/home/LauncherBarBinder.kt b/app/src/main/java/be/robinj/distrohopper/home/LauncherBarBinder.kt index 905295db..97feb426 100644 --- a/app/src/main/java/be/robinj/distrohopper/home/LauncherBarBinder.kt +++ b/app/src/main/java/be/robinj/distrohopper/home/LauncherBarBinder.kt @@ -752,6 +752,24 @@ class LauncherBarBinder(private val appManager: AppManager) { * with just the activity. */ + /** + * Whether the current drag shows the "app info" target in the trash's + * place. Set (by the dash's long-click listener) when an app icon is + * picked up from the dash — the dash always shows every installed app, + * so the trash would have nothing to delete there; opening the system's + * App info screen is the useful drop instead. Dash *folders* keep the + * trash (dropping one there deletes the folder). Sticky for the drag's + * lifetime — mid-drag [startedDragging] refreshers (e.g. hovering the + * launcher bar) re-apply it — and cleared by [stoppedDragging]. + */ + private var dragShowsAppInfo = false + + /** Marks the drag that is starting as a dash app-icon drag (see [dragShowsAppInfo]). */ + @JvmStatic + fun dragStartedFromDashApp() { + dragShowsAppInfo = true + } + @JvmStatic fun startedDragging(activity: HomeActivity) { val viewFinder = activity.viewFinder @@ -759,6 +777,7 @@ class LauncherBarBinder(private val appManager: AppManager) { val lalBfb = viewFinder.get(llLauncher, R.id.lalBfb) val lalPreferences = viewFinder.get(llLauncher, R.id.lalPreferences) val lalTrash = viewFinder.get(llLauncher, R.id.lalTrash) + val lalAppInfo = viewFinder.get(llLauncher, R.id.lalAppInfo) // The BFB stays visible during the drag: it is the "re-open the dash" // target for the cross-surface drag (hover it to bring the dash back), @@ -768,7 +787,8 @@ class LauncherBarBinder(private val appManager: AppManager) { // the dash to reveal the desktop — see Bfb/Launcher drag listeners. lalBfb.visibility = View.VISIBLE lalPreferences.visibility = View.GONE - lalTrash.visibility = View.VISIBLE + lalTrash.visibility = if (dragShowsAppInfo) View.GONE else View.VISIBLE + lalAppInfo.visibility = if (dragShowsAppInfo) View.VISIBLE else View.GONE viewFinder.get(llLauncher, R.id.llLauncherPinnedApps).alpha = 0.9F @@ -777,11 +797,14 @@ class LauncherBarBinder(private val appManager: AppManager) { @JvmStatic fun stoppedDragging(activity: HomeActivity) { + dragShowsAppInfo = false + val viewFinder = activity.viewFinder val llLauncher = viewFinder.get(R.id.llLauncher) val lalBfb = viewFinder.get(llLauncher, R.id.lalBfb) val lalPreferences = viewFinder.get(llLauncher, R.id.lalPreferences) val lalTrash = viewFinder.get(llLauncher, R.id.lalTrash) + val lalAppInfo = viewFinder.get(llLauncher, R.id.lalAppInfo) val theme = DependencyContainer.of(activity).themeManager.current val lalPreferences_location = theme.lalPreferences_getLocation( @@ -790,6 +813,7 @@ class LauncherBarBinder(private val appManager: AppManager) { lalPreferences.visibility = if (lalPreferences_location == Location.NONE) View.GONE else View.VISIBLE lalTrash.visibility = View.GONE + lalAppInfo.visibility = View.GONE viewFinder.get(llLauncher, R.id.llLauncherPinnedApps).alpha = 1.0F diff --git a/app/src/main/java/be/robinj/distrohopper/home/WallpaperColourApplier.kt b/app/src/main/java/be/robinj/distrohopper/home/WallpaperColourApplier.kt index 678b64e0..71f38240 100644 --- a/app/src/main/java/be/robinj/distrohopper/home/WallpaperColourApplier.kt +++ b/app/src/main/java/be/robinj/distrohopper/home/WallpaperColourApplier.kt @@ -44,6 +44,7 @@ class WallpaperColourApplier( this.viewFinder.get(R.id.lalPreferences).colour = colour this.viewFinder.get(R.id.lalSpinner).colour = colour this.viewFinder.get(R.id.lalTrash).colour = colour + this.viewFinder.get(R.id.lalAppInfo).colour = colour } val launcherBackgroundResources = res.obtainTypedArray(this.theme.launcher_background) diff --git a/app/src/main/res/drawable/launcher_appinfo.xml b/app/src/main/res/drawable/launcher_appinfo.xml new file mode 100644 index 00000000..8d9c94c6 --- /dev/null +++ b/app/src/main/res/drawable/launcher_appinfo.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/app/src/main/res/layout/activity_home.xml b/app/src/main/res/layout/activity_home.xml index 9b1a3b73..74f6ec03 100644 --- a/app/src/main/res/layout/activity_home.xml +++ b/app/src/main/res/layout/activity_home.xml @@ -219,6 +219,15 @@ custom:applauncher_special="false" custom:icon="@drawable/launcher_trash" /> + + diff --git a/app/src/test/java/be/robinj/distrohopper/desktop/launcher/AppInfoDragListenerTest.kt b/app/src/test/java/be/robinj/distrohopper/desktop/launcher/AppInfoDragListenerTest.kt new file mode 100644 index 00000000..17d467f9 --- /dev/null +++ b/app/src/test/java/be/robinj/distrohopper/desktop/launcher/AppInfoDragListenerTest.kt @@ -0,0 +1,85 @@ +package be.robinj.distrohopper.desktop.launcher + +import android.graphics.Color +import android.provider.Settings +import android.view.DragEvent +import android.view.View +import androidx.test.core.app.ActivityScenario +import be.robinj.distrohopper.ActivityTestSupport +import be.robinj.distrohopper.DragEvents +import be.robinj.distrohopper.HomeActivity +import be.robinj.distrohopper.R +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows +import org.robolectric.annotation.LooperMode + +@RunWith(RobolectricTestRunner::class) +@LooperMode(LooperMode.Mode.LEGACY) +class AppInfoDragListenerTest { + private lateinit var scenario: ActivityScenario + + @Before fun setUp() { scenario = ActivityTestSupport.launchHome() } + @After fun tearDown() { scenario.close() } + + @Test fun enteringTheAppInfoTargetHighlightsItAndExitingRestoresIt() { + scenario.onActivity { activity -> + val lalAppInfo = activity.findViewById(R.id.lalAppInfo) + val originalColour = lalAppInfo.colour + val listener = AppInfoDragListener(activity) + + listener.onDrag(lalAppInfo, DragEvents.obtain(DragEvent.ACTION_DRAG_ENTERED)) + assertEquals(Color.rgb(40, 120, 255), lalAppInfo.colour) + + listener.onDrag(lalAppInfo, DragEvents.obtain(DragEvent.ACTION_DRAG_EXITED)) + assertEquals(originalColour, lalAppInfo.colour) + } + } + + @Test fun droppingADashAppOpensItsAppInfoScreenAndLeavesDragMode() { + scenario.onActivity { activity -> + val app = activity.appManager.findAppsByPackageName("com.example.alpha").first() + val lalAppInfo = activity.findViewById(R.id.lalAppInfo) + val listener = AppInfoDragListener(activity) + + listener.onDrag(lalAppInfo, + DragEvents.obtain(DragEvent.ACTION_DROP, localState = app)) + + val started = Shadows.shadowOf(activity).nextStartedActivity + assertEquals(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, started.action) + assertEquals("package:com.example.alpha", started.dataString) + assertEquals(View.GONE, lalAppInfo.visibility) + } + } + + @Test fun droppingAPinnedDashAppOpensItsAppInfoScreen() { + scenario.onActivity { activity -> + val app = activity.appManager.findAppsByPackageName("com.example.beta").first() + val listener = AppInfoDragListener(activity) + + listener.onDrag(activity.findViewById(R.id.lalAppInfo), + DragEvents.obtain(DragEvent.ACTION_DROP, + localState = LauncherDragPayload.PinnedAppDrag(app))) + + val started = Shadows.shadowOf(activity).nextStartedActivity + assertEquals(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, started.action) + assertEquals("package:com.example.beta", started.dataString) + } + } + + @Test fun droppingSomethingElseOpensNothing() { + scenario.onActivity { activity -> + val listener = AppInfoDragListener(activity) + + listener.onDrag(activity.findViewById(R.id.lalAppInfo), + DragEvents.obtain(DragEvent.ACTION_DROP, localState = Any())) + + assertNull(Shadows.shadowOf(activity).nextStartedActivity) + } + } +} diff --git a/app/src/test/java/be/robinj/distrohopper/home/LauncherBarBinderTest.kt b/app/src/test/java/be/robinj/distrohopper/home/LauncherBarBinderTest.kt index fd3c3517..b4a90f93 100644 --- a/app/src/test/java/be/robinj/distrohopper/home/LauncherBarBinderTest.kt +++ b/app/src/test/java/be/robinj/distrohopper/home/LauncherBarBinderTest.kt @@ -143,6 +143,26 @@ class LauncherBarBinderTest { } } + @Test fun draggingADashAppIconShowsAppInfoInsteadOfTheTrash() { + scenario.onActivity { activity -> + val binder = LauncherBarBinder(activity.appManager) + + LauncherBarBinder.dragStartedFromDashApp() + binder.startedDraggingPinnedApp() + + assertEquals(View.GONE, activity.findViewById(R.id.lalTrash).visibility) + assertEquals(View.VISIBLE, activity.findViewById(R.id.lalAppInfo).visibility) + + // Stopping the drag clears the flag: the next drag shows the trash again. + binder.stoppedDraggingPinnedApp() + assertEquals(View.GONE, activity.findViewById(R.id.lalAppInfo).visibility) + binder.startedDraggingPinnedApp() + assertEquals(View.VISIBLE, activity.findViewById(R.id.lalTrash).visibility) + assertEquals(View.GONE, activity.findViewById(R.id.lalAppInfo).visibility) + binder.stoppedDraggingPinnedApp() + } + } + @Test fun stoppingADragRestoresTheLauncherBar() { scenario.onActivity { activity -> val binder = LauncherBarBinder(activity.appManager) From bfacea2bcdca28b0e7c08346d00593edb0ebea33 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 31 Jul 2026 22:09:04 +0000 Subject: [PATCH 2/2] Fix blank app-info target: route its icon through the theme system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The layout gave lalAppInfo a custom:icon attribute, but that resolves to appcompat's unread `icon` attr — AppLauncher's styleable only reads `applauncher_icon` — so the tile rendered without a glyph. The sibling trash/preferences icons never come from the layout either: ThemeApplier sets them from per-theme launcher_*_image resources. Do the same here: a launcher_appinfo_image Theme field, aliased in every theme's values file to the shared info glyph (themes can override it with a styled asset later), applied by ThemeApplier next to the trash icon. The dead attribute is dropped from the layout, and a regression test asserts the target actually has an icon after theming. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01GP3qoGWaw2SXDWy4sQuy1m --- AGENTS.md | 2 +- .../be/robinj/distrohopper/home/ThemeApplier.kt | 2 ++ .../java/be/robinj/distrohopper/theme/Budgie.java | 1 + .../be/robinj/distrohopper/theme/Cinnamon.java | 1 + .../java/be/robinj/distrohopper/theme/Cosmic.java | 1 + .../java/be/robinj/distrohopper/theme/Default.java | 1 + .../be/robinj/distrohopper/theme/Elementary.java | 1 + .../java/be/robinj/distrohopper/theme/Gnome.java | 1 + .../java/be/robinj/distrohopper/theme/Mate.java | 1 + .../java/be/robinj/distrohopper/theme/Plasma.java | 1 + .../java/be/robinj/distrohopper/theme/Theme.java | 1 + app/src/main/res/layout/activity_home.xml | 6 ++++-- app/src/main/res/values/theme_budgie.xml | 1 + app/src/main/res/values/theme_cinnamon.xml | 1 + app/src/main/res/values/theme_cosmic.xml | 1 + app/src/main/res/values/theme_default.xml | 1 + app/src/main/res/values/theme_elementary.xml | 1 + app/src/main/res/values/theme_gnome.xml | 1 + app/src/main/res/values/theme_mate.xml | 1 + app/src/main/res/values/theme_plasma.xml | 1 + .../desktop/launcher/AppInfoDragListenerTest.kt | 14 ++++++++++++++ 21 files changed, 38 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index b30a255f..c2b19775 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). Dragging an app *icon* from the dash (loose or launcher-pinned; also dash search results) shows the **app-info target** (`lalAppInfo`, `desktop/launcher/AppInfoDragListener`) in the trash's place — the dash always shows every installed app, so there is nothing to delete; dropping the icon there opens the system's App info screen (`App.openAppInfo()`, via LauncherApps for other-profile apps). The dash long-click listener marks the drag via `LauncherBarBinder.dragStartedFromDashApp()`, sticky until `stoppedDragging`; dash folders/folder members keep the trash (delete / leave folder). 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` (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). Dragging an app *icon* from the dash (loose or launcher-pinned; also dash search results) shows the **app-info target** (`lalAppInfo`, `desktop/launcher/AppInfoDragListener`; its icon is a per-theme `launcher_appinfo_image` applied by `ThemeApplier`, like the trash/preferences icons — a layout-level `custom:icon` does nothing, `AppLauncher` only reads `applauncher_icon`) in the trash's place — the dash always shows every installed app, so there is nothing to delete; dropping the icon there opens the system's App info screen (`App.openAppInfo()`, via LauncherApps for other-profile apps). The dash long-click listener marks the drag via `LauncherBarBinder.dragStartedFromDashApp()`, sticky until `stoppedDragging`; dash folders/folder members keep the trash (delete / leave folder). 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/home/ThemeApplier.kt b/app/src/main/java/be/robinj/distrohopper/home/ThemeApplier.kt index b8899af4..5ce2d08e 100644 --- a/app/src/main/java/be/robinj/distrohopper/home/ThemeApplier.kt +++ b/app/src/main/java/be/robinj/distrohopper/home/ThemeApplier.kt @@ -40,6 +40,7 @@ class ThemeApplier( val lalBfb = this.viewFinder.get(llBfbSpinnerWrapper, R.id.lalBfb) val lalPreferences = this.viewFinder.get(llLauncher, R.id.lalPreferences) val lalTrash = this.viewFinder.get(llLauncher, R.id.lalTrash) + val lalAppInfo = this.viewFinder.get(llLauncher, R.id.lalAppInfo) val llDash = this.viewFinder.get(R.id.llDash) val llDashCustomise = this.viewFinder.get(llDash, R.id.llDashCustomise) val imgDashBackgroundGradient = this.viewFinder.get(llDash, R.id.imgDashBackgroundGradient) @@ -59,6 +60,7 @@ class ThemeApplier( imgDashBackgroundGradient.setImageResource(this.theme.dash_background_gradient) lalPreferences.setIcon(res.getDrawable(this.theme.launcher_preferences_image, null)) lalTrash.setIcon(res.getDrawable(this.theme.launcher_trash_image, null)) + lalAppInfo.setIcon(res.getDrawable(this.theme.launcher_appinfo_image, null)) val llPanel_layoutParams = llPanel.layoutParams as RelativeLayout.LayoutParams llPanel_layoutParams.height = res.getDimension(this.theme.panel_height).toInt() diff --git a/app/src/main/java/be/robinj/distrohopper/theme/Budgie.java b/app/src/main/java/be/robinj/distrohopper/theme/Budgie.java index 29c77d61..45f53f53 100644 --- a/app/src/main/java/be/robinj/distrohopper/theme/Budgie.java +++ b/app/src/main/java/be/robinj/distrohopper/theme/Budgie.java @@ -35,6 +35,7 @@ public Budgie () this.launcher_preferences_image = R.drawable.theme_budgie_launcher_preferences_image; this.launcher_preferences_location_when_panel_hidden = R.integer.theme_budgie_launcher_preferences_location_when_panel_hidden; this.launcher_trash_image = R.drawable.theme_budgie_launcher_trash_image; + this.launcher_appinfo_image = R.drawable.theme_budgie_launcher_appinfo_image; this.launcher_applauncher_backgroundcolour_dynamic = R.bool.theme_budgie_launcher_applauncher_backgroundcolour_dynamic; this.launcher_applauncher_backgroundcolour = R.color.theme_budgie_launcher_applauncher_backgroundcolour; this.launcher_applauncher_backgroundcolour_opacity = R.integer.theme_budgie_launcher_applauncher_backgroundcolour_opacity; diff --git a/app/src/main/java/be/robinj/distrohopper/theme/Cinnamon.java b/app/src/main/java/be/robinj/distrohopper/theme/Cinnamon.java index 86bfe770..d76cc41f 100644 --- a/app/src/main/java/be/robinj/distrohopper/theme/Cinnamon.java +++ b/app/src/main/java/be/robinj/distrohopper/theme/Cinnamon.java @@ -35,6 +35,7 @@ public Cinnamon() this.launcher_preferences_image = R.drawable.theme_cinnamon_launcher_preferences_image; this.launcher_preferences_location_when_panel_hidden = R.integer.theme_cinnamon_launcher_preferences_location_when_panel_hidden; this.launcher_trash_image = R.drawable.theme_cinnamon_launcher_trash_image; + this.launcher_appinfo_image = R.drawable.theme_cinnamon_launcher_appinfo_image; this.launcher_applauncher_backgroundcolour_dynamic = R.bool.theme_cinnamon_launcher_applauncher_backgroundcolour_dynamic; this.launcher_applauncher_backgroundcolour = R.color.theme_cinnamon_launcher_applauncher_backgroundcolour; this.launcher_applauncher_backgroundcolour_opacity = R.integer.theme_cinnamon_launcher_applauncher_backgroundcolour_opacity; diff --git a/app/src/main/java/be/robinj/distrohopper/theme/Cosmic.java b/app/src/main/java/be/robinj/distrohopper/theme/Cosmic.java index d1e57f79..b2c2b440 100644 --- a/app/src/main/java/be/robinj/distrohopper/theme/Cosmic.java +++ b/app/src/main/java/be/robinj/distrohopper/theme/Cosmic.java @@ -36,6 +36,7 @@ public Cosmic() this.launcher_preferences_image = R.drawable.theme_cosmic_launcher_preferences_image; this.launcher_preferences_location_when_panel_hidden = R.integer.theme_cosmic_launcher_preferences_location_when_panel_hidden; this.launcher_trash_image = R.drawable.theme_cosmic_launcher_trash_image; + this.launcher_appinfo_image = R.drawable.theme_cosmic_launcher_appinfo_image; this.launcher_applauncher_backgroundcolour_dynamic = R.bool.theme_cosmic_launcher_applauncher_backgroundcolour_dynamic; this.launcher_applauncher_backgroundcolour = R.color.theme_cosmic_launcher_applauncher_backgroundcolour; this.launcher_applauncher_backgroundcolour_opacity = R.integer.theme_cosmic_launcher_applauncher_backgroundcolour_opacity; diff --git a/app/src/main/java/be/robinj/distrohopper/theme/Default.java b/app/src/main/java/be/robinj/distrohopper/theme/Default.java index 6a0269cd..cbf7388f 100644 --- a/app/src/main/java/be/robinj/distrohopper/theme/Default.java +++ b/app/src/main/java/be/robinj/distrohopper/theme/Default.java @@ -35,6 +35,7 @@ public Default () this.launcher_preferences_image = R.drawable.theme_default_launcher_preferences_image; this.launcher_preferences_location_when_panel_hidden = R.integer.theme_default_launcher_preferences_location_when_panel_hidden; this.launcher_trash_image = R.drawable.theme_default_launcher_trash_image; + this.launcher_appinfo_image = R.drawable.theme_default_launcher_appinfo_image; this.launcher_applauncher_backgroundcolour_dynamic = R.bool.theme_default_launcher_applauncher_backgroundcolour_dynamic; this.launcher_applauncher_backgroundcolour = R.color.theme_default_launcher_applauncher_backgroundcolour; this.launcher_applauncher_backgroundcolour_opacity = R.integer.theme_default_launcher_applauncher_backgroundcolour_opacity; diff --git a/app/src/main/java/be/robinj/distrohopper/theme/Elementary.java b/app/src/main/java/be/robinj/distrohopper/theme/Elementary.java index 183da512..aa9418ec 100644 --- a/app/src/main/java/be/robinj/distrohopper/theme/Elementary.java +++ b/app/src/main/java/be/robinj/distrohopper/theme/Elementary.java @@ -35,6 +35,7 @@ public Elementary () this.launcher_preferences_image = R.drawable.theme_elementary_launcher_preferences_image; this.launcher_preferences_location_when_panel_hidden = R.integer.theme_elementary_launcher_preferences_location_when_panel_hidden; this.launcher_trash_image = R.drawable.theme_elementary_launcher_trash_image; + this.launcher_appinfo_image = R.drawable.theme_elementary_launcher_appinfo_image; this.launcher_applauncher_backgroundcolour_dynamic = R.bool.theme_elementary_launcher_applauncher_backgroundcolour_dynamic; this.launcher_applauncher_backgroundcolour = R.color.theme_elementary_launcher_applauncher_backgroundcolour; this.launcher_applauncher_backgroundcolour_opacity = R.integer.theme_elementary_launcher_applauncher_backgroundcolour_opacity; diff --git a/app/src/main/java/be/robinj/distrohopper/theme/Gnome.java b/app/src/main/java/be/robinj/distrohopper/theme/Gnome.java index 740338d5..46de65a5 100644 --- a/app/src/main/java/be/robinj/distrohopper/theme/Gnome.java +++ b/app/src/main/java/be/robinj/distrohopper/theme/Gnome.java @@ -35,6 +35,7 @@ public Gnome () this.launcher_preferences_image = R.drawable.theme_gnome_launcher_preferences_image; this.launcher_preferences_location_when_panel_hidden = R.integer.theme_gnome_launcher_preferences_location_when_panel_hidden; this.launcher_trash_image = R.drawable.theme_gnome_launcher_trash_image; + this.launcher_appinfo_image = R.drawable.theme_gnome_launcher_appinfo_image; this.launcher_applauncher_backgroundcolour_dynamic = R.bool.theme_gnome_launcher_applauncher_backgroundcolour_dynamic; this.launcher_applauncher_backgroundcolour = R.color.theme_gnome_launcher_applauncher_backgroundcolour; this.launcher_applauncher_backgroundcolour_opacity = R.integer.theme_gnome_launcher_applauncher_backgroundcolour_opacity; diff --git a/app/src/main/java/be/robinj/distrohopper/theme/Mate.java b/app/src/main/java/be/robinj/distrohopper/theme/Mate.java index 884322a7..e13ed758 100644 --- a/app/src/main/java/be/robinj/distrohopper/theme/Mate.java +++ b/app/src/main/java/be/robinj/distrohopper/theme/Mate.java @@ -36,6 +36,7 @@ public Mate() this.launcher_preferences_image = R.drawable.theme_mate_launcher_preferences_image; this.launcher_preferences_location_when_panel_hidden = R.integer.theme_mate_launcher_preferences_location_when_panel_hidden; this.launcher_trash_image = R.drawable.theme_mate_launcher_trash_image; + this.launcher_appinfo_image = R.drawable.theme_mate_launcher_appinfo_image; this.launcher_applauncher_backgroundcolour_dynamic = R.bool.theme_mate_launcher_applauncher_backgroundcolour_dynamic; this.launcher_applauncher_backgroundcolour = R.color.theme_mate_launcher_applauncher_backgroundcolour; this.launcher_applauncher_backgroundcolour_opacity = R.integer.theme_mate_launcher_applauncher_backgroundcolour_opacity; diff --git a/app/src/main/java/be/robinj/distrohopper/theme/Plasma.java b/app/src/main/java/be/robinj/distrohopper/theme/Plasma.java index 1412b5e8..ca2b56e4 100644 --- a/app/src/main/java/be/robinj/distrohopper/theme/Plasma.java +++ b/app/src/main/java/be/robinj/distrohopper/theme/Plasma.java @@ -35,6 +35,7 @@ public Plasma() this.launcher_preferences_image = R.drawable.theme_plasma_launcher_preferences_image; this.launcher_preferences_location_when_panel_hidden = R.integer.theme_plasma_launcher_preferences_location_when_panel_hidden; this.launcher_trash_image = R.drawable.theme_plasma_launcher_trash_image; + this.launcher_appinfo_image = R.drawable.theme_plasma_launcher_appinfo_image; this.launcher_applauncher_backgroundcolour_dynamic = R.bool.theme_plasma_launcher_applauncher_backgroundcolour_dynamic; this.launcher_applauncher_backgroundcolour = R.color.theme_plasma_launcher_applauncher_backgroundcolour; this.launcher_applauncher_backgroundcolour_opacity = R.integer.theme_plasma_launcher_applauncher_backgroundcolour_opacity; diff --git a/app/src/main/java/be/robinj/distrohopper/theme/Theme.java b/app/src/main/java/be/robinj/distrohopper/theme/Theme.java index 83d7929d..3dcd7969 100644 --- a/app/src/main/java/be/robinj/distrohopper/theme/Theme.java +++ b/app/src/main/java/be/robinj/distrohopper/theme/Theme.java @@ -63,6 +63,7 @@ public abstract class Theme public int launcher_preferences_image; public int launcher_preferences_location_when_panel_hidden; public int launcher_trash_image; + public int launcher_appinfo_image; public int launcher_applauncher_backgroundcolour_dynamic; public int launcher_applauncher_backgroundcolour; public int launcher_applauncher_backgroundcolour_opacity; diff --git a/app/src/main/res/layout/activity_home.xml b/app/src/main/res/layout/activity_home.xml index 74f6ec03..40f07270 100644 --- a/app/src/main/res/layout/activity_home.xml +++ b/app/src/main/res/layout/activity_home.xml @@ -219,14 +219,16 @@ custom:applauncher_special="false" custom:icon="@drawable/launcher_trash" /> + + custom:applauncher_special="false" /> diff --git a/app/src/main/res/values/theme_budgie.xml b/app/src/main/res/values/theme_budgie.xml index 545bfd0e..bffdf3f3 100644 --- a/app/src/main/res/values/theme_budgie.xml +++ b/app/src/main/res/values/theme_budgie.xml @@ -45,6 +45,7 @@ @drawable/theme_budgie_res_launcher_preferences @integer/position_none @drawable/launcher_trash + @drawable/launcher_appinfo false @android:color/transparent 204 diff --git a/app/src/main/res/values/theme_cinnamon.xml b/app/src/main/res/values/theme_cinnamon.xml index 71a98502..ee1c88f9 100644 --- a/app/src/main/res/values/theme_cinnamon.xml +++ b/app/src/main/res/values/theme_cinnamon.xml @@ -41,6 +41,7 @@ @drawable/theme_cinnamon_res_launcher_preferences @integer/position_none @drawable/launcher_trash + @drawable/launcher_appinfo false @android:color/transparent 204 diff --git a/app/src/main/res/values/theme_cosmic.xml b/app/src/main/res/values/theme_cosmic.xml index c40ef4da..eb806732 100644 --- a/app/src/main/res/values/theme_cosmic.xml +++ b/app/src/main/res/values/theme_cosmic.xml @@ -42,6 +42,7 @@ @drawable/theme_cosmic_res_launcher_preferences @integer/position_none @drawable/launcher_trash + @drawable/launcher_appinfo false @android:color/transparent 204 diff --git a/app/src/main/res/values/theme_default.xml b/app/src/main/res/values/theme_default.xml index 9cbdff1b..0ad7dc2b 100644 --- a/app/src/main/res/values/theme_default.xml +++ b/app/src/main/res/values/theme_default.xml @@ -40,6 +40,7 @@ @drawable/launcher_preferences @integer/position_none @drawable/launcher_trash + @drawable/launcher_appinfo true @android:color/transparent 204 diff --git a/app/src/main/res/values/theme_elementary.xml b/app/src/main/res/values/theme_elementary.xml index 9ed8edca..bd47c8fe 100644 --- a/app/src/main/res/values/theme_elementary.xml +++ b/app/src/main/res/values/theme_elementary.xml @@ -41,6 +41,7 @@ @drawable/theme_elementary_res_launcher_preferences @integer/position_right @drawable/theme_elementary_res_launcher_trash + @drawable/launcher_appinfo false @android:color/transparent 204 diff --git a/app/src/main/res/values/theme_gnome.xml b/app/src/main/res/values/theme_gnome.xml index d8a5f01d..f335a758 100644 --- a/app/src/main/res/values/theme_gnome.xml +++ b/app/src/main/res/values/theme_gnome.xml @@ -42,6 +42,7 @@ @drawable/launcher_preferences @integer/position_none @drawable/launcher_trash + @drawable/launcher_appinfo false @android:color/transparent 204 diff --git a/app/src/main/res/values/theme_mate.xml b/app/src/main/res/values/theme_mate.xml index a2a03aa0..e51a4548 100644 --- a/app/src/main/res/values/theme_mate.xml +++ b/app/src/main/res/values/theme_mate.xml @@ -41,6 +41,7 @@ @drawable/theme_mate_res_launcher_preferences @integer/position_none @drawable/launcher_trash + @drawable/launcher_appinfo false @android:color/transparent 204 diff --git a/app/src/main/res/values/theme_plasma.xml b/app/src/main/res/values/theme_plasma.xml index d5c3aed5..2b6102aa 100644 --- a/app/src/main/res/values/theme_plasma.xml +++ b/app/src/main/res/values/theme_plasma.xml @@ -41,6 +41,7 @@ @drawable/theme_plasma_res_launcher_preferences @integer/position_none @drawable/launcher_trash + @drawable/launcher_appinfo false @android:color/transparent 204 diff --git a/app/src/test/java/be/robinj/distrohopper/desktop/launcher/AppInfoDragListenerTest.kt b/app/src/test/java/be/robinj/distrohopper/desktop/launcher/AppInfoDragListenerTest.kt index 17d467f9..30c6a60a 100644 --- a/app/src/test/java/be/robinj/distrohopper/desktop/launcher/AppInfoDragListenerTest.kt +++ b/app/src/test/java/be/robinj/distrohopper/desktop/launcher/AppInfoDragListenerTest.kt @@ -11,6 +11,7 @@ import be.robinj.distrohopper.HomeActivity import be.robinj.distrohopper.R import org.junit.After import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull import org.junit.Before import org.junit.Test @@ -27,6 +28,19 @@ class AppInfoDragListenerTest { @Before fun setUp() { scenario = ActivityTestSupport.launchHome() } @After fun tearDown() { scenario.close() } + @Test fun theAppInfoTargetGetsItsThemedIcon() { + // The icon is applied by ThemeApplier from the theme's launcher_appinfo_image + // (a layout-level custom:icon attribute is dead — see AppLauncher's styleable); + // regression test for the target rendering as a blank tile. + scenario.onActivity { activity -> + val lalAppInfo = activity.findViewById(R.id.lalAppInfo) + + assertNotNull(lalAppInfo.icon) + assertNotNull(lalAppInfo + .findViewById(R.id.imgIcon).drawable) + } + } + @Test fun enteringTheAppInfoTargetHighlightsItAndExitingRestoresIt() { scenario.onActivity { activity -> val lalAppInfo = activity.findViewById(R.id.lalAppInfo)