Refine Usage screen typography and match Claude Code spinner easing

- Drop "Claude" prefix from titles, center all three
- Replace verbose "Current Session" / "Current Week" labels with rounded
  pills above the bar; reset countdown moves below the bar
- Extend bar to fill panel inner width (was 8px short)
- Symmetric vertical pill placement within panel
- Spinner: per-frame hold times + ping-pong indexing, modeled on
  Claude Code's Cavalry triangle oscillator (turn-around frames hold
  ~2x longer)
- Bottom spinner re-centered between weekly panel and screen edge
- screenshot.sh: 480x320 -> 480x480 for the new panel
- CLAUDE.md: document screenshot.sh + temporary boot-screen swap as
  the standard self-QA workflow for UI changes
This commit is contained in:
Hermann Björgvin Haraldsson
2026-05-10 00:38:04 +00:00
parent 38d8894016
commit 8b5878724f
3 changed files with 53 additions and 25 deletions
+6
View File
@@ -42,6 +42,12 @@ pio run -d firmware -t upload --upload-port /dev/ttyACM0 # flash (binary path u
Device shows up as `/dev/ttyACM0` (Espressif USB JTAG/serial debug unit). No boot-mode gymnastics needed — direct flash works. Device shows up as `/dev/ttyACM0` (Espressif USB JTAG/serial debug unit). No boot-mode gymnastics needed — direct flash works.
## QA your own UI changes — don't ask the user
The firmware ships a `screenshot` serial command that dumps the LVGL framebuffer over `/dev/ttyACM0`. `./screenshot.sh out.png /dev/ttyACM0` captures a 480×480 PNG. **Use this on every UI iteration** — Read the PNG with the Read tool, verify the change visually, iterate.
The boot screen is `SCREEN_SPLASH` and only advances on a physical button press, so a fresh flash will sit on the splash. To screenshot the screen you're actually editing without asking the user to press a button, **temporarily change the default boot screen** in `main.cpp` (search for `ui_show_screen(SCREEN_SPLASH);`) to `SCREEN_USAGE` / `SCREEN_CONTROLLER` / `SCREEN_BLUETOOTH`, do your iteration, then revert before committing.
## Critical gotchas ## Critical gotchas
1. **CO5300 cannot rotate.** Its MADCTL only supports axis flips, not column/row exchange. Rotation is done by **CPU pixel remapping in `my_flush_cb`** in main.cpp. We use **PARTIAL render mode with strip rotation** (small 480×40 strips, fast). On rotation change → AMOLED brightness flash → force redraw. 1. **CO5300 cannot rotate.** Its MADCTL only supports axis flips, not column/row exchange. Rotation is done by **CPU pixel remapping in `my_flush_cb`** in main.cpp. We use **PARTIAL render mode with strip rotation** (small 480×40 strips, fast). On rotation change → AMOLED brightness flash → force redraw.
+44 -23
View File
@@ -71,9 +71,9 @@ static screen_t current_screen = SCREEN_USAGE;
// Animation state // Animation state
static uint32_t anim_last_ms = 0; static uint32_t anim_last_ms = 0;
static uint8_t anim_spinner_idx = 0; static uint8_t anim_spinner_idx = 0;
static uint8_t anim_phase = 0;
static uint8_t anim_msg_idx = 0; static uint8_t anim_msg_idx = 0;
static uint32_t anim_msg_start = 0; static uint32_t anim_msg_start = 0;
#define ANIM_SPIN_MS 120
#define ANIM_MSG_MS 4000 #define ANIM_MSG_MS 4000
static const char* const spinner_frames[] = { static const char* const spinner_frames[] = {
@@ -81,6 +81,14 @@ static const char* const spinner_frames[] = {
"\xE2\x9C\xB6", "\xE2\x9C\xB3", "\xE2\x9C\xA2", "\xE2\x9C\xB6", "\xE2\x9C\xB3", "\xE2\x9C\xA2",
}; };
#define SPINNER_COUNT 6 #define SPINNER_COUNT 6
#define SPINNER_PHASES (2 * (SPINNER_COUNT - 1)) // 10: ping-pong 0..5..0
// Per-frame hold time. Modeled on Claude Code's spinner (Cavalry triangle
// oscillator, range 0..5, period 5s) — turn-around frames (0 and 5) appear
// once per cycle, middle frames twice, so 0/5 read as held longer.
static const uint16_t spinner_ms[SPINNER_COUNT] = {
260, 130, 130, 130, 130, 260,
};
static const char* const anim_messages[] = { static const char* const anim_messages[] = {
"Accomplishing", "Elucidating", "Perusing", "Accomplishing", "Elucidating", "Perusing",
@@ -237,6 +245,21 @@ static lv_obj_t* make_hint(lv_obj_t* parent, int x, int y, const char* text) {
return lbl; return lbl;
} }
static lv_obj_t* make_pill(lv_obj_t* parent, const char* text) {
lv_obj_t* lbl = lv_label_create(parent);
lv_label_set_text(lbl, text);
lv_obj_set_style_text_font(lbl, &font_styrene_28, 0);
lv_obj_set_style_text_color(lbl, COL_TEXT, 0);
lv_obj_set_style_bg_color(lbl, COL_BAR_BG, 0);
lv_obj_set_style_bg_opa(lbl, LV_OPA_COVER, 0);
lv_obj_set_style_radius(lbl, LV_RADIUS_CIRCLE, 0);
lv_obj_set_style_pad_left(lbl, 18, 0);
lv_obj_set_style_pad_right(lbl, 18, 0);
lv_obj_set_style_pad_top(lbl, 6, 0);
lv_obj_set_style_pad_bottom(lbl, 6, 0);
return lbl;
}
// ---- Battery icon initialization ---- // ---- Battery icon initialization ----
static void init_battery_icons(void) { static void init_battery_icons(void) {
init_icon_dsc_rgb565a8(&battery_dscs[0], ICON_BATTERY_W, ICON_BATTERY_H, icon_battery_data); init_icon_dsc_rgb565a8(&battery_dscs[0], ICON_BATTERY_W, ICON_BATTERY_H, icon_battery_data);
@@ -262,10 +285,10 @@ static void init_usage_screen(lv_obj_t* scr) {
// Title // Title
lbl_title = lv_label_create(usage_container); lbl_title = lv_label_create(usage_container);
lv_label_set_text(lbl_title, "Claude Usage"); lv_label_set_text(lbl_title, "Usage");
lv_obj_set_style_text_font(lbl_title, &font_tiempos_56, 0); lv_obj_set_style_text_font(lbl_title, &font_tiempos_56, 0);
lv_obj_set_style_text_color(lbl_title, COL_TEXT, 0); lv_obj_set_style_text_color(lbl_title, COL_TEXT, 0);
lv_obj_set_pos(lbl_title, 106, TITLE_Y); lv_obj_align(lbl_title, LV_ALIGN_TOP_MID, 0, TITLE_Y);
// Session panel // Session panel
lv_obj_t* p_session = make_panel(usage_container, MARGIN, CONTENT_Y, CONTENT_W, PANEL_H); lv_obj_t* p_session = make_panel(usage_container, MARGIN, CONTENT_Y, CONTENT_W, PANEL_H);
@@ -276,19 +299,18 @@ static void init_usage_screen(lv_obj_t* scr) {
lv_obj_set_style_text_color(lbl_session_pct, COL_TEXT, 0); lv_obj_set_style_text_color(lbl_session_pct, COL_TEXT, 0);
lv_obj_set_pos(lbl_session_pct, 0, 0); lv_obj_set_pos(lbl_session_pct, 0, 0);
bar_session = make_bar(p_session, 0, 56, CONTENT_W - 40, 24); lbl_session_label = make_pill(p_session, "Current");
// Symmetric: panel-outer-top → pill-top equals pill-bottom → bar-top.
// Pill height = font line_height(30) + pad_top(6) + pad_bottom(6) = 42; panel pad_top = 12; bar at y=56 → y_inner=1.
lv_obj_align(lbl_session_label, LV_ALIGN_TOP_RIGHT, 0, 1);
lbl_session_label = lv_label_create(p_session); bar_session = make_bar(p_session, 0, 56, CONTENT_W - 32, 24);
lv_label_set_text(lbl_session_label, "Current Session");
lv_obj_set_style_text_font(lbl_session_label, &font_styrene_28, 0);
lv_obj_set_style_text_color(lbl_session_label, COL_DIM, 0);
lv_obj_set_pos(lbl_session_label, 0, 94);
lbl_session_reset = lv_label_create(p_session); lbl_session_reset = lv_label_create(p_session);
lv_label_set_text(lbl_session_reset, "---"); lv_label_set_text(lbl_session_reset, "---");
lv_obj_set_style_text_font(lbl_session_reset, &font_styrene_28, 0); lv_obj_set_style_text_font(lbl_session_reset, &font_styrene_28, 0);
lv_obj_set_style_text_color(lbl_session_reset, COL_DIM, 0); lv_obj_set_style_text_color(lbl_session_reset, COL_DIM, 0);
lv_obj_align(lbl_session_reset, LV_ALIGN_TOP_RIGHT, 0, 94); lv_obj_set_pos(lbl_session_reset, 0, 94);
// Weekly panel // Weekly panel
int weekly_y = CONTENT_Y + PANEL_H + PANEL_GAP; int weekly_y = CONTENT_Y + PANEL_H + PANEL_GAP;
@@ -300,26 +322,23 @@ static void init_usage_screen(lv_obj_t* scr) {
lv_obj_set_style_text_color(lbl_weekly_pct, COL_TEXT, 0); lv_obj_set_style_text_color(lbl_weekly_pct, COL_TEXT, 0);
lv_obj_set_pos(lbl_weekly_pct, 0, 0); lv_obj_set_pos(lbl_weekly_pct, 0, 0);
bar_weekly = make_bar(p_weekly, 0, 56, CONTENT_W - 40, 24); lbl_weekly_label = make_pill(p_weekly, "Weekly");
lv_obj_align(lbl_weekly_label, LV_ALIGN_TOP_RIGHT, 0, 1);
lbl_weekly_label = lv_label_create(p_weekly); bar_weekly = make_bar(p_weekly, 0, 56, CONTENT_W - 32, 24);
lv_label_set_text(lbl_weekly_label, "Current Week");
lv_obj_set_style_text_font(lbl_weekly_label, &font_styrene_28, 0);
lv_obj_set_style_text_color(lbl_weekly_label, COL_DIM, 0);
lv_obj_set_pos(lbl_weekly_label, 0, 94);
lbl_weekly_reset = lv_label_create(p_weekly); lbl_weekly_reset = lv_label_create(p_weekly);
lv_label_set_text(lbl_weekly_reset, "---"); lv_label_set_text(lbl_weekly_reset, "---");
lv_obj_set_style_text_font(lbl_weekly_reset, &font_styrene_28, 0); lv_obj_set_style_text_font(lbl_weekly_reset, &font_styrene_28, 0);
lv_obj_set_style_text_color(lbl_weekly_reset, COL_DIM, 0); lv_obj_set_style_text_color(lbl_weekly_reset, COL_DIM, 0);
lv_obj_align(lbl_weekly_reset, LV_ALIGN_TOP_RIGHT, 0, 94); lv_obj_set_pos(lbl_weekly_reset, 0, 94);
// Animation // Animation
lbl_anim = lv_label_create(usage_container); lbl_anim = lv_label_create(usage_container);
lv_label_set_text(lbl_anim, ""); lv_label_set_text(lbl_anim, "");
lv_obj_set_style_text_font(lbl_anim, &font_mono_32, 0); lv_obj_set_style_text_font(lbl_anim, &font_mono_32, 0);
lv_obj_set_style_text_color(lbl_anim, COL_ACCENT, 0); lv_obj_set_style_text_color(lbl_anim, COL_ACCENT, 0);
lv_obj_align(lbl_anim, LV_ALIGN_BOTTOM_MID, 0, -24); lv_obj_align(lbl_anim, LV_ALIGN_BOTTOM_MID, 0, -15);
} }
// ======== Controller Screen (480x480) ======== // ======== Controller Screen (480x480) ========
@@ -340,10 +359,10 @@ static void init_controller_screen(lv_obj_t* scr) {
// Title // Title
lv_obj_t* lbl_ctrl_title = lv_label_create(ctrl_container); lv_obj_t* lbl_ctrl_title = lv_label_create(ctrl_container);
lv_label_set_text(lbl_ctrl_title, "Claude Controller"); lv_label_set_text(lbl_ctrl_title, "Controller");
lv_obj_set_style_text_font(lbl_ctrl_title, &font_tiempos_56, 0); lv_obj_set_style_text_font(lbl_ctrl_title, &font_tiempos_56, 0);
lv_obj_set_style_text_color(lbl_ctrl_title, COL_TEXT, 0); lv_obj_set_style_text_color(lbl_ctrl_title, COL_TEXT, 0);
lv_obj_set_pos(lbl_ctrl_title, 66, TITLE_Y); lv_obj_align(lbl_ctrl_title, LV_ALIGN_TOP_MID, 0, TITLE_Y);
// Initialize icon descriptors // Initialize icon descriptors
init_icon_dsc(&icon_escape_dsc, ICON_ESCAPE_W, ICON_ESCAPE_H, icon_escape_data); init_icon_dsc(&icon_escape_dsc, ICON_ESCAPE_W, ICON_ESCAPE_H, icon_escape_data);
@@ -445,7 +464,7 @@ static void init_bluetooth_screen(lv_obj_t* scr) {
lv_label_set_text(lbl_ble_title, "Bluetooth"); lv_label_set_text(lbl_ble_title, "Bluetooth");
lv_obj_set_style_text_font(lbl_ble_title, &font_tiempos_56, 0); lv_obj_set_style_text_font(lbl_ble_title, &font_tiempos_56, 0);
lv_obj_set_style_text_color(lbl_ble_title, COL_TEXT, 0); lv_obj_set_style_text_color(lbl_ble_title, COL_TEXT, 0);
lv_obj_set_pos(lbl_ble_title, 66, TITLE_Y); lv_obj_align(lbl_ble_title, LV_ALIGN_TOP_MID, 0, TITLE_Y);
// Info panel (taller for 480x480) // Info panel (taller for 480x480)
lv_obj_t* p_info = make_panel(ble_container, MARGIN, CONTENT_Y, CONTENT_W, 160); lv_obj_t* p_info = make_panel(ble_container, MARGIN, CONTENT_Y, CONTENT_W, 160);
@@ -587,9 +606,11 @@ void ui_tick_anim(void) {
anim_msg_start = now; anim_msg_start = now;
} }
if (now - anim_last_ms >= ANIM_SPIN_MS) { if (now - anim_last_ms >= spinner_ms[anim_spinner_idx]) {
anim_last_ms = now; anim_last_ms = now;
anim_spinner_idx = (anim_spinner_idx + 1) % SPINNER_COUNT; anim_phase = (anim_phase + 1) % SPINNER_PHASES;
anim_spinner_idx = (anim_phase < SPINNER_COUNT) ? anim_phase
: (SPINNER_PHASES - anim_phase);
static char buf[80]; static char buf[80];
snprintf(buf, sizeof(buf), "%s %s\xE2\x80\xA6", snprintf(buf, sizeof(buf), "%s %s\xE2\x80\xA6",
+3 -2
View File
@@ -1,5 +1,5 @@
#!/bin/bash #!/bin/bash
# Take a screenshot from the SC01 Plus display via LVGL snapshot. # Take a screenshot from the Waveshare AMOLED display via LVGL snapshot.
# Usage: ./screenshot.sh [output.png] [port] # Usage: ./screenshot.sh [output.png] [port]
OUTPUT="${1:-screenshot.png}" OUTPUT="${1:-screenshot.png}"
@@ -55,9 +55,10 @@ if [ $? -ne 0 ]; then
exit 1 exit 1
fi fi
ffmpeg -y -f rawvideo -pixel_format rgb565le -video_size 480x320 \ ffmpeg -y -f rawvideo -pixel_format rgb565le -video_size 480x480 \
-i "$TMPRAW" -update 1 -frames:v 1 "$OUTPUT" 2>/dev/null || true -i "$TMPRAW" -update 1 -frames:v 1 "$OUTPUT" 2>/dev/null || true
if [ -f "$OUTPUT" ]; then if [ -f "$OUTPUT" ]; then
echo "Saved: $OUTPUT" echo "Saved: $OUTPUT"
else else