fix: e-ink portrait/landscape layout — separators, battery, keyboard, screens

- DisplayDriver: add sepH() (2px landscape, 1px OLED); fix transliterateCodepoint
  fallback '?' (was '\xDB' which broke UTF-8 word-wrap and rendered tiny block char)
- GxEPDDisplay: drawRect draws double border on landscape for visible 2px weight
- UITask: navigation dots scale with lh; content_y saves 16px by using dots_y+6;
  battery iconW=lh*2, fill margin bm scales with orientation; indicator spacing fixed;
  clock separator uses sepH(); alert popup tight and vertically centred
- KeyboardWidget: compact cell height (no stretch), multi-line preview, cursor always
  on last visible line
- BotScreen / NearbyScreen detail: use lineStep() as natural item height, shrink only
  when items don't fit (fixes portrait stretch)
- QuickMsgScreen: send button pinned to display bottom (height-lh-2), not floating
- SettingsScreen: AUTO_OFF guarded with #if AUTO_OFF_MILLIS>0; default sel=AUTO_LOCK
- All screens: separators updated to sepH()

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-05-22 23:13:27 +02:00
parent 118f29b0a1
commit 9c671891d9
12 changed files with 100 additions and 54 deletions

View File

@@ -31,7 +31,7 @@ public:
int tip_y = bar_y + bar_h + 4;
display.drawTextCentered(display.width() / 2, 0, "AUTO-ADVERT");
display.fillRect(0, display.headerH() - 1, display.width(), 1);
display.fillRect(0, display.headerH() - 1, display.width(), display.sepH());
display.setCursor(2, label_y);
display.print("Interval:");

View File

