Skip to content

Commit ff9337e

Browse files
authored
ADFA-3127 | Support onHoverListener and tooltips for mouse interactions (#1120)
* feat: implement hover listeners and tooltip support for active elements Added hover event handling to UI components and tooltips to action buttons * fix: update import * 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.
1 parent 2775fba commit ff9337e

File tree

9 files changed

+151
-6
lines changed

9 files changed

+151
-6
lines changed

app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -432,6 +432,17 @@ open class EditorHandlerActivity :
432432
tag = action.retrieveTooltipTag(false),
433433
)
434434
},
435+
onHover = { anchor ->
436+
TooltipManager.cancelScheduledDismiss()
437+
TooltipManager.showIdeCategoryTooltip(
438+
context = this@EditorHandlerActivity,
439+
anchorView = anchor,
440+
tag = action.retrieveTooltipTag(false)
441+
)
442+
},
443+
onHoverExit = {
444+
TooltipManager.scheduleActiveTooltipDismiss()
445+
},
435446
shouldAddMargin = !isLast,
436447
)
437448
}

app/src/main/java/com/itsaky/androidide/activities/editor/LandscapeImmersiveController.kt

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package com.itsaky.androidide.activities.editor
22

33
import android.annotation.SuppressLint
44
import android.content.res.Configuration
5+
import android.view.InputDevice
56
import android.view.MotionEvent
67
import android.view.View
78
import android.view.View.OnLayoutChangeListener
@@ -122,6 +123,22 @@ class LandscapeImmersiveController(
122123
}
123124
}
124125

