From c6e80ceed3d67cf3ecdc6b2ef7e6453cca880f2a Mon Sep 17 00:00:00 2001 From: John Trujillo Date: Wed, 25 Mar 2026 15:42:03 -0500 Subject: [PATCH 1/3] feat: implement hover listeners and tooltip support for active elements Added hover event handling to UI components and tooltips to action buttons --- .../editor/EditorHandlerActivity.kt | 10 +++++++ .../editor/LandscapeImmersiveController.kt | 18 +++++++++++++ .../layout-land/fragment_saved_projects.xml | 3 +++ .../res/layout/layout_project_filters_bar.xml | 1 + .../layout/layout_project_filters_sheet.xml | 11 ++++---- .../androidide/ui/ProjectActionsToolbar.kt | 26 ++++++++++++++++++- .../ui/TouchObservingLinearLayout.kt | 6 +++++ .../androidide/idetooltips/ToolTipManager.kt | 17 +++++++++++- resources/src/main/res/values/strings.xml | 1 + 9 files changed, 86 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt index ebe0ebd0da..834845cbc9 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt @@ -431,6 +431,16 @@ open class EditorHandlerActivity : tag = action.retrieveTooltipTag(false), ) }, + onHover = { anchor -> + TooltipManager.showIdeCategoryTooltip( + context = this@EditorHandlerActivity, + anchorView = anchor, + tag = action.retrieveTooltipTag(false) + ) + }, + onHoverExit = { + TooltipManager.dismissActiveTooltip() + }, shouldAddMargin = !isLast, ) } diff --git a/app/src/main/java/com/itsaky/androidide/activities/editor/LandscapeImmersiveController.kt b/app/src/main/java/com/itsaky/androidide/activities/editor/LandscapeImmersiveController.kt index 201c88b5ee..53aaaa0a74 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/editor/LandscapeImmersiveController.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/editor/LandscapeImmersiveController.kt @@ -122,6 +122,22 @@ class LandscapeImmersiveController( } } + /** + * Observes mouse hover events to manage the top bar's auto-hide behavior. + * It pauses the auto-hide timer while the cursor is over the bar (or its buttons), + * and resumes it when the cursor leaves. + */ + private val topBarHoverObserver: (MotionEvent) -> Unit = { event -> + if (event.isFromSource(android.view.InputDevice.SOURCE_MOUSE)) { + when (event.actionMasked) { + MotionEvent.ACTION_HOVER_ENTER, + MotionEvent.ACTION_HOVER_MOVE -> onTopBarInteractionStarted() + + MotionEvent.ACTION_HOVER_EXIT -> onTopBarInteractionEnded() + } + } + } + @SuppressLint("ClickableViewAccessibility") fun bind() { if (isBound) return @@ -131,6 +147,7 @@ class LandscapeImmersiveController( appBarContent.addOnLayoutChangeListener(appBarLayoutChangeListener) bottomSheetBehavior.addBottomSheetCallback(bottomSheetCallback) appBarContent.onTouchEventObserved = topBarTouchObserver + appBarContent.onHoverEventObserved = topBarHoverObserver } fun onPause() { @@ -166,6 +183,7 @@ class LandscapeImmersiveController( appBarContent.removeOnLayoutChangeListener(appBarLayoutChangeListener) bottomSheetBehavior.removeBottomSheetCallback(bottomSheetCallback) appBarContent.onTouchEventObserved = null + appBarContent.onHoverEventObserved = null } fun onConfigurationChanged(newConfig: Configuration) { diff --git a/app/src/main/res/layout-land/fragment_saved_projects.xml b/app/src/main/res/layout-land/fragment_saved_projects.xml index 90bed44b20..2f2240c9aa 100644 --- a/app/src/main/res/layout-land/fragment_saved_projects.xml +++ b/app/src/main/res/layout-land/fragment_saved_projects.xml @@ -52,6 +52,7 @@ android:layout_height="wrap_content" android:minWidth="0dp" android:layout_marginEnd="2dp" + android:tooltipText="@string/exit" android:text="@string/exit" /> @@ -72,6 +74,7 @@ style="@style/Widget.Material3.Button.TextButton" android:layout_width="match_parent" android:layout_height="wrap_content" + android:tooltipText="@string/open_from_folder" android:text="@string/open_from_folder" /> diff --git a/app/src/main/res/layout/layout_project_filters_bar.xml b/app/src/main/res/layout/layout_project_filters_bar.xml index 214db313ee..d32239eed2 100644 --- a/app/src/main/res/layout/layout_project_filters_bar.xml +++ b/app/src/main/res/layout/layout_project_filters_bar.xml @@ -31,5 +31,6 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginStart="6dp" + android:tooltipText="@string/sort_projects_label" app:icon="@drawable/ic_sort" /> \ No newline at end of file diff --git a/app/src/main/res/layout/layout_project_filters_sheet.xml b/app/src/main/res/layout/layout_project_filters_sheet.xml index 35aa9e0d2f..b5feec552b 100644 --- a/app/src/main/res/layout/layout_project_filters_sheet.xml +++ b/app/src/main/res/layout/layout_project_filters_sheet.xml @@ -40,11 +40,12 @@ @@ -67,13 +68,13 @@ android:layout_marginEnd="12dp" android:visibility="gone" android:text="@string/reset_sorting" + android:tooltipText="@string/reset_sorting" app:iconTint="@color/white" app:icon="@drawable/ic_close" /> Unit, onLongClick: () -> Unit, + onHover: ((View) -> Unit)? = null, + onHoverExit: (() -> Unit)? = null, shouldAddMargin: Boolean ) { val item = ImageButton(context).apply { - tooltipText = hint + if (onHover == null) { + tooltipText = hint + } contentDescription = hint setImageDrawable(icon) addCircleRipple() @@ -57,6 +63,24 @@ class ProjectActionsToolbar @JvmOverloads constructor( onLongClick() true } + var hoverRunnable: Runnable? = null + setOnHoverListener { view, event -> + if (!event.isFromSource(InputDevice.SOURCE_MOUSE)) return@setOnHoverListener false + + when (event.actionMasked) { + MotionEvent.ACTION_HOVER_ENTER -> { + hoverRunnable?.let { view.removeCallbacks(it) } + hoverRunnable = Runnable { onHover?.invoke(view) } + view.postDelayed(hoverRunnable, 600L) + } + MotionEvent.ACTION_HOVER_EXIT -> { + hoverRunnable?.let { view.removeCallbacks(it) } + onHoverExit?.invoke() + } + } + + false + } } binding.menuContainer.addView(item) } diff --git a/common/src/main/java/com/itsaky/androidide/ui/TouchObservingLinearLayout.kt b/common/src/main/java/com/itsaky/androidide/ui/TouchObservingLinearLayout.kt index f9b6376f71..5fe064e146 100644 --- a/common/src/main/java/com/itsaky/androidide/ui/TouchObservingLinearLayout.kt +++ b/common/src/main/java/com/itsaky/androidide/ui/TouchObservingLinearLayout.kt @@ -11,9 +11,15 @@ class TouchObservingLinearLayout @JvmOverloads constructor( ) : LinearLayout(context, attrs) { var onTouchEventObserved: ((MotionEvent) -> Unit)? = null + var onHoverEventObserved: ((MotionEvent) -> Unit)? = null override fun dispatchTouchEvent(ev: MotionEvent): Boolean { onTouchEventObserved?.invoke(ev) return super.dispatchTouchEvent(ev) } + + override fun dispatchHoverEvent(event: MotionEvent): Boolean { + onHoverEventObserved?.invoke(event) + return super.dispatchHoverEvent(event) + } } diff --git a/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/ToolTipManager.kt b/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/ToolTipManager.kt index b95e3803d4..74e6eca7a4 100644 --- a/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/ToolTipManager.kt +++ b/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/ToolTipManager.kt @@ -41,6 +41,7 @@ import java.io.File object TooltipManager { private const val TAG = "TooltipManager" + private var activePopupWindow: PopupWindow? = null private val databaseTimestamp: Long = File(Environment.DOC_DB.absolutePath).lastModified() private val debugDatabaseFile: File = File(android.os.Environment.getExternalStorageDirectory().toString() + "/Download/documentation.db") @@ -149,6 +150,11 @@ object TooltipManager { } } + fun dismissActiveTooltip() { + activePopupWindow?.dismiss() + activePopupWindow = null + } + // Displays a tooltip for category [TooltipCategory.CATEGORY_IDE] in a particular context // (An Activity, Fragment, Dialog etc) fun showIdeCategoryTooltip(context: Context, anchorView: View, tag: String) { @@ -355,7 +361,16 @@ object TooltipManager { popupWindow.setBackgroundDrawable(ColorDrawable(transparentColor)) popupView.setBackgroundResource(R.drawable.idetooltip_popup_background) - popupWindow.isFocusable = true + dismissActiveTooltip() + + activePopupWindow = popupWindow + popupWindow.setOnDismissListener { + if (activePopupWindow === popupWindow) { + activePopupWindow = null + } + } + + popupWindow.isFocusable = false popupWindow.isOutsideTouchable = true if (anchorView.isInOverlayWindow()) { showOverlayTooltip(popupWindow, popupView, anchorView) diff --git a/resources/src/main/res/values/strings.xml b/resources/src/main/res/values/strings.xml index 29a5be4cf3..955eb04a59 100644 --- a/resources/src/main/res/values/strings.xml +++ b/resources/src/main/res/values/strings.xml @@ -145,6 +145,7 @@ Save files and close project Search projects by name… Sort projects + Toggle sorting Reset Sort Sort by From 934032a79b2b76d4c658595e3ba5acdcf47f5bd6 Mon Sep 17 00:00:00 2001 From: John Trujillo Date: Thu, 26 Mar 2026 13:08:10 -0500 Subject: [PATCH 2/3] fix: update import --- .../activities/editor/LandscapeImmersiveController.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/itsaky/androidide/activities/editor/LandscapeImmersiveController.kt b/app/src/main/java/com/itsaky/androidide/activities/editor/LandscapeImmersiveController.kt index 53aaaa0a74..1864b4ca5c 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/editor/LandscapeImmersiveController.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/editor/LandscapeImmersiveController.kt @@ -2,6 +2,7 @@ package com.itsaky.androidide.activities.editor import android.annotation.SuppressLint import android.content.res.Configuration +import android.view.InputDevice import android.view.MotionEvent import android.view.View import android.view.View.OnLayoutChangeListener @@ -128,7 +129,7 @@ class LandscapeImmersiveController( * and resumes it when the cursor leaves. */ private val topBarHoverObserver: (MotionEvent) -> Unit = { event -> - if (event.isFromSource(android.view.InputDevice.SOURCE_MOUSE)) { + if (event.isFromSource(InputDevice.SOURCE_MOUSE)) { when (event.actionMasked) { MotionEvent.ACTION_HOVER_ENTER, MotionEvent.ACTION_HOVER_MOVE -> onTopBarInteractionStarted() From 22078ae1e11030a888d54e6aa39ea6147a0f358a Mon Sep 17 00:00:00 2001 From: John Trujillo Date: Fri, 27 Mar 2026 09:41:58 -0500 Subject: [PATCH 3/3] feat(tooltip): improve hover UX with delayed dismiss and hover guard Add delayed dismiss logic to prevent tooltip flicker when moving cursor between anchor and popup. Includes hover guard for tooltip content and enables ESC dismissal via focusable popup. --- .../editor/EditorHandlerActivity.kt | 3 +- .../androidide/idetooltips/ToolTipManager.kt | 66 ++++++++++++++++++- 2 files changed, 67 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt index 0ed5f70f7f..6c6315cfae 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt @@ -433,6 +433,7 @@ open class EditorHandlerActivity : ) }, onHover = { anchor -> + TooltipManager.cancelScheduledDismiss() TooltipManager.showIdeCategoryTooltip( context = this@EditorHandlerActivity, anchorView = anchor, @@ -440,7 +441,7 @@ open class EditorHandlerActivity : ) }, onHoverExit = { - TooltipManager.dismissActiveTooltip() + TooltipManager.scheduleActiveTooltipDismiss() }, shouldAddMargin = !isLast, ) diff --git a/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/ToolTipManager.kt b/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/ToolTipManager.kt index 74e6eca7a4..8180003054 100644 --- a/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/ToolTipManager.kt +++ b/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/ToolTipManager.kt @@ -22,6 +22,10 @@ import android.webkit.WebViewClient import android.widget.ImageButton import android.widget.PopupWindow import android.widget.TextView +import android.os.Handler +import android.os.Looper +import android.view.InputDevice +import android.view.MotionEvent import androidx.appcompat.app.AlertDialog import androidx.core.content.ContextCompat.getColor import com.itsaky.androidide.activities.editor.HelpActivity @@ -41,7 +45,10 @@ import java.io.File object TooltipManager { private const val TAG = "TooltipManager" + private const val DEFAULT_HOVER_DISMISS_DELAY_MS = 800L private var activePopupWindow: PopupWindow? = null + private val dismissHandler = Handler(Looper.getMainLooper()) + private var pendingDismiss: Runnable? = null private val databaseTimestamp: Long = File(Environment.DOC_DB.absolutePath).lastModified() private val debugDatabaseFile: File = File(android.os.Environment.getExternalStorageDirectory().toString() + "/Download/documentation.db") @@ -151,10 +158,26 @@ object TooltipManager { } fun dismissActiveTooltip() { + cancelScheduledDismiss() activePopupWindow?.dismiss() activePopupWindow = null } + fun scheduleActiveTooltipDismiss(delayMs: Long = DEFAULT_HOVER_DISMISS_DELAY_MS) { + cancelScheduledDismiss() + val popup = activePopupWindow ?: return + pendingDismiss = Runnable { + if (activePopupWindow === popup) { + popup.dismiss() + } + }.also { dismissHandler.postDelayed(it, delayMs) } + } + + fun cancelScheduledDismiss() { + pendingDismiss?.let { dismissHandler.removeCallbacks(it) } + pendingDismiss = null + } + // Displays a tooltip for category [TooltipCategory.CATEGORY_IDE] in a particular context // (An Activity, Fragment, Dialog etc) fun showIdeCategoryTooltip(context: Context, anchorView: View, tag: String) { @@ -365,12 +388,13 @@ object TooltipManager { activePopupWindow = popupWindow popupWindow.setOnDismissListener { + cancelScheduledDismiss() if (activePopupWindow === popupWindow) { activePopupWindow = null } } - popupWindow.isFocusable = false + popupWindow.isFocusable = true popupWindow.isOutsideTouchable = true if (anchorView.isInOverlayWindow()) { showOverlayTooltip(popupWindow, popupView, anchorView) @@ -406,6 +430,31 @@ object TooltipManager { } setColorFilter(iconTintColor) } + + val hoverGuard: (MotionEvent) -> Unit = label@{ event -> + if (!event.isFromSource(InputDevice.SOURCE_MOUSE)) return@label + when (event.actionMasked) { + MotionEvent.ACTION_HOVER_ENTER, + MotionEvent.ACTION_HOVER_MOVE -> cancelScheduledDismiss() + MotionEvent.ACTION_HOVER_EXIT -> scheduleActiveTooltipDismiss() + } + } + + val hoverListener = View.OnHoverListener { _, event -> + hoverGuard(event) + false + } + + installHoverGuard( + hoverListener = hoverListener, + popupView = popupView, + webView = webView, + seeMore = seeMore, + infoButton = infoButton, + feedbackButton = feedbackButton, + ) + + cancelScheduledDismiss() } /** @@ -523,4 +572,19 @@ object TooltipManager { popupWindow.showAtLocation(parentView, Gravity.NO_GRAVITY, x, y) } + private fun installHoverGuard( + hoverListener: View.OnHoverListener, + popupView: View, + webView: WebView, + seeMore: View, + infoButton: View, + feedbackButton: View, + ) { + popupView.setOnHoverListener(hoverListener) + webView.setOnHoverListener(hoverListener) + seeMore.setOnHoverListener(hoverListener) + infoButton.setOnHoverListener(hoverListener) + feedbackButton.setOnHoverListener(hoverListener) + } + }