mirror of
https://github.com/MarekZegare4/MeshCore-Solo.git
synced 2026-07-26 23:08:11 +00:00
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:
86
examples/companion_radio/ui-new/DigitEditor.h
Normal file
86
examples/companion_radio/ui-new/DigitEditor.h
Normal 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);
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user