Initial check in of a graphical layout system

WIP: But supports vertical and horizontal stacking of items and a simple text item
This commit is contained in:
Michael Davidson 2023-12-17 20:19:16 +11:00
parent 313cb2bff5
commit 399bbe29e8
No known key found for this signature in database
GPG key ID: B8D1A99712B8B0EB
19 changed files with 587 additions and 0 deletions

View file

@ -116,6 +116,7 @@ esphome/components/gp8403/* @jesserockz
esphome/components/gpio/* @esphome/core
esphome/components/gps/* @coogle
esphome/components/graph/* @synco
esphome/components/graphical_layout/* @MrMDavidson
esphome/components/gree/* @orestismers
esphome/components/grove_tb6612fng/* @max246
esphome/components/growatt_solar/* @leeuwte

View file

@ -166,6 +166,13 @@ void Display::qr_code(int x, int y, qr_code::QrCode *qr_code, Color color_on, in
}
#endif // USE_QR_CODE
#ifdef USE_GRAPHICAL_LAYOUT
void Display::render_layout(int x, int y, graphical_layout::RootLayoutComponent *layout) {
display::Rect b2(x, y, 100, 100);
layout->render_at(this, x, y);
}
#endif
void Display::get_text_bounds(int x, int y, const char *text, BaseFont *font, TextAlign align, int *x1, int *y1,
int *width, int *height) {
int x_offset, baseline;

View file

@ -18,6 +18,10 @@
#include "esphome/components/qr_code/qr_code.h"
#endif
#ifdef USE_GRAPHICAL_LAYOUT
#include "esphome/components/graphical_layout/graphical_layout.h"
#endif
namespace esphome {
namespace display {
@ -393,6 +397,17 @@ class Display : public PollingComponent {
void qr_code(int x, int y, qr_code::QrCode *qr_code, Color color_on = COLOR_ON, int scale = 1);
#endif
#ifdef USE_GRAPHICAL_LAYOUT
/** Draw the graphical layout with the top corner at [x,y]
*
* @param x The x coordinate of the upper left corner
* @param y The y coordinate of the upper left corner
* @param layout The graphical layout to render
*
*/
void render_layout(int x, int y, graphical_layout::RootLayoutComponent *layout);
#endif
/** Get the text bounds of the given string.
*
* @param x The x coordinate to place the string at, can be 0 if only interested in dimensions.

View file

@ -0,0 +1,91 @@
import esphome.codegen as cg
import esphome.config_validation as cv
import esphome.components.graphical_layout.horizontal_stack as horizontal_stack
import esphome.components.graphical_layout.vertical_stack as vertical_stack
import esphome.components.graphical_layout.text_panel as text_panel
from esphome.components import font, color
from esphome.const import CONF_ID
graphical_layout_ns = cg.esphome_ns.namespace("graphical_layout")
RootLayoutComponent = graphical_layout_ns.class_("RootLayoutComponent", cg.Component)
LayoutItem = graphical_layout_ns.class_("LayoutItem")
ContainerLayoutItem = graphical_layout_ns.class_("ContainerLayoutItem", LayoutItem)
CODEOWNERS = ["@MrMDavidson"]
AUTO_LOAD = ["display"]
MULTI_CONF = True
CONF_ITEMS = "items"
CONF_LAYOUT = "layout"
CONF_ITEM_TYPE = "type"
BASE_ITEM_SCHEMA = cv.Schema(
{
}
)
def item_type_schema(value):
return ITEM_TYPE_SCHEMA(value)
ITEM_TYPE_SCHEMA = cv.typed_schema(
{
text_panel.CONF_TYPE: cv.Schema(
{
cv.GenerateID(): cv.declare_id(text_panel.TextPanel),
cv.Optional(text_panel.CONF_ITEM_PADDING, default=0): cv.templatable(cv.int_),
cv.Required(text_panel.CONF_FONT): cv.use_id(font.Font),
cv.Optional(text_panel.CONF_FOREGROUND_COLOR): cv.use_id(color.ColorStruct),
cv.Optional(text_panel.CONF_BACKGROUND_COLOR): cv.use_id(color.ColorStruct),
cv.Required(text_panel.CONF_TEXT): cv.templatable(cv.string),
}
),
horizontal_stack.CONF_TYPE: BASE_ITEM_SCHEMA.extend(
{
cv.GenerateID(): cv.declare_id(horizontal_stack.HorizontalStack),
cv.Optional(horizontal_stack.CONF_ITEM_PADDING, default=0): cv.templatable(cv.int_),
cv.Required(CONF_ITEMS): cv.All(
cv.ensure_list(item_type_schema), cv.Length(min=1)
)
}
),
vertical_stack.CONF_TYPE: BASE_ITEM_SCHEMA.extend(
{
cv.GenerateID(): cv.declare_id(vertical_stack.VerticalStack),
cv.Optional(vertical_stack.CONF_ITEM_PADDING, default=0): cv.templatable(cv.int_),
cv.Required(CONF_ITEMS): cv.All(
cv.ensure_list(item_type_schema), cv.Length(min=1)
)
}
)
}
)
CODE_GENERATORS = {
text_panel.CONF_TYPE: text_panel.config_to_layout_item,
horizontal_stack.CONF_TYPE: horizontal_stack.config_to_layout_item,
vertical_stack.CONF_TYPE: vertical_stack.config_to_layout_item
}
CONFIG_SCHEMA = cv.Schema(
{
cv.GenerateID(): cv.declare_id(RootLayoutComponent),
cv.Required(CONF_LAYOUT): ITEM_TYPE_SCHEMA
}
).extend(cv.COMPONENT_SCHEMA)
async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
layout_config = config[CONF_LAYOUT]
layout_type = layout_config[CONF_ITEM_TYPE]
if layout_type in CODE_GENERATORS:
layout_var = await CODE_GENERATORS[layout_type](layout_config, CODE_GENERATORS)
cg.add(var.set_layout_root(layout_var))
else:
raise f"Do not know how to build type {layout_type}"
cg.add_define("USE_GRAPHICAL_LAYOUT")

View file

@ -0,0 +1,29 @@
#pragma once
#include "esphome/components/graphical_layout/layout_item.h"
namespace esphome {
namespace display {
class Display;
class Rect;
}
namespace graphical_layout {
/** The ContainerLayoutItem can be used to derive from when a layout item has children.
* It does not define what or how child items get used just that they exist for the item
*/
class ContainerLayoutItem : public LayoutItem {
public:
/** Adds an item to this container */
void add_item(LayoutItem *child) {
this->children_.push_back(child);
}
protected:
std::vector<LayoutItem *> children_;
};
}
}

