[lvgl] Add a color picker widget (#17883)

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
This commit is contained in:
Clyde Stubbs
2026-10-12 15:12:22 +11:00
committed by GitHub
co-authored by Claude Opus 5 J. Nick Koston
parent 5c1c946e88
commit 3f21ddf6ff
16 changed files with 2171 additions and 25 deletions
+625
View File
@@ -0,0 +1,625 @@
#pragma once
#include "lvgl_esphome.h"
#include "esphome/core/color.h"
#include <algorithm>
#include <array>
#include <cmath>
#include <cstdio>
#include <numbers>
#ifdef USE_LVGL_COLOR_PICKER
namespace esphome::lvgl {
// The hue gradients need seven stops; gradient.py raises the limit when a colour picker is in use.
static_assert(LV_GRADIENT_MAX_STOPS >= 7, "color_picker needs LV_GRADIENT_MAX_STOPS >= 7");
class LvColorPickerType : public LvCompound {
public:
enum SliderIndex : size_t {
SLIDER_HUE,
SLIDER_SATURATION,
SLIDER_BRIGHTNESS,
SLIDER_RED,
SLIDER_GREEN,
SLIDER_BLUE,
SLIDER_COUNT,
};
enum SliderFlag : uint8_t {
SLIDER_FLAG_HUE = 1 << SLIDER_HUE,
SLIDER_FLAG_SATURATION = 1 << SLIDER_SATURATION,
SLIDER_FLAG_BRIGHTNESS = 1 << SLIDER_BRIGHTNESS,
SLIDER_FLAG_RED = 1 << SLIDER_RED,
SLIDER_FLAG_GREEN = 1 << SLIDER_GREEN,
SLIDER_FLAG_BLUE = 1 << SLIDER_BLUE,
};
explicit LvColorPickerType(uint8_t sliders, bool use_wheel) : sliders_mask_(sliders), use_wheel_(use_wheel) {}
// Returns null for a slider the widget was not built with.
lv_obj_t *get_slider(size_t index) const { return this->sliders_[index]; }
void set_tint_knobs(bool tint_knobs) { this->tint_knobs_ = tint_knobs; }
void set_color(lv_color_t color) {
lv_color32_t c32 = lv_color_to_32(color, LV_OPA_COVER);
this->state_ = Color(c32.red, c32.green, c32.blue);
if (this->obj != nullptr)
this->update_color_();
}
Color get_color() const { return this->state_; }
void set_obj(lv_obj_t *outer) override {
LvCompound::set_obj(outer);
init_shared();
// The far end of these two follows the current hue.
init_bar(&this->saturation_bar_, LV_GRAD_DIR_HOR, lv_color_white(), lv_color_hex(0xFF0000));
init_bar(&this->brightness_bar_, LV_GRAD_DIR_HOR, lv_color_black(), lv_color_white());
auto channels = static_cast<int32_t>(this->enabled_(SLIDER_RED)) +
static_cast<int32_t>(this->enabled_(SLIDER_GREEN)) +
static_cast<int32_t>(this->enabled_(SLIDER_BLUE));
// No layout on the outer object, so LV_SIZE_CONTENT uses the size from self_size_cb.
lv_obj_add_event_cb(outer, self_size_cb, LV_EVENT_GET_SELF_SIZE, nullptr);
lv_obj_add_event_cb(outer, size_changed_cb, LV_EVENT_SIZE_CHANGED, this);
lv_obj_set_style_pad_all(outer, KNOB_OVERHANG, LV_PART_MAIN);
int32_t left_width = 0;
lv_obj_t *left = nullptr;
if (this->enabled_(SLIDER_HUE)) {
left_width = HUE_WIDTH_PCT;
left = this->create_slider_(outer, SLIDER_HUE, "Hue", true, &hue_bar, 360);
lv_obj_set_size(left, lv_pct(HUE_WIDTH_PCT), lv_pct(100));
}
#ifdef USE_LVGL_COLOR_PICKER_WHEEL
else if (this->use_wheel_) {
left_width = WHEEL_WIDTH_PCT;
left = this->create_wheel_(outer);
lv_obj_set_size(left, lv_pct(WHEEL_WIDTH_PCT), lv_pct(WHEEL_WIDTH_PCT));
}
#endif
if (left != nullptr) {
lv_obj_set_style_align(left, LV_ALIGN_LEFT_MID, LV_PART_MAIN);
// A narrow hue column may be slimmer than its label.
lv_obj_add_flag(left, LV_OBJ_FLAG_OVERFLOW_VISIBLE);
}
// The text rows keep their natural height and the bars between them take what is left,
// so a small widget gets thinner bars rather than clipped text.
auto *middle = create_flex(outer, LV_FLEX_FLOW_COLUMN, LV_FLEX_ALIGN_START);
lv_obj_set_style_align(middle, LV_ALIGN_RIGHT_MID, LV_PART_MAIN);
lv_obj_set_style_pad_row(middle, KNOB_OVERHANG, LV_PART_MAIN);
lv_obj_set_size(middle, lv_pct(100 - left_width), lv_pct(100));
// A dark bar needs light text, unlike every other value label.
if (this->enabled_(SLIDER_BRIGHTNESS))
this->create_row_slider_(middle, SLIDER_BRIGHTNESS, "Brightness", &this->brightness_bar_, lv_color_white());
auto *inner = create_flex(middle, LV_FLEX_FLOW_ROW, LV_FLEX_ALIGN_START);
lv_obj_set_width(inner, lv_pct(100));
lv_obj_set_flex_grow(inner, 1);
// The channel bars keep a fixed width each, so dropping one widens the swatch.
auto channels_width = CHANNEL_WIDTH_PCT * channels;
auto *indicator_container = create_flex(inner, LV_FLEX_FLOW_COLUMN, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER);
lv_obj_set_size(indicator_container, lv_pct(CONTENT_WIDTH_PCT - channels_width), lv_pct(100));
lv_obj_set_style_pad_ver(indicator_container, KNOB_OVERHANG, LV_PART_MAIN);
lv_obj_set_style_pad_row(indicator_container, KNOB_OVERHANG, LV_PART_MAIN);
this->color_text_ = lv_label_create(indicator_container);
this->color_indicator_ = lv_obj_create(indicator_container);
lv_obj_set_style_border_color(this->color_indicator_, lv_color_hex(0x808080), LV_PART_MAIN);
lv_obj_set_style_border_width(this->color_indicator_, 1, LV_PART_MAIN);
lv_obj_set_style_radius(this->color_indicator_, 0, LV_PART_MAIN);
lv_obj_set_width(this->color_indicator_, lv_pct(90));
lv_obj_set_flex_grow(this->color_indicator_, 1);
if (channels != 0) {
auto *rgb_container = create_flex(inner, LV_FLEX_FLOW_ROW, LV_FLEX_ALIGN_SPACE_BETWEEN);
lv_obj_set_size(rgb_container, lv_pct(channels_width), lv_pct(100));
struct ChannelInfo {
SliderIndex index;
const char *name;
const lv_grad_dsc_t *grad;
};
const std::array<ChannelInfo, 3> channel_info{{
{SLIDER_RED, "R", &red_bar},
{SLIDER_GREEN, "G", &green_bar},
{SLIDER_BLUE, "B", &blue_bar},
}};
for (const auto &channel : channel_info) {
if (!this->enabled_(channel.index))
continue;
auto *container = this->create_slider_(rgb_container, channel.index, channel.name, true, channel.grad, 255);
lv_obj_set_size(container, lv_pct(100 / channels), lv_pct(100));
this->add_value_label_(channel.index, lv_color_black());
}
}
if (this->enabled_(SLIDER_SATURATION))
this->create_row_slider_(middle, SLIDER_SATURATION, "Saturation", &this->saturation_bar_, lv_color_black());
this->update_color_();
for (size_t index = 0; index < SLIDER_COUNT; index++) {
if (this->sliders_[index] == nullptr)
continue;
lv_obj_add_event_cb(this->sliders_[index], index <= SLIDER_BRIGHTNESS ? hsv_changed_cb : rgb_changed_cb,
LV_EVENT_VALUE_CHANGED, this);
}
bubble_events(outer);
}
protected:
// LVGL keeps only pointers to gradients and styles, so one copy serves every colour picker.
static void init_shared() {
if (shared_ready)
return;
shared_ready = true;
// Clockwise from red at 3 o'clock, matching how lv_color_hsv_to_rgb() reads the angle.
std::array<lv_color_t, 7> hues{lv_color_hex(0xFF0000), lv_color_hex(0xFFFF00), lv_color_hex(0x00FF00),
lv_color_hex(0x00FFFF), lv_color_hex(0x0000FF), lv_color_hex(0xFF00FF),
lv_color_hex(0xFF0000)};
#ifdef USE_LVGL_COLOR_PICKER_WHEEL
lv_grad_conical_init(&wheel_hue_grad, lv_pct(50), lv_pct(50), 0, 360, LV_GRAD_EXTEND_PAD);
lv_grad_init_stops(&wheel_hue_grad, hues.data(), nullptr, nullptr, static_cast<int>(hues.size()));
// White at the centre fading out at the rim, giving saturation over the hue wheel.
const lv_color_t whites[] = {lv_color_white(), lv_color_white()};
const lv_opa_t white_opas[] = {LV_OPA_COVER, LV_OPA_TRANSP};
lv_grad_radial_init(&wheel_white_grad, lv_pct(50), lv_pct(50), lv_pct(100), lv_pct(50), LV_GRAD_EXTEND_PAD);
lv_grad_init_stops(&wheel_white_grad, whites, white_opas, nullptr, 2);
#endif
// A vertical slider has its minimum at the bottom, so the hue bar runs the other way.
std::reverse(hues.begin(), hues.end());
lv_grad_vertical_init(&hue_bar);
lv_grad_init_stops(&hue_bar, hues.data(), nullptr, nullptr, static_cast<int>(hues.size()));
init_bar(&red_bar, LV_GRAD_DIR_VER, lv_color_hex(0xFF0000), lv_color_white());
init_bar(&green_bar, LV_GRAD_DIR_VER, lv_color_hex(0x00FF00), lv_color_white());
init_bar(&blue_bar, LV_GRAD_DIR_VER, lv_color_hex(0x0000FF), lv_color_white());
lv_style_init(&plain_box);
lv_style_set_bg_opa(&plain_box, LV_OPA_TRANSP);
lv_style_set_border_width(&plain_box, 0);
lv_style_set_pad_all(&plain_box, 0);
lv_style_set_pad_row(&plain_box, 0);
lv_style_set_pad_column(&plain_box, 0);
init_slider_styles(&slider_vert, &slider_vert_knob, true);
init_slider_styles(&slider_horz, &slider_horz_knob, false);
}
static void init_bar(lv_grad_dsc_t *grad, lv_grad_dir_t dir, lv_color_t from, lv_color_t to) {
const lv_color_t colors[] = {from, to};
grad->dir = dir;
lv_grad_init_stops(grad, colors, nullptr, nullptr, 2);
}
static void init_slider_styles(lv_style_t *bar, lv_style_t *knob, bool vertical) {
lv_style_init(bar);
lv_style_set_align(bar, LV_ALIGN_CENTER);
lv_style_set_bg_opa(bar, LV_OPA_COVER);
lv_style_set_pad_all(bar, 0);
lv_style_set_radius(bar, 0);
// A horizontal bar's thickness is set by update_bar_thickness_().
if (vertical) {
lv_style_set_width(bar, lv_pct(50));
lv_style_set_height(bar, lv_pct(90));
} else {
lv_style_set_width(bar, lv_pct(90));
}
lv_style_init(knob);
lv_style_set_bg_color(knob, lv_color_white());
lv_style_set_border_width(knob, 1);
lv_style_set_outline_color(knob, lv_color_black());
lv_style_set_radius(knob, 0);
// Both axes are set, or the theme picks a knob size the layout has not left room for.
int32_t along = -KNOB_TRIM;
int32_t across = KNOB_OVERHANG;
lv_style_set_pad_top(knob, vertical ? along : across);
lv_style_set_pad_bottom(knob, vertical ? along : across);
lv_style_set_pad_left(knob, vertical ? across : along);
lv_style_set_pad_right(knob, vertical ? across : along);
}
// A borderless, unpadded container.
static lv_obj_t *create_box(lv_obj_t *parent) {
auto *obj = lv_obj_create(parent);
lv_obj_add_style(obj, &plain_box, LV_PART_MAIN);
lv_obj_remove_flag(obj, LV_OBJ_FLAG_SCROLLABLE);
return obj;
}
static lv_obj_t *create_flex(lv_obj_t *parent, lv_flex_flow_t flow, lv_flex_align_t main_place,
lv_flex_align_t track_place = LV_FLEX_ALIGN_START) {
auto *obj = create_box(parent);
lv_obj_set_layout(obj, LV_LAYOUT_FLEX);
lv_obj_set_flex_flow(obj, flow);
lv_obj_set_flex_align(obj, main_place, LV_FLEX_ALIGN_CENTER, track_place);
return obj;
}
// Preferred size, in lines of the text font. The widget is square.
static constexpr int SELF_SIZE_FONT_MULTIPLE = 16;
// At either end of its travel half the knob lies outside the bar. KNOB_TRIM pulls it back
// along the bar, and KNOB_OVERHANG makes it stand out across the bar.
static constexpr int KNOB_TRIM = 4;
static constexpr int KNOB_OVERHANG = 4;
// Layout proportions, as percentages.
static constexpr int32_t HUE_WIDTH_PCT = 15;
static constexpr int32_t CHANNEL_WIDTH_PCT = 19;
static constexpr int32_t CONTENT_WIDTH_PCT = 99;
static constexpr int32_t WHEEL_WIDTH_PCT = 45;
static constexpr int32_t WHEEL_HANDLE_SIZE = 14;
bool enabled_(SliderIndex index) const { return (this->sliders_mask_ & (1 << index)) != 0; }
bool has_(SliderIndex index) const { return this->sliders_[index] != nullptr; }
bool can_change_saturation_() const {
return this->use_wheel_ || (this->sliders_mask_ &
(SLIDER_FLAG_SATURATION | SLIDER_FLAG_RED | SLIDER_FLAG_GREEN | SLIDER_FLAG_BLUE)) != 0;
}
bool can_change_brightness_() const {
return (this->sliders_mask_ & (SLIDER_FLAG_BRIGHTNESS | SLIDER_FLAG_RED | SLIDER_FLAG_GREEN | SLIDER_FLAG_BLUE)) !=
0;
}
// A slider the widget was built without keeps the value held internally.
int32_t slider_value_(SliderIndex index, int32_t fallback) const {
return this->has_(index) ? lv_slider_get_value(this->sliders_[index]) : fallback;
}
void set_slider_value_(SliderIndex index, int32_t value) {
if (this->has_(index))
lv_slider_set_value(this->sliders_[index], value, LV_ANIM_OFF);
}
// Builds a slider with a name label above it, and returns its container.
lv_obj_t *create_slider_(lv_obj_t *parent, SliderIndex index, const char *name, bool vertical,
const lv_grad_dsc_t *grad, int32_t max) {
// A column keeps the label on its own row however large the font is.
auto *container = create_flex(parent, LV_FLEX_FLOW_COLUMN, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER);
if (vertical) {
// How far the knob reaches past the ends depends on the font, so follow style changes.
update_knob_clearance(container);
lv_obj_add_event_cb(container, knob_clearance_cb, LV_EVENT_STYLE_CHANGED, nullptr);
} else {
lv_obj_set_style_pad_bottom(container, KNOB_OVERHANG, LV_PART_MAIN);
lv_obj_set_style_pad_row(container, KNOB_OVERHANG, LV_PART_MAIN);
}
lv_label_set_text(lv_label_create(container), name);
auto *slider = lv_slider_create(container);
this->sliders_[index] = slider;
lv_obj_add_style(slider, vertical ? &slider_vert : &slider_horz, LV_PART_MAIN);
lv_obj_set_style_bg_grad(slider, grad, LV_PART_MAIN);
lv_obj_set_style_bg_opa(slider, LV_OPA_TRANSP, LV_PART_INDICATOR);
lv_obj_add_style(slider, vertical ? &slider_vert_knob : &slider_horz_knob, LV_PART_KNOB);
if (vertical)
lv_obj_set_flex_grow(slider, 1);
lv_slider_set_range(slider, 0, max);
lv_slider_set_mode(slider, LV_SLIDER_MODE_NORMAL);
return container;
}
void create_row_slider_(lv_obj_t *parent, SliderIndex index, const char *name, const lv_grad_dsc_t *grad,
lv_color_t text_color) {
auto *container = this->create_slider_(parent, index, name, false, grad, 100);
lv_obj_set_size(container, lv_pct(100), LV_SIZE_CONTENT);
this->add_value_label_(index, text_color);
}
#ifdef USE_LVGL_COLOR_PICKER_WHEEL
// A full-size circle filled with a gradient.
static lv_obj_t *create_disc(lv_obj_t *parent, const lv_grad_dsc_t *grad) {
auto *disc = create_box(parent);
lv_obj_set_size(disc, lv_pct(100), lv_pct(100));
lv_obj_set_style_align(disc, LV_ALIGN_CENTER, LV_PART_MAIN);
lv_obj_set_style_radius(disc, LV_RADIUS_CIRCLE, LV_PART_MAIN);
lv_obj_set_style_bg_opa(disc, LV_OPA_COVER, LV_PART_MAIN);
lv_obj_set_style_bg_grad(disc, grad, LV_PART_MAIN);
lv_obj_remove_flag(disc, LV_OBJ_FLAG_SCROLLABLE);
return disc;
}
// Builds the colour wheel that replaces the hue and saturation sliders, and returns its container.
lv_obj_t *create_wheel_(lv_obj_t *parent) {
auto *container = create_box(parent);
this->wheel_ = create_disc(container, &wheel_hue_grad);
// Not clickable, so touches fall through to the wheel underneath.
auto *overlay = create_disc(this->wheel_, &wheel_white_grad);
lv_obj_remove_flag(overlay, LV_OBJ_FLAG_CLICKABLE);
this->wheel_handle_ = lv_obj_create(this->wheel_);
lv_obj_set_size(this->wheel_handle_, WHEEL_HANDLE_SIZE, WHEEL_HANDLE_SIZE);
lv_obj_set_style_align(this->wheel_handle_, LV_ALIGN_CENTER, LV_PART_MAIN);
lv_obj_set_style_radius(this->wheel_handle_, LV_RADIUS_CIRCLE, LV_PART_MAIN);
lv_obj_set_style_bg_opa(this->wheel_handle_, LV_OPA_TRANSP, LV_PART_MAIN);
lv_obj_set_style_border_width(this->wheel_handle_, 2, LV_PART_MAIN);
lv_obj_set_style_border_color(this->wheel_handle_, lv_color_white(), LV_PART_MAIN);
lv_obj_remove_flag(this->wheel_handle_, LV_OBJ_FLAG_CLICKABLE);
lv_obj_remove_flag(this->wheel_handle_, LV_OBJ_FLAG_SCROLLABLE);
// PRESSED as well, so a tap takes effect without the finger moving.
lv_obj_add_event_cb(this->wheel_, wheel_touch_cb, LV_EVENT_PRESSED, this);
lv_obj_add_event_cb(this->wheel_, wheel_touch_cb, LV_EVENT_PRESSING, this);
// The handle can only be placed once the wheel has been laid out.
lv_obj_add_event_cb(this->wheel_, wheel_size_cb, LV_EVENT_SIZE_CHANGED, this);
return container;
}
#endif
void add_value_label_(SliderIndex index, lv_color_t color) {
auto *slider = this->sliders_[index];
lv_obj_add_flag(slider, LV_OBJ_FLAG_OVERFLOW_VISIBLE);
auto *label = lv_label_create(slider);
this->values_[index] = label;
lv_obj_set_style_align(label, LV_ALIGN_CENTER, LV_PART_MAIN);
lv_obj_set_style_text_color(label, color, LV_PART_MAIN);
}
// Leaves room at the ends of a vertical slider for the overhanging knob: half a line of text.
static void update_knob_clearance(lv_obj_t *container) {
const auto *font = lv_obj_get_style_text_font(container, LV_PART_MAIN);
auto clearance = static_cast<int32_t>(lv_font_get_line_height(font)) / 2;
if (clearance == lv_obj_get_style_pad_bottom(container, LV_PART_MAIN))
return;
lv_obj_set_style_pad_bottom(container, clearance, LV_PART_MAIN);
lv_obj_set_style_pad_row(container, clearance, LV_PART_MAIN);
}
static void knob_clearance_cb(lv_event_t *event) { update_knob_clearance(lv_event_get_current_target_obj(event)); }
static LvColorPickerType *from_event(lv_event_t *event) {
return static_cast<LvColorPickerType *>(lv_event_get_user_data(event));
}
static void hsv_changed_cb(lv_event_t *event) { from_event(event)->update_hsv_(); }
static void rgb_changed_cb(lv_event_t *event) { from_event(event)->update_rgb_(); }
#ifdef USE_LVGL_COLOR_PICKER_WHEEL
static void wheel_touch_cb(lv_event_t *event) { from_event(event)->update_from_wheel_touch_(); }
static void wheel_size_cb(lv_event_t *event) { from_event(event)->update_wheel_handle_(); }
#endif
static void size_changed_cb(lv_event_t *event) { from_event(event)->update_bar_thickness_(); }
// Gives the horizontal bars the thickness of the vertical ones, half a hue column, so every
// knob is the same size. A knob is sized from the thickness of its bar.
void update_bar_thickness_() {
auto thickness = lv_obj_get_content_width(this->obj) * HUE_WIDTH_PCT / 200;
for (auto index : {SLIDER_SATURATION, SLIDER_BRIGHTNESS}) {
if (this->has_(index) && lv_obj_get_style_height(this->sliders_[index], LV_PART_MAIN) != thickness)
lv_obj_set_style_height(this->sliders_[index], thickness, LV_PART_MAIN);
}
}
// LVGL also counts this size when working out the scroll area, so a widget given a fixed
// width must not claim it, or it would scroll whenever it is smaller.
static void self_size_cb(lv_event_t *event) {
auto *obj = lv_event_get_current_target_obj(event);
if (lv_obj_get_style_width(obj, LV_PART_MAIN) != LV_SIZE_CONTENT)
return;
const auto *font = lv_obj_get_style_text_font(obj, LV_PART_MAIN);
auto self_size = static_cast<int32_t>(lv_font_get_line_height(font)) * SELF_SIZE_FONT_MULTIPLE;
auto *point = static_cast<lv_point_t *>(lv_event_get_param(event));
point->x = self_size;
point->y = self_size;
}
void update_text_() const {
// "#" + 3x "%02X" + null
char buf[8];
snprintf(buf, sizeof(buf), "#%02X%02X%02X", static_cast<unsigned>(this->state_.r),
static_cast<unsigned>(this->state_.g), static_cast<unsigned>(this->state_.b));
lv_label_set_text(this->color_text_, buf);
}
void update_hue_bar_(SliderIndex index, lv_grad_dsc_t *grad, lv_color_t color) {
if (!this->has_(index))
return;
grad->stops[1].color = color;
lv_obj_invalidate(this->sliders_[index]);
}
void update_values_() const {
for (size_t index = 0; index < SLIDER_COUNT; index++) {
if (this->values_[index] == nullptr)
continue;
// up to 3 digits + null
char buf[4];
snprintf(buf, sizeof(buf), "%d", static_cast<int>(lv_slider_get_value(this->sliders_[index])));
lv_label_set_text(this->values_[index], buf);
}
}
void tint_knob_(SliderIndex index, lv_color_t color) const {
if (this->has_(index))
lv_obj_set_style_bg_color(this->sliders_[index], color, LV_PART_KNOB);
}
// Tints each knob with the colour of its bar at the current value. The brightness knob
// shows the selected colour instead, since its bar leaves out saturation.
void update_knobs_(lv_color_t full_hue) const {
if (!this->tint_knobs_)
return;
this->tint_knob_(SLIDER_HUE, full_hue);
this->tint_knob_(SLIDER_SATURATION, lv_color_hsv_to_rgb(this->hue_, this->saturation_, 100));
this->tint_knob_(SLIDER_BRIGHTNESS, this->state_);
this->tint_knob_(SLIDER_RED, lv_color_make(255, 255 - this->state_.r, 255 - this->state_.r));
this->tint_knob_(SLIDER_GREEN, lv_color_make(255 - this->state_.g, 255, 255 - this->state_.g));
this->tint_knob_(SLIDER_BLUE, lv_color_make(255 - this->state_.b, 255 - this->state_.b, 255));
}
#ifdef USE_LVGL_COLOR_PICKER_WHEEL
// Returns the wheel's radius, which is zero until it has been laid out.
double wheel_radius_(lv_area_t *area) const {
lv_obj_get_coords(this->wheel_, area);
return static_cast<double>(area->x2 - area->x1) / 2.0;
}
void update_wheel_handle_() {
if (this->wheel_handle_ == nullptr)
return;
lv_area_t area;
auto radius = this->wheel_radius_(&area);
if (radius <= 0)
return;
auto angle_rad = static_cast<double>(this->hue_) * (std::numbers::pi / 180.0);
auto distance = (static_cast<double>(this->saturation_) / 100.0) * radius;
lv_obj_set_style_translate_x(this->wheel_handle_, static_cast<int32_t>(std::lround(distance * std::cos(angle_rad))),
LV_PART_MAIN);
lv_obj_set_style_translate_y(this->wheel_handle_, static_cast<int32_t>(std::lround(distance * std::sin(angle_rad))),
LV_PART_MAIN);
}
// The angle from the centre gives the hue, the distance the saturation. Brightness is unchanged.
void update_from_wheel_touch_() {
lv_indev_t *indev = lv_indev_active();
if (indev == nullptr)
return;
lv_point_t point;
lv_indev_get_point(indev, &point);
lv_area_t area;
auto radius = this->wheel_radius_(&area);
if (radius <= 0)
return;
auto dx = point.x - (area.x1 + area.x2) / 2.0;
auto dy = point.y - (area.y1 + area.y2) / 2.0;
auto angle = std::atan2(dy, dx) * (180.0 / std::numbers::pi);
if (angle < 0)
angle += 360.0;
this->hue_ = static_cast<uint16_t>(std::lround(angle)) % 360;
auto distance = std::sqrt(dx * dx + dy * dy);
this->saturation_ = static_cast<uint8_t>(std::lround(std::min(100.0, distance * 100.0 / radius)));
this->brightness_ = static_cast<uint8_t>(this->slider_value_(SLIDER_BRIGHTNESS, this->brightness_));
this->apply_hsv_();
// Only sliders send this themselves; without it on_value would not fire for the wheel.
lv_obj_send_event(this->wheel_, LV_EVENT_VALUE_CHANGED, nullptr);
}
#endif
// Lets events from the internal sliders reach the widget, where the triggers listen. Every
// object in between needs the flag; the widget itself does not, so events stop there.
static void bubble_events(lv_obj_t *obj) {
for (uint32_t i = 0; i < lv_obj_get_child_count(obj); i++) {
auto *child = lv_obj_get_child(obj, i);
lv_obj_add_flag(child, LV_OBJ_FLAG_EVENT_BUBBLE);
bubble_events(child);
}
}
void refresh_() {
lv_obj_set_style_bg_color(this->color_indicator_, this->state_, LV_PART_MAIN);
auto full_hue = lv_color_hsv_to_rgb(this->hue_, 100, 100);
this->update_hue_bar_(SLIDER_SATURATION, &this->saturation_bar_, full_hue);
this->update_hue_bar_(SLIDER_BRIGHTNESS, &this->brightness_bar_, full_hue);
this->update_text_();
this->update_values_();
this->update_knobs_(full_hue);
#ifdef USE_LVGL_COLOR_PICKER_WHEEL
this->update_wheel_handle_();
#endif
}
// Sets the colour from the hue, saturation and brightness, and moves the RGB sliders to match.
void apply_hsv_() {
lv_color32_t c32 =
lv_color_to_32(lv_color_hsv_to_rgb(this->hue_, this->saturation_, this->brightness_), LV_OPA_COVER);
this->state_ = Color(c32.red, c32.green, c32.blue);
this->set_slider_value_(SLIDER_RED, c32.red);
this->set_slider_value_(SLIDER_GREEN, c32.green);
this->set_slider_value_(SLIDER_BLUE, c32.blue);
this->refresh_();
}
void update_hsv_() {
this->hue_ = static_cast<uint16_t>(this->slider_value_(SLIDER_HUE, this->hue_));
this->saturation_ = static_cast<uint8_t>(this->slider_value_(SLIDER_SATURATION, this->saturation_));
this->brightness_ = static_cast<uint8_t>(this->slider_value_(SLIDER_BRIGHTNESS, this->brightness_));
this->apply_hsv_();
}
void update_rgb_() {
this->state_ = Color(static_cast<uint8_t>(this->slider_value_(SLIDER_RED, this->state_.r)),
static_cast<uint8_t>(this->slider_value_(SLIDER_GREEN, this->state_.g)),
static_cast<uint8_t>(this->slider_value_(SLIDER_BLUE, this->state_.b)));
auto hsv = lv_color_rgb_to_hsv(this->state_.r, this->state_.g, this->state_.b);
this->hue_ = hsv.h;
this->saturation_ = hsv.s;
this->brightness_ = hsv.v;
// A grey, white or black colour leaves a picker stuck there if nothing it has can move the
// saturation, or the brightness, away from zero.
bool stuck = false;
if (this->saturation_ == 0 && !this->can_change_saturation_()) {
this->saturation_ = 100;
stuck = true;
}
if (this->brightness_ == 0 && !this->can_change_brightness_()) {
this->brightness_ = 100;
stuck = true;
}
this->set_slider_value_(SLIDER_HUE, this->hue_);
this->set_slider_value_(SLIDER_SATURATION, this->saturation_);
this->set_slider_value_(SLIDER_BRIGHTNESS, this->brightness_);
if (stuck) {
this->apply_hsv_();
} else {
this->refresh_();
}
}
void update_color_() {
this->set_slider_value_(SLIDER_RED, this->state_.r);
this->set_slider_value_(SLIDER_GREEN, this->state_.g);
this->set_slider_value_(SLIDER_BLUE, this->state_.b);
this->update_rgb_();
lv_obj_invalidate(this->obj);
}
// Shared by every colour picker; set up once by init_shared().
inline static bool shared_ready{false};
inline static lv_grad_dsc_t hue_bar{};
inline static lv_grad_dsc_t red_bar{};
inline static lv_grad_dsc_t green_bar{};
inline static lv_grad_dsc_t blue_bar{};
#ifdef USE_LVGL_COLOR_PICKER_WHEEL
inline static lv_grad_dsc_t wheel_hue_grad{};
inline static lv_grad_dsc_t wheel_white_grad{};
#endif
inline static lv_style_t plain_box{};
inline static lv_style_t slider_vert{};
inline static lv_style_t slider_vert_knob{};
inline static lv_style_t slider_horz{};
inline static lv_style_t slider_horz_knob{};
// Per widget, since their far end follows the chosen hue.
lv_grad_dsc_t saturation_bar_{};
lv_grad_dsc_t brightness_bar_{};
Color state_{0x80, 0x80, 0x80};
std::array<lv_obj_t *, SLIDER_COUNT> sliders_{};
std::array<lv_obj_t *, SLIDER_COUNT> values_{};
lv_obj_t *color_indicator_{};
lv_obj_t *color_text_{};
// Kept alongside state_ so a slider left out of the widget still has a value.
uint16_t hue_{0};
uint8_t saturation_{0};
uint8_t brightness_{50};
const uint8_t sliders_mask_;
const bool use_wheel_;
bool tint_knobs_{true};
#ifdef USE_LVGL_COLOR_PICKER_WHEEL
// Null unless use_wheel_ is set.
lv_obj_t *wheel_{};
lv_obj_t *wheel_handle_{};
#endif
};
} // namespace esphome::lvgl
#endif // USE_LVGL_COLOR_PICKER
+3 -2
View File
@@ -143,8 +143,8 @@ def get_theme_styles() -> dict[str, MockObj]:
return _get_data(KEY_THEME_STYLES, {})
def get_widget_theme_style_data() -> dict[str, list[tuple[MockObj, MockObj]]]:
"""Get the map of widget type names to the list of (style variable, part/state name)"""
def get_widget_theme_style_data() -> dict[str, list[tuple[MockObj, str, str]]]:
"""Get the map of widget type names to the list of (style variable, part, state)"""
return _get_data(KEY_WIDGET_THEME_STYLES, {})
@@ -723,6 +723,7 @@ CONF_CHANGE_RATE = "change_rate"
CONF_CLICKABLE = "clickable"
CONF_CLOSE_BUTTON = "close_button"
CONF_COLOR_END = "color_end"
CONF_COLOR_PICKER = "color_picker"
CONF_COLOR_START = "color_start"
CONF_CONTAINER = "container"
CONF_CONTROL = "control"
+3 -1
View File
@@ -13,6 +13,7 @@ from esphome.core import ID
from esphome.cpp_generator import MockObj
from .defines import (
CONF_COLOR_PICKER,
CONF_END_ANGLE,
CONF_GRADIENTS,
CONF_OPA,
@@ -22,6 +23,7 @@ from .defines import (
add_define,
add_lv_use,
add_warning,
get_lv_uses,
)
from .lv_validation import (
lv_angle_degrees,
@@ -164,7 +166,7 @@ GRADIENT_SCHEMA = cv.ensure_list(
async def gradients_to_code(config):
add_lv_use("gradient")
max_stops = 2
max_stops = 7 if CONF_COLOR_PICKER in get_lv_uses() else 2
if any(CONF_DITHER in x for x in config.get(CONF_GRADIENTS, ())):
add_warning(
"The 'dither' option for gradients is not supported by LVGL 9.x and will be ignored"
+19 -7
View File
@@ -12,7 +12,7 @@ from .defines import (
STATES,
LValidator,
add_lv_use,
get_part_state_selector,
add_warning,
get_styles_used,
get_theme_styles,
get_theme_update_requests,
@@ -28,7 +28,7 @@ from .schemas import (
theme_update_schema,
)
from .types import ObjUpdateAction, lv_style_t
from .widgets import collect_parts
from .widgets import collect_parts, misplaced_obj_properties
def has_style_props(config) -> bool:
@@ -77,9 +77,20 @@ class LVStyle:
return cls.named_styles.setdefault(id_name, LVStyle(id_name))
def _warn_misplaced_obj_properties(config: dict, owner: str) -> None:
"""Warn about widget properties in a style or theme, where they have no effect."""
for where, prop in misplaced_obj_properties(config, include_top=True):
place = f"{owner} under '{where}'" if where else owner
add_warning(
f"'{prop}' applies to a widget, not to a style, so setting it in {place} has "
f"no effect. Set it on each widget instead."
)
async def styles_to_code(config):
"""Convert styles to C__ code."""
for style in config.get(CONF_STYLE_DEFINITIONS, ()):
_warn_misplaced_obj_properties(style, f"style '{style[CONF_ID].id}'")
await create_style(style[CONF_ID].id, style)
@@ -109,8 +120,8 @@ def _get_theme_style_name(w_name: str, part: str, state: str) -> str:
return f"_lv_theme_style_{w_name}_{part}_{state}"
def get_widget_theme_styles(w_name: str) -> list[tuple[MockObj, MockObj]]:
"""Return a list of (style variable, part/state name) for all theme styles used by the given widget type."""
def get_widget_theme_styles(w_name: str) -> list[tuple[MockObj, str, str]]:
"""Return a list of (style variable, part, state) for all theme styles used by the given widget type."""
widget_styles = get_widget_theme_style_data()
if w_name in widget_styles:
return widget_styles[w_name]
@@ -120,9 +131,7 @@ def get_widget_theme_styles(w_name: str) -> list[tuple[MockObj, MockObj]]:
for state in STATES + (CONF_DEFAULT,):
style_name = _get_theme_style_name(w_name, part, state)
if style_name in theme_styles:
style_list.append(
(theme_styles[style_name], get_part_state_selector(part, state))
)
style_list.append((theme_styles[style_name], part, state))
widget_styles[w_name] = style_list
return style_list
@@ -137,6 +146,9 @@ async def theme_to_code(config):
:return:
"""
theme = config.get(CONF_THEME) or {}
for w_name, w_theme in theme.items():
if isinstance(w_theme, dict):
_warn_misplaced_obj_properties(w_theme, f"the theme for '{w_name}'")
requests = get_theme_update_requests()
widget_names = [
w_name for w_name in WIDGET_TYPES if w_name in theme or w_name in requests
+113 -15
View File
@@ -1,4 +1,5 @@
from collections.abc import Awaitable, Callable
from collections.abc import Awaitable, Callable, Iterator
from contextlib import ExitStack
import sys
from typing import Any
@@ -45,6 +46,7 @@ from ..defines import (
STATES,
LValidator,
add_lv_use,
add_warning,
get_options,
get_part_state_selector,
get_styles_used,
@@ -54,6 +56,7 @@ from ..defines import (
)
from ..lv_validation import lv_int
from ..lvcode import (
LocalVariable,
add_line_marks,
lv,
lv_add,
@@ -182,7 +185,7 @@ class WidgetType:
wid = config[CONF_ID]
add_line_marks(wid)
if self.is_compound():
var = cg.new_Pvariable(wid)
var = cg.new_Pvariable(wid, *await self.get_ctor_args(config))
lv_add(var.set_obj(creator))
await self.on_create(var.obj, config)
else:
@@ -229,6 +232,37 @@ class WidgetType:
:param config: Its configuration
"""
async def get_ctor_args(self, config: dict) -> list:
"""
Get extra arguments for a compound widget's constructor. Anything the widget needs
before it builds its parts belongs here, since a setter would be called too late.
:param config: Its configuration
:return:
"""
return []
def obj_targets(self, w: "Widget", prop: str) -> list["Widget"]:
"""
Get the widgets one object property, as opposed to a style, applies to. A widget
built out of other widgets can point a property at the ones it really concerns, such
as sending a touch margin to the parts that are actually pressed.
:param w: The widget being configured
:param prop: The property name, one of OBJ_PROPERTIES
:return:
"""
return [w]
def part_targets(self, w: "Widget", part: str) -> list[tuple["Widget", str]]:
"""
Get the widgets a part's styles should be applied to, each paired with the part to
use on it. A widget built out of other widgets can point a part at those, so that
styling it works the same as it does for a plain widget.
:param w: The widget being styled
:param part: The configured part name
:return:
"""
return [(w, part)]
def get_uses(self) -> tuple:
"""
Get a list of other widgets used by this one
@@ -270,8 +304,9 @@ def apply_theme_styles(w: "Widget") -> None:
"""Apply the current theme's styles for this widget's type"""
from ..styles import get_widget_theme_styles
for style, lv_state in get_widget_theme_styles(w.type.name):
w.add_style(style, lv_state)
for style, part, state in get_widget_theme_styles(w.type.name):
for target, target_part in w.type.part_targets(w, part):
target.add_style(style, get_part_state_selector(target_part, state))
def apply_debug_outline(w: "Widget") -> None:
@@ -539,6 +574,38 @@ def collect_states(config):
return states
def misplaced_obj_properties(
config: dict, include_top: bool = False
) -> Iterator[tuple[str, str]]:
"""
Find object properties set where LVGL has no way to apply them. They belong to a widget
as a whole, taking neither a part nor a state, so anywhere else they are quietly dropped.
:param config: A widget, style or theme configuration
:param include_top: Also report the top level, as for a style, which never holds them
:return: Each place one was found, as a part/state path ("" for the top level), paired
with the property name
"""
from ..schemas import OBJ_PROPERTIES
def scan(sub: Any, where: str) -> Iterator[tuple[str, str]]:
if isinstance(sub, dict):
for prop in sorted(OBJ_PROPERTIES.intersection(sub)):
yield where, prop
if include_top:
yield from scan(config, "")
for state in STATES:
if state in config:
yield from scan(config[state], state)
for part in PARTS:
if part not in config:
continue
yield from scan(config[part], part)
for state in STATES:
if state in config[part]:
yield from scan(config[part][state], f"{part}/{state}")
def collect_parts(config):
"""
Collect properties and states for all widget parts
@@ -610,6 +677,13 @@ def _set_layout_options(w: Widget, layout: dict, base_name: str | None) -> None:
w.set_style("flex_track_place", literal(track))
def _widget_name(w: Widget) -> str:
"""The id a widget was given, for naming it in a warning."""
if isinstance(w.config, dict) and (wid := w.config.get(CONF_ID)) is not None:
return str(wid)
return str(w.var)
async def set_obj_properties(w: Widget, config):
"""Generate a list of C++ statements to apply properties to an lv_obj_t"""
@@ -628,19 +702,42 @@ async def set_obj_properties(w: Widget, config):
else:
base_name = None
_set_layout_options(w, layout, base_name)
for where, prop in misplaced_obj_properties(config):
add_warning(
f"'{prop}' applies to a widget as a whole, not to one part or state of it, so "
f"setting it under '{where}' of widget '{_widget_name(w)}' has no effect. Set it "
f"on the widget itself instead."
)
parts = collect_parts(config)
for part, states in parts.items():
targets = w.type.part_targets(w, part)
for state, props in states.items():
lv_state = get_part_state_selector(part, state)
for style_id in props.get(CONF_STYLES, ()):
w.add_style(style_id, lv_state)
for prop, value in {
k: v for k, v in props.items() if k in ALL_STYLES
}.items():
if isinstance(ALL_STYLES[prop], LValidator):
value = await ALL_STYLES[prop].process(value)
prop_r = remap_property(prop)
w.set_style(prop_r, value, lv_state)
style_ids = props.get(CONF_STYLES, ())
# Each value is worked out once and then given to every target. A widget built
# out of others has one target per part, and a lambda is written out in full
# wherever it appears, so with more than one target it goes into a variable
# rather than being repeated. The blocks holding those variables stay open
# until every target has been dealt with.
with ExitStack() as stack:
values = []
for prop, value in {
k: v for k, v in props.items() if k in ALL_STYLES
}.items():
validator = ALL_STYLES[prop]
if isinstance(validator, LValidator):
is_lambda = isinstance(value, cv.Lambda)
value = await validator.process(value)
if is_lambda and len(targets) > 1:
value = stack.enter_context(
LocalVariable(prop, validator.rtype, value, modifier="")
)
values.append((remap_property(prop), value))
for target, target_part in targets:
lv_state = get_part_state_selector(target_part, state)
for style_id in style_ids:
target.add_style(style_id, lv_state)
for prop_r, value in values:
target.set_style(prop_r, value, lv_state)
if group := config.get(CONF_GROUP):
group = await cg.get_variable(group)
lv.group_add_obj(group, w.obj)
@@ -663,7 +760,8 @@ async def set_obj_properties(w: Widget, config):
w.set_state(state, value)
for property in OBJ_PROPERTIES:
await w.set_property(property, config, lv_name="obj")
for target in w.type.obj_targets(w, property):
await target.set_property(property, config, lv_name="obj")
async def add_widgets(parent: Widget, config: dict):
@@ -0,0 +1,240 @@
from typing import Any
import esphome.codegen as cg
import esphome.config_validation as cv
from esphome.const import CONF_COLOR, CONF_HEIGHT, CONF_ITEMS, CONF_WIDTH
from esphome.core import CORE
from ..defines import (
CONF_COLOR_PICKER,
CONF_EXT_CLICK_AREA,
CONF_KNOB,
CONF_MAIN,
CONF_STYLES,
CONF_THEME,
DOMAIN,
add_define,
add_lv_use,
literal,
)
from ..lv_validation import lv_color, size
from ..lvcode import lv_add
from ..types import LvCompound, LvType
from . import Widget, WidgetType
from .label import CONF_LABEL
from .lv_bar import CONF_BAR
from .slider import CONF_SLIDER, slider_spec
# esphome::Color, not lv_color_t: it carries the same value but exposes the components as
# `x.r`/`x.g`/`x.b`, and converts to lv_color_t on its own where LVGL needs one.
Color = cg.esphome_ns.class_("Color")
lv_color_picker_t = LvType(
"LvColorPickerType",
parents=(LvCompound,),
largs=[(Color, "x")],
lvalue=lambda w: w.var.get_color(),
has_on_value=True,
)
# Makes `lvgl.widget.update` with a `color:` report the change through `on_value`, the same
# as setting the value of any other widget does.
lv_color_picker_t.value_property = CONF_COLOR
CONF_SLIDERS = "sliders"
CONF_WHEEL = "wheel"
CONF_BG_COLOR = "bg_color"
# The sliders the widget can be built from. The order must match
# LvColorPickerType::SliderIndex, since it decides which bit of the mask each one gets.
SLIDER_NAMES = (
"hue",
"saturation",
"brightness",
"red",
"green",
"blue",
)
# Shorthand for the two sets of sliders that are usually wanted together.
SLIDER_GROUPS = {
"hsv": ("hue", "saturation", "brightness"),
"hsb": ("hue", "saturation", "brightness"),
"rgb": ("red", "green", "blue"),
}
# The wheel draws hue and saturation itself, and the layout drops the RGB sliders to make
# room for it, leaving brightness as the only slider that still fits.
WHEEL_EXCLUDED_SLIDERS = ("hue", "saturation", "red", "green", "blue")
def validate_sliders(value: Any) -> list[str]:
"""Expand any group names and drop repeats, keeping the widget's own slider order."""
value = cv.ensure_list(cv.one_of(*SLIDER_NAMES, *SLIDER_GROUPS, lower=True))(value)
chosen = {name for item in value for name in SLIDER_GROUPS.get(item, (item,))}
if not chosen:
raise cv.Invalid("At least one slider is required")
return [name for name in SLIDER_NAMES if name in chosen]
COLOR_PICKER_MODIFY_SCHEMA = cv.Schema(
{
cv.Optional(CONF_COLOR): lv_color,
}
)
COLOR_PICKER_SCHEMA = COLOR_PICKER_MODIFY_SCHEMA.extend(
{
cv.Optional(CONF_WIDTH, default="SIZE_CONTENT"): size,
cv.Optional(CONF_HEIGHT): cv.invalid("Height will be set to the same as width"),
cv.Optional(CONF_SLIDERS, default=list(SLIDER_NAMES)): validate_sliders,
cv.Optional(CONF_WHEEL, default=False): cv.boolean,
}
)
# Starting colour for a picker with no way to change saturation, which would otherwise be stuck
# at the grey it starts with. Saturation changes with its own slider, the wheel, or any of the
# red, green and blue sliders, so only these two sliders on their own leave it fixed.
SATURATED_DEFAULT_COLOR = 0xFF0000
HUE_AND_BRIGHTNESS = {"hue", "brightness"}
def _knob_color_is_set(knob: dict) -> bool:
"""Whether a knob is given a background colour, directly or through named styles."""
return CONF_BG_COLOR in knob or bool(knob.get(CONF_STYLES))
def _theme_sets_knob_color() -> bool:
"""Whether any LVGL theme gives the color picker's knobs a background colour."""
configs = CORE.config.get(DOMAIN) or []
if isinstance(configs, dict):
configs = [configs]
return any(
_knob_color_is_set(
((config.get(CONF_THEME) or {}).get(CONF_COLOR_PICKER) or {}).get(
CONF_KNOB, {}
)
)
for config in configs
)
def _sliders(config: dict) -> list[str]:
"""Get the sliders a widget was created with, in the widget's own order."""
return config.get(CONF_SLIDERS) or list(SLIDER_NAMES)
class ColorPickerType(WidgetType):
def __init__(self) -> None:
super().__init__(
CONF_COLOR_PICKER,
lv_color_picker_t,
parts=(CONF_MAIN, CONF_ITEMS, CONF_KNOB),
schema=COLOR_PICKER_SCHEMA,
modify_schema=COLOR_PICKER_MODIFY_SCHEMA,
lv_name="obj",
)
def validate(self, value: Any) -> Any:
add_lv_use(CONF_COLOR_PICKER)
value = dict(value)
if cv.boolean(value.get(CONF_WHEEL, False)):
# The wheel is a conical hue gradient under a radial white-to-transparent one;
# LVGL's software renderer only draws either kind when this is enabled.
add_define("LV_USE_DRAW_SW_COMPLEX_GRADIENTS")
if CONF_SLIDERS in value:
sliders = validate_sliders(value[CONF_SLIDERS])
if any(name in sliders for name in WHEEL_EXCLUDED_SLIDERS):
raise cv.Invalid(
"the color wheel already shows hue and saturation, and replaces "
"the red, green and blue sliders with it; remove them from "
"'sliders' or set 'wheel: false'"
)
else:
# Otherwise the schema default would build every slider including these.
value[CONF_SLIDERS] = [
name for name in SLIDER_NAMES if name not in WHEEL_EXCLUDED_SLIDERS
]
return super().validate(value)
async def get_ctor_args(self, config: dict) -> list:
# The layout is worked out from the sliders, so they are needed before the widget is
# built rather than set afterwards.
return [
literal(
" | ".join(
f"{lv_color_picker_t}::SLIDER_FLAG_{name.upper()}"
for name in _sliders(config)
)
),
config[CONF_WHEEL],
]
@staticmethod
def _sliders_of(w: Widget) -> list[Widget]:
"""Wrap each slider the widget was created with, so it can be configured directly."""
return [
Widget(
w.var.get_slider(
literal(f"{lv_color_picker_t}::SLIDER_{name.upper()}")
),
slider_spec,
)
for name in _sliders(w.config or {})
]
def obj_targets(self, w: Widget, prop: str) -> list[Widget]:
# Nothing is ever pressed on its own object, so the touch margin belongs on the
# sliders. Everything else, scrolling in particular, is a matter for the container
# holding them and so stays where it is.
if prop == CONF_EXT_CLICK_AREA:
return self._sliders_of(w)
return [w]
def part_targets(self, w: Widget, part: str) -> list[tuple[Widget, str]]:
# The widget is made of sliders, so `items` and `knob` are the main and knob parts of
# each of those. Its own object is a plain container with neither. Only the sliders
# the widget was created with exist, so only those are styled.
if part == CONF_MAIN:
return [(w, part)]
target_part = CONF_MAIN if part == CONF_ITEMS else part
return [(slider, target_part) for slider in self._sliders_of(w)]
async def to_code(self, w: Widget, config: dict) -> None:
# Only the configuration that creates the widget has `sliders`; an update does not.
creating = CONF_SLIDERS in config
if creating and config[CONF_WHEEL]:
# Without a wheel anywhere, its code and gradients are left out of the build.
cg.add_define("USE_LVGL_COLOR_PICKER_WHEEL")
color = config.get(CONF_COLOR)
if (
color is None
and creating
and not config[CONF_WHEEL]
and set(config[CONF_SLIDERS]) <= HUE_AND_BRIGHTNESS
):
color = SATURATED_DEFAULT_COLOR
# A hex colour validates to a plain int, so black must not be mistaken for absent.
if color is not None:
lv_add(w.var.set_color(await lv_color.process(color)))
# Each knob is normally tinted with the colour its own slider shows. That is applied
# as a local style, which outranks any style the knob has been given, so a background
# colour configured for the knob, here or in the theme, would never show; tinting
# gives way to it instead.
if _knob_color_is_set(config.get(CONF_KNOB, {})) or (
creating and _theme_sets_knob_color()
):
lv_add(w.var.set_tint_knobs(False))
# The widget is square, so the height simply follows the configured width.
# SIZE_CONTENT works too: the widget reports a size based on its text font.
# Width is required when creating the widget but absent when updating one.
if (width := config.get(CONF_WIDTH)) is not None:
w.set_style(CONF_HEIGHT, await size.process(width), 0)
def get_uses(self) -> tuple[str, ...]:
return ("flex", CONF_SLIDER, CONF_BAR, CONF_LABEL)
color_picker_spec = ColorPickerType()
+11
View File
@@ -111,6 +111,8 @@
#define USE_LVGL_CANVAS
#define USE_LVGL_CHART
#define USE_LVGL_CHECKBOX
#define USE_LVGL_COLOR_PICKER
#define USE_LVGL_COLOR_PICKER_WHEEL
#define USE_LVGL_DROPDOWN
#define USE_LVGL_FONT
#define USE_LVGL_GRADIENT
@@ -718,3 +720,12 @@
#define ESPHOME_ENTITY_VALVE_COUNT 1
#define ESPHOME_ENTITY_WATER_HEATER_COUNT 1
#define ESPHOME_MAX_USB_CDC_INSTANCES 1
// Keep clang-tidy happy.
// The real value comes from lvgl/gradient.py, which works it out from the configuration. The
// largest it uses is set here so that static analysis sees every gradient in the component,
// but only where a generated lv_conf.h has not already given it a value.
#ifndef LV_GRADIENT_MAX_STOPS
#define LV_GRADIENT_MAX_STOPS 7
#endif
@@ -0,0 +1,133 @@
esphome:
name: test-color-picker
esp32:
board: lolin_c3_mini
logger:
spi:
mosi_pin:
number: GPIO2
ignore_strapping_warning: true
clk_pin: GPIO1
display:
- platform: mipi_spi
data_rate: 20MHz
model: st7735
cs_pin:
number: GPIO8
ignore_strapping_warning: true
dc_pin:
number: GPIO3
color:
- id: picker_colour
hex: "3366CC"
lvgl:
style_definitions:
- id: picker_style
bg_opa: cover
widgets:
# Sized from the text font
- color_picker:
id: picker_content
width: SIZE_CONTENT
# Fixed width, colour given as a hex literal
- color_picker:
id: picker_fixed
width: 120
color: 0xFF8800
# Styling the sliders the widget is built from
- color_picker:
id: picker_styled
width: 100
items:
bg_opa: 80%
pressed:
border_width: 2
knob:
bg_color: 0x112233
radius: 4
styles: [picker_style]
# One value for six sliders: it must not be generated once per slider. The body
# is more than an expression so that it stays a lambda rather than being folded
# into a constant.
border_width: !lambda |-
int width = 3;
return width;
pressed:
styles: [picker_style]
# Belongs to the widget as a whole, so it is reported and ignored here
ext_click_area: 8
# Only the red, green and blue sliders, with a margin making them easier to press.
# The scrolling options belong to the widget's own object, not to the sliders.
- color_picker:
id: picker_rgb
width: 100
sliders: rgb
ext_click_area: 12
scrollbar_mode: "OFF"
# Black must not be mistaken for no colour at all
- color_picker:
id: picker_black
width: 100
color: 0x000000
# The knob background arrives through a named style rather than directly
- color_picker:
id: picker_knob_style
width: 100
knob:
styles: [picker_style]
# Only the hue, saturation and brightness sliders, styled
- color_picker:
id: picker_hsv
width: 100
sliders: [hsv]
knob:
radius: 2
# A single slider, for picking a hue with the brightness controlled elsewhere
- color_picker:
id: picker_hue
width: 100
sliders: hue
# A colour wheel in place of the hue and saturation sliders
- color_picker:
id: picker_wheel
width: 100
wheel: true
# Colour given as the id of a colour defined above
- color_picker:
id: picker_colour_id
width: 50%
color: picker_colour
on_value:
- logger.log:
format: "picked %d,%d,%d"
args: [x.r, x.g, x.b]
on_release:
- logger.log: released
on_update:
- logger.log: updated
script:
- id: update_picker
then:
- lvgl.color_picker.update:
id: picker_fixed
color: 0x00FF00
- lvgl.color_picker.update:
id: picker_hue
color: !lambda return lv_color_hex(0x123456);
@@ -0,0 +1,48 @@
esphome:
name: test-color-picker
esp32:
board: lolin_c3_mini
logger:
spi:
mosi_pin:
number: GPIO2
ignore_strapping_warning: true
clk_pin: GPIO1
display:
- platform: mipi_spi
data_rate: 20MHz
model: st7735
cs_pin:
number: GPIO8
ignore_strapping_warning: true
dc_pin:
number: GPIO3
lvgl:
style_definitions:
# A widget property in a style is dropped, so it is reported
- id: clickable_style
ext_click_area: 10
theme:
# A themed knob colour must not be covered by the knob tinting
color_picker:
knob:
bg_color: 0x112233
# Widget properties in a theme are dropped too, at any level
button:
scrollbar_mode: "OFF"
pressed:
ext_click_area: 4
widgets:
- color_picker:
id: themed_picker
width: 100
- button:
id: themed_button
styles: clickable_style
@@ -0,0 +1,47 @@
esphome:
name: test-named-styles
esp32:
board: lolin_c3_mini
logger:
spi:
mosi_pin:
number: GPIO2
ignore_strapping_warning: true
clk_pin: GPIO1
display:
- platform: mipi_spi
data_rate: 20MHz
model: st7735
cs_pin:
number: GPIO8
ignore_strapping_warning: true
dc_pin:
number: GPIO3
lvgl:
style_definitions:
- id: chunky
bg_opa: cover
border_width: 2
widgets:
# A plain widget, so the selector each named style is added with is the one LVGL is
# given directly, with no compound widget in the way.
- slider:
id: plain_slider
width: 100
# No part and no state: the selector is a bare zero.
styles: [chunky]
knob:
# A part on its own.
styles: [chunky]
pressed:
# A part combined with a state, which has to survive the cast.
styles: [chunky]
pressed:
# A state on its own.
styles: [chunky]
@@ -0,0 +1,628 @@
"""Tests for the LVGL color picker widget schema and code generation."""
from __future__ import annotations
from collections.abc import Callable
from pathlib import Path
import re
import pytest
from voluptuous import Schema
from esphome.components.lvgl.schemas import container_schema
from esphome.components.lvgl.widgets import _build_update_schema
from esphome.components.lvgl.widgets.color_picker import color_picker_spec
from esphome.config_validation import Invalid
from esphome.const import CONF_WIDTH
@pytest.fixture()
def update_schema() -> Schema:
"""The schema behind `lvgl.color_picker.update`."""
return _build_update_schema(color_picker_spec)
# ---------------------------------------------------------------------------
# Validation
# ---------------------------------------------------------------------------
class TestColorPickerValidation:
"""Test the widget's own schema options."""
@pytest.fixture()
def schema(self) -> Callable[[dict], dict]:
return container_schema(color_picker_spec)
@pytest.mark.parametrize("width", [100, "50%", "SIZE_CONTENT"])
def test_width_accepted(
self, schema: Callable[[dict], dict], width: int | str
) -> None:
assert schema({"width": width}) is not None
def test_width_defaults_to_size_content(
self, schema: Callable[[dict], dict]
) -> None:
assert schema({})[CONF_WIDTH] == "LV_SIZE_CONTENT"
def test_height_is_rejected(self, schema: Callable[[dict], dict]) -> None:
"""The widget is square, so a height of its own makes no sense."""
with pytest.raises(Invalid, match="Height will be set to the same as width"):
schema({"width": 100, "height": 100})
def test_color_is_optional(self, schema: Callable[[dict], dict]) -> None:
assert "color" not in schema({"width": 100})
def test_on_value_is_accepted(self, schema: Callable[[dict], dict]) -> None:
"""The trigger is only offered if the widget declares it has a value."""
result = schema({"width": 100, "on_value": [{"logger.log": "picked"}]})
assert "on_value" in result
def test_value_argument_is_a_color(self) -> None:
"""`x` must be an esphome Color, so that lambdas can use x.r/x.g/x.b.
esphome::Color converts to lv_color_t implicitly, so declaring the wrong
one of the two still compiles but gives lambdas the LVGL struct, whose
members are named differently.
"""
(arg_type,) = color_picker_spec.w_type.get_arg_type()
assert str(arg_type) == "Color"
def test_update_action_sets_the_colour(self, update_schema: Schema) -> None:
"""The colour is the one thing an update action is for."""
assert update_schema({"id": "picker", "color": 0xFF8800}) is not None
def test_update_action_does_not_require_width(self, update_schema: Schema) -> None:
"""Width is only needed when the widget is created, unlike in the widget schema."""
assert CONF_WIDTH not in update_schema({"id": "picker", "color": 0xFF8800})
def test_slider_parts_are_declared(self) -> None:
"""`items` and `knob` stand for the sliders the widget is built from."""
assert color_picker_spec.parts == ("main", "items", "knob")
@pytest.mark.parametrize("part", ["items", "knob"])
def test_slider_parts_are_accepted(
self, schema: Callable[[dict], dict], part: str
) -> None:
assert schema({"width": 100, part: {"radius": 4}}) is not None
class TestColorPickerSliderChoice:
"""Test choosing which of the six sliders the widget is built from."""
@pytest.fixture()
def schema(self) -> Callable[[dict], dict]:
return container_schema(color_picker_spec)
def test_all_sliders_by_default(self, schema: Callable[[dict], dict]) -> None:
assert schema({"width": 100})["sliders"] == [
"hue",
"saturation",
"brightness",
"red",
"green",
"blue",
]
@pytest.mark.parametrize(
("configured", "expected"),
[
("rgb", ["red", "green", "blue"]),
("hsv", ["hue", "saturation", "brightness"]),
# Both spellings of the third HSV component are accepted.
("hsb", ["hue", "saturation", "brightness"]),
(["hue", "saturation"], ["hue", "saturation"]),
(["hue"], ["hue"]),
# A group can be mixed with single sliders.
(["rgb", "hue"], ["hue", "red", "green", "blue"]),
],
)
def test_groups_are_expanded(
self,
schema: Callable[[dict], dict],
configured: str | list[str],
expected: list[str],
) -> None:
assert schema({"width": 100, "sliders": configured})["sliders"] == expected
def test_order_follows_the_widget_not_the_config(
self, schema: Callable[[dict], dict]
) -> None:
"""The layout is fixed, so the order they are listed in makes no difference."""
assert schema({"width": 100, "sliders": ["blue", "red"]})["sliders"] == [
"red",
"blue",
]
def test_repeats_are_dropped(self, schema: Callable[[dict], dict]) -> None:
assert schema({"width": 100, "sliders": ["red", "rgb"]})["sliders"] == [
"red",
"green",
"blue",
]
def test_empty_list_is_rejected(self, schema: Callable[[dict], dict]) -> None:
with pytest.raises(Invalid, match="At least one slider is required"):
schema({"width": 100, "sliders": []})
def test_unknown_slider_is_rejected(self, schema: Callable[[dict], dict]) -> None:
with pytest.raises(Invalid, match="Unknown value 'cyan'"):
schema({"width": 100, "sliders": ["cyan"]})
def test_sliders_cannot_be_updated(self, update_schema: Schema) -> None:
"""The layout is built from them, so they cannot change after creation."""
with pytest.raises(Invalid, match="sliders"):
update_schema({"id": "picker", "sliders": "rgb"})
class TestColorPickerWheel:
"""Test the `wheel` option, which replaces the hue and saturation sliders, and the RGB
sliders besides, with a circular hue/saturation picker.
The exclusion of these sliders happens in `ColorPickerType.validate()`, which runs
before the schema itself (see `WIDGET_TYPES` validation in schemas.py), so these tests
reproduce that order rather than using the `container_schema` fixture alone.
"""
@pytest.fixture()
def schema(self) -> Callable[[dict], dict]:
container = container_schema(color_picker_spec)
def _validate(value: dict) -> dict:
return container(color_picker_spec.validate(value))
return _validate
def test_wheel_defaults_to_false(self, schema: Callable[[dict], dict]) -> None:
assert schema({"width": 100})["wheel"] is False
def test_wheel_must_be_a_boolean(self, schema: Callable[[dict], dict]) -> None:
with pytest.raises(Invalid, match="boolean"):
schema({"width": 100, "wheel": "round"})
def test_wheel_leaves_only_brightness_by_default(
self, schema: Callable[[dict], dict]
) -> None:
assert schema({"width": 100, "wheel": True})["sliders"] == ["brightness"]
def test_wheel_is_compatible_with_an_explicit_slider_list(
self, schema: Callable[[dict], dict]
) -> None:
assert schema({"width": 100, "wheel": True, "sliders": "brightness"})[
"sliders"
] == ["brightness"]
@pytest.mark.parametrize(
"sliders",
["hue", "saturation", "red", "green", "blue", "rgb", ["hue", "red"], "hsv"],
)
def test_wheel_conflicts_with_sliders_it_replaces(
self, schema: Callable[[dict], dict], sliders: str | list[str]
) -> None:
with pytest.raises(Invalid, match="color wheel"):
schema({"width": 100, "wheel": True, "sliders": sliders})
# ---------------------------------------------------------------------------
# Code generation
# ---------------------------------------------------------------------------
_SET_STYLE_RE = re.compile(r"lv_obj_set_style_(\w+)\((\w+)->obj, (.+?), \w+\);")
def _styles_for(main_cpp: str, var: str) -> dict[str, str]:
"""Return {property: value} for every style set directly on a widget."""
return {
m.group(1): m.group(3)
for m in _SET_STYLE_RE.finditer(main_cpp)
if m.group(2) == var
}
class TestColorPickerCodeGeneration:
@pytest.fixture()
def main_cpp(
self,
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> str:
return generate_main(component_config_path("color_picker.yaml"))
def test_height_follows_width(self, main_cpp: str) -> None:
"""The widget is square, so height is generated to match the given width."""
for var, expected in (
("picker_content", "LV_SIZE_CONTENT"),
("picker_fixed", "120"),
("picker_colour_id", "lv_pct(50)"),
):
styles = _styles_for(main_cpp, var)
assert styles["width"] == expected
assert styles["height"] == expected
def test_color_is_applied(self, main_cpp: str) -> None:
assert "picker_fixed->set_color(" in main_cpp
assert "picker_colour_id->set_color(" in main_cpp
@pytest.mark.parametrize("var", ["picker_content", "picker_rgb", "picker_wheel"])
def test_color_is_omitted_when_not_configured(
self, main_cpp: str, var: str
) -> None:
"""Each of these can change saturation, so the grey it starts with is no trap."""
assert f"{var}->set_color(" not in main_cpp
def test_wheel_code_is_built_for_a_wheel(self, main_cpp: str) -> None:
from esphome.core import CORE
assert "USE_LVGL_COLOR_PICKER_WHEEL" in {d.name for d in CORE.defines}
def test_wheel_code_is_left_out_without_a_wheel(
self,
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""Its gradients would otherwise take RAM in every build with a picker."""
from esphome.core import CORE
generate_main(component_config_path("color_picker_theme.yaml"))
assert "USE_LVGL_COLOR_PICKER_WHEEL" not in {d.name for d in CORE.defines}
def test_hue_only_picker_starts_saturated(self, main_cpp: str) -> None:
"""With no way to change saturation, a grey start would leave it stuck at grey."""
assert "picker_hue->set_color(lv_color_make(255, 0, 0));" in main_cpp
def test_black_is_applied(self, main_cpp: str) -> None:
"""A hex colour validates to a plain int, and black must not read as absent."""
assert "picker_black->set_color(lv_color_make(0, 0, 0))" in main_cpp
def test_trigger_uses_color_type(self, main_cpp: str) -> None:
"""The on_value trigger is declared with the Color the widget reports."""
assert "Trigger<Color, lv_event_t *>" in main_cpp
def test_trigger_reads_the_current_color(self, main_cpp: str) -> None:
assert "picker_colour_id->get_color()" in main_cpp
def test_update_action_generates_set_color(self, main_cpp: str) -> None:
"""`lvgl.color_picker.update` used to fail outright on the missing width."""
assert main_cpp.count("picker_fixed->set_color(") == 2
def test_update_action_accepts_a_lambda(self, main_cpp: str) -> None:
# A lambda that only returns an expression is generated as that expression.
assert "picker_hue->set_color(lv_color_hex(0x123456));" in main_cpp
@pytest.mark.parametrize("message", ["released", "updated"])
def test_other_triggers_are_generated(self, main_cpp: str, message: str) -> None:
assert f'"{message}"' in main_cpp
def test_gradient_stops_are_raised_for_the_hue_bar(self, main_cpp: str) -> None:
"""The hue bar fills seven stops of a fixed-size array.
`gradient.py` raises the limit when a colour picker is in use, and the C++
`static_assert` relies on that having happened. It works because the widget
records the use during validation, which runs first, but nothing in either
file says so, hence this.
"""
from esphome.components.lvgl.defines import get_defines
assert get_defines()["LV_GRADIENT_MAX_STOPS"] == "7"
def test_gradient_stops_are_left_alone_without_a_picker(
self,
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""The other half of the same invariant: only a picker pays for the extra stops."""
from esphome.components.lvgl.defines import get_defines
# Any configuration without a colour picker in it will do.
generate_main(component_config_path("named_styles.yaml"))
assert get_defines()["LV_GRADIENT_MAX_STOPS"] == "2"
def test_complex_gradients_are_enabled_for_a_wheel(self, main_cpp: str) -> None:
"""The wheel is a conical and a radial gradient, which need this to render at all."""
from esphome.components.lvgl.defines import get_defines
assert get_defines()["LV_USE_DRAW_SW_COMPLEX_GRADIENTS"] == "1"
def test_complex_gradients_are_left_alone_without_a_wheel(
self,
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
from esphome.components.lvgl.defines import get_defines
generate_main(component_config_path("named_styles.yaml"))
assert "LV_USE_DRAW_SW_COMPLEX_GRADIENTS" not in get_defines()
# The widget's own object is a plain container, so styles for the parts a slider has are
# applied to each of its sliders instead.
_SLIDER_STYLE_RE = re.compile(
r"lv_obj_set_style_(\w+)\((\w+)->get_slider\(LvColorPickerType::SLIDER_(\w+)\), "
r"(.+?), (\w+)\);"
)
ALL_SLIDERS = {"HUE", "SATURATION", "BRIGHTNESS", "RED", "GREEN", "BLUE"}
def _slider_style_values(main_cpp: str, var: str, prop: str) -> set[str]:
"""Return every value one style property was set to across a widget's sliders."""
return {
m.group(4)
for m in _SLIDER_STYLE_RE.finditer(main_cpp)
if m.group(2) == var and m.group(1) == prop and m.group(5) == "LV_PART_KNOB"
}
def _slider_styles(main_cpp: str, var: str) -> dict[tuple[str, str], set[str]]:
"""Return {(property, selector): sliders it was applied to} for one widget."""
found: dict[tuple[str, str], set[str]] = {}
for m in _SLIDER_STYLE_RE.finditer(main_cpp):
if m.group(2) == var:
found.setdefault((m.group(1), m.group(5)), set()).add(m.group(3))
return found
class TestColorPickerSliderStyles:
@pytest.fixture()
def styles(
self,
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> dict[tuple[str, str], set[str]]:
main_cpp = generate_main(component_config_path("color_picker.yaml"))
return _slider_styles(main_cpp, "picker_styled")
@pytest.mark.parametrize(
("prop", "selector"),
[
# `knob` keeps its name on the slider...
("bg_color", "LV_PART_KNOB"),
("radius", "LV_PART_KNOB"),
# ...while `items` becomes the slider's own body.
("bg_opa", "LV_PART_MAIN"),
# A state without a part names only the state, as both are zero.
("border_width", "LV_STATE_PRESSED"),
],
)
def test_style_reaches_every_slider(
self, styles: dict[tuple[str, str], set[str]], prop: str, selector: str
) -> None:
assert styles[prop, selector] == ALL_SLIDERS
def test_style_skips_sliders_the_widget_lacks(
self,
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""A style must not be applied to a slider that was never created."""
main_cpp = generate_main(component_config_path("color_picker.yaml"))
styles = _slider_styles(main_cpp, "picker_hsv")
assert styles["radius", "LV_PART_KNOB"] == {"HUE", "SATURATION", "BRIGHTNESS"}
@pytest.mark.parametrize(
"selector",
[
# A part on its own needs no cast...
"LV_PART_KNOB",
# ...while a part combined with a state is cast to the type LVGL takes, which is
# wide enough for both. A state alone would not be.
(
"(static_cast<lv_style_selector_t>(LV_STATE_PRESSED) | "
"static_cast<lv_style_selector_t>(LV_PART_KNOB))"
),
],
)
def test_named_style_reaches_every_slider(
self,
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
selector: str,
) -> None:
"""A style listed under `styles:` is added to each slider, not to the container."""
main_cpp = generate_main(component_config_path("color_picker.yaml"))
added = re.findall(
r"lv_obj_add_style\(picker_styled->get_slider\("
r"LvColorPickerType::SLIDER_(\w+)\), picker_style, (.+?)\);",
main_cpp,
)
assert {name for name, sel in added if sel == selector} == ALL_SLIDERS
def test_a_lambda_is_generated_once_for_all_the_sliders(
self,
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""One YAML value must not become one C++ function per slider."""
main_cpp = generate_main(component_config_path("color_picker.yaml"))
# The source also appears once in the comment codegen writes above the lambda.
assert main_cpp.count("int border_width_VAR_ = []() -> int {") == 1
def test_the_same_lambda_reaches_every_slider(
self,
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""Generating it once must not mean applying it once."""
main_cpp = generate_main(component_config_path("color_picker.yaml"))
styles = _slider_styles(main_cpp, "picker_styled")
assert styles["border_width", "LV_PART_KNOB"] == ALL_SLIDERS
assert _slider_style_values(main_cpp, "picker_styled", "border_width") == {
"border_width_VAR_"
}
def test_no_styles_are_left_on_the_widget_itself(
self,
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""Setting these on the container would have no visible effect."""
main_cpp = generate_main(component_config_path("color_picker.yaml"))
own = _styles_for(main_cpp, "picker_styled")
assert set(own) == {"width", "height"}
@pytest.mark.parametrize(
"var",
[
# The background is set on the knob directly...
"picker_styled",
# ...or through a named style, which the tint would still outrank.
"picker_knob_style",
],
)
def test_knob_tinting_is_disabled_by_a_knob_background(
self,
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
var: str,
) -> None:
"""The tint is a local style, so it would overwrite the configured colour."""
main_cpp = generate_main(component_config_path("color_picker.yaml"))
assert f"{var}->set_tint_knobs(false)" in main_cpp
@pytest.mark.parametrize(
"var", ["picker_content", "picker_fixed", "picker_colour_id"]
)
def test_knob_tinting_is_left_on_otherwise(
self,
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
var: str,
) -> None:
main_cpp = generate_main(component_config_path("color_picker.yaml"))
assert f"{var}->set_tint_knobs(" not in main_cpp
class TestColorPickerObjectProperties:
"""The widget's own object is never pressed, so touch settings go to the sliders."""
@pytest.fixture()
def main_cpp(
self,
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> str:
return generate_main(component_config_path("color_picker.yaml"))
def test_ext_click_area_reaches_the_sliders(self, main_cpp: str) -> None:
"""This is what makes the knobs easier to grab: it widens each slider's touch area."""
found = re.findall(
r"lv_obj_set_ext_click_area\(picker_rgb->get_slider\("
r"LvColorPickerType::SLIDER_(\w+)\), 12\);",
main_cpp,
)
assert set(found) == {"RED", "GREEN", "BLUE"}
def test_ext_click_area_is_not_left_on_the_widget_itself(
self, main_cpp: str
) -> None:
"""The container has nothing clickable in it, so setting it there does nothing."""
assert "lv_obj_set_ext_click_area(picker_rgb->obj" not in main_cpp
def test_ext_click_area_under_a_part_is_reported(self, main_cpp: str) -> None:
"""It takes no part, so LVGL cannot apply it to one; that must not pass silently."""
from esphome.components.lvgl.defines import get_warnings
assert any(
"'ext_click_area'" in w and "'knob'" in w and "'picker_styled'" in w
for w in get_warnings()
), get_warnings()
def test_scrollbar_mode_stays_on_the_widget_itself(self, main_cpp: str) -> None:
"""Only the touch margin moves: a slider never scrolls, but the container can."""
assert "lv_obj_set_scrollbar_mode(picker_rgb->obj, LV_SCROLLBAR_MODE_OFF)" in (
main_cpp
)
def test_scrollbar_mode_does_not_reach_the_sliders(self, main_cpp: str) -> None:
assert "lv_obj_set_scrollbar_mode(picker_rgb->get_slider(" not in main_cpp
class TestColorPickerSliderConstruction:
"""The chosen sliders are passed to the constructor, before the layout is built."""
@pytest.fixture()
def main_cpp(
self,
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> str:
return generate_main(component_config_path("color_picker.yaml"))
@pytest.mark.parametrize(
("var", "expected"),
[
("picker_rgb", ["RED", "GREEN", "BLUE"]),
("picker_hsv", ["HUE", "SATURATION", "BRIGHTNESS"]),
("picker_hue", ["HUE"]),
(
"picker_fixed",
["HUE", "SATURATION", "BRIGHTNESS", "RED", "GREEN", "BLUE"],
),
],
)
def test_constructor_names_the_chosen_sliders(
self, main_cpp: str, var: str, expected: list[str]
) -> None:
flags = " | ".join(
f"LvColorPickerType::SLIDER_FLAG_{name}" for name in expected
)
assert f"new({var}) LvColorPickerType({flags}, false);" in main_cpp
def test_constructor_passes_wheel_true(self, main_cpp: str) -> None:
assert (
"new(picker_wheel) LvColorPickerType("
"LvColorPickerType::SLIDER_FLAG_BRIGHTNESS, true);"
) in main_cpp
def test_constructor_passes_wheel_false_by_default(self, main_cpp: str) -> None:
assert "new(picker_hue) LvColorPickerType(" in main_cpp
assert "LvColorPickerType::SLIDER_FLAG_HUE, false);" in main_cpp
class TestColorPickerTheme:
"""Settings that come from the theme or a style definition rather than the widget."""
@pytest.fixture()
def main_cpp(
self,
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> str:
return generate_main(component_config_path("color_picker_theme.yaml"))
def test_theme_knob_color_turns_tinting_off(self, main_cpp: str) -> None:
"""The tint is a local style, so it would overwrite the themed colour."""
assert "themed_picker->set_tint_knobs(false)" in main_cpp
@pytest.mark.parametrize(
"expected",
[
("'ext_click_area'", "style 'clickable_style'"),
("'scrollbar_mode'", "the theme for 'button'"),
("'ext_click_area'", "the theme for 'button' under 'pressed'"),
],
)
def test_widget_properties_in_styles_are_reported(
self, main_cpp: str, expected: tuple[str, str]
) -> None:
"""A style only holds style properties, so these would be dropped silently."""
from esphome.components.lvgl.defines import get_warnings
prop, place = expected
assert any(prop in w and f"in {place} " in w for w in get_warnings()), (
get_warnings()
)
@@ -0,0 +1,57 @@
"""Tests for the selector a named style is added to a widget with.
A part and a state are combined into one value, but they live in different halves of it:
a state fits in 16 bits, while a part starts at bit 16. Casting the pair to the state type
would throw the part away and quietly move the style to the main part, so these pin the
type each selector is generated with.
"""
from __future__ import annotations
from collections.abc import Callable
from pathlib import Path
import re
import pytest
def _selectors(main_cpp: str, var: str, style: str) -> set[str]:
"""Return every selector a named style was added to a widget with."""
return set(
re.findall(
rf"lv_obj_add_style\({var}, {style}, (.+?)\);",
main_cpp,
)
)
class TestNamedStyleSelectors:
@pytest.fixture()
def main_cpp(
self,
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> str:
return generate_main(component_config_path("named_styles.yaml"))
def test_every_selector_is_generated(self, main_cpp: str) -> None:
assert _selectors(main_cpp, "plain_slider", "chunky") == {
# Neither a part nor a state, which names the main part.
"LV_PART_MAIN",
# A state on its own, which needs no cast as it is the narrower half.
"LV_STATE_PRESSED",
# A part on its own.
"LV_PART_KNOB",
# A part with a state, cast to the type LVGL takes, which is wide enough for
# both. The state type alone would drop the part.
(
"(static_cast<lv_style_selector_t>(LV_STATE_PRESSED) | "
"static_cast<lv_style_selector_t>(LV_PART_KNOB))"
),
}
def test_a_combined_selector_is_not_narrowed_to_a_state(
self, main_cpp: str
) -> None:
"""`lv_state_t` is 16 bits, so it cannot hold a part."""
assert "(lv_state_t)((int)LV_PART_KNOB" not in main_cpp
+52
View File
@@ -1777,6 +1777,58 @@ lvgl:
- label:
text: "2,2"
# Sized from its text font, with a colour given as an id
- color_picker:
id: lv_color_picker_1
width: SIZE_CONTENT
text_font: space16
color: light_blue
on_value:
- logger.log:
format: "color picker is now %d,%d,%d"
args: [x.r, x.g, x.b]
on_release:
- lvgl.color_picker.update:
id: lv_color_picker_2
color: !lambda return x;
# Fixed size, with a colour given as a hex literal
- color_picker:
id: lv_color_picker_2
width: 200
color: 0x3366CC
# Styles for the sliders the widget is built from
items:
border_color: 0x808080
pressed:
border_width: 2
knob:
radius: 4
# A named style, added for both a part on its own and a part with a state
styles: [style_test]
pressed:
styles: [style_test]
on_update:
- logger.log: color picker updated
# Only the red, green and blue sliders
- color_picker:
id: lv_color_picker_3
width: 150
sliders: rgb
# A hue and saturation pair, as used where brightness is set elsewhere
- color_picker:
id: lv_color_picker_4
width: 150
sliders: [hue, saturation]
# Widens the area each slider can be pressed on, so the knobs are easier to move
ext_click_area: 10
# Applies to the widget as a whole, unlike the setting above
scrollbar_mode: "OFF"
knob:
bg_color: 0xFFFFFF
font:
- file: "gfonts://Roboto"
id: space16
+5
View File
@@ -79,6 +79,11 @@ lvgl:
- lvgl.animation.start:
id: anim_slide
lvgl_id: lvgl_0
# Sliders only and no complex gradients, so the wheel's gradient set-up must still build
- color_picker:
id: host_hue_picker
width: 200
sliders: hue
font:
- file: "gfonts://Roboto"
@@ -0,0 +1,82 @@
esphome:
name: lvgl-color-picker-render
host:
api:
actions:
# A snapshot never writes over an existing file, so the test supplies a new name each time.
- action: take_screenshot
variables:
name: string
then:
- snapshot.take:
id: picker_display
filename: !lambda return name;
- action: set_orange
then:
- lvgl.color_picker.update:
id: picker_sliders
color: 0xFF8800
- lvgl.color_picker.update:
id: picker_wheel
color: 0xFF8800
# White has no saturation, which a hue-only picker could never change again
- lvgl.color_picker.update:
id: picker_hue
color: 0xFFFFFF
logger:
level: DEBUG
display:
- platform: snapshot
id: picker_display
auto_clear_enabled: false
dimensions:
width: 680
height: 480
lvgl:
displays: picker_display
default_font: montserrat_14
widgets:
- obj:
width: 100%
height: 100%
bg_color: 0x404040
bg_opa: cover
border_width: 0
radius: 0
pad_all: 4
layout:
type: flex
flex_flow: row_wrap
pad_row: 4
pad_column: 4
widgets:
- color_picker:
id: picker_sliders
width: 196
color: 0x3366CC
- color_picker:
id: picker_wheel
width: 196
wheel: true
color: 0x3366CC
# Sized from the text font
- color_picker:
id: picker_content
color: 0x3366CC
# No red, green and blue bars beside the swatch
- color_picker:
id: picker_hsv
width: 196
sliders: hsv
color: 0x3366CC
# Only the hue bar
- color_picker:
id: picker_hue
width: 196
sliders: hue
color: 0x3366CC
@@ -0,0 +1,105 @@
"""Golden-image test for the LVGL colour picker, drawn on a display with no screen.
Pickers are drawn with every slider, with a colour wheel, at the size taken from the font,
and with only some of the sliders. The first two are then given a new colour by
`lvgl.color_picker.update`, and the hue-only one is given white, and they are drawn again. Each picture is compared against a hash, as in
test_lvgl_headless_render.py. If a change alters the output, the test fails, keeps the image it
saw and prints its hash; update the hash once the image has been looked at and found to be
correct.
"""
from __future__ import annotations
import asyncio
import hashlib
from pathlib import Path
import pytest
from .artifact_utils import keep_artifact
from .bmp_utils import Bmp, capture_when_drawn, wait_for_bmp
from .types import APIClientConnectedFactory, RunCompiledFunction
WIDTH = 680
HEIGHT = 480
# Every picker showing 0x3366CC.
EXPECTED_INITIAL_SHA256 = (
"5d93f4bd98fcbe418ef8d34572e3b0912e0421cfd87e61e8cc3c49778a0e0e47"
)
# The first two pickers showing 0xFF8800, and the hue-only one red rather than stuck at white,
# after the update action.
EXPECTED_UPDATED_SHA256 = (
"8300a6d95f38e60ea3d175aa413e71b99de6ac7ad78c9adf5248bfb350cb35d3"
)
# The LVGL version the hashes were generated against; a version change can move them.
EXPECTED_LVGL_VERSION = "9.6.0"
REDRAW_TIMEOUT = 5.0
def _check(image: Bmp, capture: Path, expected: str, name: str) -> None:
assert (image.width, image.height, image.bits) == (WIDTH, HEIGHT, 24)
digest = hashlib.sha256(image.pixels).hexdigest()
if digest == expected:
return
kept = keep_artifact(f"lvgl_color_picker_{name}_actual.bmp", capture.read_bytes())
from esphome.components.lvgl import LVGL_VERSION
version_hint = ""
if LVGL_VERSION != EXPECTED_LVGL_VERSION:
version_hint = (
f"the bundled LVGL version changed ({EXPECTED_LVGL_VERSION} -> "
f"{LVGL_VERSION}), which is the likely cause\n"
)
pytest.fail(
f"{name} screen does not match the expected hash\n"
f"{version_hint}"
f" expected: {expected}\n"
f" actual: {digest}\n"
f"the image that was rendered has been kept at {kept}"
)
@pytest.mark.asyncio
async def test_lvgl_color_picker_render(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The colour picker draws as expected, before and after its colour is changed."""
snapshot_dir = tmp_path / "snapshots"
monkeypatch.setenv("ESPHOME_SNAPSHOT_DIR", str(snapshot_dir))
async with run_compiled(yaml_config), api_client_connected() as client:
_, services = await client.list_entities_services()
take_screenshot = next(s for s in services if s.name == "take_screenshot")
set_orange = next(s for s in services if s.name == "set_orange")
async def take(name: str) -> None:
await client.execute_service(take_screenshot, {"name": name})
image, capture = await capture_when_drawn(take, snapshot_dir, prefix="initial")
_check(image, capture, EXPECTED_INITIAL_SHA256, "initial")
await client.execute_service(set_orange, {})
# The redraw follows the update on a later LVGL tick, so capture until the picture changes.
loop = asyncio.get_running_loop()
deadline = loop.time() + REDRAW_TIMEOUT
attempt = 0
while True:
attempt += 1
capture = snapshot_dir / f"updated-{attempt}.bmp"
await take(capture.name)
updated = await wait_for_bmp(capture)
if updated.pixels != image.pixels:
break
if loop.time() >= deadline:
pytest.fail(
f"the screen did not change within {REDRAW_TIMEOUT}s of the update"
)
await asyncio.sleep(0.2)
_check(updated, capture, EXPECTED_UPDATED_SHA256, "updated")