summaryrefslogtreecommitdiff
path: root/source/ui/Ui.mc
diff options
context:
space:
mode:
Diffstat (limited to 'source/ui/Ui.mc')
-rw-r--r--source/ui/Ui.mc211
1 files changed, 211 insertions, 0 deletions
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<String> {
+ var lines = [] as Array<String>;
+ 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<String> {
+ var parts = [] as Array<String>;
+ 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;
+ }
+}