View file

@ -0,0 +1,37 @@
#include "graphical_layout.h"
#include "esphome/components/display/display.h"
#include "esphome/core/log.h"
namespace esphome {
namespace graphical_layout {
static const char *const TAG = "rootlayoutcomponent";
void RootLayoutComponent::setup() {
}
void RootLayoutComponent::dump_config() {
ESP_LOGCONFIG(TAG, "Graphical Layout");
this->layout_root_->dump_config(2, 2);
}
void RootLayoutComponent::render_at(display::Display *display, int x, int y) {
display->set_local_coordinate(x, y);
display::Rect layout_rect = this->layout_root_->measure_item(display);
display::Rect clipping_rect = display::Rect(x, y, layout_rect.w, layout_rect.h);
// TODO: Should clipping be relative to local?
display->start_clipping(clipping_rect);
// Render everything
this->layout_root_->render(display, layout_rect);
display->pop_local_coordinates();
display->shrink_clipping(clipping_rect);
}
} // namespace graphical_layout
} // namespace esphome

View file

@ -0,0 +1,38 @@
#pragma once
#include <vector>
#include "esphome/core/component.h"
#include "esphome/components/display/rect.h"
#include "esphome/components/graphical_layout/layout_item.h"
namespace esphome {
namespace display {
class Display;
class Rect;
}
namespace graphical_layout {
/** Component used for rendering the layout*/
class RootLayoutComponent : public Component {
public:
void setup() override;
void dump_config() override;
/** Render the graphical layout to the screen
*
* param[in] display: Display that will be rendered to
* param[in] x: x coordinate to render at
* param[in] y: y coorindate to render at
*/
void render_at(display::Display *display, int x, int y);
void set_layout_root(LayoutItem *layout) { this->layout_root_ = layout; };
protected:
LayoutItem *layout_root_{nullptr};
};
} // namespace graphical_layout
} // namespace esphome

