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" />
+
+ * 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