2 Commits

Author SHA1 Message Date
ml 1ebbfebe31 Add watch/phone icons and logo, reorder the interpretation report, and stamp every report with the day it's for
build / build (push) Successful in 30s
2026-07-16 18:54:32 +02:00
ml b94e76931a Replace the watch app's menu UI with a single scrollable report page, add the config page
build / build (push) Successful in 18s
Report screen (ui_report_window.c, new): replaces the MenuLayer +
per-item drill-down windows (ui_menu_window.c/h, ui_card_window.c/h,
removed) with one continuously scrollable page mirroring the
interpreter's own text/HTML report - day significance, each transit's
narrative, the full Celtic Cross spread, and guidance last. Bigger/
bolder fonts throughout. Card images are a fixed 72px wide: a full-
display-width version was tried first but reliably crashes the real
PNG decoder ("PNG memory allocation failed") on actual hardware,
independent of the app's own heap or lazy-loading - 72px is the
hardware-verified ceiling.

Config page (pkjs/index.js): a self-contained `data:` URI settings form
for birth data/language/notification, submitting via AppMessage to the
existing app_message.c receiver. package.json needed
"capabilities": ["configurable"] for the Pebble mobile app to expose a
Settings entry for the sideloaded app at all - app_message.c's own
receiving logic was already correct. Added config_log() (config.c) to
confirm on-device, via APP_LOG, exactly which values a shown reading is
based on - both right after a config submission and at every app
launch.
2026-07-14 19:03:25 +02:00
34 changed files with 1044 additions and 425 deletions
+1
View File
@@ -5,6 +5,7 @@
/watch/build
/watch/src/c/i18n_tables.auto.c
/watch/resources/img
/watch/resources/app_icon.png
# Durable backup of the same real birth data, outside dist/ so a dist/
# wipe doesn't lose it — see the Makefile's `scripts` target. Never
+26 -5
View File
@@ -171,7 +171,7 @@ cards rendered as an image:
### `--format json`
A single JSON object mirroring `DailyReading` exactly — `natal`,
A single JSON object mirroring `DailyReading` exactly — `date`, `natal`,
`transits`, `spread` — using the **slug** accessors
(`astro_body_slug()`, `astro_sign_slug()`, `astro_moon_phase_slug()`,
`astro_aspect_slug()`, `tarot_card_slug()`, `tarot_position_slug()`)
@@ -182,6 +182,7 @@ read.
```json
{
"date": "2026-07-16",
"natal": {
"bodies": [
{"body": "sun", "sign": "taurus", "degree_in_sign": 23.4926, "ecliptic_longitude": 53.4926, "house": 3},
@@ -210,6 +211,13 @@ read.
}
```
- `date` is the UTC calendar date of `--date` (or the current system time
if `--date` was omitted) — the day this reading is *for*, not
necessarily the day it was generated on. `reading_generate()` stores
the full moment in `DailyReading.utc_moment`; only the date part is
serialized here since that's the only thing the rest of this format
(and the interpreter's own reports, which read this field back — see
"The interpreter" below) ever display.
- `bodies` is always `NUM_BODIES` (10) entries, Sun..Pluto in `Body` enum
order; `spread.positions` is always `TAROT_SPREAD_SIZE` (10) entries in
`CelticCrossPosition` enum order (Present, Challenge, Crown,
@@ -279,11 +287,16 @@ dist/run-interpreter.sh html de # override format/language for this run
### `--format text`
A plain-text report, printed in a fixed order —
1. the day's overall significance level (`Quiet`/`Notable`/
1. the day this reading is for (`Reading for: 2026-07-16`), from the
input JSON's top-level `date` field (see `--format json` above) —
not necessarily the day the report is actually being read on, if
`reading.json` was generated earlier and piped into
`interpreter-cli` later;
2. the day's overall significance level (`Quiet`/`Notable`/
`Significant`/`Major`, plus its rank out of the 4 defined levels,
e.g. `Notable (2/4)`), with a "worth a deeper Celtic Cross look"
line on `Major` days only;
2. up to 5 of today's aspects ranked by score, each naming the sign
3. up to 5 of today's aspects ranked by score, each naming the sign
and natal house the transiting planet currently occupies and the
sign and house of the natal planet it's aspecting, followed by a
short narrative sentence about what that transiting planet
@@ -291,10 +304,10 @@ A plain-text report, printed in a fixed order —
governs* (see `CLAUDE.md`'s `narrative.c` bullet) — omitted only if
the source material has nothing to say for that planet/aspect
combination;
3. the full 10-position Celtic Cross spread, in Waite's own drawing
4. the full 10-position Celtic Cross spread, in Waite's own drawing
order, each with its card (and orientation), the position's own
description, and that card's actual meaning;
4. finally, a guidance paragraph tying the day's top transit to the
5. finally, a guidance paragraph tying the day's top transit to the
spread's Attitude/Outcome cards, with its opening sentence tailored
to `day_level` (stronger wording on `Major`, softer on
`Quiet`/`Notable`, falling back to a transit-free reading of the
@@ -305,6 +318,8 @@ A plain-text report, printed in a fixed order —
seen the full spread it references.
```
Reading for: 2026-07-16
Day significance: Major (4/4)
(a major transit today - worth a deeper Celtic Cross look)
@@ -354,6 +369,8 @@ The same report with `--lang de` (same input as the Major example
above):
```
Lesung für: 2026-07-16
Bedeutung des Tages: Einschneidend (4/4)
(ein einschneidender Transit heute - ein genauerer Blick auf das Keltische Kreuz lohnt sich)
@@ -415,6 +432,7 @@ has the slugs to work with directly.
```json
{
"date": "2026-07-16",
"day_significance": {
"level": "major",
"rank": 4,
@@ -448,6 +466,9 @@ has the slugs to work with directly.
}
```
- `date` is copied straight through from the input JSON's own top-level
`date` field (see `--format json` above) - always language-independent
regardless of `--lang`, same as every other slug in this document.
- `significant_transits` has `interp.top_item_count` entries (0-5, same
ranking as `--format text`); `celtic_cross` always has exactly 10, in
`CelticCrossPosition` enum order (same order as `deck-engine`'s own
+1
View File
@@ -162,6 +162,7 @@ card.world.upright=Gesicherter Erfolg, Belohnung, Reise, Weg, Auswanderung, Fluc
card.world.reversed=Trägheit, Erstarrung, Stillstand, Beständigkeit.
# --- UI-Bezeichnungen des Interpreters (interpreter/src/main.c) ---
interp.reading_date=Lesung für:
interp.day_significance=Bedeutung des Tages:
interp.day_level.quiet=Ruhig
interp.day_level.notable=Bemerkenswert
+1
View File
@@ -171,6 +171,7 @@ card.world.upright=Assured success, recompense, voyage, route, emigration, fligh
card.world.reversed=Inertia, fixity, stagnation, permanence.
# --- Interpreter UI labels (interpreter/src/main.c) ---
interp.reading_date=Reading for:
interp.day_significance=Day significance:
interp.day_level.quiet=Quiet
interp.day_level.notable=Notable
+4
View File
@@ -5,6 +5,7 @@
void reading_generate(const char *tarot_seed, const BirthData *birth,
time_t utc_moment, DailyReading *out) {
out->utc_moment = utc_moment;
astro_compute_natal_chart(birth, &out->natal);
astro_compute_daily_transits(utc_moment, &out->natal, &out->transits);
tarot_draw_celtic_cross(tarot_seed, &out->spread);
@@ -160,6 +161,9 @@ static void print_body_position_json(FILE *out, const char *indent, Body body,
void reading_print_json(const DailyReading *r, FILE *out) {
fprintf(out, "{\n");
struct tm *utc = gmtime(&r->utc_moment);
fprintf(out, " \"date\": \"%04d-%02d-%02d\",\n", utc->tm_year + 1900, utc->tm_mon + 1, utc->tm_mday);
fprintf(out, " \"natal\": {\n \"bodies\": [\n");
for (int b = 0; b < NUM_BODIES; b++) {
print_body_position_json(out, " ", (Body)b, &r->natal.bodies[b]);
+6
View File
@@ -15,6 +15,12 @@
#include "tarot.h"
typedef struct {
/* The moment passed to reading_generate() - the day this reading is
* for, not when it happened to be generated. Set by reading_generate()
* itself, so every caller (CLI, watch, reading_load_json() on the
* interpreter side) gets it for free without threading it through
* separately. */
time_t utc_moment;
NatalChart natal;
DailyTransits transits;
CelticCrossSpread spread;
+1
View File
@@ -120,6 +120,7 @@ static void test_reading_print_json_shape(void) {
* a stray astro_*_name()/tarot_*_name() call leaking translated text
* into what must stay language-independent regardless of --lang). */
assert(buf[0] == '{');
assert(strstr(buf, "\"date\": \"2025-07-02\"") != NULL); /* UTC date of the fixed 1751500000 seed */
assert(strstr(buf, "\"natal\"") != NULL);
assert(strstr(buf, "\"transits\"") != NULL);
assert(strstr(buf, "\"spread\"") != NULL);
+124 -60
View File
@@ -126,6 +126,15 @@ static const char *sign_display_name(ZodiacSign sign) {
return ui(key, fallback[sign]);
}
/* "YYYY-MM-DD" for reading->utc_moment - the day this reading is for
* (see reading_load_json()/parse_date() in reading_io.c), not today's
* real date if the two ever differ (e.g. reading.json was generated
* earlier and interpreted later). */
static void format_date(time_t utc_moment, char *out, size_t out_size) {
struct tm *utc = gmtime(&utc_moment);
snprintf(out, out_size, "%04d-%02d-%02d", utc->tm_year + 1900, utc->tm_mon + 1, utc->tm_mday);
}
static const char *day_level_name(DaySignificance level) {
switch (level) {
case DAY_QUIET: return ui("interp.day_level.quiet", "Quiet");
@@ -179,6 +188,32 @@ static char *capture_guidance(const DailyInterpretation *interp, const CelticCro
return buf;
}
/* Prints one significant-transit item's full detail (title line with
* sign/house/orb/score, plus its narrative sentence) - shared between
* the "Significant Transits" list and the day's top item, which is
* repeated in full just above the guidance paragraph (see
* interp_print_text) so the reader has that same detail in view right
* next to the guidance text it informs. `index` is 1-based and prefixes
* the line with "N. "; pass 0 to omit it, for the repeated top item,
* which isn't part of the numbered list. */
static void print_transit_item_text(FILE *out, const DailyReading *reading,
const SignificantItem *item, int index) {
const Aspect *a = &item->aspect;
if (index > 0) fprintf(out, " %d. ", index);
fprintf(out, "%s %s", ui("ui.transiting", "Transiting"), body_display_name(a->transiting_planet));
print_body_in_sign_text(out, &reading->transits.bodies[a->transiting_planet]);
fprintf(out, " %s %s %s", aspect_display_name(a->type), ui("ui.natal", "natal"),
body_display_name(a->natal_planet));
print_body_in_sign_text(out, &reading->natal.bodies[a->natal_planet]);
fprintf(out, " (%s %.1f\xc2\xb0, %s %.2f)\n", ui("ui.orb", "orb"), a->orb,
ui("interp.score", "score"), item->score);
char narrative_buf[NARRATIVE_TEXT_MAX];
narrative_write(narrative_buf, sizeof narrative_buf, " ", a,
reading->transits.bodies[a->transiting_planet].house);
fputs(narrative_buf, out);
}
static void print_top_transits_header(FILE *out, int count) {
if (count == 1) {
fprintf(out, "%s\n", ui("interp.top_transits.one", "Top significant transit:"));
@@ -203,6 +238,10 @@ static void print_celtic_cross_text(FILE *out, const CelticCrossSpread *spread)
}
static void interp_print_text(FILE *out, const DailyReading *reading, const DailyInterpretation *interp) {
char date_buf[32];
format_date(reading->utc_moment, date_buf, sizeof date_buf);
fprintf(out, "%s %s\n\n", ui("interp.reading_date", "Reading for:"), date_buf);
fprintf(out, "%s %s (%d/%d)\n", ui("interp.day_significance", "Day significance:"),
day_level_name(interp->day_level), interp->day_level + 1, DAY_SIGNIFICANCE_COUNT);
if (interpretation_deserves_framing(interp)) {
@@ -211,41 +250,68 @@ static void interp_print_text(FILE *out, const DailyReading *reading, const Dail
}
fprintf(out, "\n");
fprintf(out, "%s\n", ui("interp.heading_guidance", "Guidance"));
if (interp->top_item_count > 0) {
print_transit_item_text(out, reading, &interp->top_items[0], 0);
}
char guidance_buf[GUIDANCE_TEXT_MAX];
guidance_write(guidance_buf, sizeof guidance_buf, "", interp, &reading->spread);
fputs(guidance_buf, out);
fprintf(out, "\n");
if (interp->top_item_count == 0) {
fprintf(out, "%s\n", ui("interp.no_notable_transits", "No notable transits today."));
} else {
print_top_transits_header(out, interp->top_item_count);
for (int i = 0; i < interp->top_item_count; i++) {
const SignificantItem *item = &interp->top_items[i];
const Aspect *a = &item->aspect;
fprintf(out, " %d. %s %s", i + 1, ui("ui.transiting", "Transiting"),
body_display_name(a->transiting_planet));
print_body_in_sign_text(out, &reading->transits.bodies[a->transiting_planet]);
fprintf(out, " %s %s %s", aspect_display_name(a->type), ui("ui.natal", "natal"),
body_display_name(a->natal_planet));
print_body_in_sign_text(out, &reading->natal.bodies[a->natal_planet]);
fprintf(out, " (%s %.1f\xc2\xb0, %s %.2f)\n", ui("ui.orb", "orb"), a->orb,
ui("interp.score", "score"), item->score);
char narrative_buf[NARRATIVE_TEXT_MAX];
narrative_write(narrative_buf, sizeof narrative_buf, " ", a,
reading->transits.bodies[a->transiting_planet].house);
fputs(narrative_buf, out);
print_transit_item_text(out, reading, &interp->top_items[i], i + 1);
}
}
fprintf(out, "\n");
print_celtic_cross_text(out, &reading->spread);
fprintf(out, "\n");
char guidance_buf[GUIDANCE_TEXT_MAX];
guidance_write(guidance_buf, sizeof guidance_buf, "", interp, &reading->spread);
fputs(guidance_buf, out);
}
/* ===== --format html ===== */
/* HTML counterpart of print_transit_item_text() - same shared-between-
* the-repeated-top-item-and-the-full-list purpose, one table row (title
* + orb/score) plus a narrative row if non-empty. `index` is 1-based;
* pass 0 to omit the "N." cell, for the repeated top item. */
static void print_transit_item_html(FILE *out, const DailyReading *reading,
const SignificantItem *item, int index) {
const Aspect *a = &item->aspect;
char index_buf[16] = "";
if (index > 0) snprintf(index_buf, sizeof index_buf, "%d.", index);
char *text = capture_narrative(a, reading->transits.bodies[a->transiting_planet].house);
fprintf(out, "<tr><td>%s</td><td>%s %s %s %s %s</td>"
"<td>%s %.1f&deg;, %s %.2f</td></tr>\n",
index_buf, ui("ui.transiting", "Transiting"), body_display_name(a->transiting_planet),
aspect_display_name(a->type), ui("ui.natal", "natal"), body_display_name(a->natal_planet),
ui("ui.orb", "orb"), a->orb, ui("interp.score", "score"), item->score);
if (text[0] != '\0') fprintf(out, "<tr><td></td><td colspan=\"2\">%s</td></tr>\n", text);
free(text);
}
/* One Celtic Cross card as a ".card" div - shared between the
* Attitude/Outcome preview (right after guidance) and the full spread
* further down, so both render identically. */
static void print_card_html(FILE *out, CelticCrossPosition position, const TarotDraw *draw) {
fprintf(out,
"<div class=\"card\">\n"
" <div class=\"position\">%s</div>\n"
" <img class=\"%s\" src=\"img/%s\" alt=\"%s\">\n"
" <div class=\"name\">%s%s</div>\n"
" <div class=\"desc\">%s</div>\n"
" <div class=\"meaning\">%s</div>\n"
"</div>\n",
tarot_position_name(position), draw->reversed ? "reversed" : "",
tarot_card_image_file(draw->card), tarot_card_name(draw->card), tarot_card_name(draw->card),
reversed_marker(draw->reversed), tarot_position_description(position),
tarot_card_meaning(draw->card, draw->reversed));
}
static void interp_print_html(FILE *out, const DailyReading *reading, const DailyInterpretation *interp) {
fprintf(out,
"<!doctype html>\n<html><head><meta charset=\"utf-8\">\n"
@@ -267,6 +333,11 @@ static void interp_print_html(FILE *out, const DailyReading *reading, const Dail
fprintf(out, "<h1>%s</h1>\n", ui("interp.heading", "Daily Interpretation"));
char date_buf[32];
format_date(reading->utc_moment, date_buf, sizeof date_buf);
fprintf(out, "<p class=\"reading-date\">%s %s</p>\n",
ui("interp.reading_date", "Reading for:"), date_buf);
fprintf(out, "<h2>%s</h2>\n<p>%s %s (%d/%d)",
ui("interp.heading_day_significance", "Day Significance"),
ui("interp.day_significance", "Day significance:"), day_level_name(interp->day_level),
@@ -277,48 +348,20 @@ static void interp_print_html(FILE *out, const DailyReading *reading, const Dail
}
fprintf(out, "</p>\n");
fprintf(out, "<h2>%s</h2>\n", ui("interp.heading_transits", "Significant Transits"));
if (interp->top_item_count == 0) {
fprintf(out, "<p>%s</p>\n", ui("interp.no_notable_transits", "No notable transits today."));
} else {
fprintf(out, "<table>\n");
for (int i = 0; i < interp->top_item_count; i++) {
const SignificantItem *item = &interp->top_items[i];
const Aspect *a = &item->aspect;
char *text = capture_narrative(a, reading->transits.bodies[a->transiting_planet].house);
char *guidance = capture_guidance(interp, &reading->spread);
fprintf(out, "<h2>%s</h2>\n", ui("interp.heading_guidance", "Guidance"));
fprintf(out, "<tr><td>%d.</td><td>%s %s %s %s %s</td>"
"<td>%s %.1f&deg;, %s %.2f</td></tr>\n",
i + 1, ui("ui.transiting", "Transiting"), body_display_name(a->transiting_planet),
aspect_display_name(a->type), ui("ui.natal", "natal"),
body_display_name(a->natal_planet), ui("ui.orb", "orb"), a->orb,
ui("interp.score", "score"), item->score);
if (text[0] != '\0') fprintf(out, "<tr><td></td><td colspan=\"2\">%s</td></tr>\n", text);
free(text);
}
fprintf(out, "</table>\n");
}
fprintf(out, "<h2>%s</h2>\n<div class=\"spread\">\n", ui("ui.celtic_cross", "Celtic Cross"));
for (int i = 0; i < TAROT_SPREAD_SIZE; i++) {
const TarotDraw *draw = &reading->spread.positions[i];
fprintf(out,
"<div class=\"card\">\n"
" <div class=\"position\">%s</div>\n"
" <img class=\"%s\" src=\"img/%s\" alt=\"%s\">\n"
" <div class=\"name\">%s%s</div>\n"
" <div class=\"desc\">%s</div>\n"
" <div class=\"meaning\">%s</div>\n"
"</div>\n",
tarot_position_name((CelticCrossPosition)i), draw->reversed ? "reversed" : "",
tarot_card_image_file(draw->card), tarot_card_name(draw->card), tarot_card_name(draw->card),
reversed_marker(draw->reversed), tarot_position_description((CelticCrossPosition)i),
tarot_card_meaning(draw->card, draw->reversed));
}
fprintf(out, "<div class=\"spread attitude-outcome\">\n");
print_card_html(out, POSITION_ATTITUDE, &reading->spread.positions[POSITION_ATTITUDE]);
print_card_html(out, POSITION_OUTCOME, &reading->spread.positions[POSITION_OUTCOME]);
fprintf(out, "</div>\n");
char *guidance = capture_guidance(interp, &reading->spread);
fprintf(out, "<h2>%s</h2>\n<div class=\"guidance\">\n", ui("interp.heading_guidance", "Guidance"));
fprintf(out, "<div class=\"guidance\">\n");
if (interp->top_item_count > 0) {
fprintf(out, "<table>\n");
print_transit_item_html(out, reading, &interp->top_items[0], 0);
fprintf(out, "</table>\n");
}
const char *start = guidance;
for (const char *p = guidance; ; p++) {
if (*p == '\n' || *p == '\0') {
@@ -330,6 +373,23 @@ static void interp_print_html(FILE *out, const DailyReading *reading, const Dail
free(guidance);
fprintf(out, "</div>\n");
fprintf(out, "<h2>%s</h2>\n", ui("interp.heading_transits", "Significant Transits"));
if (interp->top_item_count == 0) {
fprintf(out, "<p>%s</p>\n", ui("interp.no_notable_transits", "No notable transits today."));
} else {
fprintf(out, "<table>\n");
for (int i = 0; i < interp->top_item_count; i++) {
print_transit_item_html(out, reading, &interp->top_items[i], i + 1);
}
fprintf(out, "</table>\n");
}
fprintf(out, "<h2>%s</h2>\n<div class=\"spread\">\n", ui("ui.celtic_cross", "Celtic Cross"));
for (int i = 0; i < TAROT_SPREAD_SIZE; i++) {
print_card_html(out, (CelticCrossPosition)i, &reading->spread.positions[i]);
}
fprintf(out, "</div>\n");
fprintf(out, "</body></html>\n");
}
@@ -356,6 +416,10 @@ static void json_string(FILE *out, const char *s) {
static void interp_print_json(FILE *out, const DailyReading *reading, const DailyInterpretation *interp) {
fprintf(out, "{\n");
char date_buf[32];
format_date(reading->utc_moment, date_buf, sizeof date_buf);
fprintf(out, " \"date\": \"%s\",\n", date_buf);
fprintf(out, " \"day_significance\": {\n");
fprintf(out, " \"level\": \"%s\",\n", k_day_level_slug[interp->day_level]);
fprintf(out, " \"rank\": %d,\n", interp->day_level + 1);
+24
View File
@@ -1,6 +1,9 @@
#define _DEFAULT_SOURCE /* for timegm, parsing "date" back into utc_moment */
#include "reading_io.h"
#include "json.h"
#include <stdio.h>
#include <string.h>
/* Mirrors astro.c's own k_body_slug/k_aspect_slug (its i18n lookup-key
@@ -133,6 +136,25 @@ static void parse_spread(const JsonValue *positions, CelticCrossSpread *out) {
}
}
/* Parses the top-level "date" field ("YYYY-MM-DD", see reading_print_json
* in engine/src/reading.c) back into out->utc_moment, as midnight UTC of
* that date - reading_print_json only ever wrote the calendar date, not a
* time of day, so that's the only value that round-trips through
* gmtime() to the same date. Lenient like parse_bodies()/parse_spread():
* a missing or malformed "date" just leaves out->utc_moment at 0 (from
* reading_load_json's own memset), not a load failure - nothing here
* depends on it for scoring, only for display. */
static void parse_date(const JsonValue *date, DailyReading *out) {
const char *date_str = date ? json_as_string(date) : NULL;
if (!date_str) return;
struct tm tm_date = {0};
if (sscanf(date_str, "%d-%d-%d", &tm_date.tm_year, &tm_date.tm_mon, &tm_date.tm_mday) != 3) return;
tm_date.tm_year -= 1900;
tm_date.tm_mon -= 1;
out->utc_moment = timegm(&tm_date);
}
bool reading_load_json(const char *text, size_t length, DailyReading *out) {
memset(out, 0, sizeof(*out));
@@ -174,6 +196,8 @@ bool reading_load_json(const char *text, size_t length, DailyReading *out) {
}
out->transits.aspect_count = filled;
parse_date(json_object_get(root, "date"), out);
const JsonValue *natal = json_object_get(root, "natal");
parse_bodies(natal ? json_object_get(natal, "bodies") : NULL, out->natal.bodies);
parse_bodies(json_object_get(transits, "bodies"), out->transits.bodies);
+5 -3
View File
@@ -14,10 +14,12 @@
* around each significant event, and for guidance_print()'s tarot
* framing: out->transits.aspects[]/aspect_count (used for scoring - see
* significance.c), out->natal.bodies[]/out->transits.bodies[] (sign +
* house per body, used only for display), and out->spread.positions[]
* house per body, used only for display), out->spread.positions[]
* (card + reversed per Celtic Cross position, used only by
* guidance.c). out->natal.ascendant_longitude/houses[] are left zeroed -
* nothing reads them yet.
* guidance.c), and out->utc_moment (the top-level "date" field, parsed
* back to midnight UTC of that date - used only for display, e.g. main.c
* printing which day a reading is for). out->natal.ascendant_longitude/
* houses[] are left zeroed - nothing reads them yet.
*
* Returns false on malformed JSON, or if "transits"."aspects" isn't
* present as an array (an empty array is fine - that's a real "no
+28
View File
@@ -1,6 +1,7 @@
#include <assert.h>
#include <stdio.h>
#include <string.h>
#include <time.h>
#include "../src/json.h"
#include "../src/reading_io.h"
@@ -48,6 +49,7 @@ static void test_json_parse_rejects_malformed(void) {
* absent entirely, to prove partial spread data doesn't fail the load. */
static const char *k_fixture =
"{"
" \"date\": \"2026-07-16\","
" \"natal\": {\"bodies\": [{\"body\": \"sun\", \"sign\": \"taurus\", \"house\": 3}], \"houses\": []},"
" \"transits\": {"
" \"bodies\": [{\"body\": \"sun\", \"sign\": \"cancer\", \"house\": 5}],"
@@ -80,6 +82,30 @@ static void test_reading_load_json_extracts_aspects(void) {
printf("PASS test_reading_load_json_extracts_aspects\n");
}
static void test_reading_load_json_extracts_date(void) {
DailyReading reading;
bool ok = reading_load_json(k_fixture, strlen(k_fixture), &reading);
assert(ok);
struct tm *utc = gmtime(&reading.utc_moment);
assert(utc->tm_year + 1900 == 2026);
assert(utc->tm_mon + 1 == 7);
assert(utc->tm_mday == 16);
printf("PASS test_reading_load_json_extracts_date\n");
}
static void test_reading_load_json_missing_date_defaults_to_zero(void) {
const char *text = "{\"transits\": {\"aspects\": []}}";
DailyReading reading;
bool ok = reading_load_json(text, strlen(text), &reading);
assert(ok);
assert(reading.utc_moment == 0);
printf("PASS test_reading_load_json_missing_date_defaults_to_zero\n");
}
static void test_reading_load_json_extracts_body_sign_and_house(void) {
DailyReading reading;
bool ok = reading_load_json(k_fixture, strlen(k_fixture), &reading);
@@ -178,6 +204,8 @@ int main(void) {
test_json_parse_basic_shapes();
test_json_parse_rejects_malformed();
test_reading_load_json_extracts_aspects();
test_reading_load_json_extracts_date();
test_reading_load_json_missing_date_defaults_to_zero();
test_reading_load_json_extracts_body_sign_and_house();
test_reading_load_json_extracts_spread_positions();
test_reading_load_json_missing_spread_is_not_fatal();
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.
+122 -23
View File
@@ -2,7 +2,9 @@
"name": "deck_in_a_dash_watch",
"author": "Matthias Ladkau",
"version": "1.0.0",
"keywords": ["pebble-app"],
"keywords": [
"pebble-app"
],
"private": true,
"dependencies": {},
"pebble": {
@@ -10,6 +12,9 @@
"uuid": "7f47ed93-f44e-4c1a-9998-7edcfa61218e",
"sdkVersion": "3",
"enableMultiJS": true,
"capabilities": [
"configurable"
],
"targetPlatforms": [
"basalt",
"chalk",
@@ -37,28 +42,122 @@
],
"resources": {
"media": [
{ "type": "bitmap", "name": "IMG_CARD_FOOL", "file": "img/fool.png" },
{ "type": "bitmap", "name": "IMG_CARD_MAGICIAN", "file": "img/magician.png" },
{ "type": "bitmap", "name": "IMG_CARD_HIGH_PRIESTESS", "file": "img/high_priestess.png" },
{ "type": "bitmap", "name": "IMG_CARD_EMPRESS", "file": "img/empress.png" },
{ "type": "bitmap", "name": "IMG_CARD_EMPEROR", "file": "img/emperor.png" },
{ "type": "bitmap", "name": "IMG_CARD_HIEROPHANT", "file": "img/hierophant.png" },
{ "type": "bitmap", "name": "IMG_CARD_LOVERS", "file": "img/lovers.png" },
{ "type": "bitmap", "name": "IMG_CARD_CHARIOT", "file": "img/chariot.png" },
{ "type": "bitmap", "name": "IMG_CARD_STRENGTH", "file": "img/strength.png" },
{ "type": "bitmap", "name": "IMG_CARD_HERMIT", "file": "img/hermit.png" },
{ "type": "bitmap", "name": "IMG_CARD_WHEEL_OF_FORTUNE", "file": "img/wheel_of_fortune.png" },
{ "type": "bitmap", "name": "IMG_CARD_JUSTICE", "file": "img/justice.png" },
{ "type": "bitmap", "name": "IMG_CARD_HANGED_MAN", "file": "img/hanged_man.png" },
{ "type": "bitmap", "name": "IMG_CARD_DEATH", "file": "img/death.png" },
{ "type": "bitmap", "name": "IMG_CARD_TEMPERANCE", "file": "img/temperance.png" },
{ "type": "bitmap", "name": "IMG_CARD_DEVIL", "file": "img/devil.png" },
{ "type": "bitmap", "name": "IMG_CARD_TOWER", "file": "img/tower.png" },
{ "type": "bitmap", "name": "IMG_CARD_STAR", "file": "img/star.png" },
{ "type": "bitmap", "name": "IMG_CARD_MOON", "file": "img/moon.png" },
{ "type": "bitmap", "name": "IMG_CARD_SUN", "file": "img/sun.png" },
{ "type": "bitmap", "name": "IMG_CARD_JUDGEMENT", "file": "img/judgement.png" },
{ "type": "bitmap", "name": "IMG_CARD_WORLD", "file": "img/world.png" }
{
"type": "bitmap",
"name": "IMAGE_APP_ICON",
"file": "app_icon.png",
"menuIcon": true
},
{
"type": "bitmap",
"name": "IMG_CARD_FOOL",
"file": "img/fool.png"
},
{
"type": "bitmap",
"name": "IMG_CARD_MAGICIAN",
"file": "img/magician.png"
},
{
"type": "bitmap",
"name": "IMG_CARD_HIGH_PRIESTESS",
"file": "img/high_priestess.png"
},
{
"type": "bitmap",
"name": "IMG_CARD_EMPRESS",
"file": "img/empress.png"
},
{
"type": "bitmap",
"name": "IMG_CARD_EMPEROR",
"file": "img/emperor.png"
},
{
"type": "bitmap",
"name": "IMG_CARD_HIEROPHANT",
"file": "img/hierophant.png"
},
{
"type": "bitmap",
"name": "IMG_CARD_LOVERS",
"file": "img/lovers.png"
},
{
"type": "bitmap",
"name": "IMG_CARD_CHARIOT",
"file": "img/chariot.png"
},
{
"type": "bitmap",
"name": "IMG_CARD_STRENGTH",
"file": "img/strength.png"
},
{
"type": "bitmap",
"name": "IMG_CARD_HERMIT",
"file": "img/hermit.png"
},
{
"type": "bitmap",
"name": "IMG_CARD_WHEEL_OF_FORTUNE",
"file": "img/wheel_of_fortune.png"
},
{
"type": "bitmap",
"name": "IMG_CARD_JUSTICE",
"file": "img/justice.png"
},
{
"type": "bitmap",
"name": "IMG_CARD_HANGED_MAN",
"file": "img/hanged_man.png"
},
{
"type": "bitmap",
"name": "IMG_CARD_DEATH",
"file": "img/death.png"
},
{
"type": "bitmap",
"name": "IMG_CARD_TEMPERANCE",
"file": "img/temperance.png"
},
{
"type": "bitmap",
"name": "IMG_CARD_DEVIL",
"file": "img/devil.png"
},
{
"type": "bitmap",
"name": "IMG_CARD_TOWER",
"file": "img/tower.png"
},
{
"type": "bitmap",
"name": "IMG_CARD_STAR",
"file": "img/star.png"
},
{
"type": "bitmap",
"name": "IMG_CARD_MOON",
"file": "img/moon.png"
},
{
"type": "bitmap",
"name": "IMG_CARD_SUN",
"file": "img/sun.png"
},
{
"type": "bitmap",
"name": "IMG_CARD_JUDGEMENT",
"file": "img/judgement.png"
},
{
"type": "bitmap",
"name": "IMG_CARD_WORLD",
"file": "img/world.png"
}
]
}
}
+49
View File
@@ -0,0 +1,49 @@
#!/usr/bin/env python3
"""Downsizes res/app_icon_50x50_64_color.png into watch/resources/app_icon.png.
Pebble's own build tooling (process_sdk_resources.py) hard-fails any
"menuIcon": true resource larger than 25x25px - the fixed size Pebble OS
renders app icons at in the watch's own app launcher list (and, synced
from the watch, in the mobile app's installed-apps list too). The source
art in res/ is 50x50 (square, so it downsamples to exactly 25x25 with no
letterboxing) - the color variant is used, per the same "prefer color"
choice as the rest of this app's card art, over the companion
*_gray.png fallback also present in res/.
Generated output, like i18n_tables.auto.c and resources/img/*.png - not
committed (see .gitignore), regenerate by running this script directly
or via `pebble build` (wired into wscript alongside gen_card_images.py).
Requires Pillow (`pip install Pillow`), not part of this repo's own
desktop build/test toolchain.
"""
import os
import sys
from PIL import Image
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
SRC_PATH = os.path.normpath(
os.path.join(SCRIPT_DIR, "..", "..", "res", "app_icon_50x50_64_color.png")
)
OUT_PATH = os.path.normpath(os.path.join(SCRIPT_DIR, "..", "resources", "app_icon.png"))
MAX_ICON_SIZE = 25 # Pebble's own hard cap for "menuIcon": true resources.
def main():
if os.path.exists(OUT_PATH) and os.path.getmtime(OUT_PATH) >= os.path.getmtime(SRC_PATH):
print(f"{OUT_PATH} already up to date", file=sys.stderr)
return
os.makedirs(os.path.dirname(OUT_PATH), exist_ok=True)
with Image.open(SRC_PATH) as img:
ratio = MAX_ICON_SIZE / max(img.width, img.height)
size = (round(img.width * ratio), round(img.height * ratio))
resized = img.convert("RGBA").resize(size, Image.LANCZOS)
resized.save(OUT_PATH, "PNG", optimize=True)
print(f"wrote {OUT_PATH} ({size[0]}x{size[1]})", file=sys.stderr)
if __name__ == "__main__":
main()
+13 -14
View File
@@ -3,21 +3,20 @@
The 22 Major Arcana JPEGs in res/img/ are ~1MB/~1086x1810px each -
suitable for the desktop HTML reading (an <img> tag pointed at the file
as-is) but far too large for a Pebble app's own resource budget (256KB
total on basalt-class platforms, shared with every other resource this
app ships). This script resizes each to CARD_WIDTH px wide (preserving
aspect ratio) and writes a PNG per card, named after tarot_data.c's own
k_card_slug table so watch/package.json's "resources.media" entries
(IMG_CARD_<SLUG UPPERCASE>, file img/<slug>.png) line up without a
separate mapping table anywhere.
as-is) but far too large for a Pebble app's own resource budget. This
script resizes each to CARD_WIDTH px wide (preserving aspect ratio) and
writes a PNG per card, named after tarot_data.c's own k_card_slug table
so watch/package.json's "resources.media" entries (IMG_CARD_<SLUG
UPPERCASE>, file img/<slug>.png) line up without a separate mapping
table anywhere.
CARD_WIDTH=72 (-> 72x120) was picked empirically: a real `pebble build`
of two sample cards at this size measured ~5KB/card on color platforms
(basalt/chalk/emery/gabbro) and ~2.9KB/card on the black-and-white
diorite/flint - so all 22 cards together cost roughly 111KB/64KB
respectively, comfortably inside the 256KB resource budget with room
left for other future resources (fonts, icons), while still rendering
at close to a full basalt-class screen's height (120px of 168px).
CARD_WIDTH=72 (-> 72x120) is a hardware-verified ceiling, not a design
choice: full-display-width images (144-260px depending on platform) were
tried first and reliably crash real hardware with "PNG memory allocation
failed" - a runtime PNG-decoder memory constraint independent of the
app's own heap budget (lazy-loading only one image at a time didn't help
either). 72px is the size the original per-card screens used
successfully all session before that experiment.
Generated output, like i18n_tables.auto.c - not committed (see
.gitignore), regenerate by running this script directly or via the root
+1
View File
@@ -60,6 +60,7 @@ static void inbox_received_handler(DictionaryIterator *iter, void *context) {
* of the settings page) must not un-configure an already-set-up watch. */
if (got_birth) cfg.configured = true;
config_log(&cfg); /* confirms what was actually received/parsed */
config_save(&cfg);
if (s_callback) s_callback(&cfg);
}
+16
View File
@@ -68,3 +68,19 @@ void config_save(const WatchConfig *cfg) {
persist_write_int(PKEY_NOTIFY_HOUR, cfg->notify_hour);
persist_write_int(PKEY_NOTIFY_MINUTE, cfg->notify_minute);
}
/* Cast to int/long rather than %f - Pebble's APP_LOG doesn't support
* floating-point format specifiers. */
void config_log(const WatchConfig *cfg) {
if (!cfg->configured) {
APP_LOG(APP_LOG_LEVEL_INFO, "config: not configured yet");
return;
}
APP_LOG(APP_LOG_LEVEL_INFO, "config: birth %04d-%02d-%02d %02d:%02d UTC offset %dmin",
cfg->birth.year, cfg->birth.month, cfg->birth.day, cfg->birth.hour, cfg->birth.minute,
(int)(cfg->birth.utc_offset_hours * 60));
APP_LOG(APP_LOG_LEVEL_INFO, "config: lat=%ld/1e6 lon=%ld/1e6 lang=%s",
(long)(cfg->birth.latitude * 1000000), (long)(cfg->birth.longitude * 1000000), cfg->lang);
APP_LOG(APP_LOG_LEVEL_INFO, "config: notify_enabled=%d at %02d:%02d",
cfg->notify_enabled, cfg->notify_hour, cfg->notify_minute);
}
+6
View File
@@ -28,4 +28,10 @@ void config_load(WatchConfig *out);
void config_save(const WatchConfig *cfg);
/* Prints `cfg` via APP_LOG - the same fields a reading is computed from,
* so callers can confirm on-device what values a shown reading is
* actually based on (there's no way to read them back from the watch
* otherwise). Prints "not configured yet" instead if !cfg->configured. */
void config_log(const WatchConfig *cfg);
#endif
+8 -5
View File
@@ -5,7 +5,7 @@
#include "config.h"
#include "i18n_tables.h"
#include "reading_state.h"
#include "ui_menu_window.h"
#include "ui_report_window.h"
#include "ui_text_window.h"
static WatchConfig s_config;
@@ -24,11 +24,12 @@ static void on_config_updated(const WatchConfig *cfg) {
if (!s_config.configured) return;
config_log(&s_config); /* the values this newly-recomputed reading is based on */
reading_state_recompute(&s_config.birth);
if (was_configured) {
menu_window_reload();
report_window_reload();
} else {
menu_window_push();
report_window_push();
}
}
@@ -37,14 +38,16 @@ int main(void) {
watch_i18n_load(s_config.lang);
app_message_init(on_config_updated);
config_log(&s_config); /* the values this session's reading (if any) is based on */
if (s_config.configured) {
reading_state_recompute(&s_config.birth);
menu_window_push();
report_window_push();
} else {
show_setup_required();
}
app_event_loop();
menu_window_deinit();
report_window_deinit();
}
+4
View File
@@ -29,3 +29,7 @@ uint32_t card_resource_id(TarotCard card) {
}
return RESOURCE_ID_IMG_CARD_FOOL; /* unreachable for a valid TarotCard */
}
int16_t card_image_height_for_width(int16_t width) {
return (int16_t)((width * 1810 + 543) / 1086); /* +543 = round, not truncate */
}
+6
View File
@@ -15,4 +15,10 @@
* place in the app that switches over. */
uint32_t card_resource_id(TarotCard card);
/* Height in pixels of a card image resized to `width` wide, preserving
* the source art's fixed 1086x1810 aspect ratio - matches
* gen_card_images.py's own resize math, so this stays correct for
* whichever per-platform width variant package.json resolves to. */
int16_t card_image_height_for_width(int16_t width);
#endif
-78
View File
@@ -1,78 +0,0 @@
#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
@@ -1,12 +0,0 @@
#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
@@ -1,191 +0,0 @@
#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
@@ -1,19 +0,0 @@
#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
+331
View File
@@ -0,0 +1,331 @@
#include "ui_report_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 "resource_map.h"
#define MARGIN 6
/* Matches gen_card_images.py's own CARD_WIDTH - a hardware-verified
* ceiling, not a design choice: full-display-width images reliably
* crashed the real PNG decoder ("PNG memory allocation failed", a
* firmware-level constraint independent of this app's own heap budget,
* confirmed unaffected by lazy-loading only one image at a time). */
#define IMAGE_WIDTH 72
/* fonts_get_system_font() needs Pebble's runtime initialized, so these
* are resolved once in window_load() rather than at static-init time. */
static GFont s_font_section;
static GFont s_font_subhead;
static GFont s_font_body;
/* Static buffers for every dynamically-built string this window shows -
* tarot_position_description()/tarot_card_meaning()/i18n_get() fallback
* text all point straight at flash/ROM data (see i18n.h's own comment on
* i18n_load_table()), so only the pieces actually assembled at runtime
* (via snprintf/narrative_write/guidance_write) need their own storage.
* Static rather than malloc'd - fixed, known-in-advance sizes, and it
* keeps report_window_reload() from having to reason about freeing the
* previous run's buffers before reusing them. */
static char s_reading_date[48];
static char s_day_title[64];
static char s_transit_titles[MAX_SIGNIFICANT_ITEMS][96];
static char s_narratives[MAX_SIGNIFICANT_ITEMS][NARRATIVE_TEXT_MAX];
static char s_position_titles[TAROT_SPREAD_SIZE][96];
static char s_guidance[GUIDANCE_TEXT_MAX];
/* Loads/frees each position's GBitmap as scroll_offset_changed() sees it
* enter/leave the visible viewport, rather than decoding all ten Celtic
* Cross images up front - a defensive habit left over from an earlier,
* larger IMAGE_WIDTH (see that constant's own comment); at 72px this
* struct's images are small enough that it's no longer strictly
* necessary, but it's harmless to keep. */
typedef struct {
TarotCard card;
int16_t y;
int16_t height;
BitmapLayer *bitmap_layer;
GBitmap *bitmap;
} ImageSlot;
static ImageSlot s_slots[TAROT_SPREAD_SIZE];
/* Attitude (0) and Outcome (1) preview slots, drawn at half screen
* width each so the two sit side by side - the BitmapLayer's own bounds
* (narrower than IMAGE_WIDTH on 144px-wide platforms) clip the centered
* image automatically, same as GAlignCenter already does for the
* full-width slots above, no separate scaling logic needed. */
#define REPEAT_SLOT_COUNT 2
static ImageSlot s_repeat_slots[REPEAT_SLOT_COUNT];
#define MAX_TEXT_LAYERS 50
static TextLayer *s_text_layers[MAX_TEXT_LAYERS];
static int s_text_layer_count;
static Window *s_window;
static ScrollLayer *s_scroll_layer;
static int16_t s_screen_height;
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 int16_t add_text_at(int16_t x, int16_t width, int16_t y, const char *text, GFont font) {
TextLayer *tl = text_layer_create(GRect(x, y, width, 4000));
text_layer_set_font(tl, font);
text_layer_set_text(tl, text);
text_layer_set_overflow_mode(tl, GTextOverflowModeWordWrap);
GSize size = text_layer_get_content_size(tl);
size.w = width;
size.h += 4;
text_layer_set_size(tl, size);
scroll_layer_add_child(s_scroll_layer, text_layer_get_layer(tl));
s_text_layers[s_text_layer_count++] = tl;
return y + size.h + MARGIN;
}
static int16_t add_text(int16_t width, int16_t y, const char *text, GFont font) {
return add_text_at(0, width, y, text, font);
}
static void teardown_content(void) {
for (int i = 0; i < s_text_layer_count; i++) {
text_layer_destroy(s_text_layers[i]);
}
s_text_layer_count = 0;
for (int i = 0; i < TAROT_SPREAD_SIZE; i++) {
if (s_slots[i].bitmap) {
gbitmap_destroy(s_slots[i].bitmap);
s_slots[i].bitmap = NULL;
}
if (s_slots[i].bitmap_layer) {
bitmap_layer_destroy(s_slots[i].bitmap_layer);
s_slots[i].bitmap_layer = NULL;
}
}
for (int i = 0; i < REPEAT_SLOT_COUNT; i++) {
if (s_repeat_slots[i].bitmap) {
gbitmap_destroy(s_repeat_slots[i].bitmap);
s_repeat_slots[i].bitmap = NULL;
}
if (s_repeat_slots[i].bitmap_layer) {
bitmap_layer_destroy(s_repeat_slots[i].bitmap_layer);
s_repeat_slots[i].bitmap_layer = NULL;
}
}
}
static void update_slot_visibility(ImageSlot *slot, int16_t visible_top, int16_t visible_bottom) {
bool visible = slot->y < visible_bottom && slot->y + slot->height > visible_top;
if (visible && !slot->bitmap) {
slot->bitmap = gbitmap_create_with_resource(card_resource_id(slot->card));
bitmap_layer_set_bitmap(slot->bitmap_layer, slot->bitmap);
} else if (!visible && slot->bitmap) {
bitmap_layer_set_bitmap(slot->bitmap_layer, NULL);
gbitmap_destroy(slot->bitmap);
slot->bitmap = NULL;
}
}
static void update_visible_images(void) {
GPoint offset = scroll_layer_get_content_offset(s_scroll_layer);
int16_t visible_top = -offset.y;
int16_t visible_bottom = visible_top + s_screen_height;
for (int i = 0; i < TAROT_SPREAD_SIZE; i++) {
update_slot_visibility(&s_slots[i], visible_top, visible_bottom);
}
for (int i = 0; i < REPEAT_SLOT_COUNT; i++) {
update_slot_visibility(&s_repeat_slots[i], visible_top, visible_bottom);
}
}
static void scroll_offset_changed(ScrollLayer *scroll_layer, void *context) {
update_visible_images();
}
static void build_content(int16_t width) {
const DailyReading *reading = reading_state_get_reading();
const DailyInterpretation *interp = reading_state_get_interpretation();
int16_t y = MARGIN;
/* The day this reading is for (reading->utc_moment, set by
* reading_generate() - see reading_state_recompute()), not necessarily
* "today" if the report is viewed right at a UTC-midnight boundary
* before the next recompute. Plain gmtime(), same justification as
* reading_state.c's own use of it. */
struct tm *utc = gmtime(&reading->utc_moment);
snprintf(s_reading_date, sizeof s_reading_date, "%s %04d-%02d-%02d",
i18n_get("interp.reading_date", "Reading for:"), utc->tm_year + 1900,
utc->tm_mon + 1, utc->tm_mday);
y = add_text(width, y, s_reading_date, s_font_body);
y = add_text(width, y, i18n_get("interp.heading_day_significance", "Day Significance"), s_font_section);
snprintf(s_day_title, sizeof s_day_title, "%s (%d/%d)", day_level_name(interp->day_level),
interp->day_level + 1, DAY_SIGNIFICANCE_COUNT);
y = add_text(width, y, s_day_title, s_font_body);
/* Build every transit's title + narrative up front (used both for the
* repeated top-item line below and the full list further down), so
* neither buffer is computed twice for item 0. */
if (interp->top_item_count > 0) {
for (int i = 0; i < interp->top_item_count; i++) {
const Aspect *a = &interp->top_items[i].aspect;
snprintf(s_transit_titles[i], sizeof s_transit_titles[i], "%s %s %s %s %s",
i18n_get("ui.transiting", "Transiting"), astro_body_name(a->transiting_planet),
astro_aspect_name(a->type), i18n_get("ui.natal", "natal"),
astro_body_name(a->natal_planet));
narrative_write(s_narratives[i], sizeof s_narratives[i], "", a,
reading->transits.bodies[a->transiting_planet].house);
}
}
y = add_text(width, y, i18n_get("interp.heading_guidance", "Guidance"), s_font_section);
/* Attitude/Outcome preview, side by side, image only - no per-card
* caption text. A "Position: Card Name (Reversed)" label doesn't fit
* a half-width column without wrapping into 3+ short lines, and with
* both columns wrapping independently the reader ends up reading
* fragments across columns rather than down one column at a time
* (confirmed on-device: it reads as one garbled interleaved sentence,
* not two). Captions are unnecessary anyway - the guidance text
* printed immediately below already names and quotes both cards in
* full ("Deine Haltungskarte ist ..." / "Das wahrscheinliche Ergebnis
* ... ist ..."), and both reappear with their full position/name/
* meaning again in the Celtic Cross walkthrough further down. */
{
int16_t half_w = width / 2;
const TarotDraw *attitude = &reading->spread.positions[POSITION_ATTITUDE];
const TarotDraw *outcome = &reading->spread.positions[POSITION_OUTCOME];
int16_t img_height = card_image_height_for_width(IMAGE_WIDTH);
s_repeat_slots[0].card = attitude->card;
s_repeat_slots[0].y = y;
s_repeat_slots[0].height = img_height;
s_repeat_slots[0].bitmap_layer = bitmap_layer_create(GRect(0, y, half_w, img_height));
bitmap_layer_set_alignment(s_repeat_slots[0].bitmap_layer, GAlignCenter);
scroll_layer_add_child(s_scroll_layer, bitmap_layer_get_layer(s_repeat_slots[0].bitmap_layer));
s_repeat_slots[1].card = outcome->card;
s_repeat_slots[1].y = y;
s_repeat_slots[1].height = img_height;
s_repeat_slots[1].bitmap_layer =
bitmap_layer_create(GRect(half_w, y, width - half_w, img_height));
bitmap_layer_set_alignment(s_repeat_slots[1].bitmap_layer, GAlignCenter);
scroll_layer_add_child(s_scroll_layer, bitmap_layer_get_layer(s_repeat_slots[1].bitmap_layer));
y += img_height + MARGIN;
}
if (interp->top_item_count > 0) {
y = add_text(width, y, s_transit_titles[0], s_font_subhead);
y = add_text(width, y, s_narratives[0], s_font_body);
}
guidance_write(s_guidance, sizeof s_guidance, "", interp, &reading->spread);
y = add_text(width, y, s_guidance, s_font_body);
y = add_text(width, y, i18n_get("interp.heading_transits", "Significant Transits"), s_font_section);
if (interp->top_item_count == 0) {
y = add_text(width, y, i18n_get("interp.no_notable_transits", "No notable transits today."), s_font_body);
} else {
for (int i = 0; i < interp->top_item_count; i++) {
y = add_text(width, y, s_transit_titles[i], s_font_subhead);
y = add_text(width, y, s_narratives[i], s_font_body);
}
}
y = add_text(width, y, i18n_get("ui.celtic_cross", "Celtic Cross"), s_font_section);
for (int i = 0; i < TAROT_SPREAD_SIZE; i++) {
const TarotDraw *draw = &reading->spread.positions[i];
snprintf(s_position_titles[i], sizeof s_position_titles[i], "%s: %s %s",
tarot_position_name((CelticCrossPosition)i), tarot_card_name(draw->card),
reversed_marker(draw->reversed));
y = add_text(width, y, s_position_titles[i], s_font_subhead);
s_slots[i].card = draw->card;
s_slots[i].y = y;
s_slots[i].height = card_image_height_for_width(IMAGE_WIDTH);
int16_t image_x = (width - IMAGE_WIDTH) / 2;
s_slots[i].bitmap_layer = bitmap_layer_create(GRect(image_x, y, IMAGE_WIDTH, s_slots[i].height));
bitmap_layer_set_alignment(s_slots[i].bitmap_layer, GAlignCenter);
scroll_layer_add_child(s_scroll_layer, bitmap_layer_get_layer(s_slots[i].bitmap_layer));
y += s_slots[i].height + MARGIN;
y = add_text(width, y, tarot_position_description((CelticCrossPosition)i), s_font_body);
y = add_text(width, y, tarot_card_meaning(draw->card, draw->reversed), s_font_body);
}
scroll_layer_set_content_size(s_scroll_layer, GSize(width, y));
}
static void window_load(Window *window) {
s_font_section = fonts_get_system_font(FONT_KEY_GOTHIC_28_BOLD);
s_font_subhead = fonts_get_system_font(FONT_KEY_GOTHIC_24_BOLD);
s_font_body = fonts_get_system_font(FONT_KEY_GOTHIC_24_BOLD);
Layer *window_layer = window_get_root_layer(window);
GRect bounds = layer_get_bounds(window_layer);
s_screen_height = bounds.size.h;
s_scroll_layer = scroll_layer_create(bounds);
scroll_layer_set_click_config_onto_window(s_scroll_layer, window);
scroll_layer_set_callbacks(s_scroll_layer, (ScrollLayerCallbacks){
.content_offset_changed_handler = scroll_offset_changed,
});
layer_add_child(window_layer, scroll_layer_get_layer(s_scroll_layer));
build_content(bounds.size.w);
update_visible_images();
}
static void window_unload(Window *window) {
teardown_content();
scroll_layer_destroy(s_scroll_layer);
s_scroll_layer = NULL;
}
void report_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 report_window_reload(void) {
if (!s_window || !s_scroll_layer) return;
Layer *window_layer = window_get_root_layer(s_window);
int16_t width = layer_get_bounds(window_layer).size.w;
teardown_content();
build_content(width);
update_visible_images();
}
void report_window_deinit(void) {
if (s_window) window_destroy(s_window);
s_window = NULL;
}
+23
View File
@@ -0,0 +1,23 @@
#ifndef WATCH_UI_REPORT_WINDOW_H
#define WATCH_UI_REPORT_WINDOW_H
/* The app's only real screen: one continuously scrollable page mirroring
* the interpreter's own --format text/html report - day significance,
* each significant transit's narrative, the full Celtic Cross spread
* (each position's card art at the watch's own display width, name,
* description, and meaning), and the guidance paragraph last - see
* reading_state.h for where all of this data comes from.
*
* Card art is loaded lazily as it scrolls into view and freed once it
* scrolls back out - a full-display-width card image is too large to
* keep all ten resident at once on this app's RAM budget (see
* ui_report_window.c's own comment) - so unlike a plain ScrollLayer of
* static content, this needs report_window_push()'s window to actually
* be on-screen to drive that loading; call report_window_reload() (not
* push() again) to refresh already-built content after the day rolls
* over or new birth data is submitted. */
void report_window_push(void);
void report_window_reload(void);
void report_window_deinit(void);
#endif
+3 -3
View File
@@ -2,7 +2,7 @@
#include <pebble.h>
#define TITLE_HEIGHT 24
#define TITLE_HEIGHT 30
typedef struct {
char *title;
@@ -18,7 +18,7 @@ static void window_load(Window *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_font(state->title_layer, fonts_get_system_font(FONT_KEY_GOTHIC_24_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));
@@ -31,7 +31,7 @@ static void window_load(Window *window) {
* 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_font(state->body_layer, fonts_get_system_font(FONT_KEY_GOTHIC_24_BOLD));
text_layer_set_text(state->body_layer, state->body);
text_layer_set_overflow_mode(state->body_layer, GTextOverflowModeWordWrap);
+231 -5
View File
File diff suppressed because one or more lines are too long
+10 -7
View File
@@ -63,15 +63,18 @@ def configure(ctx):
def build(ctx):
ctx.load('pebble_sdk')
# Regenerate src/c/i18n_tables.auto.c from engine/i18n/*.lang, and
# resources/img/*.png from res/img/*.jpeg, before compiling/bundling
# anything - see each script's own doc comment. Both run eagerly (not
# as waf Tasks) since the bundle step below depends on their output
# existing; gen_card_images.py skips any card whose PNG is already
# up to date, so this stays cheap on repeat builds. Requires Pillow
# (`pip install Pillow`) - see watch/README.md.
# Regenerate src/c/i18n_tables.auto.c from engine/i18n/*.lang,
# resources/img/*.png from res/img/*.jpeg, and resources/app_icon.png
# from res/app_icon_50x50_64_color.png, before compiling/bundling
# anything - see each script's own doc comment. All three run eagerly
# (not as waf Tasks) since the bundle step below depends on their
# output existing; gen_card_images.py/gen_app_icon.py both skip
# regenerating anything already up to date, so this stays cheap on
# repeat builds. Requires Pillow (`pip install Pillow`) - see
# watch/README.md.
subprocess.check_call([sys.executable, 'scripts/gen_i18n_tables.py'])
subprocess.check_call([sys.executable, 'scripts/gen_card_images.py'])
subprocess.check_call([sys.executable, 'scripts/gen_app_icon.py'])
engine_dir = ctx.path.parent.find_dir('engine/src')
engine_nodes = [engine_dir.find_node(name) for name in ENGINE_SRCS]