watchface: first rendering baseline (canvas renderer, widget table, layout persist)

This commit is contained in:
Mystica Venatus
2026-09-11 15:50:29 -04:00
parent 09266ab07d
commit 9c45250f4c
11 changed files with 569 additions and 0 deletions

View File

@@ -0,0 +1,27 @@
#include <pebble.h>
#include <string.h>
#include "face_packet.h"
static FacePacket s_packet;
void face_packet_init(void) {
if (persist_exists(PACKET_KEY) &&
persist_read_data(PACKET_KEY, &s_packet, sizeof(s_packet)) ==
(int)sizeof(s_packet) &&
s_packet.version == PACKET_VERSION) {
return; // cached packet restored — countdown resumes from end_epoch
}
memset(&s_packet, 0, sizeof(s_packet));
s_packet.version = PACKET_VERSION;
persist_write_data(PACKET_KEY, &s_packet, sizeof(s_packet));
}
void face_packet_store(const FacePacket *p) {
if (!p || p->version != PACKET_VERSION) return;
s_packet = *p;
persist_write_data(PACKET_KEY, &s_packet, sizeof(s_packet));
}
const FacePacket *face_packet_get(void) {
return &s_packet;
}

View File

@@ -0,0 +1,20 @@
#pragma once
#include <pebble.h>
// The face's ONLY view of game truth (DESIGN.md §4.1). Small, cacheable,
// stale-detectable. End timestamp is authoritative: countdown is local math.
#define PACKET_VERSION 1
#define PACKET_KEY 101
#define TASK_NAME_MAX 24
typedef struct __attribute__((packed)) {
uint8_t version; // PACKET_VERSION
char task_name[TASK_NAME_MAX]; // "" = no hero / not linked
uint32_t end_epoch; // UTC, chunk end of current task (0 = none)
uint8_t queue_fill; // chunks used, 0..36
uint32_t fetched_epoch; // when JS delivered this (staleness signal)
} FacePacket;
void face_packet_init(void); // load-or-default from persist
void face_packet_store(const FacePacket *p); // JS delivered a fresh one
const FacePacket *face_packet_get(void);

View File

