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; 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; 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 }); } }