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

16
nine-hazes-face/.gitignore vendored Normal file
View File

@@ -0,0 +1,16 @@
# Build output
/build/
# Waf artifacts
.sconsign.dblite
.lock-wafbuild
# Editor / OS noise
.vscode/
.idea/
*.swp
*~
.DS_Store
# Pebble tool local state
pebble.log

File diff suppressed because one or more lines are too long

36
nine-hazes-face/README.md Normal file
View File

@@ -0,0 +1,36 @@
# nine-hazes-face
A Pebble watchapp/watchface written in C using the Pebble SDK.
## Building & running
```sh
pebble build # build for all targetPlatforms
pebble install --emulator emery # install on the emery emulator
pebble install --phone <ip> # install to a paired phone
```
## Target platforms
`targetPlatforms` in `package.json` controls which watches you build for. The
modern Pebble hardware is **emery** (Pebble Time 2), **gabbro** (Pebble Round
2), and **flint** (Pebble 2 Duo); the original Pebble platforms (aplite,
basalt, chalk, diorite) are included by default for backwards compatibility.
## Project layout
```
src/c/ C source for the watchapp
src/pkjs/ PebbleKit JS (phone-side) source, if any
worker_src/c/ Background worker source, if any
resources/ Images, fonts, and other bundled resources
package.json Project metadata (UUID, platforms, resources, message keys)
wscript Build rules — usually no need to edit
```
By default this project is configured as a watchapp. To make it a watchface,
set `pebble.watchapp.watchface` to `true` in `package.json`.
## Documentation
Full SDK docs, tutorials, and API reference: <https://developer.repebble.com>

47
nine-hazes-face/dev Executable file
View File

@@ -0,0 +1,47 @@
#!/usr/bin/env bash
# nine-hazes-face dev helpers
# Usage:
# ./dev.sh build + install on emulator
# ./dev.sh run same, plus stream watch logs (Ctrl-C detaches, app keeps running)
# ./dev.sh log attach logs only (emulator must be open)
# ./dev.sh kill stop the emulator and free the websocket
# ./dev.sh clean full rebuild (removes ./build)
set -euo pipefail
cd "$(dirname "$0")"
EMU="emery"
build_install() {
echo "==> Building ($EMU)..."
pebble build
echo "==> Installing to emulator..."
pebble install --emulator "$EMU"
echo "==> Done."
}
case "${1:-}" in
"")
build_install
;;
run)
build_install
echo "==> Streaming logs (Ctrl-C to detach; app keeps running)..."
pebble logs --emulator "$EMU"
;;
log)
pebble logs --emulator "$EMU"
;;
kill)
pebble kill-pebble
;;
clean)
rm -rf build
echo "==> ./build removed; next run is a full rebuild."
;;
*)
echo "Unknown command: $1" >&2
sed -n '2,8p' "$0" | sed 's/^# \{0,1\}//'
exit 1
;;
esac

View File

@@ -0,0 +1,23 @@
{
"name": "nine-hazes-face",
"author": "Artimidorus",
"version": "1.0.1",
"license": "Apache-2.0",
"keywords": ["pebble-app"],
"private": true,
"dependencies": {},
"pebble": {
"displayName": "nine-hazes-face",
"uuid": "cc8b7604-d956-4c6c-a0be-f33bec671181",
"sdkVersion": "3",
"enableMultiJS": true,
"targetPlatforms": ["emery"],
"watchapp": {
"watchface": true
},
"messageKeys": ["taskName", "taskEnd", "queueFill"],
"resources": {
"media": []
}
}
}

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');
});

54
nine-hazes-face/wscript Normal file
View File

@@ -0,0 +1,54 @@
#
# This file is the default set of rules to compile a Pebble application.
#
# Feel free to customize this to your needs.
#
import os.path
top = '.'
out = 'build'
def options(ctx):
ctx.load('pebble_sdk')
def configure(ctx):
"""
This method is used to configure your build. ctx.load(`pebble_sdk`) automatically configures
a build for each valid platform in `targetPlatforms`. Platform-specific configuration: add your
change after calling ctx.load('pebble_sdk') and make sure to set the correct environment first.
Universal configuration: add your change prior to calling ctx.load('pebble_sdk').
"""
ctx.load('pebble_sdk')
def build(ctx):
ctx.load('pebble_sdk')
build_worker = os.path.exists('worker_src')
binaries = []
cached_env = ctx.env
for platform in ctx.env.TARGET_PLATFORMS:
ctx.env = ctx.all_envs[platform]
ctx.set_group(ctx.env.PLATFORM_NAME)
app_elf = '{}/pebble-app.elf'.format(ctx.env.BUILD_DIR)
ctx.pbl_build(source=ctx.path.ant_glob('src/c/**/*.c'), target=app_elf, bin_type='app')
if build_worker:
worker_elf = '{}/pebble-worker.elf'.format(ctx.env.BUILD_DIR)
binaries.append({'platform': platform, 'app_elf': app_elf, 'worker_elf': worker_elf})
ctx.pbl_build(source=ctx.path.ant_glob('worker_src/c/**/*.c'),
target=worker_elf,
bin_type='worker')
else:
binaries.append({'platform': platform, 'app_elf': app_elf})
ctx.env = cached_env
ctx.set_group('bundle')
ctx.pbl_bundle(binaries=binaries,
js=ctx.path.ant_glob(['src/pkjs/**/*.js',
'src/pkjs/**/*.json',
'src/common/**/*.js']),
js_entry_file='src/pkjs/index.js')