// Author: cirakg & claude ai // Summary: Shared drawing helpers: screen shape, usable width on round screens, theme colours, fonts, icons, text fitting. // // Every view draws through these helpers, so round, semi-round, rectangular // and semi-octagon screens and the dark and light themes are handled in one // place. Nothing here allocates in the per-second redraw path except fit(), // which only shortens text that doesn't fit. import Toybox.Graphics; import Toybox.Lang; import Toybox.Math; import Toybox.System; module Ui { const ELLIPSIS = "…"; const WARN_COLOR = 0xFF5555; const LIGHT_WARN_COLOR = 0xCC0000; // 0xFF5555 is too faint on white for small text const ACCENT_COLOR = 0x00AAAA; // the teal of the launcher icon const LIGHT_ACCENT_COLOR = 0x007777; // Round and semi-round screens get rings and narrower rows near the top // and bottom; the others get bars and full-width rows. function isRound() as Boolean { var shape = System.getDeviceSettings().screenShape; return shape == System.SCREEN_SHAPE_ROUND || shape == System.SCREEN_SHAPE_SEMI_ROUND; } function isTouch() as Boolean { return System.getDeviceSettings().isTouchScreen; } // Half the usable width at height y: on round screens the half chord of // the circle there (minus a margin), elsewhere half the screen width. function halfWidthAt(dc as Graphics.Dc, y as Number) as Number { var w = dc.getWidth(); if (!isRound()) { return w / 2 - 4; } var r = w / 2; var dy = (y - dc.getHeight() / 2).abs(); if (dy >= r) { return 0; } return (Math.sqrt(r * r - dy * dy) as Float).toNumber() - 6; } // The usable half width for a band from y0 to y1 (the narrower end). function halfWidthBetween(dc as Graphics.Dc, y0 as Number, y1 as Number) as Number { var a = halfWidthAt(dc, y0); var b = halfWidthAt(dc, y1); return a < b ? a : b; } function dark() as Boolean { return AppState.options.theme == Options.THEME_DARK; } function fg() as Number { return dark() ? Graphics.COLOR_WHITE : Graphics.COLOR_BLACK; } function bg() as Number { return dark() ? Graphics.COLOR_BLACK : Graphics.COLOR_WHITE; } function dim() as Number { return dark() ? Graphics.COLOR_LT_GRAY : Graphics.COLOR_DK_GRAY; } function accent() as Number { return dark() ? ACCENT_COLOR : LIGHT_ACCENT_COLOR; } // Warnings (wrong PIN, the last seconds of a code) in the theme. function warn() as Number { return dark() ? WARN_COLOR : LIGHT_WARN_COLOR; } // Clear the screen in the theme's background. function clear(dc as Graphics.Dc) as Void { dc.setColor(fg(), bg()); dc.clear(); } // The digit font for codes on the detail screen, by the code-size option. // Number fonts only hold digits, so Steam codes (letters) use text fonts. function codeFont(letters as Boolean) as Graphics.FontType { var size = AppState.options.codeSize; if (letters) { return size == Options.CODE_HUGE ? Graphics.FONT_LARGE : Graphics.FONT_MEDIUM; } if (size == Options.CODE_NORMAL) { return Graphics.FONT_NUMBER_MILD; } return size == Options.CODE_HUGE ? Graphics.FONT_NUMBER_HOT : Graphics.FONT_NUMBER_MEDIUM; } // Text shortened with an ellipsis to fit maxWidth pixels. function fit(dc as Graphics.Dc, text as String, font as Graphics.FontType, maxWidth as Number) as String { if (maxWidth <= 0) { return ""; } if (dc.getTextWidthInPixels(text, font) <= maxWidth) { return text; } var length = text.length(); while (length > 0) { length--; var shorter = text.substring(0, length) + ELLIPSIS; if (dc.getTextWidthInPixels(shorter, font) <= maxWidth) { return shorter; } } return ""; } // A small padlock whose body is `size` pixels wide, top-left at (x, y). function drawLock(dc as Graphics.Dc, x as Number, y as Number, size as Number, color as Number) as Void { dc.setColor(color, Graphics.COLOR_TRANSPARENT); var bodyTop = y + size * 2 / 5; dc.setPenWidth(size / 6 > 1 ? size / 6 : 1); dc.drawArc(x + size / 2, bodyTop, size * 3 / 10, Graphics.ARC_COUNTER_CLOCKWISE, 0, 180); dc.fillRectangle(x, bodyTop, size, size * 3 / 5); dc.setPenWidth(1); } // A small barcode icon (vertical bars), `size` pixels square. function drawBarcode(dc as Graphics.Dc, x as Number, y as Number, size as Number, color as Number) as Void { dc.setColor(color, Graphics.COLOR_TRANSPARENT); var widths = [2, 1, 3, 1, 1, 2, 1, 3]; var unit = size / 16 > 0 ? size / 16 : 1; var cx = x; for (var i = 0; i < widths.size() && cx < x + size; i++) { var w = (widths[i] as Number) * unit; if (i % 2 == 0) { dc.fillRectangle(cx, y, w, size); } cx += w + unit; } } // A countdown pie: a ring with the remaining fraction filled. function drawCountdown(dc as Graphics.Dc, cx as Number, cy as Number, r as Number, remaining as Number, period as Number) as Void { dc.setColor(dim(), Graphics.COLOR_TRANSPARENT); dc.setPenWidth(1); dc.drawCircle(cx, cy, r); if (period <= 0 || remaining <= 0) { return; } dc.setColor(remaining <= 5 ? warn() : accent(), Graphics.COLOR_TRANSPARENT); var pen = r / 2 > 1 ? r / 2 : 1; dc.setPenWidth(pen); var sweep = 360 * remaining / period; if (sweep >= 360) { dc.drawCircle(cx, cy, r - pen / 2); } else { dc.drawArc(cx, cy, r - pen / 2, Graphics.ARC_CLOCKWISE, 90, (450 - sweep) % 360); } dc.setPenWidth(1); } // "m:ss" for a number of seconds. function minutesSeconds(seconds as Number) as String { return (seconds / 60) + ":" + (seconds % 60).format("%02d"); } // Split text into lines no wider than maxWidth, at spaces where possible. function wrap(dc as Graphics.Dc, text as String, font as Graphics.FontType, maxWidth as Number) as Array { var lines = [] as Array; var words = split(text, " "); var line = ""; for (var i = 0; i < words.size(); i++) { var candidate = line.length() == 0 ? words[i] : line + " " + words[i]; if (dc.getTextWidthInPixels(candidate, font) <= maxWidth || line.length() == 0) { line = candidate; } else { lines.add(line); line = words[i]; } } if (line.length() > 0) { lines.add(line); } // A single word wider than the line is shortened. for (var i = 0; i < lines.size(); i++) { lines[i] = fit(dc, lines[i], font, maxWidth); } return lines; } // Split text at every occurrence of separator. function split(text as String, separator as String) as Array { var parts = [] as Array; var rest = text; var at = rest.find(separator); while (at != null) { parts.add(rest.substring(0, at) as String); rest = rest.substring(at + separator.length(), rest.length()) as String; at = rest.find(separator); } parts.add(rest); return parts; } // True when text is non-empty and only ASCII digits and spaces. function isDigits(text as String) as Boolean { var chars = text.toCharArray(); for (var i = 0; i < chars.size(); i++) { var c = chars[i]; if ((c < '0' || c > '9') && c != ' ') { return false; } } return chars.size() > 0; } }