View file

@ -0,0 +1,51 @@
#include "horizontal_stack.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*TAG = "horizontalstack";
void HorizontalStack::dump_config(int indent_depth, int additional_level_depth) {
ESP_LOGCONFIG(TAG, "%*sItem Padding: %i", indent_depth, "", this->item_padding_);
ESP_LOGCONFIG(TAG, "%*sChildren: %i", indent_depth, "", this->children_.size());
for (LayoutItem *child : this->children_) {
child->dump_config(indent_depth + additional_level_depth, additional_level_depth);
}
}
const display::Rect HorizontalStack::measure_item(display::Display *display) {
display::Rect rect(this->item_padding_, 0, 0, 0);
for (LayoutItem *child : this->children_) {
display::Rect child_rect = child->measure_item(display);
rect.h = std::max(rect.h, child_rect.h);
rect.w += child_rect.w + this->item_padding_;
}
// Add item padding top and bottom
rect.h += (this->item_padding_ * 2);
ESP_LOGD(TAG, "Measured size is (%i, %i, %i, %i)", rect.x, rect.y, rect.x2(), rect.y2());
return rect;
}
void HorizontalStack::render(display::Display *display, display::Rect bounds) {
int width_offset = this->item_padding_;
for (LayoutItem *item : this->children_) {
display::Rect measure = item->measure_item(display);
display->set_local_coordinates_relative_to_current(width_offset, this->item_padding_);
item->render(display, measure);
display->pop_local_coordinates();
width_offset += measure.w + this->item_padding_;
}
}
}
}

View file

@ -0,0 +1,25 @@
#pragma once
#include "esphome/components/graphical_layout/graphical_layout.h"
#include "esphome/components/graphical_layout/container_layout_item.h"
namespace esphome {
namespace graphical_layout {
/**
* The HorizontalStack is a UI element which will render a series of items left-to-right across a display
*/
class HorizontalStack : public ContainerLayoutItem {
public:
const display::Rect measure_item(display::Display *display);
void render(display::Display *display, display::Rect bounds);
void dump_config(int indent_depth, int additional_level_depth);
void set_item_padding(int item_padding) { this->item_padding_ = item_padding; };
protected:
int item_padding_{0};
};
}
}

View file

@ -0,0 +1,27 @@
import esphome.codegen as cg
import esphome.config_validation as cv
from esphome.const import CONF_ID
graphical_layout_ns = cg.esphome_ns.namespace("graphical_layout")
HorizontalStack = graphical_layout_ns.class_("HorizontalStack")
CONF_ITEM_PADDING = "item_padding"
CONF_TYPE = "horizontal_stack"
CONF_ITEMS = "items"
CONF_ITEM_TYPE = "type"
async def config_to_layout_item(item_config, child_item_builder):
var = cg.new_Pvariable(item_config[CONF_ID])
if item_padding_config := item_config[CONF_ITEM_PADDING]:
cg.add(var.set_item_padding(item_padding_config))
for child_item_config in item_config[CONF_ITEMS]:
child_item_type = child_item_config[CONF_ITEM_TYPE]
if child_item_type in child_item_builder:
child_item_var = await child_item_builder[child_item_type](child_item_config, child_item_builder)
cg.add(var.add_item(child_item_var))
else:
raise f"Do not know how to build type {child_item_type}"
return var

View file

