Faster web UI: only changed lists reloaded, icon cache kept, storage usage cached

- Web UI: loadLists(only) fetches only the lists an action changed; saving a
  bitmap, icon or font also reloads the pages that use it
- Icons: a save or delete forgets only that name and updates the name list in
  place instead of listing the directory again
- Storage usage cached until a store writes or deletes a file (at most 60 s):
  /api/status 0.6 s -> 0.08 s
- Version 2.19.2

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mangavalkandClaude Opus 5.5 committed 2026-10-22 00:13:46 +02:00
1 parent 72a193130e
commit 41af2944bc
8 files changed
+120 -35

No files matched your search

+9
View File
@@ -227,6 +227,15 @@ See README.md for the overview.
takes about 100 ms, and pages are drawn for every frame and every web preview. Writes
must go through `saveBitmap()`/`deleteBitmap()`, which clear the cache. Long JSON lists
are sent in pieces of about 2 KB (`sendPiece()` in `web_ui.cpp`).
- Icons are cached too (`display/icon_store`, all of them). A save or delete only forgets
that name (`forget()`) and updates the cached name list in place (`listAdd()`/
`listRemove()`): clearing it all made the next icon list read every icon again (7 s).
- Storage usage (`bitmapStorageUsage()`, for `/api/status`) is cached: `usedBytes()` walks
the file system (about 0.5 s). Every store calls `bitmapStorageChanged()` after a write
or delete; the cache also expires after 60 s.
- The web UI reloads only the lists that changed (`loadLists(only)`, the ESP32 answers one
request at a time): saving a bitmap, icon or font also reloads the pages when a page
uses it (`pagesUsing()`, `pagesWithFont()`).
- Check the page's JavaScript after changes: extract the `<script>` part and run
`node --check` on it (node is installed).
+1 -1
View File
@@ -118,7 +118,7 @@ const uint32_t SETUP_AP_DELAY = 20000; // open the AP when WiFi fails thi
const uint32_t WIFI_CONNECT_TIMEOUT = 30000;
const uint32_t WIFI_RESTART_TIME = 10UL * 60 * 1000;
#define FIRMWARE_VERSION "2.19.1"
#define FIRMWARE_VERSION "2.19.2"
// Home Assistant device id (do not change: Home Assistant's entities are linked to it)
const uint8_t HA_DEVICE_ID[] = {0x34, 0x86, 0x5d, 0x3a, 0x08, 0x28};
+23 -2
View File
@@ -62,10 +62,29 @@ bool bitmapStorageAvailable()
return mounted;
}
// usedBytes() walks the whole file system (about 0.5 s), and the web UI asks every 5 s:
// kept until a file is written or deleted, at most USAGE_MAX_AGE
static const uint32_t USAGE_MAX_AGE = 60000;
static bool usage_valid = false;
static uint32_t usage_time = 0;
static size_t usage_used = 0, usage_total = 0;
void bitmapStorageChanged()
{
usage_valid = false;
}
void bitmapStorageUsage(size_t &used, size_t &total)
{
used = mounted ? LittleFS.usedBytes() : 0;
total = mounted ? LittleFS.totalBytes() : 0;
if (!usage_valid || millis() - usage_time >= USAGE_MAX_AGE)
{
usage_used = mounted ? LittleFS.usedBytes() : 0;
usage_total = mounted ? LittleFS.totalBytes() : 0;
usage_valid = true;
usage_time = millis();
}
used = usage_used;
total = usage_total;
}
std::vector<String> storedNames(const char *dir_path, const char *extension)
@@ -175,6 +194,7 @@ const char *saveBitmap(const char *name, const uint8_t *bitmap)
bool ok = file.write(bitmap, BITMAP_BYTES) == (size_t)BITMAP_BYTES;
file.close();
revision++;
bitmapStorageChanged();
cache.clear();
return ok ? nullptr : "Storage full";
}
@@ -188,6 +208,7 @@ const char *deleteBitmap(const char *name)
if (!LittleFS.remove(path(name)))
return "Could not delete the file";
revision++;
bitmapStorageChanged();
cache.clear();
return nullptr;
}
+2
View File
@@ -24,6 +24,8 @@ bool bitmapStoreBegin();
// (LittleFS is shared with display/page_store and display/font_store)
bool bitmapStorageAvailable();
void bitmapStorageUsage(size_t &used, size_t &total);
// A file in the storage was written or deleted (the stores call it): usage changed
void bitmapStorageChanged();
// The valid names of the files with this extension in a LittleFS directory
std::vector<String> storedNames(const char *dir, const char *extension);
+2
View File
@@ -97,6 +97,7 @@ const char *saveFont(const char *name, const uint8_t *data, size_t size)
file.close();
cache.clear();
revision++;
bitmapStorageChanged();
return ok ? nullptr : "Could not write the file (storage full?)";
}
@@ -110,5 +111,6 @@ const char *deleteFont(const char *name)
return "Could not delete the file";
cache.clear();
revision++;
bitmapStorageChanged();
return nullptr;
}
+44 -7
View File
@@ -62,6 +62,14 @@ struct CachedIcon
static std::map<String, CachedIcon> cache;
static const size_t CACHE_MAX = 128;
// After a save or delete: only this name (in any case, as it was looked up) is read again.
// Clearing everything made the next list of all icons read each one again (7 s).
static void forget(const char *name)
{
for (auto it = cache.begin(); it != cache.end();)
it = it->first.equalsIgnoreCase(name) ? cache.erase(it) : std::next(it);
}
// Full: first forget the missing names, only then the icons
static void makeRoom()
{
@@ -97,10 +105,35 @@ void iconStoreBegin()
}
}
// The sorted names, kept until an icon is saved or deleted (listing takes about a second)
// The sorted names, listed once (about a second) and then kept up to date
static std::vector<String> listed;
static bool listed_valid = false;
static bool nameBefore(const String &a, const String &b)
{
return strcasecmp(a.c_str(), b.c_str()) < 0;
}
static void listAdd(const char *name)
{
// It may replace a built-in one in another case: list again
if (builtinIconName(name))
listed_valid = false;
if (!listed_valid || std::find(listed.begin(), listed.end(), String(name)) != listed.end())
return;
listed.insert(std::lower_bound(listed.begin(), listed.end(), String(name), nameBefore), String(name));
}
static void listRemove(const char *name)
{
// A built-in one stays in the list (from the firmware): list again
if (builtinIconName(name))
listed_valid = false;
auto it = std::find(listed.begin(), listed.end(), String(name));
if (listed_valid && it != listed.end())
listed.erase(it);
}
std::vector<String> listIcons()
{
if (listed_valid)
@@ -109,8 +142,7 @@ std::vector<String> listIcons()
for (const BuiltinIcon &b : BUILTIN) // not stored (no storage)
if (std::none_of(names.begin(), names.end(), [&b](const String &n) { return strcasecmp(n.c_str(), b.name) == 0; }))
names.push_back(b.name);
std::sort(names.begin(), names.end(),
[](const String &a, const String &b) { return strcasecmp(a.c_str(), b.c_str()) < 0; });
std::sort(names.begin(), names.end(), nameBefore);
listed = names;
listed_valid = true;
return names;
@@ -194,16 +226,20 @@ const char *saveIcon(const char *name, const Icon &icon)
bool ok = file.write(size, 2) == 2 && file.write(icon.data, bytes) == bytes;
file.close();
revision++;
cache.clear();
listed_valid = false;
bitmapStorageChanged();
forget(name);
if (!ok)
{
listed_valid = false;
LittleFS.remove(path(name)); // not half an icon
count = -1;
return "Storage full";
}
if (is_new)
{
count++;
listAdd(name);
}
return nullptr;
}
@@ -216,7 +252,8 @@ const char *deleteIcon(const char *name)
if (count > 0)
count--;
revision++;
cache.clear();
listed_valid = false;
bitmapStorageChanged();
forget(name);
listRemove(name);
return nullptr;
}
+2
View File
@@ -132,6 +132,7 @@ const char *savePage(const char *name, const DynamicPage &page)
file.close();
delete stored;
revision++;
bitmapStorageChanged();
names_valid = false;
return ok ? nullptr : "Could not write the file (storage full?)";
}
@@ -147,6 +148,7 @@ const char *deletePage(const char *name)
if (!removed)
return "Could not delete the file";
revision++;
bitmapStorageChanged();
return nullptr;
}
+37 -25
View File
@@ -216,7 +216,7 @@ details{margin-top:10px}summary{cursor:pointer;color:var(--dim);font-size:13px;m
</main><div id="msg"></div>
<script>
const W=84,H=19,RB=11,$=id=>document.getElementById(id);
let settings=null,status=null,bitmapList=[],iconList=[],pageList=[],fontList=[],sched=null,schedDirty=false;
let settings=null,status=null,bitmapList=[],iconList=[],pageList=[],fontList=[],storageOk=true,sched=null,schedDirty=false;
const tabs=[['show','Show'],['pages','Pages'],['schedule','Schedule'],['stopwatch','Stopwatch'],['bitmaps','Bitmaps'],['icons','Icons'],['draw','Draw / Live'],['settings','Settings'],['ha','Home Assistant'],['system','System']];
tabs.forEach(([id,name],i)=>{const b=document.createElement('button');b.textContent=name;b.onclick=()=>tab(id);b.id='b-'+id;$('tabs').appendChild(b)});
function tab(id){if(!$('t-'+id))id='show';tabs.forEach(([t])=>{$('t-'+t).classList.toggle('on',t==id);$('b-'+t).classList.toggle('on',t==id)});location.hash=id}
@@ -269,15 +269,27 @@ function usedBitmaps(){const u=new Set(),add=n=>n&&u.add(n.toLowerCase());
else if(e.text)bitmapList.forEach(b=>{if(b.name.toLowerCase().startsWith(e.text.toLowerCase()))add(b.name)})})}
if(sched)(sched.rules||[]).forEach(r=>add(r.page));return u}
// The bitmaps and icons a page shows (backgrounds, fixed images, the names of images by state)
function pageImages(page){const u=new Set(),add=n=>n&&u.add(n.toLowerCase());(page.slots||[]).forEach(s=>add(s.bitmap));
function pageImages(page,extra=[]){const u=new Set(),add=n=>n&&u.add(n.toLowerCase()),icons=iconList.concat(extra);(page.slots||[]).forEach(s=>add(s.bitmap));
(page.elements||[]).forEach(e=>{if(e.type=='life'&&e.start=='pattern')add(e.text);
if(e.type=='life'&&(e.start=='rotate'||e.start=='shuffle'))iconList.forEach(b=>{if(b.name.toLowerCase().startsWith((e.text||'').toLowerCase()))add(b.name)});
if(e.type=='life'&&(e.start=='rotate'||e.start=='shuffle'))icons.forEach(b=>{if(b.name.toLowerCase().startsWith((e.text||'').toLowerCase()))add(b.name)});
if(e.type!='image')return;if(!e.entity)add(e.text);
else if(e.text)bitmapList.concat(iconList).forEach(b=>{if(b.name.toLowerCase().startsWith(e.text.toLowerCase()))add(b.name)})});return u}
else if(e.text)bitmapList.concat(icons).forEach(b=>{if(b.name.toLowerCase().startsWith(e.text.toLowerCase()))add(b.name)})});return u}
function subsection(id,n){$(id+'_box').style.display=n?'':'none';$(id+'_box').querySelector('summary').textContent='Used by pages and rules ('+n+')'}
async function loadLists(){const[b,p,f,sc,ic]=await Promise.all([getList('/api/bitmaps','bitmaps'),getList('/api/pages','pages'),getList('/api/fonts','fonts'),getList('/api/schedule','blocks'),getList('/api/icons','icons')]);
bitmapList=b.bitmaps;pageList=p.pages;fontList=f.fonts;iconList=ic.icons;iconList.forEach(x=>x.bits=icFromHex(x.width,x.height,x.data));if(!schedDirty)sched=sc.blocks&&sc.rules?sc:{enabled:true,blocks:[],rules:[]};bitmapList.forEach(bm=>bm.bits=fromHex(bm.data));pageList.forEach(pg=>pg.bits=fromHex(pg.data));
$('storage').textContent=b.storage?'':'Storage not available: install the new flash layout (see README).';
// Pages that must be loaded again (their previews are drawn on the ESP32) when these
// bitmaps or icons change, or when this font changes
const pagesUsing=(...names)=>pageList.some(p=>{const u=pageImages(p.page,names.map(name=>({name})));return names.some(n=>u.has(n.toLowerCase()))})?['pages']:[];
const pagesWithFont=n=>pageList.some(p=>(p.page.elements||[]).some(e=>same(e.font,n)))?['pages']:[];
// Loads the lists named in `only` again (bitmaps, pages, fonts, schedule, icons; all when
// left out) and redraws them all. The ESP32 answers one request at a time, so after a
// change only what changed is fetched.
async function loadLists(only){const want=k=>!only||only.includes(k),get=(k,p,key)=>want(k)?getList(p,key):null;
const[b,p,f,sc,ic]=await Promise.all([get('bitmaps','/api/bitmaps','bitmaps'),get('pages','/api/pages','pages'),get('fonts','/api/fonts','fonts'),get('schedule','/api/schedule','blocks'),get('icons','/api/icons','icons')]);
if(b){bitmapList=b.bitmaps;bitmapList.forEach(bm=>bm.bits=fromHex(bm.data));storageOk=b.storage}
if(p){pageList=p.pages;pageList.forEach(pg=>pg.bits=fromHex(pg.data))}
if(f)fontList=f.fonts;
if(ic){iconList=ic.icons;iconList.forEach(x=>x.bits=icFromHex(x.width,x.height,x.data))}
if(sc&&!schedDirty)sched=sc.blocks&&sc.rules?sc:{enabled:true,blocks:[],rules:[]};
$('storage').textContent=storageOk?'':'Storage not available: install the new flash layout (see README).';
const pages=$('pages'),offs=$('offpages'),bms=$('bitmaps'),dyn=$('dynpages'),used=usedBitmaps();
['pages','offpages','bitmaps','dynpages','pages_used','offpages_used','bitmaps_used'].forEach(id=>$(id).innerHTML='');
offs.appendChild(card('Blank','all dots off',new Uint8Array(W*H),()=>selectOff(''),settings&&!settings.off_page));
@@ -308,15 +320,15 @@ async function loadLists(){const[b,p,f,sc,ic]=await Promise.all([getList('/api/b
function btn(t,f,cls){const b=document.createElement('button');b.className='s '+(cls||'');b.textContent=t;b.onclick=e=>{e.stopPropagation();f()};return b}
function card(name,tag,bits,onclick,cur){const d=mk('div',{className:'item'+(cur?' cur':'')});d.appendChild(mini(bits));
d.append(mk('div',{className:'name',textContent:name}),mk('div',{className:'tag',textContent:tag}));if(onclick)d.onclick=onclick;return d}
async function selectPage(p){await post('/api/page',{page:p});settings.page=p;msg('Page: '+p);loadLists()}
async function selectOff(p){const r=await post('/api/settings',{off_page:p});settings=r.settings;msg('Off image: '+(p||'blank'));loadLists()}
async function delBitmap(n){if(!confirm('Delete '+n+'?'))return;await post('/api/bitmap/delete',{name:n});msg('Deleted');settings=await api('/api/settings');loadLists()}
async function delIcon(n){if(!confirm('Delete icon '+n+'?'))return;await post('/api/icon/delete',{name:n});msg('Deleted');if(ic&&same(ic.orig,n))icClose();loadLists()}
async function toIcon(n){if(!confirm('Make '+n+' an icon? It is then only used on pages, not shown on its own.'))return;await post('/api/bitmap/to_icon',{name:n});msg(n+' is now an icon');loadLists()}
async function toBitmap(n){await post('/api/icon/to_bitmap',{name:n});msg(n+' is now a bitmap');if(ic&&same(ic.orig,n))icClose();loadLists()}
async function resetPage(n){if(!confirm('Reset '+n+' to the original page? Your changes to it are lost.'))return;await post('/api/pages/delete',{name:n});msg(n+' reset');if(pe&&same(pe.orig,n))peClose();loadLists()}
async function delPage(n){if(!confirm('Delete page '+n+'?'))return;await post('/api/pages/delete',{name:n});msg('Deleted');if(pe&&same(pe.orig,n))peClose();settings=await api('/api/settings');loadLists()}
async function delFont(n){if(!confirm('Delete font '+n+'?'))return;await post('/api/fonts/delete',{name:n});msg('Deleted');loadLists()}
async function selectPage(p){await post('/api/page',{page:p});settings.page=p;msg('Page: '+p);loadLists([])}
async function selectOff(p){const r=await post('/api/settings',{off_page:p});settings=r.settings;msg('Off image: '+(p||'blank'));loadLists([])}
async function delBitmap(n){if(!confirm('Delete '+n+'?'))return;await post('/api/bitmap/delete',{name:n});msg('Deleted');settings=await api('/api/settings');loadLists(['bitmaps'])}
async function delIcon(n){if(!confirm('Delete icon '+n+'?'))return;await post('/api/icon/delete',{name:n});msg('Deleted');if(ic&&same(ic.orig,n))icClose();loadLists(['icons'])}
async function toIcon(n){if(!confirm('Make '+n+' an icon? It is then only used on pages, not shown on its own.'))return;await post('/api/bitmap/to_icon',{name:n});msg(n+' is now an icon');loadLists(['bitmaps','icons'])}
async function toBitmap(n){await post('/api/icon/to_bitmap',{name:n});msg(n+' is now a bitmap');if(ic&&same(ic.orig,n))icClose();loadLists(['bitmaps','icons'])}
async function resetPage(n){if(!confirm('Reset '+n+' to the original page? Your changes to it are lost.'))return;await post('/api/pages/delete',{name:n});msg(n+' reset');if(pe&&same(pe.orig,n))peClose();loadLists(['pages'])}
async function delPage(n){if(!confirm('Delete page '+n+'?'))return;await post('/api/pages/delete',{name:n});msg('Deleted');if(pe&&same(pe.orig,n))peClose();settings=await api('/api/settings');loadLists(['pages'])}
async function delFont(n){if(!confirm('Delete font '+n+'?'))return;await post('/api/fonts/delete',{name:n});msg('Deleted');loadLists(['fonts'])}
function swText(ms){const s=Math.floor(ms/1000),d=Math.floor(s/86400),h=Math.floor(s%86400/3600),m=Math.floor(s%3600/60),p=n=>String(n).padStart(2,'0');return(d?d+'d '+h+':'+p(m):h?h+':'+p(m):m)+':'+p(s%60)}
function swShow(sw){if(!sw)return;$('swtime').textContent=swText(sw.ms)+(sw.running?' (running)':'');$('swsince').textContent=sw.start?'Counting since '+sw.start.replace('T',' '):sw.ms?'Paused':''}
async function stopwatch(action,extra){swShow(await post('/api/stopwatch',Object.assign({action},extra)));if(pe)pePreview()}
@@ -367,7 +379,7 @@ function scAddBlock(){if(sched.blocks.length>=8){msg('8 blocks at most',true);re
sched.blocks.push({start:'12:00',effect:true,items:[{page:settings.page,seconds:30}]});sched.blocks.sort(bySlot);schedDirty=true;schedBuild()}
function scAddRule(){if(sched.rules.length>=8){msg('8 rules at most',true);return}
sched.rules.push({entity:'',attribute:'',compare:'eq',value:'',page:settings.page,mode:'while',seconds:30,interval:10,over_manual:true,over_slow_mode:false,skip_slow_mode:false});schedDirty=true;schedBuild()}
async function scSave(){sched.blocks.sort(bySlot);sched=await post('/api/schedule',schedPayload());schedDirty=false;schedBuild();msg('Schedule saved');loadLists()}
async function scSave(){sched.blocks.sort(bySlot);sched=await post('/api/schedule',schedPayload());schedDirty=false;schedBuild();msg('Schedule saved');loadLists([])}
async function backToSchedule(){await post('/api/schedule/back');msg('Back to the schedule');setTimeout(pollStatusOnce,500)}
async function pollStatusOnce(){try{status=await api('/api/status');$('sc_now').textContent='Showing: '+status.shown+' ('+status.shown_status+')';$('shownnow').textContent=$('sc_now').textContent}catch(e){}}
// page editor: the ESP32 draws the preview and returns the element boxes
@@ -499,7 +511,7 @@ async function haValue(entity,attribute){for(let i=0;i<25;i++){const r=await api
if(!r.pending)return r.value;await new Promise(f=>setTimeout(f,300))}msg('No answer from Home Assistant',true);throw 0}
async function haTest(e){const v=await haValue(e.entity,e.attribute);msg(e.entity+(e.attribute?' '+e.attribute:'')+' = '+v);pePreview()}
async function peSave(){const n=$('pe_name').value.trim();if(!n){msg('Enter a name',true);return}
await post('/api/pages/save',{name:n,page:pePage()});pe.orig=n;$('pe_title').textContent='Edit '+n;msg('Saved '+n);loadLists()}
await post('/api/pages/save',{name:n,page:pePage()});pe.orig=n;$('pe_title').textContent='Edit '+n;msg('Saved '+n);loadLists(['pages'])}
async function peLive(){if(peData)await live(peData)}
// fonts: a TTF/OTF drawn with the browser, each character cut to its dots (format: src/esp32/logic/font.h)
let fontFam=null,fontData=null;
@@ -527,7 +539,7 @@ function fontPreview(){const b=new Uint8Array(W*H);let x=0,first=true;
for(let y=0;y<fontData.h;y++)for(let i=0;i<gl.w;i++)if(gl.rows[y][i]&&x+i<W)b[y*W+x+i]=1;x+=gl.w}
draw($('f_canvas'),b)}
async function fontSave(){const n=$('f_name').value.trim();if(!n||!fontData){msg('Choose a font file and a name',true);return}
await post('/api/fonts/save',{name:n,data:fontData.bytes.map(v=>v.toString(16).padStart(2,'0')).join('')});msg('Saved font '+n);loadLists()}
await post('/api/fonts/save',{name:n,data:fontData.bytes.map(v=>v.toString(16).padStart(2,'0')).join('')});msg('Saved font '+n);loadLists(['fonts',...pagesWithFont(n)])}
// backup: everything in one JSON file; the import posts the chosen parts to the normal endpoints
const SGROUPS=[['schedule','Schedule',s=>({schedule:s.schedule})],
['display','Shown page, off image, No WiFi screen',s=>({page:s.page,off_page:s.off_page,clock:s.clock})],
@@ -620,9 +632,9 @@ function edClear(){ed.set(new Uint8Array(W*H));changed()}
function edInvert(){ed.set(ed.bits.map(v=>1-v));changed()}
async function sendLive(){await live(ed.bits)}
async function saveBitmap(){const n=$('savename').value.trim();if(!n){msg('Enter a name',true);return}
await post('/api/bitmap',{name:n,data:toHex(ed.bits)});msg('Saved '+n);loadLists()}
await post('/api/bitmap',{name:n,data:toHex(ed.bits)});msg('Saved '+n);loadLists(['bitmaps',...pagesUsing(n)])}
async function saveAsIcon(){const n=$('savename').value.trim();if(!n){msg('Enter a name',true);return}
const c=cropBits(ed.bits,W,H);await post('/api/icon',{name:n,width:c.w,height:c.h,data:icToHex(c.bits,c.w,c.h)});msg('Saved icon '+n+' ('+c.w+' x '+c.h+')');loadLists()}
const c=cropBits(ed.bits,W,H);await post('/api/icon',{name:n,width:c.w,height:c.h,data:icToHex(c.bits,c.w,c.h)});msg('Saved icon '+n+' ('+c.w+' x '+c.h+')');loadLists(['icons',...pagesUsing(n)])}
// image conversion: w x h dots, dark parts on
$('file').onchange=e=>{const f=e.target.files[0];if(!f)return;const img=new Image();img.onload=()=>{srcImg=img;$('imgopts').style.display='flex';convert()};img.src=URL.createObjectURL(f)};
['thr','inv','dith','fit'].forEach(id=>$(id).oninput=convert);
@@ -653,9 +665,9 @@ function rleBits(rle){const p=[];let x=0,y=0,w=1,n='';for(const c of rle){if(c>=
function lifeLib(){const d=$('lifepats');d.innerHTML='';for(const l of LIFEPATS){const p=rleBits(l[2]),have=iconList.some(x=>same(x.name,l[0]));
d.append(mk('div',{className:'item'},icCanvas(p.bits,p.w,p.h),mk('div',{className:'name',textContent:l[0]}),mk('div',{className:'tag',textContent:p.w+' x '+p.h+', '+l[1]}),
mk('div',{className:'row'},have?mk('span',{className:'hint',textContent:'added'}):btn('Add',()=>lifeAdd([l])))))}}
async function lifeAdd(list){let k=0,bad=0;for(const[n,,r]of list){if(iconList.some(x=>same(x.name,n)))continue;const p=rleBits(r);
try{await post('/api/icon',{name:n,width:p.w,height:p.h,data:icToHex(p.bits,p.w,p.h)});k++}catch(e){bad++}}
if(!bad)msg(k?'Added '+k+' pattern'+(k>1?'s':''):'All patterns are there');loadLists()}
async function lifeAdd(list){let k=0,bad=0;const added=[];for(const[n,,r]of list){if(iconList.some(x=>same(x.name,n)))continue;const p=rleBits(r);
try{await post('/api/icon',{name:n,width:p.w,height:p.h,data:icToHex(p.bits,p.w,p.h)});k++;added.push(n)}catch(e){bad++}}
if(!bad)msg(k?'Added '+k+' pattern'+(k>1?'s':''):'All patterns are there');loadLists(['icons',...pagesUsing(...added)])}
// icon editor: its own size, drawn like the bitmap editor
let ic=null,icPaint=null,icImg=null;const icc=$('ic_canvas');
function icShow(title){icImg=null;$('ic_imgopts').style.display='none';$('ic_file').value='';$('ic_title').textContent=title;$('ic_name').value=ic.orig;$('ic_w').value=ic.w;$('ic_h').value=ic.h;$('ic').style.display='block';icDraw();$('ic').scrollIntoView({behavior:'smooth'})}
@@ -678,7 +690,7 @@ $('ic_file').onchange=e=>{const f=e.target.files[0];if(!f||!ic)return;const img=
['ic_thr','ic_inv','ic_dith'].forEach(id=>$(id).oninput=()=>icConvert());
function icConvert(){if(!icImg||!ic)return;ic.bits=imgBits(icImg,ic.w,ic.h,+$('ic_thr').value,$('ic_inv').checked,$('ic_dith').checked,'contain');icDraw()}
async function icSave(){const n=$('ic_name').value.trim();if(!n){msg('Enter a name',true);return}
await post('/api/icon',{name:n,width:ic.w,height:ic.h,data:icToHex(ic.bits,ic.w,ic.h)});ic.orig=n;$('ic_title').textContent='Edit '+n;msg('Saved '+n);loadLists()}
await post('/api/icon',{name:n,width:ic.w,height:ic.h,data:icToHex(ic.bits,ic.w,ic.h)});ic.orig=n;$('ic_title').textContent='Edit '+n;msg('Saved '+n);loadLists(['icons',...pagesUsing(n)])}
async function icLive(){if(ic)await live(icFull(ic.bits,ic.w,ic.h))}
// test patterns (live mode)
let testTimer=null;