From b8760f7e412b7d1780734baffe1abf0a13f29961 Mon Sep 17 00:00:00 2001 From: MarekZegare4 Date: Mon, 22 Jun 2026 18:13:33 +0200 Subject: [PATCH] feat(ui): collapsible Tools, shared AccordionList helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The flat 10-item Tools list grew crowded, so tools are now grouped into collapsible sections (Location / Comms / System) that fold in place — the same model as Settings. Extracted that fold-in-place behaviour into a reusable AccordionList helper (owns the collapse mask, flattened visible-row list, selection and scroll; host supplies section sizes + two row painters) and migrated SettingsScreen onto it, dropping its hand-rolled buildVis/visIndexOf/sectionIndex and five state fields. AccordionList joins drawList/DigitEditor/NavView as a shared UI element used by two screens. Added four mini-icons (bot, note, chart, gear) so every Tools row and section header carries a glyph. Builds verified: WioTrackerL1 + e-ink solo (Flash 61.0%). Co-Authored-By: Claude Opus 4.8 --- .../companion_radio/ui-new/AccordionList.h | 97 +++++++++++ .../companion_radio/ui-new/SettingsScreen.h | 160 ++++++++---------- examples/companion_radio/ui-new/ToolsScreen.h | 132 ++++++++++++--- examples/companion_radio/ui-new/UITask.cpp | 1 + examples/companion_radio/ui-new/icons.h | 32 ++++ 5 files changed, 305 insertions(+), 117 deletions(-) create mode 100644 examples/companion_radio/ui-new/AccordionList.h diff --git a/examples/companion_radio/ui-new/AccordionList.h b/examples/companion_radio/ui-new/AccordionList.h new file mode 100644 index 00000000..05d3fe26 --- /dev/null +++ b/examples/companion_radio/ui-new/AccordionList.h @@ -0,0 +1,97 @@ +#pragma once +// Shared collapsible section list (accordion) — a Settings-style list whose +// rows are grouped under headers that expand/collapse in place. Owns the +// collapse bitmask, the flattened visible-row list, selection and scroll; the +// host screen only supplies the section sizes and two row painters. +// +// Reusable UI element (cf. drawList / DigitEditor / NavView): any screen that +// wants grouped, foldable rows drives it the same way — +// _acc.begin(sizes, n); // once, on enter() +// _acc.render(display, headerFn, itemFn); // in render() +// switch (_acc.handleInput(c)) { ... } // in handleInput() +// +// Relies on drawList() + the KEY_* codes already pulled in by icons.h / +// UIScreen.h before this header (same include-order contract as the screens). +#include "icons.h" + +class AccordionList { +public: + // A visible row is either a section header (item < 0) or the item-th entry + // within section `sec`. + struct Row { int8_t sec; int8_t item; }; + + enum Result { IGNORED, HANDLED, ACTIVATED }; + + static const int MAX_SECTIONS = 8; // collapse bitmask is 8 bits + static const int MAX_ROWS = 64; + + // (Re)initialise from per-section item counts. `collapsed` sets the initial + // fold state for every section (Settings opens folded, so default true). + void begin(const uint8_t* section_sizes, int section_count, bool collapsed = true) { + _count = section_count > MAX_SECTIONS ? MAX_SECTIONS : section_count; + for (int i = 0; i < _count; i++) _sizes[i] = section_sizes[i]; + _collapsed = collapsed ? 0xFF : 0x00; + _sel = 0; _scroll = 0; + rebuild(); + } + + int visibleCount() const { return _vis_count; } + bool collapsed(int sec) const { return (_collapsed >> sec) & 1; } + const Row& selected() const { return _rows[_sel]; } + bool onHeader() const { return _vis_count && _rows[_sel].item < 0; } + + // Paint via the shared drawList skeleton. `header(sec, y, sel, reserve, + // collapsed)` and `item(sec, item, y, sel, reserve)` each draw one row + // (including its own selection bar, as drawList's callers do). + template + void render(DisplayDriver& d, HeaderFn header, ItemFn item) { + drawList(d, _vis_count, _sel, _scroll, [&](int idx, int y, bool sel, int reserve) { + const Row& r = _rows[idx]; + if (r.item < 0) header(r.sec, y, sel, reserve, collapsed(r.sec)); + else item(r.sec, r.item, y, sel, reserve); + }); + } + + // Standard navigation. Returns ACTIVATED when Enter lands on an item (the + // host then acts on selected()); HANDLED for nav / fold toggles; IGNORED + // otherwise so the host can treat the key itself (e.g. Cancel). + Result handleInput(char c) { + if (c == KEY_UP) { if (_vis_count) _sel = (_sel > 0) ? _sel - 1 : _vis_count - 1; return HANDLED; } + if (c == KEY_DOWN) { if (_vis_count) _sel = (_sel + 1 < _vis_count) ? _sel + 1 : 0; return HANDLED; } + if (c == KEY_ENTER) { + if (!_vis_count) return HANDLED; + if (_rows[_sel].item < 0) { toggle(_rows[_sel].sec); return HANDLED; } + return ACTIVATED; + } + return IGNORED; + } + + // Fold/unfold a section, keeping that section's header selected afterwards so + // the cursor never jumps to an unrelated row when items appear/disappear. + void toggle(int sec) { + if (sec < 0 || sec >= _count) return; + _collapsed ^= (uint8_t)(1u << sec); + rebuild(); + for (int i = 0; i < _vis_count; i++) + if (_rows[i].sec == sec && _rows[i].item < 0) { _sel = i; break; } + } + +private: + uint8_t _sizes[MAX_SECTIONS]; + int _count = 0; + uint8_t _collapsed = 0xFF; + Row _rows[MAX_ROWS]; + int _vis_count = 0; + int _sel = 0, _scroll = 0; + + void rebuild() { + _vis_count = 0; + for (int s = 0; s < _count; s++) { + if (_vis_count < MAX_ROWS) _rows[_vis_count++] = { (int8_t)s, (int8_t)-1 }; + if (!collapsed(s)) + for (int it = 0; it < _sizes[s] && _vis_count < MAX_ROWS; it++) + _rows[_vis_count++] = { (int8_t)s, (int8_t)it }; + } + if (_sel >= _vis_count) _sel = _vis_count ? _vis_count - 1 : 0; + } +}; diff --git a/examples/companion_radio/ui-new/SettingsScreen.h b/examples/companion_radio/ui-new/SettingsScreen.h index 3c1af0f1..09fbc3b8 100644 --- a/examples/companion_radio/ui-new/SettingsScreen.h +++ b/examples/companion_radio/ui-new/SettingsScreen.h @@ -6,6 +6,7 @@ #include "../RadioPresets.h" #include "RadioParamsEditor.h" #include "RadioPresetPicker.h" +#include "AccordionList.h" class SettingsScreen : public UIScreen { UITask* _task; @@ -76,15 +77,20 @@ class SettingsScreen : public UIScreen { Count }; - int _selected; - int _scroll; - int _visible; // items fitting on screen; updated each render, used by handleInput - int _reserve = 0; // right-edge px reserved for the scrollbar (0 when list fits) - bool _dirty; - uint8_t _collapsed = 0x7F; // bit N set = section N collapsed (Display=0..Messages=6) - static const int MAX_VIS = 60; - uint8_t _vis[MAX_VIS]; // filtered list of visible SettingItem values - int _vis_count = 0; + // Cursor + scroll, fold state and the flattened visible list are owned by the + // shared AccordionList helper. We keep only the section→SettingItem mapping it + // needs (sections are walked once from the enum, honouring the #if guards). + int _selected = 0; // SettingItem under the cursor, resolved per input/render + int _reserve = 0; // right-edge px reserved for the scrollbar (0 when list fits) + bool _dirty = false; + + AccordionList _acc; + static const int NUM_SECTIONS = 7; + static const int MAX_PER_SEC = 16; + uint8_t _sec_items[NUM_SECTIONS][MAX_PER_SEC]; // SettingItem per (section, row) + uint8_t _sec_count[NUM_SECTIONS]; + uint8_t _sec_header[NUM_SECTIONS]; // the SECTION_* enum for each section + int _num_sections = 0; #if AUTO_OFF_MILLIS > 0 static const uint16_t AUTO_OFF_OPTS[5]; @@ -176,36 +182,33 @@ class SettingsScreen : public UIScreen { return ""; } - int sectionIndex(int item) const { - if (item == SECTION_DISPLAY) return 0; - if (item == SECTION_SOUND) return 1; - if (item == SECTION_HOME_PAGES) return 2; - if (item == SECTION_RADIO) return 3; - if (item == SECTION_SYSTEM) return 4; - if (item == SECTION_CONTACTS) return 5; - if (item == SECTION_MESSAGES) return 6; - return -1; - } - - int visIndexOf(int item) const { - for (int i = 0; i < _vis_count; i++) - if (_vis[i] == (uint8_t)item) return i; - return 0; - } - - void buildVis() { - _vis_count = 0; - int cur_sec = -1; - bool cur_collapsed = false; + // Walk the SettingItem enum once, bucketing items under their section header. + // #if-guarded items need no special handling — they simply aren't in the enum. + void buildSections() { + int cur = -1; for (int i = 0; i < (int)Count; i++) { if (isSection(i)) { - cur_sec++; - cur_collapsed = (_collapsed >> cur_sec) & 1; - if (_vis_count < MAX_VIS) _vis[_vis_count++] = (uint8_t)i; - } else if (!cur_collapsed && _vis_count < MAX_VIS) { - _vis[_vis_count++] = (uint8_t)i; + if (++cur >= NUM_SECTIONS) break; + _sec_header[cur] = (uint8_t)i; + _sec_count[cur] = 0; + } else if (cur >= 0 && _sec_count[cur] < MAX_PER_SEC) { + _sec_items[cur][_sec_count[cur]++] = (uint8_t)i; } } + _num_sections = cur + 1; + } + + // (Re)load the section sizes into the accordion (folds all, resets the cursor). + void resetList() { + uint8_t sizes[NUM_SECTIONS]; + for (int i = 0; i < _num_sections; i++) sizes[i] = _sec_count[i]; + _acc.begin(sizes, _num_sections); + } + + // Resolve the accordion's selected row to a SettingItem (header → SECTION_*). + int currentItem() const { + const AccordionList::Row& r = _acc.selected(); + return (r.item < 0) ? _sec_header[r.sec] : _sec_items[r.sec][r.item]; } bool isHomePage(int item) const { @@ -416,23 +419,9 @@ class SettingsScreen : public UIScreen { return item - MSG_SLOT_0; } - void renderItem(DisplayDriver& display, int item, int y) { + void renderItem(DisplayDriver& display, int item, int y, bool sel) { NodePrefs* p = _task->getNodePrefs(); - if (isSection(item)) { - int si = sectionIndex(item); - bool collapsed = (_collapsed >> si) & 1; - bool sel = (item == _selected); - display.setColor(DisplayDriver::LIGHT); - display.drawSelectionRow(0, y - 1, display.width() - _reserve, display.lineStep() - 1, sel); - display.setCursor(2, y); - display.print(collapsed ? "+" : "-"); - display.print(" "); - display.print(sectionName(item)); - return; - } - - bool sel = (item == _selected); display.drawSelectionRow(0, y - 1, display.width() - _reserve, display.lineStep() - 1, sel); display.setCursor(2, y); @@ -641,7 +630,7 @@ class SettingsScreen : public UIScreen { } // Keyboard state for editing message slots - int _edit_slot; // -1 = not editing, 0..9 = slot being edited + int _edit_slot = -1; // -1 = not editing, 0..9 = slot being edited KeyboardWidget* _kb; // Radio preset picker — names are too long for the value column, so Enter on @@ -656,17 +645,15 @@ class SettingsScreen : public UIScreen { public: SettingsScreen(UITask* task, KeyboardWidget* kb) - : _task(task), _kb(kb), _selected(SECTION_DISPLAY), _scroll(0), _visible(4), _dirty(false), _edit_slot(-1) { - buildVis(); + : _task(task), _kb(kb) { + buildSections(); + resetList(); } void markClean() { _dirty = false; - _collapsed = 0x7F; - _selected = SECTION_DISPLAY; - buildVis(); - _scroll = 0; + resetList(); _editor.freq.active = false; } @@ -677,18 +664,24 @@ public: return _kb->render(display); } - int item_h = display.lineStep(); - int start_y = display.listStart(); - _visible = display.listVisible(item_h); - _reserve = scrollIndicatorReserve(display, _vis_count, _visible); - display.drawCenteredHeader("SETTINGS"); - for (int i = 0; i < _visible && (_scroll + i) < _vis_count; i++) { - renderItem(display, _vis[_scroll + i], start_y + i * item_h); - } - - drawScrollIndicator(display, start_y, _visible * item_h, _vis_count, _visible, _scroll); + _acc.render(display, + // Section header: "[+/-] Name" + [&](int sec, int y, bool sel, int reserve, bool collapsed) { + _reserve = reserve; + display.setColor(DisplayDriver::LIGHT); + display.drawSelectionRow(0, y - 1, display.width() - reserve, display.lineStep() - 1, sel); + display.setCursor(2, y); + display.print(collapsed ? "+" : "-"); + display.print(" "); + display.print(sectionName(_sec_header[sec])); + }, + // Item row + [&](int sec, int item, int y, bool sel, int reserve) { + _reserve = reserve; + renderItem(display, _sec_items[sec][item], y, sel); + }); if (_picker.menu.active) _picker.menu.render(display); @@ -760,42 +753,23 @@ public: return true; } - if (c == KEY_UP && _vis_count > 0) { - int vi = visIndexOf(_selected); - vi = (vi > 0) ? vi - 1 : _vis_count - 1; - _selected = _vis[vi]; - if (vi < _scroll) _scroll = vi; - else if (vi >= _scroll + _visible) _scroll = vi - _visible + 1; - return true; - } - if (c == KEY_DOWN && _vis_count > 0) { - int vi = visIndexOf(_selected); - vi = (vi + 1 < _vis_count) ? vi + 1 : 0; - _selected = _vis[vi]; - if (vi >= _scroll + _visible) _scroll = vi - _visible + 1; - else if (vi < _scroll) _scroll = vi; - return true; - } if (c == KEY_CANCEL) { if (_dirty) the_mesh.savePrefs(); _task->gotoHomeScreen(); return true; } + // Up/down navigation and section fold/unfold live in the shared helper. + // Enter on an item returns ACTIVATED and falls through to the per-item logic + // below; left/right are ignored by the helper and likewise fall through. + AccordionList::Result ar = _acc.handleInput(c); + if (ar == AccordionList::HANDLED) return true; + _selected = currentItem(); + bool right = keyIsNext(c); bool left = keyIsPrev(c); bool enter = (c == KEY_ENTER); - if (enter && isSection(_selected)) { - int si = sectionIndex(_selected); - _collapsed ^= (1 << si); - buildVis(); - int vi = visIndexOf(_selected); - if (vi < _scroll) _scroll = vi; - if (_visible > 0 && vi >= _scroll + _visible) _scroll = vi - _visible + 1; - return true; - } - #if FEAT_BRIGHTNESS_SETTING if (_selected == BRIGHTNESS) { uint8_t lvl = _task->getBrightnessLevel(); diff --git a/examples/companion_radio/ui-new/ToolsScreen.h b/examples/companion_radio/ui-new/ToolsScreen.h index 4aeb4d6d..a4a535f6 100644 --- a/examples/companion_radio/ui-new/ToolsScreen.h +++ b/examples/companion_radio/ui-new/ToolsScreen.h @@ -1,48 +1,132 @@ #pragma once // Custom screen — not part of upstream UITask.cpp // Included by UITask.cpp just before HomeScreen. +// +// The flat 10-item list grew crowded, so tools are grouped into collapsible +// sections (Location / Comms / System) via the shared AccordionList helper — +// the same fold-in-place model as Settings. Each row carries a mini-icon; +// section headers show their cog/marker plus a fold indicator. + +#include "AccordionList.h" class ToolsScreen : public UIScreen { UITask* _task; - int _sel; - int _scroll = 0; - static const int ITEM_COUNT = 10; - static const char* ITEMS[ITEM_COUNT]; + enum Action { + ACT_NEARBY, ACT_LIVESHARE, ACT_TRAIL, ACT_GEOALERT, ACT_COMPASS, + ACT_BOT, ACT_AUTOADVERT, ACT_REPEATER, + ACT_RINGTONE, ACT_DIAGNOSTICS + }; + struct Tool { const char* label; const MiniIcon* icon; Action action; }; + struct Section { const char* name; const MiniIcon* icon; const Tool* tools; uint8_t count; }; + + static const Tool LOCATION_TOOLS[]; + static const Tool COMMS_TOOLS[]; + static const Tool SYSTEM_TOOLS[]; + static const Section SECTIONS[]; + static const int SECTION_COUNT = 3; + + AccordionList _acc; + + // Fixed icon gutter so labels line up whether or not a row has a glyph. Sized + // for the widest icon (the 7px cog) at the current font scale. + static int gutter(DisplayDriver& d) { return 7 * miniIconScale(d) + 2; } + + static void drawIcon(DisplayDriver& d, int x, int y, const MiniIcon* ic) { + if (!ic) return; + const int s = miniIconScale(d); + const int top = (y - 1) + ((d.lineStep() - 1) - ic->h * s) / 2; + miniIconDrawTop(d, x, top, *ic); + } + + void dispatch(Action a) { + switch (a) { + case ACT_NEARBY: _task->gotoNearbyScreen(); break; + case ACT_LIVESHARE: _task->gotoLiveShareScreen(); break; + case ACT_TRAIL: _task->gotoTrailScreen(); break; + case ACT_GEOALERT: _task->gotoGeoAlertScreen(); break; + case ACT_COMPASS: _task->gotoCompassScreen(); break; + case ACT_BOT: _task->gotoBotScreen(); break; + case ACT_AUTOADVERT: _task->gotoAutoAdvertScreen(); break; + case ACT_REPEATER: _task->gotoRepeaterScreen(); break; + case ACT_RINGTONE: _task->gotoRingtoneEditor(); break; + case ACT_DIAGNOSTICS: _task->gotoDiagnosticsScreen(); break; + } + } public: - ToolsScreen(UITask* task) : _task(task), _sel(0) {} + ToolsScreen(UITask* task) : _task(task) {} + + // Open folded at the section list each time Tools is entered from Home. + void enter() { + static uint8_t sizes[SECTION_COUNT]; + for (int i = 0; i < SECTION_COUNT; i++) sizes[i] = SECTIONS[i].count; + _acc.begin(sizes, SECTION_COUNT); + } int render(DisplayDriver& display) override { display.setTextSize(1); display.setColor(DisplayDriver::LIGHT); display.drawCenteredHeader("TOOLS"); - drawList(display, ITEM_COUNT, _sel, _scroll, [&](int idx, int y, bool sel, int reserve) { - display.drawSelectionRow(0, y - 1, display.width() - reserve, display.lineStep() - 1, sel); - display.setCursor(2, y); - display.print(ITEMS[idx]); - }); + const int cw = display.getCharWidth(); + const int g = gutter(display); + + _acc.render(display, + // Section header: "[+/-] Name" + [&](int sec, int y, bool sel, int reserve, bool collapsed) { + display.drawSelectionRow(0, y - 1, display.width() - reserve, display.lineStep() - 1, sel); + display.setCursor(2, y); + display.print(collapsed ? "+" : "-"); + const int icon_x = 2 + cw + 2; + drawIcon(display, icon_x, y, SECTIONS[sec].icon); + display.setCursor(icon_x + g, y); + display.print(SECTIONS[sec].name); + }, + // Item: indented " Label" + [&](int sec, int item, int y, bool sel, int reserve) { + display.drawSelectionRow(0, y - 1, display.width() - reserve, display.lineStep() - 1, sel); + const int icon_x = 2 + cw + 2; // align item icons under the header icon + drawIcon(display, icon_x, y, SECTIONS[sec].tools[item].icon); + display.setCursor(icon_x + g, y); + display.print(SECTIONS[sec].tools[item].label); + }); return 500; } bool handleInput(char c) override { - if (c == KEY_UP) { _sel = (_sel > 0) ? _sel - 1 : ITEM_COUNT - 1; return true; } - if (c == KEY_DOWN) { _sel = (_sel < ITEM_COUNT - 1) ? _sel + 1 : 0; return true; } if (c == KEY_CANCEL || c == KEY_CONTEXT_MENU) { _task->gotoHomeScreen(); return true; } - if (c == KEY_ENTER) { - if (_sel == 0) { _task->gotoRingtoneEditor(); return true; } - if (_sel == 1) { _task->gotoBotScreen(); return true; } - if (_sel == 2) { _task->gotoNearbyScreen(); return true; } - if (_sel == 3) { _task->gotoAutoAdvertScreen(); return true; } - if (_sel == 4) { _task->gotoLiveShareScreen(); return true; } - if (_sel == 5) { _task->gotoTrailScreen(); return true; } - if (_sel == 6) { _task->gotoGeoAlertScreen(); return true; } - if (_sel == 7) { _task->gotoCompassScreen(); return true; } - if (_sel == 8) { _task->gotoDiagnosticsScreen(); return true; } - if (_sel == 9) { _task->gotoRepeaterScreen(); return true; } + switch (_acc.handleInput(c)) { + case AccordionList::ACTIVATED: { + const AccordionList::Row& r = _acc.selected(); + dispatch(SECTIONS[r.sec].tools[r.item].action); + return true; + } + case AccordionList::HANDLED: return true; + case AccordionList::IGNORED: return false; } return false; } }; -const char* ToolsScreen::ITEMS[10] = { "Ringtone Editor", "Auto-Reply Bot", "Nearby Nodes", "Auto-Advert", "Live Share", "Trail", "Geo Alert", "Compass", "Diagnostics", "Repeater" }; + +const ToolsScreen::Tool ToolsScreen::LOCATION_TOOLS[] = { + { "Nearby Nodes", &ICON_MAP_CONTACT, ACT_NEARBY }, + { "Live Share", &ICON_ADVERT, ACT_LIVESHARE }, + { "Trail", &ICON_TRAIL, ACT_TRAIL }, + { "Geo Alert", &ICON_MAP_WAYPOINT, ACT_GEOALERT }, + { "Compass", &ICON_MAP_NORTH, ACT_COMPASS }, +}; +const ToolsScreen::Tool ToolsScreen::COMMS_TOOLS[] = { + { "Auto-Reply Bot", &ICON_BOT, ACT_BOT }, + { "Auto-Advert", &ICON_ADVERT, ACT_AUTOADVERT }, + { "Repeater", &ICON_REPEATER, ACT_REPEATER }, +}; +const ToolsScreen::Tool ToolsScreen::SYSTEM_TOOLS[] = { + { "Ringtone Editor", &ICON_NOTE, ACT_RINGTONE }, + { "Diagnostics", &ICON_CHART, ACT_DIAGNOSTICS }, +}; +const ToolsScreen::Section ToolsScreen::SECTIONS[] = { + { "Location", &ICON_MAP_CONTACT, LOCATION_TOOLS, 5 }, + { "Comms", &ICON_ADVERT, COMMS_TOOLS, 3 }, + { "System", &ICON_GEAR, SYSTEM_TOOLS, 2 }, +}; diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 25fdd0ba..17dae05a 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -1373,6 +1373,7 @@ void UITask::gotoSettingsScreen() { } void UITask::gotoToolsScreen() { + ((ToolsScreen*)tools_screen)->enter(); setCurrScreen(tools_screen); } diff --git a/examples/companion_radio/ui-new/icons.h b/examples/companion_radio/ui-new/icons.h index 3e42961f..9b7374be 100644 --- a/examples/companion_radio/ui-new/icons.h +++ b/examples/companion_radio/ui-new/icons.h @@ -165,6 +165,38 @@ MINI_ICON(ICON_REPEATER, 6, // » double chevron — relaying/forwarding (repe packRow(".#..#."), packRow("#..#..")); +// Tools-menu glyphs (auto-reply bot, ringtone editor, diagnostics, system). +MINI_ICON(ICON_BOT, 5, // robot head: antenna + eyes + grille (auto-reply bot) + packRow("..#.."), + packRow("#####"), + packRow("#.#.#"), + packRow("#####"), + packRow("#.#.#"), + packRow("#####")); +MINI_ICON(ICON_NOTE, 5, // ♪ quaver — ringtone editor + packRow("...##"), + packRow("...##"), + packRow("...#."), + packRow("...#."), + packRow("...#."), + packRow("##.#."), + packRow("##...")); +MINI_ICON(ICON_CHART, 5, // ascending bars — diagnostics / stats + packRow("....#"), + packRow("..#.#"), + packRow("..#.#"), + packRow("#.#.#"), + packRow("#.#.#"), + packRow("#####")); +MINI_ICON(ICON_GEAR, 7, // ⚙ cog with hub hole — system + packRow("..#.#.."), + packRow(".#####."), + packRow("#######"), + packRow("###.###"), + packRow("#######"), + packRow(".#####."), + packRow("..#.#..")); + // Trail-map markers — centred on a point (see miniIconDrawCentered) rather // than anchored to a text line. MINI_ICON(ICON_MAP_DOT, 3, // ● filled trail point