@@ -0,0 +1,275 @@
// ===========================================================================
// Nine Hazes — watchface (v0.1 clean rewrite, Muninn-lifecycle pattern)
// Replace the WHOLE file with this. Do not splice.
// ===========================================================================
#include <pebble.h>
#include <time.h>
#include <string.h>
#include "widgets.h"
#include "face_packet.h"
static Window *s_window;
static Layer *s_canvas;
static LayoutState s_layout;
static char s_rendered[WIDGET_SLOTS][64];
static char s_clock[8], s_date[16], s_strip[64];
static int s_bat_pct = -1;
// ---- battery estimator (unchanged design) ---------------------------------
#define BAT_SAMPLES_MAX 16
#define BAT_KEY 102
typedef struct __attribute__((packed)) {
uint8_t version;
uint8_t count;
uint32_t ts[BAT_SAMPLES_MAX];
uint8_t pct[BAT_SAMPLES_MAX];
} BatLog;
static BatLog s_batlog;
static void bat_sample(uint8_t pct) {
if (pct > 100) return;
if (s_batlog.count > 0 && s_batlog.pct[s_batlog.count - 1] == pct) return;
if (s_batlog.count == BAT_SAMPLES_MAX) {
memmove(&s_batlog.ts[0], &s_batlog.ts[1], (BAT_SAMPLES_MAX - 1) * sizeof(uint32_t));
memmove(&s_batlog.pct[0], &s_batlog.pct[1], (BAT_SAMPLES_MAX - 1) * sizeof(uint8_t));
s_batlog.count--;
}
s_batlog.ts[s_batlog.count] = (uint32_t)(time(NULL) / 60);
s_batlog.pct[s_batlog.count] = pct;
s_batlog.count++;
persist_write_data(BAT_KEY, &s_batlog, sizeof(s_batlog));
}
static int bat_days_remaining(void) {
if (s_batlog.count < 3) return -1;
int n = s_batlog.count;
double sx = 0, sy = 0, sxy = 0, sxx = 0;
for (int i = 0; i < n; i++) {
double x = (double)s_batlog.ts[i] / 1440.0;
double y = (double)s_batlog.pct[i];
sx += x; sy += y; sxy += x * y; sxx += x * x;
}
double denom = n * sxx - sx * sx;
if (denom <= 0) return -1;
double slope = (n * sxy - sx * sy) / denom;
if (slope >= -0.05) return -1;
return (int)((double)s_batlog.pct[n - 1] / -slope + 0.5);
}
// ---- palette & fonts --------------------------------------------------------
static GColor pal_color(PalIndex i) {
switch (i) {
case PAL_BG: return GColorWhite;
case PAL_TEXT: return GColorBlack;
case PAL_ACCENT: return GColorFromHEX(0xB08D3F);
case PAL_POSITIVE: return GColorFromHEX(0x5A7A4A);
case PAL_WARNING: return GColorFromHEX(0x6E1F24);
case PAL_WATER: return GColorFromHEX(0x4A5D73);
default: return GColorBlack;
}
}
static GFont s_fonts[5];
static void fonts_load(void) {
s_fonts[0] = fonts_get_system_font(FONT_KEY_GOTHIC_14);
s_fonts[1] = fonts_get_system_font(FONT_KEY_GOTHIC_18);
s_fonts[2] = fonts_get_system_font(FONT_KEY_GOTHIC_24);
s_fonts[3] = fonts_get_system_font(FONT_KEY_GOTHIC_28);
s_fonts[4] = fonts_get_system_font(FONT_KEY_BITHAM_42_BOLD);
}
// ---- values -----------------------------------------------------------------
static void update_values(void) {
time_t now = time(NULL);
struct tm *t = localtime(&now);
strftime(s_clock, sizeof(s_clock),
clock_is_24h_style() ? "%H:%M" : "%l:%M", t);
strftime(s_date, sizeof(s_date), "%a %d %b", t);
const FacePacket *p = face_packet_get();
if (p->end_epoch > 0 && p->task_name[0]) {
int32_t secs = (int32_t)(p->end_epoch - (uint32_t)now);
if (secs > 0) {
int32_t m = secs / 60 + ((secs % 60) ? 1 : 0);
snprintf(s_strip, sizeof(s_strip), "%s %ld:%02ld %u/36",
p->task_name, (long)(m / 60), (long)(m % 60), p->queue_fill);
} else {
snprintf(s_strip, sizeof(s_strip), "%s - return for reward", p->task_name);
}
} else {
snprintf(s_strip, sizeof(s_strip), "No hero yet");
}
}
// ---- renderer -----------------------------------------------------------------
static void canvas_update(Layer *layer, GContext *ctx) {
static int n = 0;
if (n < 5) { APP_LOG(APP_LOG_LEVEL_DEBUG, "[RENDER] pass %d", n); n++; }
GRect b = layer_get_bounds(layer);
graphics_context_set_fill_color(ctx, pal_color(PAL_BG));
graphics_fill_rect(ctx, b, 0, GCornerNone);
update_values(); // ALWAYS fresh values before drawing
for (uint8_t i = 0; i < s_layout.count; i++) {
const WidgetCfg *w = &s_layout.widgets[i];
if (!w->visible || w->type >= WIDGET_TYPE_COUNT) continue;
const char *text;
char buf[64];
switch ((WidgetType)w->type) {
case W_TIME: text = s_clock; break;
case W_DATE: text = s_date; break;
case W_BATTERY: {
int days = bat_days_remaining();
if (days < 0) snprintf(buf, sizeof(buf), "%d%%", s_bat_pct);
else snprintf(buf, sizeof(buf), "%d%% (~%dd)", s_bat_pct, days);
text = buf; break;
}
case W_PHONE_BATTERY: text = "phone?"; break;
case W_STEPS: text = "steps?"; break;
case W_DISTANCE: text = "dist?"; break;
case W_WEATHER: text = "--/--"; break;
case W_OMEN: text = ""; break;
case W_SLEEP: text = "sleep?"; break;
case W_TASK_STRIP: text = s_strip; break;
default: continue;
}
GFont f = (w->font_idx < 5) ? s_fonts[w->font_idx] : s_fonts[1];
graphics_context_set_text_color(ctx, pal_color((PalIndex)w->color_idx));
GRect box = GRect(w->x, w->y, b.size.w - w->x, 40);
graphics_draw_text(ctx, text, f, box, GTextOverflowModeTrailingEllipsis,
GTextAlignmentLeft, NULL);
}
}
static void force_full_redraw(void) {
for (int i = 0; i < WIDGET_SLOTS; i++) s_rendered[i][0] = '\0';
if (s_canvas) layer_mark_dirty(s_canvas);
}
// ---- layout (persist-or-default) ---------------------------------------------
static void layout_default(void) {
memset(&s_layout, 0, sizeof(s_layout));
s_layout.version = LAYOUT_VERSION;
s_layout.count = WIDGET_TYPE_COUNT;
const WidgetCfg defs[WIDGET_TYPE_COUNT] = {
{W_TIME, 1, 0, 10, 3, PAL_TEXT, 0},
{W_DATE, 1, 4, 70, 0, PAL_ACCENT, 0},
{W_BATTERY, 1, 4, 90, 0, PAL_TEXT, BAT_BOTH},
{W_STEPS, 1, 4, 110, 0, PAL_TEXT, 0},
{W_TASK_STRIP, 1, 0, 200, 1, PAL_ACCENT, 0},
{W_PHONE_BATTERY, 0, 4, 130, 0, PAL_TEXT, 0},
{W_DISTANCE, 0, 4, 150, 0, PAL_TEXT, 0},
{W_WEATHER, 0, 4, 130, 2, PAL_TEXT, 0},
{W_OMEN, 0, 4, 150, 0, PAL_POSITIVE,0},
{W_SLEEP, 0, 4, 170, 1, PAL_WATER, 0},
};
memcpy(s_layout.widgets, defs, sizeof(defs));
persist_write_data(LAYOUT_KEY, &s_layout, sizeof(s_layout));
APP_LOG(APP_LOG_LEVEL_DEBUG, "[LAYOUT] defaults written");
}
static void layout_init(void) {
if (persist_exists(LAYOUT_KEY)) {
int read = persist_read_data(LAYOUT_KEY, &s_layout, sizeof(s_layout));
bool ok = (read == (int)sizeof(s_layout)) &&
(s_layout.version == LAYOUT_VERSION) &&
(s_layout.count > 0 && s_layout.count <= WIDGET_SLOTS);
APP_LOG(APP_LOG_LEVEL_DEBUG, "[LAYOUT] read=%d ok=%d count=%u", read, ok, s_layout.count);
if (ok) return;
}
layout_default();
}
// ---- services ------------------------------------------------------------------
static void tick_handler(struct tm *t, TimeUnits u) {
static int n = 0;
if (n < 3) { APP_LOG(APP_LOG_LEVEL_DEBUG, "[TICK] %02d:%02d", t->tm_hour, t->tm_min); n++; }
if (s_canvas) layer_mark_dirty(s_canvas);
}
static void battery_handler(BatteryChargeState state) {
s_bat_pct = state.charge_percent;
APP_LOG(APP_LOG_LEVEL_DEBUG, "[BAT] %d%%", s_bat_pct);
bat_sample((uint8_t)state.charge_percent);
if (s_canvas) layer_mark_dirty(s_canvas);
}
// ---- AppMessage -------------------------------------------------------------
static void inbox_received(DictionaryIterator *iter, void *ctx) {
APP_LOG(APP_LOG_LEVEL_DEBUG, "[MSG] inbox received");
FacePacket p = *face_packet_get();
Tuple *t = dict_find(iter, MESSAGE_KEY_taskName);
if (t) { strncpy(p.task_name, t->value->cstring, TASK_NAME_MAX - 1);
p.task_name[TASK_NAME_MAX - 1] = '\0'; }
if ((t = dict_find(iter, MESSAGE_KEY_taskEnd))) p.end_epoch = t->value->uint32;
if ((t = dict_find(iter, MESSAGE_KEY_queueFill))) p.queue_fill = t->value->uint8;
p.fetched_epoch = (uint32_t)time(NULL);
face_packet_store(&p);
force_full_redraw();
}
// ---- window lifecycle ---------------------------------------------------------
static void window_load(Window *w) {
Layer *root = window_get_root_layer(w);
GRect b = layer_get_bounds(root);
s_canvas = layer_create(b);
layer_set_update_proc(s_canvas, canvas_update);
layer_add_child(root, s_canvas);
APP_LOG(APP_LOG_LEVEL_DEBUG, "[LOAD] canvas up, count=%u w0=%u", s_layout.count, s_layout.widgets[0].type);
update_values();
force_full_redraw();
}
static void window_unload(Window *w) {
APP_LOG(APP_LOG_LEVEL_DEBUG, "[UNLOAD]");
layer_destroy(s_canvas);
s_canvas = NULL;
}
// ---- app lifecycle (Muninn pattern: all state first, then window) -------------
static void init(void) {
APP_LOG(APP_LOG_LEVEL_DEBUG, "[INIT] start");
fonts_load();
layout_init();
face_packet_init();
if (persist_exists(BAT_KEY) &&
persist_read_data(BAT_KEY, &s_batlog, sizeof(s_batlog)) == (int)sizeof(s_batlog) &&
s_batlog.version == 1) {
// restored
} else {
memset(&s_batlog, 0, sizeof(s_batlog));
s_batlog.version = 1;
}
s_window = window_create();
window_set_window_handlers(s_window, (WindowHandlers){
.load = window_load, .unload = window_unload });
window_stack_push(s_window, true);
APP_LOG(APP_LOG_LEVEL_DEBUG, "[INIT] window pushed");
tick_timer_service_subscribe(MINUTE_UNIT, tick_handler);
battery_state_service_subscribe(battery_handler);
battery_handler(battery_state_service_peek());
app_message_register_inbox_received(inbox_received);
app_message_open(64, 64);
APP_LOG(APP_LOG_LEVEL_DEBUG, "[INIT] done");
}
static void deinit(void) {
tick_timer_service_unsubscribe();
battery_state_service_unsubscribe();
window_destroy(s_window);
}
int main(void) {
init();
app_event_loop();
deinit();
}