@ -0,0 +1,37 @@
#pragma once
namespace esphome {
namespace display {
class Display;
class Rect;
}
namespace graphical_layout {
/** LayoutItem is the base from which all items derive from*/
class LayoutItem {
public:
/** Measures the item as it would be drawn on the display and returns the bounds for it
*
* param[in] display: Display that will be used for rendering. May be used to help with calculations
*/
virtual const display::Rect measure_item(display::Display *display) = 0;
/** Perform the rendering of the item to the display
*
* param[in] display: Display to render to
* param[in] bounds: Size of the area drawing should be constrained to
*/
virtual void render(display::Display *display, display::Rect bounds) = 0;
/**
* param[in] indent_depth: Depth to indent the config
* param[in] additional_level_depth: If children require their config to be dumped you increment
* their indent_depth before calling it
*/
virtual void dump_config(int indent_depth, int additional_level_depth) = 0;
};
}
}

View file

@ -0,0 +1,9 @@
import esphome.config_validation as cv
from typing import Awaitable, Any, Callable, Optional
class LayoutImport:
def __init__(self, name : str, schema_builder_func: Callable[[cv.Schema, cv.Schema, cv.Schema], cv.Schema], builder_func : Awaitable[Any], parent_schema_builder_func : Optional[Callable[[], cv.Schema]] = None):
self.name = name
self.schema_builder_func = schema_builder_func
self.builder_func = builder_func
self.parent_schema_builder_func = parent_schema_builder_func

View file

@ -0,0 +1,32 @@
#include "text_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 = "textpanel";
void TextPanel::dump_config(int indent_depth, int additional_level_depth) {
ESP_LOGCONFIG(TAG, "%*sText: %s", indent_depth, "", this->text_.c_str());
}
const display::Rect TextPanel::measure_item(display::Display *display) {
int x1;
int y1;
int width;
int height;
display->get_text_bounds(0, 0, this->text_.c_str(), this->font_, display::TextAlign::TOP_LEFT, &x1, &y1, &width, &height);
return display::Rect(0, 0, width, height);
}
void TextPanel::render(display::Display *display, display::Rect bounds) {
display->print(0, 0, this->font_, this->foreground_color_, display::TextAlign::TOP_LEFT, this->text_.c_str());
}
}
}

View file

@ -0,0 +1,36 @@
#pragma once
#include "esphome/components/graphical_layout/graphical_layout.h"
#include "esphome/components/font/font.h"
namespace esphome {
namespace graphical_layout {
const Color COLOR_ON(255, 255, 255, 255);
const Color COLOR_OFF(0, 0, 0, 0);
/** The TextPanel is a UI item that renders a single line of text to a display */
class TextPanel : public LayoutItem {
public:
const display::Rect measure_item(display::Display *display);
void render(display::Display *display, display::Rect bounds);
void dump_config(int indent_depth, int additional_level_depth);
void set_item_padding(int item_padding) { this->item_padding_ = item_padding; };
void set_text(std::string text) { this->text_ = text; };
void set_font(display::BaseFont *font) { 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; };
protected:
int item_padding_{0};
std::string text_{};
display::BaseFont *font_{nullptr};
Color foreground_color_{COLOR_ON};
Color background_color_{COLOR_OFF};
};
}
}

View file

@ -0,0 +1,48 @@
import esphome.codegen as cg
import esphome.config_validation as cv
from esphome.components import font, color
from esphome.const import CONF_ID
graphical_layout_ns = cg.esphome_ns.namespace("graphical_layout")
TextPanel = graphical_layout_ns.class_("TextPanel")
CONF_ITEM_PADDING = "item_padding"
CONF_TYPE = "text_panel"
CONF_FONT = "font"
CONF_FOREGROUND_COLOR = "foreground_color"
CONF_BACKGROUND_COLOR = "background_color"
CONF_TEXT = "text"
LAYOUT_ITEM_SCHEMA = cv.Schema(
{
cv.GenerateID(): cv.declare_id(TextPanel),
cv.Optional(CONF_ITEM_PADDING, default=0): cv.templatable(cv.int_),
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),
}
)
async def config_to_layout_item(item_config, child_item_builder):
var = cg.new_Pvariable(item_config[CONF_ID])
if item_padding_config := item_config[CONF_ITEM_PADDING]:
cg.add(var.set_item_padding(item_padding_config))
font = await cg.get_variable(item_config[CONF_FONT])
cg.add(var.set_font(font))
if foreground_color_config := item_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 := item_config.get(CONF_BACKGROUND_COLOR):
background_color = await cg.get_variable(background_color_config)
cg.add(var.set_background_color(background_color))
text = await cg.templatable(item_config[CONF_TEXT], args = [], output_type = str)
cg.add(var.set_text(text))
return var

