mirror of
https://github.com/esphome/esphome.git
synced 2026-10-14 02:02:51 +00:00
[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:
co-authored by
Claude Opus 5
J. Nick Koston
parent
5c1c946e88
commit
3f21ddf6ff
@@ -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
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user