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:
|
|
|
|
|
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