@@ -55,12 +55,14 @@ public:
return _kb.render(display);
}
int avail_h = display.height() - display.listStart();
int item_h = display.lineStep();
if (item_h * ITEM_COUNT > avail_h) item_h = avail_h / ITEM_COUNT;
int start_y = display.listStart();
int val_x = display.valCol();
display.drawTextCentered(display.width() / 2, 0, "AUTO-REPLY BOT");
display.fillRect(0, display.headerH() - 1, display.width(), 1);
display.fillRect(0, display.headerH() - 1, display.width(), display.sepH());
static const char* labels[] = { "Enable", "Channel", "Trigger", "Reply DM", "Reply Ch" };
for (int i = 0; i < ITEM_COUNT; i++) {

View File

@@ -46,7 +46,7 @@ public:
int val_x = display.valCol();
display.drawTextCentered(display.width() / 2, 0, "CLOCK FIELDS");
display.fillRect(0, display.headerH() - 1, display.width(), 1);
display.fillRect(0, display.headerH() - 1, display.width(), display.sepH());
static const char* labels[] = { "Field 1", "Field 2", "Field 3" };
for (int i = 0; i < FIELD_SLOTS; i++) {

View File

@@ -61,22 +61,39 @@ struct KeyboardWidget {
const int lh = display.getLineHeight();
const int cw = display.getCharWidth();
const int cell_w = display.width() / KB_COLS_CHAR;
const int sep_y = lh; // tight separator: preview row height only
// compact: don't stretch cells beyond lh; freed vertical space goes to preview lines
const int kb_h = (KB_ROWS_CHAR + 1) * lh;
const int preview_h = display.height() - kb_h - 1;
const int prev_lines = (preview_h / lh) > 1 ? (preview_h / lh) : 1;
const int sep_y = prev_lines * lh;
const int chars_y = sep_y + 1;
const int cell_h = (display.height() - chars_y) / (KB_ROWS_CHAR + 1);
const int spec_y = chars_y + KB_ROWS_CHAR * cell_h;
const int spec_w = display.width() / KB_SPECIAL;
// text preview: last N chars + cursor (fit within screen width)
int max_preview = display.width() / cw - 1;
if (max_preview > 30) max_preview = 30;
const char* disp_start = buf;
int disp_len = len;
if (disp_len > max_preview) { disp_start = buf + (disp_len - max_preview); disp_len = max_preview; }
char preview[32];
snprintf(preview, sizeof(preview), "%.*s_", disp_len, disp_start);
display.setCursor(0, 0);
display.print(preview);
// multi-line text preview: cursor always on last preview line
int cpl = display.width() / cw; // chars per preview line
if (cpl < 1) cpl = 1;
int cursor_line = len / cpl;
int first_line = (cursor_line >= prev_lines) ? (cursor_line - prev_lines + 1) : 0;
int start = first_line * cpl;
for (int pl = 0; pl < prev_lines; pl++) {
int ps = start + pl * cpl;
int pe = ps + cpl;
bool cursor_here = (ps <= len && (len < pe || pl == prev_lines - 1));
char linebuf[32];
if (cursor_here) {
int nc = len - ps; if (nc < 0) nc = 0; if (nc > cpl - 1) nc = cpl - 1;
snprintf(linebuf, sizeof(linebuf), "%.*s_", nc, buf + ps);
} else if (len > ps) {
int nc = (len < pe) ? (len - ps) : cpl;
snprintf(linebuf, sizeof(linebuf), "%.*s", nc, buf + ps);
} else {
linebuf[0] = '\0';
}
display.setCursor(0, pl * lh);
display.print(linebuf);
}
display.fillRect(0, sep_y, display.width(), 1);
// character grid

View File

@@ -234,8 +234,9 @@ class NearbyScreen : public UIScreen {
display.print(b64_line);
}
// distribute 4 remaining lines across available height below b64
int step = (display.height() - hdr) / 5;
// distribute 4 remaining lines below b64 — compact step, shrink only if needed
int step = display.lineStep();
if (step * 5 > display.height() - hdr) step = (display.height() - hdr) / 5;
char buf[32];
snprintf(buf, sizeof(buf), "RSSI: %d dBm", (int)r.rssi);
display.setCursor(2, hdr + step); display.print(buf);
@@ -419,8 +420,9 @@ public:
display.drawTextEllipsized(2, 1, display.width() - 4, filtered);
display.setColor(DisplayDriver::LIGHT);
// 5 lines: lat, lon, dist+bearing, type, seen — distributed across available height
int step = (display.height() - hdr) / 5;
// 5 lines: lat, lon, dist+bearing, type, seen — compact step, shrink only if needed
int step = display.lineStep();
if (step * 5 > display.height() - hdr) step = (display.height() - hdr) / 5;
char buf[32];
snprintf(buf, sizeof(buf), "Lat: %.5f", e.lat_e6 / 1e6);
display.setCursor(2, hdr); display.print(buf);
@@ -459,7 +461,7 @@ public:
char title[22];
snprintf(title, sizeof(title), "NEARBY[%s]", FILTER_LABELS[_filter]);
display.drawTextCentered(display.width() / 2, 0, title);
display.fillRect(0, display.headerH() - 1, display.width(), 1);
display.fillRect(0, display.headerH() - 1, display.width(), display.sepH());
if (_count == 0) {
display.drawTextCentered(display.width() / 2, display.height() / 2 - display.lineStep() / 2, "No contacts found");

View File

@@ -468,7 +468,7 @@ public:
if (_phase == MODE_SELECT) {
display.drawTextCentered(display.width()/2, 0, "MESSAGE");
display.fillRect(0, display.headerH() - 1, display.width(), 1);
display.fillRect(0, display.headerH() - 1, display.width(), display.sepH());
const char* opts[] = { "Direct message", "Channels", "Room Servers" };
int badges[3] = {
getDMUnreadTotal(),
@@ -501,7 +501,7 @@ public:
} else if (_phase == CONTACT_PICK) {
display.drawTextCentered(display.width()/2, 0, _room_mode ? "SELECT ROOM" : "SELECT CONTACT");
display.fillRect(0, display.headerH() - 1, display.width(), 1);
display.fillRect(0, display.headerH() - 1, display.width(), display.sepH());
if (_num_contacts == 0) {
display.drawTextCentered(display.width()/2, display.height()/2, _room_mode ? "No room servers" : "No favourites");
@@ -551,7 +551,7 @@ public:
} else if (_phase == CHANNEL_PICK) {
display.drawTextCentered(display.width()/2, 0, "SELECT CHANNEL");
display.fillRect(0, display.headerH() - 1, display.width(), 1);
display.fillRect(0, display.headerH() - 1, display.width(), display.sepH());
if (_num_channels == 0) {
display.drawTextCentered(display.width()/2, display.height()/2, "No channels");
@@ -618,15 +618,16 @@ public:
int hist_box_h = 2 * lh + 3;
int hist_item_h = hist_box_h + 2;
int hist_start_y = display.headerH();
_hist_visible = (display.height() - hist_start_y) / hist_item_h;
int btn_h = lh + 4;
_hist_visible = (display.height() - hist_start_y - btn_h) / hist_item_h;
if (_hist_visible < 1) _hist_visible = 1;
int cby = hist_start_y + _hist_visible * hist_item_h + 2;
int cby = display.height() - lh - 2;
char title[24];
display.setColor(DisplayDriver::LIGHT);
snprintf(title, sizeof(title), "%.23s", filtered_name);
display.drawTextCentered(display.width()/2, 0, title);
display.fillRect(0, lh + 1, display.width(), 1);
display.fillRect(0, lh + 1, display.width(), display.sepH());
int dm_count = dmHistCountForContact(_sel_contact.id.pub_key);
@@ -720,16 +721,17 @@ public:
int hist_box_h = 2 * lh + 3;
int hist_item_h = hist_box_h + 2;
int hist_start_y = display.headerH();
_hist_visible = (display.height() - hist_start_y) / hist_item_h;
int btn_h = lh + 4;
_hist_visible = (display.height() - hist_start_y - btn_h) / hist_item_h;
if (_hist_visible < 1) _hist_visible = 1;
int cby = hist_start_y + _hist_visible * hist_item_h + 2;
int cby = display.height() - lh - 2;
ChannelDetails ch;
the_mesh.getChannel(_sel_channel_idx, ch);
char title[24];
snprintf(title, sizeof(title), "%.23s", ch.name);
display.drawTextCentered(display.width()/2, 0, title);
display.fillRect(0, lh + 1, display.width(), 1);
display.fillRect(0, lh + 1, display.width(), display.sepH());
int ch_hist_count = histCountForChannel(_sel_channel_idx);
@@ -832,7 +834,7 @@ public:
snprintf(title, sizeof(title), "TO:%.14s", _sel_contact.name);
}
display.drawTextCentered(display.width()/2, 0, title);
display.fillRect(0, display.headerH() - 1, display.width(), 1);
display.fillRect(0, display.headerH() - 1, display.width(), display.sepH());
int total_msg_items = 1 + _active_msg_count;
for (int i = 0; i < _visible && (_msg_scroll+i) < total_msg_items; i++) {

View File

@@ -102,7 +102,7 @@ public:
snprintf(hdr, sizeof(hdr), "M%d BPM:%u %d/%d", _slot + 1, BPM_OPTS[_bpm_idx], _len, MAX_NOTES);
display.setCursor(0, 0);
display.print(hdr);
display.fillRect(0, display.headerH() - 1, display.width(), 1);
display.fillRect(0, display.headerH() - 1, display.width(), display.sepH());
for (int i = 0; i < _visible_notes; i++) {
int ni = _scroll + i;

View File

@@ -11,7 +11,9 @@ class SettingsScreen : public UIScreen {
#if !defined(EINK_DISPLAY_MODEL)
BRIGHTNESS,
#endif
#if AUTO_OFF_MILLIS > 0
AUTO_OFF,
#endif
AUTO_LOCK,
BATT_DISPLAY,
#if !defined(EINK_DISPLAY_MODEL)
@@ -64,9 +66,11 @@ class SettingsScreen : public UIScreen {
int _visible; // items fitting on screen; updated each render, used by handleInput
bool _dirty;
#if AUTO_OFF_MILLIS > 0
static const uint16_t AUTO_OFF_OPTS[5];
static const char* AUTO_OFF_LABELS[5];
static const int AUTO_OFF_COUNT = 5;
#endif
#if ENV_INCLUDE_GPS == 1
static const uint32_t GPS_INTERVAL_OPTS[6];
static const char* GPS_INTERVAL_LABELS[6];
@@ -101,6 +105,7 @@ class SettingsScreen : public UIScreen {
}
}
#if AUTO_OFF_MILLIS > 0
int autoOffIndex() {
NodePrefs* p = _task->getNodePrefs();
if (!p) return 1;
@@ -108,6 +113,7 @@ class SettingsScreen : public UIScreen {
if (AUTO_OFF_OPTS[i] == p->auto_off_secs) return i;
return 1;
}
#endif
#if ENV_INCLUDE_GPS == 1
int gpsIntervalIndex() {
@@ -289,10 +295,12 @@ class SettingsScreen : public UIScreen {
sprintf(buf, "%ddBm", p ? p->tx_power_dbm : 0);
display.setCursor(display.valCol(), y);
display.print(buf);
#if AUTO_OFF_MILLIS > 0
} else if (item == AUTO_OFF) {
display.print("AutoOff");
display.setCursor(display.valCol(), y);
display.print(AUTO_OFF_LABELS[autoOffIndex()]);
#endif
} else if (item == AUTO_LOCK) {
display.print("AutoLock");
display.setCursor(display.valCol(), y);
@@ -367,7 +375,7 @@ class SettingsScreen : public UIScreen {
public:
SettingsScreen(UITask* task)
: _task(task), _selected(AUTO_OFF), _scroll(0), _visible(4), _dirty(false), _edit_slot(-1) {}
: _task(task), _selected(AUTO_LOCK), _scroll(0), _visible(4), _dirty(false), _edit_slot(-1) {}
void markClean() { _dirty = false; }
@@ -385,7 +393,7 @@ public:
display.setColor(DisplayDriver::LIGHT);
display.drawTextCentered(display.width() / 2, 0, "SETTINGS");
display.fillRect(0, display.headerH() - 2, display.width(), 1);
display.fillRect(0, display.headerH() - display.sepH(), display.width(), display.sepH());
for (int i = 0; i < _visible && (_scroll + i) < SettingItem::Count; i++) {
renderItem(display, _scroll + i, start_y + i * item_h);
@@ -489,12 +497,14 @@ public:
if (right && p->tx_power_dbm < 22) { p->tx_power_dbm++; _task->applyTxPower(); _dirty = true; return true; }
if (left && p->tx_power_dbm > 2) { p->tx_power_dbm--; _task->applyTxPower(); _dirty = true; return true; }
}
#if AUTO_OFF_MILLIS > 0
if (_selected == AUTO_OFF && p) {
int idx = autoOffIndex();
if (right) idx = (idx + 1) % AUTO_OFF_COUNT;
if (left) idx = (idx + AUTO_OFF_COUNT - 1) % AUTO_OFF_COUNT;
if (left || right) { p->auto_off_secs = AUTO_OFF_OPTS[idx]; _dirty = true; return true; }
}
#endif
if (_selected == AUTO_LOCK && p && (left || right || enter)) {
p->auto_lock ^= 1;
_dirty = true;
@@ -576,8 +586,10 @@ public:
}
};
#if AUTO_OFF_MILLIS > 0
const uint16_t SettingsScreen::AUTO_OFF_OPTS[5] = { 5, 15, 30, 60, 0 };
const char* SettingsScreen::AUTO_OFF_LABELS[5] = { "5s", "15s", "30s", "60s", "never" };
#endif
#if ENV_INCLUDE_GPS == 1
const uint32_t SettingsScreen::GPS_INTERVAL_OPTS[6] = { 0, 30, 60, 300, 900, 1800 };
const char* SettingsScreen::GPS_INTERVAL_LABELS[6] = { "off", "30s", "1min", "5min", "15min", "30min" };

View File

@@ -16,7 +16,7 @@ public:
display.setTextSize(1);
display.setColor(DisplayDriver::LIGHT);
display.drawTextCentered(display.width() / 2, 0, "TOOLS");
display.fillRect(0, display.headerH() - 1, display.width(), 1);
display.fillRect(0, display.headerH() - 1, display.width(), display.sepH());
int item_h = display.lineStep();
int start_y = display.listStart();

View File

@@ -240,9 +240,10 @@ class HomeScreen : public UIScreen {
display.setTextSize(1);
display.setColor(DisplayDriver::LIGHT);
const int lh = display.getLineHeight();
const int cw = display.getCharWidth();
const int ind = cw + 2; // single-char indicator width
const int lh = display.getLineHeight();
const int cw = display.getCharWidth();
const int ind = cw + 2; // single-char indicator width
const int ind_gap = (lh >= 16) ? 3 : 1; // gap between indicator boxes
int battLeftX;
if (mode == 1) { // percent
@@ -259,18 +260,19 @@ class HomeScreen : public UIScreen {
display.print(buf);
} else { // icon — scales with lh
const int iconH = lh;
const int iconW = iconH * 3;
const int iconW = lh * 2;
const int bm = (lh >= 16) ? 3 : 2; // inner margin: 3px on landscape (2px border), 2px on OLED
battLeftX = display.width() - iconW - 3;
display.drawRect(battLeftX, 0, iconW, iconH);
display.fillRect(battLeftX + iconW, iconH / 4, 2, iconH / 2);
int fillW = (pct * (iconW - 4)) / 100;
display.fillRect(battLeftX + 2, 2, fillW, iconH - 4);
int fillW = (pct * (iconW - 2 * bm)) / 100;
display.fillRect(battLeftX + bm, bm, fillW, iconH - 2 * bm);
}
#ifdef PIN_BUZZER
if (_task->isBuzzerQuiet()) {
display.setColor(DisplayDriver::LIGHT);
int mx = battLeftX - ind - 1;
int mx = battLeftX - ind - ind_gap;
display.fillRect(mx, 0, ind, lh);
display.setColor(DisplayDriver::DARK);
display.setCursor(mx + 1, 0);
@@ -283,7 +285,7 @@ class HomeScreen : public UIScreen {
// BT connection indicator (left of muted/battery icons)
int leftmostX = battLeftX;
if (_task->isSerialEnabled()) {
int btX = battLeftX - ind - 1;
int btX = battLeftX - ind - ind_gap;
if (_task->hasConnection()) {
display.setColor(DisplayDriver::LIGHT);
display.fillRect(btX, 0, ind, lh);
@@ -295,7 +297,7 @@ class HomeScreen : public UIScreen {
display.setCursor(btX + 1, 0);
display.print("b");
}
leftmostX = btX - 1;
leftmostX = btX - ind_gap;
// "A" indicator — left of BT, blinks 50% duty at 4s period
if (_node_prefs && _node_prefs->advert_auto_interval_sec > 0) {
@@ -358,7 +360,7 @@ public:
const int lh = display.getLineHeight(); // line height at sz1
const int step = display.lineStep(); // lh + 2
const int dots_y = lh + 4; // page-dot row: just below header
const int content_y = dots_y + step; // first content row
const int content_y = dots_y + 6; // first content row (6px gap keeps dots visible)
// node name + battery — hidden on CLOCK page (full screen used for dashboard)
if (_page != CLOCK) {
@@ -385,8 +387,9 @@ public:
int vi = 0;
for (int i = 0; i < (int)Count; i++) {
if (!isPageVisible(i)) continue;
if (vi == curr_vis) display.fillRect(x-1, dots_y-1, 3, 3);
else display.fillRect(x, dots_y, 1, 1);
int ds = (lh >= 16) ? 2 : 1;
if (vi == curr_vis) display.fillRect(x-ds, dots_y-ds, 2*ds+1, 2*ds+1);
else display.fillRect(x-ds+1, dots_y-ds+1, 2*ds-1, 2*ds-1);
x += 10; vi++;
}
}
@@ -431,8 +434,8 @@ public:
display.drawTextCentered(display.width() / 2, date_y, buf);
int sep_y = date_y + lh + 1;
int dash0 = sep_y + 3;
display.fillRect(0, sep_y, display.width(), 1);
int dash0 = sep_y + display.sepH() + 2;
display.fillRect(0, sep_y, display.width(), display.sepH());
// dashboard data fields
if (_node_prefs) {
@@ -1448,13 +1451,17 @@ void UITask::loop() {
int delay_millis = curr->render(*_display);
if (millis() < _alert_expiry && curr == home) { // render alert only on home screen
_display->setTextSize(1);
int y = _display->height() / 3;
int p = _display->height() / 32;
int lh = _display->getLineHeight();
int pad = 3;
int box_h = lh + pad * 2;
int box_w = _display->width() - 8;
int box_x = 4;
int box_y = (_display->height() - box_h) / 2;
_display->setColor(DisplayDriver::DARK);
_display->fillRect(p, y, _display->width() - p*2, y);
_display->setColor(DisplayDriver::LIGHT); // draw box border
_display->drawRect(p, y, _display->width() - p*2, y);
_display->drawTextCentered(_display->width() / 2, y + p*3, _alert);
_display->fillRect(box_x, box_y, box_w, box_h);
_display->setColor(DisplayDriver::LIGHT);
_display->drawRect(box_x, box_y, box_w, box_h);
_display->drawTextCentered(_display->width() / 2, box_y + pad, _alert);
_next_refresh = _alert_expiry; // will need refresh when alert is dismissed
} else {
_next_refresh = millis() + delay_millis;

View File

@@ -41,6 +41,8 @@ public:
int listVisible() const { return listVisible(lineStep()); }
// x where a right-side value column starts (leaves ~8 chars for the value)
int valCol() const { return width() - getCharWidth() * 8; }
// separator line thickness: 2px on landscape e-ink, 1px on OLED
int sepH() const { return (width() >= height()) ? 2 : 1; }
virtual void drawTextCentered(int mid_x, int y, const char* str) { // helper method (override to optimise)
int w = getTextWidth(str);
setCursor(mid_x - w/2, y);
@@ -135,7 +137,7 @@ public:
case 0x00E7: return 'c'; case 0x00C7: return 'C'; // ç Ç
case 0x00F1: return 'n'; case 0x00D1: return 'N'; // ñ Ñ
case 0x00FD: return 'y'; case 0x00DD: return 'Y'; // ý Ý
default: return '\xDB'; // CP437 full block █
default: return '?';
}
}

View File

@@ -139,6 +139,8 @@ void GxEPDDisplay::drawRect(int x, int y, int w, int h) {
display_crc.update<int>(w);
display_crc.update<int>(h);
display.drawRect(x, y, w, h, _curr_color);
if (width() >= height() && w > 2 && h > 2)
display.drawRect(x + 1, y + 1, w - 2, h - 2, _curr_color);
}
void GxEPDDisplay::drawXbm(int x, int y, const uint8_t* bits, int w, int h) {