View file

@ -0,0 +1,50 @@
#include "vertical_stack.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*TAG = "verticalstack";
void VerticalStack::dump_config(int indent_depth, int additional_level_depth) {
ESP_LOGCONFIG(TAG, "%*sItem Padding: %i", indent_depth, "", this->item_padding_);
ESP_LOGCONFIG(TAG, "%*sChildren: %i", indent_depth, "", this->children_.size());
for (LayoutItem *child : this->children_) {
child->dump_config(indent_depth + additional_level_depth, additional_level_depth);
}
}
const display::Rect VerticalStack::measure_item(display::Display *display) {
display::Rect rect(0, this->item_padding_, 0, 0);
for (LayoutItem *child : this->children_) {
display::Rect child_rect = child->measure_item(display);
rect.w = std::max(rect.w, child_rect.w);
rect.h += child_rect.h + this->item_padding_;
}
// Add item padding left and right
rect.h += (this->item_padding_ * 2);
return rect;
}
void VerticalStack::render(display::Display *display, display::Rect bounds) {
int height_offset = this->item_padding_;
for (LayoutItem *item : this->children_) {
display::Rect measure = item->measure_item(display);
display->set_local_coordinates_relative_to_current(this->item_padding_, height_offset);
item->render(display, measure);
display->pop_local_coordinates();
height_offset += measure.h + this->item_padding_;
}
}
}
}

View file

@ -0,0 +1,25 @@
#pragma once
#include "esphome/components/graphical_layout/graphical_layout.h"
#include "esphome/components/graphical_layout/container_layout_item.h"
namespace esphome {
namespace graphical_layout {
/** The HorizontalStack is a UI element which will render a series of items top to bottom down a display
*/
class VerticalStack : public ContainerLayoutItem {
public:
const display::Rect measure_item(display::Display *display);
void render(display::Display *display, display::Rect bounds);
void dump_config(int indent_depth, int additional_level_depth);
void set_item_padding(int item_padding) { this->item_padding_ = item_padding; };
protected:
int item_padding_{0};
};
}
}

View file

@ -0,0 +1,28 @@
import esphome.codegen as cg
import esphome.config_validation as cv
from esphome.const import CONF_ID
graphical_layout_ns = cg.esphome_ns.namespace("graphical_layout")
VerticalStack = graphical_layout_ns.class_("VerticalStack")
CONF_ITEM_PADDING = "item_padding"
CONF_TYPE = "vertical_stack"
CONF_ITEMS = "items"
CONF_ITEM_TYPE = "type"
async def config_to_layout_item(item_config, child_item_builder):
var = cg.new_Pvariable(item_config[CONF_ID])
if item_padding_config := item_config[CONF_ITEM_PADDING]:
cg.add(var.set_item_padding(item_padding_config))
for child_item_config in item_config[CONF_ITEMS]:
child_item_type = child_item_config[CONF_ITEM_TYPE]
if child_item_type in child_item_builder:
child_item_var = await child_item_builder[child_item_type](child_item_config, child_item_builder)
cg.add(var.add_item(child_item_var))
else:
raise f"Do not know how to build type {child_item_type}"
return var

View file

@ -51,6 +51,7 @@
#define USE_UART_DEBUGGER
#define USE_WIFI
#define USE_WIFI_AP
#define USE_GRAPHICAL_LAYOUT
// Arduino-specific feature flags
#ifdef USE_ARDUINO