From 35042ff04ffc50e55ff59a385849442be4429d6b Mon Sep 17 00:00:00 2001 From: cirakg Date: Mon, 19 Oct 2026 22:28:09 +0200 Subject: feat(ui): add the list, OTP detail, PIN entry, menu, info screens and glance List with live codes and countdowns, adaptive rows and lazy headers; OTP detail with ring or bar, next code and HOTP next; PIN keypad or button selector with create, unlock, change, lockout display and pending promotion; native menu; glance with the entry count; self-test after the first frame; option properties with defaults. Codes are computed one per timer event, apart from drawing, for the watchdog. A UI-test build (uitest.jungle) starts with fake entries, PIN 123456 and a frozen clock. Co-Authored-By: Claude Opus 5.5 --- source/ui/Ui.mc | 211 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 211 insertions(+) create mode 100644 source/ui/Ui.mc (limited to 'source/ui/Ui.mc') diff --git a/source/ui/Ui.mc b/source/ui/Ui.mc new file mode 100644 index 0000000..a8d1ec7 --- /dev/null +++ b/source/ui/Ui.mc @@ -0,0 +1,211 @@ +// 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 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; + } + + // 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_COLOR : 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; + } +} -- cgit v1.2.3