mirror of
				https://github.com/esphome/esphome.git
				synced 2025-10-30 06:33:51 +00:00 
			
		
		
		
	Add graphical display menu (#4105)
Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Co-authored-by: Alex Hermann <gaaf@gmx.net>
This commit is contained in:
		
							
								
								
									
										96
									
								
								esphome/components/graphical_display_menu/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										96
									
								
								esphome/components/graphical_display_menu/__init__.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,96 @@ | ||||
| import esphome.codegen as cg | ||||
| import esphome.config_validation as cv | ||||
| from esphome.components import display, font, color | ||||
| from esphome.const import CONF_ID, CONF_TRIGGER_ID | ||||
| from esphome import automation, core | ||||
|  | ||||
| from esphome.components.display_menu_base import ( | ||||
|     DISPLAY_MENU_BASE_SCHEMA, | ||||
|     DisplayMenuComponent, | ||||
|     display_menu_to_code, | ||||
| ) | ||||
|  | ||||
| CONF_DISPLAY = "display" | ||||
| CONF_FONT = "font" | ||||
| CONF_MENU_ITEM_VALUE = "menu_item_value" | ||||
| CONF_FOREGROUND_COLOR = "foreground_color" | ||||
| CONF_BACKGROUND_COLOR = "background_color" | ||||
| CONF_ON_REDRAW = "on_redraw" | ||||
|  | ||||
| graphical_display_menu_ns = cg.esphome_ns.namespace("graphical_display_menu") | ||||
| GraphicalDisplayMenu = graphical_display_menu_ns.class_( | ||||
|     "GraphicalDisplayMenu", DisplayMenuComponent | ||||
| ) | ||||
| GraphicalDisplayMenuConstPtr = GraphicalDisplayMenu.operator("ptr").operator("const") | ||||
| MenuItemValueArguments = graphical_display_menu_ns.struct("MenuItemValueArguments") | ||||
| MenuItemValueArgumentsConstPtr = MenuItemValueArguments.operator("ptr").operator( | ||||
|     "const" | ||||
| ) | ||||
| GraphicalDisplayMenuOnRedrawTrigger = graphical_display_menu_ns.class_( | ||||
|     "GraphicalDisplayMenuOnRedrawTrigger", automation.Trigger | ||||
| ) | ||||
|  | ||||
| CODEOWNERS = ["@MrMDavidson"] | ||||
|  | ||||
| AUTO_LOAD = ["display_menu_base"] | ||||
|  | ||||
| CONFIG_SCHEMA = DISPLAY_MENU_BASE_SCHEMA.extend( | ||||
|     cv.Schema( | ||||
|         { | ||||
|             cv.GenerateID(): cv.declare_id(GraphicalDisplayMenu), | ||||
|             cv.Optional(CONF_DISPLAY): cv.use_id(display.DisplayBuffer), | ||||
|             cv.Required(CONF_FONT): cv.use_id(font.Font), | ||||
|             cv.Optional(CONF_MENU_ITEM_VALUE): cv.templatable(cv.string), | ||||
|             cv.Optional(CONF_FOREGROUND_COLOR): cv.use_id(color.ColorStruct), | ||||
|             cv.Optional(CONF_BACKGROUND_COLOR): cv.use_id(color.ColorStruct), | ||||
|             cv.Optional(CONF_ON_REDRAW): automation.validate_automation( | ||||
|                 { | ||||
|                     cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( | ||||
|                         GraphicalDisplayMenuOnRedrawTrigger | ||||
|                     ) | ||||
|                 } | ||||
|             ), | ||||
|         } | ||||
|     ) | ||||
| ) | ||||
|  | ||||
|  | ||||
| async def to_code(config): | ||||
|     var = cg.new_Pvariable(config[CONF_ID]) | ||||
|     await cg.register_component(var, config) | ||||
|  | ||||
|     if display_config := config.get(CONF_DISPLAY): | ||||
|         drawing_display = await cg.get_variable(display_config) | ||||
|         cg.add(var.set_display(drawing_display)) | ||||
|  | ||||
|     menu_font = await cg.get_variable(config[CONF_FONT]) | ||||
|     cg.add(var.set_font(menu_font)) | ||||
|  | ||||
|     if (menu_item_value_config := config.get(CONF_MENU_ITEM_VALUE, None)) is not None: | ||||
|         if isinstance(menu_item_value_config, core.Lambda): | ||||
|             template_ = await cg.templatable( | ||||
|                 menu_item_value_config, | ||||
|                 [(MenuItemValueArgumentsConstPtr, "it")], | ||||
|                 cg.std_string, | ||||
|             ) | ||||
|             cg.add(var.set_menu_item_value(template_)) | ||||
|         else: | ||||
|             cg.add(var.set_menu_item_value(menu_item_value_config)) | ||||
|  | ||||
|     if foreground_color_config := config.get(CONF_FOREGROUND_COLOR): | ||||
|         foreground_color = await cg.get_variable(foreground_color_config) | ||||
|         cg.add(var.set_foreground_color(foreground_color)) | ||||
|  | ||||
|     if background_color_config := config.get(CONF_BACKGROUND_COLOR): | ||||
|         background_color = await cg.get_variable(background_color_config) | ||||
|         cg.add(var.set_background_color(background_color)) | ||||
|  | ||||
|     for conf in config.get(CONF_ON_REDRAW, []): | ||||
|         trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) | ||||
|         await automation.build_automation( | ||||
|             trigger, [(GraphicalDisplayMenuConstPtr, "it")], conf | ||||
|         ) | ||||
|  | ||||
|     await display_menu_to_code(var, config) | ||||
|  | ||||
|     cg.add_define("USE_GRAPHICAL_DISPLAY_MENU") | ||||
| @@ -0,0 +1,243 @@ | ||||
| #include "graphical_display_menu.h" | ||||
| #include "esphome/core/hal.h" | ||||
| #include "esphome/core/helpers.h" | ||||
| #include "esphome/core/log.h" | ||||
| #include <cstdlib> | ||||
| #include "esphome/components/display/display.h" | ||||
|  | ||||
| namespace esphome { | ||||
| namespace graphical_display_menu { | ||||
|  | ||||
| static const char *const TAG = "graphical_display_menu"; | ||||
|  | ||||
| void GraphicalDisplayMenu::setup() { | ||||
|   if (this->display_ != nullptr) { | ||||
|     display::display_writer_t writer = [this](display::Display &it) { this->draw_menu(); }; | ||||
|     this->display_page_ = make_unique<display::DisplayPage>(writer); | ||||
|   } | ||||
|  | ||||
|   if (!this->menu_item_value_.has_value()) { | ||||
|     this->menu_item_value_ = [](const MenuItemValueArguments *it) { | ||||
|       std::string label = " "; | ||||
|       if (it->is_item_selected && it->is_menu_editing) { | ||||
|         label.append(">"); | ||||
|         label.append(it->item->get_value_text()); | ||||
|         label.append("<"); | ||||
|       } else { | ||||
|         label.append("("); | ||||
|         label.append(it->item->get_value_text()); | ||||
|         label.append(")"); | ||||
|       } | ||||
|       return label; | ||||
|     }; | ||||
|   } | ||||
|  | ||||
|   display_menu_base::DisplayMenuComponent::setup(); | ||||
| } | ||||
|  | ||||
| void GraphicalDisplayMenu::dump_config() { | ||||
|   ESP_LOGCONFIG(TAG, "Graphical Display Menu"); | ||||
|   ESP_LOGCONFIG(TAG, "Has Display: %s", YESNO(this->display_ != nullptr)); | ||||
|   ESP_LOGCONFIG(TAG, "Popup Mode: %s", YESNO(this->display_ != nullptr)); | ||||
|   ESP_LOGCONFIG(TAG, "Advanced Drawing Mode: %s", YESNO(this->display_ == nullptr)); | ||||
|   ESP_LOGCONFIG(TAG, "Has Font: %s", YESNO(this->font_ != nullptr)); | ||||
|   ESP_LOGCONFIG(TAG, "Mode: %s", this->mode_ == display_menu_base::MENU_MODE_ROTARY ? "Rotary" : "Joystick"); | ||||
|   ESP_LOGCONFIG(TAG, "Active: %s", YESNO(this->active_)); | ||||
|   ESP_LOGCONFIG(TAG, "Menu items:"); | ||||
|   for (size_t i = 0; i < this->displayed_item_->items_size(); i++) { | ||||
|     auto *item = this->displayed_item_->get_item(i); | ||||
|     ESP_LOGCONFIG(TAG, "  %i: %s (Type: %s, Immediate Edit: %s)", i, item->get_text().c_str(), | ||||
|                   LOG_STR_ARG(display_menu_base::menu_item_type_to_string(item->get_type())), | ||||
|                   YESNO(item->get_immediate_edit())); | ||||
|   } | ||||
| } | ||||
|  | ||||
| void GraphicalDisplayMenu::set_display(display::Display *display) { this->display_ = display; } | ||||
|  | ||||
| void GraphicalDisplayMenu::set_font(display::BaseFont *font) { this->font_ = font; } | ||||
|  | ||||
| void GraphicalDisplayMenu::set_foreground_color(Color foreground_color) { this->foreground_color_ = foreground_color; } | ||||
| void GraphicalDisplayMenu::set_background_color(Color background_color) { this->background_color_ = background_color; } | ||||
|  | ||||
| void GraphicalDisplayMenu::on_before_show() { | ||||
|   if (this->display_ != nullptr) { | ||||
|     this->previous_display_page_ = this->display_->get_active_page(); | ||||
|     this->display_->show_page(this->display_page_.get()); | ||||
|     this->display_->clear(); | ||||
|   } else { | ||||
|     this->update(); | ||||
|   } | ||||
| } | ||||
|  | ||||
| void GraphicalDisplayMenu::on_before_hide() { | ||||
|   if (this->previous_display_page_ != nullptr) { | ||||
|     this->display_->show_page((display::DisplayPage *) this->previous_display_page_); | ||||
|     this->display_->clear(); | ||||
|     this->update(); | ||||
|     this->previous_display_page_ = nullptr; | ||||
|   } else { | ||||
|     this->update(); | ||||
|   } | ||||
| } | ||||
|  | ||||
| void GraphicalDisplayMenu::draw_and_update() { | ||||
|   this->update(); | ||||
|  | ||||
|   // If we're in advanced drawing mode we won't have a display and will instead require the update callback to do | ||||
|   // our drawing | ||||
|   if (this->display_ != nullptr) { | ||||
|     draw_menu(); | ||||
|   } | ||||
| } | ||||
|  | ||||
| void GraphicalDisplayMenu::draw_menu() { | ||||
|   if (this->display_ == nullptr) { | ||||
|     ESP_LOGE(TAG, "draw_menu() called without a display_. This is only available when using the menu in pop up mode"); | ||||
|     return; | ||||
|   } | ||||
|   display::Rect bounds(0, 0, this->display_->get_width(), this->display_->get_height()); | ||||
|   this->draw_menu_internal_(this->display_, &bounds); | ||||
| } | ||||
|  | ||||
| void GraphicalDisplayMenu::draw(display::Display *display, const display::Rect *bounds) { | ||||
|   this->draw_menu_internal_(display, bounds); | ||||
| } | ||||
|  | ||||
| void GraphicalDisplayMenu::draw_menu_internal_(display::Display *display, const display::Rect *bounds) { | ||||
|   int total_height = 0; | ||||
|   int y_padding = 2; | ||||
|   bool scroll_menu_items = false; | ||||
|   std::vector<display::Rect> menu_dimensions; | ||||
|   int number_items_fit_to_screen = 0; | ||||
|   const int max_item_index = this->displayed_item_->items_size() - 1; | ||||
|  | ||||
|   for (size_t i = 0; i <= max_item_index; i++) { | ||||
|     const auto *item = this->displayed_item_->get_item(i); | ||||
|     const bool selected = i == this->cursor_index_; | ||||
|     const display::Rect item_dimensions = this->measure_item(display, item, bounds, selected); | ||||
|  | ||||
|     menu_dimensions.push_back(item_dimensions); | ||||
|     total_height += item_dimensions.h + (i == 0 ? 0 : y_padding); | ||||
|  | ||||
|     if (total_height <= bounds->h) { | ||||
|       number_items_fit_to_screen++; | ||||
|     } else { | ||||
|       // Scroll the display if the selected item or the item immediately after it overflows | ||||
|       if ((selected) || (i == this->cursor_index_ + 1)) { | ||||
|         scroll_menu_items = true; | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   // Determine what items to draw | ||||
|   int first_item_index = 0; | ||||
|   int last_item_index = max_item_index; | ||||
|  | ||||
|   if (number_items_fit_to_screen <= 1) { | ||||
|     // If only one item can fit to the bounds draw the current cursor item | ||||
|     last_item_index = std::min(last_item_index, this->cursor_index_ + 1); | ||||
|     first_item_index = this->cursor_index_; | ||||
|   } else { | ||||
|     if (scroll_menu_items) { | ||||
|       // Attempt to draw the item after the current item (+1 for equality check in the draw loop) | ||||
|       last_item_index = std::min(last_item_index, this->cursor_index_ + 1); | ||||
|  | ||||
|       // Go back through the measurements to determine how many prior items we can fit | ||||
|       int height_left_to_use = bounds->h; | ||||
|       for (int i = last_item_index; i >= 0; i--) { | ||||
|         const display::Rect item_dimensions = menu_dimensions[i]; | ||||
|         height_left_to_use -= (item_dimensions.h + y_padding); | ||||
|  | ||||
|         if (height_left_to_use <= 0) { | ||||
|           // Ran out of space -  this is our first item to draw | ||||
|           first_item_index = i; | ||||
|           break; | ||||
|         } | ||||
|       } | ||||
|       const int items_to_draw = last_item_index - first_item_index; | ||||
|       // Dont't draw last item partially if it is the selected item | ||||
|       if ((this->cursor_index_ == last_item_index) && (number_items_fit_to_screen <= items_to_draw) && | ||||
|           (first_item_index < max_item_index)) { | ||||
|         first_item_index++; | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   // Render the items into the view port | ||||
|   display->start_clipping(*bounds); | ||||
|  | ||||
|   int y_offset = bounds->y; | ||||
|   for (size_t i = first_item_index; i <= last_item_index; i++) { | ||||
|     const auto *item = this->displayed_item_->get_item(i); | ||||
|     const bool selected = i == this->cursor_index_; | ||||
|     display::Rect dimensions = menu_dimensions[i]; | ||||
|  | ||||
|     dimensions.y = y_offset; | ||||
|     dimensions.x = bounds->x; | ||||
|     this->draw_item(display, item, &dimensions, selected); | ||||
|  | ||||
|     y_offset = dimensions.y + dimensions.h + y_padding; | ||||
|   } | ||||
|  | ||||
|   display->end_clipping(); | ||||
| } | ||||
|  | ||||
| display::Rect GraphicalDisplayMenu::measure_item(display::Display *display, const display_menu_base::MenuItem *item, | ||||
|                                                  const display::Rect *bounds, const bool selected) { | ||||
|   display::Rect dimensions(0, 0, 0, 0); | ||||
|  | ||||
|   if (selected) { | ||||
|     // TODO: Support selection glyph | ||||
|     dimensions.w += 0; | ||||
|     dimensions.h += 0; | ||||
|   } | ||||
|  | ||||
|   std::string label = item->get_text(); | ||||
|   if (item->has_value()) { | ||||
|     // Append to label | ||||
|     MenuItemValueArguments args(item, selected, this->editing_); | ||||
|     label.append(this->menu_item_value_.value(&args)); | ||||
|   } | ||||
|  | ||||
|   int x1; | ||||
|   int y1; | ||||
|   int width; | ||||
|   int height; | ||||
|   display->get_text_bounds(0, 0, label.c_str(), this->font_, display::TextAlign::TOP_LEFT, &x1, &y1, &width, &height); | ||||
|  | ||||
|   dimensions.w = std::min((int16_t) width, bounds->w); | ||||
|   dimensions.h = std::min((int16_t) height, bounds->h); | ||||
|  | ||||
|   return dimensions; | ||||
| } | ||||
|  | ||||
| inline void GraphicalDisplayMenu::draw_item(display::Display *display, const display_menu_base::MenuItem *item, | ||||
|                                             const display::Rect *bounds, const bool selected) { | ||||
|   const auto background_color = selected ? this->foreground_color_ : this->background_color_; | ||||
|   const auto foreground_color = selected ? this->background_color_ : this->foreground_color_; | ||||
|  | ||||
|   // int background_width = std::max(bounds->width, available_width); | ||||
|   int background_width = bounds->w; | ||||
|  | ||||
|   if (selected) { | ||||
|     display->filled_rectangle(bounds->x, bounds->y, background_width, bounds->h, background_color); | ||||
|   } | ||||
|  | ||||
|   std::string label = item->get_text(); | ||||
|   if (item->has_value()) { | ||||
|     MenuItemValueArguments args(item, selected, this->editing_); | ||||
|     label.append(this->menu_item_value_.value(&args)); | ||||
|   } | ||||
|  | ||||
|   display->print(bounds->x, bounds->y, this->font_, foreground_color, display::TextAlign::TOP_LEFT, label.c_str()); | ||||
| } | ||||
|  | ||||
| void GraphicalDisplayMenu::draw_item(const display_menu_base::MenuItem *item, const uint8_t row, const bool selected) { | ||||
|   ESP_LOGE(TAG, "draw_item(MenuItem *item, uint8_t row, bool selected) called. The graphical_display_menu specific " | ||||
|                 "draw_item should be called."); | ||||
| } | ||||
|  | ||||
| void GraphicalDisplayMenu::update() { this->on_redraw_callbacks_.call(); } | ||||
|  | ||||
| }  // namespace graphical_display_menu | ||||
| }  // namespace esphome | ||||
| @@ -0,0 +1,84 @@ | ||||
| #pragma once | ||||
|  | ||||
| #include "esphome/core/color.h" | ||||
| #include "esphome/components/display_menu_base/display_menu_base.h" | ||||
| #include "esphome/components/display_menu_base/menu_item.h" | ||||
| #include "esphome/core/automation.h" | ||||
| #include <cstdlib> | ||||
|  | ||||
| namespace esphome { | ||||
|  | ||||
| // forward declare from display namespace | ||||
| namespace display { | ||||
| class Display; | ||||
| class DisplayPage; | ||||
| class BaseFont; | ||||
| class Rect; | ||||
| }  // namespace display | ||||
|  | ||||
| namespace graphical_display_menu { | ||||
|  | ||||
| const Color COLOR_ON(255, 255, 255, 255); | ||||
| const Color COLOR_OFF(0, 0, 0, 0); | ||||
|  | ||||
| struct MenuItemValueArguments { | ||||
|   MenuItemValueArguments(const display_menu_base::MenuItem *item, bool is_item_selected, bool is_menu_editing) { | ||||
|     this->item = item; | ||||
|     this->is_item_selected = is_item_selected; | ||||
|     this->is_menu_editing = is_menu_editing; | ||||
|   } | ||||
|  | ||||
|   const display_menu_base::MenuItem *item; | ||||
|   bool is_item_selected; | ||||
|   bool is_menu_editing; | ||||
| }; | ||||
|  | ||||
| class GraphicalDisplayMenu : public display_menu_base::DisplayMenuComponent { | ||||
|  public: | ||||
|   void setup() override; | ||||
|   void dump_config() override; | ||||
|  | ||||
|   void set_display(display::Display *display); | ||||
|   void set_font(display::BaseFont *font); | ||||
|   template<typename V> void set_menu_item_value(V menu_item_value) { this->menu_item_value_ = menu_item_value; } | ||||
|   void set_foreground_color(Color foreground_color); | ||||
|   void set_background_color(Color background_color); | ||||
|  | ||||
|   void add_on_redraw_callback(std::function<void()> &&cb) { this->on_redraw_callbacks_.add(std::move(cb)); } | ||||
|  | ||||
|   void draw(display::Display *display, const display::Rect *bounds); | ||||
|  | ||||
|  protected: | ||||
|   void draw_and_update() override; | ||||
|   void draw_menu() override; | ||||
|   void draw_menu_internal_(display::Display *display, const display::Rect *bounds); | ||||
|   void draw_item(const display_menu_base::MenuItem *item, uint8_t row, bool selected) override; | ||||
|   virtual display::Rect measure_item(display::Display *display, const display_menu_base::MenuItem *item, | ||||
|                                      const display::Rect *bounds, bool selected); | ||||
|   virtual void draw_item(display::Display *display, const display_menu_base::MenuItem *item, | ||||
|                          const display::Rect *bounds, bool selected); | ||||
|   void update() override; | ||||
|  | ||||
|   void on_before_show() override; | ||||
|   void on_before_hide() override; | ||||
|  | ||||
|   std::unique_ptr<display::DisplayPage> display_page_{nullptr}; | ||||
|   const display::DisplayPage *previous_display_page_{nullptr}; | ||||
|   display::Display *display_{nullptr}; | ||||
|   display::BaseFont *font_{nullptr}; | ||||
|   TemplatableValue<std::string, const MenuItemValueArguments *> menu_item_value_; | ||||
|   Color foreground_color_{COLOR_ON}; | ||||
|   Color background_color_{COLOR_OFF}; | ||||
|  | ||||
|   CallbackManager<void()> on_redraw_callbacks_{}; | ||||
| }; | ||||
|  | ||||
| class GraphicalDisplayMenuOnRedrawTrigger : public Trigger<const GraphicalDisplayMenu *> { | ||||
|  public: | ||||
|   explicit GraphicalDisplayMenuOnRedrawTrigger(GraphicalDisplayMenu *parent) { | ||||
|     parent->add_on_redraw_callback([this, parent]() { this->trigger(parent); }); | ||||
|   } | ||||
| }; | ||||
|  | ||||
| }  // namespace graphical_display_menu | ||||
| }  // namespace esphome | ||||
		Reference in New Issue
	
	Block a user