Split the face into modules, comment it, and verify every field

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>
This commit is contained in:
2026-09-10 05:48:58 +08:00
parent d457f8244e
commit 52346bdd09
7 changed files with 781 additions and 499 deletions

View File

@@ -27,9 +27,12 @@ fenix8v3-watchface/
│ ├── gen_icons.py # design/*.svg -> 各尺寸图标位图 + 启动图标 │ ├── gen_icons.py # design/*.svg -> 各尺寸图标位图 + 启动图标
│ └── gen_preview_svg.py # source/Themes.mc -> preview-themes.svg + preview.html │ └── gen_preview_svg.py # source/Themes.mc -> preview-themes.svg + preview.html
├── source/ ├── source/
│ ├── Fenix8V3App.mc # 应用入口 │ ├── Fenix8V3App.mc # 应用入口12 行壳子)
│ ├── Fenix8V3View.mc # 渲染逻辑(含常亮模式 │ ├── Fenix8V3View.mc # 绘制顺序:进度环 / 各元素 / 常亮模式
│ ├── Layout.mc # 500 画布常量、缩放、字号选择、文字绘制
│ ├── Icons.mc # 图标:位图三件套 + 其余图元
│ ├── Fields.mc # 75 项数据字段的取值与格式化 │ ├── Fields.mc # 75 项数据字段的取值与格式化
│ ├── Settings.mc # 所有设置项的读取与默认值
│ ├── FieldTable.mc # ⚠️ 自动生成:字段 id 表 │ ├── FieldTable.mc # ⚠️ 自动生成:字段 id 表
│ └── Themes.mc # ⚠️ 自动生成:七套主题配色 │ └── Themes.mc # ⚠️ 自动生成:七套主题配色
├── resources/ # 英文文案 + 设置项定义(部分自动生成) ├── resources/ # 英文文案 + 设置项定义(部分自动生成)
@@ -50,6 +53,23 @@ python3 tools/gen_themes.py && python3 tools/gen_fields.py \
--- ---
## 1.5 代码结构
一句话:**位置归 `Layout`,内容归 `Fields`,配色归 `Themes`,设置归 `Settings``Fenix8V3View` 只剩画的顺序。**
| 模块 | 负责 | 不负责 |
|------|------|--------|
| `Layout` | 500 基准画布的全部坐标常量、`px()` 缩放、极坐标换算、字号选择与自动缩字、渐变文字的分带绘制 | 具体画什么 |
| `Icons` | 三个位图图标的加载与染色、电池、蓝牙、其余数据项的图元 | 图标该出现在哪个数据位(由字段 id 决定,但查表在这里) |
| `Fields` | 75 项字段的取值、单位换算、本地化文案(星期/月份/标签) | 排版 |
| `Settings` | 每个设置项的读取与默认值,包含空值回退 | 设置项的 UI 定义(在 `settings.xml` |
| `Fenix8V3View` | `onUpdate` 的绘制顺序、进度环、常亮模式 | 以上任何一项的细节 |
`Fields.value()` 按分类拆成 7 个函数(`dateTime` / `activity` / `body` / `system` / `environment` / `weather` / `custom`),每个在不认识 id 时返回 `null``value()` 依次尝试。id 是交错的1、2、3 是活动8、11、12 是身体14 又是活动),所以按区间派发行不通。
`Fields.Ctx` 每次绘制构造一个,把 `ActivityMonitor` / `Weather` / `UserProfile` / `SensorHistory` 的查询**懒加载并缓存**——四个数据位可能都要天气,但只查一次;没有数据位用到 `UserProfile` 时就完全不查。
## 2. 布局500 基准,取自设计稿 SVG 路径的墨迹包围盒) ## 2. 布局500 基准,取自设计稿 SVG 路径的墨迹包围盒)
| 元素 | 位置 | 说明 | | 元素 | 位置 | 说明 |
@@ -61,7 +81,7 @@ python3 tools/gen_themes.py && python3 tools/gen_fields.py \
| 时间 | `cy=221.10`;小时右对齐 `x=211.40`,分钟左对齐 `x=276.02` | 竖向渐变,见 §4 | | 时间 | `cy=221.10`;小时右对齐 `x=211.40`,分钟左对齐 `x=276.02` | 竖向渐变,见 §4 |
| 冒号 | `x=237.88``24.24×21.30``y=183.68 / 234.57` | 两块矩形 | | 冒号 | `x=237.88``24.24×21.30``y=183.68 / 234.57` | 两块矩形 |
| 日期带 | `y=286.30`,高 `44.19`,文字 `cy=308.10` | 蓝牙 `cx=36.88`、星期 `cx=178.05`、月日 `cx=298.28`、AM/PM 右对齐 `465.89` | | 日期带 | `y=286.30`,高 `44.19`,文字 `cy=308.10` | 蓝牙 `cx=36.88`、星期 `cx=178.05`、月日 `cx=298.28`、AM/PM 右对齐 `465.89` |
| 底部数值 | `cy=366.76`距离右对齐 `234.30`步数左对齐 `267.40` | | | 底部数值 | `cy=366.76`左格右对齐 `234.30`右格左对齐 `267.40` | 过宽时自动降一档字号 |
| 底部标签 | `cy=410.88`,同上对齐 | 跟随所选数据(`DST` / `STEP` / `距离` …) | | 底部标签 | `cy=410.88`,同上对齐 | 跟随所选数据(`DST` / `STEP` / `距离` …) |
字号按设计稿的**字面高度cap height**选取:日期带与电量 `25.34`、上方数据与底部行 `31.50`、时间 `98.70` 字号按设计稿的**字面高度cap height**选取:日期带与电量 `25.34`、上方数据与底部行 `31.50`、时间 `98.70`

View File

