diff --git a/.github/workflows/compile-arduino.yml b/.github/workflows/compile-arduino.yml
index ed5d6aaf..2a98b522 100644
--- a/.github/workflows/compile-arduino.yml
+++ b/.github/workflows/compile-arduino.yml
@@ -26,6 +26,7 @@ jobs:
- name: SSD1803A_I2C
- name: DHT sensor library
- name: Adafruit Unified Sensor
+ - name: U8g2
UNIVERSAL_SKETCH_PATHS: |
- examples/Basic
diff --git a/README.md b/README.md
index b2e27f14..b1617def 100644
--- a/README.md
+++ b/README.md
@@ -23,7 +23,7 @@
LcdMenu is an open-source Arduino library for creating menu systems. It is designed to be easy to use and flexible enough to support a wide range of use cases.
-With LcdMenu, you can create a menu system for your Arduino project with minimal effort. The library provides a simple API for creating menus and handling user input. There are also a number of built-in [display interfaces](reference/api/display/index) to choose from, including LCD displays and OLED displays _(coming soon)_.
+With LcdMenu, you can create a menu system for your Arduino project with minimal effort. The library provides a simple API for creating menus and handling user input. There are also a number of built-in [display interfaces](reference/api/display/index) to choose from, including classic character LCDs and graphical displays powered by U8g2 (for example SSD1306 and ST7920 modules).
diff --git a/docs/source/overview/rendering/graphical-display.rst b/docs/source/overview/rendering/graphical-display.rst
new file mode 100644
index 00000000..55c085fc
--- /dev/null
+++ b/docs/source/overview/rendering/graphical-display.rst
@@ -0,0 +1,40 @@
+Graphical display renderer
+==========================
+
+The graphical display renderer targets pixel-addressable displays through
+:cpp:class:`GraphicalDisplayInterface`.
+
+It is designed for U8g2-driven modules, including OLED displays such as
+SSD1306 and monochrome graphical LCDs such as ST7920.
+
+Features
+--------
+
+- Dynamic row and column calculation from the active font metrics
+- Row highlighting for selection and value-area highlighting while editing
+- Checkbox rendering for boolean items and toggles
+- Scrollbar and value indicators for widget-driven items
+- Buffered drawing with renderer-managed frame flushing
+- Per-item custom fonts (any U8g2 font)
+
+Basic usage
+-----------
+
+.. code-block:: cpp
+
+ #include
+ #include
+ #include
+
+ U8G2_SSD1306_128X64_NONAME_F_HW_I2C u8g2(U8G2_R0, U8X8_PIN_NONE);
+ U8g2DisplayAdapter display(&u8g2);
+ GraphicalDisplayRenderer renderer(&display, u8g2_font_6x10_tf);
+
+ MENU_SCREEN(mainScreen, mainItems,
+ ITEM_FONT(ITEM_LABEL("Overview"), u8g2_font_7x13B_tf),
+ ITEM_BOOL("Enabled", true, "ON", "OFF", nullptr));
+
+ void setup() {
+ renderer.begin();
+ renderer.setItemFont(mainItems[1], u8g2_font_6x13_tf);
+ }
diff --git a/docs/source/overview/rendering/index.rst b/docs/source/overview/rendering/index.rst
index 7c4cbd19..2767ba27 100644
--- a/docs/source/overview/rendering/index.rst
+++ b/docs/source/overview/rendering/index.rst
@@ -16,6 +16,7 @@ For example, you could create a renderer for a TFT display, a touchscreen, or ev
:caption: The library comes with the following built-in renderers:
character-display
+ graphical-display
Don't see a renderer for your favorite output device? Feel free to create a new one and share it with the community!
diff --git a/examples/SSD1306_I2C/SSD1306_I2C.ino b/examples/SSD1306_I2C/SSD1306_I2C.ino
new file mode 100644
index 00000000..7b44d22b
--- /dev/null
+++ b/examples/SSD1306_I2C/SSD1306_I2C.ino
@@ -0,0 +1,396 @@
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include
+#include
+#include
+
+extern MenuScreen* navigationScreen;
+extern MenuScreen* nestedScreen;
+extern MenuScreen* inputScreen;
+extern MenuScreen* valuesScreen;
+extern MenuScreen* rangeListScreen;
+extern MenuScreen* widgetScreen;
+extern MenuScreen* dynamicScreen;
+
+char* cloneText(const char* text) {
+ size_t len = strlen(text);
+ char* out = new char[len + 1];
+ memcpy(out, text, len + 1);
+ return out;
+}
+
+void refreshMainScreen();
+void jumpToValuesScreen();
+void onUserInput(char* value);
+void onNoteInput(char* value);
+void onTagInput(char* value);
+void clearInputValues();
+void onServiceToggle(bool enabled);
+void onServiceToggleBox(bool enabled);
+void onRelayToggle(const Ref enabled);
+void onAlarmToggle(const bool enabled);
+void onAlarmToggleBox(const bool enabled);
+void flipRelayExternally();
+void onBrightnessChanged(const Ref value);
+void onVolumeChanged(const Ref value);
+void onThemeChanged(const uint8_t index);
+void onProfileChanged(const Ref index);
+void nextProfileExternally();
+void onScheduleWidget(int hour, int minute);
+void onControlWidget(uint8_t modeIndex, bool enabled);
+void onPidWidget(int kp, int ki, int kd);
+void addDynamicItem();
+void insertDynamicItem();
+void removeDynamicAtHead();
+void removeDynamicLast();
+void clearDynamicItems();
+void updateTelemetry();
+
+char* userName = cloneText("ALICE");
+char* note = cloneText("HELLO");
+char* tag = cloneText("A1");
+const char* inputCharset = " ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_";
+
+unsigned long uptimeSeconds = 0;
+float temperatureC = 22.0f;
+float voltage = 3.30f;
+uint16_t rpm = 1200;
+
+bool relayEnabled = false;
+int brightness = 40;
+int volume = 12;
+uint8_t profileIndex = 1;
+
+std::vector themes = {"Classic", "Comfort", "Minimal", "Dense"};
+std::vector profiles = {"A", "B", "C", "D", "E"};
+std::vector modes = {"Auto", "Manual", "Sleep", "Off"};
+
+uint8_t dynamicBaseCount = 0;
+uint8_t dynamicCounter = 0;
+
+// clang-format off
+MENU_SCREEN(mainScreen, mainItems,
+ ITEM_FONT(ITEM_LABEL("SSD1306 showcase"), u8g2_font_7x13B_tf),
+ ITEM_SUBMENU("Navigation", navigationScreen),
+ ITEM_SUBMENU("Input", inputScreen),
+ ITEM_SUBMENU("Values / toggles", valuesScreen),
+ ITEM_SUBMENU("Range / list", rangeListScreen),
+ ITEM_SUBMENU("Widget combos", widgetScreen),
+ ITEM_SUBMENU("Dynamic menu", dynamicScreen),
+ ITEM_COMMAND("Refresh now", refreshMainScreen),
+ ITEM_BASIC("This row is intentionally very long to test clipping and horizontal shifting"));
+
+MENU_SCREEN(navigationScreen, navigationItems,
+ ITEM_BACK(".. Back"),
+ ITEM_LABEL("Submenu indicator"),
+ ITEM_SUBMENU("Nested level", nestedScreen),
+ ITEM_COMMAND("Jump to values", jumpToValuesScreen),
+ ITEM_BASIC("Use BACK key or .. item to return"));
+
+MENU_SCREEN(nestedScreen, nestedItems,
+ ITEM_BACK(".. Back"),
+ ITEM_BASIC("Nested screen"),
+ ITEM_BASIC("Submenu arrow should be visible"));
+
+MENU_SCREEN(inputScreen, inputItems,
+ ITEM_BACK(".. Back"),
+ ITEM_LABEL("Input + charset"),
+ ITEM_INPUT("User", userName, onUserInput),
+ ITEM_INPUT("Note", note, onNoteInput),
+ ITEM_INPUT_CHARSET("Tag", tag, inputCharset, onTagInput),
+ ITEM_COMMAND("Clear inputs", clearInputValues),
+ ITEM_BASIC("Try arrows, Enter, Delete"));
+
+MENU_SCREEN(valuesScreen, valuesItems,
+ ITEM_BACK(".. Back"),
+ ITEM_FONT(ITEM_LABEL("Value text and boxes"), u8g2_font_7x13B_tf),
+ ITEM_VALUE("Uptime", uptimeSeconds, "%lus"),
+ ITEM_VALUE("Temp", temperatureC, "%.1fC"),
+ ITEM_VALUE("Voltage", voltage, "%.2fV"),
+ ITEM_VALUE("RPM", rpm, "%u"),
+ ITEM_LABEL("Text values"),
+ ITEM_TOGGLE("Service", "ON", "OFF", onServiceToggle),
+ ITEM_BOOL_REF("Relay", relayEnabled, "ON", "OFF", onRelayToggle),
+ ITEM_BOOL("Alarm", false, "ARM", "SAFE", onAlarmToggle),
+ ITEM_LABEL("Checkbox values"),
+ ITEM_TOGGLE("Service box", nullptr, nullptr, onServiceToggleBox),
+ ITEM_BOOL("Alarm box", false, nullptr, nullptr, onAlarmToggleBox),
+ ITEM_COMMAND("Flip relay externally", flipRelayExternally));
+
+MENU_SCREEN(rangeListScreen, rangeListItems,
+ ITEM_BACK(".. Back"),
+ ITEM_LABEL("Range / list"),
+ ITEM_RANGE_REF("Brightness", brightness, 5, 0, 100, onBrightnessChanged, "%d%%", 0, true),
+ ITEM_RANGE_REF("Volume", volume, 1, 0, 30, onVolumeChanged, "%d", 0, true),
+ ITEM_LIST("Theme", themes, onThemeChanged, 0, "%s", 0, true),
+ ITEM_LIST_REF("Profile", profiles, onProfileChanged, profileIndex, "%s", 0, true),
+ ITEM_COMMAND("Next profile external", nextProfileExternally));
+
+MENU_SCREEN(widgetScreen, widgetItems,
+ ITEM_BACK(".. Back"),
+ ITEM_FONT(ITEM_LABEL("Multi-widget edit"), u8g2_font_7x13B_tf),
+ ITEM_WIDGET(
+ "Schedule",
+ onScheduleWidget,
+ WIDGET_RANGE(8, 1, 0, 23, "%02d", 0, true),
+ WIDGET_RANGE(30, 5, 0, 55, ":%02d", 0, true)),
+ ITEM_WIDGET(
+ "Control",
+ onControlWidget,
+ WIDGET_LIST(modes, 0, "%s", 0, true),
+ WIDGET_BOOL(false, "ON", "OFF", " %s")),
+ ITEM_WIDGET(
+ "PID",
+ onPidWidget,
+ WIDGET_RANGE(10, 1, 0, 99, "P:%02d", 0, true),
+ WIDGET_RANGE(5, 1, 0, 99, " I:%02d", 0, true),
+ WIDGET_RANGE(2, 1, 0, 99, " D:%02d", 0, true)),
+ ITEM_BASIC("List indicator should be visible"));
+
+MENU_SCREEN(dynamicScreen, dynamicItems,
+ ITEM_BACK(".. Back"),
+ ITEM_FONT(ITEM_LABEL("Runtime mutations"), u8g2_font_7x13B_tf),
+ ITEM_COMMAND("Add item", addDynamicItem),
+ ITEM_COMMAND("Insert at head", insertDynamicItem),
+ ITEM_COMMAND("Remove first added", removeDynamicAtHead),
+ ITEM_COMMAND("Remove last added", removeDynamicLast),
+ ITEM_COMMAND("Clear all added", clearDynamicItems));
+// clang-format on
+
+U8G2_SSD1306_128X64_NONAME_F_HW_I2C u8g2(U8G2_R0, U8X8_PIN_NONE);
+U8g2DisplayAdapter display(&u8g2);
+GraphicalDisplayRenderer renderer(&display, u8g2_font_6x10_tf);
+LcdMenu menu(renderer);
+KeyboardAdapter keyboard(&menu, &Serial);
+
+void setup() {
+ Serial.begin(115200);
+
+ renderer.begin();
+ renderer.setItemFont(navigationItems[1], u8g2_font_7x13B_tf);
+ renderer.setItemFont(inputItems[1], u8g2_font_7x13B_tf);
+ renderer.setItemFont(rangeListItems[1], u8g2_font_7x13B_tf);
+
+ dynamicBaseCount = dynamicScreen->size();
+
+ menu.setScreen(mainScreen);
+
+ Serial.println(F("SSD1306 showcase ready"));
+ Serial.println(F("Controls: arrows, Enter, Esc, Delete, Backspace"));
+}
+
+void loop() {
+ keyboard.observe();
+ updateTelemetry();
+ menu.poll(250);
+}
+
+void refreshMainScreen() {
+ menu.refresh();
+}
+
+void jumpToValuesScreen() {
+ menu.setScreen(valuesScreen);
+}
+
+void onUserInput(char* value) {
+ Serial.print(F("User: "));
+ Serial.println(value);
+}
+
+void onNoteInput(char* value) {
+ Serial.print(F("Note: "));
+ Serial.println(value);
+}
+
+void onTagInput(char* value) {
+ Serial.print(F("Tag: "));
+ Serial.println(value);
+}
+
+void clearInputValues() {
+ ItemInput* user = static_cast(inputItems[2]);
+ ItemInput* noteInput = static_cast(inputItems[3]);
+ ItemInput* tagInput = static_cast(inputItems[4]);
+
+ char* oldUser = user->getValue();
+ char* oldNote = noteInput->getValue();
+ char* oldTag = tagInput->getValue();
+
+ user->setValue(cloneText(""));
+ noteInput->setValue(cloneText(""));
+ tagInput->setValue(cloneText(""));
+
+ delete[] oldUser;
+ delete[] oldNote;
+ delete[] oldTag;
+
+ menu.refresh();
+}
+
+void onServiceToggle(bool enabled) {
+ Serial.print(F("Service: "));
+ Serial.println(enabled ? F("ON") : F("OFF"));
+}
+
+void onServiceToggleBox(bool enabled) {
+ Serial.print(F("Service box: "));
+ Serial.println(enabled ? F("ON") : F("OFF"));
+}
+
+void onRelayToggle(const Ref enabled) {
+ Serial.print(F("Relay: "));
+ Serial.println(static_cast(enabled) ? F("ON") : F("OFF"));
+}
+
+void onAlarmToggle(const bool enabled) {
+ Serial.print(F("Alarm: "));
+ Serial.println(enabled ? F("ARM") : F("SAFE"));
+}
+
+void onAlarmToggleBox(const bool enabled) {
+ Serial.print(F("Alarm box: "));
+ Serial.println(enabled ? F("ON") : F("OFF"));
+}
+
+void flipRelayExternally() {
+ relayEnabled = !relayEnabled;
+ menu.refresh();
+}
+
+void onBrightnessChanged(const Ref value) {
+ Serial.print(F("Brightness: "));
+ Serial.println(static_cast(value));
+}
+
+void onVolumeChanged(const Ref value) {
+ Serial.print(F("Volume: "));
+ Serial.println(static_cast(value));
+}
+
+void onThemeChanged(const uint8_t index) {
+ if (index < themes.size()) {
+ Serial.print(F("Theme: "));
+ Serial.println(themes[index]);
+ }
+}
+
+void onProfileChanged(const Ref index) {
+ uint8_t i = static_cast(index);
+ if (i < profiles.size()) {
+ Serial.print(F("Profile: "));
+ Serial.println(profiles[i]);
+ }
+}
+
+void nextProfileExternally() {
+ profileIndex = static_cast((profileIndex + 1) % profiles.size());
+ menu.refresh();
+}
+
+void onScheduleWidget(int hour, int minute) {
+ Serial.print(F("Schedule: "));
+ if (hour < 10) {
+ Serial.print('0');
+ }
+ Serial.print(hour);
+ Serial.print(':');
+ if (minute < 10) {
+ Serial.print('0');
+ }
+ Serial.println(minute);
+}
+
+void onControlWidget(uint8_t modeIndex, bool enabled) {
+ if (modeIndex < modes.size()) {
+ Serial.print(F("Mode: "));
+ Serial.print(modes[modeIndex]);
+ Serial.print(F(" / Enabled: "));
+ Serial.println(enabled ? F("ON") : F("OFF"));
+ }
+}
+
+void onPidWidget(int kp, int ki, int kd) {
+ Serial.print(F("PID: "));
+ Serial.print(kp);
+ Serial.print(' ');
+ Serial.print(ki);
+ Serial.print(' ');
+ Serial.println(kd);
+}
+
+void addDynamicItem() {
+ char text[24];
+ snprintf(text, sizeof(text), "Added #%u", dynamicCounter++);
+ dynamicScreen->addItem(ITEM_BASIC(cloneText(text)));
+ menu.refresh();
+}
+
+void insertDynamicItem() {
+ char text[24];
+ snprintf(text, sizeof(text), "Inserted #%u", dynamicCounter++);
+ dynamicScreen->addItemAt(dynamicBaseCount, ITEM_BASIC(cloneText(text)));
+ menu.refresh();
+}
+
+void removeDynamicAtHead() {
+ if (dynamicScreen->size() <= dynamicBaseCount) {
+ return;
+ }
+ dynamicScreen->removeItemAt(dynamicBaseCount);
+ menu.refresh();
+}
+
+void removeDynamicLast() {
+ if (dynamicScreen->size() <= dynamicBaseCount) {
+ return;
+ }
+ dynamicScreen->removeLastItem();
+ menu.refresh();
+}
+
+void clearDynamicItems() {
+ while (dynamicScreen->size() > dynamicBaseCount) {
+ dynamicScreen->removeLastItem();
+ }
+ menu.refresh();
+}
+
+void updateTelemetry() {
+ static unsigned long lastTick = 0;
+ static int8_t temperatureDirection = 1;
+
+ unsigned long now = millis();
+ if (now - lastTick < 1000) {
+ return;
+ }
+ lastTick = now;
+
+ uptimeSeconds++;
+ temperatureC += 0.2f * temperatureDirection;
+ if (temperatureC > 28.0f || temperatureC < 21.0f) {
+ temperatureDirection = -temperatureDirection;
+ }
+ voltage = 3.20f + 0.01f * (uptimeSeconds % 20);
+ rpm = 1100 + (uptimeSeconds % 16) * 40;
+}
diff --git a/examples/ST7920_SPI/ST7920_SPI.ino b/examples/ST7920_SPI/ST7920_SPI.ino
new file mode 100644
index 00000000..00072903
--- /dev/null
+++ b/examples/ST7920_SPI/ST7920_SPI.ino
@@ -0,0 +1,32 @@
+#include
+#include
+#include
+#include
+#include
+#include
+
+// clang-format off
+MENU_SCREEN(mainScreen, mainItems,
+ ITEM_BASIC("Start service"),
+ ITEM_BASIC("Connect to WiFi"),
+ ITEM_BASIC("Settings"),
+ ITEM_BASIC("Blink SOS"),
+ ITEM_BASIC("Blink random"));
+// clang-format on
+
+U8G2_ST7920_128X64_F_HW_SPI u8g2(U8G2_R0, 10, U8X8_PIN_NONE);
+U8g2DisplayAdapter display(&u8g2);
+GraphicalDisplayRenderer renderer(&display, u8g2_font_6x10_tf);
+LcdMenu menu(renderer);
+KeyboardAdapter keyboard(&menu, &Serial);
+
+void setup() {
+ Serial.begin(9600);
+ renderer.begin();
+ renderer.setItemFont(mainItems[0], u8g2_font_7x13B_tf);
+ menu.setScreen(mainScreen);
+}
+
+void loop() {
+ keyboard.observe();
+}
diff --git a/platformio.ini b/platformio.ini
index 4217c94c..9911d3f3 100644
--- a/platformio.ini
+++ b/platformio.ini
@@ -24,6 +24,7 @@ lib_deps =
adafruit/DHT sensor library@^1.4.6
adafruit/Adafruit Unified Sensor@^1.1.14
mike-matera/ArduinoSTL@^1.3.3
+ olikraus/U8g2@^2.36.7
[env:esp32]
platform = espressif32
@@ -38,4 +39,5 @@ lib_deps =
Wire
sstaub/SSD1803A_I2C@^1.0.2
adafruit/DHT sensor library@^1.4.6
- adafruit/Adafruit Unified Sensor@^1.1.14
\ No newline at end of file
+ adafruit/Adafruit Unified Sensor@^1.1.14
+ olikraus/U8g2@^2.36.7
diff --git a/src/display/GraphicalDisplayInterface.h b/src/display/GraphicalDisplayInterface.h
new file mode 100644
index 00000000..95a20ef2
--- /dev/null
+++ b/src/display/GraphicalDisplayInterface.h
@@ -0,0 +1,23 @@
+#pragma once
+
+#include "DisplayInterface.h"
+
+/**
+ * @class GraphicalDisplayInterface
+ * @brief Interface for pixel-addressable displays.
+ */
+class GraphicalDisplayInterface : public DisplayInterface {
+ public:
+ virtual void setFont(const uint8_t* font) = 0;
+ virtual uint8_t getDisplayWidth() const = 0;
+ virtual uint8_t getDisplayHeight() const = 0;
+ virtual uint8_t getFontWidth() const = 0;
+ virtual uint8_t getFontHeight() const = 0;
+ virtual uint8_t getTextWidth(const char* text) = 0;
+ virtual void setDrawColor(uint8_t color) = 0;
+ virtual void clearBuffer() = 0;
+ virtual void sendBuffer() = 0;
+ virtual void drawBox(uint8_t x, uint8_t y, uint8_t w, uint8_t h) = 0;
+ virtual void drawFrame(uint8_t x, uint8_t y, uint8_t w, uint8_t h) = 0;
+ virtual void drawXbm(uint8_t x, uint8_t y, uint8_t w, uint8_t h, const uint8_t* bitmap) = 0;
+};
diff --git a/src/display/U8g2DisplayAdapter.h b/src/display/U8g2DisplayAdapter.h
new file mode 100644
index 00000000..54abe293
--- /dev/null
+++ b/src/display/U8g2DisplayAdapter.h
@@ -0,0 +1,82 @@
+#pragma once
+
+#include
+
+#include "GraphicalDisplayInterface.h"
+
+/**
+ * @class U8g2DisplayAdapter
+ * @brief Adapter for displays driven by U8g2.
+ */
+class U8g2DisplayAdapter : public GraphicalDisplayInterface {
+ private:
+ U8G2* u8g2;
+ uint8_t cursorX = 0;
+ uint8_t cursorY = 0;
+
+ public:
+ explicit U8g2DisplayAdapter(U8G2* u8g2) : u8g2(u8g2) {}
+
+ void begin() override {
+ u8g2->begin();
+ u8g2->clearBuffer();
+ u8g2->sendBuffer();
+ }
+
+ void clear() override {
+ u8g2->clearBuffer();
+ u8g2->sendBuffer();
+ }
+
+ void clearBuffer() override { u8g2->clearBuffer(); }
+
+ void sendBuffer() override { u8g2->sendBuffer(); }
+
+ void show() override { u8g2->setPowerSave(0); }
+
+ void hide() override { u8g2->setPowerSave(1); }
+
+ void draw(uint8_t byte) override {
+ char c[2] = {static_cast(byte), '\0'};
+ u8g2->drawUTF8(cursorX, cursorY, c);
+ cursorX += u8g2->getUTF8Width(c);
+ }
+
+ void draw(const char* text) override {
+ u8g2->drawUTF8(cursorX, cursorY, text);
+ cursorX += u8g2->getUTF8Width(text);
+ }
+
+ void setCursor(uint8_t col, uint8_t row) override {
+ cursorX = col;
+ cursorY = row;
+ }
+
+ void setBacklight(bool) override {}
+
+ void setDrawColor(uint8_t color) override { u8g2->setDrawColor(color); }
+
+ void setFont(const uint8_t* font) override { u8g2->setFont(font); }
+
+ uint8_t getDisplayWidth() const override { return u8g2->getDisplayWidth(); }
+
+ uint8_t getDisplayHeight() const override { return u8g2->getDisplayHeight(); }
+
+ uint8_t getFontWidth() const override { return u8g2->getMaxCharWidth(); }
+
+ uint8_t getFontHeight() const override { return u8g2->getMaxCharHeight(); }
+
+ uint8_t getTextWidth(const char* text) override { return u8g2->getUTF8Width(text); }
+
+ void drawBox(uint8_t x, uint8_t y, uint8_t w, uint8_t h) override {
+ u8g2->drawBox(x, y, w, h);
+ }
+
+ void drawFrame(uint8_t x, uint8_t y, uint8_t w, uint8_t h) override {
+ u8g2->drawFrame(x, y, w, h);
+ }
+
+ void drawXbm(uint8_t x, uint8_t y, uint8_t w, uint8_t h, const uint8_t* bitmap) override {
+ u8g2->drawXBM(x, y, w, h, bitmap);
+ }
+};
diff --git a/src/renderer/GraphicalDisplayRenderer.cpp b/src/renderer/GraphicalDisplayRenderer.cpp
new file mode 100644
index 00000000..fbe05814
--- /dev/null
+++ b/src/renderer/GraphicalDisplayRenderer.cpp
@@ -0,0 +1,306 @@
+#include "GraphicalDisplayRenderer.h"
+
+#include "MenuItem.h"
+#include "renderer/GraphicalMenuItem.h"
+
+#include
+
+namespace {
+const uint8_t listGlyph[] = {0x08, 0x1C, 0x3E, 0x00, 0x3E, 0x1C, 0x08, 0x00};
+
+const GraphicalMenuItem* asGraphical(const MenuItem* item) {
+ if (item == NULL) {
+ return NULL;
+ }
+ const void* capability = item->queryCapability(GraphicalMenuItem::capabilityId());
+ return static_cast(capability);
+}
+} // namespace
+
+GraphicalDisplayRenderer::GraphicalDisplayRenderer(
+ GraphicalDisplayInterface* display,
+ const uint8_t* defaultFont,
+ const char* cursorIcon,
+ const char* editCursorIcon)
+ : MenuRenderer(display, 0, 0),
+ gDisplay(display),
+ defaultFont(defaultFont),
+ cursorIcon(cursorIcon == NULL ? ">" : cursorIcon),
+ editCursorIcon(editCursorIcon == NULL ? "*" : editCursorIcon) {}
+
+void GraphicalDisplayRenderer::setDefaultFont(const uint8_t* font) {
+ defaultFont = font;
+ applyItemFont(activeItem);
+}
+
+bool GraphicalDisplayRenderer::setItemFont(MenuItem* item, const uint8_t* font) {
+ if (!::setItemFont(item, font)) {
+ return false;
+ }
+ applyItemFont(activeItem);
+ return true;
+}
+
+void GraphicalDisplayRenderer::captureCurrentFontMetrics() {
+ uint8_t h = gDisplay->getFontHeight();
+ uint8_t w = gDisplay->getFontWidth();
+
+ if (h == 0) {
+ h = 8;
+ }
+ if (w == 0) {
+ w = 1;
+ }
+
+ if (h > maxRowHeight) {
+ maxRowHeight = h;
+ }
+ if (w > maxFontWidth) {
+ maxFontWidth = w;
+ }
+}
+
+void GraphicalDisplayRenderer::applyItemFont(const MenuItem* item) {
+ const uint8_t* font = defaultFont;
+ const GraphicalMenuItem* graphicalItem = asGraphical(item);
+ if (graphicalItem != NULL && graphicalItem->getGraphicalFont() != NULL) {
+ font = graphicalItem->getGraphicalFont();
+ }
+ if (font != NULL) {
+ gDisplay->setFont(font);
+ }
+ captureCurrentFontMetrics();
+}
+
+void GraphicalDisplayRenderer::begin() {
+ MenuRenderer::begin();
+ maxRowHeight = 8;
+ maxFontWidth = 1;
+ applyItemFont(NULL);
+ beginFrame();
+ endFrame();
+}
+
+void GraphicalDisplayRenderer::beginFrame() {
+ gDisplay->clearBuffer();
+}
+
+void GraphicalDisplayRenderer::endFrame() {
+ gDisplay->sendBuffer();
+}
+
+void GraphicalDisplayRenderer::setViewportContext(uint8_t viewStart, uint8_t totalItems) {
+ this->viewStart = viewStart;
+ this->totalItems = totalItems;
+}
+
+void GraphicalDisplayRenderer::setValueAreaWidth(uint8_t width) {
+ valueAreaWidth = width;
+}
+
+void GraphicalDisplayRenderer::setActiveItem(const MenuItem* item) {
+ activeItem = item;
+ applyItemFont(item);
+}
+
+GraphicalDisplayInterface* GraphicalDisplayRenderer::getGraphicalDisplay() {
+ return gDisplay;
+}
+
+void* GraphicalDisplayRenderer::queryExtension(uint8_t extensionId) {
+ if (extensionId == FrameLifecycleRenderer::extensionId()) {
+ return static_cast(this);
+ }
+ if (extensionId == GraphicalIndicatorRenderer::extensionId()) {
+ return static_cast(this);
+ }
+ if (extensionId == GraphicalRendererContext::extensionId()) {
+ return static_cast(this);
+ }
+ return MenuRenderer::queryExtension(extensionId);
+}
+
+const void* GraphicalDisplayRenderer::queryExtension(uint8_t extensionId) const {
+ if (extensionId == FrameLifecycleRenderer::extensionId()) {
+ return static_cast(this);
+ }
+ if (extensionId == GraphicalIndicatorRenderer::extensionId()) {
+ return static_cast(this);
+ }
+ if (extensionId == GraphicalRendererContext::extensionId()) {
+ return static_cast(this);
+ }
+ return MenuRenderer::queryExtension(extensionId);
+}
+
+void GraphicalDisplayRenderer::draw(uint8_t byte) {
+ gDisplay->draw(byte);
+}
+
+void GraphicalDisplayRenderer::drawItem(const char* text, const char* value, bool padWithBlanks) {
+ (void)padWithBlanks;
+
+ uint8_t h = rowHeight();
+ uint8_t yTop = cursorRow * h;
+ uint8_t fontHeight = gDisplay->getFontHeight();
+ uint8_t baseline = yTop + (h > fontHeight ? (h + fontHeight) / 2 - 1 : h - 1);
+
+ uint8_t displayWidth = gDisplay->getDisplayWidth();
+ uint8_t rightInset = totalItems > getMaxRows() ? scrollbarWidth + scrollbarGap : 0;
+ uint8_t contentRight = displayWidth > rightInset ? displayWidth - rightInset : displayWidth;
+
+ gDisplay->setDrawColor(0);
+ gDisplay->drawBox(0, yTop, contentRight, h);
+
+ if (hasFocus && !MenuItem::isEditing()) {
+ gDisplay->setDrawColor(1);
+ gDisplay->drawBox(0, yTop, contentRight, h);
+ gDisplay->setDrawColor(0);
+ } else {
+ gDisplay->setDrawColor(1);
+ }
+
+ uint8_t x = leftPadding;
+ const char* focusedIcon = MenuItem::isEditing() ? editCursorIcon : cursorIcon;
+ uint8_t iconWidth = measureText(focusedIcon);
+ if (hasFocus && focusedIcon != NULL && focusedIcon[0] != '\0') {
+ gDisplay->setCursor(x, baseline);
+ gDisplay->draw(focusedIcon);
+ }
+ x += iconWidth + cursorGap;
+
+ const char* label = text == NULL ? "" : text;
+ gDisplay->setCursor(x, baseline);
+ gDisplay->draw(label);
+ x += measureText(label) + 1;
+
+ if (value != NULL && value[0] != '\0') {
+ uint8_t valueWidth = valueAreaWidth;
+ if (valueWidth == 0) {
+ valueWidth = measureText(value);
+ }
+ uint8_t valueRight = contentRight > rightPadding ? contentRight - rightPadding : contentRight;
+ uint8_t valueX = valueRight > valueWidth ? valueRight - valueWidth : x;
+ gDisplay->setCursor(valueX, baseline);
+ gDisplay->draw(value);
+ } else {
+ const GraphicalMenuItem* graphicalItem = asGraphical(activeItem);
+ if (graphicalItem != NULL && graphicalItem->hasGraphicalToggle()) {
+ uint8_t box = toggleIndicatorWidth();
+ uint8_t xBox = contentRight > rightPadding + box ? contentRight - rightPadding - box : x;
+ uint8_t yBox = yTop + (h > box ? (h - box) / 2 : 0);
+ gDisplay->drawFrame(xBox, yBox, box, box);
+ if (graphicalItem->graphicalToggleState() && box > 4) {
+ gDisplay->drawBox(xBox + 2, yBox + 2, box - 4, box - 4);
+ }
+ }
+ }
+
+ gDisplay->setDrawColor(1);
+ if (cursorRow == 0) {
+ drawScrollBar();
+ }
+}
+
+void GraphicalDisplayRenderer::clearBlinker() {}
+
+void GraphicalDisplayRenderer::drawBlinker() {
+ if (!MenuItem::isEditing()) {
+ return;
+ }
+ uint8_t h = rowHeight();
+ uint8_t top = cursorPixelY + 1 > h ? cursorPixelY + 1 - h : 0;
+ gDisplay->drawBox(cursorPixelX, top, 1, h);
+}
+
+void GraphicalDisplayRenderer::moveCursor(uint8_t col, uint8_t row) {
+ MenuRenderer::moveCursor(col, row);
+ uint8_t charW = gDisplay->getFontWidth() == 0 ? 1 : gDisplay->getFontWidth();
+ uint8_t h = rowHeight();
+ cursorPixelX = col * charW;
+ cursorPixelY = row * h + h - 1;
+ gDisplay->setCursor(cursorPixelX, cursorPixelY);
+}
+
+void GraphicalDisplayRenderer::drawSubMenuIndicator() {
+ uint8_t h = rowHeight();
+ uint8_t top = cursorRow * h;
+ uint8_t rightInset = totalItems > getMaxRows() ? scrollbarWidth + scrollbarGap : 0;
+ uint8_t contentRight = gDisplay->getDisplayWidth() > rightInset ? gDisplay->getDisplayWidth() - rightInset : gDisplay->getDisplayWidth();
+ uint8_t x = contentRight > rightPadding + submenuGlyphWidth ? contentRight - rightPadding - submenuGlyphWidth : leftPadding;
+ uint8_t y = top + (h > submenuGlyphHeight ? (h - submenuGlyphHeight) / 2 : 0);
+ gDisplay->drawBox(x, y, 1, 1);
+ gDisplay->drawBox(x, y + 1, 2, 1);
+ gDisplay->drawBox(x, y + 2, 3, 1);
+ gDisplay->drawBox(x, y + 3, 2, 1);
+ gDisplay->drawBox(x, y + 4, 1, 1);
+}
+
+void GraphicalDisplayRenderer::drawListIndicator() {
+ uint8_t h = rowHeight();
+ uint8_t top = cursorRow * h;
+ uint8_t rightInset = totalItems > getMaxRows() ? scrollbarWidth + scrollbarGap : 0;
+ uint8_t contentRight = gDisplay->getDisplayWidth() > rightInset ? gDisplay->getDisplayWidth() - rightInset : gDisplay->getDisplayWidth();
+ uint8_t x = contentRight > rightPadding + listGlyphWidth ? contentRight - rightPadding - listGlyphWidth : leftPadding;
+ uint8_t y = top + (h > listGlyphHeight ? (h - listGlyphHeight) / 2 : 0);
+ gDisplay->drawXbm(x, y, listGlyphWidth, listGlyphHeight, listGlyph);
+}
+
+uint8_t GraphicalDisplayRenderer::measureText(const char* text) const {
+ if (text == NULL) {
+ return 0;
+ }
+ return gDisplay->getTextWidth(text);
+}
+
+uint8_t GraphicalDisplayRenderer::toggleIndicatorWidth() const {
+ uint8_t h = rowHeight();
+ return h > 4 ? h - 4 : h;
+}
+
+uint8_t GraphicalDisplayRenderer::rowHeight() const {
+ return maxRowHeight == 0 ? 8 : maxRowHeight + 2;
+}
+
+uint8_t GraphicalDisplayRenderer::getMaxRows() const {
+ uint8_t h = rowHeight();
+ return h == 0 ? 0 : gDisplay->getDisplayHeight() / h;
+}
+
+uint8_t GraphicalDisplayRenderer::getMaxCols() const {
+ uint8_t w = maxFontWidth == 0 ? 1 : maxFontWidth;
+ return gDisplay->getDisplayWidth() / w;
+}
+
+uint8_t GraphicalDisplayRenderer::getEffectiveCols() const {
+ uint8_t w = gDisplay->getFontWidth();
+ if (w == 0) {
+ w = 1;
+ }
+ uint8_t rightInset = totalItems > getMaxRows() ? scrollbarWidth + scrollbarGap : 0;
+ uint8_t usable = gDisplay->getDisplayWidth() > rightInset ? gDisplay->getDisplayWidth() - rightInset : 0;
+ return usable / w;
+}
+
+void GraphicalDisplayRenderer::drawScrollBar() {
+ uint8_t rows = getMaxRows();
+ if (rows == 0 || totalItems <= rows) {
+ return;
+ }
+
+ uint8_t x = gDisplay->getDisplayWidth() - scrollbarWidth;
+ uint8_t areaHeight = rows * rowHeight();
+ if (areaHeight > gDisplay->getDisplayHeight()) {
+ areaHeight = gDisplay->getDisplayHeight();
+ }
+
+ uint8_t handleHeight = (static_cast(rows) * areaHeight) / totalItems;
+ if (handleHeight < 2) {
+ handleHeight = 2;
+ }
+
+ uint16_t scrollRange = totalItems - rows;
+ uint16_t trackRange = areaHeight - handleHeight;
+ uint8_t y = scrollRange == 0 ? 0 : (static_cast(viewStart) * trackRange) / scrollRange;
+ gDisplay->drawBox(x, y, scrollbarWidth, handleHeight);
+}
diff --git a/src/renderer/GraphicalDisplayRenderer.h b/src/renderer/GraphicalDisplayRenderer.h
new file mode 100644
index 00000000..061323e7
--- /dev/null
+++ b/src/renderer/GraphicalDisplayRenderer.h
@@ -0,0 +1,88 @@
+#pragma once
+
+#include "FrameLifecycleRenderer.h"
+#include "GraphicalIndicatorRenderer.h"
+#include "GraphicalItemFont.h"
+#include "GraphicalRendererContext.h"
+#include "MenuRenderer.h"
+#include "display/GraphicalDisplayInterface.h"
+#include "utils/std.h"
+
+/**
+ * @class GraphicalDisplayRenderer
+ * @brief Renderer for pixel-addressable displays (for example U8g2-backed).
+ */
+class GraphicalDisplayRenderer : public MenuRenderer,
+ public FrameLifecycleRenderer,
+ public GraphicalIndicatorRenderer,
+ public GraphicalRendererContext {
+ private:
+ GraphicalDisplayInterface* gDisplay;
+ const uint8_t* defaultFont = NULL;
+
+ const MenuItem* activeItem = NULL;
+ uint8_t valueAreaWidth = 0;
+ uint8_t viewStart = 0;
+ uint8_t totalItems = 0;
+
+ uint8_t cursorPixelX = 0;
+ uint8_t cursorPixelY = 0;
+ uint8_t maxRowHeight = 8;
+ uint8_t maxFontWidth = 1;
+
+ const char* cursorIcon;
+ const char* editCursorIcon;
+
+ static const uint8_t scrollbarWidth = 1;
+ static const uint8_t scrollbarGap = 1;
+ static const uint8_t leftPadding = 1;
+ static const uint8_t rightPadding = 1;
+ static const uint8_t cursorGap = 1;
+ static const uint8_t listGlyphWidth = 7;
+ static const uint8_t listGlyphHeight = 8;
+ static const uint8_t listGap = 1;
+ static const uint8_t submenuGlyphWidth = 3;
+ static const uint8_t submenuGlyphHeight = 5;
+
+ void captureCurrentFontMetrics();
+ void applyItemFont(const MenuItem* item);
+
+ uint8_t measureText(const char* text) const;
+ uint8_t toggleIndicatorWidth() const;
+ uint8_t rowHeight() const;
+ void drawScrollBar();
+
+ uint8_t getMaxRows() const override;
+ uint8_t getMaxCols() const override;
+ uint8_t getEffectiveCols() const override;
+
+ public:
+ explicit GraphicalDisplayRenderer(
+ GraphicalDisplayInterface* display,
+ const uint8_t* defaultFont = NULL,
+ const char* cursorIcon = NULL,
+ const char* editCursorIcon = NULL);
+
+ void setDefaultFont(const uint8_t* font);
+ bool setItemFont(MenuItem* item, const uint8_t* font);
+
+ void begin() override;
+ void beginFrame() override;
+ void endFrame() override;
+
+ void setViewportContext(uint8_t viewStart, uint8_t totalItems) override;
+ void setValueAreaWidth(uint8_t width) override;
+ void setActiveItem(const MenuItem* item) override;
+ GraphicalDisplayInterface* getGraphicalDisplay() override;
+
+ void* queryExtension(uint8_t extensionId) override;
+ const void* queryExtension(uint8_t extensionId) const override;
+
+ void draw(uint8_t byte) override;
+ void drawItem(const char* text, const char* value, bool padWithBlanks = true) override;
+ void clearBlinker() override;
+ void drawBlinker() override;
+ void moveCursor(uint8_t cursorCol, uint8_t cursorRow) override;
+ void drawSubMenuIndicator() override;
+ void drawListIndicator() override;
+};
diff --git a/src/renderer/GraphicalIndicatorRenderer.h b/src/renderer/GraphicalIndicatorRenderer.h
new file mode 100644
index 00000000..1535854b
--- /dev/null
+++ b/src/renderer/GraphicalIndicatorRenderer.h
@@ -0,0 +1,16 @@
+#pragma once
+
+#include
+
+/**
+ * @brief Optional renderer interface for graphical row indicators.
+ */
+class GraphicalIndicatorRenderer {
+ public:
+ static uint8_t extensionId() { return 3; }
+
+ virtual ~GraphicalIndicatorRenderer() {}
+
+ virtual void drawSubMenuIndicator() = 0;
+ virtual void drawListIndicator() = 0;
+};
diff --git a/src/renderer/GraphicalItemFont.h b/src/renderer/GraphicalItemFont.h
new file mode 100644
index 00000000..04a47c2b
--- /dev/null
+++ b/src/renderer/GraphicalItemFont.h
@@ -0,0 +1,40 @@
+#pragma once
+
+#include "MenuItem.h"
+#include "renderer/GraphicalMenuItem.h"
+
+inline GraphicalMenuItem* asGraphicalMenuItem(MenuItem* item) {
+ if (item == NULL) {
+ return NULL;
+ }
+
+ const void* capability = item->queryCapability(GraphicalMenuItem::capabilityId());
+ return const_cast(static_cast(capability));
+}
+
+inline const GraphicalMenuItem* asGraphicalMenuItem(const MenuItem* item) {
+ if (item == NULL) {
+ return NULL;
+ }
+
+ const void* capability = item->queryCapability(GraphicalMenuItem::capabilityId());
+ return static_cast(capability);
+}
+
+inline bool setItemFont(MenuItem* item, const uint8_t* font) {
+ GraphicalMenuItem* graphicalItem = asGraphicalMenuItem(item);
+ if (graphicalItem == NULL) {
+ return false;
+ }
+
+ graphicalItem->setGraphicalFont(font);
+ return true;
+}
+
+template
+inline T* ITEM_FONT(T* item, const uint8_t* font) {
+ if (item != NULL) {
+ setItemFont(static_cast(item), font);
+ }
+ return item;
+}
diff --git a/src/renderer/GraphicalMenuItem.h b/src/renderer/GraphicalMenuItem.h
index dc8537f6..6d6fbde5 100644
--- a/src/renderer/GraphicalMenuItem.h
+++ b/src/renderer/GraphicalMenuItem.h
@@ -8,6 +8,9 @@ class GraphicalDisplayInterface;
* @brief Optional capabilities for items rendered on graphical displays.
*/
class GraphicalMenuItem {
+ private:
+ const uint8_t* itemFont = nullptr;
+
public:
static uint8_t capabilityId() { return 1; }
@@ -21,4 +24,12 @@ class GraphicalMenuItem {
virtual bool hasGraphicalToggle() const { return false; }
virtual bool graphicalToggleState() const { return false; }
+
+ virtual bool hasGraphicalListIndicator() const { return false; }
+
+ virtual bool useTightGraphicalSelectionBox() const { return false; }
+
+ virtual void setGraphicalFont(const uint8_t* font) { itemFont = font; }
+
+ virtual const uint8_t* getGraphicalFont() const { return itemFont; }
};