diff --git a/AGENTS.md b/AGENTS.md index 3220c75..c6cdcce 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,7 +13,9 @@ - Explain meaningful design choices in plain language so maintainers can review and modify the code. - Prefer small, readable changes over broad rewrites or generated abstractions. -- Ask before changing hardware pin assignments, partition layouts, storage formats, or dependencies. +- Ask before changing hardware pin assignments, partition layouts, storage formats, + or non-UI dependencies. Focused UI libraries are allowed when they materially + improve the on-device experience; document why they are needed. - Never commit secrets, Wi-Fi credentials, private keys, or device-specific provisioning data. ## Code conventions diff --git a/README.md b/README.md index f8498b3..08c6a23 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,8 @@ The device asks which buddy to start before enabling Bluetooth: | Claude Owl page | Claude Activity page | Claude Clock page | Claude Info page | These previews come from the same 320 x 240 RGB565 renderers used by the -physical display. +physical display, including LVGL's anti-aliased Montserrat fonts. Regenerate +them with `sh scripts/render_ui_previews.sh` after UI changes. ## Using Codex mode diff --git a/components/buddy_ui/CMakeLists.txt b/components/buddy_ui/CMakeLists.txt index e6c1e46..bddb57d 100644 --- a/components/buddy_ui/CMakeLists.txt +++ b/components/buddy_ui/CMakeLists.txt @@ -1,5 +1,6 @@ idf_component_register(SRCS "buddy_ui.cpp" INCLUDE_DIRS "include" - REQUIRES claude_model) + REQUIRES claude_model ui_font) target_compile_options(${COMPONENT_LIB} PRIVATE -Wall -Wextra -Werror) +target_compile_definitions(${COMPONENT_LIB} PRIVATE BUDDY_USE_LVGL_FONT=1) diff --git a/components/buddy_ui/buddy_ui.cpp b/components/buddy_ui/buddy_ui.cpp index 6a3350c..7199e28 100644 --- a/components/buddy_ui/buddy_ui.cpp +++ b/components/buddy_ui/buddy_ui.cpp @@ -5,6 +5,10 @@ #include #include +#ifdef BUDDY_USE_LVGL_FONT +#include "ui_font.hpp" +#endif + namespace buddy::display { constexpr std::uint16_t rgb565(unsigned red, unsigned green, @@ -14,19 +18,20 @@ constexpr std::uint16_t rgb565(unsigned red, unsigned green, ((red & 0xf8U) << 8) | ((green & 0xfcU) << 3) | (blue >> 3)); } -constexpr auto Background = rgb565(9, 13, 22); -constexpr auto Panel = rgb565(22, 30, 45); -constexpr auto Text = rgb565(242, 246, 252); -constexpr auto Muted = rgb565(146, 159, 180); -constexpr auto Dim = rgb565(76, 89, 108); -constexpr auto CodexColor = rgb565(45, 145, 235); -constexpr auto ClaudeColor = rgb565(215, 118, 76); -constexpr auto Owl = rgb565(184, 125, 73); -constexpr auto OwlLight = rgb565(233, 195, 133); -constexpr auto Amber = rgb565(245, 170, 40); -constexpr auto Green = rgb565(35, 190, 100); -constexpr auto Red = rgb565(230, 65, 75); -constexpr auto Cyan = rgb565(45, 190, 225); +constexpr auto Background = rgb565(16, 19, 24); +constexpr auto Panel = rgb565(25, 30, 37); +constexpr auto Outline = rgb565(53, 61, 71); +constexpr auto Text = rgb565(199, 206, 214); +constexpr auto Muted = rgb565(145, 155, 167); +constexpr auto Dim = rgb565(82, 92, 104); +constexpr auto CodexColor = rgb565(75, 135, 188); +constexpr auto ClaudeColor = rgb565(181, 105, 72); +constexpr auto Owl = rgb565(161, 111, 69); +constexpr auto OwlLight = rgb565(206, 174, 119); +constexpr auto Amber = rgb565(181, 132, 68); +constexpr auto Green = rgb565(67, 146, 101); +constexpr auto Red = rgb565(181, 83, 89); +constexpr auto Cyan = rgb565(73, 153, 174); constexpr int TabTop = 210; struct Canvas { @@ -104,6 +109,16 @@ static void box(Canvas *canvas, int x, int y, int width, int height, rounded(canvas, x + 2, y + 2, width - 4, height - 4, 6, fill); } +/* Draws a quiet surface card with a short semantic accent along its top. */ +static void card(Canvas *canvas, int x, int y, int width, int height, + uint16_t accent) +{ + rounded(canvas, x, y, width, height, 8, Outline); + rounded(canvas, x + 1, y + 1, width - 2, height - 2, 7, Panel); + rounded(canvas, x + 18, y + 1, width - 36, 4, 2, accent); +} + +#ifndef BUDDY_USE_LVGL_FONT /* Returns the five-column bitmap for a supported character or fallback glyph. */ static const uint8_t *glyph(char value) { @@ -148,12 +163,17 @@ static const uint8_t *glyph(char value) if (value == '!') return exclamation; return blank; } +#endif /* Calculates pixel width for fixed 5x7 glyphs plus spacing. */ static int text_width(const char *text, int scale) { +#ifdef BUDDY_USE_LVGL_FONT + return text == nullptr ? 0 : buddy::font::textWidth(text, scale); +#else return text == nullptr || text[0] == '\0' ? 0 : static_cast(strlen(text)) * 6 * scale - scale; +#endif } /* Rasterizes scaled bitmap text at the requested top-left coordinate. */ @@ -161,6 +181,10 @@ static void text(Canvas *canvas, const char *value, int x, int y, int scale, uint16_t color) { if (value == nullptr) return; +#ifdef BUDDY_USE_LVGL_FONT + buddy::font::draw({canvas->pixels, PixelCount}, Width, Height, value, + x, y, scale, color); +#else for (; *value != '\0'; ++value, x += 6 * scale) { const uint8_t *columns = glyph(*value); for (int column = 0; column < 5; ++column) @@ -169,6 +193,7 @@ static void text(Canvas *canvas, const char *value, int x, int y, int scale, rect(canvas, x + column * scale, y + row * scale, scale, scale, color); } +#endif } /* Centers bitmap text horizontally around a requested coordinate. */ @@ -212,13 +237,13 @@ void renderSelector(std::span pixels) noexcept centered(&canvas, "CHOOSE YOUR BUDDY", 160, 18, 2, Text); centered(&canvas, "BLUETOOTH STARTS AFTER SELECTION", 160, 43, 1, Muted); - box(&canvas, 12, 66, 142, 135, CodexColor, Panel); + card(&canvas, 12, 66, 142, 135, CodexColor); centered(&canvas, "CODEX", 83, 84, 3, Text); centered(&canvas, "6 AGENTS", 83, 124, 2, CodexColor); centered(&canvas, "CHATGPT", 83, 158, 1, Muted); centered(&canvas, "TAP TO START", 83, 179, 1, Text); - box(&canvas, 166, 66, 142, 135, ClaudeColor, Panel); + card(&canvas, 166, 66, 142, 135, ClaudeColor); draw_mini_owl(&canvas, 237, 99, Amber); centered(&canvas, "CLAUDE", 237, 124, 2, Text); centered(&canvas, "OWL BUDDY", 237, 151, 1, ClaudeColor); @@ -329,17 +354,16 @@ static void draw_owl(Canvas *canvas, buddy::claude::PetState state, } } -/* Draws the Claude title and live/secure connection badges. */ +/* Draws the Claude title and live connection status. */ static void draw_header(Canvas *canvas, const buddy::claude::Model *model, bool battery_known, uint8_t battery_percent, bool charging) { rect(canvas, 0, 0, Width, 30, Panel); text(canvas, "CLAUDE", 7, 8, 2, Text); - draw_mini_owl(canvas, 88, 15, ClaudeColor); const bool connected = model->connection == buddy::claude::Connection::Connected; - circle(canvas, 239, 15, 4, connected ? Green : Dim); - text(canvas, connected ? "LIVE" : "PAIR", 249, 12, 1, + circle(canvas, 247, 15, 4, connected ? Green : Dim); + text(canvas, connected ? "LIVE" : "PAIR", 257, 12, 1, connected ? Text : Muted); if (battery_known) { char battery[8]; @@ -443,9 +467,8 @@ static void draw_clock_page(Canvas *canvas, const buddy::claude::Model *model, { int64_t local = 0; if (!buddy::claude::clockSeconds(*model, now_ms, local) || local < 0) { - draw_mini_owl(canvas, 160, 87, ClaudeColor); - centered(canvas, "WAITING FOR TIME", 160, 121, 2, Text); - centered(canvas, "CLAUDE WILL SYNC THE CLOCK", 160, 151, 1, Muted); + centered(canvas, "WAITING FOR TIME", 160, 87, 2, Text); + centered(canvas, "CLAUDE WILL SYNC THE CLOCK", 160, 121, 1, Muted); return; } const int64_t days = local / 86400; @@ -471,7 +494,6 @@ static void draw_clock_page(Canvas *canvas, const buddy::claude::Model *model, snprintf(date, sizeof(date), "%s %04d-%02u-%02u", weekdays[weekday], year, month, day); centered(canvas, date, 160, 132, 2, OwlLight); - draw_mini_owl(canvas, 160, 178, ClaudeColor); } /* Draws device identity, decision statistics, and the mode-switch control. */ diff --git a/components/codex_ui/CMakeLists.txt b/components/codex_ui/CMakeLists.txt index 32920be..dcd34be 100644 --- a/components/codex_ui/CMakeLists.txt +++ b/components/codex_ui/CMakeLists.txt @@ -1,5 +1,6 @@ idf_component_register(SRCS "codex_ui.cpp" INCLUDE_DIRS "include" - REQUIRES codex_input codex_model) + REQUIRES codex_input codex_model ui_font) target_compile_options(${COMPONENT_LIB} PRIVATE -Wall -Wextra -Werror) +target_compile_definitions(${COMPONENT_LIB} PRIVATE BUDDY_USE_LVGL_FONT=1) diff --git a/components/codex_ui/codex_ui.cpp b/components/codex_ui/codex_ui.cpp index b8e5d24..611bfb0 100644 --- a/components/codex_ui/codex_ui.cpp +++ b/components/codex_ui/codex_ui.cpp @@ -3,6 +3,10 @@ #include #include +#ifdef BUDDY_USE_LVGL_FONT +#include "ui_font.hpp" +#endif + namespace buddy::codex::ui { constexpr std::uint16_t rgb565(unsigned red, unsigned green, @@ -12,23 +16,24 @@ constexpr std::uint16_t rgb565(unsigned red, unsigned green, ((red & 0xf8U) << 8) | ((green & 0xfcU) << 3) | (blue >> 3)); } -constexpr auto Background = rgb565(7, 11, 18); -constexpr auto Panel = rgb565(19, 27, 40); -constexpr auto PanelPressed = rgb565(42, 56, 76); -constexpr auto PanelDisabled = rgb565(14, 20, 29); -constexpr auto Text = rgb565(242, 246, 252); -constexpr auto Muted = rgb565(150, 162, 181); -constexpr auto Dim = rgb565(83, 94, 111); -constexpr auto Accent = rgb565(45, 145, 235); -constexpr auto Cyan = rgb565(45, 190, 225); -constexpr auto Green = rgb565(35, 190, 100); -constexpr auto Red = rgb565(230, 65, 75); -constexpr auto Amber = rgb565(245, 170, 40); -constexpr auto TintBlue = rgb565(15, 42, 65); -constexpr auto TintCyan = rgb565(13, 48, 62); -constexpr auto TintGreen = rgb565(12, 47, 31); -constexpr auto TintRed = rgb565(54, 22, 29); -constexpr auto TintAmber = rgb565(55, 39, 17); +constexpr auto Background = rgb565(16, 19, 24); +constexpr auto Panel = rgb565(25, 30, 37); +constexpr auto PanelPressed = rgb565(41, 49, 59); +constexpr auto PanelDisabled = rgb565(20, 24, 30); +constexpr auto Outline = rgb565(53, 61, 71); +constexpr auto Text = rgb565(199, 206, 214); +constexpr auto Muted = rgb565(145, 155, 167); +constexpr auto Dim = rgb565(82, 92, 104); +constexpr auto Accent = rgb565(75, 135, 188); +constexpr auto Cyan = rgb565(73, 153, 174); +constexpr auto Green = rgb565(67, 146, 101); +constexpr auto Red = rgb565(181, 83, 89); +constexpr auto Amber = rgb565(181, 132, 68); +constexpr auto TintBlue = rgb565(23, 36, 48); +constexpr auto TintCyan = rgb565(22, 39, 46); +constexpr auto TintGreen = rgb565(22, 38, 30); +constexpr auto TintRed = rgb565(43, 27, 31); +constexpr auto TintAmber = rgb565(43, 35, 23); constexpr int TabTop = 210; struct Canvas { @@ -87,6 +92,7 @@ static void rounded_box(Canvas *canvas, int x, int y, int width, int height, radius - thickness, background); } +#ifndef BUDDY_USE_LVGL_FONT /* Returns the five-column bitmap for a supported character or fallback glyph. */ static const uint8_t *glyph(char value) { @@ -121,12 +127,17 @@ static const uint8_t *glyph(char value) if (value == '/') return slash; return blank; } +#endif /* Calculates pixel width for fixed 5x7 glyphs plus one-column spacing. */ static int text_width(const char *text, int scale) { +#ifdef BUDDY_USE_LVGL_FONT + return text == nullptr ? 0 : buddy::font::textWidth(text, scale); +#else return text == nullptr ? 0 : static_cast(strlen(text)) * 6 * scale - scale; +#endif } /* Rasterizes scaled bitmap text from left to right at a baseline origin. */ @@ -134,6 +145,10 @@ static void draw_text(Canvas *canvas, const char *text, int x, int y, int scale, uint16_t color) { if (text == nullptr || scale <= 0) return; +#ifdef BUDDY_USE_LVGL_FONT + buddy::font::draw({canvas->pixels, PixelCount}, Width, Height, text, + x, y, scale, color); +#else for (; *text != '\0'; ++text, x += 6 * scale) { const uint8_t *columns = glyph(*text); for (int column = 0; column < 5; ++column) @@ -142,6 +157,7 @@ static void draw_text(Canvas *canvas, const char *text, int x, int y, fill_rect(canvas, x + column * scale, y + row * scale, scale, scale, color); } +#endif } /* Centers bitmap text horizontally around a requested x coordinate. */ @@ -184,10 +200,12 @@ static void button(Canvas *canvas, int x, int y, int width, int height, const char *label, const char *hint, uint16_t border, uint16_t background, bool pressed, bool enabled) { - rounded_box(canvas, x, y, width, height, 7, 2, - enabled ? border : Dim, + rounded_box(canvas, x, y, width, height, 7, 1, + enabled ? (pressed ? border : Outline) : Dim, enabled ? (pressed ? PanelPressed : background) : PanelDisabled); + if (enabled) + fill_rounded_rect(canvas, x + 12, y + 1, width - 24, 3, 1, border); const int scale = strlen(label) > 8 ? 1 : 2; centered_text(canvas, label, x + width / 2, y + (hint == nullptr ? height / 2 - 7 * scale / 2 : height / 2 - 13), @@ -230,20 +248,20 @@ static uint16_t slot_accent(const Slot *slot, uint32_t time_ms) { /* Stable semantic colors keep status readable across host lighting themes. Unknown future states retain the raw host color as a fallback. */ - unsigned red = 83; - unsigned green = 94; - unsigned blue = 111; + unsigned red = 82; + unsigned green = 92; + unsigned blue = 104; switch (slot->status) { - case SlotStatus::Idle: red = 45; green = 145; blue = 235; break; - case SlotStatus::Thinking: red = 45; green = 190; blue = 225; break; - case SlotStatus::Complete: red = 35; green = 190; blue = 100; break; - case SlotStatus::RequiresInput: red = 245; green = 170; blue = 40; break; - case SlotStatus::Error: red = 230; green = 65; blue = 75; break; + case SlotStatus::Idle: red = 75; green = 135; blue = 188; break; + case SlotStatus::Thinking: red = 73; green = 153; blue = 174; break; + case SlotStatus::Complete: red = 67; green = 146; blue = 101; break; + case SlotStatus::RequiresInput: red = 181; green = 132; blue = 68; break; + case SlotStatus::Error: red = 181; green = 83; blue = 89; break; case SlotStatus::Unknown: red = (slot->color >> 16) & 0xffU; green = (slot->color >> 8) & 0xffU; blue = slot->color & 0xffU; - if (red + green + blue < 80U) red = green = blue = 111U; + if (red + green + blue < 80U) red = green = blue = 104U; break; case SlotStatus::Unassigned: break; } @@ -292,7 +310,7 @@ static void draw_header(Canvas *canvas, const Model *model) const uint16_t connection_color = connected ? Green : model->connection == Connection::Connecting ? Amber : Dim; - rounded_box(canvas, 245, 6, 68, 18, 8, 1, Dim, Background); + rounded_box(canvas, 245, 6, 68, 18, 8, 1, Outline, Background); fill_circle(canvas, 256, 15, 4, connection_color); draw_text(canvas, connected ? "LIVE" : "PAIR", 266, 12, 1, connected ? Text : Muted); @@ -368,21 +386,20 @@ static void draw_agents(Canvas *canvas, const Model *model, const Slot *slot = &model->slots[index]; const uint16_t accent = slot_accent(slot, time_ms); const bool enabled = model->connection == Connection::Connected; - rounded_box(canvas, x, y, 100, 76, 7, 2, - enabled ? accent : Dim, + rounded_box(canvas, x, y, 100, 76, 7, 1, + enabled ? (model->selectedSlot == index ? Text : Outline) + : Dim, action_matches(active, ActionType::Slot, index) ? PanelPressed : slot_background(slot->status)); - fill_rect(canvas, x + 2, y + 9, 4, 58, accent); + fill_rounded_rect(canvas, x + 12, y + 1, 76, 3, 1, accent); draw_text(canvas, number, x + 12, y + 9, 1, accent); fill_circle(canvas, x + 88, y + 13, 4, accent); centered_text(canvas, label, x + 50, y + 28, 2, enabled ? Text : Muted); centered_text(canvas, slotStatusLabel(slot->status), x + 50, y + 58, 1, enabled ? accent : Dim); - if (model->selectedSlot == index) { - fill_rect(canvas, x + 78, y, 22, 3, Text); - fill_rect(canvas, x + 97, y, 3, 20, Text); - } + if (model->selectedSlot == index) + fill_circle(canvas, x + 88, y + 13, 2, Text); } } diff --git a/components/platform_core_s3/platform_core_s3.cpp b/components/platform_core_s3/platform_core_s3.cpp index f9d3fc5..00f5d16 100644 --- a/components/platform_core_s3/platform_core_s3.cpp +++ b/components/platform_core_s3/platform_core_s3.cpp @@ -18,6 +18,7 @@ namespace buddy::platform { static const char *TAG = "platform_core_s3"; +static constexpr int ComfortableBrightness = 25; static esp_lcd_panel_handle_t s_panel; static esp_lcd_panel_io_handle_t s_panel_io; static esp_lcd_touch_handle_t s_touch; @@ -78,7 +79,7 @@ esp_err_t initialize() noexcept s_panel_io, &callbacks, nullptr)) != ESP_OK || (error = bsp_display_brightness_init()) != ESP_OK || (error = esp_lcd_panel_disp_on_off(s_panel, true)) != ESP_OK || - (error = bsp_display_backlight_on()) != ESP_OK || + (error = bsp_display_brightness_set(ComfortableBrightness)) != ESP_OK || (error = bsp_touch_new(nullptr, &s_touch)) != ESP_OK) { ESP_LOGE(TAG, "Display/touch initialization failed: %s", esp_err_to_name(error)); @@ -126,9 +127,10 @@ esp_err_t setDisplayAwake(bool awake) noexcept esp_err_t error; if (awake) { error = esp_lcd_panel_disp_on_off(s_panel, true); - if (error == ESP_OK) error = bsp_display_backlight_on(); + if (error == ESP_OK) + error = bsp_display_brightness_set(ComfortableBrightness); } else { - error = bsp_display_backlight_off(); + error = bsp_display_brightness_set(0); if (error == ESP_OK) error = esp_lcd_panel_disp_on_off(s_panel, false); } return error; diff --git a/components/ui_font/CMakeLists.txt b/components/ui_font/CMakeLists.txt new file mode 100644 index 0000000..0f15270 --- /dev/null +++ b/components/ui_font/CMakeLists.txt @@ -0,0 +1,5 @@ +idf_component_register(SRCS "ui_font.cpp" + INCLUDE_DIRS "include" + REQUIRES lvgl) + +target_compile_options(${COMPONENT_LIB} PRIVATE -Wall -Wextra -Werror) diff --git a/components/ui_font/include/ui_font.hpp b/components/ui_font/include/ui_font.hpp new file mode 100644 index 0000000..7d8a873 --- /dev/null +++ b/components/ui_font/include/ui_font.hpp @@ -0,0 +1,16 @@ +#pragma once + +#include +#include +#include + +namespace buddy::font { + +/* Measures and rasterizes LVGL Montserrat text into an RGB565 framebuffer. */ +[[nodiscard]] int textWidth(std::string_view value, int scale) noexcept; + +void draw(std::span pixels, int canvasWidth, int canvasHeight, + std::string_view value, int x, int y, int scale, + std::uint16_t color) noexcept; + +} // namespace buddy::font diff --git a/components/ui_font/ui_font.cpp b/components/ui_font/ui_font.cpp new file mode 100644 index 0000000..a8b1a5d --- /dev/null +++ b/components/ui_font/ui_font.cpp @@ -0,0 +1,108 @@ +#include "ui_font.hpp" + +#include + +#include "lvgl.h" + +namespace buddy::font { +namespace { + +const lv_font_t *fontForScale(int scale) noexcept +{ + if (scale <= 1) return &lv_font_montserrat_10; + if (scale == 2) return &lv_font_montserrat_14; + if (scale == 3) return &lv_font_montserrat_20; + return &lv_font_montserrat_28; +} + +bool glyphDescriptor(const lv_font_t *font, unsigned char letter, + unsigned char next, lv_font_glyph_dsc_t &glyph) noexcept +{ + glyph = {}; + if (!font->get_glyph_dsc(font, &glyph, letter, next)) return false; + glyph.resolved_font = font; + return true; +} + +std::uint8_t alphaAt(const std::uint8_t *bitmap, + const lv_font_glyph_dsc_t &glyph, int x, int y) noexcept +{ + const unsigned bits = static_cast(glyph.format); + if (bits != 1 && bits != 2 && bits != 4 && bits != 8) return 0; + const std::size_t bit = glyph.stride != 0 + ? static_cast(y) * glyph.stride * 8U + x * bits + : (static_cast(y) * glyph.box_w + x) * bits; + const unsigned shift = 8U - bits - static_cast(bit & 7U); + const unsigned mask = (1U << bits) - 1U; + const unsigned sample = (bitmap[bit / 8U] >> shift) & mask; + return static_cast(sample * 255U / mask); +} + +std::uint16_t blend(std::uint16_t background, std::uint16_t foreground, + std::uint8_t alpha) noexcept +{ + const unsigned inverse = 255U - alpha; + const unsigned red = (((foreground >> 11) & 0x1fU) * alpha + + ((background >> 11) & 0x1fU) * inverse + 127U) / 255U; + const unsigned green = (((foreground >> 5) & 0x3fU) * alpha + + ((background >> 5) & 0x3fU) * inverse + 127U) / 255U; + const unsigned blue = ((foreground & 0x1fU) * alpha + + (background & 0x1fU) * inverse + 127U) / 255U; + return static_cast((red << 11) | (green << 5) | blue); +} + +} // namespace + +int textWidth(std::string_view value, int scale) noexcept +{ + const lv_font_t *font = fontForScale(scale); + int width = 0; + for (std::size_t index = 0; index < value.size(); ++index) { + lv_font_glyph_dsc_t glyph; + const auto letter = static_cast(value[index]); + const auto next = index + 1 < value.size() + ? static_cast(value[index + 1]) : 0; + if (glyphDescriptor(font, letter, next, glyph)) width += glyph.adv_w; + } + return width; +} + +void draw(std::span pixels, int canvasWidth, int canvasHeight, + std::string_view value, int x, int y, int scale, + std::uint16_t color) noexcept +{ + if (canvasWidth <= 0 || canvasHeight <= 0 || + pixels.size() < static_cast(canvasWidth * canvasHeight)) + return; + const lv_font_t *font = fontForScale(scale); + for (std::size_t index = 0; index < value.size(); ++index) { + lv_font_glyph_dsc_t glyph; + const auto letter = static_cast(value[index]); + const auto next = index + 1 < value.size() + ? static_cast(value[index + 1]) : 0; + if (!glyphDescriptor(font, letter, next, glyph)) continue; + glyph.req_raw_bitmap = 1; + const auto *bitmap = static_cast( + font->get_glyph_bitmap(&glyph, nullptr)); + const int glyphX = x + glyph.ofs_x; + const int glyphY = y + font->line_height - font->base_line - + glyph.box_h - glyph.ofs_y; + if (bitmap != nullptr) { + for (int row = 0; row < glyph.box_h; ++row) { + const int targetY = glyphY + row; + if (targetY < 0 || targetY >= canvasHeight) continue; + for (int column = 0; column < glyph.box_w; ++column) { + const int targetX = glyphX + column; + if (targetX < 0 || targetX >= canvasWidth) continue; + const std::uint8_t alpha = alphaAt(bitmap, glyph, column, row); + if (alpha == 0) continue; + auto &pixel = pixels[targetY * canvasWidth + targetX]; + pixel = blend(pixel, color, alpha); + } + } + } + x += glyph.adv_w; + } +} + +} // namespace buddy::font diff --git a/dependencies.lock b/dependencies.lock index a704b8f..70179f7 100644 --- a/dependencies.lock +++ b/dependencies.lock @@ -159,9 +159,17 @@ dependencies: source: type: idf version: 6.0.1 + lvgl/lvgl: + component_hash: 184e532558c1c45fefed631f3e235423d22582aafb4630f3e8885c35281a49ae + dependencies: [] + source: + registry_url: https://components.espressif.com/ + type: service + version: 9.5.0 direct_dependencies: - espressif/bmi270 - espressif/m5stack_core_s3_noglib -manifest_hash: 40a4aab284508c1dedc714650907563cf3c3f4f3be1df11f3241510dc1d7701e +- lvgl/lvgl +manifest_hash: 7828239a68b74b2b81a0c78591bf53f404f94dd833867143983e0d36145ad5e1 target: esp32s3 version: 2.0.0 diff --git a/docs/images/buddy-selector.png b/docs/images/buddy-selector.png index a2fbc59..7ea6ae4 100644 Binary files a/docs/images/buddy-selector.png and b/docs/images/buddy-selector.png differ diff --git a/docs/images/claude-activity.png b/docs/images/claude-activity.png index 8c3b740..74162bb 100644 Binary files a/docs/images/claude-activity.png and b/docs/images/claude-activity.png differ diff --git a/docs/images/claude-clock.png b/docs/images/claude-clock.png index b12d096..b3846d3 100644 Binary files a/docs/images/claude-clock.png and b/docs/images/claude-clock.png differ diff --git a/docs/images/claude-info.png b/docs/images/claude-info.png index 8729eb2..49007dd 100644 Binary files a/docs/images/claude-info.png and b/docs/images/claude-info.png differ diff --git a/docs/images/claude-owl.png b/docs/images/claude-owl.png index 99b8edf..41baa75 100644 Binary files a/docs/images/claude-owl.png and b/docs/images/claude-owl.png differ diff --git a/docs/images/codex-agents.png b/docs/images/codex-agents.png index 0bb56d7..6b25bd6 100644 Binary files a/docs/images/codex-agents.png and b/docs/images/codex-agents.png differ diff --git a/docs/images/codex-control.png b/docs/images/codex-control.png index c545ce8..51274ef 100644 Binary files a/docs/images/codex-control.png and b/docs/images/codex-control.png differ diff --git a/docs/images/codex-navigate.png b/docs/images/codex-navigate.png index 9bea339..cff140c 100644 Binary files a/docs/images/codex-navigate.png and b/docs/images/codex-navigate.png differ diff --git a/main/idf_component.yml b/main/idf_component.yml index fdf75a0..fd0baea 100644 --- a/main/idf_component.yml +++ b/main/idf_component.yml @@ -1,3 +1,4 @@ dependencies: espressif/m5stack_core_s3_noglib: "3.0.2" espressif/bmi270: "1.1.0" + lvgl/lvgl: "^9.5.0" diff --git a/scripts/render_ui_previews.sh b/scripts/render_ui_previews.sh new file mode 100644 index 0000000..227e004 --- /dev/null +++ b/scripts/render_ui_previews.sh @@ -0,0 +1,18 @@ +#!/bin/sh +set -eu + +script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +project_dir=$(CDPATH= cd -- "$script_dir/.." && pwd) +work_dir=$(mktemp -d "${TMPDIR:-/tmp}/codex-buddy-previews.XXXXXX") +trap 'rm -rf "$work_dir"' EXIT HUP INT TERM + +cmake -S "$project_dir/test/host/ui_preview" -B "$work_dir/build" \ + -DCMAKE_BUILD_TYPE=Release +cmake --build "$work_dir/build" --target render_buddy_ui --parallel +"$work_dir/build/render_buddy_ui" "$work_dir" + +for source in "$work_dir"/*.ppm; do + name=$(basename "$source" .ppm) + sips -s format png "$source" \ + --out "$project_dir/docs/images/$name.png" >/dev/null +done diff --git a/sdkconfig.defaults b/sdkconfig.defaults index d2ec885..52ee7b1 100644 --- a/sdkconfig.defaults +++ b/sdkconfig.defaults @@ -12,3 +12,11 @@ CONFIG_SPIRAM_USE_MALLOC=y # The control-plane JSON parser uses a fixed token table while handling the # multi-report lighting configuration sent at connection time. CONFIG_ESP_MAIN_TASK_STACK_SIZE=8192 + +# The framebuffer renderers use LVGL's anti-aliased Montserrat glyph data +# without requiring the LVGL object/display lifecycle. +CONFIG_LV_CONF_MINIMAL=y +CONFIG_LV_FONT_MONTSERRAT_10=y +CONFIG_LV_FONT_MONTSERRAT_14=y +CONFIG_LV_FONT_MONTSERRAT_20=y +CONFIG_LV_FONT_MONTSERRAT_28=y diff --git a/test/host/test_codex_ui.cpp b/test/host/test_codex_ui.cpp index 45c8876..85f4995 100644 --- a/test/host/test_codex_ui.cpp +++ b/test/host/test_codex_ui.cpp @@ -90,15 +90,15 @@ int main(void) Event event = ConnectionChanged{Connection::Connected}; assert(applyEvent(model, event)); buddy::codex::ui::render(model, nullptr, 0, storage); - assert(pixel_at(pixels, 160, 82) == rgb565(45, 145, 235)); + assert(pixel_at(pixels, 160, 82) == rgb565(75, 135, 188)); event = SlotSelected{1}; assert(applyEvent(model, event)); buddy::codex::ui::render(model, nullptr, 0, storage); assert(checksum(pixels) != disconnected); - assert(pixel_at(pixels, 55, 38) == rgb565(45, 190, 225)); - assert(pixel_at(pixels, 160, 38) == rgb565(35, 190, 100)); - assert(pixel_at(pixels, 265, 38) == rgb565(230, 65, 75)); + assert(pixel_at(pixels, 55, 40) == rgb565(73, 153, 174)); + assert(pixel_at(pixels, 160, 40) == rgb565(67, 146, 101)); + assert(pixel_at(pixels, 265, 40) == rgb565(181, 83, 89)); write_snapshot(std::getenv("UI_SNAPSHOT_DIR"), "control", pixels); event = PageSelected{Page::Navigate}; @@ -124,12 +124,12 @@ int main(void) dismiss_overlay(model); buddy::codex::ui::render(model, nullptr, 0, storage); - assert(pixel_at(pixels, 60, 45) == rgb565(15, 42, 65)); - assert(pixel_at(pixels, 165, 45) == rgb565(13, 48, 62)); - assert(pixel_at(pixels, 270, 45) == rgb565(12, 47, 31)); - assert(pixel_at(pixels, 60, 129) == rgb565(55, 39, 17)); - assert(pixel_at(pixels, 165, 129) == rgb565(54, 22, 29)); - assert(pixel_at(pixels, 190, 38) == rgb565(242, 246, 252)); + assert(pixel_at(pixels, 60, 45) == rgb565(23, 36, 48)); + assert(pixel_at(pixels, 165, 45) == rgb565(22, 39, 46)); + assert(pixel_at(pixels, 270, 45) == rgb565(22, 38, 30)); + assert(pixel_at(pixels, 60, 129) == rgb565(43, 35, 23)); + assert(pixel_at(pixels, 165, 129) == rgb565(43, 27, 31)); + assert(pixel_at(pixels, 190, 38) == rgb565(199, 206, 214)); const uint64_t agents_dim = checksum(pixels); buddy::codex::ui::render(model, nullptr, 800, storage); assert(checksum(pixels) != agents_dim); @@ -138,7 +138,7 @@ int main(void) set_slot(model, 2, SlotStatus::Idle, false); set_slot(model, 2, SlotStatus::Complete, false); buddy::codex::ui::render(model, nullptr, 800, storage); - assert(pixel_at(pixels, 160, 144) == rgb565(35, 190, 100)); + assert(pixel_at(pixels, 160, 144) == rgb565(67, 146, 101)); write_snapshot(std::getenv("UI_SNAPSHOT_DIR"), "complete", pixels); puts("codex_ui host tests passed"); return 0; diff --git a/test/host/ui_preview/CMakeLists.txt b/test/host/ui_preview/CMakeLists.txt new file mode 100644 index 0000000..70c4b31 --- /dev/null +++ b/test/host/ui_preview/CMakeLists.txt @@ -0,0 +1,38 @@ +cmake_minimum_required(VERSION 3.16) +project(codex_buddy_ui_preview LANGUAGES C CXX) + +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) + +get_filename_component(PROJECT_ROOT "${CMAKE_CURRENT_LIST_DIR}/../../.." ABSOLUTE) +set(LVGL_ROOT "${PROJECT_ROOT}/managed_components/lvgl__lvgl") +if(NOT EXISTS "${LVGL_ROOT}/CMakeLists.txt") + message(FATAL_ERROR "LVGL is not installed; run pio run first") +endif() + +set(LV_BUILD_CONF_PATH "${CMAKE_CURRENT_LIST_DIR}/lv_conf.h" CACHE PATH "" FORCE) +set(CONFIG_LV_BUILD_EXAMPLES OFF CACHE BOOL "" FORCE) +set(CONFIG_LV_BUILD_DEMOS OFF CACHE BOOL "" FORCE) +set(CONFIG_LV_USE_THORVG_INTERNAL OFF CACHE BOOL "" FORCE) +add_subdirectory("${LVGL_ROOT}" lvgl) + +add_executable(render_buddy_ui + "${PROJECT_ROOT}/test/host/render_buddy_ui.cpp" + "${PROJECT_ROOT}/components/buddy_ui/buddy_ui.cpp" + "${PROJECT_ROOT}/components/claude_model/claude_model.cpp" + "${PROJECT_ROOT}/components/codex_ui/codex_ui.cpp" + "${PROJECT_ROOT}/components/codex_model/codex_model.cpp" + "${PROJECT_ROOT}/components/ui_font/ui_font.cpp") + +target_compile_definitions(render_buddy_ui PRIVATE BUDDY_USE_LVGL_FONT=1) +target_compile_options(render_buddy_ui PRIVATE -Wall -Wextra -Werror -pedantic) +target_include_directories(render_buddy_ui PRIVATE + "${PROJECT_ROOT}/components/buddy_ui/include" + "${PROJECT_ROOT}/components/claude_model/include" + "${PROJECT_ROOT}/components/codex_ui/include" + "${PROJECT_ROOT}/components/codex_input/include" + "${PROJECT_ROOT}/components/codex_model/include" + "${PROJECT_ROOT}/components/codex_protocol/include" + "${PROJECT_ROOT}/components/ui_font/include") +target_link_libraries(render_buddy_ui PRIVATE lvgl::lvgl) diff --git a/test/host/ui_preview/lv_conf.h b/test/host/ui_preview/lv_conf.h new file mode 100644 index 0000000..2adef59 --- /dev/null +++ b/test/host/ui_preview/lv_conf.h @@ -0,0 +1,19 @@ +#ifndef LV_CONF_H +#define LV_CONF_H + +#define LV_COLOR_DEPTH 16 +#define LV_USE_STDLIB_MALLOC LV_STDLIB_CLIB +#define LV_USE_STDLIB_STRING LV_STDLIB_CLIB +#define LV_USE_STDLIB_SPRINTF LV_STDLIB_CLIB +#define LV_USE_OS LV_OS_NONE + +#define LV_FONT_MONTSERRAT_10 1 +#define LV_FONT_MONTSERRAT_14 1 +#define LV_FONT_MONTSERRAT_20 1 +#define LV_FONT_MONTSERRAT_28 1 +#define LV_FONT_DEFAULT &lv_font_montserrat_14 + +#define LV_BUILD_EXAMPLES 0 +#define LV_BUILD_DEMOS 0 + +#endif