From a989ab80ecd9cf34629e8ae6abd673821f45c85e Mon Sep 17 00:00:00 2001 From: stweedo Date: Tue, 17 Jun 2025 01:29:23 -0500 Subject: [PATCH] tileclk: Refactor for better performance --- apps/tileclk/README.md | 59 ++--- apps/tileclk/app-icon.js | 2 +- apps/tileclk/app.js | 529 ++++++++++++++++++++++++++----------- apps/tileclk/icon.png | Bin 0 -> 11631 bytes apps/tileclk/metadata.json | 4 +- 5 files changed, 393 insertions(+), 201 deletions(-) create mode 100644 apps/tileclk/icon.png diff --git a/apps/tileclk/README.md b/apps/tileclk/README.md index 0a042c9c3..f6eee1088 100644 --- a/apps/tileclk/README.md +++ b/apps/tileclk/README.md @@ -1,42 +1,25 @@ # Tile Clock -A tile-based digital clock with animated transitions, customizable borders, and clock info integration. - -## How to Use - -### Basic Display -- The clock shows the current time using animated tiles -- Tiles animate smoothly when digits change -- In 12-hour mode, leading zeros are hidden (e.g., "2:30" instead of "02:30") - -### Seconds Display -- **Static mode**: Seconds always shown/hidden based on settings -- **Dynamic mode**: Seconds appear when unlocked, hide when locked - -### Clock Info Integration -- Tap the seconds area (bottom of screen) to show clock info -- Clock info displays weather, notifications, or other system information -- Tap clock info area to focus it -- When focused and tapped, clock info can perform actions if supported -- Tap main time area to unfocus the clock info -- Tap main time area again to dismiss the clock info - -### Touch Controls -- **Tap seconds area**: Switch to clock info -- **Tap clock info area**: Focus the info panel -- **Tap main time once**: Unfocus the clock info -- **Tap main time again**: Dismiss clock info and return to seconds - -### Settings -Access via Settings app to configure: -- Seconds display mode (show/hide/dynamic) -- Border visibility and color -- Widget display options -- Haptic feedback +Digital clock with animated tile transitions and clock info integration. ## Features -- Smooth tile animations with color interpolation -- Customizable borders with theme color support -- Persistent user preferences -- Performance optimized for smooth operation -- Integration with Bangle.js clock info system \ No newline at end of file +- Animated digit transitions +- Seconds display (configurable) +- Clock info integration +- Customizable tile borders +- Haptic feedback + +## Controls +- **Tap bottom area**: Show clock info +- **Tap info panel**: Focus it +- **Tap time area**: Unfocus, then dismiss + +## Settings +- **Seconds**: Show/Hide/Dynamic +- **Borders**: On/off and color +- **Widgets**: Show/Hide/Swipe +- **Haptics**: On/off + +## Notes +- Dynamic seconds mode shows when unlocked, hides when locked +- Clock info provides system information when available diff --git a/apps/tileclk/app-icon.js b/apps/tileclk/app-icon.js index 87371ace9..1640208b4 100644 --- a/apps/tileclk/app-icon.js +++ b/apps/tileclk/app-icon.js @@ -1 +1 @@ -require("heatshrink").decompress(atob("mEwxH+64A/AH4A/AH4A/AH4A/AH4A/AH4A/AH4AIx4ABBQ+zBYQAFCoYAF2YvPisAio6IgEAkcHgEcCIYDCjkAg8jCAJMIF+mzAAMjAAIvIBQNrQQQRDRgtrBIIvM2cVh0cjgCBFYlksi+Bh0OVwYRGD4YQBF5sOPoYdEskAFYKPBg4vDCIwfDToIvNKgNHMAKPEshbCx4LBL4gRFOgYKBd6ASBJojJCDwYvFCIIDCkYCEF6B9JF4KPJjkAioCBh0jR5xfEDAIvIisVL4gRCAYUjBoICCF6BzBVoiPKCIy/WR41rLYSPKAAuzR6UVhwdEsitBF4MOAYIRJF4YQBF5iECx8jAAOPRYVroFABYdrCIqJBDQQABtYbCF5kOg7YBAQMcg5XEEgKPBkYRFd4MHeAIVBkaPOF4KqBVoUcRYQvFgAvBCIgvBgAvBCoMjCAK/NOogADBpoOBBRQA/AH4A/AH4A/AH4A/AH4A/AH4A/ACgA==")) \ No newline at end of file +require("heatshrink").decompress(atob("mEwxH+64A/AH4A/AH4A/AH4AF2YAFBBgaPFxcVjkjAQMciuPBBYiFCJAvMh0VkcOCgMOEwIIKF4wRHR51rkcjOoYIH2YFCDIoIBHgKQQAAOPgEcBBZfLFR4mFUgIILX5YvVawIIL2YFBF4wIBtdktYvSR7MjDQ5fcR44IBo5fUX5wvKX6qPZF6sjR4mz2drBAIEBAAIFFBBQvPhxWEirdBBAMch2PBCIv+OIyEGBYIIRAH4AmPBYRRDRQfGZQMjaQgICaYwRKDRePBAQvykcjx50FBAQvFCImPAoIaOCIYACisAEwwINx8AioRSPocVQw4IMx8VQwQRQPoccQw4IMx8cR4QRJo9ksiWBQySPXeoMOF4xfvX96PlCIxrDkdr2YADBB1rAoIRLF4YRCNYcOkcVirOBPoQIbF4YICTYgUDVogIaX5JxEOgYIcFIrNFAH4A/AH4A/AH4A7A=")) diff --git a/apps/tileclk/app.js b/apps/tileclk/app.js index bd3985621..306ec6aed 100644 --- a/apps/tileclk/app.js +++ b/apps/tileclk/app.js @@ -1,5 +1,5 @@ (function() { - /* Tile Clock with Clock Info Integration - Performance Optimized */ + /* Tile Clock with Clock Info Integration */ // ===== CONSTANTS ===== const SCALE = 12; @@ -31,53 +31,82 @@ // Display state let showingClockInfo = false; let clockInfoUnfocused = false; - let userPreferClockInfo = false; - let userDismissedClockInfo = false; + let userClockInfoPreference = null; // null: no preference, 'show': user wants it, 'hide': user dismissed it + let pendingSwitch = false; // Animation state let isDrawing = true; let isColonDrawn = false; - let isDrawingSeconds = false; + let isSeconds = false; let drawTimeout = null; let secondsTimeout = null; - // Time tracking - let lastTime = ""; - let lastSeconds = ""; + // Time tracking - simple integers + let lastTime = -1; // HHMM format (e.g., 1234 for 12:34) + let lastSeconds = -1; // 0-59 // Clock info menu let clockInfoMenu = null; + // Event handlers (for cleanup) + let touchHandler = null; + let lockHandler = null; + + // Animation timeouts tracking + let animationTimeouts = []; + + // ===== STATE PERSISTENCE ===== function loadDisplayState() { const state = require('Storage').readJSON("tileclk.state.json", true) || {}; showingClockInfo = state.showingClockInfo || false; - userPreferClockInfo = state.userPreferClockInfo || false; - userDismissedClockInfo = state.userDismissedClockInfo || false; + userClockInfoPreference = state.userClockInfoPreference || null; } function saveDisplayState() { require('Storage').writeJSON("tileclk.state.json", { showingClockInfo: showingClockInfo, - userPreferClockInfo: userPreferClockInfo, - userDismissedClockInfo: userDismissedClockInfo + userClockInfoPreference: userClockInfoPreference }); } // ===== DIGIT BITMAPS ===== - const digitBitmaps = { - ' ': [0, 0, 0, 0, 0], - '0': [7, 5, 5, 5, 7], - '1': [2, 6, 2, 2, 7], - '2': [7, 1, 7, 4, 7], - '3': [7, 1, 7, 1, 7], - '4': [5, 5, 7, 1, 1], - '5': [7, 4, 7, 1, 7], - '6': [7, 4, 7, 5, 7], - '7': [7, 1, 1, 1, 1], - '8': [7, 5, 7, 5, 7], - '9': [7, 5, 7, 1, 7] - }; + // Each digit packed into 16 bits (5 rows × 3 bits each) + const digitBitmaps = new Uint16Array([ + 0b000000000000000, // ' ' (space) + 0b111101101101111, // '0' + 0b010110010010111, // '1' + 0b111001111100111, // '2' + 0b111001111001111, // '3' + 0b101101111001001, // '4' + 0b111100111001111, // '5' + 0b111100111101111, // '6' + 0b111001001001001, // '7' + 0b111101111101111, // '8' + 0b111101111001111 // '9' + ]); + + // Helper function to get digit index + function getDigitIndex(digit) { + if (digit === null || digit === -1) return 0; // space/blank + return (digit >= 0 && digit <= 9) ? digit + 1 : 0; + } + + // Helper function to extract digits from time integer + function extractTimeDigits(time) { + if (time < 0) { + return { h1: -1, h2: -1, m1: -1, m2: -1 }; + } + // Bitwise OR 0 for integer division - more efficient on microcontrollers + const hours = (time / 100) | 0; + const minutes = time % 100; + return { + h1: (hours / 10) | 0, + h2: hours % 10, + m1: (minutes / 10) | 0, + m2: minutes % 10 + }; + } // ===== CALCULATED CONSTANTS ===== const digitWidth = 3 * SCALE; @@ -89,11 +118,11 @@ // ===== WIDGET OFFSET ===== const widgetYOffset = (settings.widgets === "hide" || settings.widgets === "swipe") ? -SCALE : 0; - // ===== BORDER COLOR (Inlined for performance) ===== + // ===== BORDER COLOR ===== const borderColor = settings.borderColor === "theme" || !settings.borderColor ? g.theme.bgH : g.toColor(settings.borderColor); - // ===== POSITION CALCULATIONS (Pre-computed for performance) ===== + // ===== POSITION CALCULATIONS ===== const positions = { threeDigit: (() => { const totalWidth = 3 * digitWidth + colonWidth + 3 * GAP; @@ -130,15 +159,20 @@ } }; - // Pre-compute touch areas for faster access - const mainTimeAreaBottom = widgetYOffset + Math.round(0.6 * height); - const secondsAreaLeft = positions.seconds.x[0] - 10; - const secondsAreaRight = positions.seconds.x[1] + secDigitWidth + 10; - const secondsAreaTop = positions.seconds.y + widgetYOffset - 10; - const secondsAreaBottom = positions.seconds.y + widgetYOffset + 5 * SEC_SCALE + 10; - const clockInfoAreaBottom = positions.seconds.y + widgetYOffset + 40; + // Touch areas + const mainTimeArea = { + top: widgetYOffset, + bottom: widgetYOffset + Math.round(0.6 * height) + }; + + const secondsArea = { + left: positions.seconds.x[0] - 10, + right: positions.seconds.x[1] + secDigitWidth + 10, + top: positions.seconds.y + widgetYOffset - 10, + bottom: positions.seconds.y + widgetYOffset + 50 // Covers both seconds and clock info + }; - // ===== LAYOUT GENERATION (Optimized with pre-built layouts) ===== + // ===== LAYOUT GENERATION ===== const threeDigitLayout = [ { type: 'digit', value: 'h2', x: positions.threeDigit.digitX[0], y: positions.threeDigit.digitsY + widgetYOffset, scale: SCALE }, { type: 'colon', x: positions.threeDigit.colonX, y: positions.threeDigit.colonY + widgetYOffset, scale: SCALE }, @@ -161,22 +195,20 @@ const key = c1 + "_" + c2 + "_" + Math.round(fraction * FRAC_STEPS); if (colorCache[key]) return colorCache[key]; - const r1 = (c1 >> 16) & 0xFF; - const g1 = (c1 >> 8) & 0xFF; - const b1 = c1 & 0xFF; - const r2 = (c2 >> 16) & 0xFF; - const g2 = (c2 >> 8) & 0xFF; - const b2 = c2 & 0xFF; + // Pre-calculate fractions to avoid repeated operations + const invFrac = FRAC_STEPS - Math.round(fraction * FRAC_STEPS); + const frac = Math.round(fraction * FRAC_STEPS); - const r = Math.round(r1 * (1 - fraction) + r2 * fraction); - const g = Math.round(g1 * (1 - fraction) + g2 * fraction); - const b = Math.round(b1 * (1 - fraction) + b2 * fraction); + // Inline color extraction and use bitwise OR for integer conversion + const r = ((((c1 >> 16) & 0xFF) * invFrac + ((c2 >> 16) & 0xFF) * frac) / FRAC_STEPS) | 0; + const g = ((((c1 >> 8) & 0xFF) * invFrac + ((c2 >> 8) & 0xFF) * frac) / FRAC_STEPS) | 0; + const b = (((c1 & 0xFF) * invFrac + (c2 & 0xFF) * frac) / FRAC_STEPS) | 0; colorCache[key] = (r << 16) | (g << 8) | b; return colorCache[key]; } - // ===== BORDER DRAWING (Inlined check for performance) ===== + // ===== BORDER DRAWING ===== function drawBorder(x, y, s, thickness) { if (!showBorders || thickness <= 0) return; @@ -193,13 +225,14 @@ const thickness = isMainDigit ? MAIN_BORDER_THICKNESS : SEC_BORDER_THICKNESS; function transition() { - if (!isDrawing) return; + if (!isDrawing || (pendingSwitch && isSeconds)) return; g.setColor(interpColor(on ? g.theme.bg : g.theme.fg, on ? g.theme.fg : g.theme.bg, Math.abs(progress - (on ? 0 : 1)))); g.fillRect(x, y, x + s - 1, y + s - 1); progress += step; if (progress >= 0 && progress <= 1) { - setTimeout(transition, ANIM_DELAY); + const timeout = setTimeout(transition, ANIM_DELAY); + animationTimeouts.push(timeout); } else { if (on) drawBorder(x, y, s, thickness); if (callback) callback(); @@ -211,23 +244,65 @@ // ===== TILE CALCULATION ===== function calculateTilesToUpdate(x, y, s, currentDigit, prevDigit) { - const current = digitBitmaps[currentDigit] || digitBitmaps[' ']; - const previous = digitBitmaps[prevDigit] || digitBitmaps[' ']; + const currentPacked = digitBitmaps[getDigitIndex(currentDigit)]; + const prevPacked = digitBitmaps[getDigitIndex(prevDigit)]; const tiles = []; - for (let row = 0; row < 5; row++) { - const diff = current[row] ^ previous[row]; - if (diff === 0) continue; - - for (let col = 0; col < 3; col++) { - if (diff & (1 << (2 - col))) { - tiles.push({ - x: x + col * s, - y: y + row * s, - state: (current[row] >> (2 - col)) & 1 - }); - } - } + let yPos = y; + + // Loop unrolled for 5 rows - eliminates loop overhead on microcontroller + // Row 0 + let currentRow = (currentPacked >> 12) & 0b111; + let prevRow = (prevPacked >> 12) & 0b111; + let diff = currentRow ^ prevRow; + if (diff) { + if (diff & 4) tiles.push({ x: x, y: yPos, state: (currentRow >> 2) & 1 }); + if (diff & 2) tiles.push({ x: x + s, y: yPos, state: (currentRow >> 1) & 1 }); + if (diff & 1) tiles.push({ x: x + s + s, y: yPos, state: currentRow & 1 }); + } + + // Row 1 + yPos += s; + currentRow = (currentPacked >> 9) & 0b111; + prevRow = (prevPacked >> 9) & 0b111; + diff = currentRow ^ prevRow; + if (diff) { + if (diff & 4) tiles.push({ x: x, y: yPos, state: (currentRow >> 2) & 1 }); + if (diff & 2) tiles.push({ x: x + s, y: yPos, state: (currentRow >> 1) & 1 }); + if (diff & 1) tiles.push({ x: x + s + s, y: yPos, state: currentRow & 1 }); + } + + // Row 2 + yPos += s; + currentRow = (currentPacked >> 6) & 0b111; + prevRow = (prevPacked >> 6) & 0b111; + diff = currentRow ^ prevRow; + if (diff) { + if (diff & 4) tiles.push({ x: x, y: yPos, state: (currentRow >> 2) & 1 }); + if (diff & 2) tiles.push({ x: x + s, y: yPos, state: (currentRow >> 1) & 1 }); + if (diff & 1) tiles.push({ x: x + s + s, y: yPos, state: currentRow & 1 }); + } + + // Row 3 + yPos += s; + currentRow = (currentPacked >> 3) & 0b111; + prevRow = (prevPacked >> 3) & 0b111; + diff = currentRow ^ prevRow; + if (diff) { + if (diff & 4) tiles.push({ x: x, y: yPos, state: (currentRow >> 2) & 1 }); + if (diff & 2) tiles.push({ x: x + s, y: yPos, state: (currentRow >> 1) & 1 }); + if (diff & 1) tiles.push({ x: x + s + s, y: yPos, state: currentRow & 1 }); + } + + // Row 4 + yPos += s; + currentRow = currentPacked & 0b111; + prevRow = prevPacked & 0b111; + diff = currentRow ^ prevRow; + if (diff) { + if (diff & 4) tiles.push({ x: x, y: yPos, state: (currentRow >> 2) & 1 }); + if (diff & 2) tiles.push({ x: x + s, y: yPos, state: (currentRow >> 1) & 1 }); + if (diff & 1) tiles.push({ x: x + s + s, y: yPos, state: currentRow & 1 }); } return tiles; @@ -252,13 +327,14 @@ } function updateTiles(tiles, s, callback, skipAnimation, isMainDigit) { - if (!isDrawing || !tiles.length) { + if (!isDrawing || !tiles.length || (pendingSwitch && isSeconds)) { if (callback) callback(); return; } const tile = tiles.shift(); updateTile(tile, s, skipAnimation, isMainDigit); - setTimeout(() => updateTiles(tiles, s, callback, skipAnimation, isMainDigit), ANIM_DELAY); + const timeout = setTimeout(() => updateTiles(tiles, s, callback, skipAnimation, isMainDigit), ANIM_DELAY); + animationTimeouts.push(timeout); } // ===== DIGIT DRAWING ===== @@ -267,6 +343,13 @@ if (callback) callback(); return; } + + // Check if we should stop seconds animation + if (isSeconds && pendingSwitch) { + if (callback) callback(); + return; + } + const tiles = calculateTilesToUpdate(x, y, s, num, prevNum); updateTiles(tiles, s, callback, skipAnimation, isMainDigit); } @@ -299,7 +382,7 @@ if (callback) callback(); return; } - const layout = is12Hour && lastTime[0] === '0' ? threeDigitLayout : fourDigitLayout; + const layout = is12Hour && lastTime >= 0 && lastTime < 1000 ? threeDigitLayout : fourDigitLayout; const colonItem = layout.find(item => item.type === 'colon'); if (colonItem) { @@ -316,15 +399,10 @@ } function clearAllDigits(callback) { - const wasThreeDigit = is12Hour && lastTime[0] === '0'; + const wasThreeDigit = is12Hour && lastTime >= 0 && lastTime < 1000; const layout = wasThreeDigit ? threeDigitLayout : fourDigitLayout; - const previousDigits = { - h1: lastTime[0] || ' ', - h2: lastTime[1] || ' ', - m1: lastTime[2] || ' ', - m2: lastTime[3] || ' ' - }; + const previousDigits = extractTimeDigits(lastTime); // Direct callback chaining for better performance function clearItems(items, next) { @@ -335,7 +413,7 @@ const item = items.shift(); if (item.type === 'digit') { - drawDigit(item.x, item.y, item.scale, " ", previousDigits[item.value], () => clearItems(items, next), false, true); + drawDigit(item.x, item.y, item.scale, -1, previousDigits[item.value], () => clearItems(items, next), false, true); } else if (item.type === 'colon') { clearColon(() => clearItems(items, next)); } @@ -351,11 +429,12 @@ clearItems(minuteItems.slice(), () => { if (showSeconds && !showingClockInfo) { clearSeconds(() => { - lastTime = ""; + lastTime = -1; if (callback) callback(); }); } else { - lastTime = ""; + lastTime = -1; + animationTimeouts = []; // Clear animation timeouts to prevent memory leak if (callback) callback(); } }); @@ -363,22 +442,32 @@ }); } - // ===== MAIN TIME UPDATE (Optimized with direct logic) ===== + // ===== MAIN TIME UPDATE ===== function updateAndAnimTime() { if (!isDrawing) return; const now = new Date(); - const hours = (is12Hour ? now.getHours() % 12 || 12 : now.getHours()).toString().padStart(2, '0'); - const minutes = now.getMinutes().toString().padStart(2, '0'); - const currentTime = hours + minutes; - const isCurrentThreeDigit = is12Hour && hours[0] === '0'; - const wasLastThreeDigit = is12Hour && lastTime[0] === '0'; + const hoursNum = is12Hour ? now.getHours() % 12 || 12 : now.getHours(); + const minutesNum = now.getMinutes(); + const currentTime = hoursNum * 100 + minutesNum; + + // Extract digits only for layout decision + const isCurrentThreeDigit = is12Hour && hoursNum < 10; + const wasLastThreeDigit = is12Hour && lastTime >= 0 && lastTime < 1000; function drawTime() { - const currentDigits = { h1: hours[0], h2: hours[1], m1: minutes[0], m2: minutes[1] }; - const previousDigits = (isCurrentThreeDigit !== wasLastThreeDigit && lastTime !== "") ? - { h1: ' ', h2: ' ', m1: ' ', m2: ' ' } : - { h1: lastTime[0] || ' ', h2: lastTime[1] || ' ', m1: lastTime[2] || ' ', m2: lastTime[3] || ' ' }; + // Extract current digits - bitwise OR faster than Math.floor on Espruino + const h1 = (hoursNum / 10) | 0; + const h2 = hoursNum % 10; + const m1 = (minutesNum / 10) | 0; + const m2 = minutesNum % 10; + + const digitMap = { h1: h1, h2: h2, m1: m1, m2: m2 }; + + // Extract previous digits (or -1 for blank) + const previousDigits = (isCurrentThreeDigit !== wasLastThreeDigit && lastTime >= 0) ? + { h1: -1, h2: -1, m1: -1, m2: -1 } : + extractTimeDigits(lastTime); const layout = isCurrentThreeDigit ? threeDigitLayout : fourDigitLayout; @@ -393,7 +482,7 @@ const next = () => drawLayout(items, onComplete); if (item.type === 'digit') { - drawDigit(item.x, item.y, item.scale, currentDigits[item.value], previousDigits[item.value], next, false, true); + drawDigit(item.x, item.y, item.scale, digitMap[item.value], previousDigits[item.value], next, false, true); } else if (item.type === 'colon') { drawColon(item.x, item.y, next); } @@ -408,6 +497,7 @@ function finishDrawing() { g.flip(); + animationTimeouts = []; // Clear animation timeouts to prevent memory leak lastTime = currentTime; if (showSeconds && !showingClockInfo) updateSeconds(); scheduleNextUpdate(); @@ -418,37 +508,55 @@ // ===== SECONDS HANDLING ===== function updateSeconds() { - if (isDrawingSeconds || !showSeconds || showingClockInfo) return; - isDrawingSeconds = true; + if (isSeconds || !showSeconds || showingClockInfo || pendingSwitch) return; + isSeconds = true; const now = new Date(); - const currentMs = now.getMilliseconds(); - let seconds = now.getSeconds(); + let secondsNum = now.getSeconds(); - const skipAnimation = lastSeconds === ""; + const skipAnimation = lastSeconds < 0; + + // Declare digit variables once + let s1, s2, prevS1, prevS2; if (skipAnimation) { // Calculate how many tiles need to be drawn from blank - const secondsStr = seconds.toString().padStart(2, '0'); - const tiles0 = calculateTilesToUpdate(positions.seconds.x[0], positions.seconds.y + widgetYOffset, SEC_SCALE, secondsStr[0], ' '); - const tiles1 = calculateTilesToUpdate(positions.seconds.x[1], positions.seconds.y + widgetYOffset, SEC_SCALE, secondsStr[1], ' '); - const tilesNeeded = tiles0.length + tiles1.length; - - // Each tile takes ANIM_DELAY to draw in fast mode - const estimatedDrawTime = tilesNeeded * ANIM_DELAY * 2 // Double for safety margin - const timeUntilNextSecond = 1000 - currentMs; - + s1 = (secondsNum / 10) | 0; // Bitwise OR for integer division + s2 = secondsNum % 10; + let tiles0 = calculateTilesToUpdate(positions.seconds.x[0], positions.seconds.y + widgetYOffset, SEC_SCALE, s1, -1); + let tiles1 = calculateTilesToUpdate(positions.seconds.x[1], positions.seconds.y + widgetYOffset, SEC_SCALE, s2, -1); + let tilesNeeded = tiles0.length + tiles1.length; + + // Check time again after calculations + const nowAfterCalc = new Date(); + const timeUntilNextSecond = 1000 - nowAfterCalc.getMilliseconds(); + const estimatedDrawTime = tilesNeeded * ANIM_DELAY * 2; // Double for safety margin + // If we can't finish in time, skip to next second if (estimatedDrawTime > timeUntilNextSecond) { - seconds = (seconds + 1) % 60; + // Get current time again to make sure we skip to the right second + secondsNum = (new Date().getSeconds() + 1) % 60; } } - - seconds = seconds.toString().padStart(2, '0'); + // Extract current and previous digits + s1 = (secondsNum / 10) | 0; // Bitwise OR for integer division + s2 = secondsNum % 10; + prevS1 = lastSeconds < 0 ? -1 : (lastSeconds / 10) | 0; + prevS2 = lastSeconds < 0 ? -1 : lastSeconds % 10; + function updateDigit(index) { - if (seconds[index] !== (lastSeconds[index] || ' ')) { - drawSecondDigit(index, seconds[index], lastSeconds[index] || ' ', () => { + // Check if we should stop + if (!isDrawing || pendingSwitch || showingClockInfo) { + isSeconds = false; + return; + } + + const currentDigit = index === 0 ? s1 : s2; + const prevDigit = index === 0 ? prevS1 : prevS2; + + if (currentDigit !== prevDigit) { + drawSecondDigit(index, currentDigit, prevDigit, () => { if (index === 0) { updateDigit(1); } else { @@ -463,13 +571,26 @@ } function finishSeconds() { - lastSeconds = seconds; - isDrawingSeconds = false; + lastSeconds = secondsNum; + isSeconds = false; + animationTimeouts = []; g.flip(); + // If we're locked after finishing animation, clear the seconds + if (settings.seconds === "dynamic" && Bangle.isLocked() && !showingClockInfo) { + clearSeconds(); + return; + } + + // Check if we have a pending switch + if (pendingSwitch) { + setTimeout(switchToClockInfo, 10); + return; + } + if (secondsTimeout) clearTimeout(secondsTimeout); secondsTimeout = setTimeout(() => { - if (showSeconds && !showingClockInfo) updateSeconds(); + if (showSeconds && !showingClockInfo && !pendingSwitch) updateSeconds(); }, 1000 - new Date().getMilliseconds()); } @@ -481,26 +602,65 @@ } function clearSeconds(callback) { - if (isDrawingSeconds) { - setTimeout(() => clearSeconds(callback), 50); + // If not drawing seconds, just call callback + if (lastSeconds < 0) { + if (callback) callback(); return; } - isDrawingSeconds = true; - drawDigit(positions.seconds.x[0], positions.seconds.y + widgetYOffset, SEC_SCALE, " ", lastSeconds[0] || ' ', () => { - drawDigit(positions.seconds.x[1], positions.seconds.y + widgetYOffset, SEC_SCALE, " ", lastSeconds[1] || ' ', () => { - lastSeconds = ""; - isDrawingSeconds = false; + // Cancel any pending seconds update + if (secondsTimeout) { + clearTimeout(secondsTimeout); + secondsTimeout = null; + } + + // Always do sequential animated clearing + isSeconds = true; + const s1 = (lastSeconds / 10) | 0; // Bitwise OR for integer division + const s2 = lastSeconds % 10; + + drawDigit(positions.seconds.x[0], positions.seconds.y + widgetYOffset, SEC_SCALE, -1, s1, () => { + drawDigit(positions.seconds.x[1], positions.seconds.y + widgetYOffset, SEC_SCALE, -1, s2, () => { + lastSeconds = -1; + isSeconds = false; + animationTimeouts = []; // Clear animation timeouts to prevent memory leak if (callback) callback(); - }); + }, false, false); }, false, false); } + // ===== ANIMATION CLEANUP ===== + function cancelAllAnimations() { + animationTimeouts.forEach(t => clearTimeout(t)); + animationTimeouts = []; + } + // ===== SWITCHING FUNCTIONS (Optimized with direct state changes) ===== function switchToClockInfo() { - if (showingClockInfo || !clockInfoMenu) return; + if (showingClockInfo || !clockInfoMenu) { + pendingSwitch = false; + return; + } - if (secondsTimeout) clearTimeout(secondsTimeout); + // Mark that we want to switch + pendingSwitch = true; + + // If seconds are drawing, wait a bit and retry + if (isSeconds) { + setTimeout(() => { + if (pendingSwitch) switchToClockInfo(); + }, 100); + return; + } + + // Clear pending flag + pendingSwitch = false; + + cancelAllAnimations(); + if (secondsTimeout) { + clearTimeout(secondsTimeout); + secondsTimeout = null; + } const show = () => { showingClockInfo = true; @@ -512,7 +672,7 @@ g.flip(); }; - if (showSeconds && lastSeconds !== "") { + if (showSeconds && lastSeconds >= 0) { clearSeconds(show); } else { show(); @@ -522,7 +682,8 @@ function hideClockInfo() { if (!showingClockInfo) return; - userPreferClockInfo = false; + pendingSwitch = false; + cancelAllAnimations(); showingClockInfo = false; clockInfoUnfocused = false; @@ -534,11 +695,12 @@ function switchToSeconds() { if (!showingClockInfo || !showSeconds) return; - userPreferClockInfo = false; - userDismissedClockInfo = true; + pendingSwitch = false; + cancelAllAnimations(); + userClockInfoPreference = 'hide'; showingClockInfo = false; clockInfoUnfocused = false; - lastSeconds = ""; + lastSeconds = -1; g.setColor(g.theme.bg); g.fillRect(0, positions.seconds.y + widgetYOffset - 10, width, positions.seconds.y + widgetYOffset + 50); @@ -547,12 +709,19 @@ updateAndAnimTime(); } - // ===== TOUCH HANDLING (Optimized with pre-computed areas) ===== + // ===== TOUCH HANDLING ===== function setupTouchHandler() { - Bangle.on("touch", (_, e) => { + // Remove old handler if exists + if (touchHandler) { + Bangle.removeListener("touch", touchHandler); + } + + // Create new handler + touchHandler = (_, e) => { if (showingClockInfo) { // Check if tap is on clock info area - if (e.y >= secondsAreaTop && e.y <= clockInfoAreaBottom) { + if (e.x >= secondsArea.left && e.x <= secondsArea.right && + e.y >= secondsArea.top && e.y <= secondsArea.bottom) { // Refocus if unfocused if (clockInfoUnfocused) { if (settings.haptics !== false) Bangle.buzz(50); // Haptic feedback for refocus @@ -567,7 +736,7 @@ } // Check main time area for dismissal - if (e.x >= 0 && e.x <= width && e.y >= widgetYOffset && e.y <= mainTimeAreaBottom) { + if (e.y >= mainTimeArea.top && e.y <= mainTimeArea.bottom) { if (!clockInfoUnfocused) { // First tap: unfocus if (settings.haptics !== false) Bangle.buzz(40); // Light haptic for unfocus @@ -581,26 +750,26 @@ // Second tap: dismiss if (settings.haptics !== false) Bangle.buzz(60); // Slightly stronger haptic for dismiss if (showSeconds) { - userPreferClockInfo = false; - userDismissedClockInfo = true; switchToSeconds(); } else { - userDismissedClockInfo = true; + userClockInfoPreference = 'hide'; hideClockInfo(); } } } } else { // Check seconds area for switching to clock info - if (e.x >= secondsAreaLeft && e.x <= secondsAreaRight && - e.y >= secondsAreaTop && e.y <= secondsAreaBottom) { + if (e.x >= secondsArea.left && e.x <= secondsArea.right && + e.y >= secondsArea.top && e.y <= secondsArea.bottom) { if (settings.haptics !== false) Bangle.buzz(50); // Haptic feedback for showing clock info - userPreferClockInfo = true; - userDismissedClockInfo = false; + userClockInfoPreference = 'show'; switchToClockInfo(); } } - }); + }; + + // Add the handler + Bangle.on("touch", touchHandler); } // ===== CLOCK INFO SETUP ===== @@ -667,39 +836,25 @@ function drawClock() { g.clear(Bangle.appRect); if (settings.widgets !== "hide") Bangle.drawWidgets(); - lastTime = ""; - lastSeconds = ""; + lastTime = -1; + lastSeconds = -1; isColonDrawn = false; // Load saved state loadDisplayState(); - // Setup clock info and touch handler - setupClockInfo(); - setupTouchHandler(); - // Determine initial display based on saved preferences and current settings if (showingClockInfo && clockInfoMenu) { // User was viewing clock info - restore it but unfocused clockInfoUnfocused = true; clockInfoMenu.focus = false; clockInfoMenu.redraw(); - } else if (userPreferClockInfo && clockInfoMenu) { + } else if (userClockInfoPreference === 'show' && clockInfoMenu) { // User prefers clock info - show it unfocused showingClockInfo = true; clockInfoUnfocused = true; clockInfoMenu.focus = false; clockInfoMenu.redraw(); - } else if (showSeconds && !userDismissedClockInfo) { - // Seconds are enabled and user hasn't dismissed clock info - showingClockInfo = false; - clockInfoUnfocused = false; - } else if (!showSeconds && !userDismissedClockInfo && settings.seconds === "dynamic" && clockInfoMenu) { - // Dynamic mode when locked - show clock info unfocused if not dismissed - showingClockInfo = true; - clockInfoUnfocused = true; - clockInfoMenu.focus = false; - clockInfoMenu.redraw(); } else { // Default: show nothing in seconds area showingClockInfo = false; @@ -713,16 +868,56 @@ Bangle.setUI({ mode: "clock", remove: function() { + // Stop all drawing isDrawing = false; + pendingSwitch = false; + isSeconds = false; + + // Save current state saveDisplayState(); - if (drawTimeout) clearTimeout(drawTimeout); - if (secondsTimeout) clearTimeout(secondsTimeout); + // Clear all timeouts + if (drawTimeout) { + clearTimeout(drawTimeout); + drawTimeout = null; + } + if (secondsTimeout) { + clearTimeout(secondsTimeout); + secondsTimeout = null; + } + cancelAllAnimations(); + // Remove event handlers + if (touchHandler) { + Bangle.removeListener("touch", touchHandler); + touchHandler = null; + } + if (lockHandler) { + Bangle.removeListener("lock", lockHandler); + lockHandler = null; + } + + // Remove clock info menu if (clockInfoMenu) { clockInfoMenu.remove(); clockInfoMenu = null; } + + // Clear state variables + showingClockInfo = false; + clockInfoUnfocused = false; + userClockInfoPreference = null; + lastTime = -1; + lastSeconds = -1; + isColonDrawn = false; + + // Clear caches + Object.keys(colorCache).forEach(key => delete colorCache[key]); + + // Restore widgets if hidden + if (["hide", "swipe"].includes(settings.widgets)) { + require("widget_utils").show(); + } } }); @@ -731,15 +926,27 @@ if (settings.widgets === "hide") require("widget_utils").hide(); else if (settings.widgets === "swipe") require("widget_utils").swipeOn(); + // ===== SETUP (run once) ===== + setupClockInfo(); + setupTouchHandler(); + // ===== LOCK HANDLER ===== - Bangle.on('lock', function(isLocked) { + // Remove old handler if exists + if (lockHandler) { + Bangle.removeListener('lock', lockHandler); + } + + // Create new handler + lockHandler = function(isLocked) { if (settings.seconds === "dynamic") { showSeconds = !isLocked; + pendingSwitch = false; // Clear any pending switch + if (isLocked) { - if (!showingClockInfo && lastSeconds !== "") { + if (!showingClockInfo && lastSeconds >= 0) { if (secondsTimeout) clearTimeout(secondsTimeout); clearSeconds(() => { - if (!userDismissedClockInfo) { + if (userClockInfoPreference === 'show') { showingClockInfo = true; clockInfoUnfocused = true; if (clockInfoMenu) { @@ -751,15 +958,17 @@ }); } } else { - if (!userPreferClockInfo && showingClockInfo && !userDismissedClockInfo) { + if (showingClockInfo && userClockInfoPreference !== 'show') { switchToSeconds(); - } else if (showSeconds && !showingClockInfo && !userPreferClockInfo) { - showingClockInfo = false; + } else if (showSeconds && !showingClockInfo) { updateAndAnimTime(); } } } - }); + }; + + // Add the handler + Bangle.on('lock', lockHandler); // ===== START CLOCK ===== drawClock(); diff --git a/apps/tileclk/icon.png b/apps/tileclk/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..f6a7f650b5fc46a0a23c9acf951075f38de7650b GIT binary patch literal 11631 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