From 948087077d22fd2ea7a4bc0a4f29caca752aed25 Mon Sep 17 00:00:00 2001 From: MangaValk Date: Sun, 18 Oct 2026 16:27:56 +0200 Subject: [PATCH] Progress bars can show the percentage on top The percentage (rounded down, in the element's font) is centred inside the bar and its dots invert the bar, so it reads on the filled and the empty part. It uses the bar's unused format and font fields, so the page format stays the same. The Bad page shows it. Co-Authored-By: Claude Opus 5.5 --- Pictures/pages/bad.png | Bin 3287 -> 3539 bytes docs/web-ui.md | 2 +- examples/home-pages.json | 24 ++++++-- src/esp32/config.h | 2 +- src/esp32/logic/page.cpp | 16 +++++ src/esp32/logic/page.h | 9 ++- src/esp32/logic/page_render.cpp | 34 +++++++++++ src/esp32/web/web_page.h | 4 +- test/test_esp32/test_main.cpp | 105 ++++++++++++++++++++++++++++++++ 9 files changed, 187 insertions(+), 9 deletions(-) diff --git a/Pictures/pages/bad.png b/Pictures/pages/bad.png index 9466cf9d82ff0a801e2aa3b277077d82518a34f7..894f26b16de9c0843362a1d8c67df33dca148029 100644 GIT binary patch literal 3539 zcmb7HX;_n27RFi`CBw8l0#(*pg(d>ySQEsESVCk81V{i8%A%!65=6@`2&EN~iX~C} z#2Q6SAOS=Y7b2)E;~J%cNgyl{MMOnpu_{YL1m@f@kL};)NAi95o_p@O=e+NGZVm+c zdl?&97#SEC82flrnFa>)Bj8!J@FVzEsyB;aobN+*58kaAdF0N0M*VbwB22c`{PPaS zE+vJ+k7}9cGEe^8D&oh|A4Qy_j^gs?l_}p*U3x=T{aF6$_{9>*8P!s4U+&L;_j7l? zc2viE_=yN((g-7xPhRBP=1h&(1~lbN)lcu3JwNR*H&fwZ);Dr&G-tG_1Pn!3no`i? z&@%mC!WLebH1;a%Oz!W$_3qJKp$7*c1C{w%d>L{ovZdSVN0>M|9@$nF6ZPX`_gPwR zv^vu`#MEDPY@mGPONwBW3OPaG{{iy|=hO$zh zn1v2fP6f&+Q> z5@U|n;ug~yn=`?_7EbJ%vcv?%S@_M$`kHiJL`=t)>PCg3WSqp8Y# ze(d_!l|n^Vx|(1Fzhf5&D`<3EOPz$=CT}0RkY{h>T;5Ju9Mc;TCs-s?P<<^D2zYhT z9azBj@c`Gw12|b$z1RG2XvxyFma52gLnou-PJ31e6~=sKj6xSV<&y4N0U{#r-v5jYm{Y@%;95T_|d`-L)3>K(JEJ0m~od6A`5D65PD@#y!BFj z`7c@Nfrmz-_9ellWbj&HP51jckXGp4Cg+v~SV5+FD}&$_N4N4h?E1p&M#~SxJ}2yS#P%z>$_?VmN0yu z5(;euqn46Ft^7*=E^)ZZ^$#mSN8RLh!Sb^i_M*^)v%`(b>Cwn{{1k*2yb%H5G-(Wg zI;2tF8e1)b)a&*S#vr5ugfQSr&ss-fA~I(Aco3if*&edar2jvb zxRxsRst{8PbjUl6lPAtEjV!*pOL62k_vovm>SJ0=vd%EnQup|En6!mC3E(`kWxQ*Z zD4u&=4d#2^Pv3D`bCG&Lk0cuV%IZ2z*W?eprrx^!RQ^&1?0?diP)(Y1@n+Jp^J>Jy zWXStk8V&H%;LoVQm!%O`izozA_Hb0(RM*&QI77aaATsCIb%p^a7Qwr-F~)Ra_YBpP zDU0@RPVgyx-hV>rh^a{OGuvAqzmAcr>-n35HZ2pi=huF5PutKwaX?~4mf^=l4x;uN zaV2@6bA=oDk*`61cUSakWjz3uTa=whPc%6$O&0S}te`0B`tZ`N1EIJ?2r|Ejd6xD2 zo8k8>jVi6_S%WkJse$#y11%}rk8Zoi^$3%*Y_Hc$Ni>dLk!Nq76hxCbDy?p5)kXU` z5aM!CaB{yzj^y37x7t*-a-lxSQQu*)3GZVa)^zRO$tJA6;hO>|~yE*Lj zU0L7ad`YE;nLxf~!-OyU@ShWmS#PoqK&r6|XgJ7+PA z*j>DfCe_;7=1eb1;gpxEyF87^FlBNuf{|HX0Y#5;F^@oM_5=BG&K@2PTF>9Yq7Y|S1yo%wQ@(xE!m^{WNAYd-8rg-`!zmMNV1Zt{2TR{T zl94c5(_K&bpfT`(yP}^QVF@7(bCXsyB<#%f{6ADjk+1`1Q8{4JiFo1A2{eQhZ>}Sd zq-ppBG8IiDK}!hmM~`jtYrR8n?qAz!_oUjYXSQdEA}>Jej;7+xlU|R{Soa}?=9(d= zBdm)BjjkKB!%QCk;GA?0^$v&&Q3kC5$Yl?2VW2R$_U3$-&9H(187wA#SY5`6qQj=D zMzU$_Axbe?<@;Hlz2CK}HK^~X7O$lEIyiC#uyXJfj!xXB^vp*_RkdYGN!ZNZT7!ZIGf;4&`3NKt0h>8x~bbC!!{pGo%OGPQblMX}dx?||(DZ^^&8O;(NGUMniN@n7jw!^F8x zzMGgB8t-48mX*-L&V)^xW*(o(-`kMZn)E5$KMFhexK-rF%P;oYz=j$02%Uknq|L<> zDp)Gpez2G;t?r)62c?RNE6uei%p5ss-iEe*cuF(PX)U>KeL$sxc3&-2d=9mkbO6Ch zBfbf5lQ&Ar#k@ySKieR{s1&G}+W=j3(gF4aPd09#$ zCn~b9^$C|cXktxk*_+$35ofpzT0^X@=re**ygywv6?eUJ9P{M$s=c}H z;j@mr&mU90d~;0$+gL#f@T7+&im5nvoB^#={+a_~tG6Clh;#N7k3ZPEU4U^e=ZuzI z-;l3*_Cl?{%^U)50-ZJq24mXfNK>+RO#KQZH3vZf-3FxvVX4fspOX?}y`*@6-tKaZ zuET_b=Kz%VYlbXMhf}=a?ON^t3 zuVMCQ)g@IMeFHs_m0*?Yn zGrCx&5tT)#j55? za}x(7`0GTlFW=*V2`9SeDoOG}jYY{aQhSyRyG%SfjHf?V-ufJ_FFKT+JPf*4=eU?x zlKp`KHp2jzZglOMgNwOAxXMNMCj*?-|4!lpquISCDBJyhgXHiUd`-wB8L!M8Nm*1G z{+AQfigknCi7PwiG>Z2!aMO~py2O^}Cf1)Np#A`&vYd?7-T9bgS(&oqwGh|^0>*o( zNZ#k}TDm2nTM0%m%_L)dOUVg0NzTda$N(6STr9nJx0G94bU&nbKTERAAr8`0T#dt z8WzG7$e}*Ub(gY*6YZUDt^7O`wmO*lcR$Y>-v47{MZ$fn;BgWBSu^n2+Gx2B;PSt{lQ-*Zsk_3!w@&-p#)JeTu4-{<=r)BW~) z8W|uB^z`(MyuC;?J-v^<25ZHte}liB%CCfadaH}PN$&KM{bPUn9(_gn^pmGQA>GY3 zJaXtoY}_cju~To$)B1uG!{a8mu!?)Mw|h8M3-pHO;@f%zRDSR4Sf{aS?OQVGpZ;Eu znr5}(vn5;_1tU;S9!{PfFArtU&dm1gt`m>h)UgG+9a9_~@r3IuG`lt0_~HtoP6I`!E^dYHv+)PEximu`G*lJkVs!^3;gU0Da3wpp z)!-A=SJX-_snU zBxRkKTBYu_b!DAz$5WZ#>{Sg=BVHw~mnlG0a_dU8!)}rc=T~~9-HGu^d?vY&wRq!@ zNLJplb!U;`(H!?M_FwNtU-`T8UMPcd=1&jgElV{Ck;VO;B-hp1`W4jN>i7#aY8k7r zs*vgZuDy@pXjA^vI%NwN8Ghp0YKz$?k9!*WMMKIA;*OOR)3ny6s{K?i(==g@&qgY9 zrnfgynNJW3a~!ebw_F5fJoDLVyrEA;nq9DhvDY1x3@YqJR-)P?i?(&Y9Y+`)!4xJ* zqeI5-2My2RXC>#i8pz!JnX6}#t`a&-bIM%T_|Q#e`j#y=e_Lp&Tfv~ zDP>qQ7nx$_w)0Y0vef0beAp{vjwS}|S4ee!7$(t@G7bS&t#wSXR7-8L&I%#(X&ix)2ZWNGe9mdYOd*QVF zisFfF_w2dou({ak>C2)tyDg>9ersCcMsiFqmuOZ^Ha+V%XBXIO4}&cIKDi<1dP~qcA*?)K6))_#v_`FtnUvLb&qBP1PWEmSM05V5Y;9`RObsK654(aNbU?v)KYa*mD2Qu!s>rWx)(mKvAwIPtkeZR9n_k}~ zZL37!L6TkKpT6>?Y)jS&;$EY>8vCU+9T(%K zqSgkOK-T9fYRCEZI%>A^*;V8Xxf*ydY|t_L7^CDb+oerQijK%#dDHSMP_uiYrfs|K zl%uW=Kt$h*bhOclaN-1X44wqnWd7QNF~0lZva9Xq;?eO3=Z~VE$}9)iUycylGN7q^ zoQYwBW@b9;_=0rab5WLn4I&SQ98=r3%=YZzn8F}HG_Yx)Z;qWX$I2e8xS+~J<7E{< zxHWK^ft=*h+pD0@;6M-RO3Y!nyc0)dv`FsYQ@{yKj2|L6_)zR>XgDyGQKFyejEC}r zGE{a#JMt(TTl9BKG9Q2vEHj9gbA&n23LwlcRxFM)0P%BxB>mvgy}TSijx+U=Me}1J zCW;Y!l{qx8I{9V(@Z}uIoeo|doR6xLfLMHUG4c_7^jSlAqAeQz(1HiPt4!A{euIg+ z%)c4-HoBuk z?gF#YHpupkc=W^=^LD7Prq>H98*xG`m^s7LMKiloCJPr2m|1!uuEtW?~*Vj$<8Z_#Tc! zm>4r2!=VI~t(*H`loT`1RvbJ$X_w0jgc%+k(3G2|HSO@w7og#Lz%-3dvilcHou|gJ z_JKPpY&C;^lKC=bR9~P0)7Muy*PUeB&^Hv0@PGC@?cIx ziP&)&q$hAGfat;KncfMf*%qVl>snc;MHIpKh96G)&DqhKU}Z2WGVk`5{1K`WnuY50x- z)?fpzg8Tu2GRFA!Z*@>F*AME|j@-<&%jf$DmM(s0ecHvVLy&Qqd7`}fQCQiX$+cd z5k{byCu){c19`0m(6aGjyx=k7U5YXSDooGIxnssr_Dh8ZUcEDkhUhHWqJyAQHumwBB*sob=O8_yHxQCqU+@9m9zoZC`EE&`=Y}-}4=>;v zOquRqN{k|l0jNNQ{MU^gEMNgS=y>-rV=1q33eYLgR~*p{a>&mnog=GG{&2!N@&E#& zuy5k;ZUE| zHtzaBC_j-}Zn2pex=V-%|G)%*_7XSk=xWo=U0?&e9dsiXmK)-l;q3uK%k6B$r+^U) j*Yz0}{^@Vm9}C}I*_g{GA9)J?VbSv@? 0) ? 0 : percent >= 100 ? 100 : (int)(percent + 1e-9); + snprintf(out, size, "%d%%", n); +} + bool elementShowsSeconds(const PageElement &e, const LocalTime &t, bool stopwatch_running) { switch (e.type) diff --git a/src/esp32/logic/page.h b/src/esp32/logic/page.h index 0ca6fc1..e854e6a 100644 --- a/src/esp32/logic/page.h +++ b/src/esp32/logic/page.h @@ -98,6 +98,11 @@ const uint8_t STOPWATCH_H_MM = 1; // 0:04 (hours and minutes) const uint8_t STOPWATCH_H_MM_SS = 2; // 0:04:05 const uint8_t STOPWATCH_FORMAT_COUNT = 3; +// Progress bar formats +const uint8_t BAR_PLAIN = 0; // only the bar +const uint8_t BAR_PERCENT = 1; // with the percentage on top (inverting the bar below it) +const uint8_t BAR_FORMAT_COUNT = 2; + // How a Home Assistant value is shown enum class HaShow : uint8_t { @@ -152,7 +157,7 @@ struct PageElement Align align = Align::Left; Overlay overlay = Overlay::OnTop; char font[PAGE_NAME_SIZE] = "Normal"; - uint8_t format = 0; // CLOCK_*, DATE_*, COUNTDOWN_*, STOPWATCH_*, HaValue: DURATION_* or TIMESTAMP_* + uint8_t format = 0; // CLOCK_*, DATE_*, COUNTDOWN_*, STOPWATCH_*, BAR_*, HaValue: DURATION_* or TIMESTAMP_* Language language = Language::Dutch; int8_t decimals = DECIMALS_AS_IS; // Text: the text; HaValue, Countdown, Stopwatch: shown before the value; Image: the @@ -222,6 +227,8 @@ void elementText(const PageElement &element, const LocalTime &time, const Elemen // Bar: how many dots of fill_width are filled for this Home Assistant value (value x // factor / bar_max, 0 to fill_width); 0 when the value is unknown or not a number int barFill(const PageElement &element, const char *ha_value, int fill_width); +// Bar with BAR_PERCENT: the text on it, e.g. "42%" (rounded down, 0-100), "--" when unknown +void barPercent(const PageElement &element, const char *ha_value, char *out, size_t out_size); // Whether the element's text shows seconds now (it then changes every second) bool elementShowsSeconds(const PageElement &element, const LocalTime &time, bool stopwatch_running); diff --git a/src/esp32/logic/page_render.cpp b/src/esp32/logic/page_render.cpp index 0f518d4..3825f66 100644 --- a/src/esp32/logic/page_render.cpp +++ b/src/esp32/logic/page_render.cpp @@ -90,6 +90,38 @@ static void drawImage(const PageElement &e, PageResources &resources, uint8_t *b } } +// Bar with BAR_PERCENT: the percentage in the middle of the area inside the frame, its +// dots flip what is below them (so it shows on the filled and the empty part). Centred on +// the dots the text really has (fonts have empty rows); left out when it doesn't fit. +static void drawBarPercent(const PageElement &e, PageResources &resources, uint8_t *bitmap, int x, int y, int w, + int h) +{ + char text[8]; + barPercent(e, resources.haValue(e), text, sizeof(text)); + Font font = usableFont(resources, e.font); + uint8_t dots[BITMAP_BYTES]; + memset(dots, 0, BITMAP_BYTES); + drawText(dots, font, 0, 0, text, DrawMode::Set); + int left = BITMAP_WIDTH, right = -1, top = BITMAP_HEIGHT, bottom = -1; + for (int yy = 0; yy < BITMAP_HEIGHT; yy++) + for (int xx = 0; xx < BITMAP_WIDTH; xx++) + if (bitmapPixel(dots, xx, yy)) + { + left = xx < left ? xx : left; + right = xx > right ? xx : right; + top = yy < top ? yy : top; + bottom = yy > bottom ? yy : bottom; + } + int tw = right - left + 1, th = bottom - top + 1; + if (right < 0 || tw > w || th > h) + return; + int ox = x + (w - tw) / 2 - left, oy = y + (h - th) / 2 - top; + for (int yy = top; yy <= bottom; yy++) + for (int xx = left; xx <= right; xx++) + if (bitmapPixel(dots, xx, yy)) + setBitmapPixel(bitmap, ox + xx, oy + yy, !bitmapPixel(bitmap, ox + xx, oy + yy)); +} + // Bar: a frame (optional) filled from the left by the Home Assistant value static void drawBar(const PageElement &e, PageResources &resources, uint8_t *bitmap, ElementBox &box) { @@ -120,6 +152,8 @@ static void drawBar(const PageElement &e, PageResources &resources, uint8_t *bit int bx = left + x, by = e.y + y; setBitmapPixel(bitmap, bx, by, e.overlay == Overlay::Invert ? !bitmapPixel(bitmap, bx, by) : true); } + if (e.format == BAR_PERCENT) + drawBarPercent(e, resources, bitmap, left + inner, e.y + inner, w - 2 * inner, h - 2 * inner); } // A text element; returns the ms until it scrolls on (0: it doesn't scroll) diff --git a/src/esp32/web/web_page.h b/src/esp32/web/web_page.h index 8bb486f..e5271dd 100644 --- a/src/esp32/web/web_page.h +++ b/src/esp32/web/web_page.h @@ -429,6 +429,8 @@ function peElems(){const d=$('pe_elems');d.innerHTML='';d.append(mk('datalist',{ row(field('Full at',inpf(e,'bar_max',{style:'width:6em'})),field('Multiply by',inpf(e,'factor',{style:'width:6em'})), field('Width',inp(e,'bar_width','number',{style:'width:4em',min:1,max:84})),field('Height',inp(e,'bar_height','number',{style:'width:4em',min:1,max:19})), chk(e,'bar_frame','frame (from 3 x 3 dots)')); + const pc=mk('input',{type:'checkbox',checked:e.format==1});pc.onchange=()=>{e.format=pc.checked?1:0;if(pc.checked&&same(e.font,'Normal'))e.font='Small';peElems();pePreview()}; + row(mk('label',{},pc,' percentage on top (inverts the bar below it; only shown when it fits inside, Small needs a height of 7 with frame)')); rows.push(mk('p',{className:'hint',textContent:'Filled from the left: value x multiplier / full at (e.g. a percentage: full at 100; seconds of a duration: use an entity with the percentage). Unknown values show an empty bar.'}))} const fonts=fontList.map(f=>[f.name,f.name+' ('+f.height+')']);if(!fontList.some(f=>same(f.name,e.font)))fonts.push([e.font,e.font+' (gone)']); const pos=[field('X',inp(e,'x','number',{style:'width:5em'})),field('Y',inp(e,'y','number',{style:'width:5em'}))]; @@ -437,7 +439,7 @@ function peElems(){const d=$('pe_elems');d.innerHTML='';d.append(mk('datalist',{ row(...pos,field('Align',sel(e,'align',[['left','left'],['center','center'],['right','right']])),field('Font',sel(e,'font',fonts)),ovl); row(field('Scroll in a window of',inp(e,'scroll_width','number',{style:'width:5em',min:0,max:168})),'dots (0: off), at', inpf(e,'scroll_speed',{style:'width:5em',min:0.05,max:30}),'dots per second (0.25: one every 4 s)')} - else if(e.type=='bar')row(...pos,field('Align',sel(e,'align',[['left','left'],['center','center'],['right','right']])),ovl); + else if(e.type=='bar')row(...pos,field('Align',sel(e,'align',[['left','left'],['center','center'],['right','right']])),...(e.format==1?[field('Font',sel(e,'font',fonts))]:[]),ovl); else row(...pos,ovl); box.append(...rows);d.append(box)})} function peAdd(t){if(pe.page.elements.length>=16){msg('16 elements at most',true);return}pe.page.elements.push(newEl(t));pe.sel=pe.page.elements.length-1;peElems();pePreview()} diff --git a/test/test_esp32/test_main.cpp b/test/test_esp32/test_main.cpp index 5c72495..004e4dd 100644 --- a/test/test_esp32/test_main.cpp +++ b/test/test_esp32/test_main.cpp @@ -2443,6 +2443,109 @@ void test_bar_render() TEST_ASSERT_TRUE(bitmapPixel(b, 61, 17)); } +void test_bar_percent_text() +{ + PageElement e = barPage().elements[0]; + char t[8]; + barPercent(e, nullptr, t, sizeof(t)); + TEST_ASSERT_EQUAL_STRING("--", t); + barPercent(e, "unavailable", t, sizeof(t)); + TEST_ASSERT_EQUAL_STRING("--", t); + barPercent(e, "-5", t, sizeof(t)); + TEST_ASSERT_EQUAL_STRING("0%", t); + barPercent(e, "42.9", t, sizeof(t)); // rounded down + TEST_ASSERT_EQUAL_STRING("42%", t); + barPercent(e, "29", t, sizeof(t)); + TEST_ASSERT_EQUAL_STRING("29%", t); + barPercent(e, "99.99", t, sizeof(t)); // 100% only when full + TEST_ASSERT_EQUAL_STRING("99%", t); + barPercent(e, "250", t, sizeof(t)); + TEST_ASSERT_EQUAL_STRING("100%", t); + e.bar_max = 7200; + barPercent(e, "1800", t, sizeof(t)); + TEST_ASSERT_EQUAL_STRING("25%", t); +} + +// The dots that differ between two bitmaps +static int countDiff(const uint8_t *a, const uint8_t *b, int &left, int &top, int &right, int &bottom) +{ + int n = 0; + left = top = 99; + right = bottom = -1; + for (int y = 0; y < BITMAP_HEIGHT; y++) + for (int x = 0; x < BITMAP_WIDTH; x++) + if (bitmapPixel(a, x, y) != bitmapPixel(b, x, y)) + { + n++; + left = x < left ? x : left; + right = x > right ? x : right; + top = y < top ? y : top; + bottom = y > bottom ? y : bottom; + } + return n; +} + +void test_bar_percent_render() +{ + FakeResources res; + DynamicPage page = barPage(); + PageElement &e = page.elements[0]; + e.x = 10; + e.y = 5; + e.bar_width = 30; // 28 x 7 inside the frame + e.bar_height = 9; + strcpy(e.font, "Small"); + TEST_ASSERT_NULL(validatePage(page)); + uint8_t plain[BITMAP_BYTES], with[BITMAP_BYTES], text[BITMAP_BYTES]; + res.value = "50"; + renderPage(page, at(12, 0), 0, res, plain); + e.format = BAR_PERCENT; + TEST_ASSERT_NULL(validatePage(page)); + ElementBox box; + renderPage(page, at(12, 0), 0, res, with, &box); + TEST_ASSERT_EQUAL(30, box.width); // the box stays the bar + + // Every dot of the text flips the bar: as many changes as the text has dots, all inside + memset(text, 0, BITMAP_BYTES); + drawText(text, builtinFont(*findBuiltinFont("Small")), 0, 0, "50%", DrawMode::Set); + int left, top, right, bottom; + TEST_ASSERT_EQUAL(countOn(text), countDiff(plain, with, left, top, right, bottom)); + TEST_ASSERT_TRUE(left >= 11 && right <= 38 && top >= 6 && bottom <= 12); + // Centred: 5 dots high in 7 rows, one row free above and below + TEST_ASSERT_EQUAL(7, top); + TEST_ASSERT_EQUAL(11, bottom); + int free_left = left - 11, free_right = 38 - right; + TEST_ASSERT_TRUE(free_left - free_right <= 1 && free_right - free_left <= 1); + // Columns 11-24 are filled: the text is dark there and lit over the empty part + int dark = 0, lit = 0; + for (int y = top; y <= bottom; y++) + for (int x = left; x <= right; x++) + if (bitmapPixel(plain, x, y) != bitmapPixel(with, x, y)) + { + TEST_ASSERT_EQUAL(x > 24, bitmapPixel(with, x, y)); + (x > 24 ? lit : dark)++; + } + TEST_ASSERT_TRUE(dark > 0 && lit > 0); + + // Unknown: "--" on the empty bar + res.value = nullptr; + renderPage(page, at(12, 0), 0, res, with); + TEST_ASSERT_TRUE(countOn(with) > 2 * 30 + 2 * 7); + + // Doesn't fit (Small digits are 5 dots high, 4 rows inside): only the bar + res.value = "50"; + e.bar_height = 6; + e.format = BAR_PLAIN; + renderPage(page, at(12, 0), 0, res, plain); + e.format = BAR_PERCENT; + renderPage(page, at(12, 0), 0, res, with); + TEST_ASSERT_EQUAL(0, countDiff(plain, with, left, top, right, bottom)); + + // Format 2 doesn't exist + e.format = BAR_FORMAT_COUNT; + TEST_ASSERT_NOT_NULL(validatePage(page)); +} + void test_date_days_ahead() { LocalTime t; // Thursday 31 December 2026 @@ -2703,6 +2806,8 @@ int main() RUN_TEST(test_frame_check_periodic_resync); RUN_TEST(test_bar_fill); RUN_TEST(test_bar_render); + RUN_TEST(test_bar_percent_text); + RUN_TEST(test_bar_percent_render); RUN_TEST(test_bar_validation); RUN_TEST(test_date_days_ahead); RUN_TEST(test_attribute_paths_and_16_elements);