From c87343e5c2058cf29746f19858f595228ec1d4a6 Mon Sep 17 00:00:00 2001 From: Amolith Date: Wed, 10 Jun 2026 22:12:28 -0600 Subject: [PATCH] Juice with Fable --- AGENTS.md | 1 + mise.toml | 2 + src/c/main.c | 359 +++++++++++++++++++++++++++++++++++++--------- src/pkjs/index.js | 163 +++++++++++++++++++-- 4 files changed, 452 insertions(+), 73 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a8a4538dbaac38c3435e4675568e1efdc93e055d..ebd8abb19312289fc297d7716a1e7853ce62487f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,3 +21,4 @@ Use `mise run emu:up`, `emu:select`, `emu:down`, and `emu:back` for button input - The Plexus admin key belongs in phone-side PebbleKit JS storage only; never send it to the watch. - `mise run build` leaves `build/` and `.lock-waf_*_build`; they are ignored build artifacts. - PebbleKit JS here is written in ES5 style for Pebble compatibility: use `var` and functions, not modern syntax. +- The watch row layout heights in `src/c/main.c` (`card_update_proc`: 42/28/50px rows, 3px separators) and the phone-side page packing in `src/pkjs/index.js` (`estimateRowHeightPx`, `PAGE_HEIGHT_BUDGET_*`) mirror each other; change them together. diff --git a/mise.toml b/mise.toml index 7e6a2e739d70954469fad4073260c823cb3591ef..6a9543427252a677cec13268650a6323e7639200 100644 --- a/mise.toml +++ b/mise.toml @@ -70,6 +70,7 @@ run = "pebble analyze-size build/{{vars.platform}}/pebble-app.elf" [tasks.install] description = "Install the app in the Pebble emulator" +depends = ["build"] run = "pebble install --emulator {{vars.platform}}" [tasks."emu:config"] @@ -78,6 +79,7 @@ run = "pebble emu-app-config --emulator {{vars.platform}}" [tasks."install:cloudpebble"] description = "Install the app on a paired device through CloudPebble" +depends = ["build"] run = "pebble install --cloudpebble" [tasks.screenshot] diff --git a/src/c/main.c b/src/c/main.c index ff68fcd311e5a2c8052458fb28c195f19e9f567f..bbf0d83f40a17c0fc43790143a452ecd61a9b3db 100644 --- a/src/c/main.c +++ b/src/c/main.c @@ -2,7 +2,30 @@ #define MAX_ROWS 4 #define ANIMATION_INTERVAL_MS 33 -#define ANIMATION_FRAMES 12 +#define REFRESH_DOT_FRAMES 12 +#define SLIDE_OUT_FRAMES 4 +#define SLIDE_IN_FRAMES 5 +#define SLIDE_OUT_DISTANCE_PX 18 +#define SLIDE_IN_DISTANCE_PX 14 +#define BAR_FILL_FRAMES 10 +#define BAR_STAGGER_FRAMES 3 +#define BOUNCE_FRAMES 3 +#define BOUNCE_DISTANCE_PX 5 +#define STAMP_FRAMES 4 + +// Page navigation choreography: the card content slides out in the travel +// direction while we wait for the phone, the new card slides in from the +// opposite side, then the quota bars pour in top-down. On failure the card +// slides back into place. When the page order wraps around, the incoming +// card overshoots and rubber-bands back to signal the seam in the loop. +typedef enum { + MOTION_IDLE, + MOTION_SLIDE_OUT, + MOTION_WAITING, + MOTION_SLIDE_IN, + MOTION_BOUNCE, + MOTION_BARS, +} MotionPhase; typedef struct { char label[24]; @@ -19,10 +42,16 @@ static AppTimer *s_animation_timer; static int s_page_index = 0; static int s_page_count = 0; static int s_row_count = 0; -static int s_animation_frame = ANIMATION_FRAMES; +static MotionPhase s_motion_phase = MOTION_IDLE; +static int s_motion_frame = 0; +static int s_slide_offset = 0; +static int s_slide_from = 0; +static int s_slide_to = 0; +static int s_slide_direction = 1; +static int s_bar_frame = BAR_FILL_FRAMES + BAR_STAGGER_FRAMES * (MAX_ROWS - 1); static int s_refresh_frame = 0; -static int s_animation_direction = 1; static bool s_is_refreshing = false; +static bool s_wrap_pending = false; static char s_title_text[40] = "Pebblexus"; static char s_group_text[40] = ""; @@ -152,15 +181,58 @@ static GColor color_for_status_text(void) { return color_for_status(1); } -static int current_slide_offset(void) { return 0; } +static int ease_quad_in(int from, int to, int frame, int frame_count) { + if (frame <= 0) + return from; + if (frame >= frame_count) + return to; + int t = (frame * 100) / frame_count; + return from + ((to - from) * t * t) / 10000; +} + +static int ease_quad_out(int from, int to, int frame, int frame_count) { + if (frame <= 0) + return from; + if (frame >= frame_count) + return to; + int t = (frame * 100) / frame_count; + return from - ((to - from) * t * (t - 200)) / 10000; +} + +static int current_slide_offset(void) { return s_slide_offset; } -static int current_bar_percent(int percent) { +static int bar_cascade_total_frames(void) { + int rows = s_row_count > MAX_ROWS ? MAX_ROWS : s_row_count; + if (rows < 1) + rows = 1; + int cascade_frames = BAR_FILL_FRAMES + BAR_STAGGER_FRAMES * (rows - 1); + int stamp_end_frame = BAR_FILL_FRAMES + STAMP_FRAMES; + return cascade_frames > stamp_end_frame ? cascade_frames : stamp_end_frame; +} + +static int current_bar_percent(int row_index, int percent) { int clamped = clamp_percent(percent); - if (s_animation_frame >= ANIMATION_FRAMES) + if (s_motion_phase == MOTION_SLIDE_IN || s_motion_phase == MOTION_BOUNCE) + return 0; + if (s_motion_phase != MOTION_BARS) return clamped; - int frame = s_animation_frame; - int eased = ANIMATION_FRAMES - (((ANIMATION_FRAMES - frame) * (ANIMATION_FRAMES - frame)) / ANIMATION_FRAMES); - return (clamped * eased) / ANIMATION_FRAMES; + int frame = s_bar_frame - (row_index * BAR_STAGGER_FRAMES); + return ease_quad_out(0, clamped, frame, BAR_FILL_FRAMES); +} + +static bool status_is_alarming(void) { + return strcmp(s_status_text, "CRITICAL") == 0 || strcmp(s_status_text, "WARNING") == 0; +} + +// The status pill stamps in as the worst quota bar lands: briefly oversized, +// then settled. Only when the news warrants it. +static int status_pill_inflation(void) { + if (s_motion_phase != MOTION_BARS || !status_is_alarming()) + return 0; + int stamp_frame = s_bar_frame - BAR_FILL_FRAMES; + if (stamp_frame < 0 || stamp_frame >= STAMP_FRAMES) + return 0; + return stamp_frame < STAMP_FRAMES / 2 ? 2 : 1; } static void mark_card_dirty(void) { @@ -168,29 +240,126 @@ static void mark_card_dirty(void) { layer_mark_dirty(s_card_layer); } -static void animation_timer_callback(void *data) { +static void motion_timer_callback(void *data) { s_animation_timer = NULL; - if (s_animation_frame < ANIMATION_FRAMES || s_is_refreshing) { - if (s_animation_frame < ANIMATION_FRAMES) { - s_animation_frame += 1; + bool needs_tick = false; + + switch (s_motion_phase) { + case MOTION_SLIDE_OUT: + s_motion_frame += 1; + s_slide_offset = ease_quad_in(s_slide_from, s_slide_to, s_motion_frame, SLIDE_OUT_FRAMES); + if (s_motion_frame >= SLIDE_OUT_FRAMES) { + s_motion_phase = MOTION_WAITING; + } else { + needs_tick = true; } - s_refresh_frame = (s_refresh_frame + 1) % ANIMATION_FRAMES; - mark_card_dirty(); - s_animation_timer = app_timer_register(ANIMATION_INTERVAL_MS, animation_timer_callback, NULL); + break; + case MOTION_SLIDE_IN: + s_motion_frame += 1; + s_slide_offset = ease_quad_out(s_slide_from, s_slide_to, s_motion_frame, SLIDE_IN_FRAMES); + if (s_motion_frame >= SLIDE_IN_FRAMES) { + if (s_slide_to != 0) { + s_motion_phase = MOTION_BOUNCE; + s_motion_frame = 0; + s_slide_from = s_slide_to; + s_slide_to = 0; + } else { + s_motion_phase = MOTION_BARS; + s_bar_frame = 0; + } + } + needs_tick = true; + break; + case MOTION_BOUNCE: + s_motion_frame += 1; + s_slide_offset = ease_quad_out(s_slide_from, 0, s_motion_frame, BOUNCE_FRAMES); + if (s_motion_frame >= BOUNCE_FRAMES) { + s_motion_phase = MOTION_BARS; + s_bar_frame = 0; + } + needs_tick = true; + break; + case MOTION_BARS: + s_bar_frame += 1; + if (s_bar_frame >= bar_cascade_total_frames()) { + s_motion_phase = MOTION_IDLE; + } else { + needs_tick = true; + } + break; + default: + break; } -} -static void start_card_animation(int direction) { - if (s_animation_timer != NULL) { - app_timer_cancel(s_animation_timer); - s_animation_timer = NULL; + if (s_is_refreshing) { + s_refresh_frame = (s_refresh_frame + 1) % REFRESH_DOT_FRAMES; + needs_tick = true; } - s_animation_direction = direction < 0 ? -1 : 1; - s_animation_frame = 0; - s_refresh_frame = 0; mark_card_dirty(); - s_animation_timer = app_timer_register(ANIMATION_INTERVAL_MS, animation_timer_callback, NULL); + if (needs_tick) + s_animation_timer = app_timer_register(ANIMATION_INTERVAL_MS, motion_timer_callback, NULL); +} + +static void ensure_motion_timer(void) { + if (s_animation_timer == NULL) + s_animation_timer = app_timer_register(ANIMATION_INTERVAL_MS, motion_timer_callback, NULL); +} + +// Card departs in the travel direction and holds offset until the phone replies. +static void start_slide_out(int direction) { + s_slide_direction = direction < 0 ? -1 : 1; + s_slide_from = s_slide_offset; + s_slide_to = -s_slide_direction * SLIDE_OUT_DISTANCE_PX; + s_motion_phase = MOTION_SLIDE_OUT; + s_motion_frame = 0; + mark_card_dirty(); + ensure_motion_timer(); +} + +// New card arrives from the opposite side, settles, then bars pour in. On a +// wraparound it overshoots past its resting position and rubber-bands back. +static void start_slide_in(void) { + s_slide_from = s_slide_direction * SLIDE_IN_DISTANCE_PX; + s_slide_to = s_wrap_pending ? -s_slide_direction * BOUNCE_DISTANCE_PX : 0; + s_wrap_pending = false; + s_slide_offset = s_slide_from; + s_motion_phase = MOTION_SLIDE_IN; + s_motion_frame = 0; + mark_card_dirty(); + ensure_motion_timer(); +} + +// The page never arrived: the card slides back into place. +static void start_slide_back(void) { + s_wrap_pending = false; + if (s_motion_phase != MOTION_SLIDE_OUT && s_motion_phase != MOTION_WAITING) + return; + s_slide_from = s_slide_offset; + s_slide_to = 0; + s_motion_phase = MOTION_SLIDE_IN; + s_motion_frame = 0; + mark_card_dirty(); + ensure_motion_timer(); +} + +// Data arrived in place (initial load or select refresh): cascade the bars only. +static void start_bar_cascade(void) { + s_wrap_pending = false; + s_slide_offset = 0; + s_motion_phase = MOTION_BARS; + s_motion_frame = 0; + s_bar_frame = 0; + mark_card_dirty(); + ensure_motion_timer(); +} + +static void snap_motion_idle(void) { + s_wrap_pending = false; + s_motion_phase = MOTION_IDLE; + s_motion_frame = 0; + s_slide_offset = 0; + s_bar_frame = bar_cascade_total_frames(); } static void request_quota(void) { @@ -210,7 +379,9 @@ static void request_quota(void) { snprintf(s_status_text, sizeof(s_status_text), "Refreshing"); s_updated_text[0] = '\0'; s_is_refreshing = true; - start_card_animation(1); + s_refresh_frame = 0; + mark_card_dirty(); + ensure_motion_timer(); } else { snprintf(s_status_text, sizeof(s_status_text), "Send fail"); s_is_refreshing = false; @@ -222,13 +393,16 @@ static void request_page(int page_index) { if (s_page_count <= 0) return; int direction = page_index >= s_page_index ? 1 : -1; + bool wrapped = false; if (page_index < 0) { page_index = s_page_count - 1; direction = -1; + wrapped = true; } if (page_index >= s_page_count) { page_index = 0; direction = 1; + wrapped = true; } DictionaryIterator *iter; @@ -243,7 +417,8 @@ static void request_page(int page_index) { dict_write_int32(iter, MESSAGE_KEY_REQUEST_PAGE, page_index); result = app_message_outbox_send(); if (result == APP_MSG_OK) { - start_card_animation(direction); + s_wrap_pending = wrapped; + start_slide_out(direction); } else { snprintf(s_status_text, sizeof(s_status_text), "Send fail"); mark_card_dirty(); @@ -267,11 +442,17 @@ static void draw_text(GContext *ctx, const char *text, GRect box, GFont font, GC graphics_draw_text(ctx, text, font, box, GTextOverflowModeTrailingEllipsis, alignment, NULL); } -static void draw_progress_bar(GContext *ctx, GRect frame, int percent, GColor color) { +static int text_width(const char *text, GFont font) { + GSize size = graphics_text_layout_get_content_size(text, font, GRect(0, 0, 500, 30), GTextOverflowModeWordWrap, + GTextAlignmentLeft); + return size.w; +} + +static void draw_progress_bar(GContext *ctx, GRect frame, int row_index, int percent, GColor color) { graphics_context_set_fill_color(ctx, color_track()); graphics_fill_rect(ctx, frame, 3, GCornersAll); - int fill_width = (frame.size.w * current_bar_percent(percent)) / 100; + int fill_width = (frame.size.w * current_bar_percent(row_index, percent)) / 100; if (fill_width < 2 && percent > 0) fill_width = 2; graphics_context_set_fill_color(ctx, color); @@ -279,10 +460,13 @@ static void draw_progress_bar(GContext *ctx, GRect frame, int percent, GColor co } static void draw_status_pill(GContext *ctx, GRect frame) { + int inflation = status_pill_inflation(); + GRect pill = GRect(frame.origin.x - inflation, frame.origin.y - inflation, frame.size.w + (inflation * 2), + frame.size.h + (inflation * 2)); graphics_context_set_fill_color(ctx, color_status_fill()); - graphics_fill_rect(ctx, frame, 6, GCornersAll); + graphics_fill_rect(ctx, pill, 6, GCornersAll); graphics_context_set_stroke_color(ctx, color_for_status_text()); - graphics_draw_rect(ctx, frame); + graphics_draw_rect(ctx, pill); draw_text(ctx, s_status_text, GRect(frame.origin.x, frame.origin.y - 4, frame.size.w, frame.size.h + 4), fonts_get_system_font(FONT_KEY_GOTHIC_18_BOLD), color_text_primary(), GTextAlignmentCenter); } @@ -300,24 +484,25 @@ static void draw_refresh_dots(GContext *ctx, GRect bounds) { graphics_fill_circle(ctx, GPoint(center_x + 7, center_y), phase >= 6 ? 3 : 2); } +// Vertical rail along the right edge: pages travel up/down, so does the rail. static void draw_page_dots(GContext *ctx, GRect frame) { if (s_page_count <= 1) return; int dots = s_page_count > 7 ? 7 : s_page_count; - int total_width = dots * 7 - 3; - int x = frame.origin.x + (frame.size.w - total_width) / 2; - int y = frame.origin.y + frame.size.h - 10; + int total_height = dots * 7 - 3; + int x = frame.origin.x + frame.size.w - 5; + int y = frame.origin.y + (frame.size.h - total_height) / 2; for (int i = 0; i < dots; i += 1) { - graphics_context_set_fill_color(ctx, i == s_page_index || (s_page_count > 7 && i == 6 && s_page_index >= 6) - ? color_card_highlight() - : color_text_muted()); int active = i == s_page_index || (s_page_count > 7 && i == 6 && s_page_index >= 6); + graphics_context_set_fill_color(ctx, active ? color_card_highlight() : color_text_muted()); int radius = active ? 2 : 1; - if (active && s_animation_frame < ANIMATION_FRAMES / 2) + bool card_in_flight = s_motion_phase == MOTION_SLIDE_OUT || s_motion_phase == MOTION_WAITING || + s_motion_phase == MOTION_SLIDE_IN || s_motion_phase == MOTION_BOUNCE; + if (active && card_in_flight) radius = 3; - graphics_fill_circle(ctx, GPoint(x + (i * 7), y), radius); + graphics_fill_circle(ctx, GPoint(x, y + (i * 7)), radius); } } @@ -371,29 +556,71 @@ static void card_update_proc(Layer *layer, GContext *ctx) { draw_text(ctx, s_reset_text, GRect(content_x, y + 86, content_w, 46), fonts_get_system_font(FONT_KEY_GOTHIC_24), color_text_secondary(), GTextAlignmentCenter); } else { + // Row layout heights (42/28/50 + 3px separators) are mirrored by the + // phone-side page packing in src/pkjs/index.js; keep them in sync. + GFont row_font = fonts_get_system_font(FONT_KEY_GOTHIC_18_BOLD); + int row_bottom_limit = bounds.size.h - 34 + offset; + + // One detail column for all bar rows keeps the bar tracks aligned. + int detail_column_w = 28; + for (int i = 0; i < s_row_count && i < MAX_ROWS; i += 1) { + if (!s_rows[i].has_bar) + continue; + int detail_w = text_width(s_rows[i].detail, row_font); + if (detail_w > detail_column_w) + detail_column_w = detail_w; + } + if (detail_column_w > 100) + detail_column_w = 100; + + int rows_drawn = 0; for (int i = 0; i < s_row_count && i < MAX_ROWS; i += 1) { QuotaRow *row = &s_rows[i]; GColor accent = color_for_status(row->status_rank); - int detail_width = 62; - draw_text(ctx, row->label, GRect(content_x, y, content_w - detail_width - 8, 22), - fonts_get_system_font(FONT_KEY_GOTHIC_18_BOLD), color_text_primary(), GTextAlignmentLeft); - draw_text(ctx, row->detail, GRect(content_x + content_w - detail_width, y, detail_width, 22), - fonts_get_system_font(FONT_KEY_GOTHIC_18_BOLD), row->has_bar ? color_text_soft() : color_text_primary(), - GTextAlignmentRight); - y += 22; if (row->has_bar) { - draw_progress_bar(ctx, GRect(content_x, y, content_w, 6), row->percent, accent); - y += 16; + if (y + 42 > row_bottom_limit) + break; + draw_text(ctx, row->label, GRect(content_x, y, content_w, 22), row_font, color_text_primary(), + GTextAlignmentLeft); + y += 22; + int bar_w = content_w - detail_column_w - 8; + draw_text(ctx, row->detail, GRect(content_x + bar_w + 8, y - 3, detail_column_w, 22), row_font, + color_text_soft(), GTextAlignmentRight); + draw_progress_bar(ctx, GRect(content_x, y + 6, bar_w, 6), i, row->percent, accent); + y += 20; } else { - y += 6; + int detail_w = text_width(row->detail, row_font); + if (detail_w > 100) + detail_w = 100; + int label_w = content_w - detail_w - 8; + bool two_line = text_width(row->label, row_font) > label_w; + if (y + (two_line ? 50 : 28) > row_bottom_limit) + break; + if (two_line) { + draw_text(ctx, row->label, GRect(content_x, y, content_w, 22), row_font, color_text_primary(), + GTextAlignmentLeft); + y += 22; + draw_text(ctx, row->detail, GRect(content_x, y, content_w, 22), row_font, color_text_primary(), + GTextAlignmentRight); + y += 28; + } else { + draw_text(ctx, row->label, GRect(content_x, y, label_w, 22), row_font, color_text_primary(), + GTextAlignmentLeft); + draw_text(ctx, row->detail, GRect(content_x + label_w + 8, y, detail_w, 22), row_font, color_text_primary(), + GTextAlignmentRight); + y += 28; + } } + rows_drawn += 1; if (i + 1 < s_row_count && i + 1 < MAX_ROWS) { graphics_context_set_stroke_color(ctx, color_card_outline()); graphics_draw_line(ctx, GPoint(content_x, y), GPoint(content_x + content_w, y)); y += 3; } } - if (s_row_count > MAX_ROWS) { + + int rows_visible = s_row_count > MAX_ROWS ? MAX_ROWS : s_row_count; + if (rows_drawn < rows_visible || s_row_count > MAX_ROWS) { draw_text(ctx, "+ more", GRect(content_x, y, content_w, 22), fonts_get_system_font(FONT_KEY_GOTHIC_18), color_text_muted(), GTextAlignmentCenter); } @@ -401,19 +628,8 @@ static void card_update_proc(Layer *layer, GContext *ctx) { draw_page_dots(ctx, bounds); - draw_text(ctx, s_reset_text, GRect(content_x, bounds.size.h - 55, content_w, 24), + draw_text(ctx, s_reset_text, GRect(content_x, bounds.size.h - 32 + offset, content_w, 24), fonts_get_system_font(FONT_KEY_GOTHIC_18), color_text_muted(), GTextAlignmentCenter); - - char footer[32]; - if (s_page_count > 0 && s_status_text[0] != '\0') { - snprintf(footer, sizeof(footer), "%.10s %.12s", s_status_text, s_updated_text); - } else if (s_page_count > 0) { - snprintf(footer, sizeof(footer), "%.31s", s_updated_text); - } else { - snprintf(footer, sizeof(footer), "%.31s", s_status_text); - } - draw_text(ctx, footer, GRect(content_x, bounds.size.h - 33, content_w, 24), fonts_get_system_font(FONT_KEY_GOTHIC_18), - color_text_secondary(), GTextAlignmentCenter); } static void copy_string_tuple(DictionaryIterator *iterator, uint32_t key, char *buffer, size_t buffer_size) { @@ -449,11 +665,14 @@ static void inbox_received_callback(DictionaryIterator *iterator, void *context) snprintf(s_reset_text, sizeof(s_reset_text), "%s", error_tuple->value->cstring); snprintf(s_status_text, sizeof(s_status_text), "Select retries"); s_updated_text[0] = '\0'; - s_animation_frame = ANIMATION_FRAMES; + snap_motion_idle(); mark_card_dirty(); return; } + static char s_last_data_status[16] = ""; + bool was_refreshing = s_is_refreshing; + s_is_refreshing = false; copy_string_tuple(iterator, MESSAGE_KEY_QUOTA_TITLE, s_title_text, sizeof(s_title_text)); copy_string_tuple(iterator, MESSAGE_KEY_QUOTA_LABEL, s_group_text, sizeof(s_group_text)); @@ -461,6 +680,12 @@ static void inbox_received_callback(DictionaryIterator *iterator, void *context) copy_string_tuple(iterator, MESSAGE_KEY_QUOTA_STATUS, s_status_text, sizeof(s_status_text)); copy_string_tuple(iterator, MESSAGE_KEY_UPDATED_AT, s_updated_text, sizeof(s_updated_text)); + // Redundant haptic feedback only when a refresh reveals a newly critical + // page; browsing between known-critical pages stays silent. + if (was_refreshing && strcmp(s_status_text, "CRITICAL") == 0 && strcmp(s_last_data_status, "CRITICAL") != 0) + vibes_short_pulse(); + snprintf(s_last_data_status, sizeof(s_last_data_status), "%s", s_status_text); + Tuple *page_index_tuple = dict_find(iterator, MESSAGE_KEY_PAGE_INDEX); if (page_index_tuple != NULL) s_page_index = (int)page_index_tuple->value->int32; @@ -491,18 +716,24 @@ static void inbox_received_callback(DictionaryIterator *iterator, void *context) read_row(iterator, i); } - start_card_animation(s_animation_direction); + if (s_motion_phase == MOTION_SLIDE_OUT || s_motion_phase == MOTION_WAITING) { + start_slide_in(); + } else { + start_bar_cascade(); + } } static void inbox_dropped_callback(AppMessageResult reason, void *context) { s_is_refreshing = false; snprintf(s_status_text, sizeof(s_status_text), "Dropped"); + start_slide_back(); mark_card_dirty(); } static void outbox_failed_callback(DictionaryIterator *iterator, AppMessageResult reason, void *context) { s_is_refreshing = false; snprintf(s_status_text, sizeof(s_status_text), "No phone"); + start_slide_back(); mark_card_dirty(); } diff --git a/src/pkjs/index.js b/src/pkjs/index.js index a3471d261336d09ec2379369095b51c37a38c107..5829aac9c2c957312ff876688e290bd21f10613e 100644 --- a/src/pkjs/index.js +++ b/src/pkjs/index.js @@ -129,17 +129,24 @@ function formatCompactNumber(value) { return formatNumber(number); } +var UNIT_SUFFIXES = { + hypercredits: "hc", + requests: "req", + request: "req", + tokens: "tok", + token: "tok", + kwh: "kWh" +}; + function formatValue(value, unit) { var number = asNumber(value); if (number === null) return "?"; if (unit === "usd") return "$" + number.toFixed(2); if (unit === "percent" || unit === "percentage") return formatNumber(number) + "%"; - if (unit === "hypercredits") return formatCompactNumber(number) + " hc"; - if (unit === "requests") return formatCompactNumber(number) + " req"; - if (unit === "kwh") return formatCompactNumber(number) + " kWh"; if (!unit) return formatNumber(number); - return formatCompactNumber(number) + " " + String(unit).slice(0, 4); + var suffix = UNIT_SUFFIXES[String(unit).toLowerCase()] || String(unit); + return formatCompactNumber(number) + " " + suffix; } function formatMeterDetail(meter) { @@ -156,6 +163,73 @@ function formatMeterDetail(meter) { return "—"; } +var LABEL_ABBREVIATIONS = { + requests: "req", + request: "req", + tokens: "tok", + token: "tok", + messages: "msg", + message: "msg", + minutes: "min", + minute: "min", + seconds: "sec", + second: "sec", + hours: "hr", + hour: "hr" +}; + +var LABEL_CHAR_BUDGET = 14; + +// Drop label words the page title already shows ("OpenAI requests" on the +// OpenAI page is just "requests"). +function stripTitleWords(label, title) { + var titleWords = {}; + var titleTokens = String(title || "") + .toLowerCase() + .split(/[^a-z0-9]+/); + for (var i = 0; i < titleTokens.length; i += 1) { + if (titleTokens[i]) titleWords[titleTokens[i]] = true; + } + + var words = String(label).split(/\s+/); + var kept = []; + for (var j = 0; j < words.length; j += 1) { + var bare = words[j].toLowerCase().replace(/[^a-z0-9]/g, ""); + if (bare === "" || !titleWords[bare]) kept.push(words[j]); + } + if (kept.length === 0) return String(label); + return kept.join(" "); +} + +function abbreviateLabel(label) { + var words = label.split(/\s+/); + var out = []; + for (var i = 0; i < words.length; i += 1) { + out.push(LABEL_ABBREVIATIONS[words[i].toLowerCase()] || words[i]); + } + return out.join(" ").replace(/(\S+) per (\S+)/gi, "$1/$2"); +} + +function displayLabelForMeter(meter, title) { + var label = stripTitleWords(meter.label, title); + if (label.length > LABEL_CHAR_BUDGET) label = abbreviateLabel(label); + return label.charAt(0).toUpperCase() + label.slice(1); +} + +// Drop the detail's unit suffix when the label already names the unit +// ("Req/min" plus "30 req" reads fine as "Req/min" plus "30"). +function displayDetailForMeter(meter, displayLabel) { + var detail = formatMeterDetail(meter); + var spaceIndex = detail.lastIndexOf(" "); + if (spaceIndex === -1) return detail; + var suffix = detail.slice(spaceIndex + 1).toLowerCase(); + var label = String(displayLabel || "").toLowerCase(); + var unit = String(meter.unit || "").toLowerCase(); + if (label.indexOf(suffix) !== -1 || (unit !== "" && label.indexOf(unit) !== -1)) + return detail.slice(0, spaceIndex); + return detail; +} + function shortResetForGroup(group) { var soonest = null; for (var i = 0; i < group.meters.length; i += 1) { @@ -254,7 +328,13 @@ function flattenGroups(response, displayNames) { } for (var groupIndex = 0; groupIndex < groups.length; groupIndex += 1) { - groups[groupIndex].meters.sort(compareMeters); + var group = groups[groupIndex]; + group.meters.sort(compareMeters); + for (var meterIndex = 0; meterIndex < group.meters.length; meterIndex += 1) { + var meter = group.meters[meterIndex]; + meter.displayLabel = displayLabelForMeter(meter, group.title); + meter.displayDetail = displayDetailForMeter(meter, meter.displayLabel); + } } groups.sort(function (left, right) { var leftHasComparableMeter = groupHasComparableMeter(left); @@ -269,6 +349,71 @@ function flattenGroups(response, displayNames) { return groups; } +// Mirrors the watch row layout in src/c/main.c: bar rows are 42px, one-line +// rows 28px, two-line rows 50px, with 3px separators between rows. Rows run +// from y=60 (y=42 without the status pill row) down to the reset line at +// height-34. Keep in sync. +var PAGE_HEIGHT_BUDGET_WITH_STATUS_PX = 134; +var PAGE_HEIGHT_BUDGET_WITHOUT_STATUS_PX = 152; +var ROW_SEPARATOR_PX = 3; +var TWO_LINE_LABEL_CHAR_ESTIMATE = 14; + +function estimateRowHeightPx(meter) { + if (meter.hasBar) return 42; + return String(meter.displayLabel).length > TWO_LINE_LABEL_CHAR_ESTIMATE + ? 50 + : 28; +} + +// Split each group into as many pages as its meters need, so nothing hides +// behind "+ more". Meters are already sorted most-urgent-first, so part one +// always carries the worst news. +function paginateGroups(groups) { + var pages = []; + for (var g = 0; g < groups.length; g += 1) { + var group = groups[g]; + var parts = []; + var current = null; + var budget = 0; + for (var m = 0; m < group.meters.length; m += 1) { + var meter = group.meters[m]; + var rowHeight = estimateRowHeightPx(meter); + var fits = + current !== null && + current.meters.length < MAX_ROWS_PER_GROUP && + current.heightPx + ROW_SEPARATOR_PX + rowHeight <= budget; + if (fits) { + current.heightPx += ROW_SEPARATOR_PX + rowHeight; + } else { + budget = meter.hasBar + ? PAGE_HEIGHT_BUDGET_WITH_STATUS_PX + : PAGE_HEIGHT_BUDGET_WITHOUT_STATUS_PX; + current = { + id: group.id, + title: group.title, + meters: [], + heightPx: rowHeight + }; + parts.push(current); + } + current.meters.push(meter); + } + for (var p = 0; p < parts.length; p += 1) { + parts[p].partIndex = p + 1; + parts[p].partCount = parts.length; + pages.push(parts[p]); + } + } + return pages; +} + +function resetTextForPage(page) { + var reset = shortResetForGroup(page); + if (page.partCount > 1) + return page.partIndex + "/" + page.partCount + " · " + reset; + return reset; +} + function sendGroupPage(pageIndex) { if (quotaGroups.length === 0) { sendError("No quota groups found"); @@ -280,7 +425,7 @@ function sendGroupPage(pageIndex) { var dictionary = { QUOTA_TITLE: String(group.title).slice(0, 38), QUOTA_STATUS: "", - QUOTA_RESET: shortResetForGroup(group).slice(0, 38), + QUOTA_RESET: resetTextForPage(group).slice(0, 38), QUOTA_LABEL: "", ROW_COUNT: group.meters.length, PAGE_INDEX: currentPageIndex, @@ -297,8 +442,8 @@ function sendGroupPage(pageIndex) { var rank = statusRank(meter.status); if (meter.hasBar) pageHasBar = true; worstStatusRank = Math.max(worstStatusRank, rank); - dictionary[keys.label] = String(meter.label).slice(0, 22); - dictionary[keys.detail] = formatMeterDetail(meter).slice(0, 26); + dictionary[keys.label] = String(meter.displayLabel).slice(0, 22); + dictionary[keys.detail] = String(meter.displayDetail).slice(0, 26); dictionary[keys.percent] = Math.max( 0, Math.min(100, Math.round(asNumber(meter.utilizationPercent) || 0)) @@ -356,7 +501,7 @@ function refreshQuotaWithDisplayNames( return; } try { - quotaGroups = flattenGroups(responseText, displayNames); + quotaGroups = paginateGroups(flattenGroups(responseText, displayNames)); sendGroupPage(pageIndex); } catch (e) { sendError("Bad quota response");