Introduces watch/, a real Pebble watchapp (built via pebble build) that
build / build (push) Successful in 30s

shows the daily tarot/astrology reading on-device: persistence, reading
computation, and UI screens, driven by the existing engine/interpreter
code linked in unchanged where possible.

Required engine-side changes to make that linking work:
- A compact low-precision ephemeris (lowprec_ephemeris.c) replacing the
  vendored ~127KB Astronomy Engine, which doesn't fit the watch's ~64KB
  app budget.
- Hand-rolled sqrt/atan2/sin/cos replacements for that ephemeris and
  astro.c's Ascendant calculation - Pebble's statically-linked libm
  hard-faults on real hardware under this app's -fPIE link for all four.
- narrative.c/guidance.c refactored from FILE*/fprintf onto snprintf-
  based buffers, since Pebble's SDK blocks fprintf at compile time.
This commit is contained in:
ml
2026-07-14 17:10:22 +02:00
parent d9b6e2889b
commit 1a654da3e9
44 changed files with 1920 additions and 182 deletions
+83
View File
@@ -0,0 +1,83 @@
#include "app_message.h"
#include <pebble.h>
/* AppMessage's Dictionary has no float tuple type, so latitude/
* longitude/utc_offset_hours (all doubles in BirthData) cross the wire
* as scaled int32s - microdegrees for lat/lon (~0.11m precision, far
* finer than this app's astrology needs), minutes for the UTC offset
* (covers every real-world offset, including the half/quarter-hour
* ones) - see config.c's own persisted representation, which uses the
* same scaling so no conversion happens twice. */
static ConfigUpdatedCallback s_callback;
static bool read_int(DictionaryIterator *iter, uint32_t key, int32_t *out) {
Tuple *t = dict_find(iter, key);
if (!t) return false;
*out = t->value->int32;
return true;
}
static void inbox_received_handler(DictionaryIterator *iter, void *context) {
WatchConfig cfg;
config_load(&cfg); /* start from existing settings - a field the page
* doesn't send (e.g. it only changed the language)
* keeps its previous value rather than resetting. */
int32_t v;
bool got_birth = false;
if (read_int(iter, MESSAGE_KEY_BIRTH_YEAR, &v)) { cfg.birth.year = v; got_birth = true; }
if (read_int(iter, MESSAGE_KEY_BIRTH_MONTH, &v)) { cfg.birth.month = v; got_birth = true; }
if (read_int(iter, MESSAGE_KEY_BIRTH_DAY, &v)) { cfg.birth.day = v; got_birth = true; }
if (read_int(iter, MESSAGE_KEY_BIRTH_HOUR, &v)) { cfg.birth.hour = v; got_birth = true; }
if (read_int(iter, MESSAGE_KEY_BIRTH_MINUTE, &v)) { cfg.birth.minute = v; got_birth = true; }
if (read_int(iter, MESSAGE_KEY_BIRTH_UTC_OFFSET_MINUTES, &v)) {
cfg.birth.utc_offset_hours = v / 60.0;
got_birth = true;
}
if (read_int(iter, MESSAGE_KEY_BIRTH_LAT_MICRODEG, &v)) {
cfg.birth.latitude = v / 1000000.0;
got_birth = true;
}
if (read_int(iter, MESSAGE_KEY_BIRTH_LON_MICRODEG, &v)) {
cfg.birth.longitude = v / 1000000.0;
got_birth = true;
}
Tuple *lang_tuple = dict_find(iter, MESSAGE_KEY_LANG);
if (lang_tuple) {
strncpy(cfg.lang, lang_tuple->value->cstring, sizeof(cfg.lang) - 1);
cfg.lang[sizeof(cfg.lang) - 1] = '\0';
}
if (read_int(iter, MESSAGE_KEY_NOTIFY_ENABLED, &v)) cfg.notify_enabled = (v != 0);
if (read_int(iter, MESSAGE_KEY_NOTIFY_HOUR, &v)) cfg.notify_hour = v;
if (read_int(iter, MESSAGE_KEY_NOTIFY_MINUTE, &v)) cfg.notify_minute = v;
/* Birth data is the one thing that makes a config submission "complete" -
* language/notification-only re-submissions (e.g. from a later re-open
* of the settings page) must not un-configure an already-set-up watch. */
if (got_birth) cfg.configured = true;
config_save(&cfg);
if (s_callback) s_callback(&cfg);
}
static void inbox_dropped_handler(AppMessageResult reason, void *context) {
APP_LOG(APP_LOG_LEVEL_ERROR, "AppMessage dropped: reason %d", (int)reason);
}
/* app_message_*_size_maximum() return the largest buffer the platform
* *could* hand out (8200 bytes each) - not a size to actually request.
* The real payload is ~150-200 bytes; 256 bytes each leaves headroom
* without eating into this app's ~12KB heap budget (see watch/README.md). */
#define INBOX_SIZE 256
#define OUTBOX_SIZE 256
void app_message_init(ConfigUpdatedCallback on_config_updated) {
s_callback = on_config_updated;
app_message_register_inbox_received(inbox_received_handler);
app_message_register_inbox_dropped(inbox_dropped_handler);
app_message_open(INBOX_SIZE, OUTBOX_SIZE);
}
+20
View File
@@ -0,0 +1,20 @@
#ifndef WATCH_APP_MESSAGE_H
#define WATCH_APP_MESSAGE_H
#include "config.h"
/* Invoked with the freshly-saved config whenever a settings submission
* arrives from the config page (src/pkjs/index.js, task #43) - main.c
* uses this to recompute the reading and refresh/show the menu. */
typedef void (*ConfigUpdatedCallback)(const WatchConfig *cfg);
/* Registers the AppMessage inbox handler and opens the message buffers.
* Call once at startup, before app_event_loop(). Message keys
* (BIRTH_YEAR, BIRTH_UTC_OFFSET_MINUTES, LANG, NOTIFY_ENABLED, etc.) are
* declared in watch/package.json's "messageKeys" and turn into
* MESSAGE_KEY_* constants in the auto-generated message_keys.auto.h -
* see app_message.c's own comment on why latitude/longitude/utc offset
* travel as scaled integers rather than floats. */
void app_message_init(ConfigUpdatedCallback on_config_updated);
#endif
+70
View File
@@ -0,0 +1,70 @@
#include "config.h"
#include <string.h>
/* Persist keys. BirthData's latitude/longitude/utc_offset_hours are
* doubles, but Pebble's persist API has no float storage - stored as
* scaled int32s instead (microdegrees / minutes), matching how
* AppMessage carries the same fields from the config page - see
* app_message.c. */
enum {
PKEY_CONFIGURED = 0,
PKEY_BIRTH_YEAR,
PKEY_BIRTH_MONTH,
PKEY_BIRTH_DAY,
PKEY_BIRTH_HOUR,
PKEY_BIRTH_MINUTE,
PKEY_BIRTH_UTC_OFFSET_MINUTES,
PKEY_BIRTH_LAT_MICRODEG,
PKEY_BIRTH_LON_MICRODEG,
PKEY_LANG,
PKEY_NOTIFY_ENABLED,
PKEY_NOTIFY_HOUR,
PKEY_NOTIFY_MINUTE,
};
void config_load(WatchConfig *out) {
memset(out, 0, sizeof *out);
out->configured = persist_exists(PKEY_CONFIGURED) && persist_read_bool(PKEY_CONFIGURED);
if (!out->configured) {
strcpy(out->lang, "en");
return;
}
out->birth.year = (int)persist_read_int(PKEY_BIRTH_YEAR);
out->birth.month = (int)persist_read_int(PKEY_BIRTH_MONTH);
out->birth.day = (int)persist_read_int(PKEY_BIRTH_DAY);
out->birth.hour = (int)persist_read_int(PKEY_BIRTH_HOUR);
out->birth.minute = (int)persist_read_int(PKEY_BIRTH_MINUTE);
out->birth.utc_offset_hours = (double)(int32_t)persist_read_int(PKEY_BIRTH_UTC_OFFSET_MINUTES) / 60.0;
out->birth.latitude = (double)(int32_t)persist_read_int(PKEY_BIRTH_LAT_MICRODEG) / 1000000.0;
out->birth.longitude = (double)(int32_t)persist_read_int(PKEY_BIRTH_LON_MICRODEG) / 1000000.0;
if (persist_read_string(PKEY_LANG, out->lang, sizeof out->lang) <= 0) {
strcpy(out->lang, "en");
}
out->notify_enabled = persist_read_bool(PKEY_NOTIFY_ENABLED);
out->notify_hour = (int)persist_read_int(PKEY_NOTIFY_HOUR);
out->notify_minute = (int)persist_read_int(PKEY_NOTIFY_MINUTE);
}
void config_save(const WatchConfig *cfg) {
persist_write_bool(PKEY_CONFIGURED, cfg->configured);
persist_write_int(PKEY_BIRTH_YEAR, cfg->birth.year);
persist_write_int(PKEY_BIRTH_MONTH, cfg->birth.month);
persist_write_int(PKEY_BIRTH_DAY, cfg->birth.day);
persist_write_int(PKEY_BIRTH_HOUR, cfg->birth.hour);
persist_write_int(PKEY_BIRTH_MINUTE, cfg->birth.minute);
persist_write_int(PKEY_BIRTH_UTC_OFFSET_MINUTES, (int32_t)(cfg->birth.utc_offset_hours * 60.0));
persist_write_int(PKEY_BIRTH_LAT_MICRODEG, (int32_t)(cfg->birth.latitude * 1000000.0));
persist_write_int(PKEY_BIRTH_LON_MICRODEG, (int32_t)(cfg->birth.longitude * 1000000.0));
persist_write_string(PKEY_LANG, cfg->lang);
persist_write_bool(PKEY_NOTIFY_ENABLED, cfg->notify_enabled);
persist_write_int(PKEY_NOTIFY_HOUR, cfg->notify_hour);
persist_write_int(PKEY_NOTIFY_MINUTE, cfg->notify_minute);
}
+31
View File
@@ -0,0 +1,31 @@
#ifndef WATCH_CONFIG_H
#define WATCH_CONFIG_H
#include <pebble.h>
#include "../../../engine/src/astro.h"
/* Everything the watch needs that isn't computed fresh each day: birth
* data (the same fields as dist/user.properties), display language, and
* daily notification settings. Persisted on-watch via Pebble's own
* key-value persist API (see config.c) - no companion app, no server,
* matching the rest of this project's architecture. */
typedef struct {
bool configured; /* false until the config page has been submitted once */
BirthData birth;
char lang[8]; /* e.g. "en", "de" - matches engine/i18n/<lang>.lang's own code */
bool notify_enabled;
int notify_hour; /* 0-23, the watch's own local wall-clock time (i.e.
* localtime(), not birth.utc_offset_hours - the
* user's current timezone, synced from their phone,
* not necessarily their birth location's). */
int notify_minute; /* 0-59 */
} WatchConfig;
/* Fills `out` with the persisted config, or a safe all-zero/`configured
* = false` default if nothing has been saved yet (first run). */
void config_load(WatchConfig *out);
void config_save(const WatchConfig *cfg);
#endif
+16
View File
@@ -0,0 +1,16 @@
#ifndef WATCH_I18N_TABLES_H
#define WATCH_I18N_TABLES_H
/* Points i18n_get() (engine/src/i18n.h) at the compiled-in translation
* table for `lang_code`, generated at build time from engine/i18n's
* .lang files by watch/scripts/gen_i18n_tables.py into
* i18n_tables.auto.c - see that script's own doc comment for the format
* and why English is deliberately not one of the compiled tables.
* Passing "en", NULL, or any code with no matching .lang file resets to
* no table at all, so every i18n_get() call falls through to its
* built-in English fallback text - the correct behavior when a user
* switches the watch's language setting back to English at runtime,
* not just on first load. */
void watch_i18n_load(const char *lang_code);
#endif
+50
View File
@@ -0,0 +1,50 @@
#include <pebble.h>
#include "../../../engine/src/i18n.h"
#include "app_message.h"
#include "config.h"
#include "i18n_tables.h"
#include "reading_state.h"
#include "ui_menu_window.h"
#include "ui_text_window.h"
static WatchConfig s_config;
static void show_setup_required(void) {
text_window_push(i18n_get("ui.setup_required_title", "Setup Required"),
i18n_get("ui.setup_required_body",
"Open this app's settings from the Pebble mobile app to add your "
"birth details, then reopen Deck in a Dash."));
}
static void on_config_updated(const WatchConfig *cfg) {
bool was_configured = s_config.configured;
s_config = *cfg;
watch_i18n_load(s_config.lang);
if (!s_config.configured) return;
reading_state_recompute(&s_config.birth);
if (was_configured) {
menu_window_reload();
} else {
menu_window_push();
}
}
int main(void) {
config_load(&s_config);
watch_i18n_load(s_config.lang);
app_message_init(on_config_updated);
if (s_config.configured) {
reading_state_recompute(&s_config.birth);
menu_window_push();
} else {
show_setup_required();
}
app_event_loop();
menu_window_deinit();
}
+27
View File
@@ -0,0 +1,27 @@
#include "reading_state.h"
#include <stdio.h>
static DailyReading s_reading;
static DailyInterpretation s_interp;
void reading_state_recompute(const BirthData *birth) {
time_t now = time(NULL);
/* Plain gmtime() (not the POSIX-only gmtime_r()), used immediately -
* same justification as astro.c's own time_from_unix(). */
struct tm *utc = gmtime(&now);
char seed[16];
snprintf(seed, sizeof seed, "%04d-%02d-%02d", utc->tm_year + 1900, utc->tm_mon + 1, utc->tm_mday);
reading_generate(seed, birth, now, &s_reading);
interpret_daily_reading(&s_reading, &s_interp);
}
const DailyReading *reading_state_get_reading(void) {
return &s_reading;
}
const DailyInterpretation *reading_state_get_interpretation(void) {
return &s_interp;
}
+19
View File
@@ -0,0 +1,19 @@
#ifndef WATCH_READING_STATE_H
#define WATCH_READING_STATE_H
#include "../../../engine/src/reading.h"
#include "../../../interpreter/src/significance.h"
/* Recomputes today's reading (astro + tarot + interpretation) from
* `birth`, using the watch's current clock: today's UTC date as the
* tarot seed (same convention as dist/run-engine.sh - see CLAUDE.md's
* "Build & run") and the current moment for the transit snapshot. Safe
* to call again later (e.g. the day rolled over while the app stayed
* open) - overwrites the previous result in place. */
void reading_state_recompute(const BirthData *birth);
/* Valid only after at least one reading_state_recompute() call. */
const DailyReading *reading_state_get_reading(void);
const DailyInterpretation *reading_state_get_interpretation(void);
#endif
+31
View File
@@ -0,0 +1,31 @@
#include "resource_map.h"
#include <pebble.h>
uint32_t card_resource_id(TarotCard card) {
switch (card) {
case CARD_FOOL: return RESOURCE_ID_IMG_CARD_FOOL;
case CARD_MAGICIAN: return RESOURCE_ID_IMG_CARD_MAGICIAN;
case CARD_HIGH_PRIESTESS: return RESOURCE_ID_IMG_CARD_HIGH_PRIESTESS;
case CARD_EMPRESS: return RESOURCE_ID_IMG_CARD_EMPRESS;
case CARD_EMPEROR: return RESOURCE_ID_IMG_CARD_EMPEROR;
case CARD_HIEROPHANT: return RESOURCE_ID_IMG_CARD_HIEROPHANT;
case CARD_LOVERS: return RESOURCE_ID_IMG_CARD_LOVERS;
case CARD_CHARIOT: return RESOURCE_ID_IMG_CARD_CHARIOT;
case CARD_STRENGTH: return RESOURCE_ID_IMG_CARD_STRENGTH;
case CARD_HERMIT: return RESOURCE_ID_IMG_CARD_HERMIT;
case CARD_WHEEL_OF_FORTUNE: return RESOURCE_ID_IMG_CARD_WHEEL_OF_FORTUNE;
case CARD_JUSTICE: return RESOURCE_ID_IMG_CARD_JUSTICE;
case CARD_HANGED_MAN: return RESOURCE_ID_IMG_CARD_HANGED_MAN;
case CARD_DEATH: return RESOURCE_ID_IMG_CARD_DEATH;
case CARD_TEMPERANCE: return RESOURCE_ID_IMG_CARD_TEMPERANCE;
case CARD_DEVIL: return RESOURCE_ID_IMG_CARD_DEVIL;
case CARD_TOWER: return RESOURCE_ID_IMG_CARD_TOWER;
case CARD_STAR: return RESOURCE_ID_IMG_CARD_STAR;
case CARD_MOON: return RESOURCE_ID_IMG_CARD_MOON;
case CARD_SUN: return RESOURCE_ID_IMG_CARD_SUN;
case CARD_JUDGEMENT: return RESOURCE_ID_IMG_CARD_JUDGEMENT;
case CARD_WORLD: return RESOURCE_ID_IMG_CARD_WORLD;
}
return RESOURCE_ID_IMG_CARD_FOOL; /* unreachable for a valid TarotCard */
}
+18
View File
@@ -0,0 +1,18 @@
#ifndef WATCH_RESOURCE_MAP_H
#define WATCH_RESOURCE_MAP_H
#include <stdint.h>
#include "../../../engine/src/tarot.h"
/* Maps a TarotCard to its compiled-in bitmap resource ID. Each of
* watch/package.json's "resources.media" entries is named
* IMG_CARD_<SLUG UPPERCASE>, matching engine/src/tarot_data.c's own
* k_card_slug table (see watch/scripts/gen_card_images.py, which
* generates the underlying PNGs with the same slugs) - Pebble's build
* turns each into a RESOURCE_ID_IMG_CARD_<SLUG> macro in the
* auto-generated resource_ids.auto.h, which this function is the one
* place in the app that switches over. */
uint32_t card_resource_id(TarotCard card);
#endif
+78
View File
@@ -0,0 +1,78 @@
#include "ui_card_window.h"
#include <pebble.h>
#include "resource_map.h"
/* Matches the fixed size gen_card_images.py resizes every card to. */
#define IMAGE_WIDTH 72
#define IMAGE_HEIGHT 120
typedef struct {
TarotCard card;
char *text; /* title + "\n" + body, combined into one scrollable block */
GBitmap *bitmap;
BitmapLayer *bitmap_layer;
ScrollLayer *scroll_layer;
TextLayer *text_layer;
} CardWindowState;
static void window_load(Window *window) {
CardWindowState *state = window_get_user_data(window);
Layer *window_layer = window_get_root_layer(window);
GRect bounds = layer_get_bounds(window_layer);
state->bitmap = gbitmap_create_with_resource(card_resource_id(state->card));
int image_x = (bounds.size.w - IMAGE_WIDTH) / 2;
state->bitmap_layer = bitmap_layer_create(GRect(image_x, 0, IMAGE_WIDTH, IMAGE_HEIGHT));
bitmap_layer_set_bitmap(state->bitmap_layer, state->bitmap);
bitmap_layer_set_alignment(state->bitmap_layer, GAlignCenter);
layer_add_child(window_layer, bitmap_layer_get_layer(state->bitmap_layer));
GRect scroll_bounds = GRect(0, IMAGE_HEIGHT, bounds.size.w, bounds.size.h - IMAGE_HEIGHT);
state->scroll_layer = scroll_layer_create(scroll_bounds);
scroll_layer_set_click_config_onto_window(state->scroll_layer, window);
state->text_layer = text_layer_create(GRect(0, 0, scroll_bounds.size.w, 2000));
text_layer_set_font(state->text_layer, fonts_get_system_font(FONT_KEY_GOTHIC_18));
text_layer_set_text(state->text_layer, state->text);
text_layer_set_overflow_mode(state->text_layer, GTextOverflowModeWordWrap);
GSize content_size = text_layer_get_content_size(state->text_layer);
content_size.w = scroll_bounds.size.w;
content_size.h += 4;
text_layer_set_size(state->text_layer, content_size);
scroll_layer_set_content_size(state->scroll_layer, content_size);
scroll_layer_add_child(state->scroll_layer, text_layer_get_layer(state->text_layer));
layer_add_child(window_layer, scroll_layer_get_layer(state->scroll_layer));
}
static void window_unload(Window *window) {
CardWindowState *state = window_get_user_data(window);
bitmap_layer_destroy(state->bitmap_layer);
gbitmap_destroy(state->bitmap);
text_layer_destroy(state->text_layer);
scroll_layer_destroy(state->scroll_layer);
free(state->text);
free(state);
window_destroy(window);
}
void card_window_push(TarotCard card, const char *title, const char *body) {
CardWindowState *state = malloc(sizeof(CardWindowState));
memset(state, 0, sizeof *state);
state->card = card;
size_t len = strlen(title) + 1 + strlen(body) + 1;
state->text = malloc(len);
snprintf(state->text, len, "%s\n%s", title, body);
Window *window = window_create();
window_set_user_data(window, state);
window_set_window_handlers(window, (WindowHandlers){
.load = window_load,
.unload = window_unload,
});
window_stack_push(window, true);
}
+12
View File
@@ -0,0 +1,12 @@
#ifndef WATCH_UI_CARD_WINDOW_H
#define WATCH_UI_CARD_WINDOW_H
#include "../../../engine/src/tarot.h"
/* Pushes a window showing `card`'s art (via resource_map.h) pinned at
* the top, with `title` and `body` below it as scrollable text (e.g. the
* Celtic Cross position name + orientation, and the position description
* + card meaning). Copies both strings, same as text_window_push(). */
void card_window_push(TarotCard card, const char *title, const char *body);
#endif
+191
View File
@@ -0,0 +1,191 @@
#include "ui_menu_window.h"
#include <pebble.h>
#include "../../../engine/src/i18n.h"
#include "../../../interpreter/src/guidance.h"
#include "../../../interpreter/src/narrative.h"
#include "reading_state.h"
#include "ui_card_window.h"
#include "ui_text_window.h"
typedef enum {
SECTION_TODAY = 0,
SECTION_TRANSITS,
SECTION_CELTIC_CROSS,
NUM_SECTIONS
} MenuSection;
static Window *s_window;
static MenuLayer *s_menu_layer;
/* Small local day-level name table, same duplication policy as
* interpreter/src/main.c's own day_level_name() - see significance.c's
* comment on why each independently-testable/linkable module keeps its
* own copy rather than sharing one across translation units that aren't
* otherwise coupled. */
static const char *day_level_name(DaySignificance level) {
switch (level) {
case DAY_QUIET: return i18n_get("interp.day_level.quiet", "Quiet");
case DAY_NOTABLE: return i18n_get("interp.day_level.notable", "Notable");
case DAY_SIGNIFICANT: return i18n_get("interp.day_level.significant", "Significant");
case DAY_MAJOR: return i18n_get("interp.day_level.major", "Major");
default: return "";
}
}
static const char *reversed_marker(bool reversed) {
return reversed ? i18n_get("ui.reversed", "(Reversed)") : "";
}
static uint16_t get_num_sections_callback(MenuLayer *menu_layer, void *data) {
return NUM_SECTIONS;
}
static uint16_t get_num_rows_callback(MenuLayer *menu_layer, uint16_t section_index, void *data) {
const DailyInterpretation *interp = reading_state_get_interpretation();
switch (section_index) {
case SECTION_TODAY: return 1;
case SECTION_TRANSITS: return interp->top_item_count > 0 ? interp->top_item_count : 1;
case SECTION_CELTIC_CROSS: return TAROT_SPREAD_SIZE;
default: return 0;
}
}
static int16_t get_header_height_callback(MenuLayer *menu_layer, uint16_t section_index, void *data) {
return MENU_CELL_BASIC_HEADER_HEIGHT;
}
static void draw_header_callback(GContext *ctx, const Layer *cell_layer, uint16_t section_index, void *data) {
const char *title = "";
switch (section_index) {
case SECTION_TODAY: title = i18n_get("interp.heading_day_significance", "Day Significance"); break;
case SECTION_TRANSITS: title = i18n_get("interp.heading_transits", "Significant Transits"); break;
case SECTION_CELTIC_CROSS: title = i18n_get("ui.celtic_cross", "Celtic Cross"); break;
}
menu_cell_basic_header_draw(ctx, cell_layer, title);
}
static void draw_row_callback(GContext *ctx, const Layer *cell_layer, MenuIndex *cell_index, void *data) {
const DailyReading *reading = reading_state_get_reading();
const DailyInterpretation *interp = reading_state_get_interpretation();
char title[48];
char subtitle[64];
switch (cell_index->section) {
case SECTION_TODAY:
snprintf(title, sizeof title, "%s (%d/%d)", day_level_name(interp->day_level),
interp->day_level + 1, DAY_SIGNIFICANCE_COUNT);
menu_cell_basic_draw(ctx, cell_layer, title, NULL, NULL);
break;
case SECTION_TRANSITS:
if (interp->top_item_count == 0) {
menu_cell_basic_draw(ctx, cell_layer,
i18n_get("interp.no_notable_transits", "No notable transits today."),
NULL, NULL);
break;
}
{
const Aspect *a = &interp->top_items[cell_index->row].aspect;
snprintf(title, sizeof title, "%s %s", i18n_get("ui.transiting", "Transiting"),
astro_body_name(a->transiting_planet));
snprintf(subtitle, sizeof subtitle, "%s %s %s", astro_aspect_name(a->type),
i18n_get("ui.natal", "natal"), astro_body_name(a->natal_planet));
menu_cell_basic_draw(ctx, cell_layer, title, subtitle, NULL);
}
break;
case SECTION_CELTIC_CROSS: {
const TarotDraw *draw = &reading->spread.positions[cell_index->row];
snprintf(title, sizeof title, "%s", tarot_position_name((CelticCrossPosition)cell_index->row));
snprintf(subtitle, sizeof subtitle, "%s %s", tarot_card_name(draw->card),
reversed_marker(draw->reversed));
menu_cell_basic_draw(ctx, cell_layer, title, subtitle, NULL);
break;
}
}
}
static void select_callback(MenuLayer *menu_layer, MenuIndex *cell_index, void *data) {
const DailyReading *reading = reading_state_get_reading();
const DailyInterpretation *interp = reading_state_get_interpretation();
switch (cell_index->section) {
case SECTION_TODAY: {
char body[GUIDANCE_TEXT_MAX];
guidance_write(body, sizeof body, "", interp, &reading->spread);
text_window_push(i18n_get("interp.heading_day_significance", "Day Significance"), body);
break;
}
case SECTION_TRANSITS: {
if (interp->top_item_count == 0) break;
const Aspect *a = &interp->top_items[cell_index->row].aspect;
char title[48];
snprintf(title, sizeof title, "%s %s", i18n_get("ui.transiting", "Transiting"),
astro_body_name(a->transiting_planet));
char body[NARRATIVE_TEXT_MAX];
narrative_write(body, sizeof body, "", a, reading->transits.bodies[a->transiting_planet].house);
text_window_push(title, body);
break;
}
case SECTION_CELTIC_CROSS: {
const TarotDraw *draw = &reading->spread.positions[cell_index->row];
char title[64];
snprintf(title, sizeof title, "%s: %s %s",
tarot_position_name((CelticCrossPosition)cell_index->row),
tarot_card_name(draw->card), reversed_marker(draw->reversed));
char body[600];
snprintf(body, sizeof body, "%s\n\n%s",
tarot_position_description((CelticCrossPosition)cell_index->row),
tarot_card_meaning(draw->card, draw->reversed));
card_window_push(draw->card, title, body);
break;
}
}
}
static void window_load(Window *window) {
Layer *window_layer = window_get_root_layer(window);
GRect bounds = layer_get_bounds(window_layer);
s_menu_layer = menu_layer_create(bounds);
menu_layer_set_callbacks(s_menu_layer, NULL, (MenuLayerCallbacks){
.get_num_sections = get_num_sections_callback,
.get_num_rows = get_num_rows_callback,
.get_header_height = get_header_height_callback,
.draw_header = draw_header_callback,
.draw_row = draw_row_callback,
.select_click = select_callback,
});
menu_layer_set_click_config_onto_window(s_menu_layer, window);
layer_add_child(window_layer, menu_layer_get_layer(s_menu_layer));
}
static void window_unload(Window *window) {
menu_layer_destroy(s_menu_layer);
s_menu_layer = NULL;
}
void menu_window_push(void) {
if (!s_window) {
s_window = window_create();
window_set_window_handlers(s_window, (WindowHandlers){
.load = window_load,
.unload = window_unload,
});
}
window_stack_push(s_window, true);
}
void menu_window_reload(void) {
if (s_menu_layer) menu_layer_reload_data(s_menu_layer);
}
void menu_window_deinit(void) {
if (s_window) window_destroy(s_window);
s_window = NULL;
}
+19
View File
@@ -0,0 +1,19 @@
#ifndef WATCH_UI_MENU_WINDOW_H
#define WATCH_UI_MENU_WINDOW_H
/* The app's root screen: a 3-section MenuLayer (Day Significance /
* Significant Transits / Celtic Cross) reading directly from
* reading_state.h's current reading - see reading.h's own doc comment
* ("walks the returned struct directly to lay out its own screens").
* Selecting a row pushes a detail screen (ui_text_window.h for prose,
* ui_card_window.h for a Celtic Cross position). A persistent singleton -
* created once and left at the bottom of the window stack; call
* menu_window_push() once at startup (and again if the day rolls over
* and the underlying reading changes - the MenuLayer re-reads
* reading_state.h on every redraw, so no explicit refresh call is
* needed beyond menu_layer_reload_data()). */
void menu_window_push(void);
void menu_window_reload(void);
void menu_window_deinit(void);
#endif
+74
View File
@@ -0,0 +1,74 @@
#include "ui_text_window.h"
#include <pebble.h>
#define TITLE_HEIGHT 24
typedef struct {
char *title;
char *body;
TextLayer *title_layer;
ScrollLayer *scroll_layer;
TextLayer *body_layer;
} TextWindowState;
static void window_load(Window *window) {
TextWindowState *state = window_get_user_data(window);
Layer *window_layer = window_get_root_layer(window);
GRect bounds = layer_get_bounds(window_layer);
state->title_layer = text_layer_create(GRect(0, 0, bounds.size.w, TITLE_HEIGHT));
text_layer_set_font(state->title_layer, fonts_get_system_font(FONT_KEY_GOTHIC_18_BOLD));
text_layer_set_text(state->title_layer, state->title);
text_layer_set_overflow_mode(state->title_layer, GTextOverflowModeTrailingEllipsis);
layer_add_child(window_layer, text_layer_get_layer(state->title_layer));
GRect scroll_bounds = GRect(0, TITLE_HEIGHT, bounds.size.w, bounds.size.h - TITLE_HEIGHT);
state->scroll_layer = scroll_layer_create(scroll_bounds);
scroll_layer_set_click_config_onto_window(state->scroll_layer, window);
/* Tall placeholder height so text_layer_get_content_size() below can
* measure the real wrapped height - standard Pebble ScrollLayer
* pattern (a TextLayer taller than its content just clips nothing). */
state->body_layer = text_layer_create(GRect(0, 0, scroll_bounds.size.w, 2000));
text_layer_set_font(state->body_layer, fonts_get_system_font(FONT_KEY_GOTHIC_18));
text_layer_set_text(state->body_layer, state->body);
text_layer_set_overflow_mode(state->body_layer, GTextOverflowModeWordWrap);
GSize content_size = text_layer_get_content_size(state->body_layer);
content_size.w = scroll_bounds.size.w;
content_size.h += 4;
text_layer_set_size(state->body_layer, content_size);
scroll_layer_set_content_size(state->scroll_layer, content_size);
scroll_layer_add_child(state->scroll_layer, text_layer_get_layer(state->body_layer));
layer_add_child(window_layer, scroll_layer_get_layer(state->scroll_layer));
}
static void window_unload(Window *window) {
TextWindowState *state = window_get_user_data(window);
text_layer_destroy(state->title_layer);
text_layer_destroy(state->body_layer);
scroll_layer_destroy(state->scroll_layer);
free(state->title);
free(state->body);
free(state);
window_destroy(window);
}
void text_window_push(const char *title, const char *body) {
TextWindowState *state = malloc(sizeof(TextWindowState));
memset(state, 0, sizeof *state);
state->title = malloc(strlen(title) + 1);
strcpy(state->title, title);
state->body = malloc(strlen(body) + 1);
strcpy(state->body, body);
Window *window = window_create();
window_set_user_data(window, state);
window_set_window_handlers(window, (WindowHandlers){
.load = window_load,
.unload = window_unload,
});
window_stack_push(window, true);
}
+16
View File
@@ -0,0 +1,16 @@
#ifndef WATCH_UI_TEXT_WINDOW_H
#define WATCH_UI_TEXT_WINDOW_H
/* Pushes a scrollable full-screen text window: `title` as a bold header,
* `body` below it, word-wrapped and scrollable with the up/down buttons.
* Used for every screen that's just prose (Summary/Guidance, a single
* transit's narrative) - the Celtic Cross card screens use
* ui_card_window.h instead, which also shows the card's image.
*
* Copies both strings internally (into the pushed window's own heap
* state, freed when it's popped), so the caller's buffer doesn't need to
* outlive the call - every caller so far builds `title`/`body` in a
* short-lived local buffer right before pushing. */
void text_window_push(const char *title, const char *body);
#endif