Created
July 3, 2026 20:28
-
-
Save erik-nilcoast/9230f0894bf1af41cfa51317b762d6e0 to your computer and use it in GitHub Desktop.
nilcoast - Pebble watchface C source
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #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); | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #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); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #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(); | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #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 | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #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; | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #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