aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--source/ui/Gesture.mc88
-rw-r--r--source/ui/InfoView.mc45
-rw-r--r--source/ui/ListDelegate.mc38
-rw-r--r--source/ui/ListView.mc6
-rw-r--r--tools/lint_project.py12
-rw-r--r--tools/ui_flow.py26
6 files changed, 206 insertions, 9 deletions
diff --git a/source/ui/Gesture.mc b/source/ui/Gesture.mc
new file mode 100644
index 0000000..0824eb1
--- /dev/null
+++ b/source/ui/Gesture.mc
@@ -0,0 +1,88 @@
+// Author: cirakg & claude ai
+// Summary: Vertical swipes and drags that scroll a custom view (the Forerunner 70 sends no next/previous-page behaviour for them), scrolling once per gesture.
+
+import Toybox.Lang;
+import Toybox.System;
+import Toybox.WatchUi;
+
+// A touch gesture can reach a delegate several ways at once: as drag events,
+// as a swipe, and as the next/previous-page behaviour. Some watches send only
+// the drag. The first one to scroll marks the time; the others arriving within
+// DEDUPE_MS are ignored, so one gesture scrolls once. The UP/DOWN buttons
+// never mark, so they are only held back right after a touch gesture.
+module Gesture {
+
+ const DEDUPE_MS = 300;
+ // A drag shorter than this part of the screen height does not scroll.
+ const MIN_DRAG_DIVISOR = 8;
+ // A drag longer than this part still counts as a drag, not a tap.
+ const SLOP_DIVISOR = 32;
+
+ var _scrolledMs as Number? = null;
+ var _draggedMs as Number? = null;
+ var _startY as Number? = null;
+
+ function _within(since as Number?) as Boolean {
+ if (since == null) {
+ return false;
+ }
+ var elapsed = System.getTimer() - since;
+ return elapsed >= 0 && elapsed < DEDUPE_MS;
+ }
+
+ // True right after a touch gesture scrolled: next/previous-page and
+ // swipe events in this window belong to that gesture.
+ function recent() as Boolean {
+ return _within(_scrolledMs);
+ }
+
+ // True right after a drag: a tap arriving now is its end, not a tap.
+ function tapIgnored() as Boolean {
+ return _within(_draggedMs);
+ }
+
+ function isVertical(event as WatchUi.SwipeEvent) as Boolean {
+ var direction = event.getDirection();
+ return direction == WatchUi.SWIPE_UP || direction == WatchUi.SWIPE_DOWN;
+ }
+
+ // The step for a vertical swipe: 1 (next, swipe up), -1 (previous), or 0
+ // when this gesture already scrolled.
+ function swipeStep(event as WatchUi.SwipeEvent) as Number {
+ if (recent()) {
+ return 0;
+ }
+ _scrolledMs = System.getTimer();
+ return event.getDirection() == WatchUi.SWIPE_UP ? 1 : -1;
+ }
+
+ // Feed a drag event. At the end of a long enough vertical drag returns
+ // the signed number of units to scroll (positive = next, finger moved
+ // up), at least 1 and distance / unit otherwise; 0 for anything else.
+ function dragStep(event as WatchUi.DragEvent, unit as Number) as Number {
+ var y = event.getCoordinates()[1];
+ var type = event.getType();
+ if (type == WatchUi.DRAG_TYPE_START) {
+ _startY = y;
+ return 0;
+ }
+ var start = _startY;
+ if (type != WatchUi.DRAG_TYPE_STOP || start == null) {
+ return 0;
+ }
+ _startY = null;
+ var height = System.getDeviceSettings().screenHeight;
+ var distance = start - y;
+ var length = distance < 0 ? -distance : distance;
+ if (length > height / SLOP_DIVISOR) {
+ _draggedMs = System.getTimer();
+ }
+ if (length < height / MIN_DRAG_DIVISOR || recent()) {
+ return 0;
+ }
+ _scrolledMs = System.getTimer();
+ var units = unit > 0 ? length / unit : 1;
+ units = units < 1 ? 1 : units;
+ return distance < 0 ? -units : units;
+ }
+}
diff --git a/source/ui/InfoView.mc b/source/ui/InfoView.mc
index cab4b52..a965713 100644
--- a/source/ui/InfoView.mc
+++ b/source/ui/InfoView.mc
@@ -16,6 +16,7 @@ class InfoView extends WatchUi.View {
private var _first as Number = 0;
private var _visible as Number = 1;
private var _shownLogged as Boolean = false;
+ private var _lineH as Number = 1;
// screen names the view in "DIAG shown" lines for UI scripts.
function initialize(title as String, text as Array<String>, screen as String) {
@@ -38,6 +39,7 @@ class InfoView extends WatchUi.View {
var max = lines.size() - _visible > 0 ? lines.size() - _visible : 0;
var next = _first + delta;
next = next < 0 ? 0 : (next > max ? max : next);
+ Diag.data("info_scroll", "first=" + next);
if (next == _first) {
return false;
}
@@ -46,12 +48,18 @@ class InfoView extends WatchUi.View {
return true;
}
+ // The height of a text line, as last drawn (for drags).
+ function lineHeight() as Number {
+ return _lineH;
+ }
+
function onUpdate(dc as Graphics.Dc) as Void {
Ui.clear(dc);
var w = dc.getWidth();
var h = dc.getHeight();
var font = Graphics.FONT_XTINY;
var lineH = dc.getFontHeight(font);
+ _lineH = lineH;
var y = Ui.isRound() ? h / 8 : 6;
dc.setColor(Ui.accent(), Graphics.COLOR_TRANSPARENT);
dc.drawText(w / 2, y, Graphics.FONT_SMALL, Ui.fit(dc, _title, Graphics.FONT_SMALL, 2 * Ui.halfWidthAt(dc, y)),
@@ -114,20 +122,47 @@ class InfoDelegate extends WatchUi.BehaviorDelegate {
}
function onNextPage() as Boolean {
- AppState.activity();
- var v = view();
- if (v != null) {
- v.scroll(3);
+ if (!Gesture.recent()) {
+ scrollBy(3);
}
return true;
}
function onPreviousPage() as Boolean {
+ if (!Gesture.recent()) {
+ scrollBy(-3);
+ }
+ return true;
+ }
+
+ private function scrollBy(lines as Number) as Void {
AppState.activity();
var v = view();
if (v != null) {
- v.scroll(-3);
+ v.scroll(lines);
+ }
+ }
+
+ // A vertical swipe or drag scrolls too (the Forerunner 70 sends no page
+ // behaviour for them); Gesture makes a gesture count once.
+ function onSwipe(event as WatchUi.SwipeEvent) as Boolean {
+ if (!Gesture.isVertical(event)) {
+ return BehaviorDelegate.onSwipe(event);
+ }
+ var step = Gesture.swipeStep(event);
+ if (step != 0) {
+ scrollBy(3 * step);
+ }
+ return true;
+ }
+
+ function onDrag(event as WatchUi.DragEvent) as Boolean {
+ var v = view();
+ var step = Gesture.dragStep(event, v != null ? v.lineHeight() : 1);
+ if (step == 0) {
+ return false;
}
+ scrollBy(step);
return true;
}
diff --git a/source/ui/ListDelegate.mc b/source/ui/ListDelegate.mc
index a7c8bf0..ca7cee9 100644
--- a/source/ui/ListDelegate.mc
+++ b/source/ui/ListDelegate.mc
@@ -14,14 +14,43 @@ class ListDelegate extends WatchUi.BehaviorDelegate {
}
function onNextPage() as Boolean {
- AppState.activity();
- _view.move(1);
+ if (!Gesture.recent()) {
+ AppState.activity();
+ _view.move(1);
+ }
return true;
}
function onPreviousPage() as Boolean {
+ if (!Gesture.recent()) {
+ AppState.activity();
+ _view.move(-1);
+ }
+ return true;
+ }
+
+ // The Forerunner 70 sends no page behaviour for a vertical swipe, so the
+ // swipe and the drag scroll here; Gesture makes a gesture count once.
+ function onSwipe(event as WatchUi.SwipeEvent) as Boolean {
+ if (!Gesture.isVertical(event)) {
+ return BehaviorDelegate.onSwipe(event);
+ }
+ var step = Gesture.swipeStep(event);
+ if (step != 0) {
+ AppState.activity();
+ _view.move(step);
+ }
+ return true;
+ }
+
+ // A drag scrolls by the rows it covered, at least one.
+ function onDrag(event as WatchUi.DragEvent) as Boolean {
+ var step = Gesture.dragStep(event, _view.rowHeight());
+ if (step == 0) {
+ return false;
+ }
AppState.activity();
- _view.move(-1);
+ _view.move(step);
return true;
}
@@ -55,6 +84,9 @@ class ListDelegate extends WatchUi.BehaviorDelegate {
// A tap opens the row under the finger.
function onTap(event as WatchUi.ClickEvent) as Boolean {
AppState.activity();
+ if (Gesture.tapIgnored()) {
+ return true;
+ }
var index = _view.rowAt(event.getCoordinates()[1]);
if (index < 0) {
return true;
diff --git a/source/ui/ListView.mc b/source/ui/ListView.mc
index 94b8c99..de73cce 100644
--- a/source/ui/ListView.mc
+++ b/source/ui/ListView.mc
@@ -82,9 +82,15 @@ class ListView extends WatchUi.View {
return;
}
focus = ((focus + delta) % n + n) % n;
+ Diag.data("list_move", "focus=" + focus);
WatchUi.requestUpdate();
}
+ // The height of a row as last drawn (for drags).
+ function rowHeight() as Number {
+ return _rowHeight;
+ }
+
function focusedId() as String? {
return focus < _ids.size() ? _ids[focus] : null;
}
diff --git a/tools/lint_project.py b/tools/lint_project.py
index 04d525f..c1c225a 100644
--- a/tools/lint_project.py
+++ b/tools/lint_project.py
@@ -42,7 +42,17 @@ AUTHOR = "cirakg & claude ai"
HEADER_LINES = 15
DIAG_FILE = Path("source/util/Diag.mc")
# Folders that hold local tool state or generated output, never project files.
-SKIP_DIRS = {".git", ".cache", ".claude", "build", "node_modules", "__pycache__", ".pytest_cache", ".mypy_cache", ".ruff_cache"}
+SKIP_DIRS = {
+ ".git",
+ ".cache",
+ ".claude",
+ "build",
+ "node_modules",
+ "__pycache__",
+ ".pytest_cache",
+ ".mypy_cache",
+ ".ruff_cache",
+}
# Written by a tool that would drop a hand-added header on the next run.
GENERATED = {Path("docker/docs/package-lock.json")}
diff --git a/tools/ui_flow.py b/tools/ui_flow.py
index 8c08caf..3c7afb2 100644
--- a/tools/ui_flow.py
+++ b/tools/ui_flow.py
@@ -437,8 +437,33 @@ def flow_locked_entry(f: Flow) -> None:
f.expect("list", AUTO_LOCK_TIMEOUT_S)
+def flow_swipes(f: Flow, screen: str) -> None:
+ """On touch screens a vertical swipe scrolls `screen` once, whatever the
+ watch does with page behaviour; checked through the debug logs so no
+ image is involved. A swipe up must report one scroll; on the list the
+ focus must move and a swipe back must return it."""
+ if not f.touch:
+ return
+ pattern = r"DIAG list_move focus=(\d+)" if screen == "list" else r"DIAG info_scroll first=(\d+)"
+ regex = re.compile(pattern)
+ x, top, bottom = f._w() // 2, f._h() // 4, f._h() * 3 // 4
+ seen: list[str] = []
+ for start, end in ((bottom, top), (top, bottom)): # swipe up: next; down: previous
+ f.act()
+ f.sim.swipe(x, start, x, end)
+ f.log.wait_for(pattern, SCREEN_TIMEOUT_S)
+ time.sleep(SETTLE_S)
+ found = [m.group(1) for line in f.log.lines()[f.log.mark :] if (m := regex.search(line))]
+ if len(found) != 1:
+ raise FlowError(f"one swipe on {screen} scrolled {len(found)} times, not once")
+ seen.append(found[0])
+ if screen == "list" and seen[0] == seen[1]:
+ raise FlowError("a swipe on the list didn't move the focus")
+
+
def flow_menu(f: Flow) -> None:
"""The menu, and About on watches whose menu has a focus to move."""
+ flow_swipes(f, "list")
f.menu()
time.sleep(MENU_SETTLE_S)
f.shot("menu")
@@ -454,6 +479,7 @@ def flow_menu(f: Flow) -> None:
f.select()
f.expect("about")
f.shot("about")
+ flow_swipes(f, "about")
f.act()
# Touch-only watches show the menu as a freely scrolling list without
# focus, so there is no reliable "select About"; just close it.