@@ -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();
}
@@ -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");