@@ -2,6 +2,9 @@ import Toybox.Application;
import Toybox.Lang; import Toybox.Lang;
import Toybox.WatchUi; import Toybox.WatchUi;
// Application shell. A watch face has no menus or input of its own, so this
// only hands back the view and forces a repaint when the wearer changes
// something in the Connect IQ settings.
class Fenix8V3App extends Application.AppBase { class Fenix8V3App extends Application.AppBase {
function initialize() { function initialize() {
@@ -18,6 +21,8 @@ class Fenix8V3App extends Application.AppBase {
return [new Fenix8V3View()]; return [new Fenix8V3View()];
} }
// Fires when the phone pushes new settings down. The view reads every
// preference fresh on each draw, so a repaint is all that is needed.
function onSettingsChanged() as Void { function onSettingsChanged() as Void {
WatchUi.requestUpdate(); WatchUi.requestUpdate();
} }

View File

@@ -1,103 +1,30 @@
import Toybox.Application;
import Toybox.Graphics; import Toybox.Graphics;
import Toybox.Lang; import Toybox.Lang;
import Toybox.Math; import Toybox.Math;
import Toybox.ActivityMonitor; import Toybox.ActivityMonitor;
import Toybox.Activity; import Toybox.SensorHistory;
import Toybox.Position;
import Toybox.Weather;
import Toybox.System; import Toybox.System;
import Toybox.Time; import Toybox.Time;
import Toybox.Time.Gregorian; import Toybox.Time.Gregorian;
import Toybox.WatchUi; import Toybox.WatchUi;
import Themes; import Themes;
import Fields; import Fields;
import Toybox.SensorHistory; import Layout;
import Icons;
import Settings;
// Every literal below is a coordinate on the 500x500 design canvas, measured // The face itself: the progress ring, the elements laid over it, and the
// from design/watchface-*.svg. Nothing here is eyeballed: ring geometry comes // stripped-down variant drawn while an AMOLED watch is asleep.
// from design/rebuild.py, element positions are the ink bounding boxes of the //
// traced SVG paths. At draw time everything is multiplied by s = width / 500. // Anything about *where* things go lives in Layout, anything about *what* a
// data slot says lives in Fields, and the colours come from Themes. What is
// left here is the drawing order.
class Fenix8V3View extends WatchUi.WatchFace { class Fenix8V3View extends WatchUi.WatchFace {
// ---------------------------------------------------------------- ring // Always-on display state. AMOLED panels have to cut their lit pixel
// Ticks are annular sectors of constant angular width, not radial lines. // count while asleep; MIP panels do not care and are left alone.
const R_IN = 231.09;
const R_OUT = 249.10;
const TOP_N = 21;
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;
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; // distance of the anchor dots from centre
const DOT_R = 9.00;
const DOT_ANG = [0.0, 93.15, 180.0, 266.85];
// ------------------------------------------------------------- layout
const BATT_X = 192.70; // battery body, left edge
const BATT_Y = 44.02;
const BATT_W = 26.20; // body only, terminal sits to its right
const BATT_H = 17.81;
const BATT_WALL = 2.20;
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 complications, centred
const RIGHT_X = 343.70;
const ICON_CY = 89.50;
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;
const BT_CY = 306.99;
const BT_HW = 8.89; // half width of the bluetooth rune
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 DST lines
const STEP_L = 267.40; // left edge of both STEP lines
// Cap heights of the three text sizes in the design.
const CAP_BAND = 25.34; // date band, battery percentage
const CAP_COMP = 31.50; // complications, bottom row
const CAP_TIME = 98.70; // time digits
// ------------------------------------------------------------- cached
private var mS = 1.0f;
private var mW = 0;
private var mH = 0;
private var mCx = 0;
private var mCy = 0;
private var mFontBand = Graphics.FONT_SMALL;
private var mFontComp = Graphics.FONT_MEDIUM;
private var mFontTime = Graphics.FONT_NUMBER_HOT;
// Always-on display: AMOLED devices must cut lit pixels hard while
// asleep, and shift what is left so nothing burns in.
private var mSleeping = false; private var mSleeping = false;
private var mBurnIn = false; private var mBurnIn = false;
private var mIconSunrise = null;
private var mIconSunset = null;
private var mIconWeather = null;
function initialize() { function initialize() {
WatchFace.initialize(); WatchFace.initialize();
@@ -113,157 +40,49 @@ class Fenix8V3View extends WatchUi.WatchFace {
WatchUi.requestUpdate(); WatchUi.requestUpdate();
} }
// Called once when the face is loaded, and again by onUpdate if anything
// it caches has gone stale (a resolution change, or resources not yet in).
function onLayout(dc as Dc) as Void { function onLayout(dc as Dc) as Void {
mW = dc.getWidth(); Layout.init(dc);
mH = dc.getHeight(); Fields.loadStrings();
mS = mW / 500.0; Icons.load();
mCx = mW / 2;
mCy = mH / 2;
var ds = System.getDeviceSettings(); var ds = System.getDeviceSettings();
mBurnIn = (ds != null) && (ds has :requiresBurnInProtection) mBurnIn = (ds != null) && (ds has :requiresBurnInProtection)
&& ds.requiresBurnInProtection; && ds.requiresBurnInProtection;
var text = [Graphics.FONT_XTINY, Graphics.FONT_TINY, Graphics.FONT_SMALL,
Graphics.FONT_MEDIUM, Graphics.FONT_LARGE];
var nums = [Graphics.FONT_NUMBER_MILD, Graphics.FONT_NUMBER_MEDIUM,
Graphics.FONT_NUMBER_HOT, Graphics.FONT_NUMBER_THAI_HOT];
mFontBand = pickFont(text, CAP_BAND * mS, CAP_OF_TEXT);
mFontComp = pickFont(text, CAP_COMP * mS, CAP_OF_TEXT);
mFontTime = pickFont(nums, CAP_TIME * mS, CAP_OF_NUM);
loadStrings();
} }
function loadStrings() as Void { // ------------------------------------------------------------- ring
Fields.loadStrings(); // Progress for one of the four arcs, 0..1. These ids are the ring's own
mIconSunrise = WatchUi.loadResource(Rez.Drawables.IconSunrise); // short list, not the data field catalogue.
mIconSunset = WatchUi.loadResource(Rez.Drawables.IconSunset); function ringProgress(idx as Number, info as ActivityMonitor.Info?) as Float {
mIconWeather = WatchUi.loadResource(Rez.Drawables.IconWeather);
}
// Connect IQ exposes ascent and descent but not cap height, and the ascent
// reserves room for diacritics no digit or capital ever uses. These two
// ratios were measured by rendering in the simulator and reading the ink
// back off the screenshot (fenix847mm: xtiny ascent 29 -> caps 22 px,
// numberMild ascent 82 -> digits 58 px).
static const CAP_OF_TEXT = 0.76;
static const CAP_OF_NUM = 0.71;
function capHeight(font as Graphics.FontDefinition, ratio as Float) as Float {
return Graphics.getFontAscent(font) * ratio;
}
// Closest cap height wins -- not the largest that fits, which would leave a
// whole size on the table 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;
}
function px(v as Numeric) as Number {
return Math.round(v * mS).toNumber();
}
// ----------------------------------------------------------------- utils
function getSetting(name as String, def as Number) as Number {
var v = Application.Properties.getValue(name);
if (v == null) { return def; }
return v as Number;
}
function getBool(name as String, def as Boolean) as Boolean {
var v = Application.Properties.getValue(name);
if (v == null) { return def; }
return v as Boolean;
}
function themeIndex() as Number {
var i = getSetting("Theme", 1) - 1;
if (i < 0 || i >= Themes.NAMES.size()) { i = 0; }
return i;
}
// TEXT_JUSTIFY_VCENTER centres the font box. Caps 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(font as Graphics.FontDefinition, dc as Dc, cy as Numeric,
ratio as Float) as Number {
var fh = dc.getFontHeight(font);
var asc = Graphics.getFontAscent(font);
return px(cy) + ((fh / 2.0) - asc + capHeight(font, ratio) / 2.0).toNumber();
}
function text(dc as Dc, x as Numeric, cy 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(font, dc, cy, CAP_OF_TEXT), font, str,
just | Graphics.TEXT_JUSTIFY_VCENTER);
}
// Draw str once per gradient stop, each pass clipped to its own horizontal
// band -- Monkey C cannot fill glyphs with a gradient directly. The first
// and last band are extended off-glyph so nothing is ever left undrawn.
function gradText(dc as Dc, x as Numeric, cy 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(font, dc, cy, CAP_OF_NUM);
var ink = capHeight(font, CAP_OF_NUM).toNumber();
var top = px(cy) - 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 = mH; }
if (y1 <= y0) { continue; }
dc.setClip(0, y0, mW, 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.
function polarX(ang as Float, r as Float) as Number {
return Math.round(mCx + Math.sin(Math.toRadians(ang)) * r).toNumber();
}
function polarY(ang as Float, r as Float) as Number {
return Math.round(mCy - Math.cos(Math.toRadians(ang)) * r).toNumber();
}
// progress 0..1 for a metric index
function metricProgress(idx as Number, info as ActivityMonitor.Info) as Float {
if (info == null) { return 0.0f; } if (info == null) { return 0.0f; }
var p = 0.0f; var p = 0.0f;
if (idx == 0) { // Steps if (idx == 0) { // steps
if (info.stepGoal != null && info.stepGoal > 0 && info.steps != null) { if (info.stepGoal != null && info.stepGoal > 0 && info.steps != null) {
p = info.steps.toFloat() / info.stepGoal.toFloat(); p = info.steps.toFloat() / info.stepGoal.toFloat();
} }
} else if (idx == 1) { // Calories } else if (idx == 1) { // calories
if (info.calories != null) { p = info.calories.toFloat() / 2000.0f; } if (info.calories != null) { p = info.calories.toFloat() / 2000.0f; }
} else if (idx == 2) { // Distance (cm -> 10 km cap) } else if (idx == 2) { // distance, 10 km ring
if (info.distance != null) { p = info.distance.toFloat() / 1000000.0f; } if (info.distance != null) { p = info.distance.toFloat() / 1000000.0f; }
} else if (idx == 3) { // Floors } else if (idx == 3) { // floors
if (info.floorsClimbed != null) { if (info.floorsClimbed != null) {
var goal = (info.floorsClimbedGoal != null && info.floorsClimbedGoal > 0) var goal = (info.floorsClimbedGoal != null && info.floorsClimbedGoal > 0)
? info.floorsClimbedGoal.toFloat() : 10.0f; ? info.floorsClimbedGoal.toFloat() : 10.0f;
p = info.floorsClimbed.toFloat() / goal; p = info.floorsClimbed.toFloat() / goal;
} }
} else if (idx == 5) { // Active minutes vs the weekly goal } else if (idx == 5) { // active minutes vs week goal
if (info.activeMinutesWeek != null && info.activeMinutesWeekGoal != null if ((info has :activeMinutesWeek) && info.activeMinutesWeek != null
&& (info has :activeMinutesWeekGoal)
&& info.activeMinutesWeekGoal != null
&& info.activeMinutesWeekGoal > 0) { && info.activeMinutesWeekGoal > 0) {
p = info.activeMinutesWeek.total.toFloat() p = info.activeMinutesWeek.total.toFloat()
/ info.activeMinutesWeekGoal.toFloat(); / info.activeMinutesWeekGoal.toFloat();
} }
} else if (idx == 6) { // Body battery is already 0..100 } else if (idx == 6) { // body battery is already 0..100
var bb = Fields.latest(SensorHistory.getBodyBatteryHistory({:period => 1})); var bb = Fields.latest(SensorHistory.getBodyBatteryHistory({:period => 1}));
if (bb != null) { p = bb.toFloat() / 100.0; } if (bb != null) { p = bb.toFloat() / 100.0; }
} else if (idx == 4) { // Move bar: an integer level, not a ratio } else if (idx == 4) { // move bar: a level, not a ratio
if (info.moveBarLevel != null) { if (info.moveBarLevel != null) {
var span = (ActivityMonitor.MOVE_BAR_LEVEL_MAX var span = (ActivityMonitor.MOVE_BAR_LEVEL_MAX
- ActivityMonitor.MOVE_BAR_LEVEL_MIN).toFloat(); - ActivityMonitor.MOVE_BAR_LEVEL_MIN).toFloat();
@@ -277,14 +96,17 @@ class Fenix8V3View extends WatchUi.WatchFace {
return p; return p;
} }
// ----------------------------------------------------------------- ring // One tick: an annular sector drawn as a quad. At three degrees the chord
// sagitta is under a tenth of a pixel, so straight edges are exact enough.
function tick(dc as Dc, ang as Float, hw as Float) as Void { function tick(dc as Dc, ang as Float, hw as Float) as Void {
var a0 = ang - hw; var a0 = ang - hw;
var a1 = ang + hw; var a1 = ang + hw;
dc.fillPolygon([[polarX(a0, R_OUT * mS), polarY(a0, R_OUT * mS)], var ro = Layout.R_OUT * Layout.scale;
[polarX(a1, R_OUT * mS), polarY(a1, R_OUT * mS)], var ri = Layout.R_IN * Layout.scale;
[polarX(a1, R_IN * mS), polarY(a1, R_IN * mS)], dc.fillPolygon([[Layout.polarX(a0, ro), Layout.polarY(a0, ro)],
[polarX(a0, R_IN * mS), polarY(a0, R_IN * mS)]]); [Layout.polarX(a1, ro), Layout.polarY(a1, ro)],
[Layout.polarX(a1, ri), Layout.polarY(a1, ri)],
[Layout.polarX(a0, ri), Layout.polarY(a0, ri)]]);
} }
// Both top arcs fill from the 9/3 o'clock end towards 12; both bottom arcs // Both top arcs fill from the 9/3 o'clock end towards 12; both bottom arcs
@@ -306,178 +128,104 @@ class Fenix8V3View extends WatchUi.WatchFace {
var bot = Themes.RINGBOTTOM[ti] as Array<Number>; var bot = Themes.RINGBOTTOM[ti] as Array<Number>;
var off = Themes.TICKOFF[ti] as Number; var off = Themes.TICKOFF[ti] as Number;
arc(dc, top, off, (pTR * TOP_N).toNumber(), TOP_PH, TOP_PITCH, TOP_HW, TOP_N, arc(dc, top, off, (pTR * Layout.TOP_N).toNumber(), Layout.TOP_PH,
0.0, false, true); Layout.TOP_PITCH, Layout.TOP_HW, Layout.TOP_N, 0.0, false, true);
arc(dc, top, off, (pTL * TOP_N).toNumber(), TOP_PH, TOP_PITCH, TOP_HW, TOP_N, arc(dc, top, off, (pTL * Layout.TOP_N).toNumber(), Layout.TOP_PH,
0.0, true, true); Layout.TOP_PITCH, Layout.TOP_HW, Layout.TOP_N, 0.0, true, true);
arc(dc, bot, off, (pBL * BOT_N).toNumber(), BOT_PH, BOT_PITCH, BOT_HW, BOT_N, arc(dc, bot, off, (pBL * Layout.BOT_N).toNumber(), Layout.BOT_PH,
180.0, false, false); Layout.BOT_PITCH, Layout.BOT_HW, Layout.BOT_N, 180.0, false, false);
arc(dc, bot, off, (pBR * BOT_N).toNumber(), BOT_PH, BOT_PITCH, BOT_HW, BOT_N, arc(dc, bot, off, (pBR * Layout.BOT_N).toNumber(), Layout.BOT_PH,
180.0, true, false); Layout.BOT_PITCH, Layout.BOT_HW, Layout.BOT_N, 180.0, true, false);
dc.setColor(Themes.ANCHORDOT[ti] as Number, Graphics.COLOR_TRANSPARENT); dc.setColor(Themes.ANCHORDOT[ti] as Number, Graphics.COLOR_TRANSPARENT);
var r = px(DOT_R); var r = Layout.px(Layout.DOT_R);
for (var i = 0; i < DOT_ANG.size(); i++) { var rad = Layout.DOT_RAD * Layout.scale;
var a = DOT_ANG[i]; for (var i = 0; i < Layout.DOT_ANG.size(); i++) {
dc.fillCircle(polarX(a, DOT_RAD * mS), polarY(a, DOT_RAD * mS), r); var a = Layout.DOT_ANG[i];
dc.fillCircle(Layout.polarX(a, rad), Layout.polarY(a, rad), r);
} }
} }
// ------------------------------------------------------------ icons // ---------------------------------------------------------- elements
// The design ships three icons; they are rebuilt here from primitives at function drawBattery(dc as Dc, ti as Number, level as Float) as Void {
// the sub-shape positions measured out of the SVG paths. Icons.battery(dc, Layout.BATT_X, Layout.BATT_CY, 1.0,
function iconBattery(dc as Dc, x as Numeric, cy as Numeric, scale as Float, Themes.ACCENT[ti] as Number, level);
col as Number, level as Float) as Void { Layout.text(dc, Layout.BATT_VX, Layout.BATT_CY, Layout.fontBand,
var w = BATT_W * scale; (level * 100.0).toNumber().format("%d") + "%",
var h = BATT_H * scale; Graphics.TEXT_JUSTIFY_LEFT, Themes.TEXTPRIMARY[ti] as Number);
var wall = BATT_WALL * scale;
var x0 = px(x);
var y0 = px(cy - h / 2);
var pw = px(w);
var ph = px(h);
var r = px(3.4 * scale);
dc.setColor(col, Graphics.COLOR_TRANSPARENT);
dc.fillRoundedRectangle(x0, y0, pw, ph, r);
dc.fillRectangle(x0 + pw, px(cy - BATT_TH * scale / 2),
px(BATT_TW * scale), px(BATT_TH * scale));
var ix = x0 + px(wall);
var iy = y0 + px(wall);
var iw = pw - 2 * px(wall);
var ih = ph - 2 * px(wall);
dc.setColor(Graphics.COLOR_BLACK, Graphics.COLOR_TRANSPARENT);
dc.fillRectangle(ix, iy, iw, ih);
var fill = (iw * level).toNumber();
if (fill > 0) {
dc.setColor(col, Graphics.COLOR_TRANSPARENT);
dc.fillRectangle(ix, iy, fill, ih);
}
} }
function stub(dc as Dc, x1 as Numeric, y1 as Numeric, x2 as Numeric, y2 as Numeric) as Void { function drawTopSlot(dc as Dc, ti as Number, id as Number, isLeft as Boolean,
dc.drawLine(px(x1), px(y1), px(x2), px(y2)); ctx as Fields.Ctx, level as Float) as Void {
var x = isLeft ? Layout.LEFT_X : Layout.RIGHT_X;
Icons.forField(dc, id, x, Themes.ACCENT[ti] as Number, level);
Layout.fittedText(dc, x, Layout.VALUE_CY, Layout.fontComp,
Fields.value(id, ctx), Graphics.TEXT_JUSTIFY_CENTER,
Themes.TEXTPRIMARY[ti] as Number, Layout.TOP_MAX_W);
} }
// The comps' sunrise and weather icons are traced outlines with two- and function drawDateBand(dc as Dc, ti as Number, ds as System.DeviceSettings?) as Void {
// three-pixel features. Rebuilding them from circles and lines held up at
// 454 px and turned to mush at 280, so they ship as bitmaps rasterised
// from the design paths at each screen size (tools/gen_icons.py) and get
// the theme's accent applied through :tintColor.
function blitIcon(dc as Dc, bmp, cx as Numeric, cy as Numeric, col as Number) as Void {
if (bmp == null) { return; }
dc.drawBitmap2(px(cx) - bmp.getWidth() / 2, px(cy) - bmp.getHeight() / 2,
bmp, {:tintColor => col});
}
function maxNum(a as Number, b as Number) as Number {
return a > b ? a : b;
}
function drawComplicationIcon(dc as Dc, cx as Numeric, cy as Numeric, scale as Float,
col as Number, typeIdx as Number, level as Float) as Void {
if (typeIdx == 712 || typeIdx == 700) {
blitIcon(dc, mIconSunrise, cx, ICON_CY, col);
} else if (typeIdx == 717 || typeIdx == 731) {
blitIcon(dc, mIconSunset, cx, ICON_CY, col);
} else if (typeIdx == 306 || typeIdx == 639 || typeIdx == 300
|| typeIdx == 301 || typeIdx == 302 || typeIdx == 304
|| typeIdx == 305 || typeIdx == 308 || typeIdx == 309
|| typeIdx == 310 || typeIdx == 307) {
blitIcon(dc, mIconWeather, cx, ICON_CY, col);
} else if (typeIdx == 250 || typeIdx == 251 || typeIdx == 257) {
iconBattery(dc, cx - 14.4 * scale, cy, scale, col, level);
} else {
var r = 13.0 * scale;
dc.setColor(col, Graphics.COLOR_TRANSPARENT);
dc.setPenWidth(maxNum(2, px(3.0 * scale)));
if (typeIdx == 2 || typeIdx == 603) { // calories: flame
dc.fillPolygon([[px(cx), px(cy - r)],
[px(cx + r * 0.72), px(cy + r * 0.25)],
[px(cx + r * 0.40), px(cy + r * 0.85)],
[px(cx - r * 0.40), px(cy + r * 0.85)],
[px(cx - r * 0.72), px(cy + r * 0.25)]]);
} else if (typeIdx == 1 || typeIdx == 14 || typeIdx == 707) { // steps
dc.fillRoundedRectangle(px(cx - r * 0.85), px(cy - r * 0.75),
px(r * 0.62), px(r * 1.15), px(r * 0.3));
dc.fillRoundedRectangle(px(cx + r * 0.22), px(cy - r * 0.35),
px(r * 0.62), px(r * 1.15), px(r * 0.3));
} else if (typeIdx == 3 || typeIdx == 9 || typeIdx == 615) { // distance
dc.fillPolygon([[px(cx), px(cy - r)],
[px(cx + r * 0.75), px(cy + r * 0.8)],
[px(cx), px(cy + r * 0.35)],
[px(cx - r * 0.75), px(cy + r * 0.8)]]);
} else if (typeIdx == 618 || typeIdx == 276 || typeIdx == 623) { // heart
dc.fillCircle(px(cx - r * 0.38), px(cy - r * 0.28), px(r * 0.44));
dc.fillCircle(px(cx + r * 0.38), px(cy - r * 0.28), px(r * 0.44));
dc.fillPolygon([[px(cx - r * 0.78), px(cy - r * 0.12)],
[px(cx + r * 0.78), px(cy - r * 0.12)],
[px(cx), px(cy + r * 0.85)]]);
} else if (typeIdx == 4 || typeIdx == 5 || typeIdx == 6) { // stairs
for (var i = 0; i < 3; i++) {
dc.fillRectangle(px(cx - r * 0.85 + r * 0.58 * i),
px(cy + r * 0.6 - r * 0.55 * i),
px(r * 0.62), px(r * 0.4));
}
}
// Everything else -- dates, week numbers, custom text -- reads fine
// without a glyph, so no icon is drawn.
}
}
// ------------------------------------------------------------ elements
function drawBattery(dc as Dc, ti as Number, stats as System.Stats) as Void {
var level = 0.0f;
if (stats != null && stats.battery != null) { level = stats.battery / 100.0; }
if (level > 1.0f) { level = 1.0f; }
if (level < 0.0f) { level = 0.0f; }
iconBattery(dc, BATT_X, BATT_CY, 1.0, Themes.ACCENT[ti] as Number, level);
text(dc, BATT_VX, BATT_CY, mFontBand, (level * 100.0).toNumber().format("%d") + "%",
Graphics.TEXT_JUSTIFY_LEFT, Themes.TEXTPRIMARY[ti] as Number);
}
function drawTopComplication(dc as Dc, ti as Number, typeIdx as Number, isLeft as Boolean,
ctx as Fields.Ctx, level as Float) as Void {
var x = isLeft ? LEFT_X : RIGHT_X;
drawComplicationIcon(dc, x, ICON_CY, 1.0, Themes.ACCENT[ti] as Number, typeIdx, level);
text(dc, x, VALUE_CY, mFontComp, Fields.value(typeIdx, ctx),
Graphics.TEXT_JUSTIFY_CENTER, Themes.TEXTPRIMARY[ti] as Number);
}
function drawBluetooth(dc as Dc, col as Number) as Void {
dc.setColor(col, Graphics.COLOR_TRANSPARENT);
dc.setPenWidth(maxNum(2, px(3.2)));
var l = BT_CX - BT_HW;
var r = BT_CX + BT_HW;
var t = BT_CY - BT_HH;
var b = BT_CY + BT_HH;
var qt = BT_CY - BT_HH / 2;
var qb = BT_CY + BT_HH / 2;
stub(dc, l, qt, r, qb);
stub(dc, r, qb, BT_CX, b);
stub(dc, BT_CX, b, BT_CX, t);
stub(dc, BT_CX, t, r, qt);
stub(dc, r, qt, l, qb);
}
function drawDateBand(dc as Dc, ti as Number, ds as System.DeviceSettings) as Void {
var fill = Themes.BANDFILL[ti] as Number; var fill = Themes.BANDFILL[ti] as Number;
dc.setColor(fill, fill); dc.setColor(fill, fill);
dc.fillRectangle(0, px(BAND_Y), mW, px(BAND_H)); dc.fillRectangle(0, Layout.px(Layout.BAND_Y), Layout.width,
Layout.px(Layout.BAND_H));
var col = Themes.BANDTEXT[ti] as Number; var col = Themes.BANDTEXT[ti] as Number;
drawBluetooth(dc, (ds != null && !ds.phoneConnected) // Dim the rune rather than hide it, so the band does not change shape
? blend(col, fill, 0.6) : col); // every time the phone wanders out of range.
Icons.bluetooth(dc, (ds != null && !ds.phoneConnected)
? blend(col, fill, 0.6) : col);
// FORMAT_SHORT, not FORMAT_MEDIUM: only the short form returns // FORMAT_SHORT, not FORMAT_MEDIUM: only the short form returns
// day_of_week and month as numbers rather than localised strings. // day_of_week and month as numbers rather than localised strings.
var g = Gregorian.info(Time.now(), Time.FORMAT_SHORT); var g = Gregorian.info(Time.now(), Time.FORMAT_SHORT);
text(dc, DOW_CX, BAND_CY, mFontBand, Fields.dow(g), Layout.text(dc, Layout.DOW_CX, Layout.BAND_CY, Layout.fontBand,
Graphics.TEXT_JUSTIFY_CENTER, col); Fields.dow(g), Graphics.TEXT_JUSTIFY_CENTER, col);
text(dc, MD_CX, BAND_CY, mFontBand, Fields.monthDay(g), Layout.text(dc, Layout.MD_CX, Layout.BAND_CY, Layout.fontBand,
Graphics.TEXT_JUSTIFY_CENTER, col); Fields.monthDay(g), Graphics.TEXT_JUSTIFY_CENTER, col);
if (ds == null || !ds.is24Hour) { if (ds == null || !ds.is24Hour) {
text(dc, AMPM_R, BAND_CY, mFontBand, Fields.meridiem(g), Layout.text(dc, Layout.AMPM_R, Layout.BAND_CY, Layout.fontBand,
Graphics.TEXT_JUSTIFY_RIGHT, col); Fields.meridiem(g), Graphics.TEXT_JUSTIFY_RIGHT, col);
} }
} }
function drawTime(dc as Dc, ti as Number, ds as System.DeviceSettings?) as Void {
var g = Gregorian.info(Time.now(), Time.FORMAT_SHORT);
var h = g.hour;
if (ds == null || !ds.is24Hour) {
if (h == 0) { h = 12; } else if (h > 12) { h = h - 12; }
}
Layout.gradText(dc, Layout.HOURS_R, Layout.TIME_CY, Layout.fontTime,
h.format("%d"), Graphics.TEXT_JUSTIFY_RIGHT,
Themes.HOURS[ti] as Array<Number>);
Layout.gradText(dc, Layout.MINUTES_L, Layout.TIME_CY, Layout.fontTime,
g.min.format("%02d"), Graphics.TEXT_JUSTIFY_LEFT,
Themes.MINUTES[ti] as Array<Number>);
dc.setColor(Themes.COLON[ti] as Number, Graphics.COLOR_TRANSPARENT);
dc.fillRectangle(Layout.px(Layout.COLON_X), Layout.px(Layout.COLON_Y1),
Layout.px(Layout.COLON_W), Layout.px(Layout.COLON_H));
dc.fillRectangle(Layout.px(Layout.COLON_X), Layout.px(Layout.COLON_Y2),
Layout.px(Layout.COLON_W), Layout.px(Layout.COLON_H));
}
function drawBottom(dc as Dc, ti as Number, ctx as Fields.Ctx) as Void {
var value = Themes.TEXTPRIMARY[ti] as Number;
var label = Themes.ACCENT[ti] as Number;
var left = Settings.bottomLeft();
var right = Settings.bottomRight();
Layout.fittedText(dc, Layout.DIST_R, Layout.BOT_VAL_CY, Layout.fontComp,
Fields.value(left, ctx), Graphics.TEXT_JUSTIFY_RIGHT,
value, Layout.BOT_MAX_W);
Layout.fittedText(dc, Layout.DIST_R, Layout.BOT_LAB_CY, Layout.fontComp,
Fields.label(left), Graphics.TEXT_JUSTIFY_RIGHT,
label, Layout.BOT_MAX_W);
Layout.fittedText(dc, Layout.STEP_L, Layout.BOT_VAL_CY, Layout.fontComp,
Fields.value(right, ctx), Graphics.TEXT_JUSTIFY_LEFT,
value, Layout.BOT_MAX_W);
Layout.fittedText(dc, Layout.STEP_L, Layout.BOT_LAB_CY, Layout.fontComp,
Fields.label(right), Graphics.TEXT_JUSTIFY_LEFT,
label, Layout.BOT_MAX_W);
}
// ------------------------------------------------------------ colour
function blend(c1 as Number, c2 as Number, f as Float) as Number { function blend(c1 as Number, c2 as Number, f as Float) as Number {
var r1 = (c1 >> 16) & 0xFF; var r1 = (c1 >> 16) & 0xFF;
var g1 = (c1 >> 8) & 0xFF; var g1 = (c1 >> 8) & 0xFF;
@@ -488,37 +236,6 @@ class Fenix8V3View extends WatchUi.WatchFace {
return (r << 16) | (g << 8) | b; return (r << 16) | (g << 8) | b;
} }
function drawTime(dc as Dc, ti as Number, ds as System.DeviceSettings) as Void {
var g = Gregorian.info(Time.now(), Time.FORMAT_SHORT);
var h = g.hour;
if (ds == null || !ds.is24Hour) {
if (h == 0) { h = 12; } else if (h > 12) { h = h - 12; }
}
gradText(dc, HOURS_R, TIME_CY, mFontTime, h.format("%d"),
Graphics.TEXT_JUSTIFY_RIGHT, Themes.HOURS[ti] as Array<Number>);
gradText(dc, MINUTES_L, TIME_CY, mFontTime, g.min.format("%02d"),
Graphics.TEXT_JUSTIFY_LEFT, Themes.MINUTES[ti] as Array<Number>);
dc.setColor(Themes.COLON[ti] as Number, Graphics.COLOR_TRANSPARENT);
dc.fillRectangle(px(COLON_X), px(COLON_Y1), px(COLON_W), px(COLON_H));
dc.fillRectangle(px(COLON_X), px(COLON_Y2), px(COLON_W), px(COLON_H));
}
function drawBottom(dc as Dc, ti as Number, ctx as Fields.Ctx) as Void {
var value = Themes.TEXTPRIMARY[ti] as Number;
var label = Themes.ACCENT[ti] as Number;
var left = getSetting("BottomLeft", 3);
var right = getSetting("BottomRight", 1);
text(dc, DIST_R, BOT_VAL_CY, mFontComp, Fields.value(left, ctx),
Graphics.TEXT_JUSTIFY_RIGHT, value);
text(dc, DIST_R, BOT_LAB_CY, mFontComp, Fields.label(left),
Graphics.TEXT_JUSTIFY_RIGHT, label);
text(dc, STEP_L, BOT_VAL_CY, mFontComp, Fields.value(right, ctx),
Graphics.TEXT_JUSTIFY_LEFT, value);
text(dc, STEP_L, BOT_LAB_CY, mFontComp, Fields.label(right),
Graphics.TEXT_JUSTIFY_LEFT, label);
}
// ------------------------------------------------------------------ AOD
function dim(col as Number, f as Float) as Number { function dim(col as Number, f as Float) as Number {
var r = (((col >> 16) & 0xFF) * f).toNumber(); var r = (((col >> 16) & 0xFF) * f).toNumber();
var g = (((col >> 8) & 0xFF) * f).toNumber(); var g = (((col >> 8) & 0xFF) * f).toNumber();
@@ -526,41 +243,42 @@ class Fenix8V3View extends WatchUi.WatchFace {
return (r << 16) | (g << 8) | b; return (r << 16) | (g << 8) | b;
} }
// Low-power face: no band fill, no unlit ticks, no complications -- just a // --------------------------------------------------------------- AOD
// Low-power face: no band fill, no unlit ticks, no data slots -- just a
// dimmed time, the lit part of the ring, and the date. The whole thing // dimmed time, the lit part of the ring, and the date. The whole thing
// walks a few pixels each minute so no pixel stays lit in one place. // walks a few pixels each minute so no pixel stays lit in one place.
function drawAmbient(dc as Dc, ti as Number, ds as System.DeviceSettings, function drawAmbient(dc as Dc, ti as Number, ds as System.DeviceSettings?,
pTR as Float, pTL as Float, pBR as Float, pBL as Float) as Void { pTR as Float, pTL as Float, pBR as Float, pBL as Float) as Void {
var g = Gregorian.info(Time.now(), Time.FORMAT_SHORT); var g = Gregorian.info(Time.now(), Time.FORMAT_SHORT);
var ox = ((g.min % 5) - 2) * 2.0 / mS; // +/- 4 device px, in design units var ox = ((g.min % 5) - 2) * 2.0 / Layout.scale; // +/- 4 device px
var oy = ((g.min / 5) % 5 - 2) * 2.0 / mS; var oy = ((g.min / 5) % 5 - 2) * 2.0 / Layout.scale;
var top = Themes.RINGTOP[ti] as Array<Number>; var top = Themes.RINGTOP[ti] as Array<Number>;
var bot = Themes.RINGBOTTOM[ti] as Array<Number>; var bot = Themes.RINGBOTTOM[ti] as Array<Number>;
var litTR = (pTR * TOP_N).toNumber(); var litTR = (pTR * Layout.TOP_N).toNumber();
var litTL = (pTL * TOP_N).toNumber(); var litTL = (pTL * Layout.TOP_N).toNumber();
var litBR = (pBR * BOT_N).toNumber(); var litBR = (pBR * Layout.BOT_N).toNumber();
var litBL = (pBL * BOT_N).toNumber(); var litBL = (pBL * Layout.BOT_N).toNumber();
for (var i = 0; i < TOP_N; i++) { for (var i = 0; i < Layout.TOP_N; i++) {
var a = TOP_PH + TOP_PITCH * i; var a = Layout.TOP_PH + Layout.TOP_PITCH * i;
if (i >= TOP_N - litTR) { if (i >= Layout.TOP_N - litTR) {
dc.setColor(dim(top[i], 0.45), Graphics.COLOR_TRANSPARENT); dc.setColor(dim(top[i], 0.45), Graphics.COLOR_TRANSPARENT);
tick(dc, a, TOP_HW); tick(dc, a, Layout.TOP_HW);
} }
if (i >= TOP_N - litTL) { if (i >= Layout.TOP_N - litTL) {
dc.setColor(dim(top[i], 0.45), Graphics.COLOR_TRANSPARENT); dc.setColor(dim(top[i], 0.45), Graphics.COLOR_TRANSPARENT);
tick(dc, -a, TOP_HW); tick(dc, -a, Layout.TOP_HW);
} }
} }
for (var i = 0; i < BOT_N; i++) { for (var i = 0; i < Layout.BOT_N; i++) {
var a = BOT_PH + BOT_PITCH * i; var a = Layout.BOT_PH + Layout.BOT_PITCH * i;
if (i < litBL) { if (i < litBL) {
dc.setColor(dim(bot[i], 0.45), Graphics.COLOR_TRANSPARENT); dc.setColor(dim(bot[i], 0.45), Graphics.COLOR_TRANSPARENT);
tick(dc, 180.0 + a, BOT_HW); tick(dc, 180.0 + a, Layout.BOT_HW);
} }
if (i < litBR) { if (i < litBR) {
dc.setColor(dim(bot[i], 0.45), Graphics.COLOR_TRANSPARENT); dc.setColor(dim(bot[i], 0.45), Graphics.COLOR_TRANSPARENT);
tick(dc, 180.0 - a, BOT_HW); tick(dc, 180.0 - a, Layout.BOT_HW);
} }
} }
@@ -570,39 +288,50 @@ class Fenix8V3View extends WatchUi.WatchFace {
} }
var hours = Themes.HOURS[ti] as Array<Number>; var hours = Themes.HOURS[ti] as Array<Number>;
var mins = Themes.MINUTES[ti] as Array<Number>; var mins = Themes.MINUTES[ti] as Array<Number>;
text(dc, HOURS_R + ox, TIME_CY + oy, mFontTime, h.format("%d"), Layout.text(dc, Layout.HOURS_R + ox, Layout.TIME_CY + oy, Layout.fontTime,
Graphics.TEXT_JUSTIFY_RIGHT, dim(hours[hours.size() / 2], 0.55)); h.format("%d"), Graphics.TEXT_JUSTIFY_RIGHT,
text(dc, MINUTES_L + ox, TIME_CY + oy, mFontTime, g.min.format("%02d"), dim(hours[hours.size() / 2], 0.55));
Graphics.TEXT_JUSTIFY_LEFT, dim(mins[mins.size() / 2], 0.55)); Layout.text(dc, Layout.MINUTES_L + ox, Layout.TIME_CY + oy, Layout.fontTime,
g.min.format("%02d"), Graphics.TEXT_JUSTIFY_LEFT,
dim(mins[mins.size() / 2], 0.55));
dc.setColor(dim(Themes.COLON[ti] as Number, 0.55), Graphics.COLOR_TRANSPARENT); dc.setColor(dim(Themes.COLON[ti] as Number, 0.55), Graphics.COLOR_TRANSPARENT);
dc.fillRectangle(px(COLON_X + ox), px(COLON_Y1 + oy), px(COLON_W), px(COLON_H)); dc.fillRectangle(Layout.px(Layout.COLON_X + ox), Layout.px(Layout.COLON_Y1 + oy),
dc.fillRectangle(px(COLON_X + ox), px(COLON_Y2 + oy), px(COLON_W), px(COLON_H)); Layout.px(Layout.COLON_W), Layout.px(Layout.COLON_H));
dc.fillRectangle(Layout.px(Layout.COLON_X + ox), Layout.px(Layout.COLON_Y2 + oy),
Layout.px(Layout.COLON_W), Layout.px(Layout.COLON_H));
var band = dim(Themes.ACCENT[ti] as Number, 0.55); var band = dim(Themes.ACCENT[ti] as Number, 0.55);
text(dc, DOW_CX + ox, BAND_CY + oy, mFontBand, Fields.dow(g), Layout.text(dc, Layout.DOW_CX + ox, Layout.BAND_CY + oy, Layout.fontBand,
Graphics.TEXT_JUSTIFY_CENTER, band); Fields.dow(g), Graphics.TEXT_JUSTIFY_CENTER, band);
text(dc, MD_CX + ox, BAND_CY + oy, mFontBand, Fields.monthDay(g), Layout.text(dc, Layout.MD_CX + ox, Layout.BAND_CY + oy, Layout.fontBand,
Graphics.TEXT_JUSTIFY_CENTER, band); Fields.monthDay(g), Graphics.TEXT_JUSTIFY_CENTER, band);
} }
// ----------------------------------------------------------------- main // -------------------------------------------------------------- main
function onUpdate(dc as Dc) as Void { function onUpdate(dc as Dc) as Void {
if (mW != dc.getWidth() || !Fields.ready()) { onLayout(dc); } if (!Layout.ready() || Layout.width != dc.getWidth()
|| !Fields.ready() || !Icons.ready()) {
onLayout(dc);
}
if (dc has :setAntiAlias) { dc.setAntiAlias(true); } if (dc has :setAntiAlias) { dc.setAntiAlias(true); }
var ti = themeIndex(); var ti = Settings.themeIndex(Themes.NAMES.size());
var ds = System.getDeviceSettings(); var ds = System.getDeviceSettings();
var ctx = new Fields.Ctx(ds, getSetting("TempUnit", 0)); var ctx = new Fields.Ctx(ds, Settings.tempUnit());
var info = ctx.info(); var info = ctx.info();
var stats = ctx.stats(); var stats = ctx.stats();
var level = 0.0f;
if (stats != null && stats.battery != null) { level = stats.battery / 100.0; }
if (level > 1.0f) { level = 1.0f; }
if (level < 0.0f) { level = 0.0f; }
dc.setColor(Graphics.COLOR_BLACK, Graphics.COLOR_BLACK); dc.setColor(Graphics.COLOR_BLACK, Graphics.COLOR_BLACK);
dc.clear(); dc.clear();
var pTR = metricProgress(getSetting("RingTR", 0), info); var pTR = ringProgress(Settings.ringTR(), info);
var pTL = metricProgress(getSetting("RingTL", 1), info); var pTL = ringProgress(Settings.ringTL(), info);
var pBR = metricProgress(getSetting("RingBR", 4), info); var pBR = ringProgress(Settings.ringBR(), info);
var pBL = metricProgress(getSetting("RingBL", 3), info); var pBL = ringProgress(Settings.ringBL(), info);
if (mSleeping && mBurnIn) { if (mSleeping && mBurnIn) {
drawAmbient(dc, ti, ds, pTR, pTL, pBR, pBL); drawAmbient(dc, ti, ds, pTR, pTL, pBR, pBL);
@@ -611,21 +340,19 @@ class Fenix8V3View extends WatchUi.WatchFace {
drawRing(dc, ti, pTR, pTL, pBR, pBL); drawRing(dc, ti, pTR, pTL, pBR, pBL);
if (getBool("ShowTop", true)) { if (Settings.showTop()) {
var level = 0.0f; drawBattery(dc, ti, level);
if (stats != null && stats.battery != null) { level = stats.battery / 100.0; } drawTopSlot(dc, ti, Settings.leftTop(), true, ctx, level);
drawBattery(dc, ti, stats); drawTopSlot(dc, ti, Settings.rightTop(), false, ctx, level);
drawTopComplication(dc, ti, getSetting("LeftTop", 712), true, ctx, level);
drawTopComplication(dc, ti, getSetting("RightTop", 639), false, ctx, level);
} }
drawTime(dc, ti, ds); drawTime(dc, ti, ds);
if (getBool("ShowBand", true)) { if (Settings.showBand()) {
drawDateBand(dc, ti, ds); drawDateBand(dc, ti, ds);
} }
if (getBool("ShowBottom", true)) { if (Settings.showBottom()) {
drawBottom(dc, ti, ctx); drawBottom(dc, ti, ctx);
} }
} }

View File

@@ -12,6 +12,7 @@ import Toybox.Weather;
import Toybox.Position; import Toybox.Position;
import Toybox.WatchUi; import Toybox.WatchUi;
import FieldTable; import FieldTable;
import Settings;
// Everything a complication slot can show. Ids match // Everything a complication slot can show. Ids match
// https://watchface.io/docs/datafields so the two catalogues line up; see // https://watchface.io/docs/datafields so the two catalogues line up; see
@@ -68,6 +69,12 @@ module Fields {
return Lang.format(mDateFmt, [month(g), g.day.format("%d")]); return Lang.format(mDateFmt, [month(g), g.day.format("%d")]);
} }
// "10" in English, "10日" in Chinese -- the format string carries the
// suffix, so feed it an empty month.
function dayOnly(g as Gregorian.Info) as String {
return Lang.format(mDateFmt, ["", g.day.format("%d")]);
}
function meridiem(g as Gregorian.Info) as String { function meridiem(g as Gregorian.Info) as String {
return mMeridiem == null ? "" : mMeridiem[g.hour >= 12 ? 1 : 0]; return mMeridiem == null ? "" : mMeridiem[g.hour >= 12 ? 1 : 0];
} }
@@ -210,18 +217,6 @@ module Fields {
return (n / 1000.0).format("%.1f") + "k"; return (n / 1000.0).format("%.1f") + "k";
} }
function setting(name as String, def as Number) as Number {
var v = Application.Properties.getValue(name);
if (v == null) { return def; }
return v as Number;
}
function settingText(name as String) as String {
var v = Application.Properties.getValue(name);
if (v == null) { return ""; }
return v.toString();
}
// Newest reading out of a SensorHistory iterator, or null. // Newest reading out of a SensorHistory iterator, or null.
function latest(iter) as Numeric? { function latest(iter) as Numeric? {
if (iter == null) { return null; } if (iter == null) { return null; }
@@ -277,15 +272,17 @@ module Fields {
} }
// --------------------------------------------------------------- value // --------------------------------------------------------------- value
function value(id as Number, c as Ctx) as String { // Clock, calendar and week-number fields.
// ---- date / time // Returns null when the id is not one of ours, so value()
// can try the next category.
function dateTime(id as Number, c as Ctx) as String? {
if (id == 860) { return clockInfo(c.greg, c); } if (id == 860) { return clockInfo(c.greg, c); }
if (id == 880) { if (id == 880) {
var utc = Gregorian.utcInfo(Time.now(), Time.FORMAT_SHORT); var utc = Gregorian.utcInfo(Time.now(), Time.FORMAT_SHORT);
return clockInfo(utc, c); return clockInfo(utc, c);
} }
if (id == 864) { return offsetClock(setting("AltOffset1", 0), c); } if (id == 864) { return offsetClock(Settings.number("AltOffset1", 0), c); }
if (id == 865) { return offsetClock(setting("AltOffset2", 0), c); } if (id == 865) { return offsetClock(Settings.number("AltOffset2", 0), c); }
if (id == 855) { return c.greg.day.format("%d"); } if (id == 855) { return c.greg.day.format("%d"); }
if (id == 868) { return c.greg.month.format("%d"); } if (id == 868) { return c.greg.month.format("%d"); }
if (id == 859) { return isoWeek(c.greg).format("%d"); } if (id == 859) { return isoWeek(c.greg).format("%d"); }
@@ -294,11 +291,17 @@ module Fields {
if (id == 856) { return dow(c.greg); } if (id == 856) { return dow(c.greg); }
if (id == 853) { return month(c.greg); } if (id == 853) { return month(c.greg); }
if (id == 852) { return monthDay(c.greg); } if (id == 852) { return monthDay(c.greg); }
if (id == 872) { return dow(c.greg) + " " + c.greg.day.format("%d"); } if (id == 872) { return dow(c.greg) + " " + dayOnly(c.greg); }
if (id == 851) { return dow(c.greg) + " " + monthDay(c.greg); } if (id == 851) { return dow(c.greg) + " " + monthDay(c.greg); }
if (id == 858) { return meridiem(c.greg); } if (id == 858) { return meridiem(c.greg); }
// ---- activity return null;
}
// Steps, calories, distance, floors and active minutes.
// Returns null when the id is not one of ours, so value()
// can try the next category.
function activity(id as Number, c as Ctx) as String? {
var i = c.info(); var i = c.info();
if (id == 1) { return i == null ? DASH : num(i.steps); } if (id == 1) { return i == null ? DASH : num(i.steps); }
if (id == 14) { return i == null ? DASH : rounded(i.steps); } if (id == 14) { return i == null ? DASH : rounded(i.steps); }
@@ -315,33 +318,65 @@ module Fields {
return num(i.calories); // device reports total only return num(i.calories); // device reports total only
} }
if (id == 150 || id == 152 || id == 154) { if (id == 150 || id == 152 || id == 154) {
if (i == null || i.activeMinutesDay == null) { return DASH; } if (i == null || !(i has :activeMinutesDay) || i.activeMinutesDay == null) {
return DASH;
}
var am = i.activeMinutesDay; var am = i.activeMinutesDay;
if (id == 152) { return num(am.moderate); } if (id == 152) { return num(am.moderate); }
if (id == 154) { return num(am.vigorous); } if (id == 154) { return num(am.vigorous); }
return num(am.total); return num(am.total);
} }
if (id == 151) { if (id == 151) {
if (i == null || i.activeMinutesWeek == null) { return DASH; } if (i == null || !(i has :activeMinutesWeek) || i.activeMinutesWeek == null) {
return DASH;
}
return num(i.activeMinutesWeek.total); return num(i.activeMinutesWeek.total);
} }
if (id == 3) { return i == null ? DASH : dist(i.distance, c); } if (id == 3) { return i == null ? DASH : dist(i.distance, c); }
if (id == 4) { return i == null ? DASH : num(i.floorsClimbed); } if (id == 4) { return i == null ? DASH : num(i.floorsClimbed); }
if (id == 5) { return i == null ? DASH : num(i.floorsDescended); } // Several ActivityMonitor.Info members compile everywhere but only
if (id == 6) { return i == null ? DASH : metres(i.metersClimbed, c); } // exist on some devices and firmware -- the wheelchair counters in
if (id == 9) { return i == null ? DASH : dist(i.pushDistance, c); } // particular threw "Symbol Not Found" on a fenix 8. Check before use.
if (id == 10) { return i == null ? DASH : num(i.pushes); } if (id == 5) {
return (i == null || !(i has :floorsDescended)) ? DASH
: num(i.floorsDescended);
}
if (id == 6) {
return (i == null || !(i has :metersClimbed)) ? DASH
: metres(i.metersClimbed, c);
}
if (id == 9) {
return (i == null || !(i has :pushDistance)) ? DASH
: dist(i.pushDistance, c);
}
if (id == 10) {
return (i == null || !(i has :pushes)) ? DASH : num(i.pushes);
}
if (id == 8 || id == 15) { if (id == 8 || id == 15) {
if (i == null || i.moveBarLevel == null) { return DASH; } if (i == null || i.moveBarLevel == null) { return DASH; }
var lvl = i.moveBarLevel; var lvl = i.moveBarLevel;
if (id == 15) { lvl = ActivityMonitor.MOVE_BAR_LEVEL_MAX - lvl; } if (id == 15) { lvl = ActivityMonitor.MOVE_BAR_LEVEL_MAX - lvl; }
return lvl.format("%d"); return lvl.format("%d");
} }
if (id == 13) { return i == null ? DASH : num(i.timeToRecovery); } if (id == 13) {
if (id == 11) { return i == null ? DASH : num(i.respirationRate); } return (i == null || !(i has :timeToRecovery)) ? DASH
if (id == 12) { return i == null ? DASH : num(i.stressScore); } : num(i.timeToRecovery);
}
if (id == 11) {
return (i == null || !(i has :respirationRate)) ? DASH
: num(i.respirationRate);
}
if (id == 12) {
return (i == null || !(i has :stressScore)) ? DASH : num(i.stressScore);
}
// ---- body return null;
}
// Heart, stress, oxygen, body battery and profile-derived values.
// Returns null when the id is not one of ours, so value()
// can try the next category.
function body(id as Number, c as Ctx) as String? {
if (id == 618) { if (id == 618) {
var a = c.act(); var a = c.act();
if (a != null && a.currentHeartRate != null) { return num(a.currentHeartRate); } if (a != null && a.currentHeartRate != null) { return num(a.currentHeartRate); }
@@ -357,7 +392,8 @@ module Fields {
} }
if (id == 276) { if (id == 276) {
var p = c.profile(); var p = c.profile();
return (p == null) ? DASH : num(p.restingHeartRate); return (p == null || !(p has :restingHeartRate)) ? DASH
: num(p.restingHeartRate);
} }
if (id == 635) { if (id == 635) {
if (!(SensorHistory has :getOxygenSaturationHistory)) { return DASH; } if (!(SensorHistory has :getOxygenSaturationHistory)) { return DASH; }
@@ -369,22 +405,31 @@ module Fields {
} }
if (id == 281 || id == 742) { if (id == 281 || id == 742) {
var p = c.profile(); var p = c.profile();
if (p == null || p.weight == null) { return DASH; } if (p == null || !(p has :weight) || p.weight == null) { return DASH; }
var kg = p.weight.toFloat() / 1000.0; var kg = p.weight.toFloat() / 1000.0;
if (id == 281) { if (id == 281) {
return (c.metric ? kg : kg * 2.20462).format("%.1f"); return (c.metric ? kg : kg * 2.20462).format("%.1f");
} }
if (p.height == null || p.height <= 0) { return DASH; } if (!(p has :height) || p.height == null || p.height <= 0) { return DASH; }
var m = p.height.toFloat() / 100.0; var m = p.height.toFloat() / 100.0;
return (kg / (m * m)).format("%.1f"); return (kg / (m * m)).format("%.1f");
} }
if (id == 277 || id == 278) { if (id == 277 || id == 278) {
var p = c.profile(); var p = c.profile();
if (p == null) { return DASH; } if (p == null) { return DASH; }
return num(id == 278 ? p.vo2maxRunning : p.vo2maxCycling); if (id == 278) {
return (p has :vo2maxRunning) ? num(p.vo2maxRunning) : DASH;
}
return (p has :vo2maxCycling) ? num(p.vo2maxCycling) : DASH;
} }
// ---- system return null;
}
// Battery, alarms, notifications and radio state.
// Returns null when the id is not one of ours, so value()
// can try the next category.
function system(id as Number, c as Ctx) as String? {
var st = c.stats(); var st = c.stats();
if (id == 250) { if (id == 250) {
return st == null || st.battery == null ? DASH return st == null || st.battery == null ? DASH
@@ -418,15 +463,26 @@ module Fields {
return ds == null ? DASH : (ds.phoneConnected ? "ON" : "OFF"); return ds == null ? DASH : (ds.phoneConnected ? "ON" : "OFF");
} }
// ---- environment return null;
}
// Altitude, pressure, sun events and moon phase.
// Returns null when the id is not one of ours, so value()
// can try the next category.
function environment(id as Number, c as Ctx) as String? {
if (id == 615) { if (id == 615) {
var a = c.act(); var a = c.act();
return (a == null) ? DASH : metres(a.altitude, c); return (a == null || !(a has :altitude)) ? DASH : metres(a.altitude, c);
} }
if (id == 616 || id == 733) { if (id == 616 || id == 733) {
var a = c.act(); var a = c.act();
if (a == null) { return DASH; } if (a == null) { return DASH; }
var pa = (id == 616) ? a.meanSeaLevelPressure : a.ambientPressure; var pa = null;
if (id == 616 && (a has :meanSeaLevelPressure)) {
pa = a.meanSeaLevelPressure;
} else if (id == 733 && (a has :ambientPressure)) {
pa = a.ambientPressure;
}
if (pa == null) { return DASH; } if (pa == null) { return DASH; }
return (pa.toFloat() / 100.0).format("%d"); return (pa.toFloat() / 100.0).format("%d");
} }
@@ -453,39 +509,77 @@ module Fields {
return (lit * 100.0).format("%d") + "%"; return (lit * 100.0).format("%d") + "%";
} }
// ---- weather return null;
}
// Garmin's cached conditions and today's forecast.
// Returns null when the id is not one of ours, so value()
// can try the next category.
function weather(id as Number, c as Ctx) as String? {
var w = c.wx(); var w = c.wx();
if (id == 306) { return w == null ? DASH : temp(w.temperature, c); } if (id == 306) { return w == null ? DASH : temp(w.temperature, c); }
if (id == 300) { return w == null ? DASH : temp(w.feelsLikeTemperature, c); } if (id == 300) {
if (id == 305) { return w == null ? DASH : num(w.relativeHumidity) + "%"; } return (w == null || !(w has :feelsLikeTemperature)) ? DASH
: temp(w.feelsLikeTemperature, c);
}
if (id == 305) {
return (w == null || !(w has :relativeHumidity)) ? DASH
: num(w.relativeHumidity) + "%";
}
if (id == 304) { if (id == 304) {
if (w == null || !(w has :precipitationChance)) { return DASH; } if (w == null || !(w has :precipitationChance)) { return DASH; }
return num(w.precipitationChance) + "%"; return num(w.precipitationChance) + "%";
} }
if (id == 308) { if (id == 308) {
if (w == null || w.windSpeed == null) { return DASH; } if (w == null || !(w has :windSpeed) || w.windSpeed == null) { return DASH; }
var kmh = w.windSpeed.toFloat() * 3.6; var kmh = w.windSpeed.toFloat() * 3.6;
return (c.metric ? kmh : kmh * 0.621371).format("%d"); return (c.metric ? kmh : kmh * 0.621371).format("%d");
} }
if (id == 307) { return w == null ? DASH : num(w.windBearing); } if (id == 307) {
if (id == 310) { return w == null ? DASH : windDir(w.windBearing); } return (w == null || !(w has :windBearing)) ? DASH : num(w.windBearing);
}
if (id == 310) {
return (w == null || !(w has :windBearing)) ? DASH : windDir(w.windBearing);
}
if (id == 309) { if (id == 309) {
if (w == null || w.observationTime == null) { return DASH; } if (w == null || !(w has :observationTime) || w.observationTime == null) {
return DASH;
}
return clock(w.observationTime, c); return clock(w.observationTime, c);
} }
if (id == 301 || id == 302 || id == 639) { if (id == 301 || id == 302 || id == 639) {
var f = c.forecast(); var f = c.forecast();
if (f == null) { return DASH; } if (f == null) { return DASH; }
if (id == 301) { return temp(f.highTemperature, c); } var hi = (f has :highTemperature) ? f.highTemperature : null;
if (id == 302) { return temp(f.lowTemperature, c); } var lo = (f has :lowTemperature) ? f.lowTemperature : null;
return temp(f.highTemperature, c) + "/" + temp(f.lowTemperature, c); if (id == 301) { return temp(hi, c); }
if (id == 302) { return temp(lo, c); }
return temp(hi, c) + "/" + temp(lo, c);
} }
// ---- custom return null;
if (id == 703) { return settingText("Custom1"); } }
if (id == 704) { return settingText("Custom2"); }
if (id == 705) { return settingText("Custom3"); }
return ""; // Free text the wearer typed into the settings.
// Returns null when the id is not one of ours, so value()
// can try the next category.
function custom(id as Number, c as Ctx) as String? {
if (id == 703) { return Settings.text("Custom1"); }
if (id == 704) { return Settings.text("Custom2"); }
if (id == 705) { return Settings.text("Custom3"); }
return null;
}
// Text for one data slot. Categories are tried in turn rather
// than dispatched on an id range, because the ids interleave.
function value(id as Number, c as Ctx) as String {
var v = dateTime(id, c);
if (v == null) { v = activity(id, c); }
if (v == null) { v = body(id, c); }
if (v == null) { v = system(id, c); }
if (v == null) { v = environment(id, c); }
if (v == null) { v = weather(id, c); }
if (v == null) { v = custom(id, c); }
return v == null ? "" : v;
} }
} }

142
source/Icons.mc Normal file
View File

@@ -0,0 +1,142 @@
import Toybox.Lang;
import Toybox.Graphics;
import Toybox.WatchUi;
import Layout;
// The glyphs that sit above the two top data slots, plus the battery and the
// bluetooth rune.
//
// The comps contain three icons -- sunrise, weather, battery. The first two
// are traced outlines whose finest features are two or three design pixels
// across; rebuilding them from circles and lines looked right at 454 px and
// turned to mush at 280. They ship instead as bitmaps rasterised from the
// design paths once per screen width (tools/gen_icons.py) and are tinted with
// the theme's accent at draw time.
//
// Everything else -- steps, calories, stairs, heart -- has no counterpart in
// the comps, so those stay as primitives sized off the same design canvas.
module Icons {
var mSunrise = null;
var mSunset = null;
var mWeather = null;
function load() as Void {
mSunrise = WatchUi.loadResource(Rez.Drawables.IconSunrise);
mSunset = WatchUi.loadResource(Rez.Drawables.IconSunset);
mWeather = WatchUi.loadResource(Rez.Drawables.IconWeather);
}
function ready() as Boolean { return mSunrise != null; }
// drawBitmap2 refuses a palettised source, which is why the generated
// drawables carry packingFormat="png" to keep their alpha channel.
function blit(dc as Graphics.Dc, bmp, cxDesign as Numeric, col as Number) as Void {
if (bmp == null) { return; }
dc.drawBitmap2(Layout.px(cxDesign) - bmp.getWidth() / 2,
Layout.px(Layout.ICON_CY) - bmp.getHeight() / 2,
bmp, {:tintColor => col});
}
// Outline, charge bar and terminal. The bar tracks the real level, the
// way the comp draws it at 40%.
function battery(dc as Graphics.Dc, x as Numeric, cyDesign as Numeric,
k as Float, col as Number, level as Float) as Void {
var x0 = Layout.px(x);
var y0 = Layout.px(cyDesign - Layout.BATT_H * k / 2);
var w = Layout.px(Layout.BATT_W * k);
var h = Layout.px(Layout.BATT_H * k);
var wall = Layout.px(Layout.BATT_WALL * k);
dc.setColor(col, Graphics.COLOR_TRANSPARENT);
dc.fillRoundedRectangle(x0, y0, w, h, Layout.px(3.4 * k));
dc.fillRectangle(x0 + w, Layout.px(cyDesign - Layout.BATT_TH * k / 2),
Layout.px(Layout.BATT_TW * k), Layout.px(Layout.BATT_TH * k));
var iw = w - 2 * wall;
var ih = h - 2 * wall;
dc.setColor(Graphics.COLOR_BLACK, Graphics.COLOR_TRANSPARENT);
dc.fillRectangle(x0 + wall, y0 + wall, iw, ih);
var fill = (iw * level).toNumber();
if (fill > 0) {
dc.setColor(col, Graphics.COLOR_TRANSPARENT);
dc.fillRectangle(x0 + wall, y0 + wall, fill, ih);
}
}
// The standard bluetooth rune, drawn as one polyline.
function bluetooth(dc as Graphics.Dc, col as Number) as Void {
dc.setColor(col, Graphics.COLOR_TRANSPARENT);
dc.setPenWidth(Layout.pen(3.2));
var l = Layout.BT_CX - Layout.BT_HW;
var r = Layout.BT_CX + Layout.BT_HW;
var t = Layout.BT_CY - Layout.BT_HH;
var b = Layout.BT_CY + Layout.BT_HH;
var qt = Layout.BT_CY - Layout.BT_HH / 2;
var qb = Layout.BT_CY + Layout.BT_HH / 2;
Layout.line(dc, l, qt, r, qb);
Layout.line(dc, r, qb, Layout.BT_CX, b);
Layout.line(dc, Layout.BT_CX, b, Layout.BT_CX, t);
Layout.line(dc, Layout.BT_CX, t, r, qt);
Layout.line(dc, r, qt, l, qb);
}
// Which glyph, if any, belongs above a given field id. Fields with no
// natural icon -- dates, week numbers, custom text -- read fine bare.
function forField(dc as Graphics.Dc, id as Number, xDesign as Numeric,
col as Number, level as Float) as Void {
if (id == 712 || id == 700) {
blit(dc, mSunrise, xDesign, col);
return;
}
if (id == 717 || id == 731) {
blit(dc, mSunset, xDesign, col);
return;
}
if (id == 306 || id == 639 || id == 300 || id == 301 || id == 302
|| id == 304 || id == 305 || id == 307 || id == 308
|| id == 309 || id == 310) {
blit(dc, mWeather, xDesign, col);
return;
}
if (id == 250 || id == 251 || id == 257) {
battery(dc, xDesign - 14.4, Layout.ICON_CY, 1.0, col, level);
return;
}
var r = 13.0;
var cyd = Layout.ICON_CY;
dc.setColor(col, Graphics.COLOR_TRANSPARENT);
dc.setPenWidth(Layout.pen(3.0));
if (id == 2 || id == 603) { // calories: flame
dc.fillPolygon([[Layout.px(xDesign), Layout.px(cyd - r)],
[Layout.px(xDesign + r * 0.72), Layout.px(cyd + r * 0.25)],
[Layout.px(xDesign + r * 0.40), Layout.px(cyd + r * 0.85)],
[Layout.px(xDesign - r * 0.40), Layout.px(cyd + r * 0.85)],
[Layout.px(xDesign - r * 0.72), Layout.px(cyd + r * 0.25)]]);
} else if (id == 1 || id == 14 || id == 707) { // steps: two prints
dc.fillRoundedRectangle(Layout.px(xDesign - r * 0.85), Layout.px(cyd - r * 0.75),
Layout.px(r * 0.62), Layout.px(r * 1.15),
Layout.px(r * 0.3));
dc.fillRoundedRectangle(Layout.px(xDesign + r * 0.22), Layout.px(cyd - r * 0.35),
Layout.px(r * 0.62), Layout.px(r * 1.15),
Layout.px(r * 0.3));
} else if (id == 3 || id == 9 || id == 615) { // distance: arrow
dc.fillPolygon([[Layout.px(xDesign), Layout.px(cyd - r)],
[Layout.px(xDesign + r * 0.75), Layout.px(cyd + r * 0.8)],
[Layout.px(xDesign), Layout.px(cyd + r * 0.35)],
[Layout.px(xDesign - r * 0.75), Layout.px(cyd + r * 0.8)]]);
} else if (id == 618 || id == 276 || id == 623) { // heart
dc.fillCircle(Layout.px(xDesign - r * 0.38), Layout.px(cyd - r * 0.28),
Layout.px(r * 0.44));
dc.fillCircle(Layout.px(xDesign + r * 0.38), Layout.px(cyd - r * 0.28),
Layout.px(r * 0.44));
dc.fillPolygon([[Layout.px(xDesign - r * 0.78), Layout.px(cyd - r * 0.12)],
[Layout.px(xDesign + r * 0.78), Layout.px(cyd - r * 0.12)],
[Layout.px(xDesign), Layout.px(cyd + r * 0.85)]]);
} else if (id == 4 || id == 5 || id == 6) { // floors: stairs
for (var i = 0; i < 3; i++) {
dc.fillRectangle(Layout.px(xDesign - r * 0.85 + r * 0.58 * i),
Layout.px(cyd + r * 0.6 - r * 0.55 * i),
Layout.px(r * 0.62), Layout.px(r * 0.4));
}
}
}
}

235
source/Layout.mc Normal file
View File

@@ -0,0 +1,235 @@
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;
}
}

59
source/Settings.mc Normal file
View File

@@ -0,0 +1,59 @@
import Toybox.Lang;
import Toybox.Application;
// Every stored preference in one place, with the defaults that reproduce the
// design comps. Keys match resources/settings/properties.xml.
//
// Properties.getValue returns null for a key the watch has not stored yet --
// a fresh install, or a setting added by an update -- so every read falls back
// to the default rather than trusting the stored value.
module Settings {
// Field ids follow https://watchface.io/docs/datafields.
const DEF_LEFT_TOP = 712; // sunrise
const DEF_RIGHT_TOP = 639; // forecast high / low
const DEF_BOTTOM_LEFT = 3; // distance
const DEF_BOTTOM_RIGHT = 1; // steps
function number(key as String, def as Number) as Number {
var v = Application.Properties.getValue(key);
if (v == null) { return def; }
return v as Number;
}
function bool(key as String, def as Boolean) as Boolean {
var v = Application.Properties.getValue(key);
if (v == null) { return def; }
return v as Boolean;
}
function text(key as String) as String {
var v = Application.Properties.getValue(key);
if (v == null) { return ""; }
return v.toString();
}
// Theme index, clamped into range in case a stored value outlives a
// release that removed a theme.
function themeIndex(count as Number) as Number {
var i = number("Theme", 1) - 1;
if (i < 0 || i >= count) { return 0; }
return i;
}
function leftTop() as Number { return number("LeftTop", DEF_LEFT_TOP); }
function rightTop() as Number { return number("RightTop", DEF_RIGHT_TOP); }
function bottomLeft() as Number { return number("BottomLeft", DEF_BOTTOM_LEFT); }
function bottomRight() as Number { return number("BottomRight", DEF_BOTTOM_RIGHT); }
function ringTR() as Number { return number("RingTR", 0); }
function ringTL() as Number { return number("RingTL", 1); }
function ringBR() as Number { return number("RingBR", 4); }
function ringBL() as Number { return number("RingBL", 3); }
function tempUnit() as Number { return number("TempUnit", 0); }
function showTop() as Boolean { return bool("ShowTop", true); }
function showBand() as Boolean { return bool("ShowBand", true); }
function showBottom() as Boolean { return bool("ShowBottom", true); }
}