View File

@@ -0,0 +1,53 @@
#pragma once
#include <pebble.h>
// ---- Widget types (10, locked in DESIGN.md §5) ---------------------------
typedef enum {
W_TIME = 0,
W_DATE,
W_BATTERY, // watch battery; percent | days | both modes
W_PHONE_BATTERY,
W_STEPS,
W_DISTANCE,
W_WEATHER, // composite: current temp + hi/lo (sub-elements later)
W_OMEN, // weather advisory flavor strip
W_SLEEP,
W_TASK_STRIP, // game presence — the one game element on the face
WIDGET_TYPE_COUNT
} WidgetType;
// ---- Celtic palette (DESIGN.md §9) — indexed, never raw ---------------
typedef enum {
PAL_BG = 0, // peat black
PAL_TEXT, // parchment white
PAL_ACCENT, // aged brass
PAL_POSITIVE, // moss green
PAL_WARNING, // oxblood
PAL_WATER, // river slate
PAL_COUNT
} PalIndex;
// Battery widget display mode
typedef enum { BAT_PERCENT = 0, BAT_DAYS, BAT_BOTH } BatMode;
// ---- Packed, persisted widget config -------------------------------------
// Layout is first-class: OWN persist key, OWN version byte (DESIGN.md §4).
typedef struct __attribute__((packed)) {
uint8_t type; // WidgetType
uint8_t visible; // bool
int16_t x; // top-left, screen coords (200x228)
int16_t y;
uint8_t font_idx; // index into font table
uint8_t color_idx; // PalIndex
uint8_t aux; // per-type: BatMode for battery, sub-flags later
} WidgetCfg;
#define LAYOUT_VERSION 1
#define LAYOUT_KEY 100
#define WIDGET_SLOTS 12 // 10 types + 2 spare, matching "12" scaffold note
typedef struct __attribute__((packed)) {
uint8_t version;
uint8_t count;
WidgetCfg widgets[WIDGET_SLOTS];
} LayoutState;

View File

@@ -0,0 +1,8 @@
// Nine Hazes watchface pkjs. Phase 1: hello only.
// Later (per DESIGN.md §4.1): on 'ready', fetch the face packet from the
// configured server (default instance, self-hostable override) respecting the
// staleness timestamp, then Pebble.sendAppMessage({taskName, taskEnd,
// queueFill}). All fetches wrapped: server down -> render cached, silently.
Pebble.addEventListener('ready', () => {
console.log('Nine Hazes pkjs ready');
});