einsatzprotokoll/source/MenuView.mc
EiSiMo a45f1b5215 Tap-to-rotate: tap icon to select, tap selected to open
Tapping an unselected icon in the menu ring rotates to it with
animation. Tapping the already-selected icon opens it. Icon
positions are tracked from last render for hit detection.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 19:35:35 +02:00

199 lines
7.1 KiB
MonkeyC

import Toybox.Application;
import Toybox.Graphics;
import Toybox.Lang;
import Toybox.Math;
import Toybox.Timer;
import Toybox.WatchUi;
// Rotating 10-icon ring modelled after the watch Controls menu.
// Selection point is fixed at the 3 o'clock position (START/STOP
// button height). UP/DOWN input rotates the ring with a smooth
// animation.
class MenuView extends WatchUi.View {
const STORAGE_KEY = "menu_idx";
const ANIM_DURATION_MS = 200;
const ANIM_TICK_MS = 16; // ~60fps
private var _items as Array<Dictionary>;
private var _selectedIndex as Number = 0;
private var _bitmaps as Dictionary = {};
// Animation state.
private var _animTimer as Timer.Timer;
private var _animFrom as Number = 0; // previous index
private var _animProgress as Float = 1.0; // 0.0 → 1.0, 1.0 = done
private var _animStartMs as Number = 0;
function initialize() {
View.initialize();
_items = Config.menuItems();
_animTimer = new Timer.Timer();
var stored = Application.Storage.getValue(STORAGE_KEY);
if (stored instanceof Number && stored >= 0 && stored < _items.size()) {
_selectedIndex = stored;
}
_animFrom = _selectedIndex;
}
function onLayout(dc as Dc) as Void {
for (var i = 0; i < _items.size(); i++) {
var iconId = _items[i][:icon];
_bitmaps[iconId] = WatchUi.loadResource(iconId) as BitmapResource;
}
}
function rotateNext() as Void {
_startAnim(_selectedIndex);
_selectedIndex = (_selectedIndex + 1) % _items.size();
Application.Storage.setValue(STORAGE_KEY, _selectedIndex);
}
function rotatePrev() as Void {
_startAnim(_selectedIndex);
_selectedIndex = (_selectedIndex - 1 + _items.size()) % _items.size();
Application.Storage.setValue(STORAGE_KEY, _selectedIndex);
}
private function _startAnim(fromIndex as Number) as Void {
_animFrom = fromIndex;
_animProgress = 0.0;
_animStartMs = System.getTimer();
_animTimer.start(method(:_animTick), ANIM_TICK_MS, true);
}
function _animTick() as Void {
var elapsed = System.getTimer() - _animStartMs;
_animProgress = elapsed.toFloat() / ANIM_DURATION_MS.toFloat();
if (_animProgress >= 1.0) {
_animProgress = 1.0;
_animTimer.stop();
}
WatchUi.requestUpdate();
}
function selectedItem() as Dictionary {
return _items[_selectedIndex];
}
function selectedIndex() as Number {
return _selectedIndex;
}
private var _iconPositions as Array = [];
// Returns the index of the icon closest to (tapX, tapY), or -1.
function itemIndexAt(tapX as Number, tapY as Number) as Number {
var bestIdx = -1;
var bestDist = 999999;
for (var i = 0; i < _iconPositions.size(); i++) {
var pos = _iconPositions[i] as Array;
var ix = pos[0] as Number;
var iy = pos[1] as Number;
var size = pos[2] as Number;
var dx = tapX - ix;
var dy = tapY - iy;
var dist = dx * dx + dy * dy;
var maxDist = (size / 2) * (size / 2);
if (dist < maxDist && dist < bestDist) {
bestDist = dist;
bestIdx = i;
}
}
return bestIdx;
}
// Rotate to a specific index with animation.
function rotateTo(index as Number) as Void {
if (index == _selectedIndex) { return; }
_startAnim(_selectedIndex);
_selectedIndex = index;
Application.Storage.setValue(STORAGE_KEY, _selectedIndex);
}
function onUpdate(dc as Dc) as Void {
dc.setColor(Config.COLOR_FG, Config.COLOR_BG);
dc.clear();
var cx = LayoutMetrics.centerX(dc);
var cy = LayoutMetrics.centerY(dc);
var radius = LayoutMetrics.ringRadius(dc);
var baseSize = LayoutMetrics.iconSize(dc);
var selSize = LayoutMetrics.selectedIconSize(dc);
var n = _items.size();
var selectionAngle = Config.SELECTION_ANGLE_DEG * Math.PI / 180.0;
var step = 2.0 * Math.PI / n;
// Ease-out for smooth deceleration.
var t = _animProgress;
var ease = 1.0 - (1.0 - t) * (1.0 - t);
// Interpolate the rotation offset between old and new index.
var fromOffset = _animFrom.toFloat();
var toOffset = _selectedIndex.toFloat();
// Handle wrap-around (e.g. 9→0 or 0→9).
var diff = toOffset - fromOffset;
if (diff > n / 2.0) { diff = diff - n; }
if (diff < -n / 2.0) { diff = diff + n; }
var currentOffset = fromOffset + diff * ease;
_iconPositions = new [n];
for (var i = 0; i < n; i++) {
var angle = selectionAngle + (i.toFloat() - currentOffset) * step;
var x = (cx + radius * Math.cos(angle)).toNumber();
var y = (cy + radius * Math.sin(angle)).toNumber();
// Smoothly scale selected icon.
var isSelected = (i == _selectedIndex);
var wasSelected = (i == _animFrom);
var targetSize = baseSize;
if (isSelected && wasSelected) {
targetSize = selSize;
} else if (isSelected) {
targetSize = (baseSize + (selSize - baseSize) * ease).toNumber();
} else if (wasSelected) {
targetSize = (selSize - (selSize - baseSize) * ease).toNumber();
}
_iconPositions[i] = [x, y, targetSize];
// Colored circle behind icon.
var color = _items[i][:color] as Number;
dc.setColor(color, Config.COLOR_BG);
dc.fillCircle(x, y, targetSize / 2);
var iconId = _items[i][:icon];
var bmp = _bitmaps[iconId] as BitmapResource;
if (bmp != null) {
_drawScaledIcon(dc, bmp, x, y, targetSize);
}
// Accent ring on selected item (also animated).
if (isSelected && ease > 0.5) {
dc.setPenWidth(LayoutMetrics.accentPenWidth(dc));
dc.setColor(Config.COLOR_ACCENT, Config.COLOR_BG);
dc.drawCircle(x, y, targetSize / 2 + 5);
}
}
// Only show label when animation is done to avoid flicker.
if (_animProgress >= 1.0) {
var lines = _items[_selectedIndex][:lines] as Array<String>;
dc.setColor(Config.COLOR_FG, Config.COLOR_BG);
TextUtils.drawCentered(dc, lines, cx, cy, Graphics.FONT_TINY);
}
}
private function _drawScaledIcon(dc as Dc, bmp as BitmapResource,
cx as Number, cy as Number,
targetSize as Number) as Void {
var bmpW = bmp.getWidth();
var bmpH = bmp.getHeight();
var scale = targetSize.toFloat() / bmpW.toFloat();
var tf = new Graphics.AffineTransform();
tf.translate(cx.toFloat(), cy.toFloat());
tf.scale(scale, scale);
tf.translate(-bmpW.toFloat() / 2.0, -bmpH.toFloat() / 2.0);
dc.drawBitmap2(0, 0, bmp, { :transform => tf });
}
}