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).

Example of a menu system created with LcdMenu 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; } };