Let the page buttons make way: a Page buttons setting that gives their room to the tiles, chevrons and dots instead of words, and a warning in the editor for pages nothing leads to (0.2.82)

GitHub issue #9 asked to drop the Previous/Next bar when Go to page tiles do the navigation and to give the tiles the whole screen. Page buttons is a setting the screen owns (on by default): a row in the Screen group of the settings page, switch.<screen>_page_buttons in Home Assistant, and a row in the editor's Screen settings. Without the bar, and on a screen with one page, place_page moves and sizes the three rows so they reach the bottom margin (Guition 108 -> 122 px, CYD 52 -> 61 px); a card keeps its circle, name and state in its middle and the single analog clock keeps its dial. With page buttons and swiping both off, the editor says where the Go to page tiles lead and which pages that leaves out of reach.

The bar itself: each half of the band under the tiles is one key with an MDI chevron and lights up under a finger, and a dot per page sits between them; the screen's settings page uses the same pager. App 0.2.82, firmware 0.2.69.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Max Gramser
2026-09-18 20:11:01 +02:00
parent b149428dcb
commit ffe639e23c
30 changed files with 566 additions and 165 deletions
+72 -15
View File
@@ -32,7 +32,9 @@ using settings_screen::rotation;
using settings_screen::rotation_supported;
using settings_screen::auto_home;
using settings_screen::auto_home_seconds;
using settings_screen::page_buttons;
inline esphome::ESPPreferenceObject rotation_preference;
inline esphome::ESPPreferenceObject buttons_preference;
inline esphome::ESPPreferenceObject swipe_preference;
inline esphome::ESPPreferenceObject home_preference;
inline esphome::ESPPreferenceObject dark_preference;
@@ -141,10 +143,13 @@ inline void load_settings() {
swipe_preference = esphome::global_preferences->make_preference<uint32_t>(0x53575031);
home_preference = esphome::global_preferences->make_preference<HomeTimeout>(0x484F4D31);
dark_preference = esphome::global_preferences->make_preference<uint32_t>(0x44524B31);
buttons_preference = esphome::global_preferences->make_preference<uint32_t>(0x50474231);
uint32_t swipe_saved=0;
if(swipe_preference.load(&swipe_saved))swipe_pages=swipe_saved==1;
uint32_t dark_saved=0;
if(dark_preference.load(&dark_saved))settings_screen::dark_mode=dark_saved==1;
uint32_t buttons_saved=1;
if(buttons_preference.load(&buttons_saved))page_buttons=buttons_saved!=0;
HomeTimeout home;
if(home_preference.load(&home) && home.seconds>=30 && home.seconds<=3600){
auto_home=home.enabled?1:0;auto_home_seconds=(int32_t)home.seconds;
@@ -167,6 +172,8 @@ inline void persist_settings() {
home_preference.save(&home);
uint32_t dark = settings_screen::dark_mode ? 1 : 0;
dark_preference.save(&dark);
uint32_t buttons = page_buttons ? 1 : 0;
buttons_preference.save(&buttons);
if (rotation_supported) { uint32_t turned = (uint32_t) rotation; rotation_preference.save(&turned); }
}
inline bool parse_settings(JsonObject obj, screen_settings::Settings &s) {
@@ -192,7 +199,8 @@ struct Widgets {
int title_x=0,title_y=0,value_x=0,value_y=0; const lv_font_t *value_font{}, *icon_font{};
// Wide cards span both columns; custom cards (clock, forecast, graph) draw into `extra`.
// Full (firmware 0.2.62+) takes the whole page: the double-width card's head on top, a control at the bottom.
bool wide=false, full=false; int base_width=0, base_height=0; const lv_font_t *title_font{};
// `base_y`: the card's row in the profile; without the page bar (firmware 0.2.69+) place_page moves it down.
bool wide=false, full=false; int base_width=0, base_height=0, base_y=0; const lv_font_t *title_font{};
// `extra_full`: the size the parts were built for; a slot that changes between full and double width rebuilds them.
lv_obj_t *extra{}; std::string extra_mode; bool extra_full=false; std::array<lv_obj_t *, 36> parts{}; lv_point_precise_t *points{};
// Analog clock: centre and radius of the dial, so the second hand can move without a card redraw.
@@ -2286,6 +2294,7 @@ inline void bind(size_t index, lv_obj_t *tile, lv_obj_t *title, lv_obj_t *value,
widgets[index] = {tile, title, value, circle, icon, index};
auto &w=widgets[index]; w.title_x=lv_obj_get_x(title);w.title_y=lv_obj_get_y(title);w.value_x=lv_obj_get_x(value);w.value_y=lv_obj_get_y(value);w.value_font=lv_obj_get_style_text_font(value,LV_PART_MAIN);
w.base_width=lv_obj_get_width(tile);w.base_height=lv_obj_get_height(tile);
w.base_y=lv_obj_get_y(tile);
w.title_font=lv_obj_get_style_text_font(title,LV_PART_MAIN);
w.icon_font=lv_obj_get_style_text_font(icon,LV_PART_MAIN);
w.unit=lv_label_create(tile);lv_obj_set_style_text_font(w.unit,w.value_font,0);lv_obj_remove_flag(w.unit,LV_OBJ_FLAG_CLICKABLE);lv_obj_add_flag(w.unit,LV_OBJ_FLAG_HIDDEN);
@@ -2452,7 +2461,11 @@ inline void render_clock(Widgets &w,const Tile &t,bool large,int width,int heigh
}
// The dial keeps the same size with or without a card behind it.
// A full-page card centres its dial; the digital time and date beside a wide dial stay off it.
int dial=std::min(height,width),cx=(w.full?(width-dial)/2:0)+dial/2,cy=height/2,outer=dial/2-1,radius=dial/2-(large?4:2);
int dial=std::min(height,width);
// A single card keeps the dial of the profile's card when it grows without the page bar (firmware 0.2.69+), so the
// calendar block beside it keeps its room; the dial stays in the card's middle.
if(!w.full && !w.wide)dial=std::min(dial,w.base_height-(tile_height(w)-height));
int cx=(w.full?(width-dial)/2:0)+dial/2,cy=height/2,outer=dial/2-1,radius=dial/2-(large?4:2);
for(int i=0;i<12;++i){
float a=i*3.14159265f/6;bool cardinal=i%3==0;
if(cardinal && large){
@@ -3129,10 +3142,13 @@ inline void render_slot(size_t slot) {
const lv_font_t *icon_font=watch && watch_icon_font ? watch_icon_font : (mini||graph_strip) && mini_icon_font ? mini_icon_font : w.icon_font;
if(lv_obj_get_style_text_font(w.icon,LV_PART_MAIN)!=icon_font){set_font(w.icon,icon_font);lv_obj_center(w.icon);}
int text_x=watch?0:(mini||graph_strip)?circle_size+(large_tile?8:6):w.title_x;
lv_obj_set_pos(w.title,text_x,watch?0:(mini||graph_strip||!large_tile)?text_y:w.title_y);
// A large card keeps the profile's places for its circle, name and state, moved down by half of what a card
// grows without the page bar, so they stay in its middle.
int lift=std::max(0,(tile_height(w)-w.base_height)/2);
lv_obj_set_pos(w.title,text_x,watch?0:(mini||graph_strip||!large_tile)?text_y:w.title_y+lift);
lv_obj_set_pos(w.value,watch?0:(mini||graph_strip)?text_x:w.value_x,
watch?(large_tile?42:19):(mini||graph_strip||!large_tile)?text_y+title_height+line_gap:w.value_y);
lv_obj_set_pos(w.circle,0,(mini||graph_strip||!large_tile)?std::max(0,(header_height-circle_size)/2):12);
watch?(large_tile?42:19):(mini||graph_strip||!large_tile)?text_y+title_height+line_gap:w.value_y+lift);
lv_obj_set_pos(w.circle,0,(mini||graph_strip||!large_tile)?std::max(0,(header_height-circle_size)/2):12+lift);
// Use the requested coordinates: LVGL getters still return the previous
// layout until its next pass when a slot changes from watch/slider to normal.
int text_room=content_w-chart_w-(graph_side?(large_tile?10:6):0)-panel_w;
@@ -3455,6 +3471,25 @@ inline void render_header() {
}
// Inspect actual LVGL coordinates, including padding and the loaded font metrics.
// The Previous and Next bar and the page number between them (show_page binds them).
inline lv_obj_t *nav_prev=nullptr,*nav_next=nullptr,*nav_number=nullptr;
// The three rows of cards. With the Previous and Next bar on the screen they keep the profile's places. Without it
// (one page, or the Page buttons setting off, firmware 0.2.69+) they share the room down to the bottom edge of the
// screen, keeping the margin the sides have, and every card grows by a third of the bar's room.
struct Rows { int top=0, height=0, pitch=0; };
inline Rows rows(bool bar) {
const auto &first=widgets[0];
const int gap=widgets[2].tile?widgets[2].base_y-first.base_y-first.base_height:0;
Rows r{first.base_y,first.base_height,first.base_height+gap};
auto *grid=first.tile?lv_obj_get_parent(first.tile):nullptr;auto *screen=grid?lv_obj_get_parent(grid):nullptr;
if(bar || !screen)return r;
const int room=lv_obj_get_height(screen)-lv_obj_get_x(first.tile)-lv_obj_get_y(grid)-first.base_y;
const int height=(room-2*gap)/3;
if(height>r.height){r.height=height;r.pitch=height+gap;}
return r;
}
inline Rows applied_rows;
inline bool applied_bar=true;
inline bool check_tile_geometry() {
bool ok=true;
if(fill_next<SLOTS_PER_PAGE){ESP_LOGW("ui_test","page fill still under way at the check");fill_cards(SLOTS_PER_PAGE);}
@@ -3473,10 +3508,16 @@ inline bool check_tile_geometry() {
fits=lv_obj_get_width(w.tile)==expected;
if(!fits)ESP_LOGE("ui_test","Wide width FAIL slot=%u width=%d expected=%d",(unsigned)w.index,lv_obj_get_width(w.tile),expected);
}
if(w.full && widgets[4].tile){
// A full card ends exactly where the third row ends.
int expected=lv_obj_get_y(widgets[4].tile)-lv_obj_get_y(widgets[0].tile)+w.base_height;
if(lv_obj_get_height(w.tile)!=expected){fits=false;ESP_LOGE("ui_test","Full height FAIL slot=%u height=%d expected=%d",(unsigned)w.index,lv_obj_get_height(w.tile),expected);}
{
// Every card lies inside the tile area, a full card reaches its end, and the area stays clear of the page bar
// and keeps the side margin at the bottom of the screen (firmware 0.2.69+ moves the rows without the bar).
lv_area_t card,area,screen;auto *grid=lv_obj_get_parent(w.tile);
lv_obj_get_coords(w.tile,&card);lv_obj_get_coords(grid,&area);lv_obj_get_coords(lv_obj_get_parent(grid),&screen);
const int margin=lv_obj_get_x(widgets[0].tile);
bool placed=card.y1>=area.y1 && card.y2<=area.y2 && area.y2<=screen.y2-margin && (!w.full || card.y2==area.y2);
if(applied_bar && nav_next){lv_area_t nav;lv_obj_get_coords(nav_next,&nav);placed=placed && area.y2<nav.y1;}
if(!applied_bar)placed=placed && area.y2>=screen.y2-margin-3;
if(!placed){fits=false;ESP_LOGE("ui_test","Card place FAIL slot=%u card=%d..%d area=%d..%d screen_bottom=%d bar=%d",(unsigned)w.index,card.y1,card.y2,area.y1,area.y2,screen.y2,applied_bar);}
}
if(!custom && w.full){
// Everything inside the card, the name above the state, a slider or the controls below them.
@@ -3570,30 +3611,40 @@ inline unsigned page_count() {
// in the very next frame. Every following LVGL refresh draws the next two cards, so the loop, and
// the touch polling in it, runs between the steps, and a new swipe drops a fill still under way.
// Keepalives and re-packing on the same page draw at once. Nothing is allocated.
inline lv_obj_t *nav_prev=nullptr,*nav_next=nullptr,*nav_number=nullptr;
inline int applied_page=-1;
// Slot assignment plus card widths and visibility for a page; contents are untouched.
// Slot assignment plus card places, sizes and visibility for a page; contents are untouched.
inline int place_page(int page) {
swipe_profile::Lap lap;
std::array<Placement,MAX_TILES> placement;
int pages=place(model,placement);
page=std::clamp(page,0,pages-1);
const bool bar=pages>1 && page_buttons;
const Rows r=rows(bar);
applied_rows=r;applied_bar=bar;
int wide_width=widgets[0].tile && widgets[1].tile ? lv_obj_get_x(widgets[1].tile)-lv_obj_get_x(widgets[0].tile)+widgets[0].base_width : 2*widgets[0].base_width;
// A full card (firmware 0.2.62+) reaches from the first row to the end of the third.
int full_height=widgets[0].tile && widgets[4].tile ? lv_obj_get_y(widgets[4].tile)-lv_obj_get_y(widgets[0].tile)+widgets[0].base_height : 3*widgets[0].base_height;
int full_height=2*r.pitch+r.height;
// The tile area ends under the third row; a card outside it would be cut off.
if(auto *grid=widgets[0].tile?lv_obj_get_parent(widgets[0].tile):nullptr; grid && lv_obj_get_style_height(grid,LV_PART_MAIN)!=r.top+full_height)
lv_obj_set_height(grid,r.top+full_height);
for(size_t slot=0;slot<widgets.size();++slot){widgets[slot].index=MAX_TILES;widgets[slot].wide=false;widgets[slot].full=false;widgets[slot].cached_active=-1;}
for(size_t i=0;i<model.count;++i)if(placement[i].page==page){auto &w=widgets[placement[i].slot];w.index=i;w.wide=model.tiles[i].wide;w.full=model.tiles[i].full;}
for(size_t slot=0;slot<widgets.size();++slot){
auto &w=widgets[slot];if(!w.tile)continue;
if(slot<SLOTS_PER_PAGE && w.index<model.count){lv_obj_set_size(w.tile,w.wide?wide_width:w.base_width,w.full?full_height:w.base_height);lv_obj_remove_flag(w.tile,LV_OBJ_FLAG_HIDDEN);}
if(slot<SLOTS_PER_PAGE && w.index<model.count){
const int y=r.top+static_cast<int>(slot/2)*r.pitch;
if(lv_obj_get_style_y(w.tile,LV_PART_MAIN)!=y)lv_obj_set_y(w.tile,y);
lv_obj_set_size(w.tile,w.wide?wide_width:w.base_width,w.full?full_height:r.height);lv_obj_remove_flag(w.tile,LV_OBJ_FLAG_HIDDEN);
}
else{lv_obj_add_flag(w.tile,LV_OBJ_FLAG_HIDDEN);hide_extra(w);hide_panel(w);}
}
for(auto *control:{nav_prev,nav_next,nav_number})set_hidden(control,pages<=1);
for(auto *control:{nav_prev,nav_next,nav_number})set_hidden(control,!bar);
if(page==0)lv_obj_add_state(nav_prev,LV_STATE_DISABLED);else lv_obj_remove_state(nav_prev,LV_STATE_DISABLED);
if(page==pages-1)lv_obj_add_state(nav_next,LV_STATE_DISABLED);else lv_obj_remove_state(nav_next,LV_STATE_DISABLED);
for(auto *control:{nav_prev,nav_next})if(lv_obj_get_child_count(control))
set_number(lv_obj_get_child(control,0),LV_STYLE_TEXT_OPA,lv_obj_has_state(control,LV_STATE_DISABLED)?LV_OPA_30:LV_OPA_COVER);
label(nav_number,std::to_string(page+1)+" / "+std::to_string(pages));
// The dots between the two chevrons (firmware 0.2.69+): the page on screen in ink.
if(bar && nav_number)settings_screen::page_dots(nav_number,page,pages,widgets[0].base_height>80);
lap(swipe_profile::PLACE);
return page;
}
@@ -3688,6 +3739,11 @@ inline void go_to_page(int page) {
*shown_page=std::clamp(page,0,int(page_count())-1);
show_page(*shown_page,nav_prev,nav_next,nav_number);
}
// The Page buttons setting changed (firmware 0.2.69+): the same page again, with the bar and the cards in their new places.
inline void page_buttons_changed() {
if(!shown_page || !nav_number || applied_page<0)return;
if(applied_bar!=(page_count()>1 && page_buttons))apply_page(applied_page);
}
#ifdef SWIPE_PROFILE
inline unsigned swipe_test_left=0, swipe_test_back=0;
@@ -3806,6 +3862,7 @@ inline void tick() {
// open card painted in code is drawn again here, in the same pass, so no frame shows half of each look.
inline void restyle() {
for (auto &w : widgets) { w.cached_active = -1; w.panel_dirty = true; }
if (nav_number && applied_bar && applied_page >= 0) settings_screen::page_dots(nav_number, applied_page, page_count(), widgets[0].base_height > 80);
for (auto &slot : header_slots) { slot.own = true; slot.icon_color = UINT32_MAX; }
if (room_label) { dirty_all = true; render(room_label); }
if (detail_root && !lv_obj_has_flag(detail_root, LV_OBJ_FLAG_HIDDEN) && detail_index < model.count) show_detail(detail_index);
+45 -15
View File
@@ -21,8 +21,9 @@ namespace settings_screen {
// Values that are not part of the persisted `screen_settings::Settings` block, because that format is
// frozen at version 1: each of these keeps its own preference, so a new option never rewrites the
// old ones. The manager sends the older ones as their own keys in the layout message; dark_mode (firmware
// 0.2.54+) only exists as the screen's own setting and entity.
inline int32_t swipe_pages = 0, rotation = 0, auto_home = 1, auto_home_seconds = 120, dark_mode = 0;
// 0.2.54+) only exists as the screen's own setting and entity, like page_buttons (firmware 0.2.69+): off, the
// Previous and Next bar under the tiles goes and the tiles take its room.
inline int32_t swipe_pages = 0, rotation = 0, auto_home = 1, auto_home_seconds = 120, dark_mode = 0, page_buttons = 1;
inline bool rotation_supported = false;
// ------------------------------------------------------------------ the table
@@ -103,7 +104,7 @@ inline SetResult set(const std::string &key, int32_t value) {
auto &s = screen_settings::current;
const auto before = s;
const int32_t before_swipe = swipe_pages, before_rotation = rotation, before_home = auto_home,
before_home_seconds = auto_home_seconds, before_dark = dark_mode;
before_home_seconds = auto_home_seconds, before_dark = dark_mode, before_buttons = page_buttons;
int32_t reported = 0;
auto flag = [](int32_t v) -> int32_t { return v ? 1 : 0; };
if (key == "standby_enabled") reported = s.standby_enabled = flag(value);
@@ -124,9 +125,10 @@ inline SetResult set(const std::string &key, int32_t value) {
else if (key == "auto_home") reported = auto_home = flag(value);
else if (key == "auto_home_seconds") reported = auto_home_seconds = std::clamp<int32_t>(value, 30, 3600);
else if (key == "dark_mode") reported = dark_mode = flag(value);
else if (key == "page_buttons") reported = page_buttons = flag(value);
else return SetResult::unknown;
if (s == before && swipe_pages == before_swipe && rotation == before_rotation && auto_home == before_home &&
auto_home_seconds == before_home_seconds && dark_mode == before_dark)
auto_home_seconds == before_home_seconds && dark_mode == before_dark && page_buttons == before_buttons)
return SetResult::same;
changed(key.c_str(), reported);
return SetResult::changed;
@@ -258,7 +260,8 @@ inline constexpr Row night_rows[] = {
[] { return screen_settings::current.night_enabled != 0; }),
};
// How the screen behaves under your finger: the clock, going back to the first page, swiping, turning.
// How the screen behaves under your finger: the clock, going back to the first page, swiping, the page buttons,
// turning.
inline constexpr Row screen_rows[] = {
choice("Clock", []() -> int32_t { return screen_settings::current.clock_24h ? 1 : 0; },
[](int32_t value) { set("clock_24h", value); }, clock_options, 2),
@@ -271,6 +274,8 @@ inline constexpr Row screen_rows[] = {
[](int32_t value) { set("home_on_standby", value); }),
toggle("Swipe between pages", []() -> int32_t { return swipe_pages; },
[](int32_t value) { set("swipe_pages", value); }),
toggle("Page buttons", []() -> int32_t { return page_buttons; },
[](int32_t value) { set("page_buttons", value); }),
choice("Rotation", []() -> int32_t { return rotation / 90; },
[](int32_t value) { set("rotation", std::clamp<int32_t>(value, 0, 3) * 90); },
rotation_options, 4, [] { return rotation_supported; }),
@@ -360,6 +365,29 @@ inline Metrics metrics() {
large ? 62 : 40, large ? 34 : 22};
}
// The page dots of a pager (firmware 0.2.69+), under the tiles and on this page: one per page, centred in `row`, the
// page on screen in ink and the others a quiet grey. The dots are made once and reused; `row` takes no touches.
inline void page_dots(lv_obj_t *row, int current, int total, bool large) {
const int dot = large ? 8 : 6, gap = large ? 10 : 7;
total = std::clamp(total, 0, 8);
while ((int) lv_obj_get_child_count(row) < total) {
auto *d = lv_obj_create(row);
lv_obj_remove_style_all(d);
lv_obj_remove_flag(d, static_cast<lv_obj_flag_t>(LV_OBJ_FLAG_SCROLLABLE | LV_OBJ_FLAG_CLICKABLE));
lv_obj_set_style_radius(d, LV_RADIUS_CIRCLE, 0);
}
const int width = total * dot + std::max(0, total - 1) * gap;
for (int i = 0; i < (int) lv_obj_get_child_count(row); ++i) {
auto *d = lv_obj_get_child(row, i);
if (i >= total) { lv_obj_add_flag(d, LV_OBJ_FLAG_HIDDEN); continue; }
lv_obj_remove_flag(d, LV_OBJ_FLAG_HIDDEN);
lv_obj_set_size(d, dot, dot);
lv_obj_align(d, LV_ALIGN_CENTER, i * (dot + gap) + dot / 2 - width / 2, 0);
lv_obj_set_style_bg_color(d, theme::color(i == current ? theme::INK : theme::SUBTLE), 0);
lv_obj_set_style_bg_opa(d, i == current ? LV_OPA_COVER : LV_OPA_40, 0);
}
}
inline lv_obj_t *plain(lv_obj_t *parent, int x, int y, int w, int h) {
auto *obj = lv_obj_create(parent);
lv_obj_remove_style_all(obj);
@@ -647,32 +675,34 @@ inline void draw() {
refresh();
if (!paged) return;
// The same pager as the tile pages, in the same place, so it is the one that is already learned.
// The same pager as the tile pages, in the same place, so it is the one that is already learned: a chevron in each
// half, the page dots between them (firmware 0.2.69+). A half lights up under a finger; one that leads nowhere is
// dimmed and takes no touches, so nothing moves from page to page.
uint8_t page_number = (uint8_t) (first_row / per_page + 1);
uint8_t page_total = (uint8_t) ((count + per_page - 1) / per_page);
int y = m.height - m.pager - m.bottom / 2, half = (m.width - 2 * m.pad) / 2 - 20;
const lv_font_t *chevrons = icon_font ? icon_font : row_font;
int chevron_h = lv_font_get_line_height(chevrons);
auto *dots = plain(root, 0, y, m.width, m.pager);
page_dots(dots, page_number - 1, page_total, m.large);
for (int side = 0; side < 2; ++side) {
bool enabled = side ? page_number < page_total : page_number > 1;
if (!enabled) continue;
auto *bar = plain(root, side ? m.width - m.pad - half : m.pad, y, half, m.pager);
auto *glyph = text(bar, side ? "\U000F0142" : "\U000F0141", chevrons, theme::INK,
side ? LV_TEXT_ALIGN_RIGHT : LV_TEXT_ALIGN_LEFT);
lv_obj_set_width(glyph, half - 12);
lv_obj_set_pos(glyph, 6, (m.pager - chevron_h) / 2);
if (!enabled) { lv_obj_set_style_opa(glyph, LV_OPA_30, 0); continue; }
lv_obj_add_flag(bar, LV_OBJ_FLAG_CLICKABLE);
lv_obj_set_style_bg_opa(bar, LV_OPA_TRANSP, 0);
lv_obj_set_style_bg_opa(bar, LV_OPA_COVER, LV_STATE_PRESSED);
lv_obj_set_style_bg_color(bar, theme::color(theme::KEY_PRESSED), LV_STATE_PRESSED);
lv_obj_set_style_radius(bar, m.radius, 0);
auto *label = text(bar, side ? "Next >" : "< Previous", row_font, theme::INK,
side ? LV_TEXT_ALIGN_RIGHT : LV_TEXT_ALIGN_LEFT);
lv_obj_set_width(label, half - 12);
lv_obj_set_pos(label, 6, (m.pager - label_h) / 2);
// CLICKED, not SHORT_CLICKED: LVGL sends no short click after a press of long_press_time (400 ms), so a firm or
// slow press did nothing (firmware 0.2.65+). The bar has no hold of its own.
lv_obj_add_event_cb(bar, pager_event, LV_EVENT_CLICKED,
(void *) (intptr_t) (side ? per_page : -per_page));
}
auto *counter = text(root, std::to_string(page_number) + " / " + std::to_string(page_total), row_font, theme::MUTED,
LV_TEXT_ALIGN_CENTER);
lv_obj_set_width(counter, m.width);
lv_obj_set_pos(counter, 0, y + (m.pager - label_h) / 2);
}
inline void hide_hold_bar() {
+7 -1
View File
@@ -175,7 +175,12 @@ middle of the screen does nothing, so tapping and dragging tiles never
accidentally changes pages. On the CYD, it stays a quick swipe across the screen. Sliders
only control their value; detail menus and standby don't change pages.
Six tiles sit on a page. With six or fewer, Previous/Next disappear.
Six tiles sit on a page. Under the tiles, the page buttons: a chevron in each half of the
bar and a dot per page between them; tap anywhere in the left or right half. With six or
fewer tiles they disappear and the tiles grow into their room (firmware 0.2.69+). **Page
buttons** in the **Screen settings** tab takes them away on a screen with more pages too:
then only swiping and *Go to page* tiles change the page, and the editor says which pages
that leaves out of reach.
The default standby time is ten minutes. For offline devices, the screen blocks
actions. If the connection to Home Assistant drops, the screen immediately shows
"HA not connected"; if the app sends nothing for two rounds (about five minutes),
@@ -283,6 +288,7 @@ After this, changes to these settings don't need a new firmware flash.
| Time format | 24 or 12 hour, without AM/PM | 24 hour |
| Return to page 1 | Also closes detail menus on standby | Off |
| Swipe between pages | Native horizontal swipe, firmware 0.2.7+ | Off |
| Page buttons | Off: no buttons under the tiles, the tiles take their room, firmware 0.2.69+ | On |
| Guition rotation | 0°, 90°, 180°, 270°, firmware 0.2.9+ | 0° |
Home Assistant shows the same settings on each screen's ESPHome device: the switch
+23
View File
@@ -215,6 +215,29 @@ icons sit off-center in the browser); run `generate_packages.py` afterward.
The `MDI_GLYPH_*` substitutions are retired: `TILEn_ICON` in manual
profiles must come from the set. No changed preferences or keys.
### Compatibility 0.2.82 / firmware 0.2.69
Page buttons (GitHub issue #9): a setting the screen owns, `page_buttons`, on by default. It has a preference record
of its own (`0x50474231`), the switch "Page buttons" in both profiles, and `SETTING_RULES`, `SETTING_ENTITIES` and
`SETTINGS_BESIDE_BLOCK` entries in the app. Like Dark mode it never travels in the layout message, and a screen
without the entity shows no row for it.
- Firmware: `place_page` places the three rows itself (`runtime_tiles::rows`). With the bar on screen (more than one
page and `page_buttons` on) they keep the profile's places (`Widgets::base_y`, `base_height`). Without it they share
the room down to the bottom edge, keeping the side margin: Guition 108 → 122 px, CYD 52 → 61 px. `tile_scroll`
grows with them. A large card keeps its profile places for the circle, name and state, lowered by half the growth;
the single analog clock keeps the profile's dial. `page_buttons_changed()` (called from `apply_screen_settings`)
places the page again when the bar comes or goes. `check_tile_geometry` checks that every card lies inside the tile
area, clear of the bar and within the bottom margin.
- Page bar: `page_prev` and `page_next` are the two halves of the band under the tiles, running from
`SCROLL_Y + SCROLL_H` to the bottom (Guition 240×60, CYD 160×36). Each holds a chevron in `materialdesign_icons_mini`
(F0141/F0142). `page_number` is a non-clickable `obj` that holds the dots (`settings_screen::page_dots`), so LVGL's
hit test gives every point of the band to one of the halves. The settings page's pager uses the same chevrons and
dots, with a dimmed chevron where it leads nowhere.
- Editor: `strandedPages()` (`model/layout.ts`) and `pageReachWarning()` (`store.ts`) warn when page buttons and
swiping are both off and the Go to page tiles leave a page out of reach, or give it no way back to page 1. The
warning shows above the pages and under the Screen card.
### Compatibility 0.2.78 / firmware 0.2.65
Repeated navigation tiles: the same `screen.page_<n>` may sit on several pages of one screen. Firmware 0.2.65 accepts
+5 -3
View File
@@ -27,6 +27,7 @@ changes them with the entity's own action, and leaves them out of the layout mes
| Back to page 1, After | `switch.<screen>_back_to_page_1`, `number.<screen>_back_to_page_1_after` | `auto_home`, `auto_home_seconds` |
| Also on standby | `switch.<screen>_back_to_page_1_on_standby` | `home_on_standby` |
| Swipe between pages | `switch.<screen>_swipe_between_pages` | `swipe_pages` |
| Page buttons (0.2.69+) | `switch.<screen>_page_buttons` | `page_buttons` |
| Rotation (Guition) | `select.<screen>_rotation` | `rotation` |
The first four entities and Auto standby existed before 0.2.49; ESP Screens recognizes a screen that owns
@@ -52,7 +53,7 @@ The page is a menu of groups, each of which opens a page of its own:
|---|---|
| Brightness | Brightness, Dark mode, Auto standby, Standby after, Standby brightness |
| Night | Night mode, Starts, Ends, Night brightness |
| Screen | Clock, Back to page 1, After, Also on standby, Swipe between pages, Rotation (boards that turn) |
| Screen | Clock, Back to page 1, After, Also on standby, Swipe between pages, Page buttons, Rotation (boards that turn) |
| This screen | Screen, Address, Firmware, Home Assistant, Restart |
Every change is stored on the screen, applied at once and published on its entity, so Home Assistant and
@@ -64,8 +65,9 @@ unknown and takes no changes until it is back.
## The rules the page follows
- **Two levels, never three.** A group page is the deepest place a setting can live.
- **No free scrolling.** A group that does not fit gets the same `< Previous / Next >` pager as the tile
pages. A 320x240 board shows five rows, a 480x480 board six.
- **No free scrolling.** A group that does not fit gets the same pager as the tile pages: a chevron in each
half of the bar and a dot per page between them (firmware 0.2.69+). A 320x240 board shows five rows, a
480x480 board six; with the pager four and five.
- **A row is a control, not a form.** Toggles flip on tap, numbers and times have `-` and `+`, a choice
cycles through its options in a chip on the right.
- **A row that depends on a switch above it is greyed out, not hidden**, so the page never jumps around.
+50
View File
@@ -0,0 +1,50 @@
# Test results app 0.2.82 / firmware 0.2.69 (2026-09-18)
GitHub issue #9 (lokitol): "when using the next page tiles, is it possible to remove the bottom of the page
next/previous buttons and extend the size of the tiles to utilise all the screen?" Max chose a screen setting with a
warning in the editor for pages that can't be reached. During the round he also asked for new page buttons: only MDI
icons in the corners, no words. He picked proposal B from two renders: bare chevrons and page dots. He kept the whole
half of the bar as the key, lit up under the finger, with no round shadow, and asked for the same buttons on the
screen's settings page. He approved the final renders before release ("ik ben blij, wat mij betreft releasen").
## What changed
- Setting `page_buttons` (default on): preference record `0x50474231`, `settings_screen::set()`, the row "Page
buttons" in the Screen group, switch "Page buttons" in both profiles, `SETTING_RULES`/`SETTING_ENTITIES`/
`SETTINGS_BESIDE_BLOCK` in the app, a row in the editor and in the Claude skill's entity table.
- `runtime_tiles::place_page` places the rows (`rows()`): the profile's rows with the bar, the whole room down to the
bottom margin without it (Guition 108 → 122 px, CYD 52 → 61 px). Large cards lower their YAML places by half the
growth; the single analog clock keeps the profile's dial.
- Page keys: each half of the band under the tiles (Guition 240×60, CYD 160×36) with a chevron; `page_number` holds
the dots (`settings_screen::page_dots`); the settings page's pager draws the same.
- Editor: `strandedPages()` + `pageReachWarning()`, shown above the pages and under the Screen card.
## Automated
- `tools/check.sh`: Python 455 tests, C++ 18/18 (`test_settings_screen.cpp` covers the row and `set("page_buttons")`),
packages, icons, Vitest 81/81 (`strandedPages`, the warning's words and when it stays quiet), vue-tsc, build.
- `tools/check.sh --firmware` (ESPHome 2026.6.2): CYD 1,655,136 B = 90.2 % of the 1,835,008 B slot, +1,632 B against
0.2.81 (tight band: the delta is stated), Guition 2,029,088 B = 25.0 % of its 8,126,464 B slot.
## On the host (the real firmware for the Mac, SDL, own API ports 6191/6192)
- Renders of every card kind, the README showcase layout, the eight full-page cards and a one-page layout, each with the
page buttons on and off, on both boards; the settings page's Screen group (two pages now) and dark mode (switched
while a page is shown, and after a page change). Max checked them all before release.
- `ui_self_test` with the bar on and off, on both boards, with two layouts: every `page_check` PASS (10/10 per run) and
no `Tile geometry`, `Card place`, `Panel bounds` or `Part bounds` failure. `check_tile_geometry` now also checks that
every card lies inside the tile area, clear of the bar, and that without the bar the area reaches the bottom margin.
- LVGL's own hit test (`lv_indev_search_obj`) across the band: every point of the left half is Previous, every point
of the right half Next (the dots take no touches); just above the band a finger gets the card; without the bar the
band belongs to the cards.
- The small slider of 0.2.81 on a taller card, with a virtual finger (a second LVGL pointer): the strip claims the top
and middle of its card, a drag that starts above the strip sets the light (`light.turn_on`), the page stays, and a
still press beside the strip is the tile's tap. 16/16 on both boards with the bar on and off.
- Editor in a browser against a demo add-on whose screen owns its settings: the warning names the pages ("You have 1
Go to page tile, to page 1, but none to pages 2, 3 and 4") and follows a new Go to page tile at once; the Page
buttons switch sends `PUT .../settings` (200) and the warning goes when it is on.
## Not tested
- On real hardware: the new keys under a finger (GT911 and XPT2046), the taller cards, and switching the setting from
the screen's settings page and from Home Assistant. The bench boards were not flashed in this round.
+38 -20
View File
@@ -2,7 +2,7 @@
# Source profile for packages/guition.yaml (tools/generate_packages.py); see docs/GUITION.md.
# A screen gets its tiles from the ESP Screen Manager add-on while it runs. The CYD is its own profile.
substitutions:
SCREEN_FIRMWARE_VERSION: "0.2.68"
SCREEN_FIRMWARE_VERSION: "0.2.69"
# 0: no drift limit. A press that drifts is still a tap as long as the finger lets go on the tile
# (runtime_tiles::event checks where it let go: LVGL keeps the press on the tile and would click it
# anywhere), and a quick flick is LVGL's gesture, never a tap (the home_page gesture handler in on_boot).
@@ -1458,6 +1458,18 @@ switch:
- lambda: 'settings_screen::set("swipe_pages", 1);'
turn_off_action:
- lambda: 'settings_screen::set("swipe_pages", 0);'
# Off, the Previous and Next bar under the tiles goes and the tiles take its room (firmware 0.2.69+).
- platform: template
id: setting_page_buttons
name: "Page buttons"
icon: "mdi:page-next-outline"
entity_category: config
restore_mode: DISABLED
lambda: return settings_screen::page_buttons != 0;
turn_on_action:
- lambda: 'settings_screen::set("page_buttons", 1);'
turn_off_action:
- lambda: 'settings_screen::set("page_buttons", 0);'
# Night hours as Home Assistant times (firmware 0.2.49+); the screen keeps minutes since midnight.
datetime:
@@ -2486,14 +2498,16 @@ lvgl:
clickable: false
# ---------- VALUE SLIDER OVERLAY ----------
# ---------- PAGE NAVIGATION: one full-width bar ----------
# ---------- PAGE NAVIGATION: the band under the tiles ----------
# Each half of the band is one key (firmware 0.2.69+): a chevron in its corner, lit up under a finger, and the
# page dots between them. The band reaches from the tile area's end (SCROLL_Y + SCROLL_H) to the bottom edge.
- obj:
id: page_prev
align: BOTTOM_LEFT
x: 0
y: 0
width: 200
height: 44
width: 240
height: 60
pad_all: 0
radius: 0
bg_opa: TRANSP
@@ -2515,28 +2529,28 @@ lvgl:
- label:
align: LEFT_MID
x: 24
text: "< Previous"
text_font: label
text: "\U000F0141" # chevron-left
text_font: materialdesign_icons_mini
styles: paint_ink
clickable: false
- label:
- obj:
id: page_number
align: BOTTOM_MID
y: 0
width: 80
height: 44
pad_top: 12
width: 160
height: 60
pad_all: 0
bg_opa: TRANSP
border_width: 0
text: "1 / 2"
text_align: CENTER
text_font: sublabel
clickable: false
scrollable: false
- obj:
id: page_next
align: BOTTOM_RIGHT
x: 0
y: 0
width: 200
height: 44
width: 240
height: 60
pad_all: 0
radius: 0
bg_opa: TRANSP
@@ -2556,8 +2570,8 @@ lvgl:
- label:
align: RIGHT_MID
x: -24
text: "Next >"
text_font: label
text: "\U000F0142" # chevron-right
text_font: materialdesign_icons_mini
styles: paint_ink
clickable: false
@@ -3852,12 +3866,14 @@ script:
bool visible = runtime_tiles::enabled ? i<6 && runtime_tiles::widgets[i].index<runtime_tiles::model.count : i < count && (paginated ? i >= 6 : i < 6);
ok = ok && (lv_obj_has_flag(tiles[i], LV_OBJ_FLAG_HIDDEN) != visible);
}
// The bar shows with more than one page, unless the Page buttons setting is off (firmware 0.2.69+).
const bool bar = paginated && (!runtime_tiles::enabled || settings_screen::page_buttons);
for (lv_obj_t* control : {id(page_prev), id(page_next), id(page_number)})
ok = ok && (lv_obj_has_flag(control, LV_OBJ_FLAG_HIDDEN) != paginated);
ok = ok && (lv_obj_has_flag(control, LV_OBJ_FLAG_HIDDEN) != bar);
lv_area_t nav, grid;
lv_obj_get_coords(id(page_next), &nav);
lv_obj_get_coords(id(tile_scroll), &grid);
ok = ok && (!paginated || grid.y2 < nav.y1);
ok = ok && (!bar || grid.y2 < nav.y1);
if(runtime_tiles::enabled)ok = runtime_tiles::check_tile_geometry() && ok;
ESP_LOGI("ui_test", "page_check=%s count=%d heap=%u frames=%u grid_bottom=%d nav_top=%d", ok ? "PASS" : "FAIL", count, (unsigned) esp_get_free_heap_size(), (unsigned) id(rendered_frames), grid.y2, nav.y1);
id(tile_page) = 0;
@@ -3911,7 +3927,7 @@ script:
lv_obj_add_flag(tiles[i], LV_OBJ_FLAG_HIDDEN);
}
}
lv_label_set_text(id(page_number), id(tile_page) == 0 ? "1 / 2" : "2 / 2");
settings_screen::page_dots(id(page_number), id(tile_page), 2, true);
if (id(tile_page) == 0) {
lv_obj_add_state(id(page_prev), LV_STATE_DISABLED);
lv_obj_clear_state(id(page_next), LV_STATE_DISABLED);
@@ -3960,6 +3976,8 @@ script:
}
const auto shown_rotation = id(setting_rotation)->active_index();
if (!id(setting_rotation)->has_state() || !shown_rotation.has_value() || *shown_rotation != (size_t) (settings_screen::rotation / 90)) id(setting_rotation)->update();
// The Page buttons setting moves the cards into the bar's room or back (firmware 0.2.69+).
if (runtime_tiles::enabled) runtime_tiles::page_buttons_changed();
settings_screen::refresh();
if (!id(ui_refresh).is_running()) id(ui_refresh).execute();
+40 -22
View File
@@ -17,7 +17,7 @@
############################################################
substitutions:
SCREEN_FIRMWARE_VERSION: "0.2.68"
SCREEN_FIRMWARE_VERSION: "0.2.69"
# A tap survives a finger that drifts up to this far (320 px over 58 mm: 1 cm); further is a swipe.
TOUCH_MOVE_LIMIT_PX: "56"
# Shortest contact that counts as a tap; a resistive panel bounces, a capacitive one does not.
@@ -1469,6 +1469,18 @@ switch:
- lambda: 'settings_screen::set("swipe_pages", 1);'
turn_off_action:
- lambda: 'settings_screen::set("swipe_pages", 0);'
# Off, the Previous and Next bar under the tiles goes and the tiles take its room (firmware 0.2.69+).
- platform: template
id: setting_page_buttons
name: "Page buttons"
icon: "mdi:page-next-outline"
entity_category: config
restore_mode: DISABLED
lambda: return settings_screen::page_buttons != 0;
turn_on_action:
- lambda: 'settings_screen::set("page_buttons", 1);'
turn_off_action:
- lambda: 'settings_screen::set("page_buttons", 0);'
# Night hours as Home Assistant times (firmware 0.2.49+); the screen keeps minutes since midnight.
datetime:
@@ -2386,14 +2398,16 @@ lvgl:
clickable: false
# ---------- VALUE SLIDER OVERLAY ----------
# ---------- PAGE NAVIGATION: one full-width bar ----------
# ---------- PAGE NAVIGATION: the band under the tiles ----------
# Each half of the band is one key (firmware 0.2.69+): a chevron in its corner, lit up under a finger, and the
# page dots between them. The band reaches from the tile area's end (SCROLL_Y + SCROLL_H) to the bottom edge.
- obj:
id: page_prev
align: BOTTOM_LEFT
x: 0
y: 0
width: 130
height: 32
width: 160
height: 36
pad_all: 0
radius: 0
bg_opa: TRANSP
@@ -2414,29 +2428,29 @@ lvgl:
widgets:
- label:
align: LEFT_MID
x: 12
text: "< Previous"
text_font: label
x: 14
text: "\U000F0141" # chevron-left
text_font: materialdesign_icons_mini
styles: paint_ink
clickable: false
- label:
- obj:
id: page_number
align: BOTTOM_MID
y: 0
width: 60
height: 32
pad_top: 9
width: 104
height: 36
pad_all: 0
bg_opa: TRANSP
border_width: 0
text: "1 / 2"
text_align: CENTER
text_font: sublabel
clickable: false
scrollable: false
- obj:
id: page_next
align: BOTTOM_RIGHT
x: 0
y: 0
width: 130
height: 32
width: 160
height: 36
pad_all: 0
radius: 0
bg_opa: TRANSP
@@ -2455,9 +2469,9 @@ lvgl:
widgets:
- label:
align: RIGHT_MID
x: -12
text: "Next >"
text_font: label
x: -14
text: "\U000F0142" # chevron-right
text_font: materialdesign_icons_mini
styles: paint_ink
clickable: false
@@ -3728,12 +3742,14 @@ script:
bool visible = runtime_tiles::enabled ? i<6 && runtime_tiles::widgets[i].index<runtime_tiles::model.count : i < count && (paginated ? i >= 6 : i < 6);
ok = ok && (lv_obj_has_flag(tiles[i], LV_OBJ_FLAG_HIDDEN) != visible);
}
// The bar shows with more than one page, unless the Page buttons setting is off (firmware 0.2.69+).
const bool bar = paginated && (!runtime_tiles::enabled || settings_screen::page_buttons);
for (lv_obj_t* control : {id(page_prev), id(page_next), id(page_number)})
ok = ok && (lv_obj_has_flag(control, LV_OBJ_FLAG_HIDDEN) != paginated);
ok = ok && (lv_obj_has_flag(control, LV_OBJ_FLAG_HIDDEN) != bar);
lv_area_t nav, grid;
lv_obj_get_coords(id(page_next), &nav);
lv_obj_get_coords(id(tile_scroll), &grid);
ok = ok && (!paginated || grid.y2 < nav.y1);
ok = ok && (!bar || grid.y2 < nav.y1);
if(runtime_tiles::enabled)ok = runtime_tiles::check_tile_geometry() && ok;
ESP_LOGI("ui_test", "page_check=%s count=%d heap=%u frames=%u grid_bottom=%d nav_top=%d", ok ? "PASS" : "FAIL", count, (unsigned) esp_get_free_heap_size(), (unsigned) id(rendered_frames), grid.y2, nav.y1);
id(tile_page) = 0;
@@ -3783,7 +3799,7 @@ script:
lv_obj_add_flag(tiles[i], LV_OBJ_FLAG_HIDDEN);
}
}
lv_label_set_text(id(page_number), id(tile_page) == 0 ? "1 / 2" : "2 / 2");
settings_screen::page_dots(id(page_number), id(tile_page), 2, false);
if (id(tile_page) == 0) {
lv_obj_add_state(id(page_prev), LV_STATE_DISABLED);
lv_obj_clear_state(id(page_next), LV_STATE_DISABLED);
@@ -3824,6 +3840,8 @@ script:
for (auto [entity, minutes] : {std::make_pair(id(setting_night_start), settings.night_start), std::make_pair(id(setting_night_end), settings.night_end)}) {
if (!entity->has_state() || entity->hour * 60 + entity->minute != minutes) entity->update();
}
// The Page buttons setting moves the cards into the bar's room or back (firmware 0.2.69+).
if (runtime_tiles::enabled) runtime_tiles::page_buttons_changed();
settings_screen::refresh();
if (!id(ui_refresh).is_running()) id(ui_refresh).execute();
+40 -22
View File
@@ -18,7 +18,7 @@
############################################################
substitutions:
SCREEN_FIRMWARE_VERSION: "0.2.68"
SCREEN_FIRMWARE_VERSION: "0.2.69"
# A tap survives a finger that drifts up to this far (320 px over 58 mm: 1 cm); further is a swipe.
TOUCH_MOVE_LIMIT_PX: "56"
# Shortest contact that counts as a tap; a resistive panel bounces, a capacitive one does not.
@@ -1458,6 +1458,18 @@ switch:
- lambda: 'settings_screen::set("swipe_pages", 1);'
turn_off_action:
- lambda: 'settings_screen::set("swipe_pages", 0);'
# Off, the Previous and Next bar under the tiles goes and the tiles take its room (firmware 0.2.69+).
- platform: template
id: setting_page_buttons
name: "Page buttons"
icon: "mdi:page-next-outline"
entity_category: config
restore_mode: DISABLED
lambda: return settings_screen::page_buttons != 0;
turn_on_action:
- lambda: 'settings_screen::set("page_buttons", 1);'
turn_off_action:
- lambda: 'settings_screen::set("page_buttons", 0);'
# Night hours as Home Assistant times (firmware 0.2.49+); the screen keeps minutes since midnight.
datetime:
@@ -2375,14 +2387,16 @@ lvgl:
clickable: false
# ---------- VALUE SLIDER OVERLAY ----------
# ---------- PAGE NAVIGATION: one full-width bar ----------
# ---------- PAGE NAVIGATION: the band under the tiles ----------
# Each half of the band is one key (firmware 0.2.69+): a chevron in its corner, lit up under a finger, and the
# page dots between them. The band reaches from the tile area's end (SCROLL_Y + SCROLL_H) to the bottom edge.
- obj:
id: page_prev
align: BOTTOM_LEFT
x: 0
y: 0
width: 130
height: 32
width: 160
height: 36
pad_all: 0
radius: 0
bg_opa: TRANSP
@@ -2403,29 +2417,29 @@ lvgl:
widgets:
- label:
align: LEFT_MID
x: 12
text: "< Previous"
text_font: label
x: 14
text: "\U000F0141" # chevron-left
text_font: materialdesign_icons_mini
styles: paint_ink
clickable: false
- label:
- obj:
id: page_number
align: BOTTOM_MID
y: 0
width: 60
height: 32
pad_top: 9
width: 104
height: 36
pad_all: 0
bg_opa: TRANSP
border_width: 0
text: "1 / 2"
text_align: CENTER
text_font: sublabel
clickable: false
scrollable: false
- obj:
id: page_next
align: BOTTOM_RIGHT
x: 0
y: 0
width: 130
height: 32
width: 160
height: 36
pad_all: 0
radius: 0
bg_opa: TRANSP
@@ -2444,9 +2458,9 @@ lvgl:
widgets:
- label:
align: RIGHT_MID
x: -12
text: "Next >"
text_font: label
x: -14
text: "\U000F0142" # chevron-right
text_font: materialdesign_icons_mini
styles: paint_ink
clickable: false
@@ -3717,12 +3731,14 @@ script:
bool visible = runtime_tiles::enabled ? i<6 && runtime_tiles::widgets[i].index<runtime_tiles::model.count : i < count && (paginated ? i >= 6 : i < 6);
ok = ok && (lv_obj_has_flag(tiles[i], LV_OBJ_FLAG_HIDDEN) != visible);
}
// The bar shows with more than one page, unless the Page buttons setting is off (firmware 0.2.69+).
const bool bar = paginated && (!runtime_tiles::enabled || settings_screen::page_buttons);
for (lv_obj_t* control : {id(page_prev), id(page_next), id(page_number)})
ok = ok && (lv_obj_has_flag(control, LV_OBJ_FLAG_HIDDEN) != paginated);
ok = ok && (lv_obj_has_flag(control, LV_OBJ_FLAG_HIDDEN) != bar);
lv_area_t nav, grid;
lv_obj_get_coords(id(page_next), &nav);
lv_obj_get_coords(id(tile_scroll), &grid);
ok = ok && (!paginated || grid.y2 < nav.y1);
ok = ok && (!bar || grid.y2 < nav.y1);
if(runtime_tiles::enabled)ok = runtime_tiles::check_tile_geometry() && ok;
ESP_LOGI("ui_test", "page_check=%s count=%d heap=%u frames=%u grid_bottom=%d nav_top=%d", ok ? "PASS" : "FAIL", count, (unsigned) esp_get_free_heap_size(), (unsigned) id(rendered_frames), grid.y2, nav.y1);
id(tile_page) = 0;
@@ -3772,7 +3788,7 @@ script:
lv_obj_add_flag(tiles[i], LV_OBJ_FLAG_HIDDEN);
}
}
lv_label_set_text(id(page_number), id(tile_page) == 0 ? "1 / 2" : "2 / 2");
settings_screen::page_dots(id(page_number), id(tile_page), 2, false);
if (id(tile_page) == 0) {
lv_obj_add_state(id(page_prev), LV_STATE_DISABLED);
lv_obj_clear_state(id(page_next), LV_STATE_DISABLED);
@@ -3813,6 +3829,8 @@ script:
for (auto [entity, minutes] : {std::make_pair(id(setting_night_start), settings.night_start), std::make_pair(id(setting_night_end), settings.night_end)}) {
if (!entity->has_state() || entity->hour * 60 + entity->minute != minutes) entity->update();
}
// The Page buttons setting moves the cards into the bar's room or back (firmware 0.2.69+).
if (runtime_tiles::enabled) runtime_tiles::page_buttons_changed();
settings_screen::refresh();
if (!id(ui_refresh).is_running()) id(ui_refresh).execute();
+38 -20
View File
@@ -3,7 +3,7 @@
# Source profile for packages/guition.yaml (tools/generate_packages.py); see docs/GUITION.md.
# A screen gets its tiles from the ESP Screen Manager add-on while it runs. The CYD is its own profile.
substitutions:
SCREEN_FIRMWARE_VERSION: "0.2.68"
SCREEN_FIRMWARE_VERSION: "0.2.69"
# 0: no drift limit. A press that drifts is still a tap as long as the finger lets go on the tile
# (runtime_tiles::event checks where it let go: LVGL keeps the press on the tile and would click it
# anywhere), and a quick flick is LVGL's gesture, never a tap (the home_page gesture handler in on_boot).
@@ -1447,6 +1447,18 @@ switch:
- lambda: 'settings_screen::set("swipe_pages", 1);'
turn_off_action:
- lambda: 'settings_screen::set("swipe_pages", 0);'
# Off, the Previous and Next bar under the tiles goes and the tiles take its room (firmware 0.2.69+).
- platform: template
id: setting_page_buttons
name: "Page buttons"
icon: "mdi:page-next-outline"
entity_category: config
restore_mode: DISABLED
lambda: return settings_screen::page_buttons != 0;
turn_on_action:
- lambda: 'settings_screen::set("page_buttons", 1);'
turn_off_action:
- lambda: 'settings_screen::set("page_buttons", 0);'
# Night hours as Home Assistant times (firmware 0.2.49+); the screen keeps minutes since midnight.
datetime:
@@ -2475,14 +2487,16 @@ lvgl:
clickable: false
# ---------- VALUE SLIDER OVERLAY ----------
# ---------- PAGE NAVIGATION: one full-width bar ----------
# ---------- PAGE NAVIGATION: the band under the tiles ----------
# Each half of the band is one key (firmware 0.2.69+): a chevron in its corner, lit up under a finger, and the
# page dots between them. The band reaches from the tile area's end (SCROLL_Y + SCROLL_H) to the bottom edge.
- obj:
id: page_prev
align: BOTTOM_LEFT
x: 0
y: 0
width: 200
height: 44
width: 240
height: 60
pad_all: 0
radius: 0
bg_opa: TRANSP
@@ -2504,28 +2518,28 @@ lvgl:
- label:
align: LEFT_MID
x: 24
text: "< Previous"
text_font: label
text: "\U000F0141" # chevron-left
text_font: materialdesign_icons_mini
styles: paint_ink
clickable: false
- label:
- obj:
id: page_number
align: BOTTOM_MID
y: 0
width: 80
height: 44
pad_top: 12
width: 160
height: 60
pad_all: 0
bg_opa: TRANSP
border_width: 0
text: "1 / 2"
text_align: CENTER
text_font: sublabel
clickable: false
scrollable: false
- obj:
id: page_next
align: BOTTOM_RIGHT
x: 0
y: 0
width: 200
height: 44
width: 240
height: 60
pad_all: 0
radius: 0
bg_opa: TRANSP
@@ -2545,8 +2559,8 @@ lvgl:
- label:
align: RIGHT_MID
x: -24
text: "Next >"
text_font: label
text: "\U000F0142" # chevron-right
text_font: materialdesign_icons_mini
styles: paint_ink
clickable: false
@@ -3841,12 +3855,14 @@ script:
bool visible = runtime_tiles::enabled ? i<6 && runtime_tiles::widgets[i].index<runtime_tiles::model.count : i < count && (paginated ? i >= 6 : i < 6);
ok = ok && (lv_obj_has_flag(tiles[i], LV_OBJ_FLAG_HIDDEN) != visible);
}
// The bar shows with more than one page, unless the Page buttons setting is off (firmware 0.2.69+).
const bool bar = paginated && (!runtime_tiles::enabled || settings_screen::page_buttons);
for (lv_obj_t* control : {id(page_prev), id(page_next), id(page_number)})
ok = ok && (lv_obj_has_flag(control, LV_OBJ_FLAG_HIDDEN) != paginated);
ok = ok && (lv_obj_has_flag(control, LV_OBJ_FLAG_HIDDEN) != bar);
lv_area_t nav, grid;
lv_obj_get_coords(id(page_next), &nav);
lv_obj_get_coords(id(tile_scroll), &grid);
ok = ok && (!paginated || grid.y2 < nav.y1);
ok = ok && (!bar || grid.y2 < nav.y1);
if(runtime_tiles::enabled)ok = runtime_tiles::check_tile_geometry() && ok;
ESP_LOGI("ui_test", "page_check=%s count=%d heap=%u frames=%u grid_bottom=%d nav_top=%d", ok ? "PASS" : "FAIL", count, (unsigned) esp_get_free_heap_size(), (unsigned) id(rendered_frames), grid.y2, nav.y1);
id(tile_page) = 0;
@@ -3900,7 +3916,7 @@ script:
lv_obj_add_flag(tiles[i], LV_OBJ_FLAG_HIDDEN);
}
}
lv_label_set_text(id(page_number), id(tile_page) == 0 ? "1 / 2" : "2 / 2");
settings_screen::page_dots(id(page_number), id(tile_page), 2, true);
if (id(tile_page) == 0) {
lv_obj_add_state(id(page_prev), LV_STATE_DISABLED);
lv_obj_clear_state(id(page_next), LV_STATE_DISABLED);
@@ -3949,6 +3965,8 @@ script:
}
const auto shown_rotation = id(setting_rotation)->active_index();
if (!id(setting_rotation)->has_state() || !shown_rotation.has_value() || *shown_rotation != (size_t) (settings_screen::rotation / 90)) id(setting_rotation)->update();
// The Page buttons setting moves the cards into the bar's room or back (firmware 0.2.69+).
if (runtime_tiles::enabled) runtime_tiles::page_buttons_changed();
settings_screen::refresh();
if (!id(ui_refresh).is_running()) id(ui_refresh).execute();
+11
View File
@@ -1,3 +1,14 @@
## 0.2.82 (firmware 0.2.69)
The page buttons can make way, and the tiles fill the screen.
- **Page buttons: a setting to take the bar under the tiles away.** Turned off, a screen with more pages has no buttons under its tiles, and the three rows of cards share their room: every card grows by 14 px on a Guition (108 to 122) and 9 px on a CYD (52 to 61). Pages then change by swiping or with *Go to page* tiles. It sits with the other screen settings: on the screen's own settings page (Screen), in the editor's *Screen settings*, and as `switch.<screen>_page_buttons` in Home Assistant. On by default. (GitHub issue #9)
- **The editor says which pages you can't reach.** With the page buttons and swiping both off, only *Go to page* tiles change the page. The editor then says where your *Go to page* tiles lead and which pages that leaves out ("You have 3 Go to page tiles, to pages 1, 2 and 3, but none to page 4, so you can't reach it"), and it names a page with no way back to page 1. The warning shows above the pages and under the setting.
- **New page buttons: a chevron in each corner and a dot per page.** The words *Previous* and *Next* and the "2 / 4" are gone. Each half of the bar is still one big key and lights up under your finger. On the first and last page the chevron that leads nowhere is dimmed. The settings page on the screen uses the same buttons.
- **A screen with one page uses the whole screen.** Its bar was already hidden, but the tiles kept their size. Now they grow into that room the same way.
- A card keeps its layout as it grows: the name, state and circle stay in its middle, and the analog clock on a single tile keeps its dial size so the date beside it still fits. The small slider of 0.2.81 still claims the whole card when the card is taller.
- Needs firmware 0.2.69: press **Update** on the screen. Includes everything from 0.2.81. CYD firmware: 1,655,136 bytes, 90.2 % of the update slot (1,632 bytes more than 0.2.81).
## 0.2.81 (firmware 0.2.68)
A card's slider takes the whole card, and a quick drag on it never turns a page.
+2 -1
View File
@@ -16,7 +16,7 @@ from core import (ALERT_CAMERA_FIELD, ALERT_ENDINGS, ALERT_EVENT, ALERT_FALLBACK
ALERT_MIN_FIRMWARE, ALERT_SUGGESTED_ICONS, AUTO_STANDBY_MIN_FIRMWARE, BROADCAST_DISMISS, BROADCAST_SHOW,
CONTROLS, DISPLAYS, FULL_PAGE_MIN_FIRMWARE, MAX_PAGES, MAX_SLOTS, MAX_TILES, PAGE_TILE_REPEAT_MIN_FIRMWARE,
SETTINGS_PAGE_MIN_FIRMWARE, SLOTS_PER_PAGE, TILE_BACKGROUNDS, TILE_EVENTS, TILE_RESULT_EVENT, WAKE_SLEEP_MIN_FIRMWARE,
SETTING_ENTITIES_MIN_FIRMWARE, DARK_MODE_MIN_FIRMWARE)
SETTING_ENTITIES_MIN_FIRMWARE, DARK_MODE_MIN_FIRMWARE, PAGE_BUTTONS_MIN_FIRMWARE)
# Full-page tiles, navigation tiles and forty-eight tiles per screen.
FULL_PAGE_VERSION = '.'.join(str(part) for part in FULL_PAGE_MIN_FIRMWARE)
# The same navigation tile on several pages (app 0.2.78).
@@ -286,6 +286,7 @@ Every screen has these entities in Home Assistant, on its ESPHome device. `<scre
| `switch.<screen>_back_to_page_1` | On: after `number.<screen>_back_to_page_1_after` seconds without a touch (30 to 3600) the screen closes a card and goes back to page 1. Pressing Wake, an alert or Auto standby don't count as a touch. |
| `switch.<screen>_back_to_page_1_on_standby` | On: going into standby also goes back to page 1. |
| `switch.<screen>_swipe_between_pages` | On: swipe between pages. |
| `switch.<screen>_page_buttons` | On: the Previous and Next bar under the tiles on a screen with more than one page. Off: no bar, the tiles take its room, and only swiping or Go to page tiles change the page. Firmware {PAGE_BUTTONS_MIN_FIRMWARE} or newer. |
| `select.<screen>_rotation` | Guition only: `0°`, `90°`, `180°` or `270°`. |
Wake and Sleep are buttons: press them with the `button.press` action. They save nothing, so an automation may press them as often as it likes, on every motion too. To reach several screens at once, list their buttons under `entity_id`. Don't target an area or a device with `button.press`: that presses every other button there as well, the Wake and Sleep of the same screen included.
+8 -2
View File
@@ -37,7 +37,7 @@ WEEKDAYS = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su']
REPO = 'https://github.com/MaxGramser/homeassistant_espscreen'
REFS = {'cyd': 'main', 'guition': 'main'}
# Firmware shipped with this app release; screens below it get an update offer.
FIRMWARE_VERSION = '0.2.68'
FIRMWARE_VERSION = '0.2.69'
# The Auto standby switch a screen offers Home Assistant automations.
AUTO_STANDBY_MIN_FIRMWARE = '0.2.41'
# The settings page the screen opens itself, and the screen.settings tile that opens it.
@@ -48,6 +48,8 @@ WAKE_SLEEP_MIN_FIRMWARE = '0.2.45'
SETTING_ENTITIES_MIN_FIRMWARE = '0.2.49'
# Dark mode: the screen's dark look, a setting and entity of its own (components/smart_display/theme.h).
DARK_MODE_MIN_FIRMWARE = '0.2.54'
# Page buttons: the Previous and Next bar under the tiles, a setting and entity of its own; off, the tiles take its room.
PAGE_BUTTONS_MIN_FIRMWARE = '0.2.69'
ATTRS = frozenset('brightness percentage current_position current_tilt_position current_temperature temperature current_humidity min_temp max_temp target_temp_step supported_color_modes hvac_modes hvac_action hs_color color_temp_kelvin min_color_temp_kelvin max_color_temp_kelvin fan_speed_list unit_of_measurement battery_level fan_speed volume_level is_volume_muted media_title options min max step temperature_unit supported_features device_class next_rising next_setting finishes_at duration remaining humidity wind_speed wind_speed_unit apparent_temperature fan_modes swing_modes fan_mode swing_mode'.split())
# Attributes whose boolean value the screen needs; every other bool stays behind.
BOOL_ATTRS = frozenset(['is_volume_muted'])
@@ -276,12 +278,15 @@ SETTING_RULES = {
# The dark look for a screen beside a bed (firmware 0.2.54+). Only a screen that owns its settings has it: it
# never travels in the layout message.
'dark_mode': (False, None, None),
# The Previous and Next bar under the tiles (firmware 0.2.69+); off, the tiles grow into its room. Only a screen
# that owns its settings has it, like dark_mode.
'page_buttons': (True, None, None),
}
# Firmware before 0.2.44 accepts a `settings` object with exactly its own eleven keys and refuses any
# other size, so everything added after it travels as its own key in the layout message. Old firmware
# ignores a key it does not know; a new screen with an old add-on keeps what it saved itself.
# docs/SETTINGS.md walks through adding one.
SETTINGS_BESIDE_BLOCK = ('swipe_pages', 'rotation', 'auto_home', 'auto_home_seconds', 'dark_mode')
SETTINGS_BESIDE_BLOCK = ('swipe_pages', 'rotation', 'auto_home', 'auto_home_seconds', 'dark_mode', 'page_buttons')
# ----- The screen owns its settings (firmware 0.2.49+) -----
# A screen offers every setting as an entity of its own device, and the settings page on the screen, Home
@@ -306,6 +311,7 @@ SETTING_ENTITIES = {
'swipe_pages': ('switch', 'Swipe between pages'),
'rotation': ('select', 'Rotation'),
'dark_mode': ('switch', 'Dark mode'),
'page_buttons': ('switch', 'Page buttons'),
}
# Entities firmware 0.2.49 added; one of them on a device means the screen owns its settings. The first five
# existed before, so they cannot tell.
+3 -2
View File
@@ -699,8 +699,9 @@ class Manager:
values = validate_settings({})
if (self.firmware_version(inbox, screen) or (0, 0, 0)) < (0, 2, 44):
keys = [key for key in keys if key not in ('auto_home', 'auto_home_seconds')]
# Dark mode came after the screens took over their settings: firmware that gets them with the layout lacks it.
keys = [key for key in keys if key != 'dark_mode']
# Dark mode and the page buttons came after the screens took over their settings: firmware that gets them
# with the layout lacks them.
keys = [key for key in keys if key not in ('dark_mode', 'page_buttons')]
return {'owner': 'layout', 'values': values, 'keys': keys, 'unavailable': []}
# Only the settings this screen has an entity for: one added in later firmware stays out of the panel.
keys = [key for key in keys if key in entities]
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -5,8 +5,8 @@
<meta name="viewport" content="width=device-width,initial-scale=1" />
<meta name="color-scheme" content="light dark" />
<title>ESP Screens</title>
<script type="module" crossorigin src="./assets/index-Cp1JAouO.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-DUJYFAbE.css">
<script type="module" crossorigin src="./assets/index-Cp13kZoB.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-CKR1N3aP.css">
</head>
<body>
<div id="app"></div>
+1 -1
View File
@@ -1,5 +1,5 @@
name: ESP Screen Manager
version: "0.2.81"
version: "0.2.82"
slug: esp_screen_manager
description: Choose tiles for your CYD and Guition touchscreen, without reflashing.
url: https://github.com/MaxGramser/homeassistant_espscreen
+6 -1
View File
@@ -45,7 +45,12 @@ class GuitionTests(unittest.TestCase):
self.assertFalse(x<xx+ww and x+w>xx and y<yy+hh and y+h>yy)
rectangles.append((x,y,w,h))
self.assertEqual(len(rectangles),6)
self.assertIn('height: 44',SOURCE.split('id: page_next',1)[1][:200])
# The page keys are the two halves of the band under the tiles (firmware 0.2.69+).
band=480-v('SCROLL_Y')-v('SCROLL_H')
self.assertEqual(band,60)
for key in ('page_prev','page_next'):
block=SOURCE.split(f'id: {key}',1)[1][:200]
self.assertIn('width: 240\n',block);self.assertIn(f'height: {band}\n',block)
def test_all_tiles_clip_long_titles(self):
for i in range(1,11):
+24
View File
@@ -37,6 +37,30 @@ class LayoutTests(unittest.TestCase):
self.assertLess(grid, nav)
self.assertLess(nav, modal)
def test_page_keys_are_the_halves_of_the_band_under_the_tiles(self):
"""Firmware 0.2.69+: a chevron in each half of the band, the dots between them take no touches."""
for name in ('home-like-2432s028.yaml', 'guition-4848s040.yaml'):
source = (ROOT / name).read_text()
values = dict(re.findall(r'^ (\w+): "([^"]*)"', source, re.M))
band = int(values['DISPLAY_H']) - int(values['SCROLL_Y']) - int(values['SCROLL_H'])
half = int(values['DISPLAY_W']) // 2
for key, glyph in (('page_prev', 'F0141'), ('page_next', 'F0142')):
block = source.split(f' id: {key}\n', 1)[1].split('\n - ', 1)[0]
self.assertIn(f'width: {half}\n', block, f'{name} {key}')
self.assertIn(f'height: {band}\n', block, f'{name} {key}')
self.assertIn('styles: paint_page_pressed', block, f'{name} {key}: the half lights up under a finger')
self.assertIn(f'\\U000{glyph}', block, f'{name} {key}')
self.assertIn('text_font: materialdesign_icons_mini', block, f'{name} {key}')
self.assertNotIn('Previous', block)
self.assertNotIn('Next >', block)
number = source.split(' id: page_number\n', 1)[1].split('\n - ', 1)[0]
self.assertIn('clickable: false', number, name)
self.assertIn(f'height: {band}\n', number, name)
self.assertIn('settings_screen::page_dots(id(page_number)', source, name)
runtime = (ROOT / 'components/smart_display/runtime_tiles.h').read_text()
self.assertIn('settings_screen::page_dots(nav_number,page,pages,', runtime)
self.assertIn('set_hidden(control,!bar)', runtime)
def test_pagination_is_conditional_and_excess_tiles_are_hidden(self):
block = SOURCE.split(' - id: show_tile_page\n', 1)[1].split(' - id: wake_display', 1)[0]
self.assertIn('const bool paginated = count > 6;', block)
+2 -2
View File
@@ -48,12 +48,12 @@ ENTITY_IDS = {
'clock_24h': 'switch.office_1_24_hour_clock', 'auto_home': 'switch.office_1_back_to_page_1',
'auto_home_seconds': 'number.office_1_back_to_page_1_after', 'home_on_standby': 'switch.office_1_back_to_page_1_on_standby',
'swipe_pages': 'switch.office_1_swipe_between_pages', 'rotation': 'select.office_1_rotation',
'dark_mode': 'switch.office_1_dark_mode',
'dark_mode': 'switch.office_1_dark_mode', 'page_buttons': 'switch.office_1_page_buttons',
}
STATES = {'brightness': '80.0', 'standby_enabled': 'on', 'standby_seconds': '600.0', 'standby_brightness': '20.0',
'night_enabled': 'on', 'night_start': '22:30:00', 'night_end': '07:00:00', 'night_brightness': '5.0',
'clock_24h': 'on', 'auto_home': 'on', 'auto_home_seconds': '120.0', 'home_on_standby': 'off',
'swipe_pages': 'on', 'rotation': '90°', 'dark_mode': 'off'}
'swipe_pages': 'on', 'rotation': '90°', 'dark_mode': 'off', 'page_buttons': 'on'}
def top_block(text, key):
+13 -2
View File
@@ -73,6 +73,17 @@ int main() {
assert(dark_mode == 0 && value_text(dark) == "Off");
const Page &screen = pages[3];
// Page buttons (firmware 0.2.69+) follows Swipe between pages: on by default, off takes the bar away.
{
const Row &buttons = row_named(screen, "Page buttons");
assert(buttons.kind == Kind::toggle && &buttons == &row_named(screen, "Swipe between pages") + 1);
assert(page_buttons == 1 && value_text(buttons) == "On");
buttons.write(0);
assert(page_buttons == 0 && value_text(buttons) == "Off");
assert(set("page_buttons", 0) == SetResult::same);
buttons.write(1);
assert(page_buttons == 1);
}
const Row &clock = row_named(screen, "Clock");
assert(clock.option_count == 2 && value_text(clock) == "24 hour");
clock.write(0);
@@ -151,7 +162,7 @@ int main() {
{"night_start", -5, 0}, {"night_end", 2000, 1439}, {"night_brightness", 7, 7}, {"clock_24h", 0, 0},
{"home_on_standby", 1, 1}, {"swipe_pages", 2, 1}, {"rotation", 100, 90}, {"rotation", 400, 270},
{"auto_home", 0, 0}, {"auto_home_seconds", 5, 30}, {"auto_home_seconds", 99999, 3600},
{"dark_mode", 7, 1},
{"dark_mode", 7, 1}, {"page_buttons", 0, 0},
};
for (const auto &c : cases) {
result = set(c.key, c.value);
@@ -160,7 +171,7 @@ int main() {
}
assert(screen_settings::current.standby_seconds == 86400 && screen_settings::current.brightness == 100);
assert(screen_settings::current.night_start == 0 && screen_settings::current.night_end == 1439);
assert(swipe_pages == 1 && rotation == 270 && auto_home == 0 && auto_home_seconds == 3600 && dark_mode == 1);
assert(swipe_pages == 1 && rotation == 270 && auto_home == 0 && auto_home_seconds == 3600 && dark_mode == 1 && page_buttons == 0);
assert(screen_settings::current.valid());
// Dark mode again is the same look: nothing stored, applied or reported; off is a change.
{
+4 -1
View File
@@ -2,7 +2,7 @@
// The pages side by side, like swiping on the screen, and the library on the right.
import { computed } from "vue";
import { entriesOf, hasGaps, MAX_PAGES, pageCount } from "../model/layout";
import { addPage, closeInspector, currentScreen, pagesShown, state, supports, tileLimit } from "../store";
import { addPage, closeInspector, currentScreen, pageReachWarning, pagesShown, state, supports, tileLimit } from "../store";
import DevicePage from "./DevicePage.vue";
import Library from "./Library.vue";
@@ -14,6 +14,8 @@ const canAdd = computed(() => pages.value < MAX_PAGES);
const positionsHint = computed(() => hasGaps(layout.value.tiles) && !supports(0, 2, 26)
? `Empty slots and fixed positions work from firmware 0.2.26. This screen (firmware ${currentScreen.value?.firmware || "unknown"}) shifts the tiles up to the first free slot until that update.`
: "");
// Page buttons and swiping off: a page no Go to page tile reaches, or one without a way back.
const reachHint = computed(() => pageReachWarning(entries.value, pages.value));
function onCanvasClick(e: MouseEvent) {
// A click beside the pages closes the drawer; the cards and the bar handle their own clicks.
if ((e.target as HTMLElement).closest(".device, .page-label, .canvas-head")) return;
@@ -28,6 +30,7 @@ function onCanvasClick(e: MouseEvent) {
<span v-if="!layout.tiles.length" id="no-tiles">Add your first light, scene, or device from the library.</span>
<span v-else>Tap the top bar or a tile to change it. Drag to move; drop on the next page for a new one.</span>
<span v-if="positionsHint" id="positions-hint" class="warn">{{ positionsHint }}</span>
<span v-if="reachHint" id="page-reach-hint" class="warn">{{ reachHint }}</span>
</div>
<div class="pages" id="layout-preview" aria-label="Screen layout">
<DevicePage v-for="page in shown" :key="page" :page="page - 1" :entries="entries" :pages="pages" :moving="state.drag.moving" />
+4 -1
View File
@@ -3,12 +3,14 @@
// once, like on the screen; no Save needed.
import { computed } from "vue";
import { glyph } from "../model/topbar";
import { currentScreen, SETTING_GROUPS, setSetting, settingText, settingValues, settingsView, state, steppedSetting, type SettingRow } from "../store";
import { currentScreen, pageReachWarning, SETTING_GROUPS, setSetting, settingText, settingValues, settingsView, state, steppedSetting, type SettingRow } from "../store";
const view = computed(() => settingsView());
const values = computed(() => settingValues());
const offline = computed(() => view.value?.owner === "screen" && !currentScreen.value?.online);
const groups = computed(() => SETTING_GROUPS.map((group) => ({ ...group, rows: (group.rows as readonly SettingRow[]).filter((row) => view.value?.keys.includes(row.key)) })).filter((g) => g.rows.length));
// Page buttons and swiping off: the pages that only Go to page tiles could reach, and don't.
const reachWarning = computed(() => pageReachWarning());
const unavailable = (row: SettingRow) => offline.value || Boolean(view.value?.unavailable.includes(row.key));
const needs = (row: SettingRow) => (row.needs ? Boolean(values.value[row.needs]) : true);
const status = computed(() => offline.value
@@ -78,6 +80,7 @@ function click(e: MouseEvent, row: SettingRow, direction: number) {
</div>
</div>
</div>
<p v-if="reachWarning && group.rows.some((row) => row.key === 'page_buttons')" class="hint warn" id="settings-page-reach">{{ reachWarning }}</p>
</section>
</div>
</div>
+28
View File
@@ -105,6 +105,34 @@ export function pageCount(entries: Entry[], wanted = 1) {
const last = Math.max(0, ...entries.map(({ tile, slot }) => slot + spanOf(sizeOf(tile))));
return Math.min(MAX_PAGES, Math.max(1, Math.ceil(last / SLOTS_PER_PAGE), wanted || 1));
}
// With the page buttons and swiping both off (firmware 0.2.69+) only Go to page tiles change the page. Pages count
// from 1: `tiles` is how many Go to page tiles lead to a page the screen has, `targets` the pages they lead to,
// `unreachable` the pages no chain of them reaches from page 1, `noWayBack` the reachable ones they never lead back from.
export function strandedPages(entries: Entry[], pages: number) {
const links = Array.from({ length: pages }, () => new Set<number>());
let tiles = 0;
for (const { tile, slot } of entries) {
const to = pageTarget(tile.entity) - 1, from = pageOf(slot);
if (to < 0 || to >= pages || from >= pages) continue;
tiles++;
links[from].add(to);
}
const reach = (next: (page: number) => number[]) => {
const seen = new Set([0]), queue = [0];
while (queue.length) for (const page of next(queue.shift()!)) if (!seen.has(page)) { seen.add(page); queue.push(page); }
return seen;
};
const forward = reach((page) => [...links[page]]);
const back = reach((page) => links.flatMap((to, from) => (to.has(page) ? [from] : [])));
const all = Array.from({ length: pages }, (_, page) => page);
const numbers = (list: number[]) => list.map((page) => page + 1);
return {
tiles,
targets: numbers(all.filter((page) => links.some((to) => to.has(page)))),
unreachable: numbers(all.filter((page) => !forward.has(page))),
noWayBack: numbers(all.filter((page) => forward.has(page) && !back.has(page))),
};
}
// New tiles start with the card that shows the entity best.
export function defaultOptions(id: string): Partial<Tile> {
const domain = id.split(".")[0];
+20 -1
View File
@@ -4,7 +4,7 @@ import { computed, reactive, toRaw } from "vue";
import { api, getJson, send, setCsrf } from "./api";
import {
arrange, cellsOf, entriesOf, firstFree, fits, isFull, isWide, MAX_PAGES, nearestFree, newTile, normalize, occupied, pageCount, pageOf,
pageTarget, rowStart, sizeOf, SLOTS_PER_PAGE, supportsFirmware as supportsVersion, tileLimit as limitFor,
pageTarget, rowStart, sizeOf, SLOTS_PER_PAGE, strandedPages, supportsFirmware as supportsVersion, tileLimit as limitFor,
} from "./model/layout";
import { agoText, BAR_METRICS, clockText, dateText, itemKey, type ItemView, whenBarFontsLoad } from "./model/topbar";
import { versionAtLeast } from "./model/layout";
@@ -636,10 +636,29 @@ export const SETTING_GROUPS = [
{ key: "auto_home_seconds", label: "After", kind: "duration", min: 30, max: 3600, needs: "auto_home" },
{ key: "home_on_standby", label: "Also on standby", kind: "toggle" },
{ key: "swipe_pages", label: "Swipe between pages", kind: "toggle" },
{ key: "page_buttons", label: "Page buttons", kind: "toggle" },
{ key: "rotation", label: "Rotation", kind: "choice", options: [[0, "0°"], [90, "90°"], [180, "180°"], [270, "270°"]] },
] },
] as const;
export type SettingRow = (typeof SETTING_GROUPS)[number]["rows"][number] & { min?: number; max?: number; step?: number; unit?: string; needs?: string; cap?: string; options?: readonly (readonly [unknown, string])[] };
// Page buttons and swiping both off (firmware 0.2.69+): only Go to page tiles change the page, so the editor says which
// pages the Go to page tiles lead to and which page that leaves out, or has no way back to page 1. Empty when either
// is on or still unknown, or every page can be reached and left.
export function pageReachWarning(entries = liveEntries(), pages = state.layout ? pageCount(entries, state.layout.pages) : 1) {
const values = settingValues();
if (pages < 2 || values.page_buttons !== false || values.swipe_pages !== false) return "";
const { tiles, targets, unreachable, noWayBack } = strandedPages(entries, pages);
if (!unreachable.length && !noWayBack.length) return "";
const named = (list: number[]) => (list.length === 1 ? `page ${list[0]}` : `pages ${list.slice(0, -1).join(", ")} and ${list[list.length - 1]}`);
// A page that tiles lead to but only from pages that can't be reached themselves.
const missing = unreachable.filter((page) => !targets.includes(page)), cutOff = unreachable.filter((page) => targets.includes(page));
let text = "Page buttons and swiping are off, so only Go to page tiles change the page. ";
text += tiles ? `You have ${tiles} Go to page ${tiles === 1 ? "tile" : "tiles"}, to ${named(targets)}` : "You have no Go to page tiles";
text += missing.length ? `${tiles ? ", but none" : ""} to ${named(missing)}, so you can't reach ${missing.length === 1 ? "it" : "them"}.` : ".";
if (cutOff.length) text += ` The tiles to ${named(cutOff)} are only on pages you can't reach.`;
if (noWayBack.length) text += ` From ${named(noWayBack)} no tile leads back to page 1.`;
return text;
}
// Changes made here that the screen has not reported back yet win over what Home Assistant still shows for a
// few seconds, so a value never flicks back while it travels.
const SETTING_EDIT_MS = 4000;
+1
View File
@@ -300,6 +300,7 @@ a:hover { text-decoration: underline; }
.set-card { background: var(--surface); border: 1px solid var(--line); border-radius: 12px; padding: 6px 14px 8px; }
.set-card h4 { display: flex; align-items: center; gap: 8px; padding: 10px 0 8px; }
.set-card h4 .mdi { color: var(--muted); font-size: 16px; }
.set-card > .hint { margin: 6px 0 4px; }
.srow { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 8px 0; border-top: 1px solid var(--line); }
.srow > .s-label { color: var(--ink-2); }
.srow.inactive .s-label, .srow.inactive .s-control { opacity: 0.45; }
+18 -1
View File
@@ -2,7 +2,8 @@
import { describe, expect, it } from "vitest";
import {
arrange, cellsOf, controlsLabel, defaultOptions, effectiveControls, firstFree, fits, hasGaps, MAX_PAGES, MAX_SLOTS, nearestFree,
newTile, normalize, occupied, packSlots, pageCount, pageStart, pageTarget, rowStart, sizeOf, SLOTS_PER_PAGE, spanOf, tileLimit, versionAtLeast,
newTile, normalize, occupied, packSlots, pageCount, pageStart, pageTarget, rowStart, sizeOf, SLOTS_PER_PAGE, spanOf, strandedPages, tileLimit,
versionAtLeast,
} from "../src/model/layout";
import type { Inventory, Layout, Tile } from "../src/types";
@@ -157,3 +158,19 @@ describe("full-page tiles and navigation tiles (firmware 0.2.62+)", () => {
expect(effectiveControls(tile("light.a", 0, { size: "full", controls: "toggle" }), inventory)).toBe("toggle");
});
});
describe("pages that only Go to page tiles reach (firmware 0.2.69)", () => {
const nav = (page: number, slot: number) => tile(`screen.page_${page}`, slot);
it("finds a page no tile leads to, and one without a way back", () => {
// Page 1 goes to 2 and 3; page 2 goes back to 1; page 3 leads nowhere; nothing goes to page 4.
const layout = entries([nav(2, 0), nav(3, 1), tile("light.a", 2), nav(1, 6), tile("light.b", 12), tile("light.c", 18)]);
expect(strandedPages(layout, 4)).toEqual({ tiles: 3, targets: [1, 2, 3], unreachable: [4], noWayBack: [3] });
});
it("follows a chain of pages, and ignores a tile to a page the screen lacks or to its own page", () => {
const chain = entries([nav(2, 0), nav(3, 6), nav(1, 12), nav(8, 13), nav(3, 14)]);
expect(strandedPages(chain, 3)).toEqual({ tiles: 4, targets: [1, 2, 3], unreachable: [], noWayBack: [] });
// A tile to page 3 that sits on a page nobody reaches leaves page 3 out too.
expect(strandedPages(entries([nav(3, 6)]), 3)).toEqual({ tiles: 1, targets: [3], unreachable: [2, 3], noWayBack: [] });
expect(strandedPages(entries([tile("light.a", 0)]), 1)).toEqual({ tiles: 0, targets: [], unreachable: [], noWayBack: [] });
});
});
+32 -1
View File
@@ -1,7 +1,7 @@
// The store: selecting a screen, editing its layout, what's new, progress, copy and import.
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
addTile, canAlert, copyLayoutFrom, fullPage, importLayout, layoutJson, liveOf, moveTileToPage, pageTilesRepeat, PHASES, removeTile,
addTile, canAlert, copyLayoutFrom, fullPage, importLayout, layoutJson, liveOf, moveTileToPage, pageReachWarning, pageTilesRepeat, PHASES, removeTile,
retargetPageTile, save, select, setTileOption, state, supports, tileLimit, topbarItems, topbarView, updateProgress, whatsNew,
} from "../src/store";
import type { Inventory, Screen } from "../src/types";
@@ -408,3 +408,34 @@ describe("several tiles that go to the same page (firmware 0.2.65)", () => {
expect(state.dirty).toBe(true);
});
});
describe("page buttons and swiping both off (firmware 0.2.69)", () => {
const settings = (values: Record<string, unknown>) =>
Object.assign(state.inventory.screens[0], { settings: { owner: "screen", values, keys: Object.keys(values), unavailable: [] } });
const tiles = (list: [string, number][]) => { state.layout!.tiles = list.map(([entity, slot]) => ({ entity, name: "", slot, options: {} })); state.layout!.pages = 1; };
it("says which pages the Go to page tiles lead to, and which one they leave out", () => {
settings({ page_buttons: false, swipe_pages: false });
select("living");
tiles([["screen.page_2", 0], ["screen.page_3", 1], ["screen.page_1", 6], ["screen.page_1", 12], ["light.a", 18]]);
expect(pageReachWarning()).toBe("Page buttons and swiping are off, so only Go to page tiles change the page. "
+ "You have 4 Go to page tiles, to pages 1, 2 and 3, but none to page 4, so you can't reach it.");
tiles([["light.a", 0], ["light.b", 6], ["switch.c", 12]]);
expect(pageReachWarning()).toBe("Page buttons and swiping are off, so only Go to page tiles change the page. "
+ "You have no Go to page tiles to pages 2 and 3, so you can't reach them.");
tiles([["screen.page_2", 0], ["light.a", 6]]);
expect(pageReachWarning()).toMatch(/You have 1 Go to page tile, to page 2\. From page 2 no tile leads back to page 1\.$/);
});
it("stays quiet while either way of paging is on, the values are unknown, or every page can be reached and left", () => {
select("living");
tiles([["light.a", 0], ["light.b", 6]]);
for (const values of [{ page_buttons: true, swipe_pages: false }, { page_buttons: false, swipe_pages: true }, { page_buttons: null, swipe_pages: false }, {}]) {
settings(values);
expect(pageReachWarning()).toBe("");
}
settings({ page_buttons: false, swipe_pages: false });
tiles([["screen.page_2", 0], ["screen.page_1", 6]]);
expect(pageReachWarning()).toBe("");
tiles([["light.a", 0]]);
expect(pageReachWarning()).toBe("");
});
});