diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index b0bf625..6258fea 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -16,6 +16,28 @@ The Accessibility service filters hardware keys, draws the overlay and performs The cursor overlay exists only while mouse mode is visible. Cursor position is local to the actual laid-out overlay viewport; touch gestures use that viewport's screen origin plus the same pointer coordinate. +## Touchpad + +```text +touch on pad window + ↓ + TouchpadView ──► PadGestureEngine (pure Java, no Android types) + ↓ listener + MouseAccessibilityService + ┌───────────┼─────────────┐ + PointerController scrollDirect performGlobalAction / mode changes +``` + +`TouchpadOverlay` owns an accessibility-overlay window (full screen or a rectangle stored as screen fractions) that, unlike the cursor overlay, receives touches. `TouchpadView` converts MotionEvents to engine calls and supplies a one-shot `Handler` timer; `PadGestureEngine` recognizes one-finger movement, tap, tap-then-hold drag and two- and three-finger swipes and reports them through a listener, so it can be unit-tested on a plain JVM with a fake timer. + +A tap does not click on release. It waits `tapDelayMs`; a second touch inside that window parks the engine in an undecided state, and only then is the outcome chosen: movement past the touch slop or a hold longer than the tap timeout starts a drag (the first tap is discarded), a quick release is a double tap (two clicks, spaced so they are two separate touches), and extra fingers make it a swipe (first tap discarded). The drag is an ordinary held virtual finger, so scrolling comes from the app's own touch handling rather than from Accessibility scroll actions. Two- and three-finger swipes share one implementation: one event per touch once the centroid travels past a threshold, mapped to a configurable `PadAction` per finger count and direction. + +Because injected gestures are hit-tested like real touches, a touchable pad above the cursor would swallow KeyCursor's own clicks. `ContinuousTouchController` therefore reports stroke begin/end to the service, which switches the pad window to `FLAG_NOT_TOUCHABLE` for the duration of an injected stroke whose target lies on the pad, and delays the first injected touch by a short settle time so the flag change takes effect first. With the pad hidden, or the cursor outside a rectangular pad, nothing changes. + +Once more than one finger has touched, a gesture never falls back to cursor movement when fingers lift; it stays inert until all fingers are up. A touch cancel during tap-and-drag releases the held virtual finger. + +Hiding the cursor (long-press of the Mouse key) also hides the pad, so a dead pad can never sit over the screen. + ## Mouse modes - **Normal** — movement only; the click binding produces real touch DOWN / MOVE / UP and can be held for drag. diff --git a/CHANGELOG.md b/CHANGELOG.md index 57a54ad..dc97bcc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## Unreleased + +- Added a touchpad: the whole screen, or a movable/resizable rectangle, becomes a trackpad for the cursor. +- Touchpad gestures: tap to click, tap-then-hold to drag and scroll, double tap for two clicks, and two- and three-finger swipes in four directions, each bound to a configurable action (Back, Home, Recents, Notifications, Quick settings, Power menu, Lock screen, Screenshot, toggle Hold, next cursor, hide touchpad). +- Adjustable tap delay: every tap waits before clicking, so starting a hold-and-drag or a multi-finger swipe never leaves an accidental click. +- Pad opacity can go to 0% on the full-screen pad (the movable pad keeps a 5% minimum). +- Added a Quick Settings tile that shows/hides the touchpad, plus a `PAD_TOGGLE` external broadcast. +- Touchpad settings: sensitivity, pointer acceleration, opacity, natural scrolling, scroll distance, area mode, pad reset. +- Keyboard control is unchanged and keeps working alongside the touchpad. + ## 0.6.0 — 2026-08-26 - Added three mouse modes: Normal, Hold and Scroll. diff --git a/PRIVACY.md b/PRIVACY.md index b77cbfa..d5820f1 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -6,4 +6,5 @@ KeyCursor does not collect, transmit or sell user data. - Bindings, cursor preferences and the external-control opt-in are stored locally in Android `SharedPreferences`. - The Accessibility service observes hardware key events, draws the cursor overlay and injects user-requested touch gestures. - While **Scroll mode** is actively used, KeyCursor retrieves the active Accessibility node tree only to find a scrollable element under the cursor and inspect its bounds/supported scroll actions. It does not read, store or transmit node text or other UI content, and it does not subscribe to UI accessibility events. +- The touchpad overlay receives touches only on its own surface and uses them to move the cursor, click, scroll and run the configured swipe actions. Touch positions are not stored or transmitted. - External broadcast control is disabled by default and must be enabled explicitly in the app. diff --git a/README.md b/README.md index 909c2f0..27a3a66 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,11 @@ KeyCursor turns hardware keys into a real touch cursor with movement, click-and- - optional blending of simultaneously held directions into intermediate angles - system light / dark theme - optional Mouse-key capture while the cursor is visible +- touchpad mode: use the whole screen or a movable pad as a trackpad: + - tap + - tap-then-hold to drag/scroll + - configurable two- and three-finger swipes +- Quick Settings tile to show / hide the touchpad - optional broadcast control for Key Mapper, Tasker, Automate or ADB - no network permission, analytics or ads @@ -39,6 +44,25 @@ Tested on **Fujitsu F-03L** (Android 8.1). When the cursor is hidden, keys pass through normally. While it is visible, movement/click/cursor-switch keys are consumed so Android focus does not move at the same time. **Capture Mouse key while cursor is visible** can also consume the Mouse key only while the cursor is active, letting the same physical key keep an app shortcut when KeyCursor is hidden. +## Touchpad + +Add the **Touchpad** tile to Quick Settings (or use *Show / hide touchpad now* in the app) to turn the screen, or part of it, into a trackpad. The cursor appears with the pad and both go away when you hide it. + +| Gesture | Result | +| --- | --- | +| One finger | Move the cursor (relative, with optional acceleration) | +| Tap | Click at the cursor, after the **tap delay** | +| Tap, then touch again and hold or drag | Hold the button and drag. This is a real touch drag, so it scrolls lists. The first tap does not click | +| Tap, then tap again | Double tap: two clicks | +| Two fingers, swipe up / down / left / right | Run the action you assigned to that direction (none by default) | +| Three fingers, swipe up / down / left / right | Run the action you assigned to that direction (Home / Notifications / Back / Recents by default) | + +**Tap delay** (0-500 ms, default 150) is how long every tap waits before it clicks. If you touch again inside that time and hold or drag, the tap is dropped, so starting a scroll never leaves a stray click behind. Set it to 0 to click instantly, which turns tap-then-hold off. A tap that turns out to be the start of a two- or three-finger swipe is also dropped. + +**Area** can be a *movable pad* (drag the bar at the top to move it, the corner grip to resize it) or the *full screen*. Opacity goes down to 0% on the full-screen pad; the movable pad keeps at least 5% so it can always be found. Keyboard control keeps working while the touchpad is up. + +Known limitation: Android documents that injecting a gesture cancels gestures already in progress, including the user's own touch. Clicks are injected after your finger lifts, so they are unaffected, but tap-then-hold and Hold mode inject a touch while a finger may still be on the pad. If drags drop out on your device, turn off *Tap, then hold to drag / scroll*, or use a pad that does not cover the drag target. + ## Direction behavior Hardware bindings are matched as physical keys first. Android-rotated D-pad key codes are normalized so the default mapping stays stable after rotation. @@ -64,7 +88,7 @@ adb shell am broadcast \ -n ru.vasys.keycursor/.input.ExternalTriggerReceiver ``` -Other actions include `MODE_TOGGLE`, `CURSOR_NEXT`, `LEFT_DOWN`, `LEFT_UP` and `MOVE_*`. `MODE_TOGGLE` switches Normal / Hold. +Other actions include `PAD_TOGGLE` (show / hide the touchpad), `MODE_TOGGLE`, `CURSOR_NEXT`, `LEFT_DOWN`, `LEFT_UP` and `MOVE_*`. `MODE_TOGGLE` switches Normal / Hold. ## Build diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 8c24423..1154747 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -31,6 +31,17 @@ android:resource="@xml/accessibility_service" /> + + + + + + + diff --git a/app/src/main/java/ru/vasys/keycursor/core/PadAction.java b/app/src/main/java/ru/vasys/keycursor/core/PadAction.java new file mode 100644 index 0000000..8786ce0 --- /dev/null +++ b/app/src/main/java/ru/vasys/keycursor/core/PadAction.java @@ -0,0 +1,40 @@ +package ru.vasys.keycursor.core; + +import android.os.Build; + +/** Actions that can be bound to a two or three-finger swipe on the touchpad. */ +public enum PadAction { + NONE("Do nothing", 1), + BACK("Back", 1), + HOME("Home", 1), + RECENTS("Recent apps", 1), + NOTIFICATIONS("Notifications", 1), + QUICK_SETTINGS("Quick settings", 1), + POWER_DIALOG("Power menu", 1), + LOCK_SCREEN("Lock screen", Build.VERSION_CODES.P), + SCREENSHOT("Screenshot", Build.VERSION_CODES.P), + TOGGLE_HOLD("Toggle Hold mode", 1), + NEXT_CURSOR("Next cursor style", 1), + HIDE_PAD("Hide touchpad", 1); + + public final String label; + public final int minSdk; + + PadAction(String label, int minSdk) { + this.label = label; + this.minSdk = minSdk; + } + + public boolean isAvailable() { + return Build.VERSION.SDK_INT >= minSdk; + } + + public static PadAction fromName(String name, PadAction fallback) { + if (name == null) return fallback; + try { + return valueOf(name); + } catch (IllegalArgumentException e) { + return fallback; + } + } +} diff --git a/app/src/main/java/ru/vasys/keycursor/core/PadGestureEngine.java b/app/src/main/java/ru/vasys/keycursor/core/PadGestureEngine.java new file mode 100644 index 0000000..e340063 --- /dev/null +++ b/app/src/main/java/ru/vasys/keycursor/core/PadGestureEngine.java @@ -0,0 +1,325 @@ +package ru.vasys.keycursor.core; + +/** + * Touchpad gesture recognizer. Deliberately free of Android classes so it can be unit-tested on a + * plain JVM; {@code TouchpadView} translates MotionEvents into the on* calls below and supplies a + * {@link Timer}. + * + *
+ * 1 finger        : relative cursor movement (held back until the touch slop is exceeded)
+ * tap             : click, but only after {@link #tapDelayMs}; a second touch inside that window
+ *                   cancels the click and starts a hold / drag instead
+ * tap, touch again: if the second touch moves or is held, the button is held down and dragged
+ *                   (a real touch drag, so it scrolls lists natively); the first tap never clicks.
+ *                   If the second touch is released quickly it is a double tap = two clicks
+ * 2 or 3 fingers  : one swipe event per touch, once swipeThresholdPx is exceeded
+ * 
+ * + * Once more than one finger has touched, the gesture never falls back to cursor movement when + * fingers lift; it stays inert until every finger is up, so lifting one finger cannot make the + * cursor jump. A tap that was waiting out its delay is discarded when the next touch turns out to + * be a multi-finger gesture, so swipes never leave an accidental click behind. + */ +public final class PadGestureEngine { + public interface Listener { + /** Cursor delta in pixels, already scaled by sensitivity/acceleration. */ + void onMove(float dx, float dy); + + void onClick(); + + void onDragStart(); + + void onDragEnd(); + + /** A two- or three-finger swipe; {@code fingers} is 2 or 3. */ + void onSwipe(int fingers, SwipeDirection direction); + } + + /** One-shot timer owned by the host; scheduling replaces any pending callback. */ + public interface Timer { + void schedule(long delayMs); + + void cancel(); + } + + private enum State { IDLE, ONE, HOLD_PENDING, DRAG, MULTI, DONE } + + private enum Timed { NONE, TAP_COMMIT, HOLD_CONFIRM } + + // ---- configuration (set by the owner; plain fields on purpose) ---- + public float sensitivity = 1.6f; + public boolean acceleration = true; + /** Tap-then-hold-to-drag. When off, every tap clicks immediately. */ + public boolean tapAndDrag = true; + /** How long a tap waits for a second touch before it clicks. 0 = click immediately. */ + public long tapDelayMs = 150; + public float touchSlopPx = 12f; + public float swipeThresholdPx = 120f; + /** Longest touch that still counts as a tap, and how long a second touch must be held. */ + public long tapTimeoutMs = 250; + /** Gap between the two clicks of a double tap, so they are two separate touches. */ + public long doubleClickGapMs = 80; + + private final Listener listener; + private Timer timer; + + private State state = State.IDLE; + private Timed timed = Timed.NONE; + private boolean tapPending; + private long tapUpTime; + + private float downX; + private float downY; + private float lastPx; + private float lastPy; + private long downTime; + private long lastMoveTime; + private boolean slopExceeded; + private boolean firstMoveAfterSlop; + + private int fingers; + private float swipeStartX; + private float swipeStartY; + private boolean swipeFired; + + public PadGestureEngine(Listener listener) { + this.listener = listener; + } + + public void setTimer(Timer timer) { + this.timer = timer; + } + + /** First finger touched down. */ + public void onDown(long t, float x, float y) { + if (state != State.IDLE) onCancel(); + + boolean chained = false; + if (timed == Timed.TAP_COMMIT) { + if (delayedTaps() && t - tapUpTime <= tapDelayMs) { + chained = true; + } else { + cancelTimer(); + tapPending = false; + listener.onClick(); + } + } + + downX = x; + downY = y; + lastPx = x; + lastPy = y; + downTime = t; + lastMoveTime = t; + slopExceeded = false; + firstMoveAfterSlop = false; + swipeFired = false; + fingers = 1; + + if (chained) { + // Undecided: drag/hold (first tap suppressed), or a double tap (two clicks). + state = State.HOLD_PENDING; + arm(Timed.HOLD_CONFIRM, tapTimeoutMs); + } else { + state = State.ONE; + } + } + + /** + * The number of fingers changed. {@code count} is the number of fingers down after the change, + * (cx, cy) their centroid and (px, py) the position of the primary finger. + */ + public void onPointers(long t, int count, float cx, float cy, float px, float py) { + if (state == State.IDLE) return; + lastPx = px; + lastPy = py; + lastMoveTime = t; + switch (state) { + case ONE: + case HOLD_PENDING: + if (count >= 2) { + discardPendingTap(); + state = State.MULTI; + fingers = Math.min(3, count); + swipeStartX = cx; + swipeStartY = cy; + } + break; + case MULTI: + if (count <= 1) { + state = State.DONE; + } else if (Math.min(3, count) != fingers) { + // Only re-baseline if the centroid has shifted significantly + // to avoid cancelling a swipe during a finger transition. + float ddx = cx - swipeStartX; + float ddy = cy - swipeStartY; + if ((ddx * ddx + ddy * ddy) > (touchSlopPx * touchSlopPx)) { + fingers = Math.min(3, count); + swipeStartX = cx; + swipeStartY = cy; + } else { + fingers = Math.min(3, count); + } + } + break; + default: + break; + } + } + + public void onMove(long t, int count, float cx, float cy, float px, float py) { + switch (state) { + case ONE: + case DRAG: + moveSingle(t, px, py); + break; + case HOLD_PENDING: { + float ddx = px - downX; + float ddy = py - downY; + if ((ddx * ddx + ddy * ddy) < (touchSlopPx * touchSlopPx)) { + lastMoveTime = t; + break; + } + cancelTimer(); + tapPending = false; + state = State.DRAG; + listener.onDragStart(); + moveSingle(t, px, py); + break; + } + case MULTI: + swipe(cx, cy); + break; + default: + break; + } + } + + /** The last finger lifted. */ + public void onUp(long t) { + State s = state; + state = State.IDLE; + switch (s) { + case ONE: { + boolean quick = !slopExceeded && (t - downTime) <= tapTimeoutMs; + if (!quick) break; + if (delayedTaps()) { + tapPending = true; + tapUpTime = t; + arm(Timed.TAP_COMMIT, tapDelayMs); + } else { + listener.onClick(); + } + break; + } + case HOLD_PENDING: { + cancelTimer(); + tapPending = false; + if (t - downTime > tapTimeoutMs) { + listener.onDragStart(); + listener.onDragEnd(); + } else { + listener.onClick(); + } + break; + } + case DRAG: + listener.onDragEnd(); + break; + case DONE: + break; + default: + break; + } + } + + public void onCancel() { + if (state == State.DRAG) listener.onDragEnd(); + cancelTimer(); + tapPending = false; + state = State.IDLE; + } + + public void onTimer() { + Timed kind = timed; + timed = Timed.NONE; + switch (kind) { + case TAP_COMMIT: + if (tapPending) { + tapPending = false; + listener.onClick(); + } + break; + case HOLD_CONFIRM: + if (state == State.HOLD_PENDING) { + tapPending = false; + state = State.DRAG; + listener.onDragStart(); + } + break; + default: + break; + } + } + + public boolean isIdle() { + return state == State.IDLE; + } + + private boolean delayedTaps() { + return tapAndDrag && tapDelayMs > 0 && timer != null; + } + + private void arm(Timed kind, long delayMs) { + timed = kind; + if (timer != null) timer.schedule(delayMs); + } + + private void cancelTimer() { + timed = Timed.NONE; + if (timer != null) timer.cancel(); + } + + private void discardPendingTap() { + if (timed == Timed.HOLD_CONFIRM) cancelTimer(); + tapPending = false; + } + + private void moveSingle(long t, float px, float py) { + if (!slopExceeded) { + float ddx = px - downX; + float ddy = py - downY; + if ((ddx * ddx + ddy * ddy) < (touchSlopPx * touchSlopPx)) { + lastMoveTime = t; + return; + } + slopExceeded = true; + firstMoveAfterSlop = true; + } + float dx = px - lastPx; + float dy = py - lastPy; + long dt = Math.max(1, Math.min(64, t - lastMoveTime)); + float gain = sensitivity; + if (acceleration && !firstMoveAfterSlop) { + float speed = (float) Math.sqrt(dx * dx + dy * dy) / dt; + gain *= 1f + 1.5f * Math.min(speed / 1.5f, 1f); + } + firstMoveAfterSlop = false; + lastPx = px; + lastPy = py; + lastMoveTime = t; + if (dx != 0f || dy != 0f) listener.onMove(dx * gain, dy * gain); + } + + private void swipe(float cx, float cy) { + if (swipeFired) return; + float dx = cx - swipeStartX; + float dy = cy - swipeStartY; + if (Math.max(Math.abs(dx), Math.abs(dy)) < swipeThresholdPx) return; + swipeFired = true; + SwipeDirection dir; + if (Math.abs(dy) >= Math.abs(dx)) dir = dy < 0 ? SwipeDirection.UP : SwipeDirection.DOWN; + else dir = dx < 0 ? SwipeDirection.LEFT : SwipeDirection.RIGHT; + listener.onSwipe(fingers, dir); + } +} diff --git a/app/src/main/java/ru/vasys/keycursor/core/Settings.java b/app/src/main/java/ru/vasys/keycursor/core/Settings.java index 0a02448..705794a 100644 --- a/app/src/main/java/ru/vasys/keycursor/core/Settings.java +++ b/app/src/main/java/ru/vasys/keycursor/core/Settings.java @@ -28,7 +28,28 @@ public final class Settings { private static final String INVERT_Y_LANDSCAPE = "invert_y_landscape"; private static final String BLEND_DIRECTIONS = "blend_directions"; private static final String CAPTURE_MOUSE_KEY_VISIBLE = "capture_mouse_key_visible"; + private static final String PAD_FULL_SCREEN = "pad_full_screen"; + private static final String PAD_LEFT = "pad_left_f"; + private static final String PAD_TOP = "pad_top_f"; + private static final String PAD_WIDTH = "pad_width_f"; + private static final String PAD_HEIGHT = "pad_height_f"; + private static final String PAD_SENSITIVITY = "pad_sensitivity_pct"; + private static final String PAD_ACCELERATION = "pad_acceleration"; + private static final String PAD_TAP_AND_DRAG = "pad_tap_and_drag"; + private static final String PAD_TAP_DELAY = "pad_tap_delay_ms"; + private static final String PAD_OPACITY = "pad_opacity_pct"; + // Three-finger keys keep their original name so existing choices survive the upgrade. + private static final String PAD_SWIPE3_PREFIX = "pad_swipe."; + private static final String PAD_SWIPE2_PREFIX = "pad_swipe2."; private static final int CURRENT_CURSOR_SET_VERSION = 3; + public static final int MIN_PAD_SENSITIVITY = 50; + public static final int MAX_PAD_SENSITIVITY = 400; + public static final int MIN_PAD_TAP_DELAY_MS = 0; + public static final int MAX_PAD_TAP_DELAY_MS = 500; + /** The movable pad must stay visible so it can be found; the full-screen pad may be invisible. */ + public static final int MIN_PAD_OPACITY_MOVABLE = 5; + public static final int MIN_PAD_OPACITY_FULL_SCREEN = 0; + public static final int MAX_PAD_OPACITY = 60; public static final int DEFAULT_CURSOR_SIZE_DP = 42; public static final int MIN_CURSOR_SIZE_DP = 24; public static final int MAX_CURSOR_SIZE_DP = 72; @@ -300,6 +321,130 @@ public void setCaptureMouseKeyVisible(boolean enabled) { prefs.edit().putBoolean(CAPTURE_MOUSE_KEY_VISIBLE, enabled).apply(); } + // ---- touchpad ---- + + public boolean padFullScreen() { + return prefs.getBoolean(PAD_FULL_SCREEN, false); + } + + public void setPadFullScreen(boolean enabled) { + prefs.edit().putBoolean(PAD_FULL_SCREEN, enabled).apply(); + } + + /** Pad rectangle as fractions of the screen: {left, top, width, height}. */ + public float[] padRect() { + float w = clampF(prefs.getFloat(PAD_WIDTH, 0.9f), 0.15f, 1f); + float h = clampF(prefs.getFloat(PAD_HEIGHT, 0.4f), 0.1f, 1f); + float l = clampF(prefs.getFloat(PAD_LEFT, 0.05f), 0f, 1f - w); + float t = clampF(prefs.getFloat(PAD_TOP, 0.55f), 0f, 1f - h); + return new float[]{l, t, w, h}; + } + + public void setPadRect(float left, float top, float width, float height) { + prefs.edit() + .putFloat(PAD_LEFT, left) + .putFloat(PAD_TOP, top) + .putFloat(PAD_WIDTH, width) + .putFloat(PAD_HEIGHT, height) + .apply(); + } + + public void resetPadRect() { + prefs.edit() + .remove(PAD_LEFT).remove(PAD_TOP).remove(PAD_WIDTH).remove(PAD_HEIGHT) + .apply(); + } + + public int padSensitivityPercent() { + return clampI(prefs.getInt(PAD_SENSITIVITY, 160), MIN_PAD_SENSITIVITY, MAX_PAD_SENSITIVITY); + } + + public void setPadSensitivityPercent(int value) { + prefs.edit().putInt(PAD_SENSITIVITY, clampI(value, MIN_PAD_SENSITIVITY, MAX_PAD_SENSITIVITY)).apply(); + } + + public boolean padAcceleration() { + return prefs.getBoolean(PAD_ACCELERATION, true); + } + + public void setPadAcceleration(boolean enabled) { + prefs.edit().putBoolean(PAD_ACCELERATION, enabled).apply(); + } + + public boolean padTapAndDrag() { + return prefs.getBoolean(PAD_TAP_AND_DRAG, true); + } + + public void setPadTapAndDrag(boolean enabled) { + prefs.edit().putBoolean(PAD_TAP_AND_DRAG, enabled).apply(); + } + + /** + * How long (ms) a tap waits before it clicks, so a second touch can turn it into a hold/drag + * without the first tap having clicked. 0 = click immediately (no tap-then-drag). + */ + public int padTapDelayMs() { + return clampI(prefs.getInt(PAD_TAP_DELAY, 150), MIN_PAD_TAP_DELAY_MS, MAX_PAD_TAP_DELAY_MS); + } + + public void setPadTapDelayMs(int value) { + prefs.edit().putInt(PAD_TAP_DELAY, clampI(value, MIN_PAD_TAP_DELAY_MS, MAX_PAD_TAP_DELAY_MS)).apply(); + } + + /** Lowest opacity allowed for the current pad area. */ + public int padOpacityMinPercent() { + return padFullScreen() ? MIN_PAD_OPACITY_FULL_SCREEN : MIN_PAD_OPACITY_MOVABLE; + } + + /** + * Effective opacity for the current area. The stored value is kept as chosen (so 0% on the + * full-screen pad survives a trip through the movable pad) and only clamped when read. + */ + public int padOpacityPercent() { + return clampI(prefs.getInt(PAD_OPACITY, 20), padOpacityMinPercent(), MAX_PAD_OPACITY); + } + + public void setPadOpacityPercent(int value) { + prefs.edit().putInt(PAD_OPACITY, clampI(value, MIN_PAD_OPACITY_FULL_SCREEN, MAX_PAD_OPACITY)).apply(); + } + + /** Three-finger swipes do nothing until the user assigns an action; two-finger have defaults. */ + public static PadAction defaultSwipeAction(int fingers, SwipeDirection direction) { + if (fingers != 2) return PadAction.NONE; + switch (direction) { + case UP: return PadAction.HOME; + case DOWN: return PadAction.NOTIFICATIONS; + case LEFT: return PadAction.BACK; + case RIGHT: return PadAction.RECENTS; + default: return PadAction.NONE; + } + } + + private static String swipeKey(int fingers, SwipeDirection direction) { + return (fingers == 2 ? PAD_SWIPE2_PREFIX : PAD_SWIPE3_PREFIX) + direction.name(); + } + + /** @param fingers 2 or 3 */ + public PadAction padSwipeAction(int fingers, SwipeDirection direction) { + PadAction stored = PadAction.fromName( + prefs.getString(swipeKey(fingers, direction), null), + defaultSwipeAction(fingers, direction)); + return stored.isAvailable() ? stored : PadAction.NONE; + } + + /** @param fingers 2 or 3 */ + public void setPadSwipeAction(int fingers, SwipeDirection direction, PadAction action) { + prefs.edit().putString(swipeKey(fingers, direction), action.name()).apply(); + } + + private static int clampI(int v, int min, int max) { + return Math.max(min, Math.min(max, v)); + } + + private static float clampF(float v, float min, float max) { + return Math.max(min, Math.min(max, v)); + } + public boolean wrapEdges() { return prefs.getBoolean(WRAP_EDGES, false); } diff --git a/app/src/main/java/ru/vasys/keycursor/core/SwipeDirection.java b/app/src/main/java/ru/vasys/keycursor/core/SwipeDirection.java new file mode 100644 index 0000000..e9d6149 --- /dev/null +++ b/app/src/main/java/ru/vasys/keycursor/core/SwipeDirection.java @@ -0,0 +1,15 @@ +package ru.vasys.keycursor.core; + +/** Direction of a multi-finger swipe on the touchpad (the direction the fingers travel). */ +public enum SwipeDirection { + UP("Swipe up"), + DOWN("Swipe down"), + LEFT("Swipe left"), + RIGHT("Swipe right"); + + public final String label; + + SwipeDirection(String label) { + this.label = label; + } +} diff --git a/app/src/main/java/ru/vasys/keycursor/input/ExternalTriggerReceiver.java b/app/src/main/java/ru/vasys/keycursor/input/ExternalTriggerReceiver.java index 403b122..96a52d4 100644 --- a/app/src/main/java/ru/vasys/keycursor/input/ExternalTriggerReceiver.java +++ b/app/src/main/java/ru/vasys/keycursor/input/ExternalTriggerReceiver.java @@ -34,6 +34,7 @@ public void onReceive(Context context, Intent intent) { if ((PREFIX + "action.MOUSE_TOGGLE").equals(action)) { service.externalMouseToggle(); return; } if ((PREFIX + "action.MODE_TOGGLE").equals(action)) { service.externalModeToggle(); return; } + if ((PREFIX + "action.PAD_TOGGLE").equals(action)) { service.externalPadToggle(); return; } if ((PREFIX + "action.MOUSE_KEY_SHORT").equals(action)) { service.externalTriggerShort(); return; } if ((PREFIX + "action.MOUSE_KEY_LONG").equals(action)) { service.externalTriggerLong(); return; } if ((PREFIX + "action.MOUSE_KEY_DOWN").equals(action)) { service.externalDown(MouseAction.TRIGGER); return; } diff --git a/app/src/main/java/ru/vasys/keycursor/service/ContinuousTouchController.java b/app/src/main/java/ru/vasys/keycursor/service/ContinuousTouchController.java index 69ca4c3..fac2871 100644 --- a/app/src/main/java/ru/vasys/keycursor/service/ContinuousTouchController.java +++ b/app/src/main/java/ru/vasys/keycursor/service/ContinuousTouchController.java @@ -12,7 +12,22 @@ * does not become a sequence of independent swipes. */ public final class ContinuousTouchController { + /** + * Notified when a virtual finger goes down / is fully released, so the touchpad window can + * stop intercepting touches while the injected stroke targets a point beneath it. + */ + public interface StrokeListener { + /** + * A virtual finger is about to go down at (x, y). Returns how many milliseconds the first + * injected touch should wait (0 for none), e.g. for a window flag change to take effect. + */ + long onStrokeBegin(float x, float y); + + void onStrokeEnd(); + } + private final AccessibilityService service; + private StrokeListener strokeListener; private final Handler main = new Handler(Looper.getMainLooper()); private GestureDescription.StrokeDescription stroke; @@ -30,6 +45,21 @@ public ContinuousTouchController(AccessibilityService service) { this.service = service; } + public void setStrokeListener(StrokeListener listener) { + strokeListener = listener; + } + + private long notifyBegin(float x, float y) { + return strokeListener == null ? 0L : Math.max(0L, strokeListener.onStrokeBegin(x, y)); + } + + /** Reports the end of the stroke only once nothing is pending or in flight. */ + private void notifyEndIfIdle() { + if (strokeListener != null && !requestedDown && !busy && stroke == null) { + strokeListener.onStrokeEnd(); + } + } + public void press(float x, float y) { runOnMain(() -> { targetX = x; @@ -58,6 +88,7 @@ public void release() { requestedDown = false; restartAfterRelease = false; if (!busy && stroke != null) dispatchRelease(); + else notifyEndIfIdle(); }); } @@ -96,7 +127,14 @@ private void dispatchStart(float x, float y) { currentY = y; dispatchedEndX = x; dispatchedEndY = y; - dispatch(next, true); + long settleMs = notifyBegin(x, y); + if (settleMs > 0) { + // Hold the controller busy so moves/releases queue up behind the first touch. + busy = true; + main.postDelayed(() -> dispatch(next, true), settleMs); + } else { + dispatch(next, true); + } } private void dispatchMove() { @@ -140,6 +178,8 @@ public void onCompleted(GestureDescription gestureDescription) { if (requestedDown && restartAfterRelease) { restartAfterRelease = false; dispatchStart(targetX, targetY); + } else { + notifyEndIfIdle(); } return; } @@ -160,12 +200,14 @@ public void onCancelled(GestureDescription gestureDescription) { busy = false; stroke = null; restartAfterRelease = false; + notifyEndIfIdle(); } }, main); if (!accepted) { busy = false; stroke = null; restartAfterRelease = false; + notifyEndIfIdle(); } } diff --git a/app/src/main/java/ru/vasys/keycursor/service/MouseAccessibilityService.java b/app/src/main/java/ru/vasys/keycursor/service/MouseAccessibilityService.java index 5b5a464..84741f5 100644 --- a/app/src/main/java/ru/vasys/keycursor/service/MouseAccessibilityService.java +++ b/app/src/main/java/ru/vasys/keycursor/service/MouseAccessibilityService.java @@ -2,6 +2,7 @@ import android.accessibilityservice.AccessibilityService; import android.content.res.Configuration; +import android.os.Build; import android.os.Handler; import android.os.Looper; import android.view.KeyEvent; @@ -20,7 +21,10 @@ import ru.vasys.keycursor.core.DpadKeyNormalizer; import ru.vasys.keycursor.core.MouseAction; import ru.vasys.keycursor.core.MouseMode; +import ru.vasys.keycursor.core.PadAction; +import ru.vasys.keycursor.core.PadGestureEngine; import ru.vasys.keycursor.core.Settings; +import ru.vasys.keycursor.core.SwipeDirection; /** * Accessibility is used as the gesture backend and hardware-key router. @@ -33,6 +37,7 @@ public final class MouseAccessibilityService extends AccessibilityService { private static volatile MouseAccessibilityService runningInstance; private static final long TICK_MS = 16; private static final long SCROLL_REPEAT_MS = 160; + private static final long PASS_THROUGH_SETTLE_MS = 40; private final Handler main = new Handler(Looper.getMainLooper()); private final EnumSet pressed = EnumSet.noneOf(MouseAction.class); @@ -41,6 +46,7 @@ public final class MouseAccessibilityService extends AccessibilityService { private Settings settings; private PointerController pointer; + private TouchpadOverlay pad; private Map bindings; private boolean tickerRunning; private boolean calibrationMode; @@ -94,6 +100,25 @@ protected void onServiceConnected() { reloadDirectionPreferences(); captureMouseKeyVisible = settings.captureMouseKeyVisible(); refreshDisplayRotation(); + + pad = new TouchpadOverlay(this, settings, padListener); + pointer.setStrokeListener(new ContinuousTouchController.StrokeListener() { + @Override + public long onStrokeBegin(float x, float y) { + if (pad == null) return 0L; + // Only step aside when the injected touch lands on the pad itself. A window flag + // change is applied on the next layout pass, so delay the touch until it has. + boolean inside = pad.containsScreenPoint(x, y); + boolean changed = pad.setPassThrough(inside); + return changed && inside ? PASS_THROUGH_SETTLE_MS : 0L; + } + + @Override + public void onStrokeEnd() { + if (pad != null) pad.setPassThrough(false); + } + }); + PadTileService.requestRefresh(this); } @Override @@ -107,20 +132,26 @@ public void onConfigurationChanged(Configuration newConfig) { super.onConfigurationChanged(newConfig); refreshDisplayRotation(); if (pointer != null) pointer.refreshDisplayBounds(); + if (pad != null) pad.refreshDisplayBounds(); } @Override public void onInterrupt() { resetInputState(); + if (pad != null) pad.hide(); if (pointer != null) pointer.hide(); + PadTileService.requestRefresh(this); } @Override public void onDestroy() { runningInstance = null; resetInputState(); + if (pad != null) pad.destroy(); + pad = null; if (pointer != null) pointer.destroy(); pointer = null; + PadTileService.requestRefresh(this); if (feedbackToast != null) feedbackToast.cancel(); feedbackToast = null; super.onDestroy(); @@ -281,9 +312,128 @@ public void reloadSettings() { pointer.setCursorColor(settings.cursorColor()); pointer.setCursorSizeDp(settings.cursorSizeDp()); } + if (pad != null) pad.applySettings(); }); } + // ---- touchpad ---- + + private final PadGestureEngine.Listener padListener = new PadGestureEngine.Listener() { + @Override + public void onMove(float dx, float dy) { + if (pointer != null && pointer.isVisible()) pointer.move(dx, dy); + } + + @Override + public void onClick() { + if (pointer == null || !pointer.isVisible()) return; + pointer.leftDown(); + pointer.leftUp(); + } + + @Override + public void onDragStart() { + if (pointer != null && pointer.isVisible()) pointer.leftDown(); + } + + @Override + public void onDragEnd() { + if (pointer != null) pointer.leftUp(); + } + + @Override + public void onSwipe(int fingers, SwipeDirection direction) { + if (settings != null) performPadAction(settings.padSwipeAction(fingers, direction)); + } + }; + + public boolean isPadVisible() { + return pad != null && pad.isShown(); + } + + /** Shows or hides the touchpad together with the cursor. */ + public void setPadVisible(boolean visible) { + if (pad == null || pointer == null) return; + if (visible == pad.isShown()) return; + cancelTriggerTapState(); + if (visible) { + if (!pointer.isVisible()) pointer.show(); + pad.show(); + if (!pad.isShown()) { + // The overlay window could not be added; do not leave a lone cursor behind. + pointer.hide(); + showToast("Touchpad unavailable"); + } + } else { + pad.hide(); + pressed.clear(); + stopTicker(); + if (pointer.isVisible()) pointer.hide(); + } + PadTileService.requestRefresh(this); + } + + /** Quick Settings tile entry point: toggles the pad and closes the shade when turning it on. */ + public void togglePadFromTile() { + main.post(() -> { + boolean turnOn = !isPadVisible(); + setPadVisible(turnOn); + if (turnOn && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + performGlobalAction(GLOBAL_ACTION_DISMISS_NOTIFICATION_SHADE); + } + }); + } + + public void externalPadToggle() { + main.post(() -> setPadVisible(!isPadVisible())); + } + + private void performPadAction(PadAction action) { + if (action == null) return; + switch (action) { + case BACK: + performGlobalAction(GLOBAL_ACTION_BACK); + break; + case HOME: + performGlobalAction(GLOBAL_ACTION_HOME); + break; + case RECENTS: + performGlobalAction(GLOBAL_ACTION_RECENTS); + break; + case NOTIFICATIONS: + performGlobalAction(GLOBAL_ACTION_NOTIFICATIONS); + break; + case QUICK_SETTINGS: + performGlobalAction(GLOBAL_ACTION_QUICK_SETTINGS); + break; + case POWER_DIALOG: + performGlobalAction(GLOBAL_ACTION_POWER_DIALOG); + break; + case LOCK_SCREEN: + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + performGlobalAction(GLOBAL_ACTION_LOCK_SCREEN); + } + break; + case SCREENSHOT: + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + performGlobalAction(GLOBAL_ACTION_TAKE_SCREENSHOT); + } + break; + case TOGGLE_HOLD: + selectNextNormalHoldMode(); + break; + case NEXT_CURSOR: + cycleCursor(); + break; + case HIDE_PAD: + setPadVisible(false); + break; + case NONE: + default: + break; + } + } + private MouseAction findAction(KeyEvent event) { if (bindings == null) return null; @@ -418,6 +568,11 @@ private void toggleMouseVisibility() { stopTicker(); } pointer.toggleVisibility(); + if (!pointer.isVisible() && pad != null && pad.isShown()) { + // A hidden cursor with a live pad would be a dead surface that eats touches. + pad.hide(); + PadTileService.requestRefresh(this); + } showToast(pointer.isVisible() ? "Mouse: on · Normal" : "Mouse: off"); } diff --git a/app/src/main/java/ru/vasys/keycursor/service/PadTileService.java b/app/src/main/java/ru/vasys/keycursor/service/PadTileService.java new file mode 100644 index 0000000..62f049b --- /dev/null +++ b/app/src/main/java/ru/vasys/keycursor/service/PadTileService.java @@ -0,0 +1,66 @@ +package ru.vasys.keycursor.service; + +import android.app.PendingIntent; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.os.Build; +import android.service.quicksettings.Tile; +import android.service.quicksettings.TileService; + +import ru.vasys.keycursor.ui.MainActivity; + +/** + * Quick Settings tile that shows or hides the touchpad. The tile is ACTIVE while the pad is up. + * If the accessibility service is not running, tapping the tile opens KeyCursor so it can be + * enabled. + */ +public final class PadTileService extends TileService { + + /** Asks the system to re-run {@link #onStartListening()} so the tile reflects the pad state. */ + public static void requestRefresh(Context context) { + try { + TileService.requestListeningState(context.getApplicationContext(), + new ComponentName(context.getApplicationContext(), PadTileService.class)); + } catch (RuntimeException ignored) { + // The tile may not be added by the user; nothing to refresh then. + } + } + + @Override + public void onStartListening() { + super.onStartListening(); + updateTile(); + } + + @Override + public void onClick() { + super.onClick(); + MouseAccessibilityService service = MouseAccessibilityService.getRunningInstance(); + if (service == null) { + openApp(); + return; + } + // The service refreshes the tile itself once the pad has actually changed state. + service.togglePadFromTile(); + } + + private void updateTile() { + Tile tile = getQsTile(); + if (tile == null) return; + MouseAccessibilityService service = MouseAccessibilityService.getRunningInstance(); + tile.setState(service != null && service.isPadVisible() ? Tile.STATE_ACTIVE : Tile.STATE_INACTIVE); + tile.updateTile(); + } + + @SuppressWarnings("deprecation") + private void openApp() { + Intent intent = new Intent(this, MainActivity.class).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + if (Build.VERSION.SDK_INT >= 34) { + startActivityAndCollapse(PendingIntent.getActivity( + this, 0, intent, PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT)); + } else { + startActivityAndCollapse(intent); + } + } +} diff --git a/app/src/main/java/ru/vasys/keycursor/service/PointerController.java b/app/src/main/java/ru/vasys/keycursor/service/PointerController.java index b6952ae..dc7e2ee 100644 --- a/app/src/main/java/ru/vasys/keycursor/service/PointerController.java +++ b/app/src/main/java/ru/vasys/keycursor/service/PointerController.java @@ -41,6 +41,10 @@ public boolean scroll(float dx, float dy) { return scroller.scrollAt(overlay.screenX(), overlay.screenY(), dx, dy); } + public void setStrokeListener(ContinuousTouchController.StrokeListener listener) { + touch.setStrokeListener(listener); + } + public void leftDown() { if (!overlay.isVisible() || leftHeld) return; leftHeld = true; diff --git a/app/src/main/java/ru/vasys/keycursor/service/TouchpadOverlay.java b/app/src/main/java/ru/vasys/keycursor/service/TouchpadOverlay.java new file mode 100644 index 0000000..ae01350 --- /dev/null +++ b/app/src/main/java/ru/vasys/keycursor/service/TouchpadOverlay.java @@ -0,0 +1,224 @@ +package ru.vasys.keycursor.service; + +import android.accessibilityservice.AccessibilityService; +import android.content.Context; +import android.graphics.PixelFormat; +import android.util.DisplayMetrics; +import android.view.Gravity; +import android.view.WindowManager; + +import ru.vasys.keycursor.core.PadGestureEngine; +import ru.vasys.keycursor.core.Settings; +import ru.vasys.keycursor.view.TouchpadView; + +/** + * Hosts the touchpad surface in its own accessibility-overlay window: either the whole screen or a + * movable / resizable rectangle (geometry is stored as screen fractions so it survives rotation). + * + * Injected gestures are hit-tested like real touches, so a touchable pad sitting on top of the + * cursor would swallow the clicks KeyCursor injects. While an injected stroke is active and its + * target lies inside the pad, the window is made FLAG_NOT_TOUCHABLE (see {@link #setPassThrough}). + */ +public final class TouchpadOverlay implements TouchpadView.Host { + private static final int MIN_WIDTH_DP = 140; + private static final int MIN_HEIGHT_DP = 100; + + private final AccessibilityService service; + private final WindowManager wm; + private final Settings settings; + private final PadGestureEngine engine; + private final TouchpadView view; + private final WindowManager.LayoutParams params; + private final float density; + + private boolean shown; + private boolean added; + private boolean passThrough; + private boolean fullScreen; + + private int screenW = 1; + private int screenH = 1; + // Pad rectangle in screen pixels (floats so slow drags do not lose sub-pixel movement). + private float padX; + private float padY; + private float padW; + private float padH; + + public TouchpadOverlay(AccessibilityService service, Settings settings, + PadGestureEngine.Listener listener) { + this.service = service; + this.settings = settings; + wm = (WindowManager) service.getSystemService(Context.WINDOW_SERVICE); + density = service.getResources().getDisplayMetrics().density; + engine = new PadGestureEngine(listener); + view = new TouchpadView(service, engine, this); + params = new WindowManager.LayoutParams( + WindowManager.LayoutParams.MATCH_PARENT, + WindowManager.LayoutParams.MATCH_PARENT, + WindowManager.LayoutParams.TYPE_ACCESSIBILITY_OVERLAY, + baseFlags(), + PixelFormat.TRANSLUCENT); + params.gravity = Gravity.TOP | Gravity.LEFT; + applySettings(); + } + + public boolean isShown() { + return shown && added; + } + + public void show() { + if (shown) return; + refreshScreenSize(); + applySettings(); + try { + wm.addView(view, params); + added = true; + shown = true; + } catch (RuntimeException e) { + added = false; + shown = false; + } + } + + public void hide() { + if (!shown && !added) return; + // Release a held drag so no virtual finger is left down. + engine.onCancel(); + shown = false; + passThrough = false; + if (added) { + try { + wm.removeViewImmediate(view); + } catch (RuntimeException ignored) { + } + added = false; + } + } + + public void destroy() { + hide(); + } + + /** Re-reads every touchpad preference. Safe to call whether or not the pad is showing. */ + public void applySettings() { + fullScreen = settings.padFullScreen(); + + engine.sensitivity = settings.padSensitivityPercent() / 100f; + engine.acceleration = settings.padAcceleration(); + engine.tapAndDrag = settings.padTapAndDrag(); + engine.tapDelayMs = settings.padTapDelayMs(); + engine.touchSlopPx = 8f * density; + engine.swipeThresholdPx = 56f * density; + + view.setRectMode(!fullScreen); + view.setOpacity(settings.padOpacityPercent() / 100f); + + refreshScreenSize(); + loadRectFromSettings(); + pushLayout(); + } + + /** Call after a rotation / configuration change. */ + public void refreshDisplayBounds() { + refreshScreenSize(); + loadRectFromSettings(); + pushLayout(); + } + + /** + * While true the pad ignores touches so an injected gesture can reach the app beneath it. + * Idempotent; only touches the window when the state actually changes. + * + * @return true if the state changed (the new flag needs a frame or two to take effect) + */ + public boolean setPassThrough(boolean value) { + if (passThrough == value) return false; + passThrough = value; + pushLayout(); + return true; + } + + /** True if the screen point lies on the touchable pad surface. */ + public boolean containsScreenPoint(float sx, float sy) { + if (!isShown()) return false; + if (fullScreen) return true; + return sx >= padX && sx <= padX + padW && sy >= padY && sy <= padY + padH; + } + + // ---- TouchpadView.Host ---- + + @Override + public void onPadDrag(int chromeMode, float rawDx, float rawDy) { + if (fullScreen) return; + if (chromeMode == TouchpadView.CHROME_MOVE) { + padX += rawDx; + padY += rawDy; + } else if (chromeMode == TouchpadView.CHROME_RESIZE) { + padW += rawDx; + padH += rawDy; + } + clampRect(); + pushLayout(); + } + + @Override + public void onPadDragEnd(int chromeMode) { + if (fullScreen) return; + settings.setPadRect(padX / screenW, padY / screenH, padW / screenW, padH / screenH); + } + + // ---- internals ---- + + private static int baseFlags() { + return WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE + | WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL + | WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN; + } + + private void pushLayout() { + params.flags = baseFlags() + | (passThrough ? WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE : 0); + if (fullScreen) { + params.width = WindowManager.LayoutParams.MATCH_PARENT; + params.height = WindowManager.LayoutParams.MATCH_PARENT; + params.x = 0; + params.y = 0; + } else { + params.width = Math.round(padW); + params.height = Math.round(padH); + params.x = Math.round(padX); + params.y = Math.round(padY); + } + if (!added) return; + try { + wm.updateViewLayout(view, params); + } catch (RuntimeException ignored) { + } + } + + private void loadRectFromSettings() { + float[] f = settings.padRect(); + padX = f[0] * screenW; + padY = f[1] * screenH; + padW = f[2] * screenW; + padH = f[3] * screenH; + clampRect(); + } + + private void clampRect() { + float minW = Math.min(MIN_WIDTH_DP * density, screenW); + float minH = Math.min(MIN_HEIGHT_DP * density, screenH); + padW = Math.max(minW, Math.min(screenW, padW)); + padH = Math.max(minH, Math.min(screenH, padH)); + padX = Math.max(0f, Math.min(screenW - padW, padX)); + padY = Math.max(0f, Math.min(screenH - padH, padY)); + } + + @SuppressWarnings("deprecation") + private void refreshScreenSize() { + DisplayMetrics m = new DisplayMetrics(); + wm.getDefaultDisplay().getRealMetrics(m); + screenW = Math.max(1, m.widthPixels); + screenH = Math.max(1, m.heightPixels); + } +} diff --git a/app/src/main/java/ru/vasys/keycursor/ui/MainActivity.java b/app/src/main/java/ru/vasys/keycursor/ui/MainActivity.java index dfce237..69625c6 100644 --- a/app/src/main/java/ru/vasys/keycursor/ui/MainActivity.java +++ b/app/src/main/java/ru/vasys/keycursor/ui/MainActivity.java @@ -31,8 +31,12 @@ import android.widget.TextView; import android.widget.Toast; +import java.util.ArrayList; import java.util.EnumMap; +import java.util.List; import java.util.Map; +import java.util.function.IntConsumer; +import java.util.function.IntSupplier; import ru.vasys.keycursor.R; import ru.vasys.keycursor.core.Binding; @@ -40,6 +44,8 @@ import ru.vasys.keycursor.core.CursorColor; import ru.vasys.keycursor.core.CursorStyle; import ru.vasys.keycursor.core.MouseAction; +import ru.vasys.keycursor.core.PadAction; +import ru.vasys.keycursor.core.SwipeDirection; import ru.vasys.keycursor.input.ExternalTriggerReceiver; import ru.vasys.keycursor.service.MouseAccessibilityService; import ru.vasys.keycursor.view.CursorGlyphView; @@ -74,6 +80,13 @@ public final class MainActivity extends Activity { private boolean refreshingCursorColor; private boolean cursorSizeTracking; private int pendingCursorSizeDp; + private Button padAreaButton; + private Switch padAccelerationCheck; + private Switch padTapAndDragCheck; + private boolean refreshingPad; + private final List padSeeks = new ArrayList<>(); + private final Map> padSwipeSpinners = new java.util.TreeMap<>(); + private final List padActionChoices = new ArrayList<>(); private final Map bindingButtons = new EnumMap<>(MouseAction.class); private final Map cursorChecks = new EnumMap<>(CursorStyle.class); @@ -144,12 +157,13 @@ private View buildUi() { title.setPadding(dp(2), 0, 0, 0); root.addView(title); - TextView subtitle = sectionDescription("Keyboard controls, real touch gestures."); + TextView subtitle = sectionDescription("Keyboard and touchpad controls, real touch gestures."); subtitle.setTextSize(13); subtitle.setPadding(dp(2), 0, 0, dp(8)); root.addView(subtitle); root.addView(buildAccessibilityCard()); + root.addView(buildTouchpadCard()); root.addView(buildPointerCard()); root.addView(buildBindingsCard()); root.addView(buildDirectionCard()); @@ -297,6 +311,224 @@ public void onNothingSelected(AdapterView parent) { return spinner; } + private View buildTouchpadCard() { + LinearLayout card = card( + "Touchpad", + "Use the screen, or part of it, as a touchpad. Show or hide it with the Touchpad tile in " + + "Quick Settings. One finger moves, a tap clicks, tap then hold and drag to scroll or " + + "drag, and two or three fingers swipe to run actions."); + + Button toggle = button("Show / hide touchpad now"); + toggle.setOnClickListener(v -> { + MouseAccessibilityService service = MouseAccessibilityService.getRunningInstance(); + if (service == null) { + showToast("Enable the KeyCursor input service first"); + return; + } + service.externalPadToggle(); + }); + card.addView(toggle); + + padAreaButton = button(""); + LinearLayout.LayoutParams areaParams = new LinearLayout.LayoutParams(-1, -2); + areaParams.topMargin = dp(8); + padAreaButton.setLayoutParams(areaParams); + padAreaButton.setOnClickListener(v -> { + settings.setPadFullScreen(!settings.padFullScreen()); + // The opacity range depends on the area, so every slider is re-read. + refreshTouchpad(); + notifyService(); + }); + card.addView(padAreaButton); + card.addView(sectionDescription( + "Pad mode: drag the bar at the top to move the pad and the corner grip to resize it.")); + + Button resetPad = button("Reset pad position and size"); + resetPad.setOnClickListener(v -> { + settings.resetPadRect(); + notifyService(); + showToast("Pad position reset"); + }); + card.addView(resetPad); + + card.addView(subheading("Pointer")); + padSeeks.clear(); + addPadSeek(card, "Sensitivity", "%", + () -> ru.vasys.keycursor.core.Settings.MIN_PAD_SENSITIVITY, + ru.vasys.keycursor.core.Settings.MAX_PAD_SENSITIVITY, + settings::padSensitivityPercent, settings::setPadSensitivityPercent); + padAccelerationCheck = optionSwitch("Pointer acceleration"); + padAccelerationCheck.setOnCheckedChangeListener((b, checked) -> { + if (refreshingPad || settings.padAcceleration() == checked) return; + settings.setPadAcceleration(checked); + notifyService(); + }); + card.addView(padAccelerationCheck); + addPadSeek(card, "Pad opacity", "%", + settings::padOpacityMinPercent, + ru.vasys.keycursor.core.Settings.MAX_PAD_OPACITY, + settings::padOpacityPercent, settings::setPadOpacityPercent); + card.addView(sectionDescription( + "The movable pad stays at least 5% visible so you can always find it. " + + "The full-screen pad can go down to 0% (invisible).")); + + card.addView(subheading("Tap, hold and drag")); + padTapAndDragCheck = optionSwitch("Tap, then hold to drag / scroll"); + padTapAndDragCheck.setOnCheckedChangeListener((b, checked) -> { + if (refreshingPad || settings.padTapAndDrag() == checked) return; + settings.setPadTapAndDrag(checked); + notifyService(); + }); + card.addView(padTapAndDragCheck); + addPadSeek(card, "Tap delay", " ms", + () -> ru.vasys.keycursor.core.Settings.MIN_PAD_TAP_DELAY_MS, + ru.vasys.keycursor.core.Settings.MAX_PAD_TAP_DELAY_MS, + settings::padTapDelayMs, settings::setPadTapDelayMs); + card.addView(sectionDescription( + "Every tap waits this long before it clicks. Touch again inside that time and hold " + + "or drag, and the first tap is ignored. That second touch is a real drag, so " + + "it scrolls lists. 0 = click instantly (tap-then-hold is unavailable).")); + + padActionChoices.clear(); + for (PadAction action : PadAction.values()) { + if (action.isAvailable()) padActionChoices.add(action); + } + addSwipeSection(card, 2, "Two-finger swipe"); + addSwipeSection(card, 3, "Three-finger swipe"); + return card; + } + + private void addSwipeSection(LinearLayout card, int fingers, String title) { + card.addView(subheading(title)); + card.addView(sectionDescription("Choose what each swipe direction does.")); + String[] labels = new String[padActionChoices.size()]; + for (int i = 0; i < labels.length; i++) labels[i] = padActionChoices.get(i).label; + Map spinners = new EnumMap<>(SwipeDirection.class); + padSwipeSpinners.put(fingers, spinners); + for (SwipeDirection direction : SwipeDirection.values()) { + card.addView(settingLabel(direction.label)); + Spinner spinner = new Spinner(this); + ArrayAdapter adapter = + new ArrayAdapter<>(this, android.R.layout.simple_spinner_item, labels); + adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); + spinner.setAdapter(adapter); + spinner.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() { + @Override + public void onItemSelected(AdapterView parent, View view, int position, long id) { + if (refreshingPad || position < 0 || position >= padActionChoices.size()) return; + PadAction selected = padActionChoices.get(position); + if (selected == settings.padSwipeAction(fingers, direction)) return; + settings.setPadSwipeAction(fingers, direction, selected); + notifyService(); + } + + @Override + public void onNothingSelected(AdapterView parent) { + } + }); + spinners.put(direction, spinner); + card.addView(spinner, new LinearLayout.LayoutParams(-1, -2)); + } + } + + private void addPadSeek(LinearLayout card, String title, String unit, IntSupplier minGetter, + int max, IntSupplier getter, IntConsumer setter) { + PadSeek seek = new PadSeek(title, unit, minGetter, max, getter, setter); + padSeeks.add(seek); + card.addView(seek.label); + card.addView(seek.seek); + } + + /** + * A labelled SeekBar bound to one integer touchpad setting; applied when the finger lifts. + * The minimum can change at runtime (opacity depends on the pad area), so it is re-read, and the + * bar re-ranged, on every refresh. + */ + private final class PadSeek { + final String title; + final String unit; + final int max; + final IntSupplier minGetter; + final IntSupplier getter; + final IntConsumer setter; + final TextView label; + final SeekBar seek; + int min; + boolean tracking; + + PadSeek(String title, String unit, IntSupplier minGetter, int max, + IntSupplier getter, IntConsumer setter) { + this.title = title; + this.unit = unit; + this.minGetter = minGetter; + this.max = max; + this.getter = getter; + this.setter = setter; + label = settingLabel(""); + seek = new SeekBar(MainActivity.this); + seek.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() { + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + if (!fromUser) return; + updateLabel(PadSeek.this.min + progress); + if (!tracking) commit(); + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) { + tracking = true; + } + + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + tracking = false; + commit(); + } + }); + refresh(); + } + + void commit() { + PadSeek.this.setter.accept(min + seek.getProgress()); + notifyService(); + } + + void updateLabel(int value) { + label.setText(title + " \u00b7 " + value + unit); + } + + void refresh() { + min = minGetter.getAsInt(); + seek.setMax(max - min); + int value = getter.getAsInt(); + updateLabel(value); + seek.setProgress(value - min); + } + } + + private void refreshTouchpad() { + refreshingPad = true; + try { + if (padAreaButton != null) { + padAreaButton.setText(settings.padFullScreen() + ? "Touchpad area: Full screen (tap to switch)" + : "Touchpad area: Movable pad (tap to switch)"); + } + if (padAccelerationCheck != null) padAccelerationCheck.setChecked(settings.padAcceleration()); + if (padTapAndDragCheck != null) padTapAndDragCheck.setChecked(settings.padTapAndDrag()); + for (PadSeek seek : padSeeks) seek.refresh(); + for (Map.Entry> group : padSwipeSpinners.entrySet()) { + for (Map.Entry entry : group.getValue().entrySet()) { + PadAction current = settings.padSwipeAction(group.getKey(), entry.getKey()); + int position = Math.max(0, padActionChoices.indexOf(current)); + entry.getValue().setSelection(position, false); + } + } + } finally { + refreshingPad = false; + } + } + private View buildDirectionCard() { LinearLayout card = card( "Direction", @@ -560,6 +792,7 @@ private void refreshAll() { refreshCursorSize(); refreshCursorPreviews(); refreshDirectionBehavior(); + refreshTouchpad(); } private void refreshDirectionBehavior() { diff --git a/app/src/main/java/ru/vasys/keycursor/view/TouchpadView.java b/app/src/main/java/ru/vasys/keycursor/view/TouchpadView.java new file mode 100644 index 0000000..1eb1160 --- /dev/null +++ b/app/src/main/java/ru/vasys/keycursor/view/TouchpadView.java @@ -0,0 +1,260 @@ +package ru.vasys.keycursor.view; + +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.RectF; +import android.os.Handler; +import android.os.Looper; +import android.view.MotionEvent; +import android.view.View; + +import ru.vasys.keycursor.core.PadGestureEngine; + +/** + * The touchpad surface. Translates MotionEvents into {@link PadGestureEngine} calls and, in + * "pad" (rectangle) mode, provides a move bar on top and a resize grip in the bottom-right + * corner. Those two chrome areas never reach the gesture engine. + */ +public final class TouchpadView extends View { + public static final int CHROME_NONE = 0; + public static final int CHROME_MOVE = 1; + public static final int CHROME_RESIZE = 2; + + /** Receives pad move/resize drags (deltas in raw screen pixels). */ + public interface Host { + void onPadDrag(int chromeMode, float rawDx, float rawDy); + + void onPadDragEnd(int chromeMode); + } + + private static final int ACCENT = 0x5B7CFA; + + private final PadGestureEngine engine; + private final Host host; + private final Handler timerHandler = new Handler(Looper.getMainLooper()); + private final Runnable timerTick = new Runnable() { + @Override + public void run() { + TouchpadView.this.engine.onTimer(); + } + }; + private final float density; + private final Paint fillPaint = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint strokePaint = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint handlePaint = new Paint(Paint.ANTI_ALIAS_FLAG); + private final RectF rect = new RectF(); + + private boolean rectMode = true; + private float opacity = 0.2f; + + private int primaryId = -1; + + private int chromeMode = CHROME_NONE; + private float chromeLastX; + private float chromeLastY; + + public TouchpadView(Context context, PadGestureEngine engine, Host host) { + super(context); + this.engine = engine; + this.host = host; + engine.setTimer(new PadGestureEngine.Timer() { + @Override + public void schedule(long delayMs) { + timerHandler.removeCallbacks(timerTick); + timerHandler.postDelayed(timerTick, delayMs); + } + + @Override + public void cancel() { + timerHandler.removeCallbacks(timerTick); + } + }); + density = context.getResources().getDisplayMetrics().density; + + fillPaint.setStyle(Paint.Style.FILL); + strokePaint.setStyle(Paint.Style.STROKE); + strokePaint.setStrokeWidth(2f * density); + handlePaint.setStyle(Paint.Style.STROKE); + handlePaint.setStrokeCap(Paint.Cap.ROUND); + handlePaint.setStrokeWidth(3f * density); + handlePaint.setColor(0xFFFFFFFF); + setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_NO); + } + + /** In rect mode the move bar and resize grip are shown; in full-screen mode they are not. */ + public void setRectMode(boolean value) { + if (rectMode == value) return; + rectMode = value; + invalidate(); + } + + /** 0..1 */ + public void setOpacity(float value) { + opacity = Math.max(0f, Math.min(1f, value)); + invalidate(); + } + + @Override + protected void onDraw(Canvas canvas) { + float w = getWidth(); + float h = getHeight(); + float effective = rectMode ? opacity : opacity * 0.5f; + fillPaint.setColor(0xFF000000 | ACCENT); + fillPaint.setAlpha(Math.round(255f * effective)); + strokePaint.setColor(0xFF000000 | ACCENT); + // The movable pad is never fully invisible (minimum opacity is enforced in settings). The + // full-screen outline fades out with the fill so 0% really is invisible. + strokePaint.setAlpha(rectMode ? 200 : Math.round(140f * Math.min(1f, opacity / 0.2f))); + + float inset = strokePaint.getStrokeWidth() / 2f; + rect.set(inset, inset, w - inset, h - inset); + float radius = rectMode ? 18f * density : 0f; + canvas.drawRoundRect(rect, radius, radius, fillPaint); + canvas.drawRoundRect(rect, radius, radius, strokePaint); + + if (!rectMode) return; + handlePaint.setAlpha(210); + // Move bar: a short pill centred near the top. + float barY = 14f * density; + canvas.drawLine(w / 2f - 22f * density, barY, w / 2f + 22f * density, barY, handlePaint); + // Resize grip: two diagonal strokes in the bottom-right corner. + float g = 16f * density; + float m = 12f * density; + canvas.drawLine(w - m - g, h - m, w - m, h - m - g, handlePaint); + canvas.drawLine(w - m - g / 2f, h - m, w - m, h - m - g / 2f, handlePaint); + } + + @Override + public boolean onTouchEvent(MotionEvent e) { + // Ignore events that are not from a physical touch device + if ((e.getSource() & android.view.InputDevice.SOURCE_TOUCHSCREEN) == 0) { + return false; + } + + final int action = e.getActionMasked(); + + if (chromeMode != CHROME_NONE) { + return handleChrome(e, action); + } + if (rectMode && action == MotionEvent.ACTION_DOWN) { + int hit = hitChrome(e.getX(), e.getY()); + if (hit != CHROME_NONE) { + chromeMode = hit; + chromeLastX = e.getRawX(); + chromeLastY = e.getRawY(); + return true; + } + } + + final long t = e.getEventTime(); + switch (action) { + case MotionEvent.ACTION_DOWN: + primaryId = e.getPointerId(0); + engine.onDown(t, e.getX(), e.getY()); + return true; + case MotionEvent.ACTION_POINTER_DOWN: + feedPointerChange(t, e, -1); + return true; + case MotionEvent.ACTION_POINTER_UP: + feedPointerChange(t, e, e.getActionIndex()); + return true; + case MotionEvent.ACTION_MOVE: + resolvePrimary(e, -1); + engine.onMove(t, e.getPointerCount(), + centroidX(e, -1), centroidY(e, -1), primaryX(e), primaryY(e)); + return true; + case MotionEvent.ACTION_UP: + engine.onUp(t); + return true; + case MotionEvent.ACTION_CANCEL: + engine.onCancel(); + return true; + default: + return true; + } + } + + private boolean handleChrome(MotionEvent e, int action) { + switch (action) { + case MotionEvent.ACTION_MOVE: { + float rx = e.getRawX(); + float ry = e.getRawY(); + if (host != null) host.onPadDrag(chromeMode, rx - chromeLastX, ry - chromeLastY); + chromeLastX = rx; + chromeLastY = ry; + return true; + } + case MotionEvent.ACTION_UP: + case MotionEvent.ACTION_CANCEL: { + int mode = chromeMode; + chromeMode = CHROME_NONE; + if (host != null) host.onPadDragEnd(mode); + return true; + } + default: + return true; + } + } + + private int hitChrome(float x, float y) { + float w = getWidth(); + float h = getHeight(); + float grip = 48f * density; + if (x >= w - grip && y >= h - grip) return CHROME_RESIZE; + if (y <= 32f * density) return CHROME_MOVE; + return CHROME_NONE; + } + + private void feedPointerChange(long t, MotionEvent e, int skipIndex) { + int count = e.getPointerCount() - (skipIndex >= 0 ? 1 : 0); + if (count <= 0) return; + resolvePrimary(e, skipIndex); + engine.onPointers(t, count, + centroidX(e, skipIndex), centroidY(e, skipIndex), primaryX(e), primaryY(e)); + } + + /** Keeps following the original finger; if it lifted, adopts the first remaining one. */ + private void resolvePrimary(MotionEvent e, int skipIndex) { + int idx = e.findPointerIndex(primaryId); + if (idx >= 0 && idx != skipIndex) return; + for (int i = 0; i < e.getPointerCount(); i++) { + if (i != skipIndex) { + primaryId = e.getPointerId(i); + return; + } + } + } + + private float primaryX(MotionEvent e) { + int idx = e.findPointerIndex(primaryId); + return e.getX(idx < 0 ? 0 : idx); + } + + private float primaryY(MotionEvent e) { + int idx = e.findPointerIndex(primaryId); + return e.getY(idx < 0 ? 0 : idx); + } + + private static float centroidX(MotionEvent e, int skipIndex) { + float sum = 0f; + int n = 0; + for (int i = 0; i < e.getPointerCount(); i++) { + if (i == skipIndex) continue; + sum += e.getX(i); + n++; + } + return n == 0 ? 0f : sum / n; + } + + private static float centroidY(MotionEvent e, int skipIndex) { + float sum = 0f; + int n = 0; + for (int i = 0; i < e.getPointerCount(); i++) { + if (i == skipIndex) continue; + sum += e.getY(i); + n++; + } + return n == 0 ? 0f : sum / n; + } +} diff --git a/app/src/main/res/drawable/ic_tile_touchpad.xml b/app/src/main/res/drawable/ic_tile_touchpad.xml new file mode 100644 index 0000000..02e6ce8 --- /dev/null +++ b/app/src/main/res/drawable/ic_tile_touchpad.xml @@ -0,0 +1,15 @@ + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index bb754e6..d1bb166 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2,4 +2,5 @@ KeyCursor KeyCursor input service Hardware-key driven mouse and touch cursor. + Touchpad