feat(companion): digit-by-digit Freq editor, scroll-indicator halo cleanup, channel-unread fix

- New DigitEditor.h: standalone, reusable LEFT/RIGHT-cursor + UP/DOWN-spin
  numeric editor. Replaces Freq's fixed ±0.025MHz nudge, which carried each
  preset's own fractional offset forever, landing on a different unpredictable
  grid depending which preset you started from. Every digit (hundreds down to
  thousandths) is now directly addressable. SF/BW/CR are unchanged (small
  discrete sets, plain left/right cycling is fine there).
- icons.h: drop the scroll-indicator's DARK contrast halo. Every caller
  already keeps its selection bar clear of the indicator column (reserve/
  _reserve), so the halo was never actually needed for contrast — and the
  bottom arrow's halo had no clip symmetric to the top one, so it bled 1px
  into the popup's bottom border.
- QuickMsgScreen.h: updateChannelUnread() now marks everything in the
  rendered visible window as read, not just the highlighted row — a taller
  screen that fits more message boxes at once should mark more read up
  front. Also drops a premature call at CHANNEL_HIST entry that ran before
  the channel's own render() had computed _hist_visible, which could ratchet
  _viewing_max_seen past what was actually shown (that field is shared with
  DM_HIST and stale until this channel's first render).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-06-18 20:04:37 +02:00
parent 243a11e6bc
commit 601606faa8
4 changed files with 137 additions and 46 deletions

View File

@@ -0,0 +1,86 @@
#pragma once
#include <helpers/ui/DisplayDriver.h>
#include <helpers/ui/UIScreen.h>
#include <Arduino.h>
// 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);
}
};

View File

@@ -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;
}

View File

@@ -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; }

View File

@@ -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