Skip to content

Instantly share code, notes, and snippets.

@erik-nilcoast
Created July 3, 2026 20:28
Show Gist options
  • Select an option

  • Save erik-nilcoast/9230f0894bf1af41cfa51317b762d6e0 to your computer and use it in GitHub Desktop.

Select an option

Save erik-nilcoast/9230f0894bf1af41cfa51317b762d6e0 to your computer and use it in GitHub Desktop.
nilcoast - Pebble watchface C source
#define BOX_IMPLEMENTATION
#include "box.h"
#include "draw.h"
#include "math.h"
#include "model.h"
static GColor s_paper, s_ink, s_ink_ghost, s_accent;
static GFont s_f_time, s_f_sec, s_f_cal, s_f_sens, s_f_batt;
static const char *const WX_WORD[] = {
"CLR",
"PCLD",
"MCLD",
"CLDY",
"RAIN",
"SNOW",
"STRM",
"FOG",
"WIND",
"--",
};
// Normal is dark ink on light paper; inverted swaps them. The red accent and
// the faint ghost both stay legible either way.
void draw_palette(bool invert) {
GColor light = GColorFromRGB(0xd7, 0xd8, 0xcd);
GColor dark = GColorFromRGB(0x16, 0x18, 0x0f);
s_accent = GColorFromRGB(0xcf, 0x35, 0x22);
s_paper = invert ? dark : light;
s_ink = invert ? light : dark;
s_ink_ghost = invert ? GColorFromRGB(0x6e, 0x6f, 0x64) : GColorFromRGB(0xb6, 0xb7, 0xac);
}
void draw_init(void) {
draw_palette(false);
s_f_time = fonts_load_custom_font(resource_get_handle(RESOURCE_ID_FONT_TIME_38));
s_f_sec = fonts_load_custom_font(resource_get_handle(RESOURCE_ID_FONT_SECONDS_16));
s_f_cal = fonts_load_custom_font(resource_get_handle(RESOURCE_ID_FONT_DATE_14));
s_f_sens = fonts_load_custom_font(resource_get_handle(RESOURCE_ID_FONT_SENSOR_16));
s_f_batt = fonts_load_custom_font(resource_get_handle(RESOURCE_ID_FONT_BATT_11));
}
void draw_deinit(void) {
fonts_unload_custom_font(s_f_time);
fonts_unload_custom_font(s_f_sec);
fonts_unload_custom_font(s_f_cal);
fonts_unload_custom_font(s_f_sens);
fonts_unload_custom_font(s_f_batt);
}
GColor draw_bg(void) { return s_paper; }
// ── primitives ────────────────────────────────────────────────────────
static int text_w(const char *s, GFont f) {
return graphics_text_layout_get_content_size(
s, f, GRect(0, 0, SCREEN_W, 60), GTextOverflowModeWordWrap, GTextAlignmentLeft)
.w;
}
static void text_at(GContext *ctx, const char *s, GFont f, GColor col, int x, int y, int w, GTextAlignment align) {
graphics_context_set_text_color(ctx, col);
graphics_draw_text(ctx, s, f, GRect(x, y, w, 60), GTextOverflowModeTrailingEllipsis, align, NULL);
}
static void fill_tri(GContext *ctx, GPoint a, GPoint b, GPoint c) {
GPathInfo info = {3, (GPoint[]){a, b, c}};
GPath *p = gpath_create(&info);
gpath_draw_filled(ctx, p);
gpath_destroy(p);
}
static void rule(GContext *ctx, int y) {
graphics_context_set_fill_color(ctx, s_ink);
graphics_fill_rect(ctx, GRect(PAD_X - 2, y, SCREEN_W - 2 * (PAD_X - 2), 2), 0, GCornerNone);
}
static void draw_battery(GContext *ctx, int right, int y, int pct) {
int w = 13, h = 7;
int x = right - w - 2; // leave room for the nub
graphics_context_set_stroke_color(ctx, s_ink);
graphics_context_set_stroke_width(ctx, 1);
graphics_draw_rect(ctx, GRect(x, y, w, h));
graphics_context_set_fill_color(ctx, s_ink);
graphics_fill_rect(ctx, GRect(x + w, y + 2, 2, 3), 0, GCornerNone); // nub
int fw = clampi(pct, 0, 100) * (w - 2) / 100;
if (fw > 0) graphics_fill_rect(ctx, GRect(x + 1, y + 1, fw, h - 2), 0, GCornerNone);
}
// ── icons (each draws in a 16x16 box at top-left x,y) ─────────────────
static void icon_steps(GContext *ctx, int x, int y) {
graphics_context_set_fill_color(ctx, s_ink);
for (int i = 0; i < 3; i++) {
int ox = x + 1 + i * 6;
fill_tri(ctx, GPoint(ox, y + 2), GPoint(ox + 5, y + 8), GPoint(ox, y + 14));
}
}
static void icon_moon(GContext *ctx, int x, int y) {
graphics_context_set_fill_color(ctx, s_ink);
graphics_fill_circle(ctx, GPoint(x + 7, y + 8), 7);
graphics_context_set_fill_color(ctx, s_paper);
graphics_fill_circle(ctx, GPoint(x + 11, y + 6), 6); // carve the crescent
}
// a classic bell: rounded dome over a body that flares to a wide rim, clapper
static void icon_alarm(GContext *ctx, int x, int y) {
graphics_context_set_fill_color(ctx, s_ink);
graphics_fill_circle(ctx, GPoint(x + 8, y + 6), 5); // rounded dome
GPathInfo body = {4, (GPoint[]){{x + 3, y + 6}, {x + 13, y + 6}, {x + 15, y + 13}, {x + 1, y + 13}}};
GPath *p = gpath_create(&body);
gpath_draw_filled(ctx, p); // flare to the rim
gpath_destroy(p);
graphics_fill_circle(ctx, GPoint(x + 8, y + 15), 2); // clapper
}
// filled triangle + a carved exclamation mark, same carve trick as icon_moon
static void icon_warning(GContext *ctx, int x, int y) {
graphics_context_set_fill_color(ctx, s_accent);
fill_tri(ctx, GPoint(x + 6, y), GPoint(x + 12, y + 11), GPoint(x, y + 11));
graphics_context_set_fill_color(ctx, s_paper);
graphics_fill_rect(ctx, GRect(x + 5, y + 3, 2, 4), 0, GCornerNone);
graphics_fill_rect(ctx, GRect(x + 5, y + 8, 2, 2), 0, GCornerNone);
}
// envelope: outlined body + a V-fold flap, classic mail silhouette. h sets the
// body height (no taller than the badge count next to it); width follows a
// ~1.4x envelope aspect ratio.
static void icon_mail(GContext *ctx, int x, int y, int h) {
int w = h * 7 / 5;
graphics_context_set_stroke_color(ctx, s_accent);
graphics_context_set_stroke_width(ctx, 1);
graphics_draw_rect(ctx, GRect(x, y, w, h));
graphics_draw_line(ctx, GPoint(x, y), GPoint(x + w / 2, y + h * 2 / 3));
graphics_draw_line(ctx, GPoint(x + w, y), GPoint(x + w / 2, y + h * 2 / 3));
}
static void icon_heart(GContext *ctx, int x, int y) {
graphics_context_set_fill_color(ctx, s_accent);
graphics_fill_circle(ctx, GPoint(x + 4, y + 5), 4);
graphics_fill_circle(ctx, GPoint(x + 11, y + 5), 4);
fill_tri(ctx, GPoint(x + 1, y + 6), GPoint(x + 15, y + 6), GPoint(x + 8, y + 15));
}
// thermometer: outlined tube + filled bulb (the wider bulb sells it)
static void icon_thermo(GContext *ctx, int x, int y) {
graphics_context_set_stroke_color(ctx, s_ink);
graphics_context_set_stroke_width(ctx, 2);
graphics_draw_round_rect(ctx, GRect(x + 6, y + 1, 5, 11), 2); // tube
graphics_context_set_fill_color(ctx, s_ink);
graphics_fill_circle(ctx, GPoint(x + 8, y + 12), 4); // bulb
}
// Condition icons. The temperature number carries warmth; these convey only
// sky/precip, each a distinct silhouette at 16px.
static void icon_sun(GContext *ctx, int x, int y) {
GPoint c = GPoint(x + 8, y + 8);
graphics_context_set_stroke_color(ctx, s_ink);
graphics_context_set_stroke_width(ctx, 1);
for (int a = 0; a < 360; a += 45) {
int32_t t = TRIG_MAX_ANGLE * a / 360;
GPoint s = GPoint(c.x + sin_lookup(t) * 7 / TRIG_MAX_RATIO, c.y - cos_lookup(t) * 7 / TRIG_MAX_RATIO);
GPoint e = GPoint(c.x + sin_lookup(t) * 10 / TRIG_MAX_RATIO, c.y - cos_lookup(t) * 10 / TRIG_MAX_RATIO);
graphics_draw_line(ctx, s, e);
}
graphics_context_set_fill_color(ctx, s_ink);
graphics_fill_circle(ctx, c, 5);
}
static void icon_cloud(GContext *ctx, int x, int y) {
graphics_context_set_fill_color(ctx, s_ink);
graphics_fill_circle(ctx, GPoint(x + 5, y + 9), 4);
graphics_fill_circle(ctx, GPoint(x + 11, y + 7), 5);
graphics_fill_circle(ctx, GPoint(x + 13, y + 10), 3);
graphics_fill_rect(ctx, GRect(x + 3, y + 9, 11, 4), 2, GCornersBottom);
}
static void icon_umbrella(GContext *ctx, int x, int y) {
graphics_context_set_fill_color(ctx, s_ink);
graphics_fill_circle(ctx, GPoint(x + 8, y + 7), 7);
graphics_context_set_fill_color(ctx, s_paper);
graphics_fill_rect(ctx, GRect(x, y + 8, 16, 9), 0, GCornerNone); // cut the disc to a dome
graphics_context_set_stroke_color(ctx, s_ink);
graphics_context_set_stroke_width(ctx, 2);
graphics_draw_line(ctx, GPoint(x + 8, y + 7), GPoint(x + 8, y + 15)); // stem
graphics_draw_line(ctx, GPoint(x + 8, y + 15), GPoint(x + 5, y + 15)); // hook
}
static void icon_snowflake(GContext *ctx, int x, int y) {
GPoint c = GPoint(x + 8, y + 8);
graphics_context_set_stroke_color(ctx, s_ink);
graphics_context_set_stroke_width(ctx, 2);
for (int a = 0; a < 180; a += 60) {
int32_t t = TRIG_MAX_ANGLE * a / 360;
int dx = sin_lookup(t) * 7 / TRIG_MAX_RATIO;
int dy = cos_lookup(t) * 7 / TRIG_MAX_RATIO;
graphics_draw_line(ctx, GPoint(c.x - dx, c.y + dy), GPoint(c.x + dx, c.y - dy));
}
}
static void icon_storm(GContext *ctx, int x, int y) {
icon_cloud(ctx, x, y);
graphics_context_set_stroke_color(ctx, s_accent);
graphics_context_set_stroke_width(ctx, 2);
graphics_draw_line(ctx, GPoint(x + 9, y + 11), GPoint(x + 6, y + 14));
graphics_draw_line(ctx, GPoint(x + 6, y + 14), GPoint(x + 9, y + 14));
graphics_draw_line(ctx, GPoint(x + 9, y + 14), GPoint(x + 5, y + 16));
}
// a flattened, low-slung cloud with mist bands beneath — distinct from the
// full icon_cloud silhouette used for plain overcast
static void icon_fog(GContext *ctx, int x, int y) {
graphics_context_set_fill_color(ctx, s_ink);
graphics_fill_circle(ctx, GPoint(x + 8, y + 6), 5);
graphics_context_set_stroke_color(ctx, s_ink);
graphics_context_set_stroke_width(ctx, 2);
graphics_draw_line(ctx, GPoint(x + 1, y + 11), GPoint(x + 15, y + 11));
graphics_draw_line(ctx, GPoint(x + 3, y + 14), GPoint(x + 13, y + 14));
}
// three gusty lines of varying length, hooked ends implying motion — no cloud
static void icon_wind(GContext *ctx, int x, int y) {
graphics_context_set_stroke_color(ctx, s_ink);
graphics_context_set_stroke_width(ctx, 2);
graphics_draw_line(ctx, GPoint(x + 1, y + 4), GPoint(x + 12, y + 4));
graphics_draw_line(ctx, GPoint(x + 12, y + 4), GPoint(x + 14, y + 2));
graphics_draw_line(ctx, GPoint(x + 1, y + 8), GPoint(x + 14, y + 8));
graphics_draw_line(ctx, GPoint(x + 1, y + 12), GPoint(x + 10, y + 12));
graphics_draw_line(ctx, GPoint(x + 10, y + 12), GPoint(x + 12, y + 14));
}
// mostly sun: rayed circle dominant, a small cloud only clipping its base
static void icon_partly_cloudy(GContext *ctx, int x, int y) {
GPoint c = GPoint(x + 5, y + 5);
graphics_context_set_stroke_color(ctx, s_ink);
graphics_context_set_stroke_width(ctx, 1);
int angles[] = {270, 315, 0};
for (int i = 0; i < 3; i++) {
int32_t t = TRIG_MAX_ANGLE * angles[i] / 360;
GPoint s = GPoint(c.x + sin_lookup(t) * 5 / TRIG_MAX_RATIO, c.y - cos_lookup(t) * 5 / TRIG_MAX_RATIO);
GPoint e = GPoint(c.x + sin_lookup(t) * 8 / TRIG_MAX_RATIO, c.y - cos_lookup(t) * 8 / TRIG_MAX_RATIO);
graphics_draw_line(ctx, s, e);
}
graphics_context_set_fill_color(ctx, s_ink);
graphics_fill_circle(ctx, c, 4);
icon_cloud(ctx, x + 3, y + 6);
}
// mostly cloud: full cloud dominant, only a sliver of sun peeking above it
static void icon_mostly_cloudy(GContext *ctx, int x, int y) {
graphics_context_set_stroke_color(ctx, s_ink);
graphics_context_set_stroke_width(ctx, 1);
GPoint c = GPoint(x + 3, y + 3);
int32_t t = TRIG_MAX_ANGLE * 315 / 360;
GPoint s = GPoint(c.x + sin_lookup(t) * 4 / TRIG_MAX_RATIO, c.y - cos_lookup(t) * 4 / TRIG_MAX_RATIO);
GPoint e = GPoint(c.x + sin_lookup(t) * 6 / TRIG_MAX_RATIO, c.y - cos_lookup(t) * 6 / TRIG_MAX_RATIO);
graphics_draw_line(ctx, s, e);
graphics_context_set_fill_color(ctx, s_ink);
graphics_fill_circle(ctx, c, 2);
icon_cloud(ctx, x + 1, y + 4);
}
static void icon_wx(GContext *ctx, int x, int y, int bucket) {
switch (bucket) {
case WX_CLEAR: icon_sun(ctx, x, y); break;
case WX_PARTLY_CLOUDY: icon_partly_cloudy(ctx, x, y); break;
case WX_MOSTLY_CLOUDY: icon_mostly_cloudy(ctx, x, y); break;
case WX_RAIN: icon_umbrella(ctx, x, y); break;
case WX_SNOW: icon_snowflake(ctx, x, y); break;
case WX_STORM: icon_storm(ctx, x, y); break;
case WX_FOG: icon_fog(ctx, x, y); break;
case WX_WIND: icon_wind(ctx, x, y); break;
default: icon_cloud(ctx, x, y); break; // WX_CLOUD, WX_UNKNOWN
}
}
// ── components ────────────────────────────────────────────────────────
typedef void (*IconFn)(GContext *, int, int);
// cy is the row's vertical center; icon box and label both center on it.
#define ROW_TEXT_DY 11 // label top offset that visually centers 16px text on cy
static void sensor_row(GContext *ctx, int x, int cy, IconFn icon, const char *val, GColor col) {
icon(ctx, x, cy - 8);
text_at(ctx, val, s_f_sens, col, x + 22, cy - ROW_TEXT_DY, 78, GTextAlignmentLeft);
}
static void draw_calendar(GContext *ctx, const Model *m) {
char batt[5];
snprintf(batt, sizeof(batt), "%d%%", m->batt);
int bw = text_w(batt, s_f_batt);
int warn_gap = m->alert_active ? 18 : 0; // room for the warning triangle, when present
int cluster_w = 19 + bw + warn_gap; // battery icon+nub(15) + gap(4) + pct text + warning
char cnt[4] = {0};
int ih = 8, iw = ih * 7 / 5, cw = 0; // envelope capped below the count's ink height
if (m->unread > 0) {
snprintf(cnt, sizeof(cnt), m->unread > 99 ? "99+" : "%d", m->unread);
cw = text_w(cnt, s_f_batt);
}
int badge_w = iw + 3 + cw;
// date / unread badge / battery cluster, packed to the row's edges with the
// badge (when present) centered in whatever space is left between them.
Box children[3] = {
{.fixed = text_w(m->date, s_f_cal)},
{.fixed = badge_w},
{.fixed = cluster_w},
};
int n = 3;
if (m->unread <= 0) {
children[1] = children[2]; // no badge: just date + cluster
n = 2;
}
Box row = {.dir = BOX_ROW, .align_main = BOX_SPACE_BETWEEN, .children = children, .n_children = n};
box_layout(&row, PAD_X, CAL_Y, SCREEN_W - 2 * PAD_X, 16);
// +2: FONT_DATE_14's ink sits flush with the box top, unlike the other
// header-row fonts -- this bottom-aligns it with the battery/badge row.
text_at(ctx, m->date, s_f_cal, s_ink, children[0].x, CAL_Y + 2, children[0].w, GTextAlignmentLeft);
int clx = children[n - 1].x; // battery cluster's left edge
if (m->alert_active) icon_warning(ctx, clx, CAL_Y + 2);
draw_battery(ctx, clx + warn_gap + 15, CAL_Y + 9, m->batt);
text_at(ctx, batt, s_f_batt, s_ink, clx + warn_gap + 19, CAL_Y + 5, bw + 2, GTextAlignmentLeft);
if (m->unread > 0) {
int bx = children[1].x;
icon_mail(ctx, bx, CAL_Y + 16 - ih, ih);
text_at(ctx, cnt, s_f_batt, s_accent, bx + iw + 3, CAL_Y + 5, cw + 2, GTextAlignmentLeft);
}
}
// The colon sits at screen center (HH right-aligned up to it, MM left-aligned
// after) so HH:MM reads symmetric. Seconds hang just past the minutes but are
// clamped to keep a PAD_X right margin. AM/PM sits in the left margin.
static void draw_time(GContext *ctx, const Model *m) {
const char *colon = strchr(m->time, ':');
char hh[4] = {0};
int hn = colon ? (int)(colon - m->time) : (int)strlen(m->time);
memcpy(hh, m->time, hn > 3 ? 3 : hn);
const char *mm = colon ? colon + 1 : "";
int hw = text_w(hh, s_f_time);
int cw = text_w(":", s_f_time);
int mw = text_w(mm, s_f_time);
int cx = SCREEN_W / 2;
int pad = 24; // slack so the right/left-aligned parts never clip
text_at(ctx, hh, s_f_time, s_ink, cx - cw / 2 - hw - pad, TIME_TOP, hw + pad, GTextAlignmentRight);
text_at(ctx, ":", s_f_time, s_ink, cx - pad, TIME_TOP, 2 * pad, GTextAlignmentCenter);
text_at(ctx, mm, s_f_time, s_ink, cx + cw / 2, TIME_TOP, mw + pad, GTextAlignmentLeft);
if (m->show_ampm) {
text_at(ctx, "AM", s_f_batt, m->is_pm ? s_ink_ghost : s_ink, PAD_X, TIME_TOP + 12, 18, GTextAlignmentLeft);
text_at(ctx, "PM", s_f_batt, m->is_pm ? s_ink : s_ink_ghost, PAD_X, TIME_TOP + 25, 18, GTextAlignmentLeft);
}
if (model_show_seconds(m)) {
int sw = text_w(m->sec, s_f_sec);
int sx = cx + cw / 2 + mw + 5;
int max_x = SCREEN_W - PAD_X - sw; // keep a right margin instead of hugging the edge
if (sx > max_x) sx = max_x;
text_at(ctx, m->sec, s_f_sec, s_accent, sx, TIME_TOP + TIME_H - 24, sw + 8, GTextAlignmentLeft);
}
}
static void draw_sensors(GContext *ctx, const Model *m) {
graphics_context_set_fill_color(ctx, s_ink);
graphics_fill_rect(ctx, GRect(SENS_MIDX - 1, SENS_TOP, 2, SENS_BOT - SENS_TOP), 0, GCornerNone);
// Three rows split the zone into equal bands, each centered in its band — so
// they fill the height evenly instead of clustering under the rule.
int colL = PAD_X + 6, colR = SENS_MIDX + 12;
int band = (SENS_BOT - SENS_TOP) / 3;
int cy0 = SENS_TOP + band / 2, pitch = band;
char buf[16];
snprintf(buf, sizeof(buf), "%d", m->steps);
sensor_row(ctx, colL, cy0, icon_steps, buf, s_ink);
if (m->sleep_min > 0)
snprintf(buf, sizeof(buf), "%dh%02d", m->sleep_min / 60, m->sleep_min % 60);
else
snprintf(buf, sizeof(buf), "--");
sensor_row(ctx, colL, cy0 + pitch, icon_moon, buf, s_ink);
// Alarm always shows the bell; the time when set, a readable "off" when not.
if (m->alarm_en)
snprintf(buf, sizeof(buf), "%d:%02d", m->alarm_h, m->alarm_m);
else
snprintf(buf, sizeof(buf), "off");
sensor_row(ctx, colL, cy0 + 2 * pitch, icon_alarm, buf, s_ink);
if (m->hr > 0)
snprintf(buf, sizeof(buf), "%d", m->hr);
else
snprintf(buf, sizeof(buf), "--");
sensor_row(ctx, colR, cy0, icon_heart, buf, s_accent);
if (m->temp_f != WEATHER_NONE)
snprintf(buf, sizeof(buf), "%d°", model_temp(m));
else
snprintf(buf, sizeof(buf), "--°");
sensor_row(ctx, colR, cy0 + pitch, icon_thermo, buf, s_ink);
// condition (replaces the unavailable message count): advice icon + sky word
int cy = cy0 + 2 * pitch;
if (m->wx_bucket != WX_NONE) {
icon_wx(ctx, colR, cy - 8, m->wx_bucket);
text_at(ctx, WX_WORD[m->wx_bucket], s_f_sens, s_ink, colR + 22, cy - ROW_TEXT_DY, 78, GTextAlignmentLeft);
} else {
icon_wx(ctx, colR, cy - 8, WX_CLOUD);
text_at(ctx, "--", s_f_sens, s_ink, colR + 22, cy - ROW_TEXT_DY, 78, GTextAlignmentLeft);
}
}
void draw_face(GContext *ctx, const Model *m) {
graphics_context_set_fill_color(ctx, s_paper);
graphics_fill_rect(ctx, GRect(0, 0, SCREEN_W, SCREEN_H), 0, GCornerNone);
draw_calendar(ctx, m);
rule(ctx, RULE1_Y);
draw_time(ctx, m);
rule(ctx, RULE2_Y);
draw_sensors(ctx, m);
}
#pragma once
#include "nilcoast.h"
void draw_init(void); // load fonts + palette
void draw_deinit(void);
void draw_palette(bool invert); // select normal / inverted theme
void draw_face(GContext *ctx, const Model *m);
GColor draw_bg(void);
#include "draw.h"
#include "model.h"
#include "nilcoast.h"
#define LIGHT_MS 10000 // backlight hold after a tap/shake (shorter than the peek)
#define DTAP_MS 400 // window for a second screen tap to count as a double-tap
static Window *s_window;
static Layer *s_canvas;
static AppTimer *s_peek_timer;
static AppTimer *s_light_timer;
static AppTimer *s_dtap_timer;
static int s_tap_count;
static Model s_model;
static void redraw(void) { layer_mark_dirty(s_canvas); }
static void canvas_update(Layer *layer, GContext *ctx) { draw_face(ctx, &s_model); }
static void tick_handler(struct tm *t, TimeUnits units) {
bool dirty = model_tick(&s_model, t);
if (units & MINUTE_UNIT) model_refresh_health(&s_model);
if (s_model.wants_vibe) vibes_double_pulse();
if (dirty) redraw();
}
// Second resolution while seconds show (always-on or a peek), minute otherwise.
static void apply_tick_unit(void) {
tick_timer_service_unsubscribe();
TimeUnits unit = model_show_seconds(&s_model) ? SECOND_UNIT : MINUTE_UNIT;
tick_timer_service_subscribe(unit, tick_handler);
}
static void peek_end(void *data) {
s_peek_timer = NULL;
model_set_peek(&s_model, false);
apply_tick_unit();
redraw();
}
static void light_off(void *data) {
s_light_timer = NULL;
light_enable(false);
}
// Reveal seconds for a minute while in minute mode and hold the backlight on
// for a shorter, readable window; re-triggering extends both. No-op when
// seconds are already always-on.
static void start_peek(void) {
if (s_model.show_seconds) return;
if (model_set_peek(&s_model, true)) apply_tick_unit();
light_enable(true);
if (s_light_timer)
app_timer_reschedule(s_light_timer, LIGHT_MS);
else
s_light_timer = app_timer_register(LIGHT_MS, light_off, NULL);
if (s_peek_timer)
app_timer_reschedule(s_peek_timer, PEEK_MS);
else
s_peek_timer = app_timer_register(PEEK_MS, peek_end, NULL);
redraw();
}
static void tap_handler(AccelAxisType axis, int32_t direction) { start_peek(); }
static void dtap_reset(void *data) {
s_dtap_timer = NULL;
s_tap_count = 0;
}
// Two screen taps within DTAP_MS trigger the same peek as a shake. (accel_tap
// only catches accelerometer jolts, not finger taps on the touchscreen.)
static void touch_handler(const TouchEvent *event, void *ctx) {
if (event->type != TouchEvent_Touchdown) return;
if (++s_tap_count >= 2) {
s_tap_count = 0;
if (s_dtap_timer) {
app_timer_cancel(s_dtap_timer);
s_dtap_timer = NULL;
}
start_peek();
return;
}
if (s_dtap_timer)
app_timer_reschedule(s_dtap_timer, DTAP_MS);
else
s_dtap_timer = app_timer_register(DTAP_MS, dtap_reset, NULL);
}
static void battery_handler(BatteryChargeState c) {
if (model_set_battery(&s_model, c.charge_percent)) redraw();
}
static void inbox_received(DictionaryIterator *iter, void *ctx) {
model_apply_message(&s_model, iter);
if (s_model.tick_unit_dirty) apply_tick_unit();
if (s_model.wants_alert_vibe) vibes_double_pulse();
draw_palette(s_model.invert);
window_set_background_color(s_window, draw_bg());
time_t now = time(NULL);
model_tick(&s_model, localtime(&now)); // reflect a clock/unit change immediately
if (s_model.wants_vibe) vibes_double_pulse(); // an alarm can land in this same minute
redraw();
}
static void window_load(Window *window) {
s_canvas = layer_create(layer_get_bounds(window_get_root_layer(window)));
layer_set_update_proc(s_canvas, canvas_update);
layer_add_child(window_get_root_layer(window), s_canvas);
}
static void window_unload(Window *window) { layer_destroy(s_canvas); }
static void init(void) {
draw_init();
model_init(&s_model);
draw_palette(s_model.invert);
s_window = window_create();
window_set_background_color(s_window, draw_bg());
window_set_window_handlers(s_window, (WindowHandlers){
.load = window_load,
.unload = window_unload,
});
window_stack_push(s_window, true);
app_message_register_inbox_received(inbox_received);
app_message_open(164, 64);
apply_tick_unit();
battery_state_service_subscribe(battery_handler);
accel_tap_service_subscribe(tap_handler);
touch_service_subscribe(touch_handler, NULL);
time_t now = time(NULL);
model_tick(&s_model, localtime(&now));
model_set_battery(&s_model, battery_state_service_peek().charge_percent);
model_refresh_health(&s_model);
}
static void deinit(void) {
if (s_peek_timer) app_timer_cancel(s_peek_timer);
if (s_light_timer) app_timer_cancel(s_light_timer);
if (s_dtap_timer) app_timer_cancel(s_dtap_timer);
light_enable(false);
touch_service_unsubscribe();
accel_tap_service_unsubscribe();
tick_timer_service_unsubscribe();
battery_state_service_unsubscribe();
draw_deinit();
window_destroy(s_window);
}
int main(void) {
init();
app_event_loop();
deinit();
}
#include "math.h"
#include "nilcoast.h"
#include <string.h>
int clampi(int v, int lo, int hi) {
if (v < lo) return lo;
if (v > hi) return hi;
return v;
}
int hour_12(int h24) {
int h = h24 % 12;
return h == 0 ? 12 : h;
}
int c_to_f(int c) { return c * 9 / 5 + 32; }
int f_to_c(int f) { return (f - 32) * 5 / 9; }
// wthr.cloud's getWeatherIcon() bucket name -> WX_*.
int wx_bucket_from_name(const char *name) {
if (strcmp(name, "sunny") == 0) return WX_CLEAR;
if (strcmp(name, "partly-cloudy") == 0) return WX_PARTLY_CLOUDY;
if (strcmp(name, "mostly-cloudy") == 0) return WX_MOSTLY_CLOUDY;
if (strcmp(name, "cloudy") == 0) return WX_CLOUD;
if (strcmp(name, "rain") == 0) return WX_RAIN;
if (strcmp(name, "snow") == 0) return WX_SNOW;
if (strcmp(name, "storm") == 0) return WX_STORM;
if (strcmp(name, "fog") == 0) return WX_FOG;
if (strcmp(name, "wind") == 0) return WX_WIND;
return WX_UNKNOWN; // "default", or anything unrecognized
}
#pragma once
int clampi(int v, int lo, int hi);
int hour_12(int h24);
int c_to_f(int c);
int f_to_c(int f);
int wx_bucket_from_name(const char *name); // WX_* in nilcoast.h
#include "model.h"
#include "math.h"
#include <string.h>
const char *const MON[12] = {"JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC"};
const char *const DOW[7] = {"SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT"};
static void load_prefs(Model *m) {
if (persist_exists(PK_CLOCK_STYLE)) m->clock_style = persist_read_int(PK_CLOCK_STYLE);
if (persist_exists(PK_TEMP_UNIT)) m->temp_unit = persist_read_int(PK_TEMP_UNIT);
if (persist_exists(PK_ALARM_EN)) m->alarm_en = persist_read_int(PK_ALARM_EN);
if (persist_exists(PK_ALARM_H)) m->alarm_h = persist_read_int(PK_ALARM_H);
if (persist_exists(PK_ALARM_M)) m->alarm_m = persist_read_int(PK_ALARM_M);
if (persist_exists(PK_LOCALE_F)) m->locale_f = persist_read_int(PK_LOCALE_F);
if (persist_exists(PK_SHOW_SECONDS)) m->show_seconds = persist_read_int(PK_SHOW_SECONDS);
if (persist_exists(PK_INVERT)) m->invert = persist_read_int(PK_INVERT);
}
static void save_prefs(const Model *m) {
persist_write_int(PK_CLOCK_STYLE, m->clock_style);
persist_write_int(PK_TEMP_UNIT, m->temp_unit);
persist_write_int(PK_ALARM_EN, m->alarm_en);
persist_write_int(PK_ALARM_H, m->alarm_h);
persist_write_int(PK_ALARM_M, m->alarm_m);
persist_write_int(PK_LOCALE_F, m->locale_f);
persist_write_int(PK_SHOW_SECONDS, m->show_seconds);
persist_write_int(PK_INVERT, m->invert);
}
void model_init(Model *m) {
*m = (Model){0};
m->temp_f = WEATHER_NONE;
m->wx_bucket = WX_NONE;
m->temp_unit = UNIT_AUTO;
m->locale_f = 1;
m->alarm_h = 7;
m->show_seconds = 0; // minute refresh by default; peek/config opt into seconds
m->last_alarm_min = -1;
load_prefs(m);
}
bool model_use_24h(const Model *m) {
if (m->clock_style == CLOCK_12) return false;
if (m->clock_style == CLOCK_24) return true;
return clock_is_24h_style();
}
bool model_show_seconds(const Model *m) { return m->show_seconds || m->peek; }
bool model_show_fahrenheit(const Model *m) {
if (m->temp_unit == UNIT_F) return true;
if (m->temp_unit == UNIT_C) return false;
return m->locale_f; // UNIT_AUTO follows phone locale
}
int model_temp(const Model *m) { return model_show_fahrenheit(m) ? m->temp_f : f_to_c(m->temp_f); }
bool model_tick(Model *m, struct tm *t) {
bool h24 = model_use_24h(m);
m->show_ampm = !h24;
m->is_pm = t->tm_hour >= 12;
int h = h24 ? t->tm_hour : hour_12(t->tm_hour);
snprintf(m->time, sizeof(m->time), "%d:%02d", h, t->tm_min);
snprintf(m->sec, sizeof(m->sec), "%02d", t->tm_sec);
snprintf(m->date, sizeof(m->date), "%s %d %s", DOW[t->tm_wday], t->tm_mday, MON[t->tm_mon]);
m->wants_vibe = m->alarm_en && t->tm_hour == m->alarm_h && t->tm_min == m->alarm_m &&
m->last_alarm_min != t->tm_min;
if (m->wants_vibe) m->last_alarm_min = t->tm_min;
return true;
}
bool model_set_battery(Model *m, int pct) {
if (m->batt == pct) return false;
m->batt = pct;
return true;
}
bool model_set_peek(Model *m, bool on) {
if (m->peek == on) return false;
m->peek = on;
m->tick_unit_dirty = true;
return true;
}
void model_refresh_health(Model *m) {
time_t now = time(NULL), today = time_start_of_today();
HealthServiceAccessibilityMask up = HealthServiceAccessibilityMaskAvailable;
if (health_service_metric_accessible(HealthMetricStepCount, today, now) & up)
m->steps = (int)health_service_sum_today(HealthMetricStepCount);
if (health_service_metric_accessible(HealthMetricSleepSeconds, today, now) & up)
m->sleep_min = (int)health_service_sum_today(HealthMetricSleepSeconds) / 60;
if (health_service_metric_accessible(HealthMetricHeartRateBPM, now, now) & up)
m->hr = (int)health_service_peek_current_value(HealthMetricHeartRateBPM);
}
bool model_apply_message(Model *m, DictionaryIterator *iter) {
Tuple *t;
bool cfg = false;
int prev_seconds = m->show_seconds;
m->tick_unit_dirty = false;
if ((t = dict_find(iter, MESSAGE_KEY_WEATHER_TEMP_F))) m->temp_f = (int)t->value->int32;
if ((t = dict_find(iter, MESSAGE_KEY_WEATHER_BUCKET)))
m->wx_bucket = wx_bucket_from_name(t->value->cstring);
if ((t = dict_find(iter, MESSAGE_KEY_WEATHER_LOCALE_F))) m->locale_f = (int)t->value->int32;
if ((t = dict_find(iter, MESSAGE_KEY_IMESSAGE_UNREAD))) m->unread = (int)t->value->int32;
if ((t = dict_find(iter, MESSAGE_KEY_WEATHER_ALERT))) m->alert_active = t->value->int32 != 0;
if ((t = dict_find(iter, MESSAGE_KEY_WEATHER_ALERT_EVENT))) {
strncpy(m->alert_event, t->value->cstring, sizeof(m->alert_event) - 1);
m->alert_event[sizeof(m->alert_event) - 1] = '\0';
}
// Vibrate once per distinct alert, not on every refresh while it's still active.
m->wants_alert_vibe = m->alert_active && strcmp(m->alert_event, m->alert_vibrated_event) != 0;
if (m->wants_alert_vibe) {
strncpy(m->alert_vibrated_event, m->alert_event, sizeof(m->alert_vibrated_event) - 1);
m->alert_vibrated_event[sizeof(m->alert_vibrated_event) - 1] = '\0';
} else if (!m->alert_active) {
m->alert_vibrated_event[0] = '\0';
}
if ((t = dict_find(iter, MESSAGE_KEY_CFG_CLOCK_STYLE))) {
m->clock_style = (int)t->value->int32;
cfg = true;
}
if ((t = dict_find(iter, MESSAGE_KEY_CFG_TEMP_UNIT))) {
m->temp_unit = (int)t->value->int32;
cfg = true;
}
if ((t = dict_find(iter, MESSAGE_KEY_CFG_SHOW_SECONDS))) {
m->show_seconds = (int)t->value->int32;
cfg = true;
}
if ((t = dict_find(iter, MESSAGE_KEY_CFG_INVERT))) {
m->invert = (int)t->value->int32;
cfg = true;
}
if ((t = dict_find(iter, MESSAGE_KEY_CFG_ALARM_ENABLED))) {
m->alarm_en = (int)t->value->int32;
cfg = true;
}
if ((t = dict_find(iter, MESSAGE_KEY_CFG_ALARM_HOUR))) {
m->alarm_h = (int)t->value->int32;
cfg = true;
}
if ((t = dict_find(iter, MESSAGE_KEY_CFG_ALARM_MIN))) {
m->alarm_m = (int)t->value->int32;
cfg = true;
}
if (cfg) {
save_prefs(m);
m->tick_unit_dirty = m->show_seconds != prev_seconds;
} else if (dict_find(iter, MESSAGE_KEY_WEATHER_LOCALE_F)) {
persist_write_int(PK_LOCALE_F, m->locale_f); // locale hint persists even without a config save
}
return true;
}
#pragma once
#include "nilcoast.h"
// State + mutators. Mutators return whether a redraw is needed; the caller also
// honors m->wants_vibe, m->wants_alert_vibe, and m->tick_unit_dirty after calling.
void model_init(Model *m);
bool model_tick(Model *m, struct tm *t);
bool model_set_battery(Model *m, int pct);
bool model_set_peek(Model *m, bool on);
bool model_apply_message(Model *m, DictionaryIterator *iter);
void model_refresh_health(Model *m);
// pure reads of the model
bool model_use_24h(const Model *m);
bool model_show_seconds(const Model *m);
bool model_show_fahrenheit(const Model *m);
int model_temp(const Model *m); // effective unit
#pragma once
#include <pebble.h>
// Layout (native emery px, 200x228). Two 2px rules split calendar / time / sensors.
#define SCREEN_W 200
#define SCREEN_H 228
#define PAD_X 10
#define CAL_Y 12
#define RULE1_Y 36
#define TIME_TOP 51 // centers the 38px hero between the two rules
#define TIME_H 46
#define RULE2_Y 106
#define SENS_TOP 114
#define SENS_BOT 214 // bottom of the sensor zone (leaves a margin)
#define SENS_MIDX 100 // column divider x
#define WEATHER_NONE -999 // temp_f sentinel: no reading yet
#define WX_NONE -1 // wx_bucket sentinel: no reading yet
#define PEEK_MS 60000 // shake reveals seconds for this long
enum { CLOCK_AUTO = 0, CLOCK_12 = 1, CLOCK_24 = 2 };
enum { UNIT_AUTO = 0, UNIT_F = 1, UNIT_C = 2 };
// Mirrors wthr.cloud's getWeatherIcon() bucket strings (see wx_bucket_from_name).
// WX_UNKNOWN is a recognized-but-uncategorized forecast ("default"), not "no data yet".
enum {
WX_CLEAR = 0,
WX_PARTLY_CLOUDY,
WX_MOSTLY_CLOUDY,
WX_CLOUD,
WX_RAIN,
WX_SNOW,
WX_STORM,
WX_FOG,
WX_WIND,
WX_UNKNOWN,
};
// persist keys
enum {
PK_CLOCK_STYLE = 1,
PK_TEMP_UNIT,
PK_ALARM_EN,
PK_ALARM_H,
PK_ALARM_M,
PK_LOCALE_F,
PK_SHOW_SECONDS,
PK_INVERT,
};
extern const char *const MON[12];
extern const char *const DOW[7];
// All watchface state lives here. Event sources mutate it through the model_*
// functions; render() is a pure read of it.
typedef struct {
// derived display strings (rebuilt on tick)
char time[6]; // "10:58"
char sec[3]; // "50"
char date[12]; // "MON 10 NOV"
bool is_pm;
bool show_ampm;
// sensors
int batt;
int steps;
int sleep_min;
int hr; // <=0: no reading
int temp_f; // WEATHER_NONE: no reading
int wx_bucket; // WX_*, WX_NONE if unknown
int unread; // iMessage unread thread count, 0 if none or not configured
// weather alerts
bool alert_active;
char alert_event[32]; // short NWS event name, "" if none
char alert_vibrated_event[32]; // last event we've already vibrated for
// prefs (persisted)
int clock_style; // CLOCK_*
int temp_unit; // UNIT_*
int locale_f; // 1 if phone locale prefers Fahrenheit (used when UNIT_AUTO)
int alarm_en;
int alarm_h;
int alarm_m;
int show_seconds;
int invert; // dark theme: swap ink/paper, keep the accent
// runtime
bool peek; // revealing seconds on demand despite minute mode
int last_alarm_min; // guards single alarm fire per minute
// side-effect requests for the caller to honor after a mutation
bool wants_vibe; // alarm fired this tick
bool wants_alert_vibe; // a new weather alert just became active
bool tick_unit_dirty; // second/minute cadence needs re-subscribing
} Model;
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment