diff --git a/examples/companion_radio/ui-new/DigitEditor.h b/examples/companion_radio/ui-new/DigitEditor.h new file mode 100644 index 00000000..fe1c3e11 --- /dev/null +++ b/examples/companion_radio/ui-new/DigitEditor.h @@ -0,0 +1,86 @@ +#pragma once +#include +#include +#include + +// Digit-by-digit numeric editor: LEFT/RIGHT moves a cursor between decimal +// places (most-significant first), UP/DOWN adds/subtracts that place's value +// (100, 10, 1, 0.1, ...). Every place is directly addressable regardless of +// the starting value's own fractional offset — unlike a fixed step size +// nudging the raw value, which carries whatever odd fraction the starting +// value had forever, making different starting points land on different, +// unpredictable grids. Caller owns persisting the result; this widget only +// edits its own `value` in place. +struct DigitEditor { + float value = 0.0f; + float min_val = 0.0f, max_val = 0.0f; + uint8_t int_digits = 0; // digits before the decimal point + uint8_t dec_digits = 0; // digits after the decimal point + int8_t cursor = 0; // 0 = most-significant digit + bool active = false; // must default false: an embedding screen's input + // handler checks this before any begin() call + + enum Result { NONE, DONE, CANCELLED }; + + void begin(float v, float vmin, float vmax, uint8_t intd, uint8_t decd) { + value = v; min_val = vmin; max_val = vmax; + int_digits = intd; dec_digits = decd; + cursor = 0; active = true; + } + + int totalDigits() const { return (int)int_digits + (int)dec_digits; } + + // Place value for the digit at `pos` (0 = most-significant integer digit), + // e.g. int_digits=3 → pos 0/1/2 = hundreds/tens/units, pos 3.. = tenths/... + float placeValue(int pos) const { + int exp = (int)int_digits - 1 - pos; + float v = 1.0f; + if (exp >= 0) { for (int i = 0; i < exp; i++) v *= 10.0f; } + else { for (int i = 0; i < -exp; i++) v /= 10.0f; } + return v; + } + + Result handleInput(char c) { + if (!active) return CANCELLED; + if (keyIsPrev(c)) { if (cursor > 0) cursor--; return NONE; } + if (keyIsNext(c)) { if (cursor < totalDigits() - 1) cursor++; return NONE; } + if (c == KEY_UP || c == KEY_DOWN) { + float step = placeValue(cursor); + float next = value + (c == KEY_UP ? step : -step); + // Snap to the editor's own decimal precision so repeated +/- can't + // drift off-grid from float rounding. + float scale = 1.0f; + for (int i = 0; i < dec_digits; i++) scale *= 10.0f; + next = roundf(next * scale) / scale; + if (next >= min_val && next <= max_val) value = next; + return NONE; + } + if (c == KEY_ENTER) { active = false; return DONE; } + if (c == KEY_CANCEL) { active = false; return CANCELLED; } + return NONE; + } + + // Draws `value` at (x, y) with the digit under the cursor shown inverted + // (small DARK box, LIGHT glyph). Assumes the caller is already inside a + // selected row (ink DARK on a LIGHT selection bar); restores LIGHT after. + void render(DisplayDriver& d, int x, int y) { + char buf[16]; + snprintf(buf, sizeof(buf), "%.*f", (int)dec_digits, value); + const int cw = d.getCharWidth(); + const int char_idx = cursor < int_digits ? cursor : cursor + 1; // skip '.' + for (int k = 0; buf[k]; k++) { + int cx = x + k * cw; + if (k == char_idx) { + d.setColor(DisplayDriver::DARK); + d.fillRect(cx, y - 1, cw, d.getLineHeight() + 1); + d.setColor(DisplayDriver::LIGHT); + } else { + d.setColor(DisplayDriver::DARK); + } + char one[2] = { buf[k], '\0' }; + d.setCursor(cx, y); + d.print(one); + } + d.setColor(DisplayDriver::LIGHT); + } +}; diff --git a/examples/companion_radio/ui-new/QuickMsgScreen.h b/examples/companion_radio/ui-new/QuickMsgScreen.h index 79c62bd6..b99119e2 100644 --- a/examples/companion_radio/ui-new/QuickMsgScreen.h +++ b/examples/companion_radio/ui-new/QuickMsgScreen.h @@ -778,9 +778,14 @@ public: } void updateChannelUnread() { - if (_hist_sel < 0 || _sel_channel_idx < 0 || _sel_channel_idx >= MAX_GROUP_CHANNELS) return; - if (_hist_sel > _viewing_max_seen) _viewing_max_seen = _hist_sel; + if (_sel_channel_idx < 0 || _sel_channel_idx >= MAX_GROUP_CHANNELS) return; // histEntryForChannel is newest-first: index 0 = newest (unread), higher = older. + // Count everything actually rendered on screen as seen — not just the + // highlighted row — so a taller screen that fits more boxes at once marks + // more read up front, instead of requiring a press per row. + int seen_to = _hist_scroll + _hist_visible - 1; + if (_hist_sel > seen_to) seen_to = _hist_sel; + if (seen_to > _viewing_max_seen) _viewing_max_seen = seen_to; // Each step down from 0 sees one more message; seen count = max_seen + 1. int remaining = _unread_at_entry - (_viewing_max_seen + 1); _ch_unread[_sel_channel_idx] = (uint8_t)(remaining > 0 ? remaining : 0); @@ -1182,6 +1187,7 @@ public: } } _hist_visible = n_vis; + updateChannelUnread(); // mark everything in the just-computed visible window as seen for (int i = 0; i < n_vis && (_hist_scroll + i) < ch_hist_count; i++) { int item = _hist_scroll + i; @@ -1555,7 +1561,10 @@ public: _hist_sel = hc > 0 ? 0 : -1; _viewing_max_seen = _hist_sel >= 0 ? _hist_sel : 0; _phase = CHANNEL_HIST; - updateChannelUnread(); + // Not updateChannelUnread() here: _hist_visible is still whatever this + // channel's first render() hasn't computed yet (stale, shared with + // DM_HIST) — calling it now could ratchet _viewing_max_seen past what's + // actually about to be shown. render() calls it once that's fresh. if (_share_mode) beginShareCompose(true); return true; } diff --git a/examples/companion_radio/ui-new/SettingsScreen.h b/examples/companion_radio/ui-new/SettingsScreen.h index 9e36d859..e7e34602 100644 --- a/examples/companion_radio/ui-new/SettingsScreen.h +++ b/examples/companion_radio/ui-new/SettingsScreen.h @@ -4,6 +4,7 @@ #include "../Features.h" #include "../RadioPresets.h" +#include "DigitEditor.h" class SettingsScreen : public UIScreen { UITask* _task; @@ -577,10 +578,15 @@ class SettingsScreen : public UIScreen { display.drawTextEllipsized(xc, y, display.width() - xc - _reserve, name); } else if (item == CUSTOM_FREQ) { display.print("Freq"); - char buf[10]; - snprintf(buf, sizeof(buf), "%.3f", p ? p->freq : 0.0f); - display.setCursor(valCol(display), y); - display.print(buf); + int xc = valCol(display); + if (sel && _freq_editor.active) { + _freq_editor.render(display, xc, y); + } else { + char buf[10]; + snprintf(buf, sizeof(buf), "%.3f", p ? p->freq : 0.0f); + display.setCursor(xc, y); + display.print(buf); + } } else if (item == CUSTOM_SF) { display.print("SF"); char buf[6]; @@ -719,6 +725,10 @@ class SettingsScreen : public UIScreen { bool _preset_saving = false; // _kb is open to name a new preset, not a msg slot bool _preset_deleting = false; // _preset_menu is showing the delete sub-list + // Digit-by-digit Freq editor (see DigitEditor.h) — only this field uses it; + // SF/BW/CR are small discrete sets where a plain left/right cycle is fine. + DigitEditor _freq_editor; + // Layout: [0]="+ Save current...", [1..RADIO_PRESET_COUNT]=built-ins, // [..+_preset_user_count]=saved user presets, ["- Delete preset..." if any]. void openPresetMenu() { @@ -764,6 +774,7 @@ public: _selected = SECTION_DISPLAY; buildVis(); _scroll = 0; + _freq_editor.active = false; } int render(DisplayDriver& display) override { @@ -810,6 +821,18 @@ public: return true; } + // Digit-by-digit Freq editor + if (_freq_editor.active) { + float before = _freq_editor.value; + _freq_editor.handleInput(c); + if (p && _freq_editor.value != before) { + p->freq = _freq_editor.value; + _task->applyRadioParams(); + _dirty = true; + } + return true; + } + // Keyboard editing mode for naming a new saved preset if (_preset_saving) { auto res = _kb->handleInput(c); @@ -957,14 +980,14 @@ public: openPresetMenu(); return true; } - // Manual fine-tune of the active radio params. Freq bounds come from the - // radio driver itself (RadioLib's own validated range for this chip) so the - // field can never be nudged to a value setFrequency() would silently reject. - if (_selected == CUSTOM_FREQ && p) { + // Enter Freq's digit-by-digit editor (see DigitEditor.h). Bounds come from + // the radio driver itself (RadioLib's own validated range for this chip) + // so a digit can never be nudged to a value setFrequency() would reject. + if (_selected == CUSTOM_FREQ && p && enter) { float min_mhz, max_mhz; radio_driver.getFreqBounds(min_mhz, max_mhz); - if (right && p->freq < max_mhz) { p->freq += 0.025f; _task->applyRadioParams(); _dirty = true; return true; } - if (left && p->freq > min_mhz) { p->freq -= 0.025f; _task->applyRadioParams(); _dirty = true; return true; } + _freq_editor.begin(p->freq, min_mhz, max_mhz, 3, 3); // SX1262 range fits 3 integer digits; 3 decimals = kHz + return true; } if (_selected == CUSTOM_SF && p) { if (right && p->sf < 12) { p->sf++; _task->applyRadioParams(); _dirty = true; return true; } diff --git a/examples/companion_radio/ui-new/icons.h b/examples/companion_radio/ui-new/icons.h index df743cb5..78b84e39 100644 --- a/examples/companion_radio/ui-new/icons.h +++ b/examples/companion_radio/ui-new/icons.h @@ -74,28 +74,6 @@ inline void miniIconDrawTop(DisplayDriver& d, int x, int y, const MiniIcon& ic) if (ic.rows[r] & (1 << c)) d.fillRect(x + c * s, y + r * s, s, s); } -// Like miniIconDrawTop, but first lays down a 1px DARK halo that hugs the icon's -// shape (each pixel dilated by 1px), then the icon in LIGHT. Invisible on a dark -// row; on the LIGHT selection bar it outlines just the glyph — no boxed-in -// rectangle around it. Restores ink to LIGHT. clip_top bounds the halo so it -// can't bleed above a given y (e.g. onto a header separator just above the icon). -inline void miniIconDrawHalo(DisplayDriver& d, int x, int y, const MiniIcon& ic, - int clip_top = -100000) { - const int s = miniIconScale(d); - d.setColor(DisplayDriver::DARK); - for (int r = 0; r < ic.h; r++) - for (int c = 0; c < ic.w; c++) - if (ic.rows[r] & (1 << c)) { - int hy = y + r * s - 1, hh = s + 2; - if (hy < clip_top) { hh -= clip_top - hy; hy = clip_top; } - if (hh > 0) d.fillRect(x + c * s - 1, hy, s + 2, hh); - } - d.setColor(DisplayDriver::LIGHT); - for (int r = 0; r < ic.h; r++) - for (int c = 0; c < ic.w; c++) - if (ic.rows[r] & (1 << c)) d.fillRect(x + c * s, y + r * s, s, s); -} - // Draw a mini-icon centred on a point (scaled) — used for map markers, which // are positioned by their centre rather than a text-line top. inline void miniIconDrawCentered(DisplayDriver& d, int cx, int cy, const MiniIcon& ic) { @@ -318,20 +296,15 @@ inline void drawScrollIndicatorPx(DisplayDriver& d, int right_x, int top_y, int if (thumb_h > band) thumb_h = band; const int thumb_y = band_t + (int)((long)(band - thumb_h) * scroll_px / span); - // Each marker is drawn with a 1px DARK halo: invisible on a normal (dark) row, - // but on the LIGHT selection bar it carves out contrast so the LIGHT marker - // stays visible. Avoids having to know which row is currently selected. - // Thumb: a rectangle, so a plain box halo matches its shape. - d.setColor(DisplayDriver::DARK); - d.fillRect(bar_x - 1, thumb_y - 1, bar_w + 2, thumb_h + 2); + // No halo: every caller already keeps its selection bar clear of this column + // (reserve/_reserve), so the markers never need to fight a LIGHT background + // for contrast — and a halo on the bottom arrow had no symmetric clip like + // the top one, so it bled 1px into the container's bottom border. d.setColor(DisplayDriver::LIGHT); d.fillRect(bar_x, thumb_y, bar_w, thumb_h); - // Arrows: shape-hugging halo so they aren't boxed in on the selection bar. - // The top arrow clips its halo at top_y so it can't bite into the header - // separator sitting one pixel above the list area. - miniIconDrawHalo(d, x, top_y, ICON_SCROLL_UP, top_y); - miniIconDrawHalo(d, x, top_y + track_h - th, ICON_SCROLL_DOWN); + miniIconDrawTop(d, x, top_y, ICON_SCROLL_UP); + miniIconDrawTop(d, x, top_y + track_h - th, ICON_SCROLL_DOWN); } // Convenience overload anchored to the screen's right edge — the common case