126+
/**
127+
* Observes mouse hover events to manage the top bar's auto-hide behavior.
128+
* It pauses the auto-hide timer while the cursor is over the bar (or its buttons),
129+
* and resumes it when the cursor leaves.
130+
*/
131+
private val topBarHoverObserver: (MotionEvent) -> Unit = { event ->
132+
if (event.isFromSource(InputDevice.SOURCE_MOUSE)) {
133+
when (event.actionMasked) {
134+
MotionEvent.ACTION_HOVER_ENTER,
135+
MotionEvent.ACTION_HOVER_MOVE -> onTopBarInteractionStarted()
136+
137+
MotionEvent.ACTION_HOVER_EXIT -> onTopBarInteractionEnded()
138+
}
139+
}
140+
}
141+
125142
@SuppressLint("ClickableViewAccessibility")
126143
fun bind() {
127144
if (isBound) return
@@ -131,6 +148,7 @@ class LandscapeImmersiveController(
131148
appBarContent.addOnLayoutChangeListener(appBarLayoutChangeListener)
132149
bottomSheetBehavior.addBottomSheetCallback(bottomSheetCallback)
133150
appBarContent.onTouchEventObserved = topBarTouchObserver
151+
appBarContent.onHoverEventObserved = topBarHoverObserver
134152
}
135153

136154
fun onPause() {
@@ -166,6 +184,7 @@ class LandscapeImmersiveController(
166184
appBarContent.removeOnLayoutChangeListener(appBarLayoutChangeListener)
167185
bottomSheetBehavior.removeBottomSheetCallback(bottomSheetCallback)
168186
appBarContent.onTouchEventObserved = null
187+
appBarContent.onHoverEventObserved = null
169188
}
170189

171190
fun onConfigurationChanged(newConfig: Configuration) {

app/src/main/res/layout-land/fragment_saved_projects.xml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@
5252
android:layout_height="wrap_content"
5353
android:minWidth="0dp"
5454
android:layout_marginEnd="2dp"
55+
android:tooltipText="@string/exit"
5556
android:text="@string/exit" />
5657

5758
<View
@@ -64,6 +65,7 @@
6465
android:layout_width="wrap_content"
6566
android:layout_height="wrap_content"
6667
android:minWidth="0dp"
68+
android:tooltipText="@string/new_project"
6769
android:text="@string/new_project" />
6870
</LinearLayout>
6971

@@ -72,6 +74,7 @@
7274
style="@style/Widget.Material3.Button.TextButton"
7375
android:layout_width="match_parent"
7476
android:layout_height="wrap_content"
77+
android:tooltipText="@string/open_from_folder"
7578
android:text="@string/open_from_folder" />
7679
</LinearLayout>
7780

app/src/main/res/layout/layout_project_filters_bar.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,5 +31,6 @@
3131
android:layout_width="wrap_content"
3232
android:layout_height="wrap_content"
3333
android:layout_marginStart="6dp"
34+
android:tooltipText="@string/sort_projects_label"
3435
app:icon="@drawable/ic_sort" />
3536
</LinearLayout>

app/src/main/res/layout/layout_project_filters_sheet.xml

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -40,11 +40,12 @@
4040
</com.google.android.material.textfield.TextInputLayout>
4141
<com.google.android.material.button.MaterialButton
4242
android:id="@+id/sort_toggle_btn"
43-
style="@style/Widget.Material3.Button.OutlinedButton.Icon"
44-
android:backgroundTint="@color/_blue_wave_light_colorPrimaryDark"
43+
style="@style/Widget.Material3.Button.IconButton.Filled"
4544
android:layout_width="50dp"
46-
android:layout_height="match_parent"
45+
android:layout_height="wrap_content"
4746
android:layout_marginStart="12dp"
47+
android:tooltipText="@string/toggle_sorting"
48+
android:contentDescription="@string/toggle_sorting"
4849
app:icon="@drawable/ic_arrow_up"
4950
app:iconTint="@android:color/primary_text_dark" />
5051
</LinearLayout>
@@ -67,13 +68,13 @@
6768
android:layout_marginEnd="12dp"
6869
android:visibility="gone"
6970
android:text="@string/reset_sorting"
71+
android:tooltipText="@string/reset_sorting"
7072
app:iconTint="@color/white"
7173
app:icon="@drawable/ic_close" />
7274

7375
<com.google.android.material.button.MaterialButton
7476
android:id="@+id/apply_filters_btn"
75-
style="@style/Widget.Material3.Button.TextButton.Dialog"
76-
android:backgroundTint="@color/_blue_wave_light_colorPrimaryDark"
77+
android:tooltipText="@string/apply_sorting"
7778
android:textColor="@android:color/white"
7879
android:layout_width="0dp"
7980
android:layout_weight="1"

common/src/main/java/com/itsaky/androidide/ui/ProjectActionsToolbar.kt

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@ import android.content.Context
44
import android.graphics.drawable.Drawable
55
import android.util.AttributeSet
66
import android.util.TypedValue
7+
import android.view.InputDevice
78
import android.view.LayoutInflater
9+
import android.view.MotionEvent
810
import android.view.View
911
import android.widget.ImageButton
1012
import android.widget.LinearLayout
@@ -36,10 +38,14 @@ class ProjectActionsToolbar @JvmOverloads constructor(
3638
hint: String,
3739
onClick: () -> Unit,
3840
onLongClick: () -> Unit,
41+
onHover: ((View) -> Unit)? = null,
42+
onHoverExit: (() -> Unit)? = null,
3943
shouldAddMargin: Boolean
4044
) {
4145
val item = ImageButton(context).apply {
42-
tooltipText = hint
46+
if (onHover == null) {
47+
tooltipText = hint
48+
}
4349
contentDescription = hint
4450
setImageDrawable(icon)
4551
addCircleRipple()
@@ -57,6 +63,24 @@ class ProjectActionsToolbar @JvmOverloads constructor(
5763
onLongClick()
5864
true
5965
}
66+
var hoverRunnable: Runnable? = null
67+
setOnHoverListener { view, event ->
68+
if (!event.isFromSource(InputDevice.SOURCE_MOUSE)) return@setOnHoverListener false
69+
70+
when (event.actionMasked) {
71+
MotionEvent.ACTION_HOVER_ENTER -> {
72+
hoverRunnable?.let { view.removeCallbacks(it) }
73+
hoverRunnable = Runnable { onHover?.invoke(view) }
74+
view.postDelayed(hoverRunnable, 600L)
75+
}
76+
MotionEvent.ACTION_HOVER_EXIT -> {
77+
hoverRunnable?.let { view.removeCallbacks(it) }
78+
onHoverExit?.invoke()
79+
}
80+
}
81+
82+
false
83+
}
6084
}
6185
binding.menuContainer.addView(item)
6286
}

common/src/main/java/com/itsaky/androidide/ui/TouchObservingLinearLayout.kt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,15 @@ class TouchObservingLinearLayout @JvmOverloads constructor(
1111
) : LinearLayout(context, attrs) {
1212

1313
var onTouchEventObserved: ((MotionEvent) -> Unit)? = null
14+
var onHoverEventObserved: ((MotionEvent) -> Unit)? = null
1415

1516
override fun dispatchTouchEvent(ev: MotionEvent): Boolean {
1617
onTouchEventObserved?.invoke(ev)
1718
return super.dispatchTouchEvent(ev)
1819
}
20+
21+
override fun dispatchHoverEvent(event: MotionEvent): Boolean {
22+
onHoverEventObserved?.invoke(event)
23+
return super.dispatchHoverEvent(event)
24+
}
1925
}

idetooltips/src/main/java/com/itsaky/androidide/idetooltips/ToolTipManager.kt

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@ import android.webkit.WebViewClient
2222
import android.widget.ImageButton
2323
import android.widget.PopupWindow
2424
import android.widget.TextView
25+
import android.os.Handler
26+
import android.os.Looper
27+
import android.view.InputDevice
28+
import android.view.MotionEvent
2529
import androidx.appcompat.app.AlertDialog
2630
import androidx.core.content.ContextCompat.getColor
2731
import com.itsaky.androidide.activities.editor.HelpActivity
@@ -41,6 +45,10 @@ import java.io.File
4145

4246
object TooltipManager {
4347
private const val TAG = "TooltipManager"
48+
private const val DEFAULT_HOVER_DISMISS_DELAY_MS = 800L
49+
private var activePopupWindow: PopupWindow? = null
50+
private val dismissHandler = Handler(Looper.getMainLooper())
51+
private var pendingDismiss: Runnable? = null
4452
private val databaseTimestamp: Long = File(Environment.DOC_DB.absolutePath).lastModified()
4553
private val debugDatabaseFile: File = File(android.os.Environment.getExternalStorageDirectory().toString() +
4654
"/Download/documentation.db")
@@ -149,6 +157,27 @@ object TooltipManager {
149157
}
150158
}
151159

160+
fun dismissActiveTooltip() {
161+
cancelScheduledDismiss()
162+
activePopupWindow?.dismiss()
163+
activePopupWindow = null
164+
}
165+
166+
fun scheduleActiveTooltipDismiss(delayMs: Long = DEFAULT_HOVER_DISMISS_DELAY_MS) {
167+
cancelScheduledDismiss()
168+
val popup = activePopupWindow ?: return
169+
pendingDismiss = Runnable {
170+
if (activePopupWindow === popup) {
171+
popup.dismiss()
172+
}
173+
}.also { dismissHandler.postDelayed(it, delayMs) }
174+
}
175+
176+
fun cancelScheduledDismiss() {
177+
pendingDismiss?.let { dismissHandler.removeCallbacks(it) }
178+
pendingDismiss = null
179+
}
180+
152181
// Displays a tooltip for category [TooltipCategory.CATEGORY_IDE] in a particular context
153182
// (An Activity, Fragment, Dialog etc)
154183
fun showIdeCategoryTooltip(context: Context, anchorView: View, tag: String) {
@@ -355,6 +384,16 @@ object TooltipManager {
355384
popupWindow.setBackgroundDrawable(ColorDrawable(transparentColor))
356385
popupView.setBackgroundResource(R.drawable.idetooltip_popup_background)
357386

387+
dismissActiveTooltip()
388+
389+
activePopupWindow = popupWindow
390+
popupWindow.setOnDismissListener {
391+
cancelScheduledDismiss()
392+
if (activePopupWindow === popupWindow) {
393+
activePopupWindow = null
394+
}
395+
}
396+
358397
popupWindow.isFocusable = true
359398
popupWindow.isOutsideTouchable = true
360399
if (anchorView.isInOverlayWindow()) {
@@ -391,6 +430,31 @@ object TooltipManager {
391430
}
392431
setColorFilter(iconTintColor)
393432
}
433+
434+
val hoverGuard: (MotionEvent) -> Unit = label@{ event ->
435+
if (!event.isFromSource(InputDevice.SOURCE_MOUSE)) return@label
436+
when (event.actionMasked) {
437+
MotionEvent.ACTION_HOVER_ENTER,
438+
MotionEvent.ACTION_HOVER_MOVE -> cancelScheduledDismiss()
439+
MotionEvent.ACTION_HOVER_EXIT -> scheduleActiveTooltipDismiss()
440+
}
441+
}
442+
443+
val hoverListener = View.OnHoverListener { _, event ->
444+
hoverGuard(event)
445+
false
446+
}
447+
448+
installHoverGuard(
449+
hoverListener = hoverListener,
450+
popupView = popupView,
451+
webView = webView,
452+
seeMore = seeMore,
453+
infoButton = infoButton,
454+
feedbackButton = feedbackButton,
455+
)
456+
457+
cancelScheduledDismiss()
394458
}
395459

396460
/**
@@ -508,4 +572,19 @@ object TooltipManager {
508572
popupWindow.showAtLocation(parentView, Gravity.NO_GRAVITY, x, y)
509573
}
510574

575+
private fun installHoverGuard(
576+
hoverListener: View.OnHoverListener,
577+
popupView: View,
578+
webView: WebView,
579+
seeMore: View,
580+
infoButton: View,
581+
feedbackButton: View,
582+
) {
583+
popupView.setOnHoverListener(hoverListener)
584+
webView.setOnHoverListener(hoverListener)
585+
seeMore.setOnHoverListener(hoverListener)
586+
infoButton.setOnHoverListener(hoverListener)
587+
feedbackButton.setOnHoverListener(hoverListener)
588+
}
589+
511590
}

resources/src/main/res/values/strings.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,7 @@
145145
<string name="save_and_close">Save files and close project</string>
146146
<string name="search_projects_hint">Search projects by name…</string>
147147
<string name="sort_projects_label">Sort projects</string>
148+
<string name="toggle_sorting">Toggle sorting</string>
148149
<string name="reset_sorting">Reset</string>
149150
<string name="apply_sorting">Sort</string>
150151
<string name="sort_by_hint">Sort by</string>

0 commit comments

Comments
 (0)