diff options
| author | cirakg <cirakg@cirakg.xyz> | 2026-10-25 13:13:39 +0100 |
|---|---|---|
| committer | cirakg <cirakg@cirakg.xyz> | 2026-10-25 13:13:39 +0100 |
| commit | 404b505a41362f340e79374ee3ad4dbd468da3e4 (patch) | |
| tree | 883d4f1ecda6c30ce890b1aa4026c8ba1726804e | |
| parent | 096b5a4d17c85fa5f87db3e72364e9c3506d1e64 (diff) | |
fix(ui): vertical swipes and drags scroll the list and text screens
The Forerunner 70 sends no page behaviour for a swipe on a custom view,
so the list and info screens only scrolled with the buttons. Swipes and
drags now scroll them directly; one gesture scrolls once even when the
device also sends the page behaviour, and a drag's release opens no row.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| -rw-r--r-- | source/ui/Gesture.mc | 88 | ||||
| -rw-r--r-- | source/ui/InfoView.mc | 45 | ||||
| -rw-r--r-- | source/ui/ListDelegate.mc | 38 | ||||
| -rw-r--r-- | source/ui/ListView.mc | 6 | ||||
| -rw-r--r-- | tools/lint_project.py | 12 | ||||
| -rw-r--r-- | tools/ui_flow.py | 26 |
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. |
