Architecture
Fenix8V3View had grown to 632 lines mixing layout constants, font
metrics, icon drawing, data lookup and orchestration. Split into:
Layout the 500-canvas constants, scaling, font choice and text drawing
Icons the three bitmap icons plus the primitive glyphs
Settings every stored preference and its default, in one place
Fields unchanged in scope, but value() is now seven category
functions that each return null for ids they do not own --
the ids interleave, so dispatching on ranges does not work
View what is left: the drawing order
The view is down to 359 lines and no longer reaches for a property or a
font metric directly.
Verification
Added a harness that walks all 75 ids in the simulator and reports each
rendered value with its pixel width. It caught a real one: pushDistance
and pushes compile on every device but throw "Symbol Not Found" at
runtime on a fenix 8 -- they are wheelchair counters that only some
firmware carries. Every optional ActivityMonitor, UserProfile, Weather
and Activity member is now behind a `has` check.
The same run showed two date fields overflowing their slot, so long
values now step down a font size rather than running into the ring.
Always-on mode checked against the simulator's Always-On display mode:
band fill gone, unlit ticks gone, slots hidden, time and date dimmed.
Also: Chinese "Day Date" was missing its 日, since it built the string by
hand instead of going through the localised date format.
All 17 devices build.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
236 lines
9.8 KiB
MonkeyC
236 lines
9.8 KiB
MonkeyC
import Toybox.Lang;
|
|
import Toybox.Graphics;
|
|
import Toybox.Math;
|
|
|
|
// Geometry and text plumbing for the face.
|
|
//
|
|
// Every literal here is a coordinate on the design's 500x500 canvas, measured
|
|
// from design/watchface-*.svg -- ring numbers come from design/rebuild.py, the
|
|
// element positions are the ink bounding boxes of the traced SVG paths. At
|
|
// draw time everything is multiplied by s = screenWidth / 500, so one set of
|
|
// numbers serves screens from 240 px (fenix 7S) to 454 px (venu 3).
|
|
//
|
|
// Call init() from the view's onLayout before anything else touches this.
|
|
module Layout {
|
|
|
|
// ---------------------------------------------------------------- ring
|
|
// Ticks are annular sectors of constant angular width, not radial lines,
|
|
// which is why they are drawn as four-point polygons rather than strokes.
|
|
const R_IN = 231.09; // inner edge of the tick band
|
|
const R_OUT = 249.10; // outer edge
|
|
const TOP_N = 21; // ticks per top arc
|
|
const TOP_PH = 6.500; // angle of tick 0, degrees clockwise from 12
|
|
const TOP_PITCH = 4.000;
|
|
const TOP_HW = 1.554; // half of the 3.108 deg tick width
|
|
const BOT_N = 21; // ticks per bottom arc
|
|
const BOT_PH = 6.005; // measured clockwise from 6 o'clock
|
|
const BOT_PITCH = 3.000;
|
|
const BOT_HW = 1.043;
|
|
const DOT_RAD = 240.10; // anchor dots sit on the band's mid-radius
|
|
const DOT_R = 9.00;
|
|
const DOT_ANG = [0.0, 93.15, 180.0, 266.85];
|
|
|
|
// ------------------------------------------------------------- elements
|
|
const BATT_X = 192.70; // battery body, left edge
|
|
const BATT_W = 26.20; // body only; the terminal sits to its right
|
|
const BATT_H = 17.81;
|
|
const BATT_WALL = 2.20; // outline thickness
|
|
const BATT_TW = 2.53; // terminal
|
|
const BATT_TH = 9.20;
|
|
const BATT_VX = 232.00; // "40%" left edge
|
|
const BATT_CY = 53.20;
|
|
|
|
const LEFT_X = 157.00; // top data slots, centred
|
|
const RIGHT_X = 343.70;
|
|
const ICON_CY = 89.50; // matches the icon bitmaps' own centre
|
|
const VALUE_CY = 131.06;
|
|
|
|
const TIME_CY = 221.10;
|
|
const HOURS_R = 211.40; // hour digits, right edge
|
|
const MINUTES_L = 276.02; // minute digits, left edge
|
|
const COLON_X = 237.88;
|
|
const COLON_W = 24.24;
|
|
const COLON_H = 21.30;
|
|
const COLON_Y1 = 183.68;
|
|
const COLON_Y2 = 234.57;
|
|
|
|
const BAND_Y = 286.30;
|
|
const BAND_H = 44.19;
|
|
const BAND_CY = 308.10;
|
|
const BT_CX = 36.88; // bluetooth rune
|
|
const BT_CY = 306.99;
|
|
const BT_HW = 8.89;
|
|
const BT_HH = 12.92;
|
|
const DOW_CX = 178.05;
|
|
const MD_CX = 298.28;
|
|
const AMPM_R = 465.89;
|
|
|
|
const BOT_VAL_CY = 366.76;
|
|
const BOT_LAB_CY = 410.88;
|
|
const DIST_R = 234.30; // right edge of both left-hand bottom lines
|
|
const STEP_L = 267.40; // left edge of both right-hand bottom lines
|
|
|
|
// Cap heights of the design's three text sizes.
|
|
const CAP_BAND = 25.34; // date band, battery percentage
|
|
const CAP_COMP = 31.50; // data slots, bottom row
|
|
const CAP_TIME = 98.70; // time digits
|
|
|
|
// How much room a value may take before it is stepped down a size. The
|
|
// top slots are centred, so this is the full width they may span; the
|
|
// bottom pair are edge-aligned and share the middle of the dial.
|
|
const TOP_MAX_W = 185.0;
|
|
const BOT_MAX_W = 150.0;
|
|
|
|
// Connect IQ exposes ascent and descent but not cap height, and the ascent
|
|
// reserves room for diacritics that no digit or capital ever uses. These
|
|
// two ratios were measured by rendering into the simulator and reading the
|
|
// ink back off the screenshot (fenix847mm: FONT_XTINY ascent 29 -> caps
|
|
// 22 px; FONT_NUMBER_MILD ascent 82 -> digits 58 px).
|
|
const CAP_OF_TEXT = 0.76;
|
|
const CAP_OF_NUM = 0.71;
|
|
|
|
// --------------------------------------------------------------- state
|
|
var scale = 1.0; // device px per design px
|
|
var width = 0;
|
|
var height = 0;
|
|
var cx = 0;
|
|
var cy = 0;
|
|
var fontBand = Graphics.FONT_SMALL;
|
|
var fontComp = Graphics.FONT_MEDIUM;
|
|
var fontTime = Graphics.FONT_NUMBER_HOT;
|
|
|
|
// Text sizes in ascending order, so a value that will not fit can walk
|
|
// down the list until it does.
|
|
var textFonts = [Graphics.FONT_XTINY, Graphics.FONT_TINY, Graphics.FONT_SMALL,
|
|
Graphics.FONT_MEDIUM, Graphics.FONT_LARGE];
|
|
|
|
function init(dc as Graphics.Dc) as Void {
|
|
width = dc.getWidth();
|
|
height = dc.getHeight();
|
|
scale = width / 500.0;
|
|
cx = width / 2;
|
|
cy = height / 2;
|
|
var nums = [Graphics.FONT_NUMBER_MILD, Graphics.FONT_NUMBER_MEDIUM,
|
|
Graphics.FONT_NUMBER_HOT, Graphics.FONT_NUMBER_THAI_HOT];
|
|
fontBand = pickFont(textFonts, CAP_BAND * scale, CAP_OF_TEXT);
|
|
fontComp = pickFont(textFonts, CAP_COMP * scale, CAP_OF_TEXT);
|
|
fontTime = pickFont(nums, CAP_TIME * scale, CAP_OF_NUM);
|
|
}
|
|
|
|
function ready() as Boolean { return width > 0; }
|
|
|
|
// Design pixels to device pixels.
|
|
function px(v as Numeric) as Number {
|
|
return Math.round(v * scale).toNumber();
|
|
}
|
|
|
|
function capHeight(font as Graphics.FontDefinition, ratio as Float) as Float {
|
|
return Graphics.getFontAscent(font) * ratio;
|
|
}
|
|
|
|
// Closest cap height wins -- not "largest that still fits", which throws
|
|
// away a whole size whenever the design falls between two fonts.
|
|
function pickFont(cands as Array<Graphics.FontDefinition>, targetCap as Float,
|
|
ratio as Float) as Graphics.FontDefinition {
|
|
var best = cands[0];
|
|
var bestErr = -1.0;
|
|
for (var i = 0; i < cands.size(); i++) {
|
|
var err = capHeight(cands[i], ratio) - targetCap;
|
|
if (err < 0) { err = -err; }
|
|
if (bestErr < 0 || err < bestErr) { bestErr = err; best = cands[i]; }
|
|
}
|
|
return best;
|
|
}
|
|
|
|
// A long value -- twelve characters of custom text, say -- would run off
|
|
// the dial at the design's size. Step down until it fits the slot.
|
|
function fitFont(dc as Graphics.Dc, str as String,
|
|
font as Graphics.FontDefinition, maxDesignW as Float)
|
|
as Graphics.FontDefinition {
|
|
var limit = px(maxDesignW);
|
|
var f = font;
|
|
var i = textFonts.size() - 1;
|
|
while (i >= 0) {
|
|
if (textFonts[i] == f) { break; }
|
|
i--;
|
|
}
|
|
if (i < 0) { return f; } // not one of the text fonts
|
|
while (i >= 0) {
|
|
if (dc.getTextWidthInPixels(str, textFonts[i]) <= limit) {
|
|
return textFonts[i];
|
|
}
|
|
i--;
|
|
}
|
|
return textFonts[0];
|
|
}
|
|
|
|
// TEXT_JUSTIFY_VCENTER centres the font box, but capitals occupy only the
|
|
// band from the baseline up by capHeight, so the ink sits high in that box.
|
|
// Push the draw down by the difference to land the ink on cy.
|
|
function inkY(dc as Graphics.Dc, font as Graphics.FontDefinition,
|
|
cyDesign as Numeric, ratio as Float) as Number {
|
|
var fh = dc.getFontHeight(font);
|
|
var asc = Graphics.getFontAscent(font);
|
|
return px(cyDesign) + ((fh / 2.0) - asc + capHeight(font, ratio) / 2.0).toNumber();
|
|
}
|
|
|
|
function text(dc as Graphics.Dc, x as Numeric, cyDesign as Numeric,
|
|
font as Graphics.FontDefinition, str as String,
|
|
just as Number, col as Number) as Void {
|
|
dc.setColor(col, Graphics.COLOR_TRANSPARENT);
|
|
dc.drawText(px(x), inkY(dc, font, cyDesign, CAP_OF_TEXT), font, str,
|
|
just | Graphics.TEXT_JUSTIFY_VCENTER);
|
|
}
|
|
|
|
// Same, but shrinks the font if the string is too wide for its slot.
|
|
function fittedText(dc as Graphics.Dc, x as Numeric, cyDesign as Numeric,
|
|
font as Graphics.FontDefinition, str as String,
|
|
just as Number, col as Number, maxDesignW as Float) as Void {
|
|
text(dc, x, cyDesign, fitFont(dc, str, font, maxDesignW), str, just, col);
|
|
}
|
|
|
|
// Monkey C cannot fill glyphs with a gradient, so the string is drawn once
|
|
// per stop with dc.setClip holding each pass to its own horizontal band.
|
|
// The first and last band are extended off-glyph so no sliver of the
|
|
// digits is ever left undrawn.
|
|
function gradText(dc as Graphics.Dc, x as Numeric, cyDesign as Numeric,
|
|
font as Graphics.FontDefinition, str as String,
|
|
just as Number, ramp as Array<Number>) as Void {
|
|
var n = ramp.size();
|
|
var y = inkY(dc, font, cyDesign, CAP_OF_NUM);
|
|
var ink = capHeight(font, CAP_OF_NUM).toNumber();
|
|
var top = px(cyDesign) - ink / 2;
|
|
for (var i = 0; i < n; i++) {
|
|
var y0 = top + (ink * i) / n;
|
|
var y1 = top + (ink * (i + 1)) / n;
|
|
if (i == 0) { y0 = 0; }
|
|
if (i == n - 1) { y1 = height; }
|
|
if (y1 <= y0) { continue; }
|
|
dc.setClip(0, y0, width, y1 - y0);
|
|
dc.setColor(ramp[i], Graphics.COLOR_TRANSPARENT);
|
|
dc.drawText(px(x), y, font, str, just | Graphics.TEXT_JUSTIFY_VCENTER);
|
|
}
|
|
dc.clearClip();
|
|
}
|
|
|
|
// Design angles run clockwise from 12 o'clock; screen y grows downward.
|
|
function polarX(ang as Float, r as Float) as Number {
|
|
return Math.round(cx + Math.sin(Math.toRadians(ang)) * r).toNumber();
|
|
}
|
|
|
|
function polarY(ang as Float, r as Float) as Number {
|
|
return Math.round(cy - Math.cos(Math.toRadians(ang)) * r).toNumber();
|
|
}
|
|
|
|
function line(dc as Graphics.Dc, x1 as Numeric, y1 as Numeric,
|
|
x2 as Numeric, y2 as Numeric) as Void {
|
|
dc.drawLine(px(x1), px(y1), px(x2), px(y2));
|
|
}
|
|
|
|
// Pen widths round to zero on small screens; never let one vanish.
|
|
function pen(v as Numeric) as Number {
|
|
var w = px(v);
|
|
return w < 2 ? 2 : w;
|
|
}
|
|
}
|