diff --git a/esphome/components/graphical_layout/__init__.py b/esphome/components/graphical_layout/__init__.py index c6a84e0eed..d91b707661 100644 --- a/esphome/components/graphical_layout/__init__.py +++ b/esphome/components/graphical_layout/__init__.py @@ -7,6 +7,7 @@ from . import vertical_stack from . import text_panel from . import display_rendering_panel from . import fixed_dimension_panel +from . import text_run_panel graphical_layout_ns = cg.esphome_ns.namespace("graphical_layout") RootLayoutComponent = graphical_layout_ns.class_("RootLayoutComponent", cg.Component) @@ -55,6 +56,9 @@ ITEM_TYPE_SCHEMA = cv.typed_schema( fixed_dimension_panel.CONF_FIXED_DIMENSION_PANEL: fixed_dimension_panel.get_config_schema( BASE_ITEM_SCHEMA, item_type_schema ), + text_run_panel.CONF_TEXT_RUN_PANEL: text_run_panel.get_config_schema( + BASE_ITEM_SCHEMA, item_type_schema + ), } ) @@ -64,6 +68,7 @@ CODE_GENERATORS = { vertical_stack.CONF_VERTICAL_STACK: vertical_stack.config_to_layout_item, display_rendering_panel.CONF_DISPLAY_RENDERING_PANEL: display_rendering_panel.config_to_layout_item, fixed_dimension_panel.CONF_FIXED_DIMENSION_PANEL: fixed_dimension_panel.config_to_layout_item, + text_run_panel.CONF_TEXT_RUN_PANEL: text_run_panel.config_to_layout_item, } CONFIG_SCHEMA = cv.Schema( @@ -105,7 +110,6 @@ async def to_code(config): ) cg.add(var.set_layout_root(layout_var)) else: - err = f"Do not know how to build type {layout_type}" raise RuntimeError(f"Do not know how to build type {layout_type}") cg.add_define("USE_GRAPHICAL_LAYOUT") diff --git a/esphome/components/graphical_layout/text_run_panel.cpp b/esphome/components/graphical_layout/text_run_panel.cpp new file mode 100644 index 0000000000..f4161f9fa9 --- /dev/null +++ b/esphome/components/graphical_layout/text_run_panel.cpp @@ -0,0 +1,284 @@ +#include "text_run_panel.h" + +#include "esphome/components/display/display.h" +#include "esphome/components/display/rect.h" +#include "esphome/core/log.h" + +namespace esphome { +namespace graphical_layout { + + +static const char *const TAG = "textrunpanel"; +static const int TEXT_ALIGN_X_MASK = (int) display::TextAlign::RIGHT | (int) display::TextAlign::CENTER_HORIZONTAL; +static const int TEXT_ALIGN_Y_MASK = + (int) display::TextAlign::BOTTOM | (int) display::TextAlign::BASELINE | (int) display::TextAlign::CENTER_VERTICAL; + +void TextRunPanel::dump_config(int indent_depth, int additional_level_depth) { + ESP_LOGCONFIG(TAG, "%*sMin Width: %i", indent_depth, "", this->min_width_); + ESP_LOGCONFIG(TAG, "%*sMax Width: %i", indent_depth, "", this->max_width_); + ESP_LOGCONFIG(TAG, "%*sText Align: %s", indent_depth, "", display::text_align_to_string(this->text_align_)); + ESP_LOGCONFIG(TAG, "%*sText Runs: %i", indent_depth, "", this->text_runs_.size()); + for (TextRun *run : this->text_runs_) { + std::string text = run->text_.value(); + ESP_LOGCONFIG(TAG, "%*sText: %s", indent_depth + additional_level_depth, "", text.c_str()); + } +} + +void TextRunPanel::setup_complete() { + if (!this->can_wrap_at_character_.has_value()) { + ESP_LOGD(TAG, "No custom can_wrap_at_character provided. Will use default implementation"); + this->can_wrap_at_character_ = [this](CanWrapAtCharacterArguments *args) { + return this->default_can_wrap_at_character(args); + }; + } +} + +display::Rect TextRunPanel::measure_item_internal(display::Display *display) { + CalculatedLayout calculated = this->determine_layout(display, display::Rect(0, 0, this->max_width_, display->get_height()), true); + return calculated.bounds; +} + +void TextRunPanel::render_internal(display::Display *display, display::Rect bounds) { + CalculatedLayout layout = this->determine_layout(display, bounds, true); + + for (auto calculated : layout.runs) { + if (calculated->run->background_color_ != display::COLOR_OFF) { + display->filled_rectangle(calculated->bounds.x, calculated->bounds.y, calculated->bounds.w, calculated->bounds.h, calculated->run->background_color_); + } + display->print(calculated->bounds.x, calculated->bounds.y, calculated->run->font_, calculated->run->foreground_color_, display::TextAlign::TOP_LEFT, calculated->text_.c_str()); + } + + if (this->debug_outline_runs_) { + ESP_LOGD(TAG, "Outlining character runs"); + for (auto calculated : layout.runs) { + display->rectangle(calculated->bounds.x, calculated->bounds.y, calculated->bounds.w, calculated->bounds.h); + } + } +} + +CalculatedLayout TextRunPanel::determine_layout(display::Display *display, display::Rect bounds, bool apply_alignment) { + ESP_LOGV(TAG, "Determining layout for (%i, %i)", bounds.w, bounds.h); + + CalculatedLayout calculated_layout; + int x_offset = 0; + int y_offset = 0; + int current_line_max_height = 0; + int widest_line = 0; + int line_number = 0; + + for (TextRun *run : this->text_runs_) { + int x1; + int y1; + int width; + int height; + int baseline; + std::string text = run->text_.value(); + + run->font_->measure(text.c_str(), &width, &x1, &baseline, &height); + + if ((x_offset + width) < bounds.w) { + // Item fits on the current line + auto calculated = std::make_shared(run, text, display::Rect(x_offset, y_offset, width, height), baseline, line_number); + calculated_layout.runs.push_back(calculated); + + + x_offset += width; + widest_line = std::max(widest_line, x_offset); + + continue; + } + + current_line_max_height = std::max(current_line_max_height, height); + + ESP_LOGVV(TAG, "'%s' will not fit on the line. Finding break characters", text.c_str()); + + // Item extends beyond our desired bounds - need to add word by word + CanWrapAtCharacterArguments can_wrap_at_args(this, 0, text, ' '); + std::string partial_line; + for (int i = 0; i < text.size(); i++) { + can_wrap_at_args.offset = i; + can_wrap_at_args.character = text.at(i); + + bool can_wrap = this->can_wrap_at_character_.value(&can_wrap_at_args); + if (can_wrap) { + ESP_LOGVV(TAG, "Can break at '%c'. String is '%s'", can_wrap_at_args.character, partial_line.c_str()); + + run->font_->measure(partial_line.c_str(), &width, &x1, &baseline, &height); + if ((x_offset + width) < bounds.w) { + ESP_LOGVV(TAG, "... Fits! (%i, %i)", x_offset, y_offset); + + // Item fits on the current line + current_line_max_height = std::max(current_line_max_height, height); + + auto calculated = std::make_shared(run, partial_line, display::Rect(x_offset, y_offset, width, height), baseline, line_number); + calculated_layout.runs.push_back(calculated); + + x_offset += width; + widest_line = std::max(widest_line, x_offset); + + partial_line = can_wrap_at_args.character; + continue; + } + + ESP_LOGVV(TAG, "... Doesn't fit - will overflow to next line"); + + // Overflows the current line + x_offset = 0; + y_offset += current_line_max_height; + line_number++; + current_line_max_height = height; + partial_line += can_wrap_at_args.character; + continue; + } + + partial_line += can_wrap_at_args.character; + } + + if (partial_line.length() > 0) { + // Remaining text + run->font_->measure(partial_line.c_str(), &width, &x1, &baseline, &height); + + current_line_max_height = std::max(height, current_line_max_height); + + ESP_LOGVV(TAG, "'%s' is remaining after character break checks. Rendering to (%i, %i)", partial_line.c_str(), x_offset, y_offset); + + auto calculated = std::make_shared(run, partial_line, display::Rect(x_offset, y_offset, width, height), baseline, line_number); + calculated_layout.runs.push_back(calculated); + + x_offset += width; + widest_line = std::max(widest_line, x_offset); + } + } + + y_offset += current_line_max_height; + + calculated_layout.bounds = display::Rect(0, 0, widest_line, y_offset); + calculated_layout.line_count = line_number + 1; + if (calculated_layout.bounds.w < this->min_width_) { + calculated_layout.bounds.w = this->min_width_; + } + + if (apply_alignment) { + this->apply_alignment_to_layout(&calculated_layout); + } + + ESP_LOGV(TAG, "Measured layout is (%i, %i) (%i lines)", calculated_layout.bounds.w, calculated_layout.bounds.h, calculated_layout.line_count); + + return calculated_layout; +} + +void TextRunPanel::apply_alignment_to_layout(CalculatedLayout *calculated_layout) { + const auto x_align = display::TextAlign(int(this->text_align_) & TEXT_ALIGN_X_MASK); + const auto y_align = display::TextAlign(int(this->text_align_) & TEXT_ALIGN_Y_MASK); + + ESP_LOGVV(TAG, "We have %i lines to apply alignment to!", calculated_layout->line_count); + + int total_y_offset = 0; + + for (int i = 0; i < calculated_layout->line_count; i++) { + std::vector> line_runs; + + // Get all the runs for the current line + for (auto run : calculated_layout->runs) { + if (run->line_number_ == i) { + line_runs.push_back(run); + } + } + + ESP_LOGVV(TAG, "Found %i runs on line %i", line_runs.size(), i); + + int16_t total_line_width = 0; + int16_t max_line_height = 0; + int16_t max_baseline = 0; + for (auto run : line_runs) { + total_line_width += run->bounds.w; + max_line_height = std::max(run->bounds.h, max_line_height); + max_baseline = std::max(run->baseline, max_baseline); + } + + ESP_LOGVV(TAG, "Line %i totals (%i, %i) pixels of (%i, %i)", i, total_line_width, max_line_height, calculated_layout->bounds.w, calculated_layout->bounds.h); + + int x_adjustment = 0; + int y_adjustment = 0; + switch (x_align) { + case display::TextAlign::RIGHT: { + x_adjustment = calculated_layout->bounds.w - total_line_width; + ESP_LOGVV(TAG, "Will adjust line %i by %i x-pixels", i, x_adjustment); + break; + } + case display::TextAlign::CENTER_HORIZONTAL: { + x_adjustment = (calculated_layout->bounds.w - total_line_width) / 2; + ESP_LOGVV(TAG, "Will adjust line %i by %i x-pixels", i, x_adjustment); + break; + } + default: { + break; + } + } + + int max_line_y_adjustment = 0; + for (auto run : line_runs) { + ESP_LOGVV(TAG, "Adjusting '%s' from (%i, %i) to (%i, %i)", run->text_.c_str(), run->bounds.x, run->bounds.y, run->bounds.x + x_adjustment, run->bounds.y + y_adjustment); + run->bounds.x += x_adjustment; + + switch (y_align) { + case display::TextAlign::BOTTOM: { + y_adjustment = max_line_height - run->bounds.h; + ESP_LOGVV(TAG, "Will adjust line %i by %i y-pixels (%i vs %i)", i, y_adjustment, max_line_height, run->bounds.h); + break; + } + case display::TextAlign::CENTER_VERTICAL: { + y_adjustment = (max_line_height - run->bounds.h) / 2; + ESP_LOGVV(TAG, "Will adjust line %i by %i y-pixels", i, y_adjustment); + break; + } + case display::TextAlign::BASELINE: { + // Adjust this run based on its difference from the maximum baseline in the line + y_adjustment = max_baseline - run->baseline; + ESP_LOGVV(TAG, "Will adjust '%s' by %i y-pixels (ML: %i, H: %i, BL: %i)", run->text_.c_str(), y_adjustment, max_line_height, run->bounds.h, run->baseline); + break; + } + default: { + break; + } + } + + run->bounds.y += y_adjustment + total_y_offset; + max_line_y_adjustment = std::max(max_line_y_adjustment, y_adjustment); + } + + total_y_offset += max_line_y_adjustment; + } + + calculated_layout->bounds.h += total_y_offset; +} + +bool TextRunPanel::default_can_wrap_at_character(CanWrapAtCharacterArguments *args) { + switch (args->character) { + case ' ': + case '\t': + case '\n': + case '\r': + case '\0': + case '=': + case '<': + case '>': + case '/': + case '&': + case '*': + case '+': + case '^': + case '|': + case '\\': { + return true; + } + default: { + return false; + } + } + + return false; +} + +} // namespace graphical_layout +} // namespace esphome diff --git a/esphome/components/graphical_layout/text_run_panel.h b/esphome/components/graphical_layout/text_run_panel.h new file mode 100644 index 0000000000..1cacd1cd3a --- /dev/null +++ b/esphome/components/graphical_layout/text_run_panel.h @@ -0,0 +1,106 @@ +#pragma once + +#include + +#include "esphome/components/graphical_layout/graphical_layout.h" +#include "esphome/components/font/font.h" +#include "esphome/core/automation.h" + +namespace esphome { +namespace display { + +extern const Color COLOR_ON; +extern const Color COLOR_OFF; + +} // namespace display +namespace graphical_layout { + +class TextRunPanel; + +struct CanWrapAtCharacterArguments { + CanWrapAtCharacterArguments(const TextRunPanel *panel, int offset, std::string string, char character) { + this->panel = panel; + this->offset = offset; + this->string = string; + this->character = character; + } + + const TextRunPanel *panel; + int offset; + std::string string; + char character; +}; + +class TextRun { + public: + TextRun(TemplatableValue text, display::BaseFont *font) { + this->text_ = text; + this->font_ = font; + } + + void set_foreground_color(Color foreground_color) { this->foreground_color_ = foreground_color; } + void set_background_color(Color background_color) { this->background_color_ = background_color; } + + TemplatableValue text_{}; + display::BaseFont *font_{nullptr}; + Color foreground_color_{display::COLOR_ON}; + Color background_color_{display::COLOR_OFF}; +}; + +class CalculatedTextRun { + public: + CalculatedTextRun(TextRun *run, std::string text, display::Rect bounds, int16_t baseline, int16_t line_number) { + this->run = run; + this->text_ = text; + this->bounds = bounds; + this->baseline = baseline; + this->line_number_ = line_number; + } + + std::string text_; + display::Rect bounds; + TextRun *run; + int16_t line_number_; + int16_t baseline; +}; + +struct CalculatedLayout { + std::vector> runs; + display::Rect bounds; + int line_count; +}; + +/** The TextRunPanel is a UI item that renders a multiple "runs" of text of independent styling to a display */ +class TextRunPanel : public LayoutItem { + public: + display::Rect measure_item_internal(display::Display *display) override; + void render_internal(display::Display *display, display::Rect bounds) override; + void dump_config(int indent_depth, int additional_level_depth) override; + void setup_complete() override; + + bool default_can_wrap_at_character(CanWrapAtCharacterArguments *args); + + CalculatedLayout determine_layout(display::Display *display, display::Rect bounds, bool apply_alignment); + void apply_alignment_to_layout(CalculatedLayout *layout); + + template void set_can_wrap_at(V can_wrap_at_character) { + this->can_wrap_at_character_ = can_wrap_at_character; + }; + + void add_text_run(TextRun *text_run) { this->text_runs_.push_back(text_run); }; + void set_text_align(display::TextAlign text_align) { this->text_align_ = text_align; }; + void set_min_width(int min_width) { this->min_width_ = min_width; }; + void set_max_width(int max_width) { this->max_width_ = max_width; }; + void set_debug_outline_runs(bool debug_outline_runs) { this->debug_outline_runs_ = debug_outline_runs; }; + + protected: + std::vector text_runs_; + display::TextAlign text_align_{display::TextAlign::TOP_LEFT}; + int min_width_{0}; + int max_width_{0}; + TemplatableValue can_wrap_at_character_{}; + bool debug_outline_runs_{false}; +}; + +} // namespace graphical_layout +} // namespace esphome diff --git a/esphome/components/graphical_layout/text_run_panel.py b/esphome/components/graphical_layout/text_run_panel.py new file mode 100644 index 0000000000..8e2ab189fa --- /dev/null +++ b/esphome/components/graphical_layout/text_run_panel.py @@ -0,0 +1,99 @@ +from esphome import automation +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.components import font, color +from esphome.components.display import display_ns +from esphome.const import CONF_ID + +graphical_layout_ns = cg.esphome_ns.namespace("graphical_layout") +TextRunPanel = graphical_layout_ns.class_("TextRunPanel") +TextAlign = display_ns.enum("TextAlign", is_class=True) +TextRun = graphical_layout_ns.class_("TextRun") + +CONF_TEXT_RUN_PANEL = "text_run_panel" +CONF_FONT = "font" +CONF_FOREGROUND_COLOR = "foreground_color" +CONF_BACKGROUND_COLOR = "background_color" +CONF_TEXT = "text" +CONF_TEXT_ALIGN = "text_align" +CONF_MAX_WIDTH = "max_width" +CONF_MIN_WIDTH = "min_width" +CONF_RUNS = "runs" + + +TEXT_ALIGN = { + "TOP_LEFT": TextAlign.TOP_LEFT, + "TOP_CENTER": TextAlign.TOP_CENTER, + "TOP_RIGHT": TextAlign.TOP_RIGHT, + "CENTER_LEFT": TextAlign.CENTER_LEFT, + "CENTER": TextAlign.CENTER, + "CENTER_RIGHT": TextAlign.CENTER_RIGHT, + "BASELINE_LEFT": TextAlign.BASELINE_LEFT, + "BASELINE_CENTER": TextAlign.BASELINE_CENTER, + "BASELINE_RIGHT": TextAlign.BASELINE_RIGHT, + "BOTTOM_LEFT": TextAlign.BOTTOM_LEFT, + "BOTTOM_CENTER": TextAlign.BOTTOM_CENTER, + "BOTTOM_RIGHT": TextAlign.BOTTOM_RIGHT, +} + +RUN_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.declare_id(TextRun), + cv.Required(CONF_FONT): cv.use_id(font.Font), + cv.Optional(CONF_FOREGROUND_COLOR): cv.use_id(color.ColorStruct), + cv.Optional(CONF_BACKGROUND_COLOR): cv.use_id(color.ColorStruct), + cv.Required(CONF_TEXT): cv.templatable(cv.string), + } +) + + +def get_config_schema(base_item_schema, item_type_schema): + return base_item_schema.extend( + { + cv.GenerateID(): cv.declare_id(TextRunPanel), + cv.Optional(CONF_TEXT_ALIGN): cv.enum(TEXT_ALIGN, upper=True), + cv.Required(CONF_MAX_WIDTH): cv.int_range(min=0), + cv.Optional(CONF_MIN_WIDTH, default=0): cv.int_range(min=0), + cv.Optional(CONF_DEBUG_OUTLINE_RUNS, default=False): cv.boolean, + cv.Required(CONF_RUNS): cv.All( + cv.ensure_list(RUN_SCHEMA), cv.Length(min=1) + ), + } + ) + + +async def config_to_layout_item(pvariable_builder, item_config, child_item_builder): + var = await pvariable_builder(item_config) + + min_width = item_config[CONF_MIN_WIDTH] + cg.add(var.set_min_width(min_width)) + + max_width = item_config[CONF_MAX_WIDTH] + cg.add(var.set_max_width(max_width)) + + if text_align := item_config.get(CONF_TEXT_ALIGN): + cg.add(var.set_text_align(text_align)) + + debug_outline_runs = item_config[CONF_DEBUG_OUTLINE_RUNS] + if debug_outline_runs: + cg.add(var.set_debug_outline_runs(debug_outline_runs)) + + for run_config in item_config[CONF_RUNS]: + run_text = await cg.templatable( + run_config[CONF_TEXT], args=[], output_type=cg.std_string + ) + run_font = await cg.get_variable(run_config[CONF_FONT]) + + run = cg.new_Pvariable(run_config[CONF_ID], run_text, run_font) + + if run_background_color_config := run_config.get(CONF_BACKGROUND_COLOR): + run_background_color = await cg.get_variable(run_background_color_config) + cg.add(run.set_background_color(run_background_color)) + + if run_foreground_color_config := run_config.get(CONF_FOREGROUND_COLOR): + run_foreground_color = await cg.get_variable(run_foreground_color_config) + cg.add(run.set_foreground_color(run_foreground_color)) + + cg.add(var.add_text_run(run)) + + return var