diff --git a/components/byte90/example/main/byte90_example.cpp b/components/byte90/example/main/byte90_example.cpp index 4e81236bf..7643b9b16 100644 --- a/components/byte90/example/main/byte90_example.cpp +++ b/components/byte90/example/main/byte90_example.cpp @@ -1,29 +1,13 @@ -#include #include +#include +#include #include #include "byte90.hpp" -using namespace std::chrono_literals; - -static constexpr size_t MAX_CIRCLES = 10; -struct Circle { - int x{0}; - int y{0}; - int radius{0}; - bool visible{false}; -}; -static std::array circles; -static size_t next_circle_index = 0; -static size_t visible_circle_count = 0; -static lv_obj_t *circle_layer = nullptr; +#include "gui.hpp" -static std::recursive_mutex lvgl_mutex; -static bool initialize_circle_layer(int width, int height); -static void draw_circle_layer(lv_event_t *event); -static void invalidate_circle_area(const Circle &circle); -static void draw_circle(int x0, int y0, int radius); -static void clear_circles(); +using namespace std::chrono_literals; extern "C" void app_main(void) { espp::Logger logger({.tag = "Byte90 Example", .level = espp::Logger::Verbosity::INFO}); @@ -49,22 +33,16 @@ extern "C" void app_main(void) { logger.error("Failed to initialize display!"); return; } - // initialize the button, which we'll use to cycle the rotation of the display + + // create the GUI: builds the UI (label and circle layer) and starts the + // task which updates LVGL. All of its public methods are thread-safe, so + // the button callback and main loop below can call them directly. + static Gui gui({.log_level = espp::Logger::Verbosity::INFO}); + gui.set_label_text("Drawing circles\nto the screen."); + + // initialize the button; each press increments the display brightness by + // 10% (wrapping back around after 100%) and cycles the display rotation logger.info("Initializing the button"); - lv_obj_t *bg = nullptr; - lv_obj_t *label = nullptr; - static auto update_layout = [&]() { - int width = byte90.rotated_display_width(); - int height = byte90.rotated_display_height(); - lv_obj_set_size(bg, width, height); - lv_obj_align(label, LV_ALIGN_CENTER, 0, 0); - if (circle_layer) { - lv_obj_set_size(circle_layer, width, height); - lv_obj_align(circle_layer, LV_ALIGN_CENTER, 0, 0); - lv_obj_move_foreground(circle_layer); - lv_obj_invalidate(circle_layer); - } - }; auto on_button_pressed = [&](const auto &event) { if (event.active) { // increment the brightness by 10%, looping back to 0% after 100% @@ -72,71 +50,31 @@ extern "C" void app_main(void) { brightness = std::fmod(brightness + 10.0f, 100.0f); logger.info("Setting brightness to {:.0f}%", brightness); byte90.brightness(brightness); - // lock the display mutex - std::lock_guard lock(lvgl_mutex); - static auto rotation = LV_DISPLAY_ROTATION_0; - rotation = static_cast((static_cast(rotation) + 1) % 4); - lv_display_t *disp = lv_display_get_default(); - lv_disp_set_rotation(disp, rotation); - update_layout(); + // cycle the rotation of the display; the Gui resizes / re-aligns the + // UI to match + gui.next_rotation(); } }; byte90.initialize_button(on_button_pressed); - // set the background color to black - bg = lv_obj_create(lv_screen_active()); - lv_obj_set_size(bg, byte90.rotated_display_width(), byte90.rotated_display_height()); - lv_obj_set_style_bg_color(bg, lv_color_make(0, 0, 0), 0); - if (!initialize_circle_layer(byte90.rotated_display_width(), byte90.rotated_display_height())) { - logger.error("Failed to initialize circle layer!"); - return; - } - - // add text in the center of the screen - label = lv_label_create(lv_screen_active()); - lv_label_set_text(label, "Drawing circles\nto the screen."); - lv_obj_align(label, LV_ALIGN_CENTER, 0, 0); - lv_obj_set_style_text_align(label, LV_TEXT_ALIGN_CENTER, 0); - update_layout(); - - lv_obj_move_foreground(circle_layer); - - // start a simple thread to do the lv_task_handler every 16ms - espp::Task lv_task({.callback = [](std::mutex &m, std::condition_variable &cv) -> bool { - { - std::lock_guard lock(lvgl_mutex); - lv_task_handler(); - } - std::unique_lock lock(m); - cv.wait_for(lock, 16ms); - return false; - }, - .task_config = { - .name = "lv_task", - }}); - lv_task.start(); - // set the display brightness to be 75% byte90.brightness(75.0f); while (true) { auto start = esp_timer_get_time(); // if there are 10 circles on the screen, clear them - if (visible_circle_count >= MAX_CIRCLES) { - // lock the lvgl mutex - std::lock_guard lock(lvgl_mutex); - clear_circles(); + if (gui.visible_circle_count() >= Gui::MAX_CIRCLES) { + gui.clear_circles(); } else { // draw a circle of circles on the screen (just draw the next circle) int middle_x = byte90.rotated_display_width() / 2; int middle_y = byte90.rotated_display_height() / 2; static constexpr int radius = 30; - float angle = visible_circle_count * 2.0f * M_PI / MAX_CIRCLES; - int x = middle_x + radius * cos(angle); - int y = middle_y + radius * sin(angle); - // lock the lvgl mutex - std::lock_guard lock(lvgl_mutex); - draw_circle(x, y, 5); + float angle = + gui.visible_circle_count() * 2.0f * std::numbers::pi_v / Gui::MAX_CIRCLES; + int x = middle_x + radius * std::cos(angle); + int y = middle_y + radius * std::sin(angle); + gui.draw_circle(x, y, 5); } auto end = esp_timer_get_time(); auto elapsed = end - start; @@ -144,101 +82,3 @@ extern "C" void app_main(void) { } //! [byte90 example] } - -static bool initialize_circle_layer(int width, int height) { - if (circle_layer) { - return true; - } - circle_layer = lv_obj_create(lv_screen_active()); - if (!circle_layer) { - return false; - } - lv_obj_remove_style_all(circle_layer); - lv_obj_set_size(circle_layer, width, height); - lv_obj_align(circle_layer, LV_ALIGN_CENTER, 0, 0); - lv_obj_clear_flag(circle_layer, LV_OBJ_FLAG_CLICKABLE); - lv_obj_clear_flag(circle_layer, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_set_style_bg_opa(circle_layer, LV_OPA_TRANSP, 0); - lv_obj_set_style_border_width(circle_layer, 0, 0); - lv_obj_set_style_outline_width(circle_layer, 0, 0); - lv_obj_set_style_shadow_width(circle_layer, 0, 0); - lv_obj_add_event_cb(circle_layer, draw_circle_layer, LV_EVENT_DRAW_MAIN, nullptr); - return true; -} - -static void draw_circle_layer(lv_event_t *event) { - if (visible_circle_count == 0) { - return; - } - auto *obj = static_cast(lv_event_get_current_target(event)); - auto *layer = lv_event_get_layer(event); - lv_area_t obj_coords; - lv_obj_get_coords(obj, &obj_coords); - - lv_draw_rect_dsc_t rect_dsc; - lv_draw_rect_dsc_init(&rect_dsc); - rect_dsc.base.layer = layer; - rect_dsc.radius = LV_RADIUS_CIRCLE; - rect_dsc.bg_opa = LV_OPA_70; - rect_dsc.bg_color = lv_color_make(0, 255, 255); - rect_dsc.border_width = 0; - rect_dsc.outline_width = 0; - rect_dsc.shadow_width = 0; - - for (const auto &circle : circles) { - if (!circle.visible) { - continue; - } - lv_area_t coords = { - .x1 = static_cast(obj_coords.x1 + circle.x - circle.radius), - .y1 = static_cast(obj_coords.y1 + circle.y - circle.radius), - .x2 = static_cast(obj_coords.x1 + circle.x + circle.radius - 1), - .y2 = static_cast(obj_coords.y1 + circle.y + circle.radius - 1), - }; - lv_draw_rect(layer, &rect_dsc, &coords); - } -} - -static void invalidate_circle_area(const Circle &circle) { - if (!circle_layer || circle.radius <= 0) { - return; - } - - lv_area_t obj_coords; - lv_obj_get_coords(circle_layer, &obj_coords); - lv_area_t coords = { - .x1 = static_cast(obj_coords.x1 + circle.x - circle.radius), - .y1 = static_cast(obj_coords.y1 + circle.y - circle.radius), - .x2 = static_cast(obj_coords.x1 + circle.x + circle.radius - 1), - .y2 = static_cast(obj_coords.y1 + circle.y + circle.radius - 1), - }; - lv_obj_invalidate_area(circle_layer, &coords); -} - -static void draw_circle(int x0, int y0, int radius) { - if (!circle_layer) { - return; - } - lv_obj_move_foreground(circle_layer); - Circle previous_circle = circles[next_circle_index]; - circles[next_circle_index] = {.x = x0, .y = y0, .radius = radius, .visible = true}; - next_circle_index = (next_circle_index + 1) % circles.size(); - if (visible_circle_count < circles.size()) { - visible_circle_count++; - } - if (previous_circle.visible) { - invalidate_circle_area(previous_circle); - } - invalidate_circle_area(circles[(next_circle_index + circles.size() - 1) % circles.size()]); -} - -static void clear_circles() { - for (auto &circle : circles) { - if (circle.visible) { - invalidate_circle_area(circle); - } - circle.visible = false; - } - next_circle_index = 0; - visible_circle_count = 0; -} diff --git a/components/byte90/example/main/gui.cpp b/components/byte90/example/main/gui.cpp new file mode 100644 index 000000000..f11f4e80d --- /dev/null +++ b/components/byte90/example/main/gui.cpp @@ -0,0 +1,166 @@ +#include "gui.hpp" + +void Gui::init_ui() { + std::lock_guard lock(mutex_); + init_background(); + init_label(); + init_circle_layer(); + // disable scrolling on the screen (so that it doesn't behave weirdly when + // rotated) + lv_obj_set_scrollbar_mode(lv_screen_active(), LV_SCROLLBAR_MODE_OFF); + lv_obj_clear_flag(lv_screen_active(), LV_OBJ_FLAG_SCROLLABLE); +} + +void Gui::deinit_ui() { + std::lock_guard lock(mutex_); + lv_obj_clean(lv_screen_active()); +} + +void Gui::init_background() { + auto &byte90 = espp::Byte90::get(); + background_ = lv_obj_create(lv_screen_active()); + lv_obj_set_size(background_, byte90.rotated_display_width(), byte90.rotated_display_height()); + lv_obj_set_style_bg_color(background_, lv_color_make(0, 0, 0), 0); +} + +void Gui::init_label() { + label_ = lv_label_create(lv_screen_active()); + lv_label_set_text(label_, ""); + lv_obj_align(label_, LV_ALIGN_CENTER, 0, 0); + lv_obj_set_style_text_align(label_, LV_TEXT_ALIGN_CENTER, 0); +} + +void Gui::init_circle_layer() { + auto &byte90 = espp::Byte90::get(); + circle_layer_ = lv_obj_create(lv_screen_active()); + lv_obj_remove_style_all(circle_layer_); + lv_obj_set_size(circle_layer_, byte90.rotated_display_width(), byte90.rotated_display_height()); + lv_obj_align(circle_layer_, LV_ALIGN_CENTER, 0, 0); + lv_obj_clear_flag(circle_layer_, LV_OBJ_FLAG_CLICKABLE); + lv_obj_clear_flag(circle_layer_, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_bg_opa(circle_layer_, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(circle_layer_, 0, 0); + lv_obj_set_style_outline_width(circle_layer_, 0, 0); + lv_obj_set_style_shadow_width(circle_layer_, 0, 0); + lv_obj_add_event_cb(circle_layer_, draw_circle_layer, LV_EVENT_DRAW_MAIN, this); + lv_obj_move_foreground(circle_layer_); +} + +bool Gui::update(std::mutex &m, std::condition_variable &cv) { + { + std::lock_guard lock(mutex_); + lv_task_handler(); + } + std::unique_lock lock(m); + cv.wait_for(lock, std::chrono::milliseconds(16)); + return false; // don't stop the task +} + +void Gui::set_label_text(std::string_view text) { + std::lock_guard lock(mutex_); + lv_label_set_text(label_, std::string(text).c_str()); +} + +void Gui::next_rotation() { + std::lock_guard lock(mutex_); + auto &byte90 = espp::Byte90::get(); + auto rotation = lv_display_get_rotation(lv_display_get_default()); + rotation = static_cast((static_cast(rotation) + 1) % 4); + lv_display_set_rotation(lv_display_get_default(), rotation); + // update the size of the screen-filling objects and re-align the label + lv_obj_set_size(background_, byte90.rotated_display_width(), byte90.rotated_display_height()); + lv_obj_align(label_, LV_ALIGN_CENTER, 0, 0); + lv_obj_set_size(circle_layer_, byte90.rotated_display_width(), byte90.rotated_display_height()); + lv_obj_align(circle_layer_, LV_ALIGN_CENTER, 0, 0); + lv_obj_move_foreground(circle_layer_); + lv_obj_invalidate(circle_layer_); +} + +void Gui::draw_circle(int x, int y, int radius) { + std::lock_guard lock(mutex_); + lv_obj_move_foreground(circle_layer_); + Circle previous_circle = circles_[next_circle_index_]; + circles_[next_circle_index_] = {.x = x, .y = y, .radius = radius, .visible = true}; + next_circle_index_ = (next_circle_index_ + 1) % circles_.size(); + if (visible_circle_count_ < circles_.size()) { + visible_circle_count_++; + } + if (previous_circle.visible) { + invalidate_circle_area(previous_circle); + } + invalidate_circle_area(circles_[(next_circle_index_ + circles_.size() - 1) % circles_.size()]); +} + +void Gui::clear_circles() { + std::lock_guard lock(mutex_); + for (auto &circle : circles_) { + if (circle.visible) { + invalidate_circle_area(circle); + } + circle.visible = false; + } + next_circle_index_ = 0; + visible_circle_count_ = 0; +} + +size_t Gui::visible_circle_count() { + std::lock_guard lock(mutex_); + return visible_circle_count_; +} + +void Gui::invalidate_circle_area(const Circle &circle) { + if (!circle_layer_ || circle.radius <= 0) { + return; + } + lv_area_t obj_coords; + lv_obj_get_coords(circle_layer_, &obj_coords); + lv_area_t coords = { + .x1 = static_cast(obj_coords.x1 + circle.x - circle.radius), + .y1 = static_cast(obj_coords.y1 + circle.y - circle.radius), + .x2 = static_cast(obj_coords.x1 + circle.x + circle.radius - 1), + .y2 = static_cast(obj_coords.y1 + circle.y + circle.radius - 1), + }; + lv_obj_invalidate_area(circle_layer_, &coords); +} + +void Gui::draw_circle_layer(lv_event_t *e) { + const auto *gui = static_cast(lv_event_get_user_data(e)); + if (!gui) { + return; + } + gui->draw_circles(e); +} + +void Gui::draw_circles(lv_event_t *e) const { + if (visible_circle_count_ == 0) { + return; + } + + auto *obj = static_cast(lv_event_get_current_target(e)); + auto *layer = lv_event_get_layer(e); + lv_area_t obj_coords; + lv_obj_get_coords(obj, &obj_coords); + + lv_draw_rect_dsc_t rect_dsc; + lv_draw_rect_dsc_init(&rect_dsc); + rect_dsc.base.layer = layer; + rect_dsc.radius = LV_RADIUS_CIRCLE; + rect_dsc.bg_opa = LV_OPA_70; + rect_dsc.bg_color = lv_color_make(0, 255, 255); + rect_dsc.border_width = 0; + rect_dsc.outline_width = 0; + rect_dsc.shadow_width = 0; + + for (const auto &circle : circles_) { + if (!circle.visible) { + continue; + } + lv_area_t coords = { + .x1 = static_cast(obj_coords.x1 + circle.x - circle.radius), + .y1 = static_cast(obj_coords.y1 + circle.y - circle.radius), + .x2 = static_cast(obj_coords.x1 + circle.x + circle.radius - 1), + .y2 = static_cast(obj_coords.y1 + circle.y + circle.radius - 1), + }; + lv_draw_rect(layer, &rect_dsc, &coords); + } +} diff --git a/components/byte90/example/main/gui.hpp b/components/byte90/example/main/gui.hpp new file mode 100644 index 000000000..9322530f9 --- /dev/null +++ b/components/byte90/example/main/gui.hpp @@ -0,0 +1,113 @@ +#pragma once + +#include +#include +#include +#include + +#include "byte90.hpp" + +#include "logger.hpp" +#include "task.hpp" + +/// The Gui class encapsulates all of the LVGL UI for this example. It follows +/// the recommended pattern for building UIs with espp + LVGL in C++: +/// +/// * All LVGL objects are created in init_ui(), which is broken into small +/// member functions - one per logical piece of the UI. +/// * The class owns the task which calls lv_task_handler(), and a recursive +/// mutex which guards every LVGL call. Public methods lock that mutex, so +/// other tasks (button callbacks, the main loop, etc.) can safely call +/// them. +/// +/// For this example the Gui shows: +/// * A label in the center of the screen +/// * A custom-drawn layer of circles which the main loop draws in a ring +/// around the center of the screen +/// +/// The Byte90 has no touchscreen, so there are no on-screen buttons; instead +/// the hardware button cycles the display rotation via next_rotation(). +class Gui { +public: + /// The maximum number of circles that can be drawn on the screen before + /// they must be cleared + static constexpr size_t MAX_CIRCLES = 10; + + /// Configuration for the Gui + struct Config { + espp::Logger::Verbosity log_level{espp::Logger::Verbosity::WARN}; ///< Log verbosity + }; + + /// Construct the Gui: builds the UI and starts the LVGL update task. + /// @param config The configuration for the Gui + explicit Gui(const Config &config) + : logger_({.tag = "Gui", .level = config.log_level}) { + init_ui(); + update_task_.start(); + } + + ~Gui() { + update_task_.stop(); + deinit_ui(); + } + + /// Set the text of the main label. Thread-safe. + /// @param text The text to display + void set_label_text(std::string_view text); + + /// Draw a circle at the given screen coordinates, replacing the oldest + /// circle if the maximum number are already visible. Thread-safe. + /// @param x The x coordinate (screen space) + /// @param y The y coordinate (screen space) + /// @param radius The radius of the circle + void draw_circle(int x, int y, int radius); + + /// Clear all circles from the screen. Thread-safe. + void clear_circles(); + + /// Get the number of circles currently visible on the screen. Thread-safe. + /// @return The number of visible circles + size_t visible_circle_count(); + + /// Rotate the display to the next of 0/90/180/270 degrees, resizing / + /// re-aligning the UI to match. Thread-safe. + void next_rotation(); + +protected: + struct Circle { + int x{0}; + int y{0}; + int radius{0}; + bool visible{false}; + }; + + void init_ui(); + void deinit_ui(); + + // the individual pieces of the UI, called from init_ui() + void init_background(); + void init_label(); + void init_circle_layer(); + + // the LVGL update task: calls lv_task_handler() under the mutex + bool update(std::mutex &m, std::condition_variable &cv); + + // custom drawing of the circle layer + static void draw_circle_layer(lv_event_t *e); + void draw_circles(lv_event_t *e) const; + void invalidate_circle_area(const Circle &circle); + + // LVGL objects + lv_obj_t *background_{nullptr}; + lv_obj_t *label_{nullptr}; + lv_obj_t *circle_layer_{nullptr}; + + std::array circles_; + size_t next_circle_index_{0}; + size_t visible_circle_count_{0}; + + espp::Task update_task_{{.callback = [this](auto &m, auto &cv) { return update(m, cv); }, + .task_config = {.name = "gui", .stack_size_bytes = 6 * 1024}}}; + espp::Logger logger_; + std::recursive_mutex mutex_; +}; diff --git a/components/esp-box/example/main/esp_box_example.cpp b/components/esp-box/example/main/esp_box_example.cpp index f6b3bdbee..4d6a2a099 100644 --- a/components/esp-box/example/main/esp_box_example.cpp +++ b/components/esp-box/example/main/esp_box_example.cpp @@ -1,34 +1,18 @@ -#include #include +#include #include +#include #include #include "esp-box.hpp" +#include "gui.hpp" #include "kalman_filter.hpp" #include "madgwick_filter.hpp" using namespace std::chrono_literals; -static constexpr size_t MAX_CIRCLES = 100; -struct Circle { - int x{0}; - int y{0}; - int radius{0}; - bool visible{false}; -}; -static std::array circles; -static size_t next_circle_index = 0; -static size_t visible_circle_count = 0; static std::vector audio_bytes; -static lv_obj_t *circle_layer = nullptr; - -static std::recursive_mutex lvgl_mutex; -static bool initialize_circle_layer(int width, int height); -static void draw_circle_layer(lv_event_t *event); -static void invalidate_circle_area(const Circle &circle); -static void draw_circle(int x0, int y0, int radius); -static void clear_circles(); static bool load_audio(size_t &out_size, size_t &out_sample_rate); static void play_click(espp::EspBox &box); @@ -42,30 +26,6 @@ extern "C" void app_main(void) { box.set_log_level(espp::Logger::Verbosity::INFO); logger.info("Running on {}", box.box_type()); - auto touch_callback = [&](const auto &touch) { - // NOTE: since we're directly using the touchpad data, and not using the - // TouchpadInput + LVGL, we'll need to ensure the touchpad data is - // converted into proper screen coordinates instead of simply using the - // raw values. - static auto previous_touchpad_data = box.touchpad_convert(touch); - auto touchpad_data = box.touchpad_convert(touch); - if (touchpad_data != previous_touchpad_data) { - logger.info("Touch: {}", touchpad_data); - previous_touchpad_data = touchpad_data; - // if the button is pressed, clear the circles - if (touchpad_data.btn_state) { - std::lock_guard lock(lvgl_mutex); - clear_circles(); - } - // if there is a touch point, draw a circle and play a click sound - if (touchpad_data.num_touch_points > 0) { - play_click(box); - std::lock_guard lock(lvgl_mutex); - draw_circle(touchpad_data.x, touchpad_data.y, 10); - } - } - }; - // initialize the sound if (!box.initialize_sound()) { logger.error("Failed to initialize sound!"); @@ -83,6 +43,7 @@ extern "C" void app_main(void) { logger.error("Failed to initialize display!"); return; } + // make the filter we'll use for the IMU to compute the orientation static constexpr float angle_noise = 0.001f; static constexpr float rate_noise = 0.1f; @@ -131,106 +92,44 @@ extern "C" void app_main(void) { return; } - // set the background color to black - lv_obj_t *bg = lv_obj_create(lv_screen_active()); - lv_obj_set_size(bg, box.lcd_width(), box.lcd_height()); - lv_obj_set_style_bg_color(bg, lv_color_make(0, 0, 0), 0); - - if (!initialize_circle_layer(box.lcd_width(), box.lcd_height())) { - logger.error("Failed to initialize circle layer!"); - return; - } - - // add text in the center of the screen - lv_obj_t *label = lv_label_create(lv_screen_active()); - static std::string label_text = - "\n\n\n\nTouch the screen!\nPress the home button to clear circles."; - lv_label_set_text(label, label_text.c_str()); - lv_obj_align(label, LV_ALIGN_TOP_LEFT, 0, 0); - lv_obj_set_style_text_align(label, LV_TEXT_ALIGN_LEFT, 0); - - /*Create style*/ - static lv_style_t style_line0; - lv_style_init(&style_line0); - lv_style_set_line_width(&style_line0, 8); - lv_style_set_line_color(&style_line0, lv_palette_main(LV_PALETTE_BLUE)); - lv_style_set_line_rounded(&style_line0, true); - - // make a line for showing the direction of "down" - lv_obj_t *line0 = lv_line_create(lv_screen_active()); - static lv_point_precise_t line_points0[] = {{0, 0}, {box.lcd_width(), box.lcd_height()}}; - lv_line_set_points(line0, line_points0, 2); - lv_obj_add_style(line0, &style_line0, 0); - - /*Create style*/ - static lv_style_t style_line1; - lv_style_init(&style_line1); - lv_style_set_line_width(&style_line1, 8); - lv_style_set_line_color(&style_line1, lv_palette_main(LV_PALETTE_RED)); - lv_style_set_line_rounded(&style_line1, true); - - // make a line for showing the direction of "down" - lv_obj_t *line1 = lv_line_create(lv_screen_active()); - static lv_point_precise_t line_points1[] = {{0, 0}, {box.lcd_width(), box.lcd_height()}}; - lv_line_set_points(line1, line_points1, 2); - lv_obj_add_style(line1, &style_line1, 0); - - static auto rotate_display = [&]() { - std::lock_guard lock(lvgl_mutex); - clear_circles(); - static auto rotation = LV_DISPLAY_ROTATION_0; - rotation = static_cast((static_cast(rotation) + 1) % 4); - lv_display_t *disp = lv_display_get_default(); - lv_disp_set_rotation(disp, rotation); - // update the size of the screen - lv_obj_set_size(bg, box.rotated_display_width(), box.rotated_display_height()); - if (circle_layer) { - lv_obj_set_size(circle_layer, box.rotated_display_width(), box.rotated_display_height()); - lv_obj_align(circle_layer, LV_ALIGN_CENTER, 0, 0); - lv_obj_invalidate(circle_layer); + // create the GUI: builds the UI (labels, buttons, gravity lines, circle + // layer) and starts the task which updates LVGL. All of its public methods + // are thread-safe, so the touch callback and IMU task below can call them + // directly. + static Gui gui({.log_level = espp::Logger::Verbosity::INFO}); + static const std::string instructions = + fmt::format("\n\n\n\nTouch the screen!\nPress the home button or the {} button to clear " + "circles.\nPress the {} button to rotate the display.", + LV_SYMBOL_TRASH, LV_SYMBOL_REFRESH); + gui.set_label_text(instructions); + + // initialize the touchpad; each touch draws a circle (and plays a click + // sound), while the home button clears the circles + auto touch_callback = [&](const auto &touch) { + // NOTE: since we're directly using the touchpad data, and not using the + // TouchpadInput + LVGL, we'll need to ensure the touchpad data is + // converted into proper screen coordinates instead of simply using the + // raw values. + static auto previous_touchpad_data = box.touchpad_convert(touch); + auto touchpad_data = box.touchpad_convert(touch); + if (touchpad_data != previous_touchpad_data) { + logger.info("Touch: {}", touchpad_data); + previous_touchpad_data = touchpad_data; + // if the home button is pressed, clear the circles + if (touchpad_data.btn_state) { + gui.clear_circles(); + } + // if there is a touch point, draw a circle and play a click sound + if (touchpad_data.num_touch_points > 0) { + play_click(box); + gui.draw_circle(touchpad_data.x, touchpad_data.y, 10); + } } }; - - // add a button in the top left which (when pressed) will rotate the display - // through 0, 90, 180, 270 degrees - lv_obj_t *btn = lv_btn_create(lv_screen_active()); - lv_obj_set_size(btn, 50, 50); - lv_obj_align(btn, LV_ALIGN_TOP_LEFT, 0, 0); - lv_obj_t *label_btn = lv_label_create(btn); - lv_label_set_text(label_btn, LV_SYMBOL_REFRESH); - // center the text in the button - lv_obj_align(label_btn, LV_ALIGN_CENTER, 0, 0); - lv_obj_add_event_cb( - btn, [](auto event) { rotate_display(); }, LV_EVENT_PRESSED, nullptr); - - // disable scrolling on the screen (so that it doesn't behave weirdly when - // rotated and drawing with your finger) - lv_obj_set_scrollbar_mode(lv_screen_active(), LV_SCROLLBAR_MODE_OFF); - lv_obj_clear_flag(lv_screen_active(), LV_OBJ_FLAG_SCROLLABLE); - - // initialize the touchpad after the circle canvas exists so touch events can - // update the trail immediately. if (!box.initialize_touch(touch_callback)) { logger.error("Failed to initialize touchpad!"); return; } - lv_obj_move_foreground(circle_layer); - - // start a simple thread to do the lv_task_handler every 16ms - espp::Task lv_task({.callback = [](std::mutex &m, std::condition_variable &cv) -> bool { - { - std::lock_guard lock(lvgl_mutex); - lv_task_handler(); - } - std::unique_lock lock(m); - cv.wait_for(lock, 16ms); - return false; - }, - .task_config = { - .name = "lv_task", - .stack_size_bytes = 6 * 1024, - }}); - lv_task.start(); // load the audio file (wav file bundled in memory) size_t wav_size = 0; @@ -251,7 +150,7 @@ extern "C" void app_main(void) { // set the display brightness to be 75% box.brightness(75.0f); - // make a task to read out the IMU data and print it to console + // make a task to read out the IMU data and update the GUI with it espp::Task imu_task( {.callback = [&](std::mutex &m, std::condition_variable &cv) -> bool { // sleep first in case we don't get IMU data and need to exit early @@ -286,21 +185,7 @@ extern "C" void app_main(void) { gravity_vector.y = -gravity_vector.y; } - // now update the gravity vector line to show the direction of "down" - // taking into account the configured rotation of the display - auto rotation = lv_display_get_rotation(lv_display_get_default()); - if (rotation == LV_DISPLAY_ROTATION_90) { - std::swap(gravity_vector.x, gravity_vector.y); - gravity_vector.x = -gravity_vector.x; - } else if (rotation == LV_DISPLAY_ROTATION_180) { - gravity_vector.x = -gravity_vector.x; - gravity_vector.y = -gravity_vector.y; - } else if (rotation == LV_DISPLAY_ROTATION_270) { - std::swap(gravity_vector.x, gravity_vector.y); - gravity_vector.y = -gravity_vector.y; - } - - std::string text = fmt::format("{}\n\n\n\n\n", label_text); + std::string text = fmt::format("{}\n\n\n\n\n", instructions); text += fmt::format("Accel: {:02.2f} {:02.2f} {:02.2f}\n", accel.x, accel.y, accel.z); text += fmt::format("Gyro: {:03.2f} {:03.2f} {:03.2f}\n", espp::deg_to_rad(gyro.x), espp::deg_to_rad(gyro.y), espp::deg_to_rad(gyro.z)); @@ -308,60 +193,23 @@ extern "C" void app_main(void) { espp::rad_to_deg(orientation.pitch)); text += fmt::format("Temp: {:02.1f} C\n", temp); - // use the pitch to to draw a line on the screen indiating the - // direction from the center of the screen to "down" - int x0 = box.rotated_display_width() / 2; - int y0 = box.rotated_display_height() / 2; - - int x1 = x0 + 50 * gravity_vector.x; - int y1 = y0 + 50 * gravity_vector.y; - - static lv_point_precise_t line_points0[] = {{x0, y0}, {x1, y1}}; - line_points0[0].x = x0; - line_points0[0].y = y0; - line_points0[1].x = x1; - line_points0[1].y = y1; - - // Now show the madgwick filter + // Now show the madgwick filter's estimate of "down" auto madgwick_orientation = madgwick_filter_fn(dt, accel, gyro); float roll = madgwick_orientation.roll; float pitch = madgwick_orientation.pitch; - [[maybe_unused]] float yaw = madgwick_orientation.yaw; float vx = sin(pitch); float vy = -cos(pitch) * sin(roll); - [[maybe_unused]] float vz = -cos(pitch) * cos(roll); if (box_type == espp::EspBox::BoxType::BOX) { std::swap(vx, vy); vy = -vy; } - // now update the line to show the direction of "down" based on the - // configured rotation of the display - if (rotation == LV_DISPLAY_ROTATION_90) { - std::swap(vx, vy); - vx = -vx; - } else if (rotation == LV_DISPLAY_ROTATION_180) { - vx = -vx; - vy = -vy; - } else if (rotation == LV_DISPLAY_ROTATION_270) { - std::swap(vx, vy); - vy = -vy; - } - - x1 = x0 + 50 * vx; - y1 = y0 + 50 * vy; - - static lv_point_precise_t line_points1[] = {{x0, y0}, {x1, y1}}; - line_points1[0].x = x0; - line_points1[0].y = y0; - line_points1[1].x = x1; - line_points1[1].y = y1; - - std::lock_guard lock(lvgl_mutex); - lv_label_set_text(label, text.c_str()); - lv_line_set_points(line0, line_points0, 2); - lv_line_set_points(line1, line_points1, 2); + // update the GUI with the new data; the Gui handles remapping the + // vectors for the current display rotation + gui.set_label_text(text); + gui.set_kalman_down(gravity_vector.x, gravity_vector.y); + gui.set_madgwick_down(vx, vy); return false; }, @@ -380,106 +228,6 @@ extern "C" void app_main(void) { //! [esp box example] } -static bool initialize_circle_layer(int width, int height) { - if (circle_layer) { - return true; - } - - circle_layer = lv_obj_create(lv_screen_active()); - if (!circle_layer) { - return false; - } - lv_obj_remove_style_all(circle_layer); - lv_obj_set_size(circle_layer, width, height); - lv_obj_align(circle_layer, LV_ALIGN_CENTER, 0, 0); - lv_obj_clear_flag(circle_layer, LV_OBJ_FLAG_CLICKABLE); - lv_obj_clear_flag(circle_layer, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_set_style_bg_opa(circle_layer, LV_OPA_TRANSP, 0); - lv_obj_set_style_border_width(circle_layer, 0, 0); - lv_obj_set_style_outline_width(circle_layer, 0, 0); - lv_obj_set_style_shadow_width(circle_layer, 0, 0); - lv_obj_add_event_cb(circle_layer, draw_circle_layer, LV_EVENT_DRAW_MAIN, nullptr); - return true; -} - -static void draw_circle_layer(lv_event_t *event) { - if (visible_circle_count == 0) { - return; - } - - auto *obj = static_cast(lv_event_get_current_target(event)); - auto *layer = lv_event_get_layer(event); - lv_area_t obj_coords; - lv_obj_get_coords(obj, &obj_coords); - - lv_draw_rect_dsc_t rect_dsc; - lv_draw_rect_dsc_init(&rect_dsc); - rect_dsc.base.layer = layer; - rect_dsc.radius = LV_RADIUS_CIRCLE; - rect_dsc.bg_opa = LV_OPA_70; - rect_dsc.bg_color = lv_color_make(0, 255, 255); - rect_dsc.border_width = 0; - rect_dsc.outline_width = 0; - rect_dsc.shadow_width = 0; - - for (const auto &circle : circles) { - if (!circle.visible) { - continue; - } - lv_area_t coords = { - .x1 = static_cast(obj_coords.x1 + circle.x - circle.radius), - .y1 = static_cast(obj_coords.y1 + circle.y - circle.radius), - .x2 = static_cast(obj_coords.x1 + circle.x + circle.radius - 1), - .y2 = static_cast(obj_coords.y1 + circle.y + circle.radius - 1), - }; - lv_draw_rect(layer, &rect_dsc, &coords); - } -} - -static void invalidate_circle_area(const Circle &circle) { - if (!circle_layer || circle.radius <= 0) { - return; - } - - lv_area_t obj_coords; - lv_obj_get_coords(circle_layer, &obj_coords); - lv_area_t coords = { - .x1 = static_cast(obj_coords.x1 + circle.x - circle.radius), - .y1 = static_cast(obj_coords.y1 + circle.y - circle.radius), - .x2 = static_cast(obj_coords.x1 + circle.x + circle.radius - 1), - .y2 = static_cast(obj_coords.y1 + circle.y + circle.radius - 1), - }; - lv_obj_invalidate_area(circle_layer, &coords); -} - -static void draw_circle(int x0, int y0, int radius) { - if (!circle_layer) { - return; - } - lv_obj_move_foreground(circle_layer); - Circle previous_circle = circles[next_circle_index]; - circles[next_circle_index] = {.x = x0, .y = y0, .radius = radius, .visible = true}; - next_circle_index = (next_circle_index + 1) % circles.size(); - if (visible_circle_count < circles.size()) { - visible_circle_count++; - } - if (previous_circle.visible) { - invalidate_circle_area(previous_circle); - } - invalidate_circle_area(circles[(next_circle_index + circles.size() - 1) % circles.size()]); -} - -static void clear_circles() { - for (auto &circle : circles) { - if (circle.visible) { - invalidate_circle_area(circle); - } - circle.visible = false; - } - next_circle_index = 0; - visible_circle_count = 0; -} - static bool load_audio(size_t &out_size, size_t &out_sample_rate) { // if the audio_bytes vector is already populated, return the size if (audio_bytes.size() > 0) { diff --git a/components/esp-box/example/main/gui.cpp b/components/esp-box/example/main/gui.cpp new file mode 100644 index 000000000..f2c07468d --- /dev/null +++ b/components/esp-box/example/main/gui.cpp @@ -0,0 +1,274 @@ +#include + +#include "gui.hpp" + +void Gui::init_ui() { + std::lock_guard lock(mutex_); + init_background(); + init_label(); + init_gravity_lines(); + init_buttons(); + init_circle_layer(); + // disable scrolling on the screen (so that it doesn't behave weirdly when + // rotated and drawing with your finger) + lv_obj_set_scrollbar_mode(lv_screen_active(), LV_SCROLLBAR_MODE_OFF); + lv_obj_clear_flag(lv_screen_active(), LV_OBJ_FLAG_SCROLLABLE); +} + +void Gui::deinit_ui() { + std::lock_guard lock(mutex_); + lv_obj_clean(lv_screen_active()); +} + +void Gui::init_background() { + auto &box = espp::EspBox::get(); + background_ = lv_obj_create(lv_screen_active()); + lv_obj_set_size(background_, box.lcd_width(), box.lcd_height()); + lv_obj_set_style_bg_color(background_, lv_color_make(0, 0, 0), 0); +} + +void Gui::init_label() { + label_ = lv_label_create(lv_screen_active()); + lv_label_set_text(label_, ""); + lv_obj_align(label_, LV_ALIGN_TOP_LEFT, 0, 0); + lv_obj_set_style_text_align(label_, LV_TEXT_ALIGN_LEFT, 0); +} + +void Gui::init_gravity_lines() { + auto &box = espp::EspBox::get(); + + lv_style_init(&kalman_line_style_); + lv_style_set_line_width(&kalman_line_style_, 8); + lv_style_set_line_color(&kalman_line_style_, lv_palette_main(LV_PALETTE_BLUE)); + lv_style_set_line_rounded(&kalman_line_style_, true); + + kalman_line_ = lv_line_create(lv_screen_active()); + kalman_line_points_[0] = {0, 0}; + kalman_line_points_[1] = {box.lcd_width(), box.lcd_height()}; + lv_line_set_points(kalman_line_, kalman_line_points_, 2); + lv_obj_add_style(kalman_line_, &kalman_line_style_, 0); + + lv_style_init(&madgwick_line_style_); + lv_style_set_line_width(&madgwick_line_style_, 8); + lv_style_set_line_color(&madgwick_line_style_, lv_palette_main(LV_PALETTE_RED)); + lv_style_set_line_rounded(&madgwick_line_style_, true); + + madgwick_line_ = lv_line_create(lv_screen_active()); + madgwick_line_points_[0] = {0, 0}; + madgwick_line_points_[1] = {box.lcd_width(), box.lcd_height()}; + lv_line_set_points(madgwick_line_, madgwick_line_points_, 2); + lv_obj_add_style(madgwick_line_, &madgwick_line_style_, 0); +} + +void Gui::init_buttons() { + // a button in the top left which rotates the display through + // 0/90/180/270 degrees + rotate_button_ = lv_btn_create(lv_screen_active()); + lv_obj_set_size(rotate_button_, 50, 50); + lv_obj_align(rotate_button_, LV_ALIGN_TOP_LEFT, 0, 0); + lv_obj_t *rotate_label = lv_label_create(rotate_button_); + lv_label_set_text(rotate_label, LV_SYMBOL_REFRESH); + lv_obj_align(rotate_label, LV_ALIGN_CENTER, 0, 0); + lv_obj_add_event_cb(rotate_button_, event_callback, LV_EVENT_PRESSED, this); + + // a button in the top right which clears the circles + clear_button_ = lv_btn_create(lv_screen_active()); + lv_obj_set_size(clear_button_, 50, 50); + lv_obj_align(clear_button_, LV_ALIGN_TOP_RIGHT, 0, 0); + lv_obj_t *clear_label = lv_label_create(clear_button_); + lv_label_set_text(clear_label, LV_SYMBOL_TRASH); + lv_obj_align(clear_label, LV_ALIGN_CENTER, 0, 0); + lv_obj_add_event_cb(clear_button_, event_callback, LV_EVENT_PRESSED, this); +} + +void Gui::init_circle_layer() { + auto &box = espp::EspBox::get(); + circle_layer_ = lv_obj_create(lv_screen_active()); + lv_obj_remove_style_all(circle_layer_); + lv_obj_set_size(circle_layer_, box.lcd_width(), box.lcd_height()); + lv_obj_align(circle_layer_, LV_ALIGN_CENTER, 0, 0); + lv_obj_clear_flag(circle_layer_, LV_OBJ_FLAG_CLICKABLE); + lv_obj_clear_flag(circle_layer_, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_bg_opa(circle_layer_, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(circle_layer_, 0, 0); + lv_obj_set_style_outline_width(circle_layer_, 0, 0); + lv_obj_set_style_shadow_width(circle_layer_, 0, 0); + lv_obj_add_event_cb(circle_layer_, draw_circle_layer, LV_EVENT_DRAW_MAIN, this); + lv_obj_move_foreground(circle_layer_); +} + +bool Gui::update(std::mutex &m, std::condition_variable &cv) { + { + std::lock_guard lock(mutex_); + lv_task_handler(); + } + std::unique_lock lock(m); + cv.wait_for(lock, std::chrono::milliseconds(16)); + return false; // don't stop the task +} + +void Gui::event_callback(lv_event_t *e) { + auto *gui = static_cast(lv_event_get_user_data(e)); + if (!gui) { + return; + } + switch (lv_event_get_code(e)) { + case LV_EVENT_PRESSED: + gui->on_pressed(e); + break; + default: + break; + } +} + +void Gui::on_pressed(lv_event_t *e) { + const auto *target = static_cast(lv_event_get_target(e)); + if (target == rotate_button_) { + logger_.info("Rotate button pressed"); + next_rotation(); + } else if (target == clear_button_) { + logger_.info("Clear button pressed"); + clear_circles(); + } +} + +void Gui::set_label_text(std::string_view text) { + std::lock_guard lock(mutex_); + lv_label_set_text(label_, std::string(text).c_str()); +} + +void Gui::next_rotation() { + std::lock_guard lock(mutex_); + auto &box = espp::EspBox::get(); + clear_circles_impl(); + auto rotation = lv_display_get_rotation(lv_display_get_default()); + rotation = static_cast((static_cast(rotation) + 1) % 4); + lv_display_set_rotation(lv_display_get_default(), rotation); + // update the size of the screen-filling objects + lv_obj_set_size(background_, box.rotated_display_width(), box.rotated_display_height()); + lv_obj_set_size(circle_layer_, box.rotated_display_width(), box.rotated_display_height()); + lv_obj_align(circle_layer_, LV_ALIGN_CENTER, 0, 0); + lv_obj_invalidate(circle_layer_); +} + +void Gui::set_kalman_down(float vx, float vy) { + std::lock_guard lock(mutex_); + set_down_line(kalman_line_, kalman_line_points_, vx, vy); +} + +void Gui::set_madgwick_down(float vx, float vy) { + std::lock_guard lock(mutex_); + set_down_line(madgwick_line_, madgwick_line_points_, vx, vy); +} + +void Gui::set_down_line(lv_obj_t *line, lv_point_precise_t *points, float vx, float vy) { + auto &box = espp::EspBox::get(); + // remap the vector according to the current display rotation so that the + // line always points toward physical "down" + auto rotation = lv_display_get_rotation(lv_display_get_default()); + if (rotation == LV_DISPLAY_ROTATION_90) { + std::swap(vx, vy); + vx = -vx; + } else if (rotation == LV_DISPLAY_ROTATION_180) { + vx = -vx; + vy = -vy; + } else if (rotation == LV_DISPLAY_ROTATION_270) { + std::swap(vx, vy); + vy = -vy; + } + // draw the line from the center of the screen toward "down" + int x0 = box.rotated_display_width() / 2; + int y0 = box.rotated_display_height() / 2; + points[0] = {static_cast(x0), static_cast(y0)}; + points[1] = {static_cast(x0 + 50 * vx), + static_cast(y0 + 50 * vy)}; + lv_line_set_points(line, points, 2); +} + +void Gui::draw_circle(int x, int y, int radius) { + std::lock_guard lock(mutex_); + lv_obj_move_foreground(circle_layer_); + Circle previous_circle = circles_[next_circle_index_]; + circles_[next_circle_index_] = {.x = x, .y = y, .radius = radius, .visible = true}; + next_circle_index_ = (next_circle_index_ + 1) % circles_.size(); + if (visible_circle_count_ < circles_.size()) { + visible_circle_count_++; + } + if (previous_circle.visible) { + invalidate_circle_area(previous_circle); + } + invalidate_circle_area(circles_[(next_circle_index_ + circles_.size() - 1) % circles_.size()]); +} + +void Gui::clear_circles() { + std::lock_guard lock(mutex_); + clear_circles_impl(); +} + +void Gui::clear_circles_impl() { + for (auto &circle : circles_) { + if (circle.visible) { + invalidate_circle_area(circle); + } + circle.visible = false; + } + next_circle_index_ = 0; + visible_circle_count_ = 0; +} + +void Gui::invalidate_circle_area(const Circle &circle) { + if (!circle_layer_ || circle.radius <= 0) { + return; + } + lv_area_t obj_coords; + lv_obj_get_coords(circle_layer_, &obj_coords); + lv_area_t coords = { + .x1 = static_cast(obj_coords.x1 + circle.x - circle.radius), + .y1 = static_cast(obj_coords.y1 + circle.y - circle.radius), + .x2 = static_cast(obj_coords.x1 + circle.x + circle.radius - 1), + .y2 = static_cast(obj_coords.y1 + circle.y + circle.radius - 1), + }; + lv_obj_invalidate_area(circle_layer_, &coords); +} + +void Gui::draw_circle_layer(lv_event_t *e) { + const auto *gui = static_cast(lv_event_get_user_data(e)); + if (!gui) { + return; + } + gui->draw_circles(e); +} + +void Gui::draw_circles(lv_event_t *e) const { + if (visible_circle_count_ == 0) { + return; + } + + auto *obj = static_cast(lv_event_get_current_target(e)); + auto *layer = lv_event_get_layer(e); + lv_area_t obj_coords; + lv_obj_get_coords(obj, &obj_coords); + + lv_draw_rect_dsc_t rect_dsc; + lv_draw_rect_dsc_init(&rect_dsc); + rect_dsc.base.layer = layer; + rect_dsc.radius = LV_RADIUS_CIRCLE; + rect_dsc.bg_opa = LV_OPA_70; + rect_dsc.bg_color = lv_color_make(0, 255, 255); + rect_dsc.border_width = 0; + rect_dsc.outline_width = 0; + rect_dsc.shadow_width = 0; + + for (const auto &circle : circles_) { + if (!circle.visible) { + continue; + } + lv_area_t coords = { + .x1 = static_cast(obj_coords.x1 + circle.x - circle.radius), + .y1 = static_cast(obj_coords.y1 + circle.y - circle.radius), + .x2 = static_cast(obj_coords.x1 + circle.x + circle.radius - 1), + .y2 = static_cast(obj_coords.y1 + circle.y + circle.radius - 1), + }; + lv_draw_rect(layer, &rect_dsc, &coords); + } +} diff --git a/components/esp-box/example/main/gui.hpp b/components/esp-box/example/main/gui.hpp new file mode 100644 index 000000000..b3a2cdffc --- /dev/null +++ b/components/esp-box/example/main/gui.hpp @@ -0,0 +1,146 @@ +#pragma once + +#include +#include +#include +#include + +#include "esp-box.hpp" + +#include "logger.hpp" +#include "task.hpp" + +/// The Gui class encapsulates all of the LVGL UI for this example. It follows +/// the recommended pattern for building UIs with espp + LVGL in C++: +/// +/// * All LVGL objects are created in init_ui(), which is broken into small +/// member functions - one per logical piece of the UI. +/// * The class owns the task which calls lv_task_handler(), and a recursive +/// mutex which guards every LVGL call. Public methods lock that mutex, so +/// other tasks (touch callbacks, sensor tasks, etc.) can safely call them. +/// * LVGL event callbacks are registered with `this` as the user-data and +/// dispatched through a single static trampoline (event_callback) into +/// member functions, keeping all UI logic inside the class. +/// +/// For this example the Gui shows: +/// * A label with instructions and live IMU data +/// * Two lines showing the direction of gravity ("down") as computed by a +/// Kalman filter (blue) and a Madgwick filter (red) +/// * A button (top-left) which rotates the display through 0/90/180/270 +/// * A button (top-right) which clears the circles drawn on the screen +/// * A custom-drawn layer of circles which trail the most recent touches +class Gui { +public: + /// Configuration for the Gui + struct Config { + espp::Logger::Verbosity log_level{espp::Logger::Verbosity::WARN}; ///< Log verbosity + }; + + /// Construct the Gui: builds the UI and starts the LVGL update task. + /// @param config The configuration for the Gui + explicit Gui(const Config &config) + : logger_({.tag = "Gui", .level = config.log_level}) { + init_ui(); + update_task_.start(); + } + + ~Gui() { + update_task_.stop(); + deinit_ui(); + } + + /// Set the text of the main label. Thread-safe. + /// @param text The text to display + void set_label_text(std::string_view text); + + /// Draw a circle at the given screen coordinates, replacing the oldest + /// circle if the maximum number are already visible. Thread-safe. + /// @param x The x coordinate (screen space) + /// @param y The y coordinate (screen space) + /// @param radius The radius of the circle + void draw_circle(int x, int y, int radius); + + /// Clear all circles from the screen. Thread-safe. + void clear_circles(); + + /// Rotate the display to the next of 0/90/180/270 degrees, resizing / + /// re-aligning the UI to match. Thread-safe. + void next_rotation(); + + /// Update the line showing "down" according to the Kalman filter. The + /// vector is provided in the display's natural (unrotated) frame; the Gui + /// accounts for the current display rotation. Thread-safe. + /// @param vx The x component of the gravity vector + /// @param vy The y component of the gravity vector + void set_kalman_down(float vx, float vy); + + /// Update the line showing "down" according to the Madgwick filter. The + /// vector is provided in the display's natural (unrotated) frame; the Gui + /// accounts for the current display rotation. Thread-safe. + /// @param vx The x component of the gravity vector + /// @param vy The y component of the gravity vector + void set_madgwick_down(float vx, float vy); + +protected: + static constexpr size_t MAX_CIRCLES = 100; + + struct Circle { + int x{0}; + int y{0}; + int radius{0}; + bool visible{false}; + }; + + void init_ui(); + void deinit_ui(); + + // the individual pieces of the UI, called from init_ui() + void init_background(); + void init_label(); + void init_gravity_lines(); + void init_buttons(); + void init_circle_layer(); + + // update the given "down" line from a vector in the unrotated display + // frame, remapping for the current display rotation + void set_down_line(lv_obj_t *line, lv_point_precise_t *points, float vx, float vy); + + // the LVGL update task: calls lv_task_handler() under the mutex + bool update(std::mutex &m, std::condition_variable &cv); + + // single trampoline for all LVGL events; dispatches to the member + // functions below based on the event target + static void event_callback(lv_event_t *e); + void on_pressed(lv_event_t *e); + + // custom drawing of the circle layer + static void draw_circle_layer(lv_event_t *e); + void draw_circles(lv_event_t *e) const; + void invalidate_circle_area(const Circle &circle); + + // unlocked implementations, called with the mutex held + void clear_circles_impl(); + + // LVGL objects + lv_obj_t *background_{nullptr}; + lv_obj_t *label_{nullptr}; + lv_obj_t *kalman_line_{nullptr}; + lv_obj_t *madgwick_line_{nullptr}; + lv_obj_t *rotate_button_{nullptr}; + lv_obj_t *clear_button_{nullptr}; + lv_obj_t *circle_layer_{nullptr}; + + lv_style_t kalman_line_style_; + lv_style_t madgwick_line_style_; + lv_point_precise_t kalman_line_points_[2]; + lv_point_precise_t madgwick_line_points_[2]; + + std::array circles_; + size_t next_circle_index_{0}; + size_t visible_circle_count_{0}; + + espp::Task update_task_{{.callback = [this](auto &m, auto &cv) { return update(m, cv); }, + .task_config = {.name = "gui", .stack_size_bytes = 6 * 1024}}}; + espp::Logger logger_; + std::recursive_mutex mutex_; +}; diff --git a/components/esp32-p4-function-ev-board/example/main/esp32_p4_function_ev_board_example.cpp b/components/esp32-p4-function-ev-board/example/main/esp32_p4_function_ev_board_example.cpp index 5c3216dde..daf674264 100644 --- a/components/esp32-p4-function-ev-board/example/main/esp32_p4_function_ev_board_example.cpp +++ b/components/esp32-p4-function-ev-board/example/main/esp32_p4_function_ev_board_example.cpp @@ -8,8 +8,6 @@ * read-out (panel, touch, SD, Ethernet, RTPS, and system memory/uptime). */ -#include -#include #include #include #include @@ -27,6 +25,8 @@ #include "esp32-p4-function-ev-board.hpp" +#include "gui.hpp" + using namespace std::chrono_literals; using Board = espp::Esp32P4FunctionEvBoard; @@ -35,6 +35,10 @@ using Board = espp::Esp32P4FunctionEvBoard; static std::mutex g_peer_mutex; static std::string g_peer_addr; +static std::vector audio_bytes; + +static bool load_audio(size_t &out_size, size_t &out_sample_rate); + // Ping a target host a few times and log the result (uses the espp Ping helper). static void ping_target(espp::Logger &logger, const char *name, const std::string &ip) { if (ip.empty() || ip == "0.0.0.0") { @@ -67,30 +71,6 @@ static void ping_target(espp::Logger &logger, const char *name, const std::strin } } -////////////////////////////////////////////////////////////////////////////// -// Touch-to-draw circle state (a ring buffer of recent touch points) -////////////////////////////////////////////////////////////////////////////// -static constexpr size_t MAX_CIRCLES = 100; -struct Circle { - int x{0}; - int y{0}; - int radius{0}; - bool visible{false}; -}; -static std::array circles; -static size_t next_circle_index = 0; -static size_t visible_circle_count = 0; -static lv_obj_t *circle_layer = nullptr; -static std::recursive_mutex lvgl_mutex; -static std::vector audio_bytes; - -static bool initialize_circle_layer(int width, int height); -static void draw_circle_layer(lv_event_t *event); -static void invalidate_circle_area(const Circle &circle); -static void draw_circle(int x0, int y0, int radius); -static void clear_circles(); -static bool load_audio(size_t &out_size, size_t &out_sample_rate); - namespace { /// Serialize a uint32 as an encapsulated little-endian CDR payload (matches std_msgs/msg/UInt32). inline std::vector serialize_uint32(uint32_t value) { @@ -141,68 +121,11 @@ extern "C" void app_main(void) { return; } - // Build the LVGL UI: a title, a status label, a rotate button, and a - // transparent layer that the touch handler draws circles onto. - lv_obj_t *bg = nullptr; - lv_obj_t *title = nullptr; - static lv_obj_t *status_label = nullptr; - { - std::lock_guard lock(lvgl_mutex); - bg = lv_obj_create(lv_screen_active()); - lv_obj_set_size(bg, board.display_width(), board.display_height()); - lv_obj_set_style_bg_color(bg, lv_color_make(0, 0, 0), 0); - - title = lv_label_create(lv_screen_active()); - lv_label_set_text(title, "ESP32-P4 Function EV Board - touch to draw!"); - lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 8); - - status_label = lv_label_create(lv_screen_active()); - lv_obj_set_style_text_align(status_label, LV_TEXT_ALIGN_LEFT, 0); - lv_obj_align(status_label, LV_ALIGN_TOP_LEFT, 8, 40); - - initialize_circle_layer(board.display_width(), board.display_height()); - lv_obj_set_scrollbar_mode(lv_screen_active(), LV_SCROLLBAR_MODE_OFF); - lv_obj_clear_flag(lv_screen_active(), LV_OBJ_FLAG_SCROLLABLE); - if (circle_layer) { - lv_obj_move_foreground(circle_layer); - } - } - - // Cycle the display rotation (0 -> 90 -> 180 -> 270) and resize the background - // and circle layers to match the new orientation. Static so the (non-capturing) - // button event callback below can call it; it captures app_main locals by - // reference, which is safe because app_main never returns. - static auto rotate_display = [&]() { - std::lock_guard lock(lvgl_mutex); - clear_circles(); - static auto rotation = LV_DISPLAY_ROTATION_0; - rotation = static_cast((static_cast(rotation) + 1) % 4); - lv_display_set_rotation(lv_display_get_default(), rotation); - lv_obj_set_size(bg, board.rotated_display_width(), board.rotated_display_height()); - if (circle_layer) { - lv_obj_set_size(circle_layer, board.rotated_display_width(), board.rotated_display_height()); - lv_obj_align(circle_layer, LV_ALIGN_CENTER, 0, 0); - lv_obj_move_foreground(circle_layer); - lv_obj_invalidate(circle_layer); - } - // re-align the labels to the (now reoriented) screen edges - lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 8); - if (status_label) { - lv_obj_align(status_label, LV_ALIGN_TOP_LEFT, 8, 40); - } - }; - - { - std::lock_guard lock(lvgl_mutex); - lv_obj_t *rotate_btn = lv_btn_create(lv_screen_active()); - lv_obj_set_size(rotate_btn, 50, 50); - lv_obj_align(rotate_btn, LV_ALIGN_TOP_RIGHT, -8, 8); - lv_obj_t *btn_label = lv_label_create(rotate_btn); - lv_label_set_text(btn_label, LV_SYMBOL_REFRESH); - lv_obj_align(btn_label, LV_ALIGN_CENTER, 0, 0); - lv_obj_add_event_cb( - rotate_btn, [](lv_event_t *) { rotate_display(); }, LV_EVENT_CLICKED, nullptr); - } + // create the GUI: builds the UI (title, status label, rotate / clear + // buttons, and a transparent circle layer) and starts the task which + // updates LVGL. All of its public methods are thread-safe, so the touch + // callback and status task below can call them directly. + static Gui gui({.log_level = espp::Logger::Verbosity::INFO}); // On-screen status state. These are filled in as each subsystem initializes // below, and rendered immediately by the status task, so the display shows SD / @@ -246,12 +169,7 @@ extern "C" void app_main(void) { "Ethernet: " + eth_text + "\n" + "RTPS: " + rtps_text + "\n" + "System: " + std::to_string(free_internal) + " KB int, " + std::to_string(free_psram) + " KB psram free, up " + std::to_string(uptime_s) + " s"; - { - std::lock_guard lock(lvgl_mutex); - if (status_label) { - lv_label_set_text(status_label, status.c_str()); - } - } + gui.set_status_text(status); std::unique_lock lock(m); cv.wait_for(lock, 100ms); return false; @@ -281,26 +199,12 @@ extern "C" void app_main(void) { board.play_audio(audio_bytes); // non-blocking, touch-down edge only } if (new_touch) { - std::lock_guard lock(lvgl_mutex); - draw_circle(td.x, td.y, kCircleRadius); + gui.draw_circle(td.x, td.y, kCircleRadius); } } prev_td = td; }); - // Run the LVGL task handler periodically - espp::Task lv_task({.callback = [](std::mutex &m, std::condition_variable &cv) -> bool { - { - std::lock_guard lock(lvgl_mutex); - lv_task_handler(); - } - std::unique_lock lock(m); - cv.wait_for(lock, 16ms); - return false; - }, - .task_config = {.name = "lvgl", .stack_size_bytes = 8192}}); - lv_task.start(); - // microSD (optional — only present if a card is inserted) bool sd_ok = board.initialize_sdcard({.format_if_mount_failed = false}); uint32_t sd_size_mb = 0, sd_free_mb = 0; @@ -346,8 +250,7 @@ extern "C" void app_main(void) { // disabled here since this example uses Ethernet. bool button_initialized = board.initialize_button([&](const auto &event) { if (event.active) { - std::lock_guard lock(lvgl_mutex); - clear_circles(); + gui.clear_circles(); } }); if (button_initialized) { @@ -481,107 +384,6 @@ extern "C" void app_main(void) { } } -////////////////////////////////////////////////////////////////////////////// -// LVGL circle-drawing helpers (a transparent full-screen layer with a custom -// draw callback that renders the ring buffer of recent touch points). -////////////////////////////////////////////////////////////////////////////// -static bool initialize_circle_layer(int width, int height) { - if (circle_layer) { - return true; - } - circle_layer = lv_obj_create(lv_screen_active()); - if (!circle_layer) { - return false; - } - lv_obj_remove_style_all(circle_layer); - lv_obj_set_size(circle_layer, width, height); - lv_obj_align(circle_layer, LV_ALIGN_CENTER, 0, 0); - lv_obj_clear_flag(circle_layer, LV_OBJ_FLAG_CLICKABLE); - lv_obj_clear_flag(circle_layer, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_set_style_bg_opa(circle_layer, LV_OPA_TRANSP, 0); - lv_obj_set_style_border_width(circle_layer, 0, 0); - lv_obj_set_style_outline_width(circle_layer, 0, 0); - lv_obj_set_style_shadow_width(circle_layer, 0, 0); - lv_obj_add_event_cb(circle_layer, draw_circle_layer, LV_EVENT_DRAW_MAIN, nullptr); - return true; -} - -static void draw_circle_layer(lv_event_t *event) { - if (visible_circle_count == 0) { - return; - } - auto *obj = static_cast(lv_event_get_current_target(event)); - auto *layer = lv_event_get_layer(event); - lv_area_t obj_coords; - lv_obj_get_coords(obj, &obj_coords); - - lv_draw_rect_dsc_t rect_dsc; - lv_draw_rect_dsc_init(&rect_dsc); - rect_dsc.base.layer = layer; - rect_dsc.radius = LV_RADIUS_CIRCLE; - rect_dsc.bg_opa = LV_OPA_70; - rect_dsc.bg_color = lv_color_make(0, 255, 255); - rect_dsc.border_width = 0; - rect_dsc.outline_width = 0; - rect_dsc.shadow_width = 0; - - for (const auto &circle : circles) { - if (!circle.visible) { - continue; - } - lv_area_t coords = { - .x1 = static_cast(obj_coords.x1 + circle.x - circle.radius), - .y1 = static_cast(obj_coords.y1 + circle.y - circle.radius), - .x2 = static_cast(obj_coords.x1 + circle.x + circle.radius - 1), - .y2 = static_cast(obj_coords.y1 + circle.y + circle.radius - 1), - }; - lv_draw_rect(layer, &rect_dsc, &coords); - } -} - -static void invalidate_circle_area(const Circle &circle) { - if (!circle_layer || circle.radius <= 0) { - return; - } - lv_area_t obj_coords; - lv_obj_get_coords(circle_layer, &obj_coords); - lv_area_t coords = { - .x1 = static_cast(obj_coords.x1 + circle.x - circle.radius), - .y1 = static_cast(obj_coords.y1 + circle.y - circle.radius), - .x2 = static_cast(obj_coords.x1 + circle.x + circle.radius - 1), - .y2 = static_cast(obj_coords.y1 + circle.y + circle.radius - 1), - }; - lv_obj_invalidate_area(circle_layer, &coords); -} - -static void draw_circle(int x0, int y0, int radius) { - if (!circle_layer) { - return; - } - lv_obj_move_foreground(circle_layer); - Circle previous_circle = circles[next_circle_index]; - circles[next_circle_index] = {.x = x0, .y = y0, .radius = radius, .visible = true}; - next_circle_index = (next_circle_index + 1) % circles.size(); - if (visible_circle_count < circles.size()) { - visible_circle_count++; - } - if (previous_circle.visible) { - invalidate_circle_area(previous_circle); - } - invalidate_circle_area(circles[(next_circle_index + circles.size() - 1) % circles.size()]); -} - -static void clear_circles() { - for (auto &circle : circles) { - if (circle.visible) { - invalidate_circle_area(circle); - } - circle.visible = false; - } - next_circle_index = 0; - visible_circle_count = 0; -} - ////////////////////////////////////////////////////////////////////////////// // Load the embedded click.wav (stripping the 44-byte WAV header) and report its // size and sample rate. diff --git a/components/esp32-p4-function-ev-board/example/main/gui.cpp b/components/esp32-p4-function-ev-board/example/main/gui.cpp new file mode 100644 index 000000000..2bf3c3b6f --- /dev/null +++ b/components/esp32-p4-function-ev-board/example/main/gui.cpp @@ -0,0 +1,224 @@ +#include "gui.hpp" + +using Board = espp::Esp32P4FunctionEvBoard; + +void Gui::init_ui() { + std::lock_guard lock(mutex_); + init_background(); + init_labels(); + init_buttons(); + init_circle_layer(); + // disable scrolling on the screen (so that it doesn't behave weirdly when + // rotated and drawing with your finger) + lv_obj_set_scrollbar_mode(lv_screen_active(), LV_SCROLLBAR_MODE_OFF); + lv_obj_clear_flag(lv_screen_active(), LV_OBJ_FLAG_SCROLLABLE); +} + +void Gui::deinit_ui() { + std::lock_guard lock(mutex_); + lv_obj_clean(lv_screen_active()); +} + +void Gui::init_background() { + auto &board = Board::get(); + background_ = lv_obj_create(lv_screen_active()); + lv_obj_set_size(background_, board.display_width(), board.display_height()); + lv_obj_set_style_bg_color(background_, lv_color_make(0, 0, 0), 0); +} + +void Gui::init_labels() { + // a title label at the top of the screen + title_label_ = lv_label_create(lv_screen_active()); + lv_label_set_text(title_label_, "ESP32-P4 Function EV Board - touch to draw!"); + lv_obj_align(title_label_, LV_ALIGN_TOP_MID, 0, 8); + + // a status label showing the live subsystem state, updated via + // set_status_text() + status_label_ = lv_label_create(lv_screen_active()); + lv_label_set_text(status_label_, ""); + lv_obj_set_style_text_align(status_label_, LV_TEXT_ALIGN_LEFT, 0); + lv_obj_align(status_label_, LV_ALIGN_TOP_LEFT, 8, 40); +} + +void Gui::init_buttons() { + // a button in the top right which rotates the display through + // 0/90/180/270 degrees + rotate_button_ = lv_btn_create(lv_screen_active()); + lv_obj_set_size(rotate_button_, 50, 50); + lv_obj_align(rotate_button_, LV_ALIGN_TOP_RIGHT, -8, 8); + lv_obj_t *rotate_label = lv_label_create(rotate_button_); + lv_label_set_text(rotate_label, LV_SYMBOL_REFRESH); + lv_obj_align(rotate_label, LV_ALIGN_CENTER, 0, 0); + lv_obj_add_event_cb(rotate_button_, event_callback, LV_EVENT_CLICKED, this); + + // a button next to it which clears the circles + clear_button_ = lv_btn_create(lv_screen_active()); + lv_obj_set_size(clear_button_, 50, 50); + lv_obj_align(clear_button_, LV_ALIGN_TOP_RIGHT, -66, 8); + lv_obj_t *clear_label = lv_label_create(clear_button_); + lv_label_set_text(clear_label, LV_SYMBOL_TRASH); + lv_obj_align(clear_label, LV_ALIGN_CENTER, 0, 0); + lv_obj_add_event_cb(clear_button_, event_callback, LV_EVENT_CLICKED, this); +} + +void Gui::init_circle_layer() { + auto &board = Board::get(); + circle_layer_ = lv_obj_create(lv_screen_active()); + lv_obj_remove_style_all(circle_layer_); + lv_obj_set_size(circle_layer_, board.display_width(), board.display_height()); + lv_obj_align(circle_layer_, LV_ALIGN_CENTER, 0, 0); + lv_obj_clear_flag(circle_layer_, LV_OBJ_FLAG_CLICKABLE); + lv_obj_clear_flag(circle_layer_, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_bg_opa(circle_layer_, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(circle_layer_, 0, 0); + lv_obj_set_style_outline_width(circle_layer_, 0, 0); + lv_obj_set_style_shadow_width(circle_layer_, 0, 0); + lv_obj_add_event_cb(circle_layer_, draw_circle_layer, LV_EVENT_DRAW_MAIN, this); + lv_obj_move_foreground(circle_layer_); +} + +bool Gui::update(std::mutex &m, std::condition_variable &cv) { + { + std::lock_guard lock(mutex_); + lv_task_handler(); + } + std::unique_lock lock(m); + cv.wait_for(lock, std::chrono::milliseconds(16)); + return false; // don't stop the task +} + +void Gui::event_callback(lv_event_t *e) { + auto *gui = static_cast(lv_event_get_user_data(e)); + if (!gui) { + return; + } + switch (lv_event_get_code(e)) { + case LV_EVENT_CLICKED: + gui->on_clicked(e); + break; + default: + break; + } +} + +void Gui::on_clicked(lv_event_t *e) { + const auto *target = static_cast(lv_event_get_target(e)); + if (target == rotate_button_) { + logger_.info("Rotate button clicked"); + next_rotation(); + } else if (target == clear_button_) { + logger_.info("Clear button clicked"); + clear_circles(); + } +} + +void Gui::set_status_text(std::string_view text) { + std::lock_guard lock(mutex_); + lv_label_set_text(status_label_, std::string(text).c_str()); +} + +void Gui::next_rotation() { + std::lock_guard lock(mutex_); + auto &board = Board::get(); + clear_circles_impl(); + auto rotation = lv_display_get_rotation(lv_display_get_default()); + rotation = static_cast((static_cast(rotation) + 1) % 4); + lv_display_set_rotation(lv_display_get_default(), rotation); + // update the size of the screen-filling objects + lv_obj_set_size(background_, board.rotated_display_width(), board.rotated_display_height()); + lv_obj_set_size(circle_layer_, board.rotated_display_width(), board.rotated_display_height()); + lv_obj_align(circle_layer_, LV_ALIGN_CENTER, 0, 0); + lv_obj_move_foreground(circle_layer_); + lv_obj_invalidate(circle_layer_); + // re-align the labels to the (now reoriented) screen edges + lv_obj_align(title_label_, LV_ALIGN_TOP_MID, 0, 8); + lv_obj_align(status_label_, LV_ALIGN_TOP_LEFT, 8, 40); +} + +void Gui::draw_circle(int x, int y, int radius) { + std::lock_guard lock(mutex_); + lv_obj_move_foreground(circle_layer_); + Circle previous_circle = circles_[next_circle_index_]; + circles_[next_circle_index_] = {.x = x, .y = y, .radius = radius, .visible = true}; + next_circle_index_ = (next_circle_index_ + 1) % circles_.size(); + if (visible_circle_count_ < circles_.size()) { + visible_circle_count_++; + } + if (previous_circle.visible) { + invalidate_circle_area(previous_circle); + } + invalidate_circle_area(circles_[(next_circle_index_ + circles_.size() - 1) % circles_.size()]); +} + +void Gui::clear_circles() { + std::lock_guard lock(mutex_); + clear_circles_impl(); +} + +void Gui::clear_circles_impl() { + for (auto &circle : circles_) { + if (circle.visible) { + invalidate_circle_area(circle); + } + circle.visible = false; + } + next_circle_index_ = 0; + visible_circle_count_ = 0; +} + +void Gui::invalidate_circle_area(const Circle &circle) { + if (!circle_layer_ || circle.radius <= 0) { + return; + } + lv_area_t obj_coords; + lv_obj_get_coords(circle_layer_, &obj_coords); + lv_area_t coords = { + .x1 = static_cast(obj_coords.x1 + circle.x - circle.radius), + .y1 = static_cast(obj_coords.y1 + circle.y - circle.radius), + .x2 = static_cast(obj_coords.x1 + circle.x + circle.radius - 1), + .y2 = static_cast(obj_coords.y1 + circle.y + circle.radius - 1), + }; + lv_obj_invalidate_area(circle_layer_, &coords); +} + +void Gui::draw_circle_layer(lv_event_t *e) { + const auto *gui = static_cast(lv_event_get_user_data(e)); + if (!gui) { + return; + } + gui->draw_circles(e); +} + +void Gui::draw_circles(lv_event_t *e) const { + if (visible_circle_count_ == 0) { + return; + } + + auto *obj = static_cast(lv_event_get_current_target(e)); + auto *layer = lv_event_get_layer(e); + lv_area_t obj_coords; + lv_obj_get_coords(obj, &obj_coords); + + lv_draw_rect_dsc_t rect_dsc; + lv_draw_rect_dsc_init(&rect_dsc); + rect_dsc.base.layer = layer; + rect_dsc.radius = LV_RADIUS_CIRCLE; + rect_dsc.bg_opa = LV_OPA_70; + rect_dsc.bg_color = lv_color_make(0, 255, 255); + rect_dsc.border_width = 0; + rect_dsc.outline_width = 0; + rect_dsc.shadow_width = 0; + + for (const auto &circle : circles_) { + if (!circle.visible) { + continue; + } + lv_area_t coords = { + .x1 = static_cast(obj_coords.x1 + circle.x - circle.radius), + .y1 = static_cast(obj_coords.y1 + circle.y - circle.radius), + .x2 = static_cast(obj_coords.x1 + circle.x + circle.radius - 1), + .y2 = static_cast(obj_coords.y1 + circle.y + circle.radius - 1), + }; + lv_draw_rect(layer, &rect_dsc, &coords); + } +} diff --git a/components/esp32-p4-function-ev-board/example/main/gui.hpp b/components/esp32-p4-function-ev-board/example/main/gui.hpp new file mode 100644 index 000000000..8f6836b6f --- /dev/null +++ b/components/esp32-p4-function-ev-board/example/main/gui.hpp @@ -0,0 +1,121 @@ +#pragma once + +#include +#include +#include +#include + +#include "esp32-p4-function-ev-board.hpp" + +#include "logger.hpp" +#include "task.hpp" + +/// The Gui class encapsulates all of the LVGL UI for this example. It follows +/// the recommended pattern for building UIs with espp + LVGL in C++: +/// +/// * All LVGL objects are created in init_ui(), which is broken into small +/// member functions - one per logical piece of the UI. +/// * The class owns the task which calls lv_task_handler(), and a recursive +/// mutex which guards every LVGL call. Public methods lock that mutex, so +/// other tasks (touch callbacks, status tasks, etc.) can safely call them. +/// * LVGL event callbacks are registered with `this` as the user-data and +/// dispatched through a single static trampoline (event_callback) into +/// member functions, keeping all UI logic inside the class. +/// +/// For this example the Gui shows: +/// * A title label at the top of the screen +/// * A status label showing live subsystem state (panel, touch, SD, Ethernet, +/// RTPS, and system memory/uptime), updated via set_status_text() +/// * A button (top-right) which rotates the display through 0/90/180/270 +/// * A button (next to it) which clears the circles drawn on the screen +/// * A custom-drawn layer of circles which trail the most recent touches +class Gui { +public: + /// Configuration for the Gui + struct Config { + espp::Logger::Verbosity log_level{espp::Logger::Verbosity::WARN}; ///< Log verbosity + }; + + /// Construct the Gui: builds the UI and starts the LVGL update task. + /// @param config The configuration for the Gui + explicit Gui(const Config &config) + : logger_({.tag = "Gui", .level = config.log_level}) { + init_ui(); + update_task_.start(); + } + + ~Gui() { + update_task_.stop(); + deinit_ui(); + } + + /// Set the text of the status label. Thread-safe. + /// @param text The text to display + void set_status_text(std::string_view text); + + /// Draw a circle at the given screen coordinates, replacing the oldest + /// circle if the maximum number are already visible. Thread-safe. + /// @param x The x coordinate (screen space) + /// @param y The y coordinate (screen space) + /// @param radius The radius of the circle + void draw_circle(int x, int y, int radius); + + /// Clear all circles from the screen. Thread-safe. + void clear_circles(); + + /// Rotate the display to the next of 0/90/180/270 degrees, resizing / + /// re-aligning the UI to match. Thread-safe. + void next_rotation(); + +protected: + static constexpr size_t MAX_CIRCLES = 100; + + struct Circle { + int x{0}; + int y{0}; + int radius{0}; + bool visible{false}; + }; + + void init_ui(); + void deinit_ui(); + + // the individual pieces of the UI, called from init_ui() + void init_background(); + void init_labels(); + void init_buttons(); + void init_circle_layer(); + + // the LVGL update task: calls lv_task_handler() under the mutex + bool update(std::mutex &m, std::condition_variable &cv); + + // single trampoline for all LVGL events; dispatches to the member + // functions below based on the event target + static void event_callback(lv_event_t *e); + void on_clicked(lv_event_t *e); + + // custom drawing of the circle layer + static void draw_circle_layer(lv_event_t *e); + void draw_circles(lv_event_t *e) const; + void invalidate_circle_area(const Circle &circle); + + // unlocked implementations, called with the mutex held + void clear_circles_impl(); + + // LVGL objects + lv_obj_t *background_{nullptr}; + lv_obj_t *title_label_{nullptr}; + lv_obj_t *status_label_{nullptr}; + lv_obj_t *rotate_button_{nullptr}; + lv_obj_t *clear_button_{nullptr}; + lv_obj_t *circle_layer_{nullptr}; + + std::array circles_; + size_t next_circle_index_{0}; + size_t visible_circle_count_{0}; + + espp::Task update_task_{{.callback = [this](auto &m, auto &cv) { return update(m, cv); }, + .task_config = {.name = "gui", .stack_size_bytes = 6 * 1024}}}; + espp::Logger logger_; + std::recursive_mutex mutex_; +}; diff --git a/components/m5stack-tab5/example/main/gui.cpp b/components/m5stack-tab5/example/main/gui.cpp new file mode 100644 index 000000000..ea312e8e0 --- /dev/null +++ b/components/m5stack-tab5/example/main/gui.cpp @@ -0,0 +1,295 @@ +#include + +#include "gui.hpp" + +void Gui::init_ui() { + std::lock_guard lock(mutex_); + init_background(); + init_label(); + init_gravity_lines(); + init_buttons(); + init_circle_layer(); + // disable scrolling on the screen (so that it doesn't behave weirdly when + // rotated and drawing with your finger) + lv_obj_set_scrollbar_mode(lv_screen_active(), LV_SCROLLBAR_MODE_OFF); + lv_obj_clear_flag(lv_screen_active(), LV_OBJ_FLAG_SCROLLABLE); +} + +void Gui::deinit_ui() { + std::lock_guard lock(mutex_); + lv_obj_clean(lv_screen_active()); +} + +void Gui::init_background() { + auto &tab5 = espp::M5StackTab5::get(); + background_ = lv_obj_create(lv_screen_active()); + lv_obj_set_size(background_, tab5.display_width(), tab5.display_height()); + lv_obj_set_style_bg_color(background_, lv_color_make(0, 0, 0), 0); +} + +void Gui::init_label() { + label_ = lv_label_create(lv_screen_active()); + lv_label_set_text(label_, ""); + lv_obj_align(label_, LV_ALIGN_TOP_LEFT, 0, 0); + lv_obj_set_style_text_align(label_, LV_TEXT_ALIGN_LEFT, 0); +} + +void Gui::init_gravity_lines() { + auto &tab5 = espp::M5StackTab5::get(); + + lv_style_init(&kalman_line_style_); + lv_style_set_line_width(&kalman_line_style_, 8); + lv_style_set_line_color(&kalman_line_style_, lv_palette_main(LV_PALETTE_BLUE)); + lv_style_set_line_rounded(&kalman_line_style_, true); + + kalman_line_ = lv_line_create(lv_screen_active()); + kalman_line_points_[0] = {0, 0}; + kalman_line_points_[1] = {tab5.display_width(), tab5.display_height()}; + lv_line_set_points(kalman_line_, kalman_line_points_, 2); + lv_obj_add_style(kalman_line_, &kalman_line_style_, 0); + + lv_style_init(&madgwick_line_style_); + lv_style_set_line_width(&madgwick_line_style_, 8); + lv_style_set_line_color(&madgwick_line_style_, lv_palette_main(LV_PALETTE_RED)); + lv_style_set_line_rounded(&madgwick_line_style_, true); + + madgwick_line_ = lv_line_create(lv_screen_active()); + madgwick_line_points_[0] = {0, 0}; + madgwick_line_points_[1] = {tab5.display_width(), tab5.display_height()}; + lv_line_set_points(madgwick_line_, madgwick_line_points_, 2); + lv_obj_add_style(madgwick_line_, &madgwick_line_style_, 0); +} + +void Gui::init_buttons() { + // a button in the top left which rotates the display through + // 0/90/180/270 degrees + rotate_button_ = lv_btn_create(lv_screen_active()); + lv_obj_set_size(rotate_button_, 50, 50); + lv_obj_align(rotate_button_, LV_ALIGN_TOP_LEFT, 0, 0); + lv_obj_t *rotate_label = lv_label_create(rotate_button_); + lv_label_set_text(rotate_label, LV_SYMBOL_REFRESH); + lv_obj_align(rotate_label, LV_ALIGN_CENTER, 0, 0); + lv_obj_add_event_cb(rotate_button_, event_callback, LV_EVENT_PRESSED, this); + + // a button next to it which cycles the backlight brightness through + // 25/50/75/100% + brightness_button_ = lv_btn_create(lv_screen_active()); + lv_obj_set_size(brightness_button_, 50, 50); + lv_obj_align(brightness_button_, LV_ALIGN_TOP_LEFT, 60, 0); + lv_obj_t *brightness_label = lv_label_create(brightness_button_); + lv_label_set_text(brightness_label, LV_SYMBOL_EYE_OPEN); + lv_obj_align(brightness_label, LV_ALIGN_CENTER, 0, 0); + lv_obj_add_event_cb(brightness_button_, event_callback, LV_EVENT_PRESSED, this); + + // a button in the top right which clears the circles + clear_button_ = lv_btn_create(lv_screen_active()); + lv_obj_set_size(clear_button_, 50, 50); + lv_obj_align(clear_button_, LV_ALIGN_TOP_RIGHT, 0, 0); + lv_obj_t *clear_label = lv_label_create(clear_button_); + lv_label_set_text(clear_label, LV_SYMBOL_TRASH); + lv_obj_align(clear_label, LV_ALIGN_CENTER, 0, 0); + lv_obj_add_event_cb(clear_button_, event_callback, LV_EVENT_PRESSED, this); +} + +void Gui::init_circle_layer() { + auto &tab5 = espp::M5StackTab5::get(); + circle_layer_ = lv_obj_create(lv_screen_active()); + lv_obj_remove_style_all(circle_layer_); + lv_obj_set_size(circle_layer_, tab5.display_width(), tab5.display_height()); + lv_obj_align(circle_layer_, LV_ALIGN_CENTER, 0, 0); + lv_obj_clear_flag(circle_layer_, LV_OBJ_FLAG_CLICKABLE); + lv_obj_clear_flag(circle_layer_, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_bg_opa(circle_layer_, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(circle_layer_, 0, 0); + lv_obj_set_style_outline_width(circle_layer_, 0, 0); + lv_obj_set_style_shadow_width(circle_layer_, 0, 0); + lv_obj_add_event_cb(circle_layer_, draw_circle_layer, LV_EVENT_DRAW_MAIN, this); + lv_obj_move_foreground(circle_layer_); +} + +bool Gui::update(std::mutex &m, std::condition_variable &cv) { + { + std::lock_guard lock(mutex_); + lv_task_handler(); + } + std::unique_lock lock(m); + cv.wait_for(lock, std::chrono::milliseconds(16)); + return false; // don't stop the task +} + +void Gui::event_callback(lv_event_t *e) { + auto *gui = static_cast(lv_event_get_user_data(e)); + if (!gui) { + return; + } + switch (lv_event_get_code(e)) { + case LV_EVENT_PRESSED: + gui->on_pressed(e); + break; + default: + break; + } +} + +void Gui::on_pressed(lv_event_t *e) { + const auto *target = static_cast(lv_event_get_target(e)); + if (target == rotate_button_) { + logger_.info("Rotate button pressed"); + next_rotation(); + } else if (target == brightness_button_) { + logger_.info("Brightness button pressed"); + cycle_brightness(); + } else if (target == clear_button_) { + logger_.info("Clear button pressed"); + clear_circles(); + } +} + +void Gui::set_label_text(std::string_view text) { + std::lock_guard lock(mutex_); + lv_label_set_text(label_, std::string(text).c_str()); +} + +void Gui::next_rotation() { + std::lock_guard lock(mutex_); + auto &tab5 = espp::M5StackTab5::get(); + clear_circles_impl(); + auto rotation = lv_display_get_rotation(lv_display_get_default()); + rotation = static_cast((static_cast(rotation) + 1) % 4); + lv_display_set_rotation(lv_display_get_default(), rotation); + // update the size of the screen-filling objects + lv_obj_set_size(background_, tab5.rotated_display_width(), tab5.rotated_display_height()); + lv_obj_set_size(circle_layer_, tab5.rotated_display_width(), tab5.rotated_display_height()); + lv_obj_align(circle_layer_, LV_ALIGN_CENTER, 0, 0); + lv_obj_invalidate(circle_layer_); +} + +void Gui::cycle_brightness() { + std::lock_guard lock(mutex_); + brightness_index_ = (brightness_index_ + 1) % BRIGHTNESS_LEVELS.size(); + float new_brightness = BRIGHTNESS_LEVELS[brightness_index_]; + espp::M5StackTab5::get().brightness(new_brightness); + logger_.info("Set brightness to {:.0f}%", new_brightness); +} + +void Gui::set_kalman_down(float vx, float vy) { + std::lock_guard lock(mutex_); + set_down_line(kalman_line_, kalman_line_points_, vx, vy); +} + +void Gui::set_madgwick_down(float vx, float vy) { + std::lock_guard lock(mutex_); + set_down_line(madgwick_line_, madgwick_line_points_, vx, vy); +} + +void Gui::set_down_line(lv_obj_t *line, lv_point_precise_t *points, float vx, float vy) { + auto &tab5 = espp::M5StackTab5::get(); + // remap the vector according to the current display rotation so that the + // line always points toward physical "down" + auto rotation = lv_display_get_rotation(lv_display_get_default()); + if (rotation == LV_DISPLAY_ROTATION_90) { + std::swap(vx, vy); + vx = -vx; + } else if (rotation == LV_DISPLAY_ROTATION_180) { + vx = -vx; + vy = -vy; + } else if (rotation == LV_DISPLAY_ROTATION_270) { + std::swap(vx, vy); + vy = -vy; + } + // draw the line from the center of the screen toward "down" + int x0 = tab5.rotated_display_width() / 2; + int y0 = tab5.rotated_display_height() / 2; + points[0] = {static_cast(x0), static_cast(y0)}; + points[1] = {static_cast(x0 + 50 * vx), + static_cast(y0 + 50 * vy)}; + lv_line_set_points(line, points, 2); +} + +void Gui::draw_circle(int x, int y, int radius) { + std::lock_guard lock(mutex_); + lv_obj_move_foreground(circle_layer_); + Circle previous_circle = circles_[next_circle_index_]; + circles_[next_circle_index_] = {.x = x, .y = y, .radius = radius, .visible = true}; + next_circle_index_ = (next_circle_index_ + 1) % circles_.size(); + if (visible_circle_count_ < circles_.size()) { + visible_circle_count_++; + } + if (previous_circle.visible) { + invalidate_circle_area(previous_circle); + } + invalidate_circle_area(circles_[(next_circle_index_ + circles_.size() - 1) % circles_.size()]); +} + +void Gui::clear_circles() { + std::lock_guard lock(mutex_); + clear_circles_impl(); +} + +void Gui::clear_circles_impl() { + for (auto &circle : circles_) { + if (circle.visible) { + invalidate_circle_area(circle); + } + circle.visible = false; + } + next_circle_index_ = 0; + visible_circle_count_ = 0; +} + +void Gui::invalidate_circle_area(const Circle &circle) { + if (!circle_layer_ || circle.radius <= 0) { + return; + } + lv_area_t obj_coords; + lv_obj_get_coords(circle_layer_, &obj_coords); + lv_area_t coords = { + .x1 = static_cast(obj_coords.x1 + circle.x - circle.radius), + .y1 = static_cast(obj_coords.y1 + circle.y - circle.radius), + .x2 = static_cast(obj_coords.x1 + circle.x + circle.radius - 1), + .y2 = static_cast(obj_coords.y1 + circle.y + circle.radius - 1), + }; + lv_obj_invalidate_area(circle_layer_, &coords); +} + +void Gui::draw_circle_layer(lv_event_t *e) { + const auto *gui = static_cast(lv_event_get_user_data(e)); + if (!gui) { + return; + } + gui->draw_circles(e); +} + +void Gui::draw_circles(lv_event_t *e) const { + if (visible_circle_count_ == 0) { + return; + } + + auto *obj = static_cast(lv_event_get_current_target(e)); + auto *layer = lv_event_get_layer(e); + lv_area_t obj_coords; + lv_obj_get_coords(obj, &obj_coords); + + lv_draw_rect_dsc_t rect_dsc; + lv_draw_rect_dsc_init(&rect_dsc); + rect_dsc.base.layer = layer; + rect_dsc.radius = LV_RADIUS_CIRCLE; + rect_dsc.bg_opa = LV_OPA_70; + rect_dsc.bg_color = lv_color_make(0, 255, 255); + rect_dsc.border_width = 0; + rect_dsc.outline_width = 0; + rect_dsc.shadow_width = 0; + + for (const auto &circle : circles_) { + if (!circle.visible) { + continue; + } + lv_area_t coords = { + .x1 = static_cast(obj_coords.x1 + circle.x - circle.radius), + .y1 = static_cast(obj_coords.y1 + circle.y - circle.radius), + .x2 = static_cast(obj_coords.x1 + circle.x + circle.radius - 1), + .y2 = static_cast(obj_coords.y1 + circle.y + circle.radius - 1), + }; + lv_draw_rect(layer, &rect_dsc, &coords); + } +} diff --git a/components/m5stack-tab5/example/main/gui.hpp b/components/m5stack-tab5/example/main/gui.hpp new file mode 100644 index 000000000..a6f3c85aa --- /dev/null +++ b/components/m5stack-tab5/example/main/gui.hpp @@ -0,0 +1,160 @@ +#pragma once + +#include +#include +#include +#include + +#include "m5stack-tab5.hpp" + +#include "logger.hpp" +#include "task.hpp" + +/// The Gui class encapsulates all of the LVGL UI for this example. It follows +/// the recommended pattern for building UIs with espp + LVGL in C++: +/// +/// * All LVGL objects are created in init_ui(), which is broken into small +/// member functions - one per logical piece of the UI. +/// * The class owns the task which calls lv_task_handler(), and a recursive +/// mutex which guards every LVGL call. Public methods lock that mutex, so +/// other tasks (touch callbacks, sensor tasks, etc.) can safely call them. +/// * LVGL event callbacks are registered with `this` as the user-data and +/// dispatched through a single static trampoline (event_callback) into +/// member functions, keeping all UI logic inside the class. +/// +/// For this example the Gui shows: +/// * A label with instructions and live battery / RTC / IMU data +/// * Two lines showing the direction of gravity ("down") as computed by a +/// Kalman filter (blue) and a Madgwick filter (red) +/// * A button (top-left) which rotates the display through 0/90/180/270 +/// * A button (next to it) which cycles the backlight through 25/50/75/100% +/// * A button (top-right) which clears the circles drawn on the screen +/// * A custom-drawn layer of circles which trail the most recent touches +class Gui { +public: + /// Configuration for the Gui + struct Config { + espp::Logger::Verbosity log_level{espp::Logger::Verbosity::WARN}; ///< Log verbosity + }; + + /// Construct the Gui: builds the UI and starts the LVGL update task. + /// @param config The configuration for the Gui + explicit Gui(const Config &config) + : logger_({.tag = "Gui", .level = config.log_level}) { + init_ui(); + update_task_.start(); + } + + ~Gui() { + update_task_.stop(); + deinit_ui(); + } + + /// Set the text of the main label. Thread-safe. + /// @param text The text to display + void set_label_text(std::string_view text); + + /// Draw a circle at the given screen coordinates, replacing the oldest + /// circle if the maximum number are already visible. Thread-safe. + /// @param x The x coordinate (screen space) + /// @param y The y coordinate (screen space) + /// @param radius The radius of the circle + void draw_circle(int x, int y, int radius); + + /// Clear all circles from the screen. Thread-safe. + void clear_circles(); + + /// Rotate the display to the next of 0/90/180/270 degrees, resizing / + /// re-aligning the UI to match. Thread-safe. + void next_rotation(); + + /// Cycle the display backlight through 25/50/75/100%. Called by the + /// on-screen brightness button, and can also be called from other tasks + /// (e.g. the hardware button callback). Thread-safe. + void cycle_brightness(); + + /// Update the line showing "down" according to the Kalman filter. The + /// vector is provided in the display's natural (unrotated) frame; the Gui + /// accounts for the current display rotation. Thread-safe. + /// @param vx The x component of the gravity vector + /// @param vy The y component of the gravity vector + void set_kalman_down(float vx, float vy); + + /// Update the line showing "down" according to the Madgwick filter. The + /// vector is provided in the display's natural (unrotated) frame; the Gui + /// accounts for the current display rotation. Thread-safe. + /// @param vx The x component of the gravity vector + /// @param vy The y component of the gravity vector + void set_madgwick_down(float vx, float vy); + +protected: + static constexpr size_t MAX_CIRCLES = 100; + + struct Circle { + int x{0}; + int y{0}; + int radius{0}; + bool visible{false}; + }; + + void init_ui(); + void deinit_ui(); + + // the individual pieces of the UI, called from init_ui() + void init_background(); + void init_label(); + void init_gravity_lines(); + void init_buttons(); + void init_circle_layer(); + + // update the given "down" line from a vector in the unrotated display + // frame, remapping for the current display rotation + void set_down_line(lv_obj_t *line, lv_point_precise_t *points, float vx, float vy); + + // the LVGL update task: calls lv_task_handler() under the mutex + bool update(std::mutex &m, std::condition_variable &cv); + + // single trampoline for all LVGL events; dispatches to the member + // functions below based on the event target + static void event_callback(lv_event_t *e); + void on_pressed(lv_event_t *e); + + // custom drawing of the circle layer + static void draw_circle_layer(lv_event_t *e); + void draw_circles(lv_event_t *e) const; + void invalidate_circle_area(const Circle &circle); + + // unlocked implementations, called with the mutex held + void clear_circles_impl(); + + // LVGL objects + lv_obj_t *background_{nullptr}; + lv_obj_t *label_{nullptr}; + lv_obj_t *kalman_line_{nullptr}; + lv_obj_t *madgwick_line_{nullptr}; + lv_obj_t *rotate_button_{nullptr}; + lv_obj_t *brightness_button_{nullptr}; + lv_obj_t *clear_button_{nullptr}; + lv_obj_t *circle_layer_{nullptr}; + + lv_style_t kalman_line_style_; + lv_style_t madgwick_line_style_; + lv_point_precise_t kalman_line_points_[2]; + lv_point_precise_t madgwick_line_points_[2]; + + std::array circles_; + size_t next_circle_index_{0}; + size_t visible_circle_count_{0}; + + // brightness levels (percent) cycled through by cycle_brightness(); the + // index starts at 75% to match the brightness set in app_main + static constexpr std::array BRIGHTNESS_LEVELS = {25.0f, 50.0f, 75.0f, 100.0f}; + size_t brightness_index_{2}; + + espp::Task update_task_{ + {.callback = [this](auto &m, auto &cv) { return update(m, cv); }, + .task_config = { + .name = "gui", .stack_size_bytes = 10 * 1024, .priority = 20, .core_id = 1}}}; + espp::Logger logger_; + std::recursive_mutex mutex_; +}; diff --git a/components/m5stack-tab5/example/main/m5stack_tab5_example.cpp b/components/m5stack-tab5/example/main/m5stack_tab5_example.cpp index 6efab66df..532a5e246 100644 --- a/components/m5stack-tab5/example/main/m5stack_tab5_example.cpp +++ b/components/m5stack-tab5/example/main/m5stack_tab5_example.cpp @@ -7,37 +7,20 @@ * and communication interfaces. */ -#include #include +#include #include #include #include "m5stack-tab5.hpp" +#include "gui.hpp" #include "kalman_filter.hpp" #include "madgwick_filter.hpp" using namespace std::chrono_literals; -static constexpr size_t MAX_CIRCLES = 100; -struct Circle { - int x{0}; - int y{0}; - int radius{0}; - bool visible{false}; -}; -static std::array circles; -static size_t next_circle_index = 0; -static size_t visible_circle_count = 0; static std::vector audio_bytes; -static lv_obj_t *circle_layer = nullptr; - -static std::recursive_mutex lvgl_mutex; -static bool initialize_circle_layer(int width, int height); -static void draw_circle_layer(lv_event_t *event); -static void invalidate_circle_area(const Circle &circle); -static void draw_circle(int x0, int y0, int radius); -static void clear_circles(); static bool load_audio(size_t &out_size, size_t &out_sample_rate); static void play_click(espp::M5StackTab5 &tab5); @@ -84,30 +67,6 @@ extern "C" void app_main(void) { return; } - auto touch_callback = [&](const auto &touch) { - // NOTE: since we're directly using the touchpad data, and not using the - // TouchpadInput + LVGL, we'll need to ensure the touchpad data is - // converted into proper screen coordinates instead of simply using the - // raw values. - static auto previous_touchpad_data = tab5.touchpad_convert(touch); - auto touchpad_data = tab5.touchpad_convert(touch); - if (touchpad_data != previous_touchpad_data) { - logger.debug("Touch: {}", touchpad_data); - previous_touchpad_data = touchpad_data; - // if the button is pressed, clear the circles - if (touchpad_data.btn_state) { - std::lock_guard lock(lvgl_mutex); - clear_circles(); - } - // if there is a touch point, draw a circle and play a click sound - if (touchpad_data.num_touch_points > 0) { - play_click(tab5); - std::lock_guard lock(lvgl_mutex); - draw_circle(touchpad_data.x, touchpad_data.y, 10); - } - } - }; - // make the filter we'll use for the IMU to compute the orientation static constexpr float angle_noise = 0.001f; static constexpr float rate_noise = 0.1f; @@ -222,130 +181,60 @@ extern "C" void app_main(void) { return; } - // Brightness control with button + // create the GUI: builds the UI (label, buttons, gravity lines, circle + // layer) and starts the task which updates LVGL. All of its public methods + // are thread-safe, so the touch callback, button callback, and data display + // task below can call them directly. + logger.info("Setting up LVGL UI..."); + static Gui gui({.log_level = espp::Logger::Verbosity::INFO}); + static const std::string instructions = + fmt::format("\n\n\n\nTouch the screen!\nPress the {} button to clear circles.\nPress the {} " + "button to rotate the display.\nPress the {} button to cycle the brightness.", + LV_SYMBOL_TRASH, LV_SYMBOL_REFRESH, LV_SYMBOL_EYE_OPEN); + gui.set_label_text(instructions); + + // Brightness control with the hardware button: cycle through the same + // 25/50/75/100% levels as the on-screen brightness button logger.info("Initializing button..."); auto button_callback = [&](const auto &state) { logger.info("Button state: {}", state.active); if (state.active) { - // Cycle through brightness levels: 25%, 50%, 75%, 100% - static int brightness_level = 0; - float brightness_values[] = {0.25f, 0.5f, 0.75f, 1.0f}; - brightness_level = (brightness_level + 1) % 4; - float new_brightness = brightness_values[brightness_level]; - tab5.brightness(new_brightness); - logger.info("Set brightness to {:.0f}%", new_brightness * 100); + gui.cycle_brightness(); } }; if (!tab5.initialize_button(button_callback)) { logger.warn("Failed to initialize button"); } - logger.info("Setting up LVGL UI..."); - // set the background color to black - lv_obj_t *bg = lv_obj_create(lv_screen_active()); - lv_obj_set_size(bg, tab5.display_width(), tab5.display_height()); - lv_obj_set_style_bg_color(bg, lv_color_make(0, 0, 0), 0); - if (!initialize_circle_layer(tab5.display_width(), tab5.display_height())) { - logger.error("Failed to initialize circle layer!"); - return; - } - - // add text in the center of the screen - lv_obj_t *label = lv_label_create(lv_screen_active()); - static std::string label_text = "\n\n\n\nTouch the screen!"; - lv_label_set_text(label, label_text.c_str()); - lv_obj_align(label, LV_ALIGN_TOP_LEFT, 0, 0); - lv_obj_set_style_text_align(label, LV_TEXT_ALIGN_LEFT, 0); - - // Create style for line 0 (blue line, used for kalman filter) - static lv_style_t style_line0; - lv_style_init(&style_line0); - lv_style_set_line_width(&style_line0, 8); - lv_style_set_line_color(&style_line0, lv_palette_main(LV_PALETTE_BLUE)); - lv_style_set_line_rounded(&style_line0, true); - - // make a line for showing the direction of "down" - lv_obj_t *line0 = lv_line_create(lv_screen_active()); - static lv_point_precise_t line_points0[] = {{0, 0}, - {tab5.display_width(), tab5.display_height()}}; - lv_line_set_points(line0, line_points0, 2); - lv_obj_add_style(line0, &style_line0, 0); - - // Create style for line 1 (red line, used for madgwick filter) - static lv_style_t style_line1; - lv_style_init(&style_line1); - lv_style_set_line_width(&style_line1, 8); - lv_style_set_line_color(&style_line1, lv_palette_main(LV_PALETTE_RED)); - lv_style_set_line_rounded(&style_line1, true); - - // make a line for showing the direction of "down" - lv_obj_t *line1 = lv_line_create(lv_screen_active()); - static lv_point_precise_t line_points1[] = {{0, 0}, - {tab5.display_width(), tab5.display_height()}}; - lv_line_set_points(line1, line_points1, 2); - lv_obj_add_style(line1, &style_line1, 0); - - static auto rotate_display = [&]() { - std::lock_guard lock(lvgl_mutex); - clear_circles(); - static auto rotation = LV_DISPLAY_ROTATION_0; - rotation = static_cast((static_cast(rotation) + 1) % 4); - lv_display_t *disp = lv_display_get_default(); - lv_disp_set_rotation(disp, rotation); - // update the size of the screen - lv_obj_set_size(bg, tab5.rotated_display_width(), tab5.rotated_display_height()); - if (circle_layer) { - lv_obj_set_size(circle_layer, tab5.rotated_display_width(), tab5.rotated_display_height()); - lv_obj_align(circle_layer, LV_ALIGN_CENTER, 0, 0); - lv_obj_move_foreground(circle_layer); - lv_obj_invalidate(circle_layer); + // initialize the touchpad; each touch draws a circle (and plays a click + // sound), while the touchscreen's button clears the circles + auto touch_callback = [&](const auto &touch) { + // NOTE: since we're directly using the touchpad data, and not using the + // TouchpadInput + LVGL, we'll need to ensure the touchpad data is + // converted into proper screen coordinates instead of simply using the + // raw values. + static auto previous_touchpad_data = tab5.touchpad_convert(touch); + auto touchpad_data = tab5.touchpad_convert(touch); + if (touchpad_data != previous_touchpad_data) { + logger.debug("Touch: {}", touchpad_data); + previous_touchpad_data = touchpad_data; + // if the button is pressed, clear the circles + if (touchpad_data.btn_state) { + gui.clear_circles(); + } + // if there is a touch point, draw a circle and play a click sound + if (touchpad_data.num_touch_points > 0) { + play_click(tab5); + gui.draw_circle(touchpad_data.x, touchpad_data.y, 10); + } } }; - - // add a button in the top left which (when pressed) will rotate the display - // through 0, 90, 180, 270 degrees - lv_obj_t *btn = lv_btn_create(lv_screen_active()); - lv_obj_set_size(btn, 50, 50); - lv_obj_align(btn, LV_ALIGN_TOP_LEFT, 0, 0); - lv_obj_t *label_btn = lv_label_create(btn); - lv_label_set_text(label_btn, LV_SYMBOL_REFRESH); - // center the text in the button - lv_obj_align(label_btn, LV_ALIGN_CENTER, 0, 0); - lv_obj_add_event_cb( - btn, [](auto event) { rotate_display(); }, LV_EVENT_PRESSED, nullptr); - - // disable scrolling on the screen (so that it doesn't behave weirdly when - // rotated and drawing with your finger) - lv_obj_set_scrollbar_mode(lv_screen_active(), LV_SCROLLBAR_MODE_OFF); - lv_obj_clear_flag(lv_screen_active(), LV_OBJ_FLAG_SCROLLABLE); - lv_obj_move_foreground(circle_layer); - logger.info("Initializing touch..."); if (!tab5.initialize_touch(touch_callback)) { logger.error("Failed to initialize touch!"); return; } - // start a simple thread to do the lv_task_handler every 16ms - logger.info("Starting LVGL task..."); - espp::Task lv_task({.callback = [](std::mutex &m, std::condition_variable &cv) -> bool { - auto start_time = std::chrono::high_resolution_clock::now(); - { - std::lock_guard lock(lvgl_mutex); - lv_task_handler(); - } - std::unique_lock lock(m); - cv.wait_until(lock, start_time + 16ms, []() { return false; }); - return false; - }, - .task_config = { - .name = "lv_task", - .stack_size_bytes = 10 * 1024, - .priority = 20, - .core_id = 1, - }}); - lv_task.start(); - // load the audio file (wav file bundled in memory) size_t wav_size = 0; size_t wav_sample_rate = 0; @@ -416,24 +305,11 @@ extern "C" void app_main(void) { auto temp = imu->get_temperature(); auto orientation = imu->get_orientation(); auto gravity_vector = imu->get_gravity_vector(); - // invert the axes + // invert the axes to convert from the sensor frame to the display's + // natural (unrotated) frame gravity_vector.y = -gravity_vector.y; gravity_vector.x = -gravity_vector.x; - // now update the gravity vector line to show the direction of "down" - // taking into account the configured rotation of the display - auto rotation = lv_display_get_rotation(lv_display_get_default()); - if (rotation == LV_DISPLAY_ROTATION_90) { - std::swap(gravity_vector.x, gravity_vector.y); - gravity_vector.x = -gravity_vector.x; - } else if (rotation == LV_DISPLAY_ROTATION_180) { - gravity_vector.x = -gravity_vector.x; - gravity_vector.y = -gravity_vector.y; - } else if (rotation == LV_DISPLAY_ROTATION_270) { - std::swap(gravity_vector.x, gravity_vector.y); - gravity_vector.y = -gravity_vector.y; - } - // separator for imu std::string imu_text = "\nIMU Data:\n"; imu_text += fmt::format("Accel: {:02.2f} {:02.2f} {:02.2f}\n", accel.x, accel.y, accel.z); @@ -443,64 +319,28 @@ extern "C" void app_main(void) { espp::rad_to_deg(orientation.pitch)); imu_text += fmt::format("Temp: {:02.1f} C\n", temp); - // use the pitch to to draw a line on the screen indiating the - // direction from the center of the screen to "down" - int x0 = tab5.rotated_display_width() / 2; - int y0 = tab5.rotated_display_height() / 2; - - int x1 = x0 + 50 * gravity_vector.x; - int y1 = y0 + 50 * gravity_vector.y; - - static lv_point_precise_t line_points0[] = {{x0, y0}, {x1, y1}}; - line_points0[0].x = x0; - line_points0[0].y = y0; - line_points0[1].x = x1; - line_points0[1].y = y1; - - // Now show the madgwick filter + // Now show the madgwick filter's estimate of "down" auto madgwick_orientation = madgwick_filter_fn(dt, accel, gyro); float roll = madgwick_orientation.roll; float pitch = madgwick_orientation.pitch; - [[maybe_unused]] float yaw = madgwick_orientation.yaw; float vx = sin(pitch); float vy = -cos(pitch) * sin(roll); - [[maybe_unused]] float vz = -cos(pitch) * cos(roll); - // invert the axes + // invert the axes to convert from the sensor frame to the display's + // natural (unrotated) frame vx = -vx; vy = -vy; - // now update the line to show the direction of "down" based on the - // configured rotation of the display - if (rotation == LV_DISPLAY_ROTATION_90) { - std::swap(vx, vy); - vx = -vx; - } else if (rotation == LV_DISPLAY_ROTATION_180) { - vx = -vx; - vy = -vy; - } else if (rotation == LV_DISPLAY_ROTATION_270) { - std::swap(vx, vy); - vy = -vy; - } - - x1 = x0 + 50 * vx; - y1 = y0 + 50 * vy; - - static lv_point_precise_t line_points1[] = {{x0, y0}, {x1, y1}}; - line_points1[0].x = x0; - line_points1[0].y = y0; - line_points1[1].x = x1; - line_points1[1].y = y1; - - std::string text = fmt::format("{}\n\n\n\n\n", label_text); + std::string text = fmt::format("{}\n\n\n\n\n", instructions); text += battery_text; text += rtc_text; text += imu_text; - std::lock_guard lock(lvgl_mutex); - lv_label_set_text(label, text.c_str()); - lv_line_set_points(line0, line_points0, 2); - lv_line_set_points(line1, line_points1, 2); + // update the GUI with the new data; the Gui handles remapping the + // vectors for the current display rotation + gui.set_label_text(text); + gui.set_kalman_down(gravity_vector.x, gravity_vector.y); + gui.set_madgwick_down(vx, vy); return false; }, @@ -519,104 +359,6 @@ extern "C" void app_main(void) { //! [m5stack tab5 example] } -static bool initialize_circle_layer(int width, int height) { - if (circle_layer) { - return true; - } - circle_layer = lv_obj_create(lv_screen_active()); - if (!circle_layer) { - return false; - } - lv_obj_remove_style_all(circle_layer); - lv_obj_set_size(circle_layer, width, height); - lv_obj_align(circle_layer, LV_ALIGN_CENTER, 0, 0); - lv_obj_clear_flag(circle_layer, LV_OBJ_FLAG_CLICKABLE); - lv_obj_clear_flag(circle_layer, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_set_style_bg_opa(circle_layer, LV_OPA_TRANSP, 0); - lv_obj_set_style_border_width(circle_layer, 0, 0); - lv_obj_set_style_outline_width(circle_layer, 0, 0); - lv_obj_set_style_shadow_width(circle_layer, 0, 0); - lv_obj_add_event_cb(circle_layer, draw_circle_layer, LV_EVENT_DRAW_MAIN, nullptr); - return true; -} - -static void draw_circle_layer(lv_event_t *event) { - if (visible_circle_count == 0) { - return; - } - auto *obj = static_cast(lv_event_get_current_target(event)); - auto *layer = lv_event_get_layer(event); - lv_area_t obj_coords; - lv_obj_get_coords(obj, &obj_coords); - - lv_draw_rect_dsc_t rect_dsc; - lv_draw_rect_dsc_init(&rect_dsc); - rect_dsc.base.layer = layer; - rect_dsc.radius = LV_RADIUS_CIRCLE; - rect_dsc.bg_opa = LV_OPA_70; - rect_dsc.bg_color = lv_color_make(0, 255, 255); - rect_dsc.border_width = 0; - rect_dsc.outline_width = 0; - rect_dsc.shadow_width = 0; - - for (const auto &circle : circles) { - if (!circle.visible) { - continue; - } - lv_area_t coords = { - .x1 = static_cast(obj_coords.x1 + circle.x - circle.radius), - .y1 = static_cast(obj_coords.y1 + circle.y - circle.radius), - .x2 = static_cast(obj_coords.x1 + circle.x + circle.radius - 1), - .y2 = static_cast(obj_coords.y1 + circle.y + circle.radius - 1), - }; - lv_draw_rect(layer, &rect_dsc, &coords); - } -} - -static void invalidate_circle_area(const Circle &circle) { - if (!circle_layer || circle.radius <= 0) { - return; - } - - lv_area_t obj_coords; - lv_obj_get_coords(circle_layer, &obj_coords); - lv_area_t coords = { - .x1 = static_cast(obj_coords.x1 + circle.x - circle.radius), - .y1 = static_cast(obj_coords.y1 + circle.y - circle.radius), - .x2 = static_cast(obj_coords.x1 + circle.x + circle.radius - 1), - .y2 = static_cast(obj_coords.y1 + circle.y + circle.radius - 1), - }; - lv_obj_invalidate_area(circle_layer, &coords); -} - -static void draw_circle(int x0, int y0, int radius) { - if (!circle_layer) { - return; - } - lv_obj_move_foreground(circle_layer); - Circle previous_circle = circles[next_circle_index]; - circles[next_circle_index] = {.x = x0, .y = y0, .radius = radius, .visible = true}; - next_circle_index = (next_circle_index + 1) % circles.size(); - if (visible_circle_count < circles.size()) { - visible_circle_count++; - } - if (previous_circle.visible) { - invalidate_circle_area(previous_circle); - } - invalidate_circle_area(circles[(next_circle_index + circles.size() - 1) % circles.size()]); -} - -static void clear_circles() { - for (auto &circle : circles) { - if (circle.visible) { - invalidate_circle_area(circle); - } - circle.visible = false; - } - next_circle_index = 0; - visible_circle_count = 0; -} - static bool load_audio(size_t &out_size, size_t &out_sample_rate) { // if the audio_bytes vector is already populated, return the size if (audio_bytes.size() > 0) { diff --git a/components/m5stack-tab5/src/touchpad.cpp b/components/m5stack-tab5/src/touchpad.cpp index 753bd9bde..c57d78357 100755 --- a/components/m5stack-tab5/src/touchpad.cpp +++ b/components/m5stack-tab5/src/touchpad.cpp @@ -63,6 +63,7 @@ bool M5StackTab5::initialize_touch(const touch_callback_t &callback) { // handler on this board. .write = espp::make_i2c_addressed_write(touch_i2c_device_), .read = espp::make_i2c_addressed_read(touch_i2c_device_), + .write_then_read = nullptr, .address = St7123TouchDriver::DEFAULT_ADDRESS, .log_level = espp::Logger::Verbosity::WARN}); touch_driver_ = espp::make_touch_driver(std::move(driver)); diff --git a/components/matouch-rotary-display/example/main/gui.cpp b/components/matouch-rotary-display/example/main/gui.cpp new file mode 100644 index 000000000..b3ba48722 --- /dev/null +++ b/components/matouch-rotary-display/example/main/gui.cpp @@ -0,0 +1,213 @@ +#include "gui.hpp" + +void Gui::init_ui() { + std::lock_guard lock(mutex_); + init_background(); + init_label(); + init_buttons(); + init_circle_layer(); + // disable scrolling on the screen (so that it doesn't behave weirdly when + // rotated and drawing with your finger) + lv_obj_set_scrollbar_mode(lv_screen_active(), LV_SCROLLBAR_MODE_OFF); + lv_obj_clear_flag(lv_screen_active(), LV_OBJ_FLAG_SCROLLABLE); +} + +void Gui::deinit_ui() { + std::lock_guard lock(mutex_); + lv_obj_clean(lv_screen_active()); +} + +void Gui::init_background() { + auto &mt_display = espp::MatouchRotaryDisplay::get(); + background_ = lv_obj_create(lv_screen_active()); + lv_obj_set_size(background_, mt_display.lcd_width(), mt_display.lcd_height()); + lv_obj_set_style_bg_color(background_, lv_color_make(0, 0, 0), 0); +} + +void Gui::init_label() { + label_ = lv_label_create(lv_screen_active()); + lv_label_set_text(label_, ""); + lv_obj_align(label_, LV_ALIGN_CENTER, 0, 0); + lv_obj_set_style_text_align(label_, LV_TEXT_ALIGN_CENTER, 0); +} + +void Gui::init_buttons() { + // a button in the top middle which rotates the display through + // 0/90/180/270 degrees + rotate_button_ = lv_btn_create(lv_screen_active()); + lv_obj_set_size(rotate_button_, 50, 50); + lv_obj_align(rotate_button_, LV_ALIGN_TOP_MID, 0, 0); + lv_obj_t *rotate_label = lv_label_create(rotate_button_); + lv_label_set_text(rotate_label, LV_SYMBOL_REFRESH); + lv_obj_align(rotate_label, LV_ALIGN_CENTER, 0, 0); + lv_obj_add_event_cb(rotate_button_, event_callback, LV_EVENT_PRESSED, this); + + // a button in the top right which clears the circles + clear_button_ = lv_btn_create(lv_screen_active()); + lv_obj_set_size(clear_button_, 50, 50); + lv_obj_align(clear_button_, LV_ALIGN_TOP_RIGHT, 0, 0); + lv_obj_t *clear_label = lv_label_create(clear_button_); + lv_label_set_text(clear_label, LV_SYMBOL_TRASH); + lv_obj_align(clear_label, LV_ALIGN_CENTER, 0, 0); + lv_obj_add_event_cb(clear_button_, event_callback, LV_EVENT_PRESSED, this); +} + +void Gui::init_circle_layer() { + auto &mt_display = espp::MatouchRotaryDisplay::get(); + circle_layer_ = lv_obj_create(lv_screen_active()); + lv_obj_remove_style_all(circle_layer_); + lv_obj_set_size(circle_layer_, mt_display.lcd_width(), mt_display.lcd_height()); + lv_obj_align(circle_layer_, LV_ALIGN_CENTER, 0, 0); + lv_obj_clear_flag(circle_layer_, LV_OBJ_FLAG_CLICKABLE); + lv_obj_clear_flag(circle_layer_, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_bg_opa(circle_layer_, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(circle_layer_, 0, 0); + lv_obj_set_style_outline_width(circle_layer_, 0, 0); + lv_obj_set_style_shadow_width(circle_layer_, 0, 0); + lv_obj_add_event_cb(circle_layer_, draw_circle_layer, LV_EVENT_DRAW_MAIN, this); + lv_obj_move_foreground(circle_layer_); +} + +bool Gui::update(std::mutex &m, std::condition_variable &cv) { + { + std::lock_guard lock(mutex_); + lv_task_handler(); + } + std::unique_lock lock(m); + cv.wait_for(lock, std::chrono::milliseconds(16)); + return false; // don't stop the task +} + +void Gui::event_callback(lv_event_t *e) { + auto *gui = static_cast(lv_event_get_user_data(e)); + if (!gui) { + return; + } + switch (lv_event_get_code(e)) { + case LV_EVENT_PRESSED: + gui->on_pressed(e); + break; + default: + break; + } +} + +void Gui::on_pressed(lv_event_t *e) { + const auto *target = static_cast(lv_event_get_target(e)); + if (target == rotate_button_) { + logger_.info("Rotate button pressed"); + next_rotation(); + } else if (target == clear_button_) { + logger_.info("Clear button pressed"); + clear_circles(); + } +} + +void Gui::set_label_text(std::string_view text) { + std::lock_guard lock(mutex_); + lv_label_set_text(label_, std::string(text).c_str()); +} + +void Gui::next_rotation() { + std::lock_guard lock(mutex_); + auto &mt_display = espp::MatouchRotaryDisplay::get(); + clear_circles_impl(); + auto rotation = lv_display_get_rotation(lv_display_get_default()); + rotation = static_cast((static_cast(rotation) + 1) % 4); + lv_display_set_rotation(lv_display_get_default(), rotation); + // update the size of the screen-filling objects + lv_obj_set_size(background_, mt_display.rotated_display_width(), + mt_display.rotated_display_height()); + lv_obj_set_size(circle_layer_, mt_display.rotated_display_width(), + mt_display.rotated_display_height()); + lv_obj_align(circle_layer_, LV_ALIGN_CENTER, 0, 0); + lv_obj_invalidate(circle_layer_); +} + +void Gui::draw_circle(int x, int y, int radius) { + std::lock_guard lock(mutex_); + lv_obj_move_foreground(circle_layer_); + Circle previous_circle = circles_[next_circle_index_]; + circles_[next_circle_index_] = {.x = x, .y = y, .radius = radius, .visible = true}; + next_circle_index_ = (next_circle_index_ + 1) % circles_.size(); + if (visible_circle_count_ < circles_.size()) { + visible_circle_count_++; + } + if (previous_circle.visible) { + invalidate_circle_area(previous_circle); + } + invalidate_circle_area(circles_[(next_circle_index_ + circles_.size() - 1) % circles_.size()]); +} + +void Gui::clear_circles() { + std::lock_guard lock(mutex_); + clear_circles_impl(); +} + +void Gui::clear_circles_impl() { + for (auto &circle : circles_) { + if (circle.visible) { + invalidate_circle_area(circle); + } + circle.visible = false; + } + next_circle_index_ = 0; + visible_circle_count_ = 0; +} + +void Gui::invalidate_circle_area(const Circle &circle) { + if (!circle_layer_ || circle.radius <= 0) { + return; + } + lv_area_t obj_coords; + lv_obj_get_coords(circle_layer_, &obj_coords); + lv_area_t coords = { + .x1 = static_cast(obj_coords.x1 + circle.x - circle.radius), + .y1 = static_cast(obj_coords.y1 + circle.y - circle.radius), + .x2 = static_cast(obj_coords.x1 + circle.x + circle.radius - 1), + .y2 = static_cast(obj_coords.y1 + circle.y + circle.radius - 1), + }; + lv_obj_invalidate_area(circle_layer_, &coords); +} + +void Gui::draw_circle_layer(lv_event_t *e) { + const auto *gui = static_cast(lv_event_get_user_data(e)); + if (!gui) { + return; + } + gui->draw_circles(e); +} + +void Gui::draw_circles(lv_event_t *e) const { + if (visible_circle_count_ == 0) { + return; + } + + auto *obj = static_cast(lv_event_get_current_target(e)); + auto *layer = lv_event_get_layer(e); + lv_area_t obj_coords; + lv_obj_get_coords(obj, &obj_coords); + + lv_draw_rect_dsc_t rect_dsc; + lv_draw_rect_dsc_init(&rect_dsc); + rect_dsc.base.layer = layer; + rect_dsc.radius = LV_RADIUS_CIRCLE; + rect_dsc.bg_opa = LV_OPA_70; + rect_dsc.bg_color = lv_color_make(0, 255, 255); + rect_dsc.border_width = 0; + rect_dsc.outline_width = 0; + rect_dsc.shadow_width = 0; + + for (const auto &circle : circles_) { + if (!circle.visible) { + continue; + } + lv_area_t coords = { + .x1 = static_cast(obj_coords.x1 + circle.x - circle.radius), + .y1 = static_cast(obj_coords.y1 + circle.y - circle.radius), + .x2 = static_cast(obj_coords.x1 + circle.x + circle.radius - 1), + .y2 = static_cast(obj_coords.y1 + circle.y + circle.radius - 1), + }; + lv_draw_rect(layer, &rect_dsc, &coords); + } +} diff --git a/components/matouch-rotary-display/example/main/gui.hpp b/components/matouch-rotary-display/example/main/gui.hpp new file mode 100644 index 000000000..e9c65dfbd --- /dev/null +++ b/components/matouch-rotary-display/example/main/gui.hpp @@ -0,0 +1,119 @@ +#pragma once + +#include +#include +#include +#include + +#include "matouch-rotary-display.hpp" + +#include "logger.hpp" +#include "task.hpp" + +/// The Gui class encapsulates all of the LVGL UI for this example. It follows +/// the recommended pattern for building UIs with espp + LVGL in C++: +/// +/// * All LVGL objects are created in init_ui(), which is broken into small +/// member functions - one per logical piece of the UI. +/// * The class owns the task which calls lv_task_handler(), and a recursive +/// mutex which guards every LVGL call. Public methods lock that mutex, so +/// other tasks (touch callbacks, button callbacks, etc.) can safely call +/// them. +/// * LVGL event callbacks are registered with `this` as the user-data and +/// dispatched through a single static trampoline (event_callback) into +/// member functions, keeping all UI logic inside the class. +/// +/// For this example the Gui shows: +/// * A label with instructions and the live rotary encoder count +/// * A button (top-middle) which rotates the display through 0/90/180/270 +/// * A button (top-right) which clears the circles drawn on the screen +/// * A custom-drawn layer of circles which trail the most recent touches +class Gui { +public: + /// Configuration for the Gui + struct Config { + espp::Logger::Verbosity log_level{espp::Logger::Verbosity::WARN}; ///< Log verbosity + }; + + /// Construct the Gui: builds the UI and starts the LVGL update task. + /// @param config The configuration for the Gui + explicit Gui(const Config &config) + : logger_({.tag = "Gui", .level = config.log_level}) { + init_ui(); + update_task_.start(); + } + + ~Gui() { + update_task_.stop(); + deinit_ui(); + } + + /// Set the text of the main label. Thread-safe. + /// @param text The text to display + void set_label_text(std::string_view text); + + /// Draw a circle at the given screen coordinates, replacing the oldest + /// circle if the maximum number are already visible. Thread-safe. + /// @param x The x coordinate (screen space) + /// @param y The y coordinate (screen space) + /// @param radius The radius of the circle + void draw_circle(int x, int y, int radius); + + /// Clear all circles from the screen. Thread-safe. + void clear_circles(); + + /// Rotate the display to the next of 0/90/180/270 degrees, resizing / + /// re-aligning the UI to match. Thread-safe. + void next_rotation(); + +protected: + static constexpr size_t MAX_CIRCLES = 100; + + struct Circle { + int x{0}; + int y{0}; + int radius{0}; + bool visible{false}; + }; + + void init_ui(); + void deinit_ui(); + + // the individual pieces of the UI, called from init_ui() + void init_background(); + void init_label(); + void init_buttons(); + void init_circle_layer(); + + // the LVGL update task: calls lv_task_handler() under the mutex + bool update(std::mutex &m, std::condition_variable &cv); + + // single trampoline for all LVGL events; dispatches to the member + // functions below based on the event target + static void event_callback(lv_event_t *e); + void on_pressed(lv_event_t *e); + + // custom drawing of the circle layer + static void draw_circle_layer(lv_event_t *e); + void draw_circles(lv_event_t *e) const; + void invalidate_circle_area(const Circle &circle); + + // unlocked implementations, called with the mutex held + void clear_circles_impl(); + + // LVGL objects + lv_obj_t *background_{nullptr}; + lv_obj_t *label_{nullptr}; + lv_obj_t *rotate_button_{nullptr}; + lv_obj_t *clear_button_{nullptr}; + lv_obj_t *circle_layer_{nullptr}; + + std::array circles_; + size_t next_circle_index_{0}; + size_t visible_circle_count_{0}; + + espp::Task update_task_{{.callback = [this](auto &m, auto &cv) { return update(m, cv); }, + .task_config = {.name = "gui", .stack_size_bytes = 6 * 1024}}}; + espp::Logger logger_; + std::recursive_mutex mutex_; +}; diff --git a/components/matouch-rotary-display/example/main/matouch_rotary_display_example.cpp b/components/matouch-rotary-display/example/main/matouch_rotary_display_example.cpp index e33f41ca2..5dfb7e601 100644 --- a/components/matouch-rotary-display/example/main/matouch_rotary_display_example.cpp +++ b/components/matouch-rotary-display/example/main/matouch_rotary_display_example.cpp @@ -1,30 +1,11 @@ -#include #include #include #include "matouch-rotary-display.hpp" -using namespace std::chrono_literals; - -static constexpr size_t MAX_CIRCLES = 100; -struct Circle { - int x{0}; - int y{0}; - int radius{0}; - bool visible{false}; -}; -static std::array circles; -static size_t next_circle_index = 0; -static size_t visible_circle_count = 0; -static lv_obj_t *circle_layer = nullptr; - -static std::recursive_mutex lvgl_mutex; +#include "gui.hpp" -static bool initialize_circle_layer(int width, int height); -static void draw_circle_layer(lv_event_t *event); -static void invalidate_circle_area(const Circle &circle); -static void draw_circle(int x0, int y0, int radius); -static void clear_circles(); +using namespace std::chrono_literals; extern "C" void app_main(void) { espp::Logger logger( @@ -35,36 +16,6 @@ extern "C" void app_main(void) { espp::MatouchRotaryDisplay &mt_display = espp::MatouchRotaryDisplay::get(); mt_display.set_log_level(espp::Logger::Verbosity::INFO); - auto on_button_pressed = [&](const auto &event) { - if (event.active) { - logger.info("Button pressed!"); - } else { - logger.info("Button released!"); - // clear the screen - std::lock_guard lock(lvgl_mutex); - clear_circles(); - } - }; - - auto on_touch = [&](const auto &touch) { - // NOTE: since we're directly using the touchpad data, and not using the - // TouchpadInput + LVGL, we'll need to ensure the touchpad data is - // converted into proper screen coordinates instead of simply using the - // raw values. - static auto previous_touchpad_data = mt_display.touchpad_convert(touch); - auto touchpad_data = mt_display.touchpad_convert(touch); - if (touchpad_data != previous_touchpad_data) { - logger.info("Touch: {}", touchpad_data); - previous_touchpad_data = touchpad_data; - // if there is a touch point, draw a circle - if (touchpad_data.num_touch_points > 0) { - std::lock_guard lock(lvgl_mutex); - draw_circle(touchpad_data.x, touchpad_data.y, 10); - } - } - previous_touchpad_data = touchpad_data; - }; - // initialize the LCD if (!mt_display.initialize_lcd()) { logger.error("Failed to initialize LCD!"); @@ -82,104 +33,64 @@ extern "C" void app_main(void) { logger.error("Failed to initialize rotary encoder!"); return; } - // initialize the button + + // create the GUI: builds the UI (label, buttons, circle layer) and starts + // the task which updates LVGL. All of its public methods are thread-safe, + // so the button / touch callbacks and the main loop below can call them + // directly. + static Gui gui({.log_level = espp::Logger::Verbosity::INFO}); + static const std::string instructions = + fmt::format("Touch the screen!\nPress the button or the {} button to clear circles.\nPress " + "the {} button to rotate the display.", + LV_SYMBOL_TRASH, LV_SYMBOL_REFRESH); + gui.set_label_text(instructions); + + // initialize the hardware button (pressing the encoder); releasing it + // clears the circles + auto on_button_pressed = [&](const auto &event) { + if (event.active) { + logger.info("Button pressed!"); + } else { + logger.info("Button released!"); + // clear the screen + gui.clear_circles(); + } + }; if (!mt_display.initialize_button(on_button_pressed)) { logger.error("Failed to initialize button!"); return; } - // set the background color to black - lv_obj_t *bg = lv_obj_create(lv_screen_active()); - lv_obj_set_size(bg, mt_display.rotated_display_width(), mt_display.rotated_display_height()); - lv_obj_set_style_bg_color(bg, lv_color_make(0, 0, 0), 0); - if (!initialize_circle_layer(mt_display.rotated_display_width(), - mt_display.rotated_display_height())) { - logger.error("Failed to initialize circle layer!"); - return; - } - - // add text in the center of the screen - lv_obj_t *label = lv_label_create(lv_screen_active()); - lv_label_set_text(label, "Touch the screen!\nPress the button to clear circles."); - lv_obj_align(label, LV_ALIGN_CENTER, 0, 0); - lv_obj_set_style_text_align(label, LV_TEXT_ALIGN_CENTER, 0); - - // add a button in the top left which (when pressed) will rotate the display - // through 0, 90, 180, 270 degrees - lv_obj_t *btn = lv_btn_create(lv_screen_active()); - lv_obj_set_size(btn, 50, 50); - lv_obj_align(btn, LV_ALIGN_TOP_MID, 0, 0); - lv_obj_t *label_btn = lv_label_create(btn); - lv_label_set_text(label_btn, LV_SYMBOL_REFRESH); - // center the text in the button - lv_obj_align(label_btn, LV_ALIGN_CENTER, 0, 0); - static auto update_layout = [&]() { - int width = mt_display.rotated_display_width(); - int height = mt_display.rotated_display_height(); - lv_obj_set_size(bg, width, height); - lv_obj_align(label, LV_ALIGN_CENTER, 0, 0); - lv_obj_align(btn, LV_ALIGN_TOP_MID, 0, 0); - if (circle_layer) { - lv_obj_set_size(circle_layer, width, height); - lv_obj_align(circle_layer, LV_ALIGN_CENTER, 0, 0); - lv_obj_move_foreground(circle_layer); - lv_obj_invalidate(circle_layer); + // initialize the touchpad; each touch draws a circle + auto on_touch = [&](const auto &touch) { + // NOTE: since we're directly using the touchpad data, and not using the + // TouchpadInput + LVGL, we'll need to ensure the touchpad data is + // converted into proper screen coordinates instead of simply using the + // raw values. + static auto previous_touchpad_data = mt_display.touchpad_convert(touch); + auto touchpad_data = mt_display.touchpad_convert(touch); + if (touchpad_data != previous_touchpad_data) { + logger.info("Touch: {}", touchpad_data); + previous_touchpad_data = touchpad_data; + // if there is a touch point, draw a circle + if (touchpad_data.num_touch_points > 0) { + gui.draw_circle(touchpad_data.x, touchpad_data.y, 10); + } } }; - static auto rotate_display = [&]() { - std::lock_guard lock(lvgl_mutex); - clear_circles(); - static auto rotation = LV_DISPLAY_ROTATION_0; - rotation = static_cast((static_cast(rotation) + 1) % 4); - lv_display_t *disp = lv_display_get_default(); - lv_disp_set_rotation(disp, rotation); - update_layout(); - }; - lv_obj_add_event_cb( - btn, [](auto event) { rotate_display(); }, LV_EVENT_PRESSED, nullptr); - update_layout(); - - // disable scrolling on the screen (so that it doesn't behave weirdly when - // rotated and drawing with your finger) - lv_obj_set_scrollbar_mode(lv_screen_active(), LV_SCROLLBAR_MODE_OFF); - lv_obj_clear_flag(lv_screen_active(), LV_OBJ_FLAG_SCROLLABLE); - lv_obj_move_foreground(circle_layer); - - // initialize the touchpad after the circle layer exists so touch events can - // update it immediately. if (!mt_display.initialize_touch(on_touch)) { logger.error("Failed to initialize touchpad!"); return; } - // start a simple thread to do the lv_task_handler every 16ms - espp::Task lv_task({.callback = [](std::mutex &m, std::condition_variable &cv) -> bool { - { - std::lock_guard lock(lvgl_mutex); - lv_task_handler(); - } - std::unique_lock lock(m); - cv.wait_for(lock, 16ms); - return false; - }, - .task_config = { - .name = "lv_task", - }}); - lv_task.start(); - // set the display brightness to be 75% mt_display.brightness(75.0f); while (true) { auto start = esp_timer_get_time(); // get the encoder count and update the label with it - { - std::lock_guard lock(lvgl_mutex); - int encoder_count = mt_display.encoder_value(); - lv_label_set_text_fmt(label, - "Touch the screen!\nPress the button to clear circles.\nEncoder: %d", - encoder_count); - } + int encoder_count = mt_display.encoder_value(); + gui.set_label_text(fmt::format("{}\nEncoder: {}", instructions, encoder_count)); // sleep for the remaining time auto end = esp_timer_get_time(); auto elapsed = end - start; @@ -187,101 +98,3 @@ extern "C" void app_main(void) { } //! [matouch-rotary-display example] } - -static bool initialize_circle_layer(int width, int height) { - if (circle_layer) { - return true; - } - circle_layer = lv_obj_create(lv_screen_active()); - if (!circle_layer) { - return false; - } - lv_obj_remove_style_all(circle_layer); - lv_obj_set_size(circle_layer, width, height); - lv_obj_align(circle_layer, LV_ALIGN_CENTER, 0, 0); - lv_obj_clear_flag(circle_layer, LV_OBJ_FLAG_CLICKABLE); - lv_obj_clear_flag(circle_layer, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_set_style_bg_opa(circle_layer, LV_OPA_TRANSP, 0); - lv_obj_set_style_border_width(circle_layer, 0, 0); - lv_obj_set_style_outline_width(circle_layer, 0, 0); - lv_obj_set_style_shadow_width(circle_layer, 0, 0); - lv_obj_add_event_cb(circle_layer, draw_circle_layer, LV_EVENT_DRAW_MAIN, nullptr); - return true; -} - -static void draw_circle_layer(lv_event_t *event) { - if (visible_circle_count == 0) { - return; - } - auto *obj = static_cast(lv_event_get_current_target(event)); - auto *layer = lv_event_get_layer(event); - lv_area_t obj_coords; - lv_obj_get_coords(obj, &obj_coords); - - lv_draw_rect_dsc_t rect_dsc; - lv_draw_rect_dsc_init(&rect_dsc); - rect_dsc.base.layer = layer; - rect_dsc.radius = LV_RADIUS_CIRCLE; - rect_dsc.bg_opa = LV_OPA_70; - rect_dsc.bg_color = lv_color_make(0, 255, 255); - rect_dsc.border_width = 0; - rect_dsc.outline_width = 0; - rect_dsc.shadow_width = 0; - - for (const auto &circle : circles) { - if (!circle.visible) { - continue; - } - lv_area_t coords = { - .x1 = static_cast(obj_coords.x1 + circle.x - circle.radius), - .y1 = static_cast(obj_coords.y1 + circle.y - circle.radius), - .x2 = static_cast(obj_coords.x1 + circle.x + circle.radius - 1), - .y2 = static_cast(obj_coords.y1 + circle.y + circle.radius - 1), - }; - lv_draw_rect(layer, &rect_dsc, &coords); - } -} - -static void invalidate_circle_area(const Circle &circle) { - if (!circle_layer || circle.radius <= 0) { - return; - } - - lv_area_t obj_coords; - lv_obj_get_coords(circle_layer, &obj_coords); - lv_area_t coords = { - .x1 = static_cast(obj_coords.x1 + circle.x - circle.radius), - .y1 = static_cast(obj_coords.y1 + circle.y - circle.radius), - .x2 = static_cast(obj_coords.x1 + circle.x + circle.radius - 1), - .y2 = static_cast(obj_coords.y1 + circle.y + circle.radius - 1), - }; - lv_obj_invalidate_area(circle_layer, &coords); -} - -static void draw_circle(int x0, int y0, int radius) { - if (!circle_layer) { - return; - } - lv_obj_move_foreground(circle_layer); - Circle previous_circle = circles[next_circle_index]; - circles[next_circle_index] = {.x = x0, .y = y0, .radius = radius, .visible = true}; - next_circle_index = (next_circle_index + 1) % circles.size(); - if (visible_circle_count < circles.size()) { - visible_circle_count++; - } - if (previous_circle.visible) { - invalidate_circle_area(previous_circle); - } - invalidate_circle_area(circles[(next_circle_index + circles.size() - 1) % circles.size()]); -} - -static void clear_circles() { - for (auto &circle : circles) { - if (circle.visible) { - invalidate_circle_area(circle); - } - circle.visible = false; - } - next_circle_index = 0; - visible_circle_count = 0; -} diff --git a/components/seeed-studio-round-display/example/main/gui.cpp b/components/seeed-studio-round-display/example/main/gui.cpp new file mode 100644 index 000000000..2191eba0f --- /dev/null +++ b/components/seeed-studio-round-display/example/main/gui.cpp @@ -0,0 +1,224 @@ +#include "gui.hpp" + +void Gui::init_ui() { + std::lock_guard lock(mutex_); + init_background(); + init_label(); + init_buttons(); + init_circle_layer(); + // disable scrolling on the screen (so that it doesn't behave weirdly when + // rotated and drawing with your finger) + lv_obj_set_scrollbar_mode(lv_screen_active(), LV_SCROLLBAR_MODE_OFF); + lv_obj_clear_flag(lv_screen_active(), LV_OBJ_FLAG_SCROLLABLE); +} + +void Gui::deinit_ui() { + std::lock_guard lock(mutex_); + lv_obj_clean(lv_screen_active()); +} + +void Gui::init_background() { + auto &round_display = espp::SsRoundDisplay::get(); + background_ = lv_obj_create(lv_screen_active()); + lv_obj_set_size(background_, round_display.rotated_display_width(), + round_display.rotated_display_height()); + lv_obj_set_style_bg_color(background_, lv_color_make(0, 0, 0), 0); +} + +void Gui::init_label() { + label_ = lv_label_create(lv_screen_active()); + lv_label_set_text(label_, ""); + lv_obj_align(label_, LV_ALIGN_CENTER, 0, 0); + lv_obj_set_style_text_align(label_, LV_TEXT_ALIGN_CENTER, 0); +} + +void Gui::init_buttons() { + // a button in the top middle which rotates the display through + // 0/90/180/270 degrees + rotate_button_ = lv_btn_create(lv_screen_active()); + lv_obj_set_size(rotate_button_, 50, 50); + lv_obj_align(rotate_button_, LV_ALIGN_TOP_MID, 0, 0); + lv_obj_t *rotate_label = lv_label_create(rotate_button_); + lv_label_set_text(rotate_label, LV_SYMBOL_REFRESH); + lv_obj_align(rotate_label, LV_ALIGN_CENTER, 0, 0); + lv_obj_add_event_cb(rotate_button_, event_callback, LV_EVENT_PRESSED, this); + + // a button in the bottom middle which clears the circles + clear_button_ = lv_btn_create(lv_screen_active()); + lv_obj_set_size(clear_button_, 50, 50); + lv_obj_align(clear_button_, LV_ALIGN_BOTTOM_MID, 0, 0); + lv_obj_add_state(clear_button_, LV_STATE_CHECKED); // make the button red + lv_obj_t *clear_label = lv_label_create(clear_button_); + lv_label_set_text(clear_label, LV_SYMBOL_TRASH); + lv_obj_align(clear_label, LV_ALIGN_CENTER, 0, 0); + lv_obj_add_event_cb(clear_button_, event_callback, LV_EVENT_PRESSED, this); +} + +void Gui::init_circle_layer() { + auto &round_display = espp::SsRoundDisplay::get(); + circle_layer_ = lv_obj_create(lv_screen_active()); + lv_obj_remove_style_all(circle_layer_); + lv_obj_set_size(circle_layer_, round_display.rotated_display_width(), + round_display.rotated_display_height()); + lv_obj_align(circle_layer_, LV_ALIGN_CENTER, 0, 0); + lv_obj_clear_flag(circle_layer_, LV_OBJ_FLAG_CLICKABLE); + lv_obj_clear_flag(circle_layer_, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_bg_opa(circle_layer_, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(circle_layer_, 0, 0); + lv_obj_set_style_outline_width(circle_layer_, 0, 0); + lv_obj_set_style_shadow_width(circle_layer_, 0, 0); + lv_obj_add_event_cb(circle_layer_, draw_circle_layer, LV_EVENT_DRAW_MAIN, this); + lv_obj_move_foreground(circle_layer_); +} + +void Gui::update_layout() { + auto &round_display = espp::SsRoundDisplay::get(); + int width = round_display.rotated_display_width(); + int height = round_display.rotated_display_height(); + lv_obj_set_size(background_, width, height); + lv_obj_align(label_, LV_ALIGN_CENTER, 0, 0); + lv_obj_align(rotate_button_, LV_ALIGN_TOP_MID, 0, 0); + lv_obj_align(clear_button_, LV_ALIGN_BOTTOM_MID, 0, 0); + lv_obj_set_size(circle_layer_, width, height); + lv_obj_align(circle_layer_, LV_ALIGN_CENTER, 0, 0); + lv_obj_move_foreground(circle_layer_); + lv_obj_invalidate(circle_layer_); +} + +bool Gui::update(std::mutex &m, std::condition_variable &cv) { + { + std::lock_guard lock(mutex_); + lv_task_handler(); + } + std::unique_lock lock(m); + cv.wait_for(lock, std::chrono::milliseconds(16)); + return false; // don't stop the task +} + +void Gui::event_callback(lv_event_t *e) { + auto *gui = static_cast(lv_event_get_user_data(e)); + if (!gui) { + return; + } + switch (lv_event_get_code(e)) { + case LV_EVENT_PRESSED: + gui->on_pressed(e); + break; + default: + break; + } +} + +void Gui::on_pressed(lv_event_t *e) { + const auto *target = static_cast(lv_event_get_target(e)); + if (target == rotate_button_) { + logger_.info("Rotate button pressed"); + next_rotation(); + } else if (target == clear_button_) { + logger_.info("Clear button pressed"); + clear_circles(); + } +} + +void Gui::set_label_text(std::string_view text) { + std::lock_guard lock(mutex_); + lv_label_set_text(label_, std::string(text).c_str()); +} + +void Gui::next_rotation() { + std::lock_guard lock(mutex_); + clear_circles_impl(); + auto rotation = lv_display_get_rotation(lv_display_get_default()); + rotation = static_cast((static_cast(rotation) + 1) % 4); + lv_display_set_rotation(lv_display_get_default(), rotation); + // re-size / re-align the UI to fill the newly-rotated display + update_layout(); +} + +void Gui::draw_circle(int x, int y, int radius) { + std::lock_guard lock(mutex_); + lv_obj_move_foreground(circle_layer_); + Circle previous_circle = circles_[next_circle_index_]; + circles_[next_circle_index_] = {.x = x, .y = y, .radius = radius, .visible = true}; + next_circle_index_ = (next_circle_index_ + 1) % circles_.size(); + if (visible_circle_count_ < circles_.size()) { + visible_circle_count_++; + } + if (previous_circle.visible) { + invalidate_circle_area(previous_circle); + } + invalidate_circle_area(circles_[(next_circle_index_ + circles_.size() - 1) % circles_.size()]); +} + +void Gui::clear_circles() { + std::lock_guard lock(mutex_); + clear_circles_impl(); +} + +void Gui::clear_circles_impl() { + for (auto &circle : circles_) { + if (circle.visible) { + invalidate_circle_area(circle); + } + circle.visible = false; + } + next_circle_index_ = 0; + visible_circle_count_ = 0; +} + +void Gui::invalidate_circle_area(const Circle &circle) { + if (!circle_layer_ || circle.radius <= 0) { + return; + } + lv_area_t obj_coords; + lv_obj_get_coords(circle_layer_, &obj_coords); + lv_area_t coords = { + .x1 = static_cast(obj_coords.x1 + circle.x - circle.radius), + .y1 = static_cast(obj_coords.y1 + circle.y - circle.radius), + .x2 = static_cast(obj_coords.x1 + circle.x + circle.radius - 1), + .y2 = static_cast(obj_coords.y1 + circle.y + circle.radius - 1), + }; + lv_obj_invalidate_area(circle_layer_, &coords); +} + +void Gui::draw_circle_layer(lv_event_t *e) { + const auto *gui = static_cast(lv_event_get_user_data(e)); + if (!gui) { + return; + } + gui->draw_circles(e); +} + +void Gui::draw_circles(lv_event_t *e) const { + if (visible_circle_count_ == 0) { + return; + } + + auto *obj = static_cast(lv_event_get_current_target(e)); + auto *layer = lv_event_get_layer(e); + lv_area_t obj_coords; + lv_obj_get_coords(obj, &obj_coords); + + lv_draw_rect_dsc_t rect_dsc; + lv_draw_rect_dsc_init(&rect_dsc); + rect_dsc.base.layer = layer; + rect_dsc.radius = LV_RADIUS_CIRCLE; + rect_dsc.bg_opa = LV_OPA_70; + rect_dsc.bg_color = lv_color_make(0, 255, 255); + rect_dsc.border_width = 0; + rect_dsc.outline_width = 0; + rect_dsc.shadow_width = 0; + + for (const auto &circle : circles_) { + if (!circle.visible) { + continue; + } + lv_area_t coords = { + .x1 = static_cast(obj_coords.x1 + circle.x - circle.radius), + .y1 = static_cast(obj_coords.y1 + circle.y - circle.radius), + .x2 = static_cast(obj_coords.x1 + circle.x + circle.radius - 1), + .y2 = static_cast(obj_coords.y1 + circle.y + circle.radius - 1), + }; + lv_draw_rect(layer, &rect_dsc, &coords); + } +} diff --git a/components/seeed-studio-round-display/example/main/gui.hpp b/components/seeed-studio-round-display/example/main/gui.hpp new file mode 100644 index 000000000..c20fd3d9e --- /dev/null +++ b/components/seeed-studio-round-display/example/main/gui.hpp @@ -0,0 +1,121 @@ +#pragma once + +#include +#include +#include +#include + +#include "seeed-studio-round-display.hpp" + +#include "logger.hpp" +#include "task.hpp" + +/// The Gui class encapsulates all of the LVGL UI for this example. It follows +/// the recommended pattern for building UIs with espp + LVGL in C++: +/// +/// * All LVGL objects are created in init_ui(), which is broken into small +/// member functions - one per logical piece of the UI. +/// * The class owns the task which calls lv_task_handler(), and a recursive +/// mutex which guards every LVGL call. Public methods lock that mutex, so +/// other tasks (touch callbacks, sensor tasks, etc.) can safely call them. +/// * LVGL event callbacks are registered with `this` as the user-data and +/// dispatched through a single static trampoline (event_callback) into +/// member functions, keeping all UI logic inside the class. +/// +/// For this example the Gui shows: +/// * A label in the center of the round display with instructions +/// * A button (top-middle) which rotates the display through 0/90/180/270 +/// * A button (bottom-middle) which clears the circles drawn on the screen +/// * A custom-drawn layer of circles which trail the most recent touches +class Gui { +public: + /// Configuration for the Gui + struct Config { + espp::Logger::Verbosity log_level{espp::Logger::Verbosity::WARN}; ///< Log verbosity + }; + + /// Construct the Gui: builds the UI and starts the LVGL update task. + /// @param config The configuration for the Gui + explicit Gui(const Config &config) + : logger_({.tag = "Gui", .level = config.log_level}) { + init_ui(); + update_task_.start(); + } + + ~Gui() { + update_task_.stop(); + deinit_ui(); + } + + /// Set the text of the main label. Thread-safe. + /// @param text The text to display + void set_label_text(std::string_view text); + + /// Draw a circle at the given screen coordinates, replacing the oldest + /// circle if the maximum number are already visible. Thread-safe. + /// @param x The x coordinate (screen space) + /// @param y The y coordinate (screen space) + /// @param radius The radius of the circle + void draw_circle(int x, int y, int radius); + + /// Clear all circles from the screen. Thread-safe. + void clear_circles(); + + /// Rotate the display to the next of 0/90/180/270 degrees, resizing / + /// re-aligning the UI to match. Thread-safe. + void next_rotation(); + +protected: + static constexpr size_t MAX_CIRCLES = 100; + + struct Circle { + int x{0}; + int y{0}; + int radius{0}; + bool visible{false}; + }; + + void init_ui(); + void deinit_ui(); + + // the individual pieces of the UI, called from init_ui() + void init_background(); + void init_label(); + void init_buttons(); + void init_circle_layer(); + + // re-size / re-align the UI to fill the (possibly rotated) display + void update_layout(); + + // the LVGL update task: calls lv_task_handler() under the mutex + bool update(std::mutex &m, std::condition_variable &cv); + + // single trampoline for all LVGL events; dispatches to the member + // functions below based on the event target + static void event_callback(lv_event_t *e); + void on_pressed(lv_event_t *e); + + // custom drawing of the circle layer + static void draw_circle_layer(lv_event_t *e); + void draw_circles(lv_event_t *e) const; + void invalidate_circle_area(const Circle &circle); + + // unlocked implementations, called with the mutex held + void clear_circles_impl(); + + // LVGL objects + lv_obj_t *background_{nullptr}; + lv_obj_t *label_{nullptr}; + lv_obj_t *rotate_button_{nullptr}; + lv_obj_t *clear_button_{nullptr}; + lv_obj_t *circle_layer_{nullptr}; + + std::array circles_; + size_t next_circle_index_{0}; + size_t visible_circle_count_{0}; + + espp::Task update_task_{{.callback = [this](auto &m, auto &cv) { return update(m, cv); }, + .task_config = {.name = "gui", .stack_size_bytes = 6 * 1024}}}; + espp::Logger logger_; + std::recursive_mutex mutex_; +}; diff --git a/components/seeed-studio-round-display/example/main/seeed_studio_round_display_example.cpp b/components/seeed-studio-round-display/example/main/seeed_studio_round_display_example.cpp index cedb8c844..c2d49fca6 100644 --- a/components/seeed-studio-round-display/example/main/seeed_studio_round_display_example.cpp +++ b/components/seeed-studio-round-display/example/main/seeed_studio_round_display_example.cpp @@ -1,29 +1,11 @@ -#include #include #include #include "seeed-studio-round-display.hpp" -using namespace std::chrono_literals; - -static constexpr size_t MAX_CIRCLES = 100; -struct Circle { - int x{0}; - int y{0}; - int radius{0}; - bool visible{false}; -}; -static std::array circles; -static size_t next_circle_index = 0; -static size_t visible_circle_count = 0; -static lv_obj_t *circle_layer = nullptr; +#include "gui.hpp" -static std::recursive_mutex lvgl_mutex; -static bool initialize_circle_layer(int width, int height); -static void draw_circle_layer(lv_event_t *event); -static void invalidate_circle_area(const Circle &circle); -static void draw_circle(int x0, int y0, int radius); -static void clear_circles(); +using namespace std::chrono_literals; extern "C" void app_main(void) { espp::Logger logger( @@ -42,6 +24,28 @@ extern "C" void app_main(void) { #endif espp::SsRoundDisplay &round_display = espp::SsRoundDisplay::get(); + // initialize the LCD + if (!round_display.initialize_lcd()) { + logger.error("Failed to initialize LCD!"); + return; + } + // set the pixel buffer to be 50 lines high + static constexpr size_t pixel_buffer_size = round_display.lcd_width() * 50; + // initialize the LVGL display for the seeed-studio-round-display + if (!round_display.initialize_display(pixel_buffer_size)) { + logger.error("Failed to initialize display!"); + return; + } + + // create the GUI: builds the UI (label, buttons, circle layer) and starts + // the task which updates LVGL. All of its public methods are thread-safe, + // so the touch callback below can call them directly. + static Gui gui({.log_level = espp::Logger::Verbosity::INFO}); + gui.set_label_text("Touch the screen!"); + + // initialize the touchpad after the GUI exists so touch events can update + // it immediately; each touch draws a circle, while the touchpad button + // clears the circles auto touch_callback = [&](const auto &touch) { // NOTE: since we're directly using the touchpad data, and not using the // TouchpadInput + LVGL, we'll need to ensure the touchpad data is @@ -54,128 +58,19 @@ extern "C" void app_main(void) { previous_touchpad_data = touchpad_data; // if the button is pressed, clear the circles if (touchpad_data.btn_state) { - std::lock_guard lock(lvgl_mutex); - clear_circles(); + gui.clear_circles(); } // if there is a touch point, draw a circle if (touchpad_data.num_touch_points > 0) { - std::lock_guard lock(lvgl_mutex); - draw_circle(touchpad_data.x, touchpad_data.y, 10); + gui.draw_circle(touchpad_data.x, touchpad_data.y, 10); } } }; - - // initialize the LCD - if (!round_display.initialize_lcd()) { - logger.error("Failed to initialize LCD!"); - return; - } - // set the pixel buffer to be 50 lines high - static constexpr size_t pixel_buffer_size = round_display.lcd_width() * 50; - // initialize the LVGL display for the seeed-studio-round-display - if (!round_display.initialize_display(pixel_buffer_size)) { - logger.error("Failed to initialize display!"); - return; - } - - // set the background color to black - lv_obj_t *bg = lv_obj_create(lv_screen_active()); - lv_obj_set_size(bg, round_display.rotated_display_width(), - round_display.rotated_display_height()); - lv_obj_set_style_bg_color(bg, lv_color_make(0, 0, 0), 0); - if (!initialize_circle_layer(round_display.rotated_display_width(), - round_display.rotated_display_height())) { - logger.error("Failed to initialize circle layer!"); - return; - } - - // add text in the center of the screen - lv_obj_t *label = lv_label_create(lv_screen_active()); - lv_label_set_text(label, "Touch the screen!"); - lv_obj_align(label, LV_ALIGN_CENTER, 0, 0); - lv_obj_set_style_text_align(label, LV_TEXT_ALIGN_CENTER, 0); - - // add a button in the top middel which (when pressed) will rotate the display - // through 0, 90, 180, 270 degrees - lv_obj_t *btn = lv_btn_create(lv_screen_active()); - lv_obj_set_size(btn, 50, 50); - lv_obj_align(btn, LV_ALIGN_TOP_MID, 0, 0); - lv_obj_t *label_btn = lv_label_create(btn); - lv_label_set_text(label_btn, LV_SYMBOL_REFRESH); - lv_obj_align(label_btn, LV_ALIGN_CENTER, 0, 0); - - // add a button in the bottom middle which (when pressed) will clear the - // circles - lv_obj_t *btn_clear = lv_btn_create(lv_screen_active()); - lv_obj_set_size(btn_clear, 50, 50); - lv_obj_align(btn_clear, LV_ALIGN_BOTTOM_MID, 0, 0); - lv_obj_add_state(btn_clear, LV_STATE_CHECKED); // make the button red - lv_obj_t *label_btn_clear = lv_label_create(btn_clear); - lv_label_set_text(label_btn_clear, LV_SYMBOL_TRASH); - lv_obj_align(label_btn_clear, LV_ALIGN_CENTER, 0, 0); - static auto update_layout = [&]() { - int width = round_display.rotated_display_width(); - int height = round_display.rotated_display_height(); - lv_obj_set_size(bg, width, height); - lv_obj_align(label, LV_ALIGN_CENTER, 0, 0); - lv_obj_align(btn, LV_ALIGN_TOP_MID, 0, 0); - lv_obj_align(btn_clear, LV_ALIGN_BOTTOM_MID, 0, 0); - if (circle_layer) { - lv_obj_set_size(circle_layer, width, height); - lv_obj_align(circle_layer, LV_ALIGN_CENTER, 0, 0); - lv_obj_move_foreground(circle_layer); - lv_obj_invalidate(circle_layer); - } - }; - static auto rotate_display = [&]() { - std::lock_guard lock(lvgl_mutex); - clear_circles(); - static auto rotation = LV_DISPLAY_ROTATION_0; - rotation = static_cast((static_cast(rotation) + 1) % 4); - lv_display_t *disp = lv_display_get_default(); - lv_disp_set_rotation(disp, rotation); - update_layout(); - }; - lv_obj_add_event_cb( - btn, [](auto event) { rotate_display(); }, LV_EVENT_PRESSED, nullptr); - lv_obj_add_event_cb( - btn_clear, - [](auto event) { - std::lock_guard lock(lvgl_mutex); - clear_circles(); - }, - LV_EVENT_PRESSED, nullptr); - update_layout(); - - // disable scrolling on the screen (so that it doesn't behave weirdly when - // rotated and drawing with your finger) - lv_obj_set_scrollbar_mode(lv_screen_active(), LV_SCROLLBAR_MODE_OFF); - lv_obj_clear_flag(lv_screen_active(), LV_OBJ_FLAG_SCROLLABLE); - lv_obj_move_foreground(circle_layer); - - // initialize the touchpad after the circle layer exists so touch events can - // update it immediately. if (!round_display.initialize_touch(touch_callback)) { logger.error("Failed to initialize touchpad!"); return; } - // start a simple thread to do the lv_task_handler every 16ms - espp::Task lv_task({.callback = [](std::mutex &m, std::condition_variable &cv) -> bool { - { - std::lock_guard lock(lvgl_mutex); - lv_task_handler(); - } - std::unique_lock lock(m); - cv.wait_for(lock, 16ms); - return false; - }, - .task_config = { - .name = "lv_task", - .stack_size_bytes = 6 * 1024, - }}); - lv_task.start(); - // set the display brightness to be 75% round_display.brightness(75.0f); @@ -185,101 +80,3 @@ extern "C" void app_main(void) { } //! [seeed studio round display example] } - -static bool initialize_circle_layer(int width, int height) { - if (circle_layer) { - return true; - } - circle_layer = lv_obj_create(lv_screen_active()); - if (!circle_layer) { - return false; - } - lv_obj_remove_style_all(circle_layer); - lv_obj_set_size(circle_layer, width, height); - lv_obj_align(circle_layer, LV_ALIGN_CENTER, 0, 0); - lv_obj_clear_flag(circle_layer, LV_OBJ_FLAG_CLICKABLE); - lv_obj_clear_flag(circle_layer, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_set_style_bg_opa(circle_layer, LV_OPA_TRANSP, 0); - lv_obj_set_style_border_width(circle_layer, 0, 0); - lv_obj_set_style_outline_width(circle_layer, 0, 0); - lv_obj_set_style_shadow_width(circle_layer, 0, 0); - lv_obj_add_event_cb(circle_layer, draw_circle_layer, LV_EVENT_DRAW_MAIN, nullptr); - return true; -} - -static void draw_circle_layer(lv_event_t *event) { - if (visible_circle_count == 0) { - return; - } - auto *obj = static_cast(lv_event_get_current_target(event)); - auto *layer = lv_event_get_layer(event); - lv_area_t obj_coords; - lv_obj_get_coords(obj, &obj_coords); - - lv_draw_rect_dsc_t rect_dsc; - lv_draw_rect_dsc_init(&rect_dsc); - rect_dsc.base.layer = layer; - rect_dsc.radius = LV_RADIUS_CIRCLE; - rect_dsc.bg_opa = LV_OPA_70; - rect_dsc.bg_color = lv_color_make(0, 255, 255); - rect_dsc.border_width = 0; - rect_dsc.outline_width = 0; - rect_dsc.shadow_width = 0; - - for (const auto &circle : circles) { - if (!circle.visible) { - continue; - } - lv_area_t coords = { - .x1 = static_cast(obj_coords.x1 + circle.x - circle.radius), - .y1 = static_cast(obj_coords.y1 + circle.y - circle.radius), - .x2 = static_cast(obj_coords.x1 + circle.x + circle.radius - 1), - .y2 = static_cast(obj_coords.y1 + circle.y + circle.radius - 1), - }; - lv_draw_rect(layer, &rect_dsc, &coords); - } -} - -static void invalidate_circle_area(const Circle &circle) { - if (!circle_layer || circle.radius <= 0) { - return; - } - - lv_area_t obj_coords; - lv_obj_get_coords(circle_layer, &obj_coords); - lv_area_t coords = { - .x1 = static_cast(obj_coords.x1 + circle.x - circle.radius), - .y1 = static_cast(obj_coords.y1 + circle.y - circle.radius), - .x2 = static_cast(obj_coords.x1 + circle.x + circle.radius - 1), - .y2 = static_cast(obj_coords.y1 + circle.y + circle.radius - 1), - }; - lv_obj_invalidate_area(circle_layer, &coords); -} - -static void draw_circle(int x0, int y0, int radius) { - if (!circle_layer) { - return; - } - lv_obj_move_foreground(circle_layer); - Circle previous_circle = circles[next_circle_index]; - circles[next_circle_index] = {.x = x0, .y = y0, .radius = radius, .visible = true}; - next_circle_index = (next_circle_index + 1) % circles.size(); - if (visible_circle_count < circles.size()) { - visible_circle_count++; - } - if (previous_circle.visible) { - invalidate_circle_area(previous_circle); - } - invalidate_circle_area(circles[(next_circle_index + circles.size() - 1) % circles.size()]); -} - -static void clear_circles() { - for (auto &circle : circles) { - if (circle.visible) { - invalidate_circle_area(circle); - } - circle.visible = false; - } - next_circle_index = 0; - visible_circle_count = 0; -} diff --git a/components/smartpanlee-sc01-plus/example/main/gui.cpp b/components/smartpanlee-sc01-plus/example/main/gui.cpp new file mode 100644 index 000000000..a86989878 --- /dev/null +++ b/components/smartpanlee-sc01-plus/example/main/gui.cpp @@ -0,0 +1,234 @@ +#include "gui.hpp" + +#include + +void Gui::init_ui() { + std::lock_guard lock(mutex_); + init_background(); + init_label(); + init_buttons(); + init_circle_layer(); + // disable scrolling on the screen (so that it doesn't behave weirdly when + // rotated and drawing with your finger) + lv_obj_set_scrollbar_mode(lv_screen_active(), LV_SCROLLBAR_MODE_OFF); + lv_obj_clear_flag(lv_screen_active(), LV_OBJ_FLAG_SCROLLABLE); +} + +void Gui::deinit_ui() { + std::lock_guard lock(mutex_); + lv_obj_clean(lv_screen_active()); +} + +void Gui::init_background() { + auto &board = espp::SmartPanleeSc01Plus::get(); + background_ = lv_obj_create(lv_screen_active()); + lv_obj_set_size(background_, board.rotated_display_width(), board.rotated_display_height()); + lv_obj_set_style_bg_color(background_, lv_color_make(8, 12, 24), 0); +} + +void Gui::init_label() { + auto &board = espp::SmartPanleeSc01Plus::get(); + label_ = lv_label_create(lv_screen_active()); + lv_label_set_text(label_, ""); + lv_label_set_long_mode(label_, LV_LABEL_LONG_WRAP); + lv_obj_set_style_text_align(label_, LV_TEXT_ALIGN_LEFT, 0); + // size / align the label for the current display width, leaving room on + // the right for the buttons + auto label_width = std::max(static_cast(board.rotated_display_width()) - 96, 120); + lv_obj_set_width(label_, label_width); + lv_obj_align(label_, LV_ALIGN_TOP_LEFT, 16, 16); +} + +void Gui::init_buttons() { + // a button in the top right which rotates the display through + // 0/90/180/270 degrees + rotate_button_ = lv_btn_create(lv_screen_active()); + lv_obj_set_size(rotate_button_, 56, 56); + lv_obj_align(rotate_button_, LV_ALIGN_TOP_RIGHT, -12, 12); + lv_obj_t *rotate_label = lv_label_create(rotate_button_); + lv_label_set_text(rotate_label, LV_SYMBOL_REFRESH); + lv_obj_align(rotate_label, LV_ALIGN_CENTER, 0, 0); + lv_obj_add_event_cb(rotate_button_, event_callback, LV_EVENT_PRESSED, this); + + // a button below the rotate button which clears the circles + clear_button_ = lv_btn_create(lv_screen_active()); + lv_obj_set_size(clear_button_, 56, 56); + lv_obj_align(clear_button_, LV_ALIGN_TOP_RIGHT, -12, 80); + lv_obj_add_state(clear_button_, LV_STATE_CHECKED); // make the button red + lv_obj_t *clear_label = lv_label_create(clear_button_); + lv_label_set_text(clear_label, LV_SYMBOL_TRASH); + lv_obj_align(clear_label, LV_ALIGN_CENTER, 0, 0); + lv_obj_add_event_cb(clear_button_, event_callback, LV_EVENT_PRESSED, this); +} + +void Gui::init_circle_layer() { + auto &board = espp::SmartPanleeSc01Plus::get(); + circle_layer_ = lv_obj_create(lv_screen_active()); + lv_obj_remove_style_all(circle_layer_); + lv_obj_set_size(circle_layer_, board.rotated_display_width(), board.rotated_display_height()); + lv_obj_align(circle_layer_, LV_ALIGN_CENTER, 0, 0); + lv_obj_clear_flag(circle_layer_, LV_OBJ_FLAG_CLICKABLE); + lv_obj_clear_flag(circle_layer_, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_bg_opa(circle_layer_, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(circle_layer_, 0, 0); + lv_obj_set_style_outline_width(circle_layer_, 0, 0); + lv_obj_set_style_shadow_width(circle_layer_, 0, 0); + lv_obj_add_event_cb(circle_layer_, draw_circle_layer, LV_EVENT_DRAW_MAIN, this); + lv_obj_move_foreground(circle_layer_); +} + +void Gui::update_layout() { + auto &board = espp::SmartPanleeSc01Plus::get(); + int width = board.rotated_display_width(); + int height = board.rotated_display_height(); + lv_obj_set_size(background_, width, height); + // size / align the label for the new display width, leaving room on the + // right for the buttons + auto label_width = std::max(width - 96, 120); + lv_obj_set_width(label_, label_width); + lv_obj_align(label_, LV_ALIGN_TOP_LEFT, 16, 16); + lv_obj_align(rotate_button_, LV_ALIGN_TOP_RIGHT, -12, 12); + lv_obj_align(clear_button_, LV_ALIGN_TOP_RIGHT, -12, 80); + lv_obj_set_size(circle_layer_, width, height); + lv_obj_align(circle_layer_, LV_ALIGN_CENTER, 0, 0); + lv_obj_move_foreground(circle_layer_); + lv_obj_invalidate(circle_layer_); +} + +bool Gui::update(std::mutex &m, std::condition_variable &cv) { + { + std::lock_guard lock(mutex_); + lv_task_handler(); + } + std::unique_lock lock(m); + cv.wait_for(lock, std::chrono::milliseconds(16)); + return false; // don't stop the task +} + +void Gui::event_callback(lv_event_t *e) { + auto *gui = static_cast(lv_event_get_user_data(e)); + if (!gui) { + return; + } + switch (lv_event_get_code(e)) { + case LV_EVENT_PRESSED: + gui->on_pressed(e); + break; + default: + break; + } +} + +void Gui::on_pressed(lv_event_t *e) { + const auto *target = static_cast(lv_event_get_target(e)); + if (target == rotate_button_) { + logger_.info("Rotate button pressed"); + next_rotation(); + } else if (target == clear_button_) { + logger_.info("Clear button pressed"); + clear_circles(); + } +} + +void Gui::set_label_text(std::string_view text) { + std::lock_guard lock(mutex_); + lv_label_set_text(label_, std::string(text).c_str()); +} + +void Gui::next_rotation() { + std::lock_guard lock(mutex_); + clear_circles_impl(); + auto rotation = lv_display_get_rotation(lv_display_get_default()); + rotation = static_cast((static_cast(rotation) + 1) % 4); + lv_display_set_rotation(lv_display_get_default(), rotation); + // re-size / re-align the UI to fill the newly-rotated display + update_layout(); +} + +void Gui::draw_circle(int x, int y, int radius) { + std::lock_guard lock(mutex_); + lv_obj_move_foreground(circle_layer_); + Circle previous_circle = circles_[next_circle_index_]; + circles_[next_circle_index_] = {.x = x, .y = y, .radius = radius, .visible = true}; + next_circle_index_ = (next_circle_index_ + 1) % circles_.size(); + if (visible_circle_count_ < circles_.size()) { + visible_circle_count_++; + } + if (previous_circle.visible) { + invalidate_circle_area(previous_circle); + } + invalidate_circle_area(circles_[(next_circle_index_ + circles_.size() - 1) % circles_.size()]); +} + +void Gui::clear_circles() { + std::lock_guard lock(mutex_); + clear_circles_impl(); +} + +void Gui::clear_circles_impl() { + for (auto &circle : circles_) { + if (circle.visible) { + invalidate_circle_area(circle); + } + circle.visible = false; + } + next_circle_index_ = 0; + visible_circle_count_ = 0; +} + +void Gui::invalidate_circle_area(const Circle &circle) { + if (!circle_layer_ || circle.radius <= 0) { + return; + } + lv_area_t obj_coords; + lv_obj_get_coords(circle_layer_, &obj_coords); + lv_area_t coords = { + .x1 = static_cast(obj_coords.x1 + circle.x - circle.radius), + .y1 = static_cast(obj_coords.y1 + circle.y - circle.radius), + .x2 = static_cast(obj_coords.x1 + circle.x + circle.radius - 1), + .y2 = static_cast(obj_coords.y1 + circle.y + circle.radius - 1), + }; + lv_obj_invalidate_area(circle_layer_, &coords); +} + +void Gui::draw_circle_layer(lv_event_t *e) { + const auto *gui = static_cast(lv_event_get_user_data(e)); + if (!gui) { + return; + } + gui->draw_circles(e); +} + +void Gui::draw_circles(lv_event_t *e) const { + if (visible_circle_count_ == 0) { + return; + } + + auto *obj = static_cast(lv_event_get_current_target(e)); + auto *layer = lv_event_get_layer(e); + lv_area_t obj_coords; + lv_obj_get_coords(obj, &obj_coords); + + lv_draw_rect_dsc_t rect_dsc; + lv_draw_rect_dsc_init(&rect_dsc); + rect_dsc.base.layer = layer; + rect_dsc.radius = LV_RADIUS_CIRCLE; + rect_dsc.bg_opa = LV_OPA_70; + rect_dsc.bg_color = lv_color_make(0, 255, 255); + rect_dsc.border_width = 0; + rect_dsc.outline_width = 0; + rect_dsc.shadow_width = 0; + + for (const auto &circle : circles_) { + if (!circle.visible) { + continue; + } + lv_area_t coords = { + .x1 = static_cast(obj_coords.x1 + circle.x - circle.radius), + .y1 = static_cast(obj_coords.y1 + circle.y - circle.radius), + .x2 = static_cast(obj_coords.x1 + circle.x + circle.radius - 1), + .y2 = static_cast(obj_coords.y1 + circle.y + circle.radius - 1), + }; + lv_draw_rect(layer, &rect_dsc, &coords); + } +} diff --git a/components/smartpanlee-sc01-plus/example/main/gui.hpp b/components/smartpanlee-sc01-plus/example/main/gui.hpp new file mode 100644 index 000000000..8f751bcd4 --- /dev/null +++ b/components/smartpanlee-sc01-plus/example/main/gui.hpp @@ -0,0 +1,122 @@ +#pragma once + +#include +#include +#include +#include + +#include "smartpanlee-sc01-plus.hpp" + +#include "logger.hpp" +#include "task.hpp" + +/// The Gui class encapsulates all of the LVGL UI for this example. It follows +/// the recommended pattern for building UIs with espp + LVGL in C++: +/// +/// * All LVGL objects are created in init_ui(), which is broken into small +/// member functions - one per logical piece of the UI. +/// * The class owns the task which calls lv_task_handler(), and a recursive +/// mutex which guards every LVGL call. Public methods lock that mutex, so +/// other tasks (touch callbacks, sensor tasks, etc.) can safely call them. +/// * LVGL event callbacks are registered with `this` as the user-data and +/// dispatched through a single static trampoline (event_callback) into +/// member functions, keeping all UI logic inside the class. +/// +/// For this example the Gui shows: +/// * A wrapping info label (top-left) with instructions +/// * A button (top-right) which rotates the display through 0/90/180/270 +/// * A button (below the rotate button) which clears the circles drawn on +/// the screen +/// * A custom-drawn layer of circles which trail the most recent touches +class Gui { +public: + /// Configuration for the Gui + struct Config { + espp::Logger::Verbosity log_level{espp::Logger::Verbosity::WARN}; ///< Log verbosity + }; + + /// Construct the Gui: builds the UI and starts the LVGL update task. + /// @param config The configuration for the Gui + explicit Gui(const Config &config) + : logger_({.tag = "Gui", .level = config.log_level}) { + init_ui(); + update_task_.start(); + } + + ~Gui() { + update_task_.stop(); + deinit_ui(); + } + + /// Set the text of the info label. Thread-safe. + /// @param text The text to display + void set_label_text(std::string_view text); + + /// Draw a circle at the given screen coordinates, replacing the oldest + /// circle if the maximum number are already visible. Thread-safe. + /// @param x The x coordinate (screen space) + /// @param y The y coordinate (screen space) + /// @param radius The radius of the circle + void draw_circle(int x, int y, int radius); + + /// Clear all circles from the screen. Thread-safe. + void clear_circles(); + + /// Rotate the display to the next of 0/90/180/270 degrees, resizing / + /// re-aligning the UI to match. Thread-safe. + void next_rotation(); + +protected: + static constexpr size_t MAX_CIRCLES = 100; + + struct Circle { + int x{0}; + int y{0}; + int radius{0}; + bool visible{false}; + }; + + void init_ui(); + void deinit_ui(); + + // the individual pieces of the UI, called from init_ui() + void init_background(); + void init_label(); + void init_buttons(); + void init_circle_layer(); + + // re-size / re-align the UI to fill the (possibly rotated) display + void update_layout(); + + // the LVGL update task: calls lv_task_handler() under the mutex + bool update(std::mutex &m, std::condition_variable &cv); + + // single trampoline for all LVGL events; dispatches to the member + // functions below based on the event target + static void event_callback(lv_event_t *e); + void on_pressed(lv_event_t *e); + + // custom drawing of the circle layer + static void draw_circle_layer(lv_event_t *e); + void draw_circles(lv_event_t *e) const; + void invalidate_circle_area(const Circle &circle); + + // unlocked implementations, called with the mutex held + void clear_circles_impl(); + + // LVGL objects + lv_obj_t *background_{nullptr}; + lv_obj_t *label_{nullptr}; + lv_obj_t *rotate_button_{nullptr}; + lv_obj_t *clear_button_{nullptr}; + lv_obj_t *circle_layer_{nullptr}; + + std::array circles_; + size_t next_circle_index_{0}; + size_t visible_circle_count_{0}; + + espp::Task update_task_{{.callback = [this](auto &m, auto &cv) { return update(m, cv); }, + .task_config = {.name = "gui", .stack_size_bytes = 6 * 1024}}}; + espp::Logger logger_; + std::recursive_mutex mutex_; +}; diff --git a/components/smartpanlee-sc01-plus/example/main/smartpanlee_sc01_plus_example.cpp b/components/smartpanlee-sc01-plus/example/main/smartpanlee_sc01_plus_example.cpp index 37bb89eaf..0a47e4e34 100755 --- a/components/smartpanlee-sc01-plus/example/main/smartpanlee_sc01_plus_example.cpp +++ b/components/smartpanlee-sc01-plus/example/main/smartpanlee_sc01_plus_example.cpp @@ -4,39 +4,18 @@ */ #include -#include #include #include #include #include "smartpanlee-sc01-plus.hpp" -#include "task.hpp" +#include "gui.hpp" using namespace std::chrono_literals; -static constexpr size_t MAX_CIRCLES = 100; -struct Circle { - int x{0}; - int y{0}; - int radius{0}; - bool visible{false}; -}; -static std::array circles; -static size_t next_circle_index = 0; -static size_t visible_circle_count = 0; static std::vector audio_bytes; -static std::recursive_mutex lvgl_mutex; -static lv_obj_t *background = nullptr; -static lv_obj_t *circle_layer = nullptr; -static lv_obj_t *info_label = nullptr; -static bool initialize_circle_layer(int width, int height); -static void draw_circle_layer(lv_event_t *event); -static void invalidate_circle_area(const Circle &circle); -static void draw_circle(int x0, int y0, int radius); -static void clear_circles(); -static void update_label_layout(int width); -static void rotate_display(); + static bool load_audio(size_t &out_size, size_t &out_sample_rate); static void play_click(espp::SmartPanleeSc01Plus &board); @@ -57,19 +36,6 @@ extern "C" void app_main(void) { return; } - auto touch_callback = [&](const auto &touch) { - static auto previous_touchpad_data = board.touchpad_convert(touch); - auto touchpad_data = board.touchpad_convert(touch); - if (touchpad_data != previous_touchpad_data) { - previous_touchpad_data = touchpad_data; - if (touchpad_data.num_touch_points > 0) { - play_click(board); - std::lock_guard lock(lvgl_mutex); - draw_circle(touchpad_data.x, touchpad_data.y, 10); - } - } - }; - if (!board.initialize_audio()) { logger.warn("Audio initialization did not complete cleanly"); } else { @@ -96,59 +62,32 @@ extern "C" void app_main(void) { logger.info("I2S pins: bclk={}, ws={}, dout={}", i2s.bclk, i2s.ws, i2s.dout); logger.info("RS485 pins: rts={}, rxd={}, txd={}", rs485.rts, rs485.rxd, rs485.txd); - lv_obj_t *bg = lv_obj_create(lv_screen_active()); - lv_obj_set_size(bg, board.display_width(), board.display_height()); - lv_obj_set_style_bg_color(bg, lv_color_make(8, 12, 24), 0); - - info_label = lv_label_create(lv_screen_active()); - lv_label_set_text(info_label, "Smart Panlee SC01 Plus\n\nTouch the screen to draw and play a " - "click.\nPress refresh to rotate.\nCheck serial output for SD " - "card, pin, and audio info."); - lv_label_set_long_mode(info_label, LV_LABEL_LONG_WRAP); - lv_obj_set_style_text_align(info_label, LV_TEXT_ALIGN_LEFT, 0); - update_label_layout(static_cast(board.display_width())); - - lv_obj_t *btn = lv_btn_create(lv_screen_active()); - lv_obj_set_size(btn, 56, 56); - lv_obj_align(btn, LV_ALIGN_TOP_RIGHT, -12, 12); - lv_obj_t *btn_label = lv_label_create(btn); - lv_label_set_text(btn_label, LV_SYMBOL_REFRESH); - lv_obj_align(btn_label, LV_ALIGN_CENTER, 0, 0); - background = bg; - if (!initialize_circle_layer(board.display_width(), board.display_height())) { - logger.error("Failed to initialize circle layer!"); - return; - } - lv_obj_add_event_cb( - btn, - [](lv_event_t *event) { - (void)event; - rotate_display(); - }, - LV_EVENT_PRESSED, nullptr); - - lv_obj_set_scrollbar_mode(lv_screen_active(), LV_SCROLLBAR_MODE_OFF); - lv_obj_clear_flag(lv_screen_active(), LV_OBJ_FLAG_SCROLLABLE); - lv_obj_move_foreground(circle_layer); - + // create the GUI: builds the UI (info label, buttons, circle layer) and + // starts the task which updates LVGL. All of its public methods are + // thread-safe, so the touch callback below can call them directly. + static Gui gui({.log_level = espp::Logger::Verbosity::INFO}); + gui.set_label_text( + fmt::format("Smart Panlee SC01 Plus\n\nTouch the screen to draw and play a click.\nPress {} " + "to rotate.\nPress {} to clear.\nCheck serial output for SD card, pin, and " + "audio info.", + LV_SYMBOL_REFRESH, LV_SYMBOL_TRASH)); + + // initialize the touchpad after the GUI exists so touch events can update + // it immediately; each touch draws a circle and plays a click sound + auto touch_callback = [&](const auto &touch) { + static auto previous_touchpad_data = board.touchpad_convert(touch); + auto touchpad_data = board.touchpad_convert(touch); + if (touchpad_data != previous_touchpad_data) { + previous_touchpad_data = touchpad_data; + if (touchpad_data.num_touch_points > 0) { + play_click(board); + gui.draw_circle(touchpad_data.x, touchpad_data.y, 10); + } + } + }; if (!board.initialize_touch(touch_callback)) { logger.warn("Touch initialization did not complete cleanly"); } - - espp::Task lv_task({.callback = [](std::mutex &m, std::condition_variable &cv) -> bool { - { - std::lock_guard lock(lvgl_mutex); - lv_task_handler(); - } - std::unique_lock lock(m); - cv.wait_for(lock, 16ms); - return false; - }, - .task_config = { - .name = "lv_task", - .stack_size_bytes = 6 * 1024, - }}); - lv_task.start(); //! [smartpanlee sc01 plus example] while (true) { @@ -163,135 +102,6 @@ extern "C" void app_main(void) { } } -static bool initialize_circle_layer(int width, int height) { - if (circle_layer) { - return true; - } - circle_layer = lv_obj_create(lv_screen_active()); - if (!circle_layer) { - return false; - } - lv_obj_remove_style_all(circle_layer); - lv_obj_set_size(circle_layer, width, height); - lv_obj_align(circle_layer, LV_ALIGN_CENTER, 0, 0); - lv_obj_clear_flag(circle_layer, LV_OBJ_FLAG_CLICKABLE); - lv_obj_clear_flag(circle_layer, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_set_style_bg_opa(circle_layer, LV_OPA_TRANSP, 0); - lv_obj_set_style_border_width(circle_layer, 0, 0); - lv_obj_set_style_outline_width(circle_layer, 0, 0); - lv_obj_set_style_shadow_width(circle_layer, 0, 0); - lv_obj_add_event_cb(circle_layer, draw_circle_layer, LV_EVENT_DRAW_MAIN, nullptr); - return true; -} - -static void draw_circle_layer(lv_event_t *event) { - if (visible_circle_count == 0) { - return; - } - - auto *obj = static_cast(lv_event_get_current_target(event)); - auto *layer = lv_event_get_layer(event); - lv_area_t obj_coords; - lv_obj_get_coords(obj, &obj_coords); - - lv_draw_rect_dsc_t rect_dsc; - lv_draw_rect_dsc_init(&rect_dsc); - rect_dsc.base.layer = layer; - rect_dsc.radius = LV_RADIUS_CIRCLE; - rect_dsc.bg_opa = LV_OPA_70; - rect_dsc.bg_color = lv_color_make(0, 255, 255); - rect_dsc.border_width = 0; - rect_dsc.outline_width = 0; - rect_dsc.shadow_width = 0; - - for (const auto &circle : circles) { - if (!circle.visible) { - continue; - } - lv_area_t coords = { - .x1 = static_cast(obj_coords.x1 + circle.x - circle.radius), - .y1 = static_cast(obj_coords.y1 + circle.y - circle.radius), - .x2 = static_cast(obj_coords.x1 + circle.x + circle.radius - 1), - .y2 = static_cast(obj_coords.y1 + circle.y + circle.radius - 1), - }; - lv_draw_rect(layer, &rect_dsc, &coords); - } -} - -static void invalidate_circle_area(const Circle &circle) { - if (!circle_layer || circle.radius <= 0) { - return; - } - - lv_area_t obj_coords; - lv_obj_get_coords(circle_layer, &obj_coords); - lv_area_t coords = { - .x1 = static_cast(obj_coords.x1 + circle.x - circle.radius), - .y1 = static_cast(obj_coords.y1 + circle.y - circle.radius), - .x2 = static_cast(obj_coords.x1 + circle.x + circle.radius - 1), - .y2 = static_cast(obj_coords.y1 + circle.y + circle.radius - 1), - }; - lv_obj_invalidate_area(circle_layer, &coords); -} - -static void draw_circle(int x0, int y0, int radius) { - if (!circle_layer) { - return; - } - lv_obj_move_foreground(circle_layer); - Circle previous_circle = circles[next_circle_index]; - circles[next_circle_index] = {.x = x0, .y = y0, .radius = radius, .visible = true}; - next_circle_index = (next_circle_index + 1) % circles.size(); - if (visible_circle_count < circles.size()) { - visible_circle_count++; - } - if (previous_circle.visible) { - invalidate_circle_area(previous_circle); - } - invalidate_circle_area(circles[(next_circle_index + circles.size() - 1) % circles.size()]); -} - -static void clear_circles() { - for (auto &circle : circles) { - if (circle.visible) { - invalidate_circle_area(circle); - } - circle.visible = false; - } - next_circle_index = 0; - visible_circle_count = 0; -} - -static void update_label_layout(int width) { - if (!info_label) { - return; - } - - auto label_width = std::max(width - 96, 120); - lv_obj_set_width(info_label, label_width); - lv_obj_align(info_label, LV_ALIGN_TOP_LEFT, 16, 16); -} - -static void rotate_display() { - auto &board = espp::SmartPanleeSc01Plus::get(); - std::lock_guard lock(lvgl_mutex); - clear_circles(); - static auto rotation = LV_DISPLAY_ROTATION_0; - rotation = static_cast((static_cast(rotation) + 1) % 4); - auto *display = lv_display_get_default(); - lv_disp_set_rotation(display, rotation); - if (background) { - lv_obj_set_size(background, board.rotated_display_width(), board.rotated_display_height()); - } - update_label_layout(static_cast(board.rotated_display_width())); - if (circle_layer) { - lv_obj_set_size(circle_layer, board.rotated_display_width(), board.rotated_display_height()); - lv_obj_align(circle_layer, LV_ALIGN_CENTER, 0, 0); - lv_obj_move_foreground(circle_layer); - lv_obj_invalidate(circle_layer); - } -} - static bool load_audio(size_t &out_size, size_t &out_sample_rate) { if (!audio_bytes.empty()) { out_size = audio_bytes.size(); diff --git a/components/t-deck/example/main/gui.cpp b/components/t-deck/example/main/gui.cpp new file mode 100644 index 000000000..7614e61bb --- /dev/null +++ b/components/t-deck/example/main/gui.cpp @@ -0,0 +1,212 @@ +#include "gui.hpp" + +void Gui::init_ui() { + std::lock_guard lock(mutex_); + init_background(); + init_label(); + init_buttons(); + init_circle_layer(); + // disable scrolling on the screen (so that it doesn't behave weirdly when + // rotated and drawing with your finger) + lv_obj_set_scrollbar_mode(lv_screen_active(), LV_SCROLLBAR_MODE_OFF); + lv_obj_clear_flag(lv_screen_active(), LV_OBJ_FLAG_SCROLLABLE); +} + +void Gui::deinit_ui() { + std::lock_guard lock(mutex_); + lv_obj_clean(lv_screen_active()); +} + +void Gui::init_background() { + auto &tdeck = espp::TDeck::get(); + background_ = lv_obj_create(lv_screen_active()); + lv_obj_set_size(background_, tdeck.lcd_width(), tdeck.lcd_height()); + lv_obj_set_style_bg_color(background_, lv_color_make(0, 0, 0), 0); +} + +void Gui::init_label() { + label_ = lv_label_create(lv_screen_active()); + lv_label_set_text(label_, ""); + lv_obj_align(label_, LV_ALIGN_CENTER, 0, 0); + lv_obj_set_style_text_align(label_, LV_TEXT_ALIGN_CENTER, 0); +} + +void Gui::init_buttons() { + // a button in the top left which rotates the display through + // 0/90/180/270 degrees + rotate_button_ = lv_btn_create(lv_screen_active()); + lv_obj_set_size(rotate_button_, 50, 50); + lv_obj_align(rotate_button_, LV_ALIGN_TOP_LEFT, 0, 0); + lv_obj_t *rotate_label = lv_label_create(rotate_button_); + lv_label_set_text(rotate_label, LV_SYMBOL_REFRESH); + lv_obj_align(rotate_label, LV_ALIGN_CENTER, 0, 0); + lv_obj_add_event_cb(rotate_button_, event_callback, LV_EVENT_PRESSED, this); + + // a button in the top right which clears the circles + clear_button_ = lv_btn_create(lv_screen_active()); + lv_obj_set_size(clear_button_, 50, 50); + lv_obj_align(clear_button_, LV_ALIGN_TOP_RIGHT, 0, 0); + lv_obj_t *clear_label = lv_label_create(clear_button_); + lv_label_set_text(clear_label, LV_SYMBOL_TRASH); + lv_obj_align(clear_label, LV_ALIGN_CENTER, 0, 0); + lv_obj_add_event_cb(clear_button_, event_callback, LV_EVENT_PRESSED, this); +} + +void Gui::init_circle_layer() { + auto &tdeck = espp::TDeck::get(); + circle_layer_ = lv_obj_create(lv_screen_active()); + lv_obj_remove_style_all(circle_layer_); + lv_obj_set_size(circle_layer_, tdeck.lcd_width(), tdeck.lcd_height()); + lv_obj_align(circle_layer_, LV_ALIGN_CENTER, 0, 0); + lv_obj_clear_flag(circle_layer_, LV_OBJ_FLAG_CLICKABLE); + lv_obj_clear_flag(circle_layer_, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_bg_opa(circle_layer_, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(circle_layer_, 0, 0); + lv_obj_set_style_outline_width(circle_layer_, 0, 0); + lv_obj_set_style_shadow_width(circle_layer_, 0, 0); + lv_obj_add_event_cb(circle_layer_, draw_circle_layer, LV_EVENT_DRAW_MAIN, this); + lv_obj_move_foreground(circle_layer_); +} + +bool Gui::update(std::mutex &m, std::condition_variable &cv) { + { + std::lock_guard lock(mutex_); + lv_task_handler(); + } + std::unique_lock lock(m); + cv.wait_for(lock, std::chrono::milliseconds(16)); + return false; // don't stop the task +} + +void Gui::event_callback(lv_event_t *e) { + auto *gui = static_cast(lv_event_get_user_data(e)); + if (!gui) { + return; + } + switch (lv_event_get_code(e)) { + case LV_EVENT_PRESSED: + gui->on_pressed(e); + break; + default: + break; + } +} + +void Gui::on_pressed(lv_event_t *e) { + const auto *target = static_cast(lv_event_get_target(e)); + if (target == rotate_button_) { + logger_.info("Rotate button pressed"); + next_rotation(); + } else if (target == clear_button_) { + logger_.info("Clear button pressed"); + clear_circles(); + } +} + +void Gui::set_label_text(std::string_view text) { + std::lock_guard lock(mutex_); + lv_label_set_text(label_, std::string(text).c_str()); +} + +void Gui::next_rotation() { + std::lock_guard lock(mutex_); + auto &tdeck = espp::TDeck::get(); + clear_circles_impl(); + auto rotation = lv_display_get_rotation(lv_display_get_default()); + rotation = static_cast((static_cast(rotation) + 1) % 4); + lv_display_set_rotation(lv_display_get_default(), rotation); + // update the size of the screen-filling objects + lv_obj_set_size(background_, tdeck.rotated_display_width(), tdeck.rotated_display_height()); + lv_obj_align(label_, LV_ALIGN_CENTER, 0, 0); + lv_obj_set_size(circle_layer_, tdeck.rotated_display_width(), tdeck.rotated_display_height()); + lv_obj_align(circle_layer_, LV_ALIGN_CENTER, 0, 0); + lv_obj_invalidate(circle_layer_); +} + +void Gui::draw_circle(int x, int y, int radius) { + std::lock_guard lock(mutex_); + lv_obj_move_foreground(circle_layer_); + Circle previous_circle = circles_[next_circle_index_]; + circles_[next_circle_index_] = {.x = x, .y = y, .radius = radius, .visible = true}; + next_circle_index_ = (next_circle_index_ + 1) % circles_.size(); + if (visible_circle_count_ < circles_.size()) { + visible_circle_count_++; + } + if (previous_circle.visible) { + invalidate_circle_area(previous_circle); + } + invalidate_circle_area(circles_[(next_circle_index_ + circles_.size() - 1) % circles_.size()]); +} + +void Gui::clear_circles() { + std::lock_guard lock(mutex_); + clear_circles_impl(); +} + +void Gui::clear_circles_impl() { + for (auto &circle : circles_) { + if (circle.visible) { + invalidate_circle_area(circle); + } + circle.visible = false; + } + next_circle_index_ = 0; + visible_circle_count_ = 0; +} + +void Gui::invalidate_circle_area(const Circle &circle) { + if (!circle_layer_ || circle.radius <= 0) { + return; + } + lv_area_t obj_coords; + lv_obj_get_coords(circle_layer_, &obj_coords); + lv_area_t coords = { + .x1 = static_cast(obj_coords.x1 + circle.x - circle.radius), + .y1 = static_cast(obj_coords.y1 + circle.y - circle.radius), + .x2 = static_cast(obj_coords.x1 + circle.x + circle.radius - 1), + .y2 = static_cast(obj_coords.y1 + circle.y + circle.radius - 1), + }; + lv_obj_invalidate_area(circle_layer_, &coords); +} + +void Gui::draw_circle_layer(lv_event_t *e) { + const auto *gui = static_cast(lv_event_get_user_data(e)); + if (!gui) { + return; + } + gui->draw_circles(e); +} + +void Gui::draw_circles(lv_event_t *e) const { + if (visible_circle_count_ == 0) { + return; + } + + auto *obj = static_cast(lv_event_get_current_target(e)); + auto *layer = lv_event_get_layer(e); + lv_area_t obj_coords; + lv_obj_get_coords(obj, &obj_coords); + + lv_draw_rect_dsc_t rect_dsc; + lv_draw_rect_dsc_init(&rect_dsc); + rect_dsc.base.layer = layer; + rect_dsc.radius = LV_RADIUS_CIRCLE; + rect_dsc.bg_opa = LV_OPA_70; + rect_dsc.bg_color = lv_color_make(0, 255, 255); + rect_dsc.border_width = 0; + rect_dsc.outline_width = 0; + rect_dsc.shadow_width = 0; + + for (const auto &circle : circles_) { + if (!circle.visible) { + continue; + } + lv_area_t coords = { + .x1 = static_cast(obj_coords.x1 + circle.x - circle.radius), + .y1 = static_cast(obj_coords.y1 + circle.y - circle.radius), + .x2 = static_cast(obj_coords.x1 + circle.x + circle.radius - 1), + .y2 = static_cast(obj_coords.y1 + circle.y + circle.radius - 1), + }; + lv_draw_rect(layer, &rect_dsc, &coords); + } +} diff --git a/components/t-deck/example/main/gui.hpp b/components/t-deck/example/main/gui.hpp new file mode 100644 index 000000000..78cf787f5 --- /dev/null +++ b/components/t-deck/example/main/gui.hpp @@ -0,0 +1,119 @@ +#pragma once + +#include +#include +#include +#include + +#include "t-deck.hpp" + +#include "logger.hpp" +#include "task.hpp" + +/// The Gui class encapsulates all of the LVGL UI for this example. It follows +/// the recommended pattern for building UIs with espp + LVGL in C++: +/// +/// * All LVGL objects are created in init_ui(), which is broken into small +/// member functions - one per logical piece of the UI. +/// * The class owns the task which calls lv_task_handler(), and a recursive +/// mutex which guards every LVGL call. Public methods lock that mutex, so +/// other tasks (touch callbacks, keyboard callbacks, etc.) can safely call +/// them. +/// * LVGL event callbacks are registered with `this` as the user-data and +/// dispatched through a single static trampoline (event_callback) into +/// member functions, keeping all UI logic inside the class. +/// +/// For this example the Gui shows: +/// * A label with instructions +/// * A button (top-left) which rotates the display through 0/90/180/270 +/// * A button (top-right) which clears the circles drawn on the screen +/// * A custom-drawn layer of circles which trail the most recent touches +class Gui { +public: + /// Configuration for the Gui + struct Config { + espp::Logger::Verbosity log_level{espp::Logger::Verbosity::WARN}; ///< Log verbosity + }; + + /// Construct the Gui: builds the UI and starts the LVGL update task. + /// @param config The configuration for the Gui + explicit Gui(const Config &config) + : logger_({.tag = "Gui", .level = config.log_level}) { + init_ui(); + update_task_.start(); + } + + ~Gui() { + update_task_.stop(); + deinit_ui(); + } + + /// Set the text of the main label. Thread-safe. + /// @param text The text to display + void set_label_text(std::string_view text); + + /// Draw a circle at the given screen coordinates, replacing the oldest + /// circle if the maximum number are already visible. Thread-safe. + /// @param x The x coordinate (screen space) + /// @param y The y coordinate (screen space) + /// @param radius The radius of the circle + void draw_circle(int x, int y, int radius); + + /// Clear all circles from the screen. Thread-safe. + void clear_circles(); + + /// Rotate the display to the next of 0/90/180/270 degrees, resizing / + /// re-aligning the UI to match. Thread-safe. + void next_rotation(); + +protected: + static constexpr size_t MAX_CIRCLES = 100; + + struct Circle { + int x{0}; + int y{0}; + int radius{0}; + bool visible{false}; + }; + + void init_ui(); + void deinit_ui(); + + // the individual pieces of the UI, called from init_ui() + void init_background(); + void init_label(); + void init_buttons(); + void init_circle_layer(); + + // the LVGL update task: calls lv_task_handler() under the mutex + bool update(std::mutex &m, std::condition_variable &cv); + + // single trampoline for all LVGL events; dispatches to the member + // functions below based on the event target + static void event_callback(lv_event_t *e); + void on_pressed(lv_event_t *e); + + // custom drawing of the circle layer + static void draw_circle_layer(lv_event_t *e); + void draw_circles(lv_event_t *e) const; + void invalidate_circle_area(const Circle &circle); + + // unlocked implementations, called with the mutex held + void clear_circles_impl(); + + // LVGL objects + lv_obj_t *background_{nullptr}; + lv_obj_t *label_{nullptr}; + lv_obj_t *rotate_button_{nullptr}; + lv_obj_t *clear_button_{nullptr}; + lv_obj_t *circle_layer_{nullptr}; + + std::array circles_; + size_t next_circle_index_{0}; + size_t visible_circle_count_{0}; + + espp::Task update_task_{{.callback = [this](auto &m, auto &cv) { return update(m, cv); }, + .task_config = {.name = "gui", .stack_size_bytes = 6 * 1024}}}; + espp::Logger logger_; + std::recursive_mutex mutex_; +}; diff --git a/components/t-deck/example/main/t_deck_example.cpp b/components/t-deck/example/main/t_deck_example.cpp index 0ebe1128e..c4881d947 100644 --- a/components/t-deck/example/main/t_deck_example.cpp +++ b/components/t-deck/example/main/t_deck_example.cpp @@ -1,36 +1,18 @@ -#include #include #include +#include #include "t-deck.hpp" +#include "gui.hpp" + using namespace std::chrono_literals; -static constexpr size_t MAX_CIRCLES = 100; -struct Circle { - int x{0}; - int y{0}; - int radius{0}; - bool visible{false}; -}; -static std::array circles; -static size_t next_circle_index = 0; -static size_t visible_circle_count = 0; static std::vector audio_bytes; -static lv_obj_t *circle_layer = nullptr; - -static std::recursive_mutex lvgl_mutex; -static bool initialize_circle_layer(int width, int height); -static void draw_circle_layer(lv_event_t *event); -static void invalidate_circle_area(const Circle &circle); -static void draw_circle(int x0, int y0, int radius); -static void clear_circles(); static bool load_audio(size_t &out_size, size_t &out_sample_rate); static void play_click(espp::TDeck &tdeck); -LV_IMG_DECLARE(mouse_cursor_icon); - extern "C" void app_main(void) { espp::Logger logger({.tag = "T-Deck Example", .level = espp::Logger::Verbosity::INFO}); logger.info("Starting example!"); @@ -39,38 +21,51 @@ extern "C" void app_main(void) { espp::TDeck &tdeck = espp::TDeck::get(); tdeck.set_log_level(espp::Logger::Verbosity::INFO); - lv_obj_t *bg = nullptr; + // initialize the uSD card + using SdCardConfig = espp::TDeck::SdCardConfig; + SdCardConfig sdcard_config{}; + if (!tdeck.initialize_sdcard(sdcard_config)) { + logger.warn("Failed to initialize uSD card, there may not be a uSD card inserted!"); + } + // initialize the sound + if (!tdeck.initialize_sound()) { + logger.error("Failed to initialize sound!"); + return; + } + // initialize the LCD + if (!tdeck.initialize_lcd()) { + logger.error("Failed to initialize LCD!"); + return; + } + // set the pixel buffer to be 50 lines high + static constexpr size_t pixel_buffer_size = tdeck.lcd_width() * 50; + // initialize the LVGL display for the T-Deck + if (!tdeck.initialize_display(pixel_buffer_size)) { + logger.error("Failed to initialize display!"); + return; + } - static auto rotation = LV_DISPLAY_ROTATION_0; - static auto rotate_display = [&]() { - std::lock_guard lock(lvgl_mutex); - clear_circles(); - rotation = static_cast((static_cast(rotation) + 1) % 4); - lv_display_t *disp = lv_display_get_default(); - lv_disp_set_rotation(disp, rotation); - // update the size of the screen - lv_obj_set_size(bg, tdeck.rotated_display_width(), tdeck.rotated_display_height()); - if (circle_layer) { - lv_obj_set_size(circle_layer, tdeck.rotated_display_width(), tdeck.rotated_display_height()); - lv_obj_align(circle_layer, LV_ALIGN_CENTER, 0, 0); - lv_obj_move_foreground(circle_layer); - lv_obj_invalidate(circle_layer); - } - }; + // create the GUI: builds the UI (label, buttons, circle layer) and starts + // the task which updates LVGL. All of its public methods are thread-safe, + // so the keyboard and touch callbacks below can call them directly. + static Gui gui({.log_level = espp::Logger::Verbosity::INFO}); + gui.set_label_text( + fmt::format("Touch the screen!\nPress the delete key or the {} button to clear " + "circles.\nPress the space key or the {} button to rotate the display.", + LV_SYMBOL_TRASH, LV_SYMBOL_REFRESH)); + // initialize the Keyboard after the Gui exists so key presses can act on it + // immediately auto keypress_callback = [&](uint8_t key) { logger.info("Key pressed: {}", key); if (key == 8) { // delete key will clear the circles logger.info("Clearing circles"); - std::lock_guard lock(lvgl_mutex); - clear_circles(); + gui.clear_circles(); } else if (key == ' ') { // space key will rotate the display logger.info("Rotating display"); - std::lock_guard lock(lvgl_mutex); - clear_circles(); - rotate_display(); + gui.next_rotation(); } else if (key == 'm') { // 'm' key will toggle audio mute logger.info("Toggling mute"); @@ -88,7 +83,23 @@ extern "C" void app_main(void) { logger.info("Volume: {}", tdeck.volume()); } }; + bool start_task = true; + if (!tdeck.initialize_keyboard(start_task, keypress_callback)) { + logger.error("Failed to initialize Keyboard!"); + return; + } + + // initialize the trackball + auto trackball_callback = [&](const auto &trackball) { + logger.debug("Trackball: {}", trackball); + }; + if (!tdeck.initialize_trackball(trackball_callback)) { + logger.error("Failed to initialize trackball!"); + return; + } + // initialize the touchpad; each touch draws a circle (and plays a click + // sound) auto touch_callback = [&](const auto &touch) { // NOTE: since we're directly using the touchpad data, and not using the // TouchpadInput + LVGL, we'll need to ensure the touchpad data is @@ -99,111 +110,18 @@ extern "C" void app_main(void) { if (touchpad_data != previous_touchpad_data) { logger.info("Touch: {}", touchpad_data); previous_touchpad_data = touchpad_data; - // if there is a touch point, draw a circle + // if there is a touch point, draw a circle and play a click sound if (touchpad_data.num_touch_points > 0) { play_click(tdeck); - std::lock_guard lock(lvgl_mutex); - draw_circle(touchpad_data.x, touchpad_data.y, 10); + gui.draw_circle(touchpad_data.x, touchpad_data.y, 10); } } }; - - auto trackball_callback = [&](const auto &trackball) { - logger.debug("Trackball: {}", trackball); - }; - - // initialize the uSD card - using SdCardConfig = espp::TDeck::SdCardConfig; - SdCardConfig sdcard_config{}; - if (!tdeck.initialize_sdcard(sdcard_config)) { - logger.warn("Failed to initialize uSD card, there may not be a uSD card inserted!"); - } - // initialize the Keyboard - bool start_task = true; - if (!tdeck.initialize_keyboard(start_task, keypress_callback)) { - logger.error("Failed to initialize Keyboard!"); - return; - } - // initialize the sound - if (!tdeck.initialize_sound()) { - logger.error("Failed to initialize sound!"); - return; - } - // initialize the LCD - if (!tdeck.initialize_lcd()) { - logger.error("Failed to initialize LCD!"); - return; - } - // set the pixel buffer to be 50 lines high - static constexpr size_t pixel_buffer_size = tdeck.lcd_width() * 50; - // initialize the LVGL display for the T-Deck - if (!tdeck.initialize_display(pixel_buffer_size)) { - logger.error("Failed to initialize display!"); - return; - } - // initialize the trackball - if (!tdeck.initialize_trackball(trackball_callback)) { - logger.error("Failed to initialize trackball!"); - return; - } - - // set the background color to black - bg = lv_obj_create(lv_screen_active()); - lv_obj_set_size(bg, tdeck.lcd_width(), tdeck.lcd_height()); - lv_obj_set_style_bg_color(bg, lv_color_make(0, 0, 0), 0); - if (!initialize_circle_layer(tdeck.lcd_width(), tdeck.lcd_height())) { - logger.error("Failed to initialize circle layer!"); - return; - } - - // add text in the center of the screen - lv_obj_t *label = lv_label_create(lv_screen_active()); - lv_label_set_text(label, "Touch the screen!\nPress the delete key to clear circles.\nPress the " - "space key to rotate the display."); - lv_obj_align(label, LV_ALIGN_CENTER, 0, 0); - lv_obj_set_style_text_align(label, LV_TEXT_ALIGN_CENTER, 0); - - // add a button in the top left which (when pressed) will rotate the display - // through 0, 90, 180, 270 degrees - lv_obj_t *btn = lv_btn_create(lv_screen_active()); - lv_obj_set_size(btn, 50, 50); - lv_obj_align(btn, LV_ALIGN_TOP_LEFT, 0, 0); - lv_obj_t *label_btn = lv_label_create(btn); - lv_label_set_text(label_btn, LV_SYMBOL_REFRESH); - // center the text in the button - lv_obj_align(label_btn, LV_ALIGN_CENTER, 0, 0); - lv_obj_add_event_cb( - btn, [](auto event) { rotate_display(); }, LV_EVENT_PRESSED, nullptr); - - // disable scrolling on the screen (so that it doesn't behave weirdly when - // rotated and drawing with your finger) - lv_obj_set_scrollbar_mode(lv_screen_active(), LV_SCROLLBAR_MODE_OFF); - lv_obj_clear_flag(lv_screen_active(), LV_OBJ_FLAG_SCROLLABLE); - lv_obj_move_foreground(circle_layer); - - // initialize the touchpad after the circle layer exists so touch events can - // update it immediately. if (!tdeck.initialize_touch(touch_callback)) { logger.error("Failed to initialize touchpad!"); return; } - // start a simple thread to do the lv_task_handler every 16ms - espp::Task lv_task({.callback = [](std::mutex &m, std::condition_variable &cv) -> bool { - { - std::lock_guard lock(lvgl_mutex); - lv_task_handler(); - } - std::unique_lock lock(m); - cv.wait_for(lock, 16ms); - return false; - }, - .task_config = { - .name = "lv_task", - .stack_size_bytes = 6 * 1024, - }}); - lv_task.start(); - // load the audio file (wav file bundled in memory) size_t wav_size = 0; size_t wav_sample_rate = 0; @@ -230,104 +148,6 @@ extern "C" void app_main(void) { //! [t-deck example] } -static bool initialize_circle_layer(int width, int height) { - if (circle_layer) { - return true; - } - circle_layer = lv_obj_create(lv_screen_active()); - if (!circle_layer) { - return false; - } - lv_obj_remove_style_all(circle_layer); - lv_obj_set_size(circle_layer, width, height); - lv_obj_align(circle_layer, LV_ALIGN_CENTER, 0, 0); - lv_obj_clear_flag(circle_layer, LV_OBJ_FLAG_CLICKABLE); - lv_obj_clear_flag(circle_layer, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_set_style_bg_opa(circle_layer, LV_OPA_TRANSP, 0); - lv_obj_set_style_border_width(circle_layer, 0, 0); - lv_obj_set_style_outline_width(circle_layer, 0, 0); - lv_obj_set_style_shadow_width(circle_layer, 0, 0); - lv_obj_add_event_cb(circle_layer, draw_circle_layer, LV_EVENT_DRAW_MAIN, nullptr); - return true; -} - -static void draw_circle_layer(lv_event_t *event) { - if (visible_circle_count == 0) { - return; - } - auto *obj = static_cast(lv_event_get_current_target(event)); - auto *layer = lv_event_get_layer(event); - lv_area_t obj_coords; - lv_obj_get_coords(obj, &obj_coords); - - lv_draw_rect_dsc_t rect_dsc; - lv_draw_rect_dsc_init(&rect_dsc); - rect_dsc.base.layer = layer; - rect_dsc.radius = LV_RADIUS_CIRCLE; - rect_dsc.bg_opa = LV_OPA_70; - rect_dsc.bg_color = lv_color_make(0, 255, 255); - rect_dsc.border_width = 0; - rect_dsc.outline_width = 0; - rect_dsc.shadow_width = 0; - - for (const auto &circle : circles) { - if (!circle.visible) { - continue; - } - lv_area_t coords = { - .x1 = static_cast(obj_coords.x1 + circle.x - circle.radius), - .y1 = static_cast(obj_coords.y1 + circle.y - circle.radius), - .x2 = static_cast(obj_coords.x1 + circle.x + circle.radius - 1), - .y2 = static_cast(obj_coords.y1 + circle.y + circle.radius - 1), - }; - lv_draw_rect(layer, &rect_dsc, &coords); - } -} - -static void invalidate_circle_area(const Circle &circle) { - if (!circle_layer || circle.radius <= 0) { - return; - } - - lv_area_t obj_coords; - lv_obj_get_coords(circle_layer, &obj_coords); - lv_area_t coords = { - .x1 = static_cast(obj_coords.x1 + circle.x - circle.radius), - .y1 = static_cast(obj_coords.y1 + circle.y - circle.radius), - .x2 = static_cast(obj_coords.x1 + circle.x + circle.radius - 1), - .y2 = static_cast(obj_coords.y1 + circle.y + circle.radius - 1), - }; - lv_obj_invalidate_area(circle_layer, &coords); -} - -static void draw_circle(int x0, int y0, int radius) { - if (!circle_layer) { - return; - } - lv_obj_move_foreground(circle_layer); - Circle previous_circle = circles[next_circle_index]; - circles[next_circle_index] = {.x = x0, .y = y0, .radius = radius, .visible = true}; - next_circle_index = (next_circle_index + 1) % circles.size(); - if (visible_circle_count < circles.size()) { - visible_circle_count++; - } - if (previous_circle.visible) { - invalidate_circle_area(previous_circle); - } - invalidate_circle_area(circles[(next_circle_index + circles.size() - 1) % circles.size()]); -} - -static void clear_circles() { - for (auto &circle : circles) { - if (circle.visible) { - invalidate_circle_area(circle); - } - circle.visible = false; - } - next_circle_index = 0; - visible_circle_count = 0; -} - static bool load_audio(size_t &out_size, size_t &out_sample_rate) { // if the audio_bytes vector is already populated, return the size if (audio_bytes.size() > 0) { @@ -357,7 +177,7 @@ static bool load_audio(size_t &out_size, size_t &out_sample_rate) { } static void play_click(espp::TDeck &tdeck) { - // use the box.play_audio() function to play a sound, breaking it into + // use the tdeck.play_audio() function to play a sound, breaking it into // audio_buffer_size chunks auto audio_buffer_size = tdeck.audio_buffer_size(); size_t offset = 0; diff --git a/components/t-dongle-s3/example/main/gui.cpp b/components/t-dongle-s3/example/main/gui.cpp new file mode 100644 index 000000000..479f05044 --- /dev/null +++ b/components/t-dongle-s3/example/main/gui.cpp @@ -0,0 +1,170 @@ +#include "gui.hpp" + +void Gui::init_ui() { + std::lock_guard lock(mutex_); + init_background(); + init_label(); + init_circle_layer(); + // disable scrolling on the screen (so that it doesn't behave weirdly when + // rotated) + lv_obj_set_scrollbar_mode(lv_screen_active(), LV_SCROLLBAR_MODE_OFF); + lv_obj_clear_flag(lv_screen_active(), LV_OBJ_FLAG_SCROLLABLE); +} + +void Gui::deinit_ui() { + std::lock_guard lock(mutex_); + lv_obj_clean(lv_screen_active()); +} + +void Gui::init_background() { + auto &tdongle = espp::TDongleS3::get(); + background_ = lv_obj_create(lv_screen_active()); + lv_obj_set_size(background_, tdongle.rotated_display_width(), tdongle.rotated_display_height()); + lv_obj_set_style_bg_color(background_, lv_color_make(0, 0, 0), 0); +} + +void Gui::init_label() { + label_ = lv_label_create(lv_screen_active()); + lv_label_set_text(label_, ""); + lv_obj_align(label_, LV_ALIGN_CENTER, 0, 0); + lv_obj_set_style_text_align(label_, LV_TEXT_ALIGN_CENTER, 0); +} + +void Gui::init_circle_layer() { + auto &tdongle = espp::TDongleS3::get(); + circle_layer_ = lv_obj_create(lv_screen_active()); + lv_obj_remove_style_all(circle_layer_); + lv_obj_set_size(circle_layer_, tdongle.rotated_display_width(), tdongle.rotated_display_height()); + lv_obj_align(circle_layer_, LV_ALIGN_CENTER, 0, 0); + lv_obj_clear_flag(circle_layer_, LV_OBJ_FLAG_CLICKABLE); + lv_obj_clear_flag(circle_layer_, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_bg_opa(circle_layer_, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(circle_layer_, 0, 0); + lv_obj_set_style_outline_width(circle_layer_, 0, 0); + lv_obj_set_style_shadow_width(circle_layer_, 0, 0); + lv_obj_add_event_cb(circle_layer_, draw_circle_layer, LV_EVENT_DRAW_MAIN, this); + lv_obj_move_foreground(circle_layer_); +} + +bool Gui::update(std::mutex &m, std::condition_variable &cv) { + { + std::lock_guard lock(mutex_); + lv_task_handler(); + } + std::unique_lock lock(m); + cv.wait_for(lock, std::chrono::milliseconds(16)); + return false; // don't stop the task +} + +void Gui::set_label_text(std::string_view text) { + std::lock_guard lock(mutex_); + lv_label_set_text(label_, std::string(text).c_str()); +} + +void Gui::next_rotation() { + std::lock_guard lock(mutex_); + auto &tdongle = espp::TDongleS3::get(); + clear_circles_impl(); + auto rotation = lv_display_get_rotation(lv_display_get_default()); + rotation = static_cast((static_cast(rotation) + 1) % 4); + logger_.info("Setting rotation to {}", static_cast(rotation)); + lv_display_set_rotation(lv_display_get_default(), rotation); + // update the size of the screen-filling objects + lv_obj_set_size(background_, tdongle.rotated_display_width(), tdongle.rotated_display_height()); + lv_obj_set_size(circle_layer_, tdongle.rotated_display_width(), tdongle.rotated_display_height()); + lv_obj_align(circle_layer_, LV_ALIGN_CENTER, 0, 0); + lv_obj_invalidate(circle_layer_); +} + +void Gui::draw_circle(int x, int y, int radius) { + std::lock_guard lock(mutex_); + lv_obj_move_foreground(circle_layer_); + Circle previous_circle = circles_[next_circle_index_]; + circles_[next_circle_index_] = {.x = x, .y = y, .radius = radius, .visible = true}; + next_circle_index_ = (next_circle_index_ + 1) % circles_.size(); + if (visible_circle_count_ < circles_.size()) { + visible_circle_count_++; + } + if (previous_circle.visible) { + invalidate_circle_area(previous_circle); + } + invalidate_circle_area(circles_[(next_circle_index_ + circles_.size() - 1) % circles_.size()]); +} + +void Gui::clear_circles() { + std::lock_guard lock(mutex_); + clear_circles_impl(); +} + +size_t Gui::circle_count() { + std::lock_guard lock(mutex_); + return visible_circle_count_; +} + +void Gui::clear_circles_impl() { + for (auto &circle : circles_) { + if (circle.visible) { + invalidate_circle_area(circle); + } + circle.visible = false; + } + next_circle_index_ = 0; + visible_circle_count_ = 0; +} + +void Gui::invalidate_circle_area(const Circle &circle) { + if (!circle_layer_ || circle.radius <= 0) { + return; + } + lv_area_t obj_coords; + lv_obj_get_coords(circle_layer_, &obj_coords); + lv_area_t coords = { + .x1 = static_cast(obj_coords.x1 + circle.x - circle.radius), + .y1 = static_cast(obj_coords.y1 + circle.y - circle.radius), + .x2 = static_cast(obj_coords.x1 + circle.x + circle.radius - 1), + .y2 = static_cast(obj_coords.y1 + circle.y + circle.radius - 1), + }; + lv_obj_invalidate_area(circle_layer_, &coords); +} + +void Gui::draw_circle_layer(lv_event_t *e) { + const auto *gui = static_cast(lv_event_get_user_data(e)); + if (!gui) { + return; + } + gui->draw_circles(e); +} + +void Gui::draw_circles(lv_event_t *e) const { + if (visible_circle_count_ == 0) { + return; + } + + auto *obj = static_cast(lv_event_get_current_target(e)); + auto *layer = lv_event_get_layer(e); + lv_area_t obj_coords; + lv_obj_get_coords(obj, &obj_coords); + + lv_draw_rect_dsc_t rect_dsc; + lv_draw_rect_dsc_init(&rect_dsc); + rect_dsc.base.layer = layer; + rect_dsc.radius = LV_RADIUS_CIRCLE; + rect_dsc.bg_opa = LV_OPA_70; + rect_dsc.bg_color = lv_color_make(0, 255, 255); + rect_dsc.border_width = 0; + rect_dsc.outline_width = 0; + rect_dsc.shadow_width = 0; + + for (const auto &circle : circles_) { + if (!circle.visible) { + continue; + } + lv_area_t coords = { + .x1 = static_cast(obj_coords.x1 + circle.x - circle.radius), + .y1 = static_cast(obj_coords.y1 + circle.y - circle.radius), + .x2 = static_cast(obj_coords.x1 + circle.x + circle.radius - 1), + .y2 = static_cast(obj_coords.y1 + circle.y + circle.radius - 1), + }; + lv_draw_rect(layer, &rect_dsc, &coords); + } +} diff --git a/components/t-dongle-s3/example/main/gui.hpp b/components/t-dongle-s3/example/main/gui.hpp new file mode 100644 index 000000000..6c8e84908 --- /dev/null +++ b/components/t-dongle-s3/example/main/gui.hpp @@ -0,0 +1,118 @@ +#pragma once + +#include +#include +#include +#include + +#include "t-dongle-s3.hpp" + +#include "logger.hpp" +#include "task.hpp" + +/// The Gui class encapsulates all of the LVGL UI for this example. It follows +/// the recommended pattern for building UIs with espp + LVGL in C++: +/// +/// * All LVGL objects are created in init_ui(), which is broken into small +/// member functions - one per logical piece of the UI. +/// * The class owns the task which calls lv_task_handler(), and a recursive +/// mutex which guards every LVGL call. Public methods lock that mutex, so +/// other tasks (button callbacks, the main loop, etc.) can safely call +/// them. +/// +/// For this example the Gui shows: +/// * A label in the center of the screen +/// * A custom-drawn layer of circles which the main loop animates in a ring +/// +/// The T-Dongle-S3 has no touch screen, so there are no on-screen buttons; +/// the hardware button cycles the display rotation via next_rotation(). +class Gui { +public: + /// The maximum number of circles which can be visible at once + static constexpr size_t MAX_CIRCLES = 10; + + /// Configuration for the Gui + struct Config { + espp::Logger::Verbosity log_level{espp::Logger::Verbosity::WARN}; ///< Log verbosity + }; + + /// Construct the Gui: builds the UI and starts the LVGL update task. + /// @param config The configuration for the Gui + explicit Gui(const Config &config) + : logger_({.tag = "Gui", .level = config.log_level}) { + init_ui(); + update_task_.start(); + } + + ~Gui() { + update_task_.stop(); + deinit_ui(); + } + + /// Set the text of the main label. Thread-safe. + /// @param text The text to display + void set_label_text(std::string_view text); + + /// Draw a circle at the given screen coordinates, replacing the oldest + /// circle if the maximum number are already visible. Thread-safe. + /// @param x The x coordinate (screen space) + /// @param y The y coordinate (screen space) + /// @param radius The radius of the circle + void draw_circle(int x, int y, int radius); + + /// Clear all circles from the screen. Thread-safe. + void clear_circles(); + + /// Get the number of circles currently visible. Thread-safe. + /// @return The number of visible circles + size_t circle_count(); + + /// Rotate the display to the next of 0/90/180/270 degrees, resizing / + /// re-aligning the UI to match. Thread-safe. + void next_rotation(); + +protected: + struct Circle { + int x{0}; + int y{0}; + int radius{0}; + bool visible{false}; + }; + + void init_ui(); + void deinit_ui(); + + // the individual pieces of the UI, called from init_ui() + void init_background(); + void init_label(); + void init_circle_layer(); + + // the LVGL update task: calls lv_task_handler() under the mutex + bool update(std::mutex &m, std::condition_variable &cv); + + // custom drawing of the circle layer + static void draw_circle_layer(lv_event_t *e); + void draw_circles(lv_event_t *e) const; + void invalidate_circle_area(const Circle &circle); + + // unlocked implementations, called with the mutex held + void clear_circles_impl(); + + // LVGL objects + lv_obj_t *background_{nullptr}; + lv_obj_t *label_{nullptr}; + lv_obj_t *circle_layer_{nullptr}; + + std::array circles_; + size_t next_circle_index_{0}; + size_t visible_circle_count_{0}; + + espp::Task update_task_{{.callback = [this](auto &m, auto &cv) { return update(m, cv); }, + .task_config = { + .name = "gui", + .stack_size_bytes = 6 * 1024, + .core_id = 1, + }}}; + espp::Logger logger_; + std::recursive_mutex mutex_; +}; diff --git a/components/t-dongle-s3/example/main/t_dongle_s3_example.cpp b/components/t-dongle-s3/example/main/t_dongle_s3_example.cpp index c251db8d5..6940633f1 100644 --- a/components/t-dongle-s3/example/main/t_dongle_s3_example.cpp +++ b/components/t-dongle-s3/example/main/t_dongle_s3_example.cpp @@ -1,29 +1,13 @@ -#include #include +#include +#include #include #include "t-dongle-s3.hpp" -using namespace std::chrono_literals; - -static constexpr size_t MAX_CIRCLES = 10; -struct Circle { - int x{0}; - int y{0}; - int radius{0}; - bool visible{false}; -}; -static std::array circles; -static size_t next_circle_index = 0; -static size_t visible_circle_count = 0; -static lv_obj_t *circle_layer = nullptr; +#include "gui.hpp" -static std::mutex lvgl_mutex; -static bool initialize_circle_layer(int width, int height); -static void draw_circle_layer(lv_event_t *event); -static void invalidate_circle_area(const Circle &circle); -static void draw_circle(int x0, int y0, int radius); -static void clear_circles(); +using namespace std::chrono_literals; extern "C" void app_main(void) { espp::Logger logger({.tag = "T-Dongle-S3 Example", .level = espp::Logger::Verbosity::INFO}); @@ -58,32 +42,18 @@ extern "C" void app_main(void) { logger.warn("Failed to initialize SD card, continuing without it."); } - // initialize the button, which we'll use to cycle the rotation of the display + // create the GUI: builds the UI (label, circle layer) and starts the task + // which updates LVGL. All of its public methods are thread-safe, so the + // button callback and the main loop below can call them directly. + static Gui gui({.log_level = espp::Logger::Verbosity::INFO}); + gui.set_label_text("Drawing circles\nto the screen."); + + // initialize the button, which we'll use to cycle the rotation of the + // display logger.info("Initializing the button"); - lv_obj_t *bg = nullptr; - lv_obj_t *label = nullptr; - static auto update_layout = [&]() { - int width = tdongle.rotated_display_width(); - int height = tdongle.rotated_display_height(); - lv_obj_set_size(bg, width, height); - lv_obj_align(label, LV_ALIGN_CENTER, 0, 0); - if (circle_layer) { - lv_obj_set_size(circle_layer, width, height); - lv_obj_align(circle_layer, LV_ALIGN_CENTER, 0, 0); - lv_obj_move_foreground(circle_layer); - lv_obj_invalidate(circle_layer); - } - }; auto on_button_pressed = [&](const auto &event) { if (event.active) { - // lock the display mutex - std::lock_guard lock(lvgl_mutex); - static auto rotation = LV_DISPLAY_ROTATION_0; - rotation = static_cast((static_cast(rotation) + 1) % 4); - fmt::print("Setting rotation to {}\n", (int)rotation); - lv_display_t *disp = lv_display_get_default(); - lv_disp_set_rotation(disp, rotation); - update_layout(); + gui.next_rotation(); } }; tdongle.initialize_button(on_button_pressed); @@ -93,41 +63,6 @@ extern "C" void app_main(void) { float brightness = 5.0f; // 5% brightness tdongle.led(hsv, brightness); - // set the background color to black - bg = lv_obj_create(lv_screen_active()); - lv_obj_set_size(bg, tdongle.rotated_display_width(), tdongle.rotated_display_height()); - lv_obj_set_style_bg_color(bg, lv_color_make(0, 0, 0), 0); - if (!initialize_circle_layer(tdongle.rotated_display_width(), tdongle.rotated_display_height())) { - logger.error("Failed to initialize circle layer!"); - return; - } - - // add text in the center of the screen - label = lv_label_create(lv_screen_active()); - lv_label_set_text(label, "Drawing circles\nto the screen."); - lv_obj_align(label, LV_ALIGN_CENTER, 0, 0); - lv_obj_set_style_text_align(label, LV_TEXT_ALIGN_CENTER, 0); - update_layout(); - - lv_obj_move_foreground(circle_layer); - - // start a simple thread to do the lv_task_handler every 16ms - espp::Task lv_task({.callback = [](std::mutex &m, std::condition_variable &cv) -> bool { - { - // lock the display mutex - std::lock_guard lock(lvgl_mutex); - lv_task_handler(); - } - std::unique_lock lock(m); - cv.wait_for(lock, 16ms); - return false; - }, - .task_config = { - .name = "lv_task", - .core_id = 1, - }}); - lv_task.start(); - // set the display brightness to be 75% tdongle.brightness(75.0f); @@ -151,22 +86,18 @@ extern "C" void app_main(void) { while (true) { auto start = esp_timer_get_time(); - // if there are 10 circles on the screen, clear them - if (visible_circle_count >= MAX_CIRCLES) { - // lock the lvgl mutex - std::lock_guard lock(lvgl_mutex); - clear_circles(); + // if the maximum number of circles are on the screen, clear them + if (gui.circle_count() >= Gui::MAX_CIRCLES) { + gui.clear_circles(); } else { // draw a circle of circles on the screen (just draw the next circle) int middle_x = tdongle.rotated_display_width() / 2; int middle_y = tdongle.rotated_display_height() / 2; static constexpr int radius = 30; - float angle = visible_circle_count * 2.0f * M_PI / MAX_CIRCLES; - int x = middle_x + radius * cos(angle); - int y = middle_y + radius * sin(angle); - // lock the lvgl mutex - std::lock_guard lock(lvgl_mutex); - draw_circle(x, y, 5); + float angle = gui.circle_count() * 2.0f * std::numbers::pi_v / Gui::MAX_CIRCLES; + int x = middle_x + radius * std::cos(angle); + int y = middle_y + radius * std::sin(angle); + gui.draw_circle(x, y, 5); } auto end = esp_timer_get_time(); auto elapsed = end - start; @@ -174,101 +105,3 @@ extern "C" void app_main(void) { } //! [t-dongle-s3 example] } - -static bool initialize_circle_layer(int width, int height) { - if (circle_layer) { - return true; - } - circle_layer = lv_obj_create(lv_screen_active()); - if (!circle_layer) { - return false; - } - lv_obj_remove_style_all(circle_layer); - lv_obj_set_size(circle_layer, width, height); - lv_obj_align(circle_layer, LV_ALIGN_CENTER, 0, 0); - lv_obj_clear_flag(circle_layer, LV_OBJ_FLAG_CLICKABLE); - lv_obj_clear_flag(circle_layer, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_set_style_bg_opa(circle_layer, LV_OPA_TRANSP, 0); - lv_obj_set_style_border_width(circle_layer, 0, 0); - lv_obj_set_style_outline_width(circle_layer, 0, 0); - lv_obj_set_style_shadow_width(circle_layer, 0, 0); - lv_obj_add_event_cb(circle_layer, draw_circle_layer, LV_EVENT_DRAW_MAIN, nullptr); - return true; -} - -static void draw_circle_layer(lv_event_t *event) { - if (visible_circle_count == 0) { - return; - } - auto *obj = static_cast(lv_event_get_current_target(event)); - auto *layer = lv_event_get_layer(event); - lv_area_t obj_coords; - lv_obj_get_coords(obj, &obj_coords); - - lv_draw_rect_dsc_t rect_dsc; - lv_draw_rect_dsc_init(&rect_dsc); - rect_dsc.base.layer = layer; - rect_dsc.radius = LV_RADIUS_CIRCLE; - rect_dsc.bg_opa = LV_OPA_70; - rect_dsc.bg_color = lv_color_make(0, 255, 255); - rect_dsc.border_width = 0; - rect_dsc.outline_width = 0; - rect_dsc.shadow_width = 0; - - for (const auto &circle : circles) { - if (!circle.visible) { - continue; - } - lv_area_t coords = { - .x1 = static_cast(obj_coords.x1 + circle.x - circle.radius), - .y1 = static_cast(obj_coords.y1 + circle.y - circle.radius), - .x2 = static_cast(obj_coords.x1 + circle.x + circle.radius - 1), - .y2 = static_cast(obj_coords.y1 + circle.y + circle.radius - 1), - }; - lv_draw_rect(layer, &rect_dsc, &coords); - } -} - -static void invalidate_circle_area(const Circle &circle) { - if (!circle_layer || circle.radius <= 0) { - return; - } - - lv_area_t obj_coords; - lv_obj_get_coords(circle_layer, &obj_coords); - lv_area_t coords = { - .x1 = static_cast(obj_coords.x1 + circle.x - circle.radius), - .y1 = static_cast(obj_coords.y1 + circle.y - circle.radius), - .x2 = static_cast(obj_coords.x1 + circle.x + circle.radius - 1), - .y2 = static_cast(obj_coords.y1 + circle.y + circle.radius - 1), - }; - lv_obj_invalidate_area(circle_layer, &coords); -} - -static void draw_circle(int x0, int y0, int radius) { - if (!circle_layer) { - return; - } - lv_obj_move_foreground(circle_layer); - Circle previous_circle = circles[next_circle_index]; - circles[next_circle_index] = {.x = x0, .y = y0, .radius = radius, .visible = true}; - next_circle_index = (next_circle_index + 1) % circles.size(); - if (visible_circle_count < circles.size()) { - visible_circle_count++; - } - if (previous_circle.visible) { - invalidate_circle_area(previous_circle); - } - invalidate_circle_area(circles[(next_circle_index + circles.size() - 1) % circles.size()]); -} - -static void clear_circles() { - for (auto &circle : circles) { - if (circle.visible) { - invalidate_circle_area(circle); - } - circle.visible = false; - } - next_circle_index = 0; - visible_circle_count = 0; -} diff --git a/components/wrover-kit/example/main/gui.cpp b/components/wrover-kit/example/main/gui.cpp new file mode 100644 index 000000000..4ba99b218 --- /dev/null +++ b/components/wrover-kit/example/main/gui.cpp @@ -0,0 +1,171 @@ +#include "gui.hpp" + +void Gui::init_ui() { + std::lock_guard lock(mutex_); + init_background(); + init_label(); + init_circle_layer(); + // disable scrolling on the screen (so that it doesn't behave weirdly when + // rotated) + lv_obj_set_scrollbar_mode(lv_screen_active(), LV_SCROLLBAR_MODE_OFF); + lv_obj_clear_flag(lv_screen_active(), LV_OBJ_FLAG_SCROLLABLE); +} + +void Gui::deinit_ui() { + std::lock_guard lock(mutex_); + lv_obj_clean(lv_screen_active()); +} + +void Gui::init_background() { + auto &wrover = espp::WroverKit::get(); + background_ = lv_obj_create(lv_screen_active()); + lv_obj_set_size(background_, wrover.rotated_display_width(), wrover.rotated_display_height()); + lv_obj_set_style_bg_color(background_, lv_color_make(0, 0, 0), 0); +} + +void Gui::init_label() { + label_ = lv_label_create(lv_screen_active()); + lv_label_set_text(label_, ""); + lv_obj_align(label_, LV_ALIGN_CENTER, 0, 0); + lv_obj_set_style_text_align(label_, LV_TEXT_ALIGN_CENTER, 0); +} + +void Gui::init_circle_layer() { + auto &wrover = espp::WroverKit::get(); + circle_layer_ = lv_obj_create(lv_screen_active()); + lv_obj_remove_style_all(circle_layer_); + lv_obj_set_size(circle_layer_, wrover.rotated_display_width(), wrover.rotated_display_height()); + lv_obj_align(circle_layer_, LV_ALIGN_CENTER, 0, 0); + lv_obj_clear_flag(circle_layer_, LV_OBJ_FLAG_CLICKABLE); + lv_obj_clear_flag(circle_layer_, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_bg_opa(circle_layer_, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(circle_layer_, 0, 0); + lv_obj_set_style_outline_width(circle_layer_, 0, 0); + lv_obj_set_style_shadow_width(circle_layer_, 0, 0); + lv_obj_add_event_cb(circle_layer_, draw_circle_layer, LV_EVENT_DRAW_MAIN, this); + lv_obj_move_foreground(circle_layer_); +} + +bool Gui::update(std::mutex &m, std::condition_variable &cv) { + { + std::lock_guard lock(mutex_); + lv_task_handler(); + } + std::unique_lock lock(m); + cv.wait_for(lock, std::chrono::milliseconds(16)); + return false; // don't stop the task +} + +void Gui::set_label_text(std::string_view text) { + std::lock_guard lock(mutex_); + lv_label_set_text(label_, std::string(text).c_str()); +} + +void Gui::next_rotation() { + std::lock_guard lock(mutex_); + auto &wrover = espp::WroverKit::get(); + auto rotation = lv_display_get_rotation(lv_display_get_default()); + rotation = static_cast((static_cast(rotation) + 1) % 4); + logger_.info("Setting rotation to {}", static_cast(rotation)); + lv_display_set_rotation(lv_display_get_default(), rotation); + // update the size of the screen-filling objects + lv_obj_set_size(background_, wrover.rotated_display_width(), wrover.rotated_display_height()); + lv_obj_align(label_, LV_ALIGN_CENTER, 0, 0); + lv_obj_set_size(circle_layer_, wrover.rotated_display_width(), wrover.rotated_display_height()); + lv_obj_align(circle_layer_, LV_ALIGN_CENTER, 0, 0); + lv_obj_move_foreground(circle_layer_); + lv_obj_invalidate(circle_layer_); +} + +void Gui::draw_circle(int x, int y, int radius) { + std::lock_guard lock(mutex_); + lv_obj_move_foreground(circle_layer_); + Circle previous_circle = circles_[next_circle_index_]; + circles_[next_circle_index_] = {.x = x, .y = y, .radius = radius, .visible = true}; + next_circle_index_ = (next_circle_index_ + 1) % circles_.size(); + if (visible_circle_count_ < circles_.size()) { + visible_circle_count_++; + } + if (previous_circle.visible) { + invalidate_circle_area(previous_circle); + } + invalidate_circle_area(circles_[(next_circle_index_ + circles_.size() - 1) % circles_.size()]); +} + +void Gui::clear_circles() { + std::lock_guard lock(mutex_); + clear_circles_impl(); +} + +size_t Gui::circle_count() { + std::lock_guard lock(mutex_); + return visible_circle_count_; +} + +void Gui::clear_circles_impl() { + for (auto &circle : circles_) { + if (circle.visible) { + invalidate_circle_area(circle); + } + circle.visible = false; + } + next_circle_index_ = 0; + visible_circle_count_ = 0; +} + +void Gui::invalidate_circle_area(const Circle &circle) { + if (!circle_layer_ || circle.radius <= 0) { + return; + } + lv_area_t obj_coords; + lv_obj_get_coords(circle_layer_, &obj_coords); + lv_area_t coords = { + .x1 = static_cast(obj_coords.x1 + circle.x - circle.radius), + .y1 = static_cast(obj_coords.y1 + circle.y - circle.radius), + .x2 = static_cast(obj_coords.x1 + circle.x + circle.radius - 1), + .y2 = static_cast(obj_coords.y1 + circle.y + circle.radius - 1), + }; + lv_obj_invalidate_area(circle_layer_, &coords); +} + +void Gui::draw_circle_layer(lv_event_t *e) { + const auto *gui = static_cast(lv_event_get_user_data(e)); + if (!gui) { + return; + } + gui->draw_circles(e); +} + +void Gui::draw_circles(lv_event_t *e) const { + if (visible_circle_count_ == 0) { + return; + } + + auto *obj = static_cast(lv_event_get_current_target(e)); + auto *layer = lv_event_get_layer(e); + lv_area_t obj_coords; + lv_obj_get_coords(obj, &obj_coords); + + lv_draw_rect_dsc_t rect_dsc; + lv_draw_rect_dsc_init(&rect_dsc); + rect_dsc.base.layer = layer; + rect_dsc.radius = LV_RADIUS_CIRCLE; + rect_dsc.bg_opa = LV_OPA_70; + rect_dsc.bg_color = lv_color_make(0, 255, 255); + rect_dsc.border_width = 0; + rect_dsc.outline_width = 0; + rect_dsc.shadow_width = 0; + + for (const auto &circle : circles_) { + if (!circle.visible) { + continue; + } + lv_area_t coords = { + .x1 = static_cast(obj_coords.x1 + circle.x - circle.radius), + .y1 = static_cast(obj_coords.y1 + circle.y - circle.radius), + .x2 = static_cast(obj_coords.x1 + circle.x + circle.radius - 1), + .y2 = static_cast(obj_coords.y1 + circle.y + circle.radius - 1), + }; + lv_draw_rect(layer, &rect_dsc, &coords); + } +} diff --git a/components/wrover-kit/example/main/gui.hpp b/components/wrover-kit/example/main/gui.hpp new file mode 100644 index 000000000..094f19e80 --- /dev/null +++ b/components/wrover-kit/example/main/gui.hpp @@ -0,0 +1,115 @@ +#pragma once + +#include +#include +#include +#include + +#include "wrover-kit.hpp" + +#include "logger.hpp" +#include "task.hpp" + +/// The Gui class encapsulates all of the LVGL UI for this example. It follows +/// the recommended pattern for building UIs with espp + LVGL in C++: +/// +/// * All LVGL objects are created in init_ui(), which is broken into small +/// member functions - one per logical piece of the UI. +/// * The class owns the task which calls lv_task_handler(), and a recursive +/// mutex which guards every LVGL call. Public methods lock that mutex, so +/// other tasks (button callbacks, the drawing loop, etc.) can safely call +/// them. +/// +/// For this example the Gui shows: +/// * A label in the center of the screen +/// * A custom-drawn layer of circles which the main loop draws automatically +/// +/// The Wrover-Kit has no touchscreen, so there are no on-screen buttons; +/// instead the boot button cycles the display rotation via next_rotation(). +class Gui { +public: + /// The maximum number of circles which can be visible at once; the main + /// loop clears the circles once this many have been drawn. + static constexpr size_t MAX_CIRCLES = 10; + + /// Configuration for the Gui + struct Config { + espp::Logger::Verbosity log_level{espp::Logger::Verbosity::WARN}; ///< Log verbosity + }; + + /// Construct the Gui: builds the UI and starts the LVGL update task. + /// @param config The configuration for the Gui + explicit Gui(const Config &config) + : logger_({.tag = "Gui", .level = config.log_level}) { + init_ui(); + update_task_.start(); + } + + ~Gui() { + update_task_.stop(); + deinit_ui(); + } + + /// Set the text of the main label. Thread-safe. + /// @param text The text to display + void set_label_text(std::string_view text); + + /// Draw a circle at the given screen coordinates, replacing the oldest + /// circle if the maximum number are already visible. Thread-safe. + /// @param x The x coordinate (screen space) + /// @param y The y coordinate (screen space) + /// @param radius The radius of the circle + void draw_circle(int x, int y, int radius); + + /// Clear all circles from the screen. Thread-safe. + void clear_circles(); + + /// Get the number of circles currently visible. Thread-safe. + /// @return The number of visible circles + size_t circle_count(); + + /// Rotate the display to the next of 0/90/180/270 degrees, resizing / + /// re-aligning the UI to match. Thread-safe. + void next_rotation(); + +protected: + struct Circle { + int x{0}; + int y{0}; + int radius{0}; + bool visible{false}; + }; + + void init_ui(); + void deinit_ui(); + + // the individual pieces of the UI, called from init_ui() + void init_background(); + void init_label(); + void init_circle_layer(); + + // the LVGL update task: calls lv_task_handler() under the mutex + bool update(std::mutex &m, std::condition_variable &cv); + + // custom drawing of the circle layer + static void draw_circle_layer(lv_event_t *e); + void draw_circles(lv_event_t *e) const; + void invalidate_circle_area(const Circle &circle); + + // unlocked implementations, called with the mutex held + void clear_circles_impl(); + + // LVGL objects + lv_obj_t *background_{nullptr}; + lv_obj_t *label_{nullptr}; + lv_obj_t *circle_layer_{nullptr}; + + std::array circles_; + size_t next_circle_index_{0}; + size_t visible_circle_count_{0}; + + espp::Task update_task_{{.callback = [this](auto &m, auto &cv) { return update(m, cv); }, + .task_config = {.name = "gui", .stack_size_bytes = 6 * 1024}}}; + espp::Logger logger_; + std::recursive_mutex mutex_; +}; diff --git a/components/wrover-kit/example/main/wrover_kit_example.cpp b/components/wrover-kit/example/main/wrover_kit_example.cpp index 58e01bd15..ae58e63a8 100644 --- a/components/wrover-kit/example/main/wrover_kit_example.cpp +++ b/components/wrover-kit/example/main/wrover_kit_example.cpp @@ -1,30 +1,14 @@ -#include #include +#include +#include #include #include "button.hpp" #include "wrover-kit.hpp" -using namespace std::chrono_literals; - -static constexpr size_t MAX_CIRCLES = 10; -struct Circle { - int x{0}; - int y{0}; - int radius{0}; - bool visible{false}; -}; -static std::array circles; -static size_t next_circle_index = 0; -static size_t visible_circle_count = 0; -static lv_obj_t *circle_layer = nullptr; +#include "gui.hpp" -static std::mutex lvgl_mutex; -static bool initialize_circle_layer(int width, int height); -static void draw_circle_layer(lv_event_t *event); -static void invalidate_circle_area(const Circle &circle); -static void draw_circle(int x0, int y0, int radius); -static void clear_circles(); +using namespace std::chrono_literals; extern "C" void app_main(void) { espp::Logger logger({.tag = "Wrover-Kit Example", .level = espp::Logger::Verbosity::INFO}); @@ -47,21 +31,11 @@ extern "C" void app_main(void) { return; } - logger.info("Adding LVGL objects to the screen."); - lv_obj_t *bg = nullptr; - lv_obj_t *label = nullptr; - static auto update_layout = [&]() { - int width = wrover.rotated_display_width(); - int height = wrover.rotated_display_height(); - lv_obj_set_size(bg, width, height); - lv_obj_align(label, LV_ALIGN_CENTER, 0, 0); - if (circle_layer) { - lv_obj_set_size(circle_layer, width, height); - lv_obj_align(circle_layer, LV_ALIGN_CENTER, 0, 0); - lv_obj_move_foreground(circle_layer); - lv_obj_invalidate(circle_layer); - } - }; + // create the GUI: builds the UI (label, circle layer) and starts the task + // which updates LVGL. All of its public methods are thread-safe, so the + // button callback and drawing loop below can call them directly. + static Gui gui({.log_level = espp::Logger::Verbosity::INFO}); + gui.set_label_text("Drawing circles to the screen."); // initialize the button, which we'll use to cycle the rotation of the display espp::Button button(espp::Button::Config{ @@ -71,15 +45,7 @@ extern "C" void app_main(void) { .callback = [](const auto &event) { if (event.active) { - // lock the display mutex - std::lock_guard lock(lvgl_mutex); - static auto rotation = LV_DISPLAY_ROTATION_0; - rotation = static_cast( - (static_cast(rotation) + 1) % 4); - fmt::print("Setting rotation to {}\n", (int)rotation); - lv_display_t *disp = lv_display_get_default(); - lv_disp_set_rotation(disp, rotation); - update_layout(); + gui.next_rotation(); } }, .active_level = espp::Interrupt::ActiveLevel::LOW, @@ -88,58 +54,23 @@ extern "C" void app_main(void) { .pulldown_enabled = false}, }); - // set the background color to black - bg = lv_obj_create(lv_screen_active()); - lv_obj_set_size(bg, wrover.rotated_display_width(), wrover.rotated_display_height()); - lv_obj_set_style_bg_color(bg, lv_color_make(0, 0, 0), 0); - if (!initialize_circle_layer(wrover.rotated_display_width(), wrover.rotated_display_height())) { - logger.error("Failed to initialize circle layer!"); - return; - } - - // add text in the center of the screen - label = lv_label_create(lv_screen_active()); - lv_label_set_text(label, "Drawing circles to the screen."); - lv_obj_align(label, LV_ALIGN_CENTER, 0, 0); - lv_obj_set_style_text_align(label, LV_TEXT_ALIGN_CENTER, 0); - update_layout(); - - lv_obj_move_foreground(circle_layer); - - // start a simple thread to do the lv_task_handler every 16ms - espp::Task lv_task({.callback = [](std::mutex &m, std::condition_variable &cv) -> bool { - { - std::lock_guard lock(lvgl_mutex); - lv_task_handler(); - } - std::unique_lock lock(m); - cv.wait_for(lock, 16ms); - return false; - }, - .task_config = { - .name = "lv_task", - }}); - lv_task.start(); - // set the display brightness to be 75% wrover.brightness(75.0f); while (true) { auto start = esp_timer_get_time(); // if there are 10 circles on the screen, clear them - if (visible_circle_count >= MAX_CIRCLES) { - std::lock_guard lock(lvgl_mutex); - clear_circles(); + if (gui.circle_count() >= Gui::MAX_CIRCLES) { + gui.clear_circles(); } else { // draw a circle of circles on the screen (just draw the next circle) int middle_x = wrover.rotated_display_width() / 2; int middle_y = wrover.rotated_display_height() / 2; static constexpr int radius = 50; - float angle = visible_circle_count * 2.0f * M_PI / MAX_CIRCLES; - int x = middle_x + radius * cos(angle); - int y = middle_y + radius * sin(angle); - std::lock_guard lock(lvgl_mutex); - draw_circle(x, y, 10); + float angle = gui.circle_count() * 2.0f * std::numbers::pi_v / Gui::MAX_CIRCLES; + int x = middle_x + radius * std::cos(angle); + int y = middle_y + radius * std::sin(angle); + gui.draw_circle(x, y, 10); } auto end = esp_timer_get_time(); auto elapsed = end - start; @@ -147,101 +78,3 @@ extern "C" void app_main(void) { } //! [wrover-kit example] } - -static bool initialize_circle_layer(int width, int height) { - if (circle_layer) { - return true; - } - circle_layer = lv_obj_create(lv_screen_active()); - if (!circle_layer) { - return false; - } - lv_obj_remove_style_all(circle_layer); - lv_obj_set_size(circle_layer, width, height); - lv_obj_align(circle_layer, LV_ALIGN_CENTER, 0, 0); - lv_obj_clear_flag(circle_layer, LV_OBJ_FLAG_CLICKABLE); - lv_obj_clear_flag(circle_layer, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_set_style_bg_opa(circle_layer, LV_OPA_TRANSP, 0); - lv_obj_set_style_border_width(circle_layer, 0, 0); - lv_obj_set_style_outline_width(circle_layer, 0, 0); - lv_obj_set_style_shadow_width(circle_layer, 0, 0); - lv_obj_add_event_cb(circle_layer, draw_circle_layer, LV_EVENT_DRAW_MAIN, nullptr); - return true; -} - -static void draw_circle_layer(lv_event_t *event) { - if (visible_circle_count == 0) { - return; - } - auto *obj = static_cast(lv_event_get_current_target(event)); - auto *layer = lv_event_get_layer(event); - lv_area_t obj_coords; - lv_obj_get_coords(obj, &obj_coords); - - lv_draw_rect_dsc_t rect_dsc; - lv_draw_rect_dsc_init(&rect_dsc); - rect_dsc.base.layer = layer; - rect_dsc.radius = LV_RADIUS_CIRCLE; - rect_dsc.bg_opa = LV_OPA_70; - rect_dsc.bg_color = lv_color_make(0, 255, 255); - rect_dsc.border_width = 0; - rect_dsc.outline_width = 0; - rect_dsc.shadow_width = 0; - - for (const auto &circle : circles) { - if (!circle.visible) { - continue; - } - lv_area_t coords = { - .x1 = static_cast(obj_coords.x1 + circle.x - circle.radius), - .y1 = static_cast(obj_coords.y1 + circle.y - circle.radius), - .x2 = static_cast(obj_coords.x1 + circle.x + circle.radius - 1), - .y2 = static_cast(obj_coords.y1 + circle.y + circle.radius - 1), - }; - lv_draw_rect(layer, &rect_dsc, &coords); - } -} - -static void invalidate_circle_area(const Circle &circle) { - if (!circle_layer || circle.radius <= 0) { - return; - } - - lv_area_t obj_coords; - lv_obj_get_coords(circle_layer, &obj_coords); - lv_area_t coords = { - .x1 = static_cast(obj_coords.x1 + circle.x - circle.radius), - .y1 = static_cast(obj_coords.y1 + circle.y - circle.radius), - .x2 = static_cast(obj_coords.x1 + circle.x + circle.radius - 1), - .y2 = static_cast(obj_coords.y1 + circle.y + circle.radius - 1), - }; - lv_obj_invalidate_area(circle_layer, &coords); -} - -static void draw_circle(int x0, int y0, int radius) { - if (!circle_layer) { - return; - } - lv_obj_move_foreground(circle_layer); - Circle previous_circle = circles[next_circle_index]; - circles[next_circle_index] = {.x = x0, .y = y0, .radius = radius, .visible = true}; - next_circle_index = (next_circle_index + 1) % circles.size(); - if (visible_circle_count < circles.size()) { - visible_circle_count++; - } - if (previous_circle.visible) { - invalidate_circle_area(previous_circle); - } - invalidate_circle_area(circles[(next_circle_index + circles.size() - 1) % circles.size()]); -} - -static void clear_circles() { - for (auto &circle : circles) { - if (circle.visible) { - invalidate_circle_area(circle); - } - circle.visible = false; - } - next_circle_index = 0; - visible_circle_count = 0; -} diff --git a/components/ws-s3-geek/example/main/gui.cpp b/components/ws-s3-geek/example/main/gui.cpp new file mode 100644 index 000000000..3158264f9 --- /dev/null +++ b/components/ws-s3-geek/example/main/gui.cpp @@ -0,0 +1,169 @@ +#include "gui.hpp" + +void Gui::init_ui() { + std::lock_guard lock(mutex_); + init_background(); + init_label(); + init_circle_layer(); +} + +void Gui::deinit_ui() { + std::lock_guard lock(mutex_); + lv_obj_clean(lv_screen_active()); +} + +void Gui::init_background() { + auto &bsp = Bsp::get(); + background_ = lv_obj_create(lv_screen_active()); + lv_obj_set_size(background_, bsp.lcd_width(), bsp.lcd_height()); + lv_obj_set_style_bg_color(background_, lv_color_make(0, 0, 0), 0); +} + +void Gui::init_label() { + label_ = lv_label_create(lv_screen_active()); + lv_label_set_text(label_, ""); + lv_obj_align(label_, LV_ALIGN_CENTER, 0, 0); + lv_obj_set_style_text_align(label_, LV_TEXT_ALIGN_CENTER, 0); +} + +void Gui::init_circle_layer() { + auto &bsp = Bsp::get(); + circle_layer_ = lv_obj_create(lv_screen_active()); + lv_obj_remove_style_all(circle_layer_); + lv_obj_set_size(circle_layer_, bsp.lcd_width(), bsp.lcd_height()); + lv_obj_align(circle_layer_, LV_ALIGN_CENTER, 0, 0); + lv_obj_clear_flag(circle_layer_, LV_OBJ_FLAG_CLICKABLE); + lv_obj_clear_flag(circle_layer_, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_bg_opa(circle_layer_, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(circle_layer_, 0, 0); + lv_obj_set_style_outline_width(circle_layer_, 0, 0); + lv_obj_set_style_shadow_width(circle_layer_, 0, 0); + lv_obj_add_event_cb(circle_layer_, draw_circle_layer, LV_EVENT_DRAW_MAIN, this); + lv_obj_move_foreground(circle_layer_); +} + +bool Gui::update(std::mutex &m, std::condition_variable &cv) { + { + std::lock_guard lock(mutex_); + lv_task_handler(); + } + std::unique_lock lock(m); + cv.wait_for(lock, std::chrono::milliseconds(16)); + return false; // don't stop the task +} + +void Gui::set_label_text(std::string_view text) { + std::lock_guard lock(mutex_); + lv_label_set_text(label_, std::string(text).c_str()); +} + +void Gui::next_rotation() { + std::lock_guard lock(mutex_); + auto rotation = lv_display_get_rotation(lv_display_get_default()); + rotation = static_cast((static_cast(rotation) + 1) % 4); + logger_.info("Setting rotation to {}", static_cast(rotation)); + lv_display_set_rotation(lv_display_get_default(), rotation); + update_layout(); +} + +void Gui::update_layout() { + auto &bsp = Bsp::get(); + int width = bsp.rotated_display_width(); + int height = bsp.rotated_display_height(); + // update the size of the screen-filling objects and re-align the rest + lv_obj_set_size(background_, width, height); + lv_obj_align(label_, LV_ALIGN_CENTER, 0, 0); + lv_obj_set_size(circle_layer_, width, height); + lv_obj_align(circle_layer_, LV_ALIGN_CENTER, 0, 0); + lv_obj_move_foreground(circle_layer_); + lv_obj_invalidate(circle_layer_); +} + +void Gui::draw_circle(int x, int y, int radius) { + std::lock_guard lock(mutex_); + lv_obj_move_foreground(circle_layer_); + Circle previous_circle = circles_[next_circle_index_]; + circles_[next_circle_index_] = {.x = x, .y = y, .radius = radius, .visible = true}; + next_circle_index_ = (next_circle_index_ + 1) % circles_.size(); + if (visible_circle_count_ < circles_.size()) { + visible_circle_count_++; + } + if (previous_circle.visible) { + invalidate_circle_area(previous_circle); + } + invalidate_circle_area(circles_[(next_circle_index_ + circles_.size() - 1) % circles_.size()]); +} + +void Gui::clear_circles() { + std::lock_guard lock(mutex_); + for (auto &circle : circles_) { + if (circle.visible) { + invalidate_circle_area(circle); + } + circle.visible = false; + } + next_circle_index_ = 0; + visible_circle_count_ = 0; +} + +size_t Gui::circle_count() { + std::lock_guard lock(mutex_); + return visible_circle_count_; +} + +void Gui::invalidate_circle_area(const Circle &circle) { + if (!circle_layer_ || circle.radius <= 0) { + return; + } + lv_area_t obj_coords; + lv_obj_get_coords(circle_layer_, &obj_coords); + lv_area_t coords = { + .x1 = static_cast(obj_coords.x1 + circle.x - circle.radius), + .y1 = static_cast(obj_coords.y1 + circle.y - circle.radius), + .x2 = static_cast(obj_coords.x1 + circle.x + circle.radius - 1), + .y2 = static_cast(obj_coords.y1 + circle.y + circle.radius - 1), + }; + lv_obj_invalidate_area(circle_layer_, &coords); +} + +void Gui::draw_circle_layer(lv_event_t *e) { + const auto *gui = static_cast(lv_event_get_user_data(e)); + if (!gui) { + return; + } + gui->draw_circles(e); +} + +void Gui::draw_circles(lv_event_t *e) const { + if (visible_circle_count_ == 0) { + return; + } + + auto *obj = static_cast(lv_event_get_current_target(e)); + auto *layer = lv_event_get_layer(e); + lv_area_t obj_coords; + lv_obj_get_coords(obj, &obj_coords); + + lv_draw_rect_dsc_t rect_dsc; + lv_draw_rect_dsc_init(&rect_dsc); + rect_dsc.base.layer = layer; + rect_dsc.radius = LV_RADIUS_CIRCLE; + rect_dsc.bg_opa = LV_OPA_70; + rect_dsc.bg_color = lv_color_make(0, 255, 255); + rect_dsc.border_width = 0; + rect_dsc.outline_width = 0; + rect_dsc.shadow_width = 0; + + for (const auto &circle : circles_) { + if (!circle.visible) { + continue; + } + lv_area_t coords = { + .x1 = static_cast(obj_coords.x1 + circle.x - circle.radius), + .y1 = static_cast(obj_coords.y1 + circle.y - circle.radius), + .x2 = static_cast(obj_coords.x1 + circle.x + circle.radius - 1), + .y2 = static_cast(obj_coords.y1 + circle.y + circle.radius - 1), + }; + lv_draw_rect(layer, &rect_dsc, &coords); + } +} diff --git a/components/ws-s3-geek/example/main/gui.hpp b/components/ws-s3-geek/example/main/gui.hpp new file mode 100644 index 000000000..522d840ea --- /dev/null +++ b/components/ws-s3-geek/example/main/gui.hpp @@ -0,0 +1,117 @@ +#pragma once + +#include +#include +#include +#include + +#include "ws-s3-geek.hpp" + +#include "logger.hpp" +#include "task.hpp" + +/// The Gui class encapsulates all of the LVGL UI for this example. It follows +/// the recommended pattern for building UIs with espp + LVGL in C++: +/// +/// * All LVGL objects are created in init_ui(), which is broken into small +/// member functions - one per logical piece of the UI. +/// * The class owns the task which calls lv_task_handler(), and a recursive +/// mutex which guards every LVGL call. Public methods lock that mutex, so +/// other tasks (button callbacks, the main loop, etc.) can safely call +/// them. +/// +/// For this example the Gui shows: +/// * A label with text centered on the screen +/// * A custom-drawn layer of circles which the main loop draws in a ring +/// around the center of the screen +/// +/// Since this board has no touchscreen there are no on-screen buttons; the +/// hardware button cycles the display rotation via next_rotation(). +class Gui { +public: + /// The maximum number of circles which can be drawn on the screen at once + static constexpr size_t MAX_CIRCLES = 10; + + /// Configuration for the Gui + struct Config { + espp::Logger::Verbosity log_level{espp::Logger::Verbosity::WARN}; ///< Log verbosity + }; + + /// Construct the Gui: builds the UI and starts the LVGL update task. + /// @param config The configuration for the Gui + explicit Gui(const Config &config) + : logger_({.tag = "Gui", .level = config.log_level}) { + init_ui(); + update_task_.start(); + } + + ~Gui() { + update_task_.stop(); + deinit_ui(); + } + + /// Set the text of the main label. Thread-safe. + /// @param text The text to display + void set_label_text(std::string_view text); + + /// Draw a circle at the given screen coordinates, replacing the oldest + /// circle if the maximum number are already visible. Thread-safe. + /// @param x The x coordinate (screen space) + /// @param y The y coordinate (screen space) + /// @param radius The radius of the circle + void draw_circle(int x, int y, int radius); + + /// Clear all circles from the screen. Thread-safe. + void clear_circles(); + + /// Get the number of circles currently visible on the screen. Thread-safe. + /// @return The number of visible circles + size_t circle_count(); + + /// Rotate the display to the next of 0/90/180/270 degrees, resizing / + /// re-aligning the UI to match. Thread-safe. + void next_rotation(); + +protected: + using Bsp = espp::WsS3Geek; + + struct Circle { + int x{0}; + int y{0}; + int radius{0}; + bool visible{false}; + }; + + void init_ui(); + void deinit_ui(); + + // the individual pieces of the UI, called from init_ui() + void init_background(); + void init_label(); + void init_circle_layer(); + + // resize / re-align the UI to match the current display rotation + void update_layout(); + + // the LVGL update task: calls lv_task_handler() under the mutex + bool update(std::mutex &m, std::condition_variable &cv); + + // custom drawing of the circle layer + static void draw_circle_layer(lv_event_t *e); + void draw_circles(lv_event_t *e) const; + void invalidate_circle_area(const Circle &circle); + + // LVGL objects + lv_obj_t *background_{nullptr}; + lv_obj_t *label_{nullptr}; + lv_obj_t *circle_layer_{nullptr}; + + std::array circles_; + size_t next_circle_index_{0}; + size_t visible_circle_count_{0}; + + espp::Task update_task_{{.callback = [this](auto &m, auto &cv) { return update(m, cv); }, + .task_config = {.name = "gui", .stack_size_bytes = 6 * 1024}}}; + espp::Logger logger_; + std::recursive_mutex mutex_; +}; diff --git a/components/ws-s3-geek/example/main/ws_s3_geek_example.cpp b/components/ws-s3-geek/example/main/ws_s3_geek_example.cpp index dd636a869..be75c729c 100644 --- a/components/ws-s3-geek/example/main/ws_s3_geek_example.cpp +++ b/components/ws-s3-geek/example/main/ws_s3_geek_example.cpp @@ -1,29 +1,13 @@ -#include #include +#include +#include #include #include "ws-s3-geek.hpp" -using namespace std::chrono_literals; - -static constexpr size_t MAX_CIRCLES = 10; -struct Circle { - int x{0}; - int y{0}; - int radius{0}; - bool visible{false}; -}; -static std::array circles; -static size_t next_circle_index = 0; -static size_t visible_circle_count = 0; -static lv_obj_t *circle_layer = nullptr; +#include "gui.hpp" -static std::mutex lvgl_mutex; -static bool initialize_circle_layer(int width, int height); -static void draw_circle_layer(lv_event_t *event); -static void invalidate_circle_area(const Circle &circle); -static void draw_circle(int x0, int y0, int radius); -static void clear_circles(); +using namespace std::chrono_literals; extern "C" void app_main(void) { espp::Logger logger({.tag = "WsS3Geek Example", .level = espp::Logger::Verbosity::INFO}); @@ -38,7 +22,7 @@ extern "C" void app_main(void) { logger.error("Failed to initialize LCD!"); return; } - // set the pixel buffer to be a full screen buffer + // set the pixel buffer to be 50 lines high static constexpr size_t pixel_buffer_size = board.lcd_width() * 50; // initialize the LVGL display for the WsS3Geek if (!board.initialize_display(pixel_buffer_size)) { @@ -53,93 +37,39 @@ extern "C" void app_main(void) { logger.warn("Failed to initialize SD card, continuing without it."); } + // create the GUI: builds the UI (label, circle layer) and starts the task + // which updates LVGL. All of its public methods are thread-safe, so the + // button callback and the main loop below can call them directly. + static Gui gui({.log_level = espp::Logger::Verbosity::INFO}); + gui.set_label_text("Drawing circles\nto the screen."); + // initialize the button, which we'll use to cycle the rotation of the display logger.info("Initializing the button"); - lv_obj_t *bg = nullptr; - lv_obj_t *label = nullptr; - static auto update_layout = [&]() { - int width = board.rotated_display_width(); - int height = board.rotated_display_height(); - lv_obj_set_size(bg, width, height); - lv_obj_align(label, LV_ALIGN_CENTER, 0, 0); - if (circle_layer) { - lv_obj_set_size(circle_layer, width, height); - lv_obj_align(circle_layer, LV_ALIGN_CENTER, 0, 0); - lv_obj_move_foreground(circle_layer); - lv_obj_invalidate(circle_layer); - } - }; auto on_button_pressed = [&](const auto &event) { if (event.active) { - // lock the display mutex - std::lock_guard lock(lvgl_mutex); - static auto rotation = LV_DISPLAY_ROTATION_0; - rotation = static_cast((static_cast(rotation) + 1) % 4); - fmt::print("Setting rotation to {}\n", (int)rotation); - lv_display_t *disp = lv_display_get_default(); - lv_disp_set_rotation(disp, rotation); - update_layout(); + logger.info("Button pressed, rotating the display"); + gui.next_rotation(); } }; board.initialize_button(on_button_pressed); - // set the background color to black - bg = lv_obj_create(lv_screen_active()); - lv_obj_set_size(bg, board.rotated_display_width(), board.rotated_display_height()); - lv_obj_set_style_bg_color(bg, lv_color_make(0, 0, 0), 0); - if (!initialize_circle_layer(board.rotated_display_width(), board.rotated_display_height())) { - logger.error("Failed to initialize circle layer!"); - return; - } - - // add text in the center of the screen - label = lv_label_create(lv_screen_active()); - lv_label_set_text(label, "Drawing circles\nto the screen."); - lv_obj_align(label, LV_ALIGN_CENTER, 0, 0); - lv_obj_set_style_text_align(label, LV_TEXT_ALIGN_CENTER, 0); - update_layout(); - - lv_obj_move_foreground(circle_layer); - - // start a simple thread to do the lv_task_handler every 16ms - espp::Task lv_task({.callback = [](std::mutex &m, std::condition_variable &cv) -> bool { - { - // lock the display mutex - std::lock_guard lock(lvgl_mutex); - lv_task_handler(); - } - std::unique_lock lock(m); - cv.wait_for(lock, 16ms); - return false; - }, - .task_config = { - .name = "lv_task", - .core_id = 1, - }}); - lv_task.start(); - // set the display brightness to be 75% board.brightness(75.0f); while (true) { auto start = esp_timer_get_time(); - // if there are 10 circles on the screen, clear them - if (visible_circle_count >= MAX_CIRCLES) { - // lock the lvgl mutex - std::lock_guard lock(lvgl_mutex); - clear_circles(); + // if the maximum number of circles are on the screen, clear them + if (gui.circle_count() >= Gui::MAX_CIRCLES) { + gui.clear_circles(); } else { // draw a circle of circles on the screen (just draw the next circle) int middle_x = board.rotated_display_width() / 2; int middle_y = board.rotated_display_height() / 2; static constexpr int radius = 30; - float angle = visible_circle_count * 2.0f * M_PI / MAX_CIRCLES; - int x = middle_x + radius * cos(angle); - int y = middle_y + radius * sin(angle); - - // lock the lvgl mutex - std::lock_guard lock(lvgl_mutex); - draw_circle(x, y, 5); + float angle = gui.circle_count() * 2.0f * std::numbers::pi_v / Gui::MAX_CIRCLES; + int x = middle_x + radius * std::cos(angle); + int y = middle_y + radius * std::sin(angle); + gui.draw_circle(x, y, 5); } auto end = esp_timer_get_time(); auto elapsed = end - start; @@ -147,101 +77,3 @@ extern "C" void app_main(void) { } //! [ws-s3-geek example] } - -static bool initialize_circle_layer(int width, int height) { - if (circle_layer) { - return true; - } - circle_layer = lv_obj_create(lv_screen_active()); - if (!circle_layer) { - return false; - } - lv_obj_remove_style_all(circle_layer); - lv_obj_set_size(circle_layer, width, height); - lv_obj_align(circle_layer, LV_ALIGN_CENTER, 0, 0); - lv_obj_clear_flag(circle_layer, LV_OBJ_FLAG_CLICKABLE); - lv_obj_clear_flag(circle_layer, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_set_style_bg_opa(circle_layer, LV_OPA_TRANSP, 0); - lv_obj_set_style_border_width(circle_layer, 0, 0); - lv_obj_set_style_outline_width(circle_layer, 0, 0); - lv_obj_set_style_shadow_width(circle_layer, 0, 0); - lv_obj_add_event_cb(circle_layer, draw_circle_layer, LV_EVENT_DRAW_MAIN, nullptr); - return true; -} - -static void draw_circle_layer(lv_event_t *event) { - if (visible_circle_count == 0) { - return; - } - auto *obj = static_cast(lv_event_get_current_target(event)); - auto *layer = lv_event_get_layer(event); - lv_area_t obj_coords; - lv_obj_get_coords(obj, &obj_coords); - - lv_draw_rect_dsc_t rect_dsc; - lv_draw_rect_dsc_init(&rect_dsc); - rect_dsc.base.layer = layer; - rect_dsc.radius = LV_RADIUS_CIRCLE; - rect_dsc.bg_opa = LV_OPA_70; - rect_dsc.bg_color = lv_color_make(0, 255, 255); - rect_dsc.border_width = 0; - rect_dsc.outline_width = 0; - rect_dsc.shadow_width = 0; - - for (const auto &circle : circles) { - if (!circle.visible) { - continue; - } - lv_area_t coords = { - .x1 = static_cast(obj_coords.x1 + circle.x - circle.radius), - .y1 = static_cast(obj_coords.y1 + circle.y - circle.radius), - .x2 = static_cast(obj_coords.x1 + circle.x + circle.radius - 1), - .y2 = static_cast(obj_coords.y1 + circle.y + circle.radius - 1), - }; - lv_draw_rect(layer, &rect_dsc, &coords); - } -} - -static void invalidate_circle_area(const Circle &circle) { - if (!circle_layer || circle.radius <= 0) { - return; - } - - lv_area_t obj_coords; - lv_obj_get_coords(circle_layer, &obj_coords); - lv_area_t coords = { - .x1 = static_cast(obj_coords.x1 + circle.x - circle.radius), - .y1 = static_cast(obj_coords.y1 + circle.y - circle.radius), - .x2 = static_cast(obj_coords.x1 + circle.x + circle.radius - 1), - .y2 = static_cast(obj_coords.y1 + circle.y + circle.radius - 1), - }; - lv_obj_invalidate_area(circle_layer, &coords); -} - -static void draw_circle(int x0, int y0, int radius) { - if (!circle_layer) { - return; - } - lv_obj_move_foreground(circle_layer); - Circle previous_circle = circles[next_circle_index]; - circles[next_circle_index] = {.x = x0, .y = y0, .radius = radius, .visible = true}; - next_circle_index = (next_circle_index + 1) % circles.size(); - if (visible_circle_count < circles.size()) { - visible_circle_count++; - } - if (previous_circle.visible) { - invalidate_circle_area(previous_circle); - } - invalidate_circle_area(circles[(next_circle_index + circles.size() - 1) % circles.size()]); -} - -static void clear_circles() { - for (auto &circle : circles) { - if (circle.visible) { - invalidate_circle_area(circle); - } - circle.visible = false; - } - next_circle_index = 0; - visible_circle_count = 0; -} diff --git a/components/ws-s3-lcd-1-47/example/main/gui.cpp b/components/ws-s3-lcd-1-47/example/main/gui.cpp new file mode 100644 index 000000000..a9ccd2e0a --- /dev/null +++ b/components/ws-s3-lcd-1-47/example/main/gui.cpp @@ -0,0 +1,170 @@ +#include "gui.hpp" + +void Gui::init_ui() { + std::lock_guard lock(mutex_); + init_background(); + init_label(); + init_circle_layer(); + update_layout(); +} + +void Gui::deinit_ui() { + std::lock_guard lock(mutex_); + lv_obj_clean(lv_screen_active()); +} + +void Gui::init_background() { + auto &board = espp::WsS3Lcd147::get(); + background_ = lv_obj_create(lv_screen_active()); + lv_obj_set_size(background_, board.rotated_display_width(), board.rotated_display_height()); + lv_obj_set_style_bg_color(background_, lv_color_make(0, 0, 0), 0); +} + +void Gui::init_label() { + label_ = lv_label_create(lv_screen_active()); + lv_label_set_text(label_, ""); + lv_obj_align(label_, LV_ALIGN_CENTER, 0, 0); + lv_obj_set_style_text_align(label_, LV_TEXT_ALIGN_CENTER, 0); +} + +void Gui::init_circle_layer() { + auto &board = espp::WsS3Lcd147::get(); + circle_layer_ = lv_obj_create(lv_screen_active()); + lv_obj_remove_style_all(circle_layer_); + lv_obj_set_size(circle_layer_, board.rotated_display_width(), board.rotated_display_height()); + lv_obj_align(circle_layer_, LV_ALIGN_CENTER, 0, 0); + lv_obj_clear_flag(circle_layer_, LV_OBJ_FLAG_CLICKABLE); + lv_obj_clear_flag(circle_layer_, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_bg_opa(circle_layer_, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(circle_layer_, 0, 0); + lv_obj_set_style_outline_width(circle_layer_, 0, 0); + lv_obj_set_style_shadow_width(circle_layer_, 0, 0); + lv_obj_add_event_cb(circle_layer_, draw_circle_layer, LV_EVENT_DRAW_MAIN, this); + lv_obj_move_foreground(circle_layer_); +} + +void Gui::update_layout() { + auto &board = espp::WsS3Lcd147::get(); + int width = board.rotated_display_width(); + int height = board.rotated_display_height(); + lv_obj_set_size(background_, width, height); + lv_obj_align(label_, LV_ALIGN_CENTER, 0, 0); + lv_obj_set_size(circle_layer_, width, height); + lv_obj_align(circle_layer_, LV_ALIGN_CENTER, 0, 0); + lv_obj_move_foreground(circle_layer_); + lv_obj_invalidate(circle_layer_); +} + +bool Gui::update(std::mutex &m, std::condition_variable &cv) { + { + std::lock_guard lock(mutex_); + lv_task_handler(); + } + std::unique_lock lock(m); + cv.wait_for(lock, std::chrono::milliseconds(16)); + return false; // don't stop the task +} + +size_t Gui::visible_circle_count() { + std::lock_guard lock(mutex_); + return visible_circle_count_; +} + +void Gui::set_label_text(std::string_view text) { + std::lock_guard lock(mutex_); + lv_label_set_text(label_, std::string(text).c_str()); +} + +void Gui::next_rotation() { + std::lock_guard lock(mutex_); + auto rotation = lv_display_get_rotation(lv_display_get_default()); + rotation = static_cast((static_cast(rotation) + 1) % 4); + logger_.info("Setting rotation to {}", static_cast(rotation)); + lv_display_set_rotation(lv_display_get_default(), rotation); + // update the size of the screen-filling objects + update_layout(); +} + +void Gui::draw_circle(int x, int y, int radius) { + std::lock_guard lock(mutex_); + lv_obj_move_foreground(circle_layer_); + Circle previous_circle = circles_[next_circle_index_]; + circles_[next_circle_index_] = {.x = x, .y = y, .radius = radius, .visible = true}; + next_circle_index_ = (next_circle_index_ + 1) % circles_.size(); + if (visible_circle_count_ < circles_.size()) { + visible_circle_count_++; + } + if (previous_circle.visible) { + invalidate_circle_area(previous_circle); + } + invalidate_circle_area(circles_[(next_circle_index_ + circles_.size() - 1) % circles_.size()]); +} + +void Gui::clear_circles() { + std::lock_guard lock(mutex_); + for (auto &circle : circles_) { + if (circle.visible) { + invalidate_circle_area(circle); + } + circle.visible = false; + } + next_circle_index_ = 0; + visible_circle_count_ = 0; +} + +void Gui::invalidate_circle_area(const Circle &circle) { + if (!circle_layer_ || circle.radius <= 0) { + return; + } + lv_area_t obj_coords; + lv_obj_get_coords(circle_layer_, &obj_coords); + lv_area_t coords = { + .x1 = static_cast(obj_coords.x1 + circle.x - circle.radius), + .y1 = static_cast(obj_coords.y1 + circle.y - circle.radius), + .x2 = static_cast(obj_coords.x1 + circle.x + circle.radius - 1), + .y2 = static_cast(obj_coords.y1 + circle.y + circle.radius - 1), + }; + lv_obj_invalidate_area(circle_layer_, &coords); +} + +void Gui::draw_circle_layer(lv_event_t *e) { + const auto *gui = static_cast(lv_event_get_user_data(e)); + if (!gui) { + return; + } + gui->draw_circles(e); +} + +void Gui::draw_circles(lv_event_t *e) const { + if (visible_circle_count_ == 0) { + return; + } + + auto *obj = static_cast(lv_event_get_current_target(e)); + auto *layer = lv_event_get_layer(e); + lv_area_t obj_coords; + lv_obj_get_coords(obj, &obj_coords); + + lv_draw_rect_dsc_t rect_dsc; + lv_draw_rect_dsc_init(&rect_dsc); + rect_dsc.base.layer = layer; + rect_dsc.radius = LV_RADIUS_CIRCLE; + rect_dsc.bg_opa = LV_OPA_70; + rect_dsc.bg_color = lv_color_make(0, 255, 255); + rect_dsc.border_width = 0; + rect_dsc.outline_width = 0; + rect_dsc.shadow_width = 0; + + for (const auto &circle : circles_) { + if (!circle.visible) { + continue; + } + lv_area_t coords = { + .x1 = static_cast(obj_coords.x1 + circle.x - circle.radius), + .y1 = static_cast(obj_coords.y1 + circle.y - circle.radius), + .x2 = static_cast(obj_coords.x1 + circle.x + circle.radius - 1), + .y2 = static_cast(obj_coords.y1 + circle.y + circle.radius - 1), + }; + lv_draw_rect(layer, &rect_dsc, &coords); + } +} diff --git a/components/ws-s3-lcd-1-47/example/main/gui.hpp b/components/ws-s3-lcd-1-47/example/main/gui.hpp new file mode 100644 index 000000000..2fde442dd --- /dev/null +++ b/components/ws-s3-lcd-1-47/example/main/gui.hpp @@ -0,0 +1,119 @@ +#pragma once + +#include +#include +#include +#include + +#include "ws-s3-lcd-1-47.hpp" + +#include "logger.hpp" +#include "task.hpp" + +/// The Gui class encapsulates all of the LVGL UI for this example. It follows +/// the recommended pattern for building UIs with espp + LVGL in C++: +/// +/// * All LVGL objects are created in init_ui(), which is broken into small +/// member functions - one per logical piece of the UI. +/// * The class owns the task which calls lv_task_handler(), and a recursive +/// mutex which guards every LVGL call. Public methods lock that mutex, so +/// other tasks (button callbacks, the main loop, etc.) can safely call them. +/// +/// For this example the Gui shows: +/// * A label in the center of the screen +/// * A custom-drawn layer of circles which the main loop draws in a ring +/// around the center of the screen +/// +/// This board has no touchscreen, so there are no on-screen buttons; the +/// hardware button cycles the display rotation via next_rotation(). +class Gui { +public: + /// Configuration for the Gui + struct Config { + espp::Logger::Verbosity log_level{espp::Logger::Verbosity::WARN}; ///< Log verbosity + }; + + /// Construct the Gui: builds the UI and starts the LVGL update task. + /// @param config The configuration for the Gui + explicit Gui(const Config &config) + : logger_({.tag = "Gui", .level = config.log_level}) { + init_ui(); + update_task_.start(); + } + + ~Gui() { + update_task_.stop(); + deinit_ui(); + } + + /// Get the maximum number of circles which can be visible at once. + /// @return The maximum number of circles + static constexpr size_t max_circles() { return MAX_CIRCLES; } + + /// Get the number of circles currently visible on the screen. Thread-safe. + /// @return The number of visible circles + size_t visible_circle_count(); + + /// Set the text of the main label. Thread-safe. + /// @param text The text to display + void set_label_text(std::string_view text); + + /// Draw a circle at the given screen coordinates, replacing the oldest + /// circle if the maximum number are already visible. Thread-safe. + /// @param x The x coordinate (screen space) + /// @param y The y coordinate (screen space) + /// @param radius The radius of the circle + void draw_circle(int x, int y, int radius); + + /// Clear all circles from the screen. Thread-safe. + void clear_circles(); + + /// Rotate the display to the next of 0/90/180/270 degrees, resizing / + /// re-aligning the UI to match. Thread-safe. + void next_rotation(); + +protected: + static constexpr size_t MAX_CIRCLES = 10; + + struct Circle { + int x{0}; + int y{0}; + int radius{0}; + bool visible{false}; + }; + + void init_ui(); + void deinit_ui(); + + // the individual pieces of the UI, called from init_ui() + void init_background(); + void init_label(); + void init_circle_layer(); + + // resize / re-align the screen-filling objects for the current display + // rotation + void update_layout(); + + // the LVGL update task: calls lv_task_handler() under the mutex + bool update(std::mutex &m, std::condition_variable &cv); + + // custom drawing of the circle layer + static void draw_circle_layer(lv_event_t *e); + void draw_circles(lv_event_t *e) const; + void invalidate_circle_area(const Circle &circle); + + // LVGL objects + lv_obj_t *background_{nullptr}; + lv_obj_t *label_{nullptr}; + lv_obj_t *circle_layer_{nullptr}; + + std::array circles_; + size_t next_circle_index_{0}; + size_t visible_circle_count_{0}; + + espp::Task update_task_{ + {.callback = [this](auto &m, auto &cv) { return update(m, cv); }, + .task_config = {.name = "gui", .stack_size_bytes = 6 * 1024, .core_id = 1}}}; + espp::Logger logger_; + std::recursive_mutex mutex_; +}; diff --git a/components/ws-s3-lcd-1-47/example/main/ws_s3_lcd_1_47_example.cpp b/components/ws-s3-lcd-1-47/example/main/ws_s3_lcd_1_47_example.cpp index ab73e39f7..10acb0e6e 100644 --- a/components/ws-s3-lcd-1-47/example/main/ws_s3_lcd_1_47_example.cpp +++ b/components/ws-s3-lcd-1-47/example/main/ws_s3_lcd_1_47_example.cpp @@ -1,28 +1,12 @@ -#include #include +#include +#include #include "ws-s3-lcd-1-47.hpp" -using namespace std::chrono_literals; - -static constexpr size_t MAX_CIRCLES = 10; -struct Circle { - int x{0}; - int y{0}; - int radius{0}; - bool visible{false}; -}; -static std::array circles; -static size_t next_circle_index = 0; -static size_t visible_circle_count = 0; -static lv_obj_t *circle_layer = nullptr; +#include "gui.hpp" -static std::mutex lvgl_mutex; -static bool initialize_circle_layer(int width, int height); -static void draw_circle_layer(lv_event_t *event); -static void invalidate_circle_area(const Circle &circle); -static void draw_circle(int x0, int y0, int radius); -static void clear_circles(); +using namespace std::chrono_literals; extern "C" void app_main(void) { espp::Logger logger({.tag = "WsS3Lcd147 Example", .level = espp::Logger::Verbosity::INFO}); @@ -59,74 +43,25 @@ extern "C" void app_main(void) { return; } + // create the GUI: builds the UI (label, circle layer) and starts the task + // which updates LVGL. All of its public methods are thread-safe, so the + // button callback and the main loop below can call them directly. + static Gui gui({.log_level = espp::Logger::Verbosity::INFO}); + gui.set_label_text("Drawing circles\nto the screen."); + // initialize the button, which we'll use to cycle the rotation of the display logger.info("Initializing the button"); - lv_obj_t *bg = nullptr; - lv_obj_t *label = nullptr; - static auto update_layout = [&]() { - int width = board.rotated_display_width(); - int height = board.rotated_display_height(); - lv_obj_set_size(bg, width, height); - lv_obj_align(label, LV_ALIGN_CENTER, 0, 0); - if (circle_layer) { - lv_obj_set_size(circle_layer, width, height); - lv_obj_align(circle_layer, LV_ALIGN_CENTER, 0, 0); - lv_obj_move_foreground(circle_layer); - lv_obj_invalidate(circle_layer); - } - }; auto on_button_pressed = [&](const auto &event) { if (event.active) { - // lock the display mutex - std::lock_guard lock(lvgl_mutex); - static auto rotation = LV_DISPLAY_ROTATION_0; - rotation = static_cast((static_cast(rotation) + 1) % 4); - fmt::print("Setting rotation to {}\n", (int)rotation); - lv_display_t *disp = lv_display_get_default(); - lv_disp_set_rotation(disp, rotation); - update_layout(); + gui.next_rotation(); } }; board.initialize_button(on_button_pressed); - // set the background color to black - bg = lv_obj_create(lv_screen_active()); - lv_obj_set_size(bg, board.rotated_display_width(), board.rotated_display_height()); - lv_obj_set_style_bg_color(bg, lv_color_make(0, 0, 0), 0); - if (!initialize_circle_layer(board.rotated_display_width(), board.rotated_display_height())) { - logger.error("Failed to initialize circle layer!"); - return; - } - - // add text in the center of the screen - label = lv_label_create(lv_screen_active()); - lv_label_set_text(label, "Drawing circles\nto the screen."); - lv_obj_align(label, LV_ALIGN_CENTER, 0, 0); - lv_obj_set_style_text_align(label, LV_TEXT_ALIGN_CENTER, 0); - update_layout(); - - lv_obj_move_foreground(circle_layer); - - // start a simple thread to do the lv_task_handler every 16ms - espp::Task lv_task({.callback = [](std::mutex &m, std::condition_variable &cv) -> bool { - { - // lock the display mutex - std::lock_guard lock(lvgl_mutex); - lv_task_handler(); - } - std::unique_lock lock(m); - cv.wait_for(lock, 16ms); - return false; - }, - .task_config = { - .name = "lv_task", - .core_id = 1, - }}); - lv_task.start(); - // set the display brightness to be 75% board.brightness(75.0f); + // make a task to cycle the color of the onboard LED espp::Task led_task({.callback = [](std::mutex &m, std::condition_variable &cv) -> bool { static auto &board = Bsp::get(); auto start = std::chrono::steady_clock::now(); @@ -147,23 +82,19 @@ extern "C" void app_main(void) { while (true) { auto start = esp_timer_get_time(); - // if there are 10 circles on the screen, clear them - if (visible_circle_count >= MAX_CIRCLES) { - // lock the lvgl mutex - std::lock_guard lock(lvgl_mutex); - clear_circles(); + // if the maximum number of circles are on the screen, clear them + if (gui.visible_circle_count() >= Gui::max_circles()) { + gui.clear_circles(); } else { // draw a circle of circles on the screen (just draw the next circle) int middle_x = board.rotated_display_width() / 2; int middle_y = board.rotated_display_height() / 2; static constexpr int radius = 50; - float angle = visible_circle_count * 2.0f * M_PI / MAX_CIRCLES; - int x = middle_x + radius * cos(angle); - int y = middle_y + radius * sin(angle); - - // lock the lvgl mutex - std::lock_guard lock(lvgl_mutex); - draw_circle(x, y, 10); + float angle = + gui.visible_circle_count() * 2.0f * std::numbers::pi_v / Gui::max_circles(); + int x = middle_x + radius * std::cos(angle); + int y = middle_y + radius * std::sin(angle); + gui.draw_circle(x, y, 10); } auto end = esp_timer_get_time(); auto elapsed = end - start; @@ -171,101 +102,3 @@ extern "C" void app_main(void) { } //! [ws-s3-lcd-1-47 example] } - -static bool initialize_circle_layer(int width, int height) { - if (circle_layer) { - return true; - } - circle_layer = lv_obj_create(lv_screen_active()); - if (!circle_layer) { - return false; - } - lv_obj_remove_style_all(circle_layer); - lv_obj_set_size(circle_layer, width, height); - lv_obj_align(circle_layer, LV_ALIGN_CENTER, 0, 0); - lv_obj_clear_flag(circle_layer, LV_OBJ_FLAG_CLICKABLE); - lv_obj_clear_flag(circle_layer, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_set_style_bg_opa(circle_layer, LV_OPA_TRANSP, 0); - lv_obj_set_style_border_width(circle_layer, 0, 0); - lv_obj_set_style_outline_width(circle_layer, 0, 0); - lv_obj_set_style_shadow_width(circle_layer, 0, 0); - lv_obj_add_event_cb(circle_layer, draw_circle_layer, LV_EVENT_DRAW_MAIN, nullptr); - return true; -} - -static void draw_circle_layer(lv_event_t *event) { - if (visible_circle_count == 0) { - return; - } - auto *obj = static_cast(lv_event_get_current_target(event)); - auto *layer = lv_event_get_layer(event); - lv_area_t obj_coords; - lv_obj_get_coords(obj, &obj_coords); - - lv_draw_rect_dsc_t rect_dsc; - lv_draw_rect_dsc_init(&rect_dsc); - rect_dsc.base.layer = layer; - rect_dsc.radius = LV_RADIUS_CIRCLE; - rect_dsc.bg_opa = LV_OPA_70; - rect_dsc.bg_color = lv_color_make(0, 255, 255); - rect_dsc.border_width = 0; - rect_dsc.outline_width = 0; - rect_dsc.shadow_width = 0; - - for (const auto &circle : circles) { - if (!circle.visible) { - continue; - } - lv_area_t coords = { - .x1 = static_cast(obj_coords.x1 + circle.x - circle.radius), - .y1 = static_cast(obj_coords.y1 + circle.y - circle.radius), - .x2 = static_cast(obj_coords.x1 + circle.x + circle.radius - 1), - .y2 = static_cast(obj_coords.y1 + circle.y + circle.radius - 1), - }; - lv_draw_rect(layer, &rect_dsc, &coords); - } -} - -static void invalidate_circle_area(const Circle &circle) { - if (!circle_layer || circle.radius <= 0) { - return; - } - - lv_area_t obj_coords; - lv_obj_get_coords(circle_layer, &obj_coords); - lv_area_t coords = { - .x1 = static_cast(obj_coords.x1 + circle.x - circle.radius), - .y1 = static_cast(obj_coords.y1 + circle.y - circle.radius), - .x2 = static_cast(obj_coords.x1 + circle.x + circle.radius - 1), - .y2 = static_cast(obj_coords.y1 + circle.y + circle.radius - 1), - }; - lv_obj_invalidate_area(circle_layer, &coords); -} - -static void draw_circle(int x0, int y0, int radius) { - if (!circle_layer) { - return; - } - lv_obj_move_foreground(circle_layer); - Circle previous_circle = circles[next_circle_index]; - circles[next_circle_index] = {.x = x0, .y = y0, .radius = radius, .visible = true}; - next_circle_index = (next_circle_index + 1) % circles.size(); - if (visible_circle_count < circles.size()) { - visible_circle_count++; - } - if (previous_circle.visible) { - invalidate_circle_area(previous_circle); - } - invalidate_circle_area(circles[(next_circle_index + circles.size() - 1) % circles.size()]); -} - -static void clear_circles() { - for (auto &circle : circles) { - if (circle.visible) { - invalidate_circle_area(circle); - } - circle.visible = false; - } - next_circle_index = 0; - visible_circle_count = 0; -} diff --git a/components/ws-s3-touch/example/main/gui.cpp b/components/ws-s3-touch/example/main/gui.cpp new file mode 100644 index 000000000..51a5eb7ec --- /dev/null +++ b/components/ws-s3-touch/example/main/gui.cpp @@ -0,0 +1,304 @@ +#include + +#include "gui.hpp" + +void Gui::init_ui() { + std::lock_guard lock(mutex_); + init_background(); + init_label(); + init_clock_label(); + init_gravity_lines(); + init_buttons(); + init_circle_layer(); + // disable scrolling on the screen (so that it doesn't behave weirdly when + // rotated and drawing with your finger) + lv_obj_set_scrollbar_mode(lv_screen_active(), LV_SCROLLBAR_MODE_OFF); + lv_obj_clear_flag(lv_screen_active(), LV_OBJ_FLAG_SCROLLABLE); +} + +void Gui::deinit_ui() { + std::lock_guard lock(mutex_); + lv_obj_clean(lv_screen_active()); +} + +void Gui::init_background() { + auto &bsp = Bsp::get(); + background_ = lv_obj_create(lv_screen_active()); + lv_obj_set_size(background_, bsp.lcd_width(), bsp.lcd_height()); + lv_obj_set_style_bg_color(background_, lv_color_make(0, 0, 0), 0); +} + +void Gui::init_label() { + auto &bsp = Bsp::get(); + label_ = lv_label_create(lv_screen_active()); + lv_label_set_text(label_, ""); + lv_label_set_long_mode(label_, LV_LABEL_LONG_WRAP); + lv_obj_set_width(label_, bsp.lcd_width()); + lv_obj_align(label_, LV_ALIGN_TOP_LEFT, 0, 0); + lv_obj_set_style_text_align(label_, LV_TEXT_ALIGN_LEFT, 0); +} + +void Gui::init_clock_label() { + // a label centered at the very top for the RTC time, offset down so that + // it's always visible + clock_label_ = lv_label_create(lv_screen_active()); + lv_label_set_text(clock_label_, ""); + lv_obj_align(clock_label_, LV_ALIGN_TOP_MID, 0, 20); + lv_obj_set_style_text_align(clock_label_, LV_TEXT_ALIGN_LEFT, 0); +} + +void Gui::init_gravity_lines() { + auto &bsp = Bsp::get(); + + lv_style_init(&kalman_line_style_); + lv_style_set_line_width(&kalman_line_style_, 8); + lv_style_set_line_color(&kalman_line_style_, lv_palette_main(LV_PALETTE_BLUE)); + lv_style_set_line_rounded(&kalman_line_style_, true); + + kalman_line_ = lv_line_create(lv_screen_active()); + kalman_line_points_[0] = {0, 0}; + kalman_line_points_[1] = {bsp.lcd_width(), bsp.lcd_height()}; + lv_line_set_points(kalman_line_, kalman_line_points_, 2); + lv_obj_add_style(kalman_line_, &kalman_line_style_, 0); + + lv_style_init(&madgwick_line_style_); + lv_style_set_line_width(&madgwick_line_style_, 8); + lv_style_set_line_color(&madgwick_line_style_, lv_palette_main(LV_PALETTE_RED)); + lv_style_set_line_rounded(&madgwick_line_style_, true); + + madgwick_line_ = lv_line_create(lv_screen_active()); + madgwick_line_points_[0] = {0, 0}; + madgwick_line_points_[1] = {bsp.lcd_width(), bsp.lcd_height()}; + lv_line_set_points(madgwick_line_, madgwick_line_points_, 2); + lv_obj_add_style(madgwick_line_, &madgwick_line_style_, 0); +} + +void Gui::init_buttons() { + // a button in the top left which rotates the display through + // 0/90/180/270 degrees + rotate_button_ = lv_btn_create(lv_screen_active()); + lv_obj_set_size(rotate_button_, 50, 50); + lv_obj_align(rotate_button_, LV_ALIGN_TOP_LEFT, 0, 0); + lv_obj_t *rotate_label = lv_label_create(rotate_button_); + lv_label_set_text(rotate_label, LV_SYMBOL_REFRESH); + lv_obj_align(rotate_label, LV_ALIGN_CENTER, 0, 0); + lv_obj_add_event_cb(rotate_button_, event_callback, LV_EVENT_PRESSED, this); + + // a button in the top right which clears the circles + clear_button_ = lv_btn_create(lv_screen_active()); + lv_obj_set_size(clear_button_, 50, 50); + lv_obj_align(clear_button_, LV_ALIGN_TOP_RIGHT, 0, 0); + lv_obj_t *clear_label = lv_label_create(clear_button_); + lv_label_set_text(clear_label, LV_SYMBOL_TRASH); + lv_obj_align(clear_label, LV_ALIGN_CENTER, 0, 0); + lv_obj_add_event_cb(clear_button_, event_callback, LV_EVENT_PRESSED, this); +} + +void Gui::init_circle_layer() { + auto &bsp = Bsp::get(); + circle_layer_ = lv_obj_create(lv_screen_active()); + lv_obj_remove_style_all(circle_layer_); + lv_obj_set_size(circle_layer_, bsp.lcd_width(), bsp.lcd_height()); + lv_obj_align(circle_layer_, LV_ALIGN_CENTER, 0, 0); + lv_obj_clear_flag(circle_layer_, LV_OBJ_FLAG_CLICKABLE); + lv_obj_clear_flag(circle_layer_, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_bg_opa(circle_layer_, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(circle_layer_, 0, 0); + lv_obj_set_style_outline_width(circle_layer_, 0, 0); + lv_obj_set_style_shadow_width(circle_layer_, 0, 0); + lv_obj_add_event_cb(circle_layer_, draw_circle_layer, LV_EVENT_DRAW_MAIN, this); + lv_obj_move_foreground(circle_layer_); +} + +bool Gui::update(std::mutex &m, std::condition_variable &cv) { + { + std::lock_guard lock(mutex_); + lv_task_handler(); + } + std::unique_lock lock(m); + cv.wait_for(lock, std::chrono::milliseconds(16)); + return false; // don't stop the task +} + +void Gui::event_callback(lv_event_t *e) { + auto *gui = static_cast(lv_event_get_user_data(e)); + if (!gui) { + return; + } + switch (lv_event_get_code(e)) { + case LV_EVENT_PRESSED: + gui->on_pressed(e); + break; + default: + break; + } +} + +void Gui::on_pressed(lv_event_t *e) { + const auto *target = static_cast(lv_event_get_target(e)); + if (target == rotate_button_) { + logger_.info("Rotate button pressed"); + next_rotation(); + } else if (target == clear_button_) { + logger_.info("Clear button pressed"); + clear_circles(); + } +} + +void Gui::set_label_text(std::string_view text) { + std::lock_guard lock(mutex_); + lv_label_set_text(label_, std::string(text).c_str()); +} + +void Gui::set_clock_text(std::string_view text) { + std::lock_guard lock(mutex_); + lv_label_set_text(clock_label_, std::string(text).c_str()); +} + +void Gui::next_rotation() { + std::lock_guard lock(mutex_); + clear_circles_impl(); + auto rotation = lv_display_get_rotation(lv_display_get_default()); + rotation = static_cast((static_cast(rotation) + 1) % 4); + lv_display_set_rotation(lv_display_get_default(), rotation); + update_layout(); +} + +void Gui::update_layout() { + auto &bsp = Bsp::get(); + int width = bsp.rotated_display_width(); + int height = bsp.rotated_display_height(); + // update the size of the screen-filling objects and re-align the rest + lv_obj_set_size(background_, width, height); + lv_obj_set_width(label_, width); + lv_obj_align(label_, LV_ALIGN_TOP_LEFT, 0, 0); + lv_obj_align(clock_label_, LV_ALIGN_TOP_MID, 0, 20); + lv_obj_align(rotate_button_, LV_ALIGN_TOP_LEFT, 0, 0); + lv_obj_align(clear_button_, LV_ALIGN_TOP_RIGHT, 0, 0); + lv_obj_set_size(circle_layer_, width, height); + lv_obj_align(circle_layer_, LV_ALIGN_CENTER, 0, 0); + lv_obj_move_foreground(circle_layer_); + lv_obj_invalidate(circle_layer_); +} + +void Gui::set_kalman_down(float vx, float vy) { + std::lock_guard lock(mutex_); + set_down_line(kalman_line_, kalman_line_points_, vx, vy); +} + +void Gui::set_madgwick_down(float vx, float vy) { + std::lock_guard lock(mutex_); + set_down_line(madgwick_line_, madgwick_line_points_, vx, vy); +} + +void Gui::set_down_line(lv_obj_t *line, lv_point_precise_t *points, float vx, float vy) { + auto &bsp = Bsp::get(); + // remap the vector according to the current display rotation so that the + // line always points toward physical "down" + auto rotation = lv_display_get_rotation(lv_display_get_default()); + if (rotation == LV_DISPLAY_ROTATION_90) { + std::swap(vx, vy); + vx = -vx; + } else if (rotation == LV_DISPLAY_ROTATION_180) { + vx = -vx; + vy = -vy; + } else if (rotation == LV_DISPLAY_ROTATION_270) { + std::swap(vx, vy); + vy = -vy; + } + // draw the line from the center of the screen toward "down" + int x0 = bsp.rotated_display_width() / 2; + int y0 = bsp.rotated_display_height() / 2; + points[0] = {static_cast(x0), static_cast(y0)}; + points[1] = {static_cast(x0 + 50 * vx), + static_cast(y0 + 50 * vy)}; + lv_line_set_points(line, points, 2); +} + +void Gui::draw_circle(int x, int y, int radius) { + std::lock_guard lock(mutex_); + lv_obj_move_foreground(circle_layer_); + Circle previous_circle = circles_[next_circle_index_]; + circles_[next_circle_index_] = {.x = x, .y = y, .radius = radius, .visible = true}; + next_circle_index_ = (next_circle_index_ + 1) % circles_.size(); + if (visible_circle_count_ < circles_.size()) { + visible_circle_count_++; + } + if (previous_circle.visible) { + invalidate_circle_area(previous_circle); + } + invalidate_circle_area(circles_[(next_circle_index_ + circles_.size() - 1) % circles_.size()]); +} + +void Gui::clear_circles() { + std::lock_guard lock(mutex_); + clear_circles_impl(); +} + +void Gui::clear_circles_impl() { + for (auto &circle : circles_) { + if (circle.visible) { + invalidate_circle_area(circle); + } + circle.visible = false; + } + next_circle_index_ = 0; + visible_circle_count_ = 0; +} + +void Gui::invalidate_circle_area(const Circle &circle) { + if (!circle_layer_ || circle.radius <= 0) { + return; + } + lv_area_t obj_coords; + lv_obj_get_coords(circle_layer_, &obj_coords); + lv_area_t coords = { + .x1 = static_cast(obj_coords.x1 + circle.x - circle.radius), + .y1 = static_cast(obj_coords.y1 + circle.y - circle.radius), + .x2 = static_cast(obj_coords.x1 + circle.x + circle.radius - 1), + .y2 = static_cast(obj_coords.y1 + circle.y + circle.radius - 1), + }; + lv_obj_invalidate_area(circle_layer_, &coords); +} + +void Gui::draw_circle_layer(lv_event_t *e) { + const auto *gui = static_cast(lv_event_get_user_data(e)); + if (!gui) { + return; + } + gui->draw_circles(e); +} + +void Gui::draw_circles(lv_event_t *e) const { + if (visible_circle_count_ == 0) { + return; + } + + auto *obj = static_cast(lv_event_get_current_target(e)); + auto *layer = lv_event_get_layer(e); + lv_area_t obj_coords; + lv_obj_get_coords(obj, &obj_coords); + + lv_draw_rect_dsc_t rect_dsc; + lv_draw_rect_dsc_init(&rect_dsc); + rect_dsc.base.layer = layer; + rect_dsc.radius = LV_RADIUS_CIRCLE; + rect_dsc.bg_opa = LV_OPA_70; + rect_dsc.bg_color = lv_color_make(0, 255, 255); + rect_dsc.border_width = 0; + rect_dsc.outline_width = 0; + rect_dsc.shadow_width = 0; + + for (const auto &circle : circles_) { + if (!circle.visible) { + continue; + } + lv_area_t coords = { + .x1 = static_cast(obj_coords.x1 + circle.x - circle.radius), + .y1 = static_cast(obj_coords.y1 + circle.y - circle.radius), + .x2 = static_cast(obj_coords.x1 + circle.x + circle.radius - 1), + .y2 = static_cast(obj_coords.y1 + circle.y + circle.radius - 1), + }; + lv_draw_rect(layer, &rect_dsc, &coords); + } +} diff --git a/components/ws-s3-touch/example/main/gui.hpp b/components/ws-s3-touch/example/main/gui.hpp new file mode 100644 index 000000000..1389e84e4 --- /dev/null +++ b/components/ws-s3-touch/example/main/gui.hpp @@ -0,0 +1,158 @@ +#pragma once + +#include +#include +#include +#include + +#include "ws-s3-touch.hpp" + +#include "logger.hpp" +#include "task.hpp" + +/// The Gui class encapsulates all of the LVGL UI for this example. It follows +/// the recommended pattern for building UIs with espp + LVGL in C++: +/// +/// * All LVGL objects are created in init_ui(), which is broken into small +/// member functions - one per logical piece of the UI. +/// * The class owns the task which calls lv_task_handler(), and a recursive +/// mutex which guards every LVGL call. Public methods lock that mutex, so +/// other tasks (touch callbacks, sensor tasks, etc.) can safely call them. +/// * LVGL event callbacks are registered with `this` as the user-data and +/// dispatched through a single static trampoline (event_callback) into +/// member functions, keeping all UI logic inside the class. +/// +/// For this example the Gui shows: +/// * A label with instructions and live IMU data +/// * A label at the top of the screen showing the current RTC time +/// * Two lines showing the direction of gravity ("down") as computed by a +/// Kalman filter (blue) and a Madgwick filter (red) +/// * A button (top-left) which rotates the display through 0/90/180/270 +/// * A button (top-right) which clears the circles drawn on the screen +/// * A custom-drawn layer of circles which trail the most recent touches +class Gui { +public: + /// Configuration for the Gui + struct Config { + espp::Logger::Verbosity log_level{espp::Logger::Verbosity::WARN}; ///< Log verbosity + }; + + /// Construct the Gui: builds the UI and starts the LVGL update task. + /// @param config The configuration for the Gui + explicit Gui(const Config &config) + : logger_({.tag = "Gui", .level = config.log_level}) { + init_ui(); + update_task_.start(); + } + + ~Gui() { + update_task_.stop(); + deinit_ui(); + } + + /// Set the text of the main label. Thread-safe. + /// @param text The text to display + void set_label_text(std::string_view text); + + /// Set the text of the clock label at the top of the screen. Thread-safe. + /// @param text The text to display + void set_clock_text(std::string_view text); + + /// Draw a circle at the given screen coordinates, replacing the oldest + /// circle if the maximum number are already visible. Thread-safe. + /// @param x The x coordinate (screen space) + /// @param y The y coordinate (screen space) + /// @param radius The radius of the circle + void draw_circle(int x, int y, int radius); + + /// Clear all circles from the screen. Thread-safe. + void clear_circles(); + + /// Rotate the display to the next of 0/90/180/270 degrees, resizing / + /// re-aligning the UI to match. Thread-safe. + void next_rotation(); + + /// Update the line showing "down" according to the Kalman filter. The + /// vector is provided in the display's natural (unrotated) frame; the Gui + /// accounts for the current display rotation. Thread-safe. + /// @param vx The x component of the gravity vector + /// @param vy The y component of the gravity vector + void set_kalman_down(float vx, float vy); + + /// Update the line showing "down" according to the Madgwick filter. The + /// vector is provided in the display's natural (unrotated) frame; the Gui + /// accounts for the current display rotation. Thread-safe. + /// @param vx The x component of the gravity vector + /// @param vy The y component of the gravity vector + void set_madgwick_down(float vx, float vy); + +protected: + using Bsp = espp::WsS3Touch; + + static constexpr size_t MAX_CIRCLES = 100; + + struct Circle { + int x{0}; + int y{0}; + int radius{0}; + bool visible{false}; + }; + + void init_ui(); + void deinit_ui(); + + // the individual pieces of the UI, called from init_ui() + void init_background(); + void init_label(); + void init_clock_label(); + void init_gravity_lines(); + void init_buttons(); + void init_circle_layer(); + + // resize / re-align the UI to match the current display rotation + void update_layout(); + + // update the given "down" line from a vector in the unrotated display + // frame, remapping for the current display rotation + void set_down_line(lv_obj_t *line, lv_point_precise_t *points, float vx, float vy); + + // the LVGL update task: calls lv_task_handler() under the mutex + bool update(std::mutex &m, std::condition_variable &cv); + + // single trampoline for all LVGL events; dispatches to the member + // functions below based on the event target + static void event_callback(lv_event_t *e); + void on_pressed(lv_event_t *e); + + // custom drawing of the circle layer + static void draw_circle_layer(lv_event_t *e); + void draw_circles(lv_event_t *e) const; + void invalidate_circle_area(const Circle &circle); + + // unlocked implementations, called with the mutex held + void clear_circles_impl(); + + // LVGL objects + lv_obj_t *background_{nullptr}; + lv_obj_t *label_{nullptr}; + lv_obj_t *clock_label_{nullptr}; + lv_obj_t *kalman_line_{nullptr}; + lv_obj_t *madgwick_line_{nullptr}; + lv_obj_t *rotate_button_{nullptr}; + lv_obj_t *clear_button_{nullptr}; + lv_obj_t *circle_layer_{nullptr}; + + lv_style_t kalman_line_style_; + lv_style_t madgwick_line_style_; + lv_point_precise_t kalman_line_points_[2]; + lv_point_precise_t madgwick_line_points_[2]; + + std::array circles_; + size_t next_circle_index_{0}; + size_t visible_circle_count_{0}; + + espp::Task update_task_{{.callback = [this](auto &m, auto &cv) { return update(m, cv); }, + .task_config = {.name = "gui", .stack_size_bytes = 6 * 1024}}}; + espp::Logger logger_; + std::recursive_mutex mutex_; +}; diff --git a/components/ws-s3-touch/example/main/ws_s3_touch_example.cpp b/components/ws-s3-touch/example/main/ws_s3_touch_example.cpp index 98e7ae4e2..1045759d6 100644 --- a/components/ws-s3-touch/example/main/ws_s3_touch_example.cpp +++ b/components/ws-s3-touch/example/main/ws_s3_touch_example.cpp @@ -1,34 +1,16 @@ -#include #include +#include #include -#include +#include #include "ws-s3-touch.hpp" +#include "gui.hpp" #include "kalman_filter.hpp" #include "madgwick_filter.hpp" using namespace std::chrono_literals; -static constexpr size_t MAX_CIRCLES = 100; -struct Circle { - int x{0}; - int y{0}; - int radius{0}; - bool visible{false}; -}; -static std::array circles; -static size_t next_circle_index = 0; -static size_t visible_circle_count = 0; -static lv_obj_t *circle_layer = nullptr; - -static std::recursive_mutex lvgl_mutex; -static bool initialize_circle_layer(int width, int height); -static void draw_circle_layer(lv_event_t *event); -static void invalidate_circle_area(const Circle &circle); -static void draw_circle(int x0, int y0, int radius); -static void clear_circles(); - extern "C" void app_main(void) { espp::Logger logger( {.tag = "Waveshare S3 Touch Example", .level = espp::Logger::Verbosity::INFO}); @@ -39,65 +21,6 @@ extern "C" void app_main(void) { auto &bsp = Bsp::get(); bsp.set_log_level(espp::Logger::Verbosity::INFO); - auto touch_callback = [&](const auto &touch) { - // NOTE: since we're directly using the touchpad data, and not using the - // TouchpadInput + LVGL, we'll need to ensure the touchpad data is - // converted into proper screen coordinates instead of simply using the - // raw values. - static auto previous_touchpad_data = bsp.touchpad_convert(touch); - auto touchpad_data = bsp.touchpad_convert(touch); - if (touchpad_data != previous_touchpad_data) { - logger.info("Touch: {}", touchpad_data); - previous_touchpad_data = touchpad_data; - // if the button is pressed, clear the circles - if (touchpad_data.btn_state) { - std::lock_guard lock(lvgl_mutex); - clear_circles(); - } - // if there is a touch point, draw a circle and play a click sound - if (touchpad_data.num_touch_points > 0) { - // set the PWM / frequency for the buzzer based on the touch point (x -> pwm, y -> - // frequency) - float pwm = - touchpad_data.x / static_cast(bsp.lcd_width()) * 100.0f; // scale to 0-100% - // scale frequency to be in range [50 Hz, 10 KHz] - static constexpr float min_frequency_hz = 50.0f; - static constexpr float max_frequency_hz = 10000.0f; - // make it a logarithmic scale so that the frequency is more sensitive to - // the lower end of the touchpad - float frequency_hz = - min_frequency_hz * std::pow(max_frequency_hz / min_frequency_hz, - touchpad_data.y / static_cast(bsp.lcd_height())); - bsp.buzzer(pwm, frequency_hz); - std::lock_guard lock(lvgl_mutex); - draw_circle(touchpad_data.x, touchpad_data.y, 10); - } else { - // if there are no touch points, stop the buzzer - bsp.buzzer(0.0f); - } - } - }; - - // initialize the button, clear the circles on the screen - logger.info("Initializing the button"); - auto on_button_pressed = [&](const auto &event) { - if (event.active) { - logger.info("Button pressed"); - std::lock_guard lock(lvgl_mutex); - clear_circles(); - // play a click sound - bsp.buzzer(50.0f, 1000.0f); // 50% duty cycle, 1 kHz frequency - } else { - logger.info("Button released"); - // stop the buzzer - bsp.buzzer(0.0f); // stop the buzzer - } - }; - if (!bsp.initialize_button(on_button_pressed)) { - logger.error("Failed to initialize button!"); - return; - } - // initialize the buzzer if (!bsp.initialize_buzzer()) { logger.error("Failed to initialize buzzer!"); @@ -188,129 +111,84 @@ extern "C" void app_main(void) { logger.info("Initialization complete, starting LVGL!"); - // set the background color to black - lv_obj_t *bg = lv_obj_create(lv_screen_active()); - lv_obj_set_size(bg, bsp.rotated_display_width(), bsp.rotated_display_height()); - lv_obj_set_style_bg_color(bg, lv_color_make(0, 0, 0), 0); - if (!initialize_circle_layer(bsp.rotated_display_width(), bsp.rotated_display_height())) { - logger.error("Failed to initialize circle layer!"); - return; - } - - // add text in the center of the screen - lv_obj_t *label = lv_label_create(lv_screen_active()); - static std::string label_text = - "\n\n\n\nTouch the screen!\nPress the home button to clear circles."; - lv_label_set_text(label, label_text.c_str()); - lv_label_set_long_mode(label, LV_LABEL_LONG_WRAP); - lv_obj_set_width(label, bsp.rotated_display_width()); - lv_obj_align(label, LV_ALIGN_TOP_LEFT, 0, 0); - lv_obj_set_style_text_align(label, LV_TEXT_ALIGN_LEFT, 0); - - /*Create style*/ - static lv_style_t style_line0; - lv_style_init(&style_line0); - lv_style_set_line_width(&style_line0, 8); - lv_style_set_line_color(&style_line0, lv_palette_main(LV_PALETTE_BLUE)); - lv_style_set_line_rounded(&style_line0, true); - - // make a line for showing the direction of "down" - lv_obj_t *line0 = lv_line_create(lv_screen_active()); - static lv_point_precise_t line_points0[] = {{0, 0}, {bsp.lcd_width(), bsp.lcd_height()}}; - lv_line_set_points(line0, line_points0, 2); - lv_obj_add_style(line0, &style_line0, 0); - - /*Create style*/ - static lv_style_t style_line1; - lv_style_init(&style_line1); - lv_style_set_line_width(&style_line1, 8); - lv_style_set_line_color(&style_line1, lv_palette_main(LV_PALETTE_RED)); - lv_style_set_line_rounded(&style_line1, true); - - // make a line for showing the direction of "down" - lv_obj_t *line1 = lv_line_create(lv_screen_active()); - static lv_point_precise_t line_points1[] = {{0, 0}, {bsp.lcd_width(), bsp.lcd_height()}}; - lv_line_set_points(line1, line_points1, 2); - lv_obj_add_style(line1, &style_line1, 0); - - // make a label centered at the very top for the RTC time - lv_obj_t *rtc_label = lv_label_create(lv_screen_active()); - lv_label_set_text(rtc_label, ""); - lv_obj_align(rtc_label, LV_ALIGN_TOP_MID, 0, 20); // add an offset so that it's always visible - lv_obj_set_style_text_align(rtc_label, LV_TEXT_ALIGN_LEFT, 0); - - // add a button in the top left which (when pressed) will rotate the display - // through 0, 90, 180, 270 degrees - lv_obj_t *btn = lv_btn_create(lv_screen_active()); - lv_obj_set_size(btn, 50, 50); - lv_obj_align(btn, LV_ALIGN_TOP_LEFT, 0, 0); - lv_obj_t *label_btn = lv_label_create(btn); - lv_label_set_text(label_btn, LV_SYMBOL_REFRESH); - // center the text in the button - lv_obj_align(label_btn, LV_ALIGN_CENTER, 0, 0); - static auto update_layout = [&]() { - int width = bsp.rotated_display_width(); - int height = bsp.rotated_display_height(); - lv_obj_set_size(bg, width, height); - lv_obj_set_width(label, width); - lv_obj_align(label, LV_ALIGN_TOP_LEFT, 0, 0); - lv_obj_align(rtc_label, LV_ALIGN_TOP_MID, 0, 20); - lv_obj_align(btn, LV_ALIGN_TOP_LEFT, 0, 0); - if (circle_layer) { - lv_obj_set_size(circle_layer, width, height); - lv_obj_align(circle_layer, LV_ALIGN_CENTER, 0, 0); - lv_obj_move_foreground(circle_layer); - lv_obj_invalidate(circle_layer); + // create the GUI: builds the UI (labels, buttons, gravity lines, circle + // layer) and starts the task which updates LVGL. All of its public methods + // are thread-safe, so the touch callback and the RTC / IMU tasks below can + // call them directly. + static Gui gui({.log_level = espp::Logger::Verbosity::INFO}); + static const std::string instructions = + fmt::format("\n\n\n\nTouch the screen!\nPress the boot button or the {} button to clear " + "circles.\nPress the {} button to rotate the display.", + LV_SYMBOL_TRASH, LV_SYMBOL_REFRESH); + gui.set_label_text(instructions); + + // initialize the touchpad; each touch draws a circle (and drives the + // buzzer), while the touchpad's button clears the circles + auto touch_callback = [&](const auto &touch) { + // NOTE: since we're directly using the touchpad data, and not using the + // TouchpadInput + LVGL, we'll need to ensure the touchpad data is + // converted into proper screen coordinates instead of simply using the + // raw values. + static auto previous_touchpad_data = bsp.touchpad_convert(touch); + auto touchpad_data = bsp.touchpad_convert(touch); + if (touchpad_data != previous_touchpad_data) { + logger.info("Touch: {}", touchpad_data); + previous_touchpad_data = touchpad_data; + // if the button is pressed, clear the circles + if (touchpad_data.btn_state) { + gui.clear_circles(); + } + // if there is a touch point, draw a circle and play a click sound + if (touchpad_data.num_touch_points > 0) { + // set the PWM / frequency for the buzzer based on the touch point (x -> pwm, y -> + // frequency) + float pwm = + touchpad_data.x / static_cast(bsp.lcd_width()) * 100.0f; // scale to 0-100% + // scale frequency to be in range [50 Hz, 10 KHz] + static constexpr float min_frequency_hz = 50.0f; + static constexpr float max_frequency_hz = 10000.0f; + // make it a logarithmic scale so that the frequency is more sensitive to + // the lower end of the touchpad + float frequency_hz = + min_frequency_hz * std::pow(max_frequency_hz / min_frequency_hz, + touchpad_data.y / static_cast(bsp.lcd_height())); + bsp.buzzer(pwm, frequency_hz); + gui.draw_circle(touchpad_data.x, touchpad_data.y, 10); + } else { + // if there are no touch points, stop the buzzer + bsp.buzzer(0.0f); + } } }; - static auto rotate_display = [&]() { - std::lock_guard lock(lvgl_mutex); - clear_circles(); - static auto rotation = LV_DISPLAY_ROTATION_0; - rotation = static_cast((static_cast(rotation) + 1) % 4); - lv_display_t *disp = lv_display_get_default(); - lv_disp_set_rotation(disp, rotation); - update_layout(); - }; - lv_obj_add_event_cb( - btn, [](auto event) { rotate_display(); }, LV_EVENT_PRESSED, nullptr); - update_layout(); - - // disable scrolling on the screen (so that it doesn't behave weirdly when - // rotated and drawing with your finger) - lv_obj_set_scrollbar_mode(lv_screen_active(), LV_SCROLLBAR_MODE_OFF); - lv_obj_clear_flag(lv_screen_active(), LV_OBJ_FLAG_SCROLLABLE); - lv_obj_move_foreground(circle_layer); - - // initialize the touchpad after the circle layer exists so touch events can - // update it immediately. if (!bsp.initialize_touch(touch_callback)) { logger.error("Failed to initialize touchpad!"); return; } - logger.info("Starting LVGL task handler!"); - - // start a simple thread to do the lv_task_handler every 16ms - espp::Task lv_task({.callback = [](std::mutex &m, std::condition_variable &cv) -> bool { - { - std::lock_guard lock(lvgl_mutex); - lv_task_handler(); - } - std::unique_lock lock(m); - cv.wait_for(lock, 16ms); - return false; - }, - .task_config = { - .name = "lv_task", - .stack_size_bytes = 6 * 1024, - }}); - lv_task.start(); + // initialize the button; while pressed, clear the circles on the screen and + // play a click sound + logger.info("Initializing the button"); + auto on_button_pressed = [&](const auto &event) { + if (event.active) { + logger.info("Button pressed"); + gui.clear_circles(); + // play a click sound + bsp.buzzer(50.0f, 1000.0f); // 50% duty cycle, 1 kHz frequency + } else { + logger.info("Button released"); + // stop the buzzer + bsp.buzzer(0.0f); // stop the buzzer + } + }; + if (!bsp.initialize_button(on_button_pressed)) { + logger.error("Failed to initialize button!"); + return; + } // set the display brightness to be 75% bsp.brightness(75.0f); - // make a task to read the rtc and print it to console + // make a task to read the RTC and update the clock label with it espp::Task rtc_task({.callback = [&](std::mutex &m, std::condition_variable &cv) -> bool { auto start = std::chrono::steady_clock::now(); static auto &bsp = Bsp::get(); @@ -320,12 +198,11 @@ extern "C" void app_main(void) { if (ec) { logger.error("Failed to get RTC time: {}", ec.message()); } else { - // update the label with the current time - std::lock_guard lock(lvgl_mutex); - lv_label_set_text_fmt(rtc_label, "%02d:%02d:%02d - %02d/%02d/%04d", - timeinfo.tm_hour, timeinfo.tm_min, timeinfo.tm_sec, - timeinfo.tm_mday, timeinfo.tm_mon + 1, - timeinfo.tm_year + 1900); + // update the clock label with the current time + gui.set_clock_text(fmt::format( + "{:02d}:{:02d}:{:02d} - {:02d}/{:02d}/{:04d}", timeinfo.tm_hour, + timeinfo.tm_min, timeinfo.tm_sec, timeinfo.tm_mday, + timeinfo.tm_mon + 1, timeinfo.tm_year + 1900)); } std::unique_lock lock(m); cv.wait_until(lock, start + 1s); @@ -337,7 +214,7 @@ extern "C" void app_main(void) { }}); rtc_task.start(); - // make a task to read out the IMU data and print it to console + // make a task to read out the IMU data and update the GUI with it espp::Task imu_task( {.callback = [&](std::mutex &m, std::condition_variable &cv) -> bool { // sleep first in case we don't get IMU data and need to exit early @@ -366,26 +243,12 @@ extern "C" void app_main(void) { auto orientation = imu->get_orientation(); auto gravity_vector = imu->get_gravity_vector(); - // NOTE: because of the moutning of the IMU w.r.t the mounting of the + // NOTE: because of the mounting of the IMU w.r.t the mounting of the // screen we have to rotate the axes. std::swap(gravity_vector.x, gravity_vector.y); gravity_vector.y = -gravity_vector.y; - // now update the gravity vector line to show the direction of "down" - // taking into account the configured rotation of the display - auto rotation = lv_display_get_rotation(lv_display_get_default()); - if (rotation == LV_DISPLAY_ROTATION_90) { - std::swap(gravity_vector.x, gravity_vector.y); - gravity_vector.x = -gravity_vector.x; - } else if (rotation == LV_DISPLAY_ROTATION_180) { - gravity_vector.x = -gravity_vector.x; - gravity_vector.y = -gravity_vector.y; - } else if (rotation == LV_DISPLAY_ROTATION_270) { - std::swap(gravity_vector.x, gravity_vector.y); - gravity_vector.y = -gravity_vector.y; - } - - std::string text = fmt::format("{}\n\n\n\n\n", label_text); + std::string text = fmt::format("{}\n\n\n\n\n", instructions); text += fmt::format("Accel: {:02.2f} {:02.2f} {:02.2f}\n", accel.x, accel.y, accel.z); text += fmt::format("Gyro: {:03.2f} {:03.2f} {:03.2f}\n", espp::deg_to_rad(gyro.x), espp::deg_to_rad(gyro.y), espp::deg_to_rad(gyro.z)); @@ -393,60 +256,23 @@ extern "C" void app_main(void) { espp::rad_to_deg(orientation.pitch)); text += fmt::format("Temp: {:02.1f} C\n", temp); - // use the pitch to to draw a line on the screen indiating the - // direction from the center of the screen to "down" - int x0 = bsp.rotated_display_width() / 2; - int y0 = bsp.rotated_display_height() / 2; - - int x1 = x0 + 50 * gravity_vector.x; - int y1 = y0 + 50 * gravity_vector.y; - - static lv_point_precise_t line_points0[] = {{x0, y0}, {x1, y1}}; - line_points0[0].x = x0; - line_points0[0].y = y0; - line_points0[1].x = x1; - line_points0[1].y = y1; - - // Now show the madgwick filter + // Now show the madgwick filter's estimate of "down" auto madgwick_orientation = madgwick_filter_fn(dt, accel, gyro); float roll = madgwick_orientation.roll; float pitch = madgwick_orientation.pitch; - [[maybe_unused]] float yaw = madgwick_orientation.yaw; float vx = sin(pitch); float vy = -cos(pitch) * sin(roll); - [[maybe_unused]] float vz = -cos(pitch) * cos(roll); - // NOTE: because of the moutning of the IMU w.r.t the mounting of the + // NOTE: because of the mounting of the IMU w.r.t the mounting of the // screen we have to rotate the axes. std::swap(vx, vy); vy = -vy; - // now update the line to show the direction of "down" based on the - // configured rotation of the display - if (rotation == LV_DISPLAY_ROTATION_90) { - std::swap(vx, vy); - vx = -vx; - } else if (rotation == LV_DISPLAY_ROTATION_180) { - vx = -vx; - vy = -vy; - } else if (rotation == LV_DISPLAY_ROTATION_270) { - std::swap(vx, vy); - vy = -vy; - } - - x1 = x0 + 50 * vx; - y1 = y0 + 50 * vy; - - static lv_point_precise_t line_points1[] = {{x0, y0}, {x1, y1}}; - line_points1[0].x = x0; - line_points1[0].y = y0; - line_points1[1].x = x1; - line_points1[1].y = y1; - - std::lock_guard lock(lvgl_mutex); - lv_label_set_text(label, text.c_str()); - lv_line_set_points(line0, line_points0, 2); - lv_line_set_points(line1, line_points1, 2); + // update the GUI with the new data; the Gui handles remapping the + // vectors for the current display rotation + gui.set_label_text(text); + gui.set_kalman_down(gravity_vector.x, gravity_vector.y); + gui.set_madgwick_down(vx, vy); return false; }, @@ -466,101 +292,3 @@ extern "C" void app_main(void) { } //! [ws-s3-touch example] } - -static bool initialize_circle_layer(int width, int height) { - if (circle_layer) { - return true; - } - circle_layer = lv_obj_create(lv_screen_active()); - if (!circle_layer) { - return false; - } - lv_obj_remove_style_all(circle_layer); - lv_obj_set_size(circle_layer, width, height); - lv_obj_align(circle_layer, LV_ALIGN_CENTER, 0, 0); - lv_obj_clear_flag(circle_layer, LV_OBJ_FLAG_CLICKABLE); - lv_obj_clear_flag(circle_layer, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_set_style_bg_opa(circle_layer, LV_OPA_TRANSP, 0); - lv_obj_set_style_border_width(circle_layer, 0, 0); - lv_obj_set_style_outline_width(circle_layer, 0, 0); - lv_obj_set_style_shadow_width(circle_layer, 0, 0); - lv_obj_add_event_cb(circle_layer, draw_circle_layer, LV_EVENT_DRAW_MAIN, nullptr); - return true; -} - -static void draw_circle_layer(lv_event_t *event) { - if (visible_circle_count == 0) { - return; - } - auto *obj = static_cast(lv_event_get_current_target(event)); - auto *layer = lv_event_get_layer(event); - lv_area_t obj_coords; - lv_obj_get_coords(obj, &obj_coords); - - lv_draw_rect_dsc_t rect_dsc; - lv_draw_rect_dsc_init(&rect_dsc); - rect_dsc.base.layer = layer; - rect_dsc.radius = LV_RADIUS_CIRCLE; - rect_dsc.bg_opa = LV_OPA_70; - rect_dsc.bg_color = lv_color_make(0, 255, 255); - rect_dsc.border_width = 0; - rect_dsc.outline_width = 0; - rect_dsc.shadow_width = 0; - - for (const auto &circle : circles) { - if (!circle.visible) { - continue; - } - lv_area_t coords = { - .x1 = static_cast(obj_coords.x1 + circle.x - circle.radius), - .y1 = static_cast(obj_coords.y1 + circle.y - circle.radius), - .x2 = static_cast(obj_coords.x1 + circle.x + circle.radius - 1), - .y2 = static_cast(obj_coords.y1 + circle.y + circle.radius - 1), - }; - lv_draw_rect(layer, &rect_dsc, &coords); - } -} - -static void invalidate_circle_area(const Circle &circle) { - if (!circle_layer || circle.radius <= 0) { - return; - } - - lv_area_t obj_coords; - lv_obj_get_coords(circle_layer, &obj_coords); - lv_area_t coords = { - .x1 = static_cast(obj_coords.x1 + circle.x - circle.radius), - .y1 = static_cast(obj_coords.y1 + circle.y - circle.radius), - .x2 = static_cast(obj_coords.x1 + circle.x + circle.radius - 1), - .y2 = static_cast(obj_coords.y1 + circle.y + circle.radius - 1), - }; - lv_obj_invalidate_area(circle_layer, &coords); -} - -static void draw_circle(int x0, int y0, int radius) { - if (!circle_layer) { - return; - } - lv_obj_move_foreground(circle_layer); - Circle previous_circle = circles[next_circle_index]; - circles[next_circle_index] = {.x = x0, .y = y0, .radius = radius, .visible = true}; - next_circle_index = (next_circle_index + 1) % circles.size(); - if (visible_circle_count < circles.size()) { - visible_circle_count++; - } - if (previous_circle.visible) { - invalidate_circle_area(previous_circle); - } - invalidate_circle_area(circles[(next_circle_index + circles.size() - 1) % circles.size()]); -} - -static void clear_circles() { - for (auto &circle : circles) { - if (circle.visible) { - invalidate_circle_area(circle); - } - circle.visible = false; - } - next_circle_index = 0; - visible_circle_count = 0; -}