mirror of
https://github.com/MarekZegare4/MeshCore-Solo.git
synced 2026-07-26 14:58:12 +00:00
feat(ui): chat bubbles, right-aligned compose button, keyboard cursor editing
- MessagesScreen: shrink message boxes to fit content and anchor them right (outgoing) / left (incoming), like typical messengers; move the [+ send] compose button to the right edge to match; fix its frame margins (no descenders in the label made the symmetric padding look uneven) and reclaim the freed 2px for the message list. - KeyboardWidget: add Hold-Enter cursor-positioning mode (LEFT/RIGHT move, UP/DOWN jump to start/end, Enter/Cancel exit) with a visual "CURSOR MODE" indicator, so edits and inserts can target any point in the typed text instead of always the end.
This commit is contained in:
@@ -297,6 +297,20 @@ static int kbUtf8LastCharBytes(const char* buf, int len) {
|
|||||||
return n;
|
return n;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Byte width of the codepoint STARTING at buf[pos] (pos in [0,len)) — the
|
||||||
|
// forward counterpart to kbUtf8LastCharBytes, for moving the edit cursor
|
||||||
|
// right by one whole codepoint instead of one byte.
|
||||||
|
static int kbUtf8CharBytesAt(const char* buf, int pos, int len) {
|
||||||
|
if (pos >= len) return 0;
|
||||||
|
uint8_t c = (uint8_t)buf[pos];
|
||||||
|
int n = 1;
|
||||||
|
if ((c & 0xE0) == 0xC0) n = 2;
|
||||||
|
else if ((c & 0xF0) == 0xE0) n = 3;
|
||||||
|
else if ((c & 0xF8) == 0xF0) n = 4;
|
||||||
|
if (pos + n > len) n = len - pos; // truncated/corrupt sequence: don't overrun
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
static const int KB_PH_MAX = 20; // max placeholders in list (PopupMenu::PM_MAX_ITEMS=24 is the hard ceiling)
|
static const int KB_PH_MAX = 20; // max placeholders in list (PopupMenu::PM_MAX_ITEMS=24 is the hard ceiling)
|
||||||
static const int KB_PH_LEN = 30; // max placeholder string length incl. null -- sized for the longest
|
static const int KB_PH_LEN = 30; // max placeholder string length incl. null -- sized for the longest
|
||||||
// CLI-command candidate (AdminScreen), not just the short {x} tokens
|
// CLI-command candidate (AdminScreen), not just the short {x} tokens
|
||||||
@@ -316,6 +330,8 @@ struct KeyboardWidget {
|
|||||||
char buf[KB_MAX_LEN + 1];
|
char buf[KB_MAX_LEN + 1];
|
||||||
int len;
|
int len;
|
||||||
int max_len;
|
int max_len;
|
||||||
|
int cursor_pos; // insertion point into buf, in bytes; defaults to len (append)
|
||||||
|
bool cursor_mode; // true while Hold-Enter has parked the grid to reposition cursor_pos
|
||||||
int row, col;
|
int row, col;
|
||||||
int page; // see totalPages()/pageIsAltAlphabet()/pageIsSymbols() below
|
int page; // see totalPages()/pageIsAltAlphabet()/pageIsSymbols() below
|
||||||
bool caps;
|
bool caps;
|
||||||
@@ -434,6 +450,8 @@ struct KeyboardWidget {
|
|||||||
strncpy(buf, initial, max_len);
|
strncpy(buf, initial, max_len);
|
||||||
buf[max_len] = '\0';
|
buf[max_len] = '\0';
|
||||||
len = strlen(buf);
|
len = strlen(buf);
|
||||||
|
cursor_pos = len;
|
||||||
|
cursor_mode = false;
|
||||||
row = col = 0;
|
row = col = 0;
|
||||||
page = 0;
|
page = 0;
|
||||||
caps = false;
|
caps = false;
|
||||||
@@ -486,21 +504,30 @@ struct KeyboardWidget {
|
|||||||
const int spec_y = chars_y + rows * cell_h;
|
const int spec_y = chars_y + rows * cell_h;
|
||||||
const int spec_w = display.width() / KB_SPECIAL;
|
const int spec_w = display.width() / KB_SPECIAL;
|
||||||
|
|
||||||
// multi-line text preview: cursor always on last preview line
|
// Multi-line text preview: the view follows cursor_pos (normally == len,
|
||||||
|
// i.e. the end — so this is identical to the old "always the last line"
|
||||||
|
// behaviour until cursor mode moves cursor_pos elsewhere, at which point
|
||||||
|
// the preview scrolls to keep the repositioned cursor in view).
|
||||||
int cpl = display.width() / cw; // chars per preview line
|
int cpl = display.width() / cw; // chars per preview line
|
||||||
if (cpl < 1) cpl = 1;
|
if (cpl < 1) cpl = 1;
|
||||||
if (cpl > KB_PREVIEW_CAP) cpl = KB_PREVIEW_CAP; // never overrun linebuf below
|
if (cpl > KB_PREVIEW_CAP) cpl = KB_PREVIEW_CAP; // never overrun linebuf below
|
||||||
int cursor_line = len / cpl;
|
int cursor_line = cursor_pos / cpl;
|
||||||
int first_line = (cursor_line >= prev_lines) ? (cursor_line - prev_lines + 1) : 0;
|
int first_line = (cursor_line >= prev_lines) ? (cursor_line - prev_lines + 1) : 0;
|
||||||
int start = first_line * cpl;
|
int start = first_line * cpl;
|
||||||
for (int pl = 0; pl < prev_lines; pl++) {
|
for (int pl = 0; pl < prev_lines; pl++) {
|
||||||
int ps = start + pl * cpl;
|
int ps = start + pl * cpl;
|
||||||
int pe = ps + cpl;
|
int pe = ps + cpl;
|
||||||
bool cursor_here = (ps <= len && (len < pe || pl == prev_lines - 1));
|
bool cursor_here = (ps <= cursor_pos && (cursor_pos < pe || pl == prev_lines - 1));
|
||||||
char linebuf[KB_PREVIEW_CAP + 2]; // cpl chars + cursor '_' + NUL
|
char linebuf[KB_PREVIEW_CAP + 2]; // cpl chars + cursor '_' + NUL
|
||||||
if (cursor_here) {
|
if (cursor_here) {
|
||||||
int nc = len - ps; if (nc < 0) nc = 0; if (nc > cpl - 1) nc = cpl - 1;
|
// Cursor drawn as an inserted '_' between whatever text precedes and
|
||||||
snprintf(linebuf, sizeof(linebuf), "%.*s_", nc, buf + ps);
|
// follows it on this line -- reduces to the old "text + trailing _"
|
||||||
|
// when cursor_pos == len (after_n is always 0 in that case).
|
||||||
|
int before_n = cursor_pos - ps; if (before_n < 0) before_n = 0; if (before_n > cpl) before_n = cpl;
|
||||||
|
int line_text_end = (len < pe) ? len : pe;
|
||||||
|
int after_n = line_text_end - cursor_pos; if (after_n < 0) after_n = 0;
|
||||||
|
if (before_n + after_n > cpl) after_n = cpl - before_n;
|
||||||
|
snprintf(linebuf, sizeof(linebuf), "%.*s_%.*s", before_n, buf + ps, after_n, buf + ps + before_n);
|
||||||
} else if (len > ps) {
|
} else if (len > ps) {
|
||||||
int nc = (len < pe) ? (len - ps) : cpl;
|
int nc = (len < pe) ? (len - ps) : cpl;
|
||||||
snprintf(linebuf, sizeof(linebuf), "%.*s", nc, buf + ps);
|
snprintf(linebuf, sizeof(linebuf), "%.*s", nc, buf + ps);
|
||||||
@@ -514,6 +541,22 @@ struct KeyboardWidget {
|
|||||||
}
|
}
|
||||||
display.fillRect(0, sep_y, display.width(), display.sepH());
|
display.fillRect(0, sep_y, display.width(), display.sepH());
|
||||||
|
|
||||||
|
// Cursor-positioning mode: LEFT/RIGHT/UP/DOWN now drive the text cursor
|
||||||
|
// instead of the grid (see handleInput), so the grid would otherwise just
|
||||||
|
// sit there frozen with no sign anything's different. Replace it with an
|
||||||
|
// explicit hint instead.
|
||||||
|
if (cursor_mode) {
|
||||||
|
const int hh = lh + 2;
|
||||||
|
display.setColor(DisplayDriver::LIGHT);
|
||||||
|
display.fillRect(0, chars_y, display.width(), hh);
|
||||||
|
display.setColor(DisplayDriver::DARK);
|
||||||
|
display.drawTextCentered(display.width() / 2, chars_y + 1, "CURSOR MODE");
|
||||||
|
display.setColor(DisplayDriver::LIGHT);
|
||||||
|
display.drawTextCentered(display.width() / 2, chars_y + hh + 2, "L/R move");
|
||||||
|
display.drawTextCentered(display.width() / 2, chars_y + hh + 2 + lh, "U/D start/end");
|
||||||
|
return 50;
|
||||||
|
}
|
||||||
|
|
||||||
// character grid
|
// character grid
|
||||||
if (isT9()) {
|
if (isT9()) {
|
||||||
for (int r = 0; r < rows; r++) {
|
for (int r = 0; r < rows; r++) {
|
||||||
@@ -607,31 +650,51 @@ struct KeyboardWidget {
|
|||||||
const char* ph = _ph_buf[idx];
|
const char* ph = _ph_buf[idx];
|
||||||
int ph_len = strlen(ph);
|
int ph_len = strlen(ph);
|
||||||
// Contextual (refresh-hook) fields complete the in-progress word --
|
// Contextual (refresh-hook) fields complete the in-progress word --
|
||||||
// the text since the last space -- instead of appending after it, so
|
// the text since the last space, up to the cursor -- instead of
|
||||||
// picking a match doesn't duplicate what's already been typed.
|
// appending after it, so picking a match doesn't duplicate what's
|
||||||
int base_len = len;
|
// already been typed. Anything after the cursor (if it's not at the
|
||||||
|
// end) shifts along with the insertion, same as a normal keystroke.
|
||||||
|
int base_len = cursor_pos;
|
||||||
if (_ph_refresh) {
|
if (_ph_refresh) {
|
||||||
while (base_len > 0 && buf[base_len - 1] != ' ') base_len--;
|
while (base_len > 0 && buf[base_len - 1] != ' ') base_len--;
|
||||||
}
|
}
|
||||||
if (base_len + ph_len <= max_len) {
|
int tail_len = len - cursor_pos;
|
||||||
|
if (base_len + ph_len + tail_len <= max_len) {
|
||||||
|
memmove(buf + base_len + ph_len, buf + cursor_pos, tail_len);
|
||||||
memcpy(buf + base_len, ph, ph_len);
|
memcpy(buf + base_len, ph, ph_len);
|
||||||
len = base_len + ph_len;
|
len = base_len + ph_len + tail_len;
|
||||||
|
cursor_pos = base_len + ph_len;
|
||||||
buf[len] = '\0';
|
buf[len] = '\0';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return NONE;
|
return NONE;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cursor-positioning sub-mode (see the Hold-Enter block below): the grid
|
||||||
|
// selection is parked while LEFT/RIGHT walk cursor_pos one codepoint at a
|
||||||
|
// time and UP/DOWN jump to the very start/end -- Home/End, in effect.
|
||||||
|
// Enter/Cancel just leave the mode; the actual edit (insert/backspace)
|
||||||
|
// happens back in normal typing, now targeting the repositioned cursor.
|
||||||
|
if (cursor_mode) {
|
||||||
|
if (c == KEY_LEFT) { if (cursor_pos > 0) cursor_pos -= kbUtf8LastCharBytes(buf, cursor_pos); return NONE; }
|
||||||
|
if (c == KEY_RIGHT) { if (cursor_pos < len) cursor_pos += kbUtf8CharBytesAt(buf, cursor_pos, len); return NONE; }
|
||||||
|
if (c == KEY_UP) { cursor_pos = 0; return NONE; }
|
||||||
|
if (c == KEY_DOWN) { cursor_pos = len; return NONE; }
|
||||||
|
if (c == KEY_ENTER || c == KEY_CANCEL) { cursor_mode = false; return NONE; }
|
||||||
|
return NONE;
|
||||||
|
}
|
||||||
|
|
||||||
if (c == KEY_CANCEL) return CANCELLED;
|
if (c == KEY_CANCEL) return CANCELLED;
|
||||||
|
|
||||||
const int rows = gridRows();
|
const int rows = gridRows();
|
||||||
const int cols = gridCols();
|
const int cols = gridCols();
|
||||||
|
|
||||||
// Hold-Enter is normally "cancel", but over the two keys where a long-press
|
// Hold-Enter is normally "cancel", but three places give it a more useful
|
||||||
// has its own well-known meaning on a phone keyboard, it does that instead:
|
// meaning instead: Shift -> toggle a persistent caps-lock (a plain tap is
|
||||||
// Shift -> toggle a persistent caps-lock (a plain tap is one-shot -- see the
|
// one-shot -- see the commit sites below); Backspace -> clear the whole
|
||||||
// commit sites below); Backspace -> clear the whole field in one action
|
// field in one action instead of holding it down; anywhere on the letter/
|
||||||
// instead of holding it down. Every other cell keeps hold-to-cancel.
|
// symbol grid -> enter cursor-positioning mode (see above). Every other
|
||||||
|
// special-row cell keeps hold-to-cancel.
|
||||||
if (c == KEY_CONTEXT_MENU) {
|
if (c == KEY_CONTEXT_MENU) {
|
||||||
if (row == rows && col == 0) { // Shift
|
if (row == rows && col == 0) { // Shift
|
||||||
caps_lock = !caps_lock;
|
caps_lock = !caps_lock;
|
||||||
@@ -640,6 +703,12 @@ struct KeyboardWidget {
|
|||||||
}
|
}
|
||||||
if (row == rows && col == 2) { // Backspace
|
if (row == rows && col == 2) { // Backspace
|
||||||
len = 0; buf[0] = '\0';
|
len = 0; buf[0] = '\0';
|
||||||
|
cursor_pos = 0;
|
||||||
|
t9_cell = -1;
|
||||||
|
return NONE;
|
||||||
|
}
|
||||||
|
if (row < rows) { // any letter/symbol cell
|
||||||
|
cursor_mode = true;
|
||||||
t9_cell = -1;
|
t9_cell = -1;
|
||||||
return NONE;
|
return NONE;
|
||||||
}
|
}
|
||||||
@@ -691,18 +760,23 @@ struct KeyboardWidget {
|
|||||||
bool cycling = (t9_cell == cell) && (millis() - t9_last_ms < KB_T9_TIMEOUT_MS);
|
bool cycling = (t9_cell == cell) && (millis() - t9_last_ms < KB_T9_TIMEOUT_MS);
|
||||||
if (cycling) {
|
if (cycling) {
|
||||||
t9_cycle = (t9_cycle + 1) % total;
|
t9_cycle = (t9_cycle + 1) % total;
|
||||||
if (len > 0) {
|
if (cursor_pos > 0) {
|
||||||
char one[5];
|
char one[5];
|
||||||
if (t9_cycle < glen) kbUtf8CharAt(group, t9_cycle, one);
|
if (t9_cycle < glen) kbUtf8CharAt(group, t9_cycle, one);
|
||||||
else { one[0] = (char)('1' + cell); one[1] = '\0'; }
|
else { one[0] = (char)('1' + cell); one[1] = '\0'; }
|
||||||
char shown[5];
|
char shown[5];
|
||||||
kbApplyCapsUtf8(one, caps, shown, sizeof(shown));
|
kbApplyCapsUtf8(one, caps, shown, sizeof(shown));
|
||||||
int old_n = kbUtf8LastCharBytes(buf, len);
|
// Replace the codepoint just before the cursor (what the previous
|
||||||
int new_len = len - old_n;
|
// tap inserted), preserving anything after the cursor too.
|
||||||
|
int old_n = kbUtf8LastCharBytes(buf, cursor_pos);
|
||||||
|
int new_pos = cursor_pos - old_n;
|
||||||
|
int tail_len = len - cursor_pos;
|
||||||
int n = (int)strlen(shown);
|
int n = (int)strlen(shown);
|
||||||
if (new_len + n <= max_len) {
|
if (new_pos + n + tail_len <= max_len) {
|
||||||
memcpy(buf + new_len, shown, n);
|
memmove(buf + new_pos + n, buf + cursor_pos, tail_len);
|
||||||
len = new_len + n;
|
memcpy(buf + new_pos, shown, n);
|
||||||
|
len = new_pos + n + tail_len;
|
||||||
|
cursor_pos = new_pos + n;
|
||||||
buf[len] = '\0';
|
buf[len] = '\0';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -710,9 +784,12 @@ struct KeyboardWidget {
|
|||||||
char one[5]; kbUtf8CharAt(group, 0, one);
|
char one[5]; kbUtf8CharAt(group, 0, one);
|
||||||
char shown[5]; kbApplyCapsUtf8(one, caps, shown, sizeof(shown));
|
char shown[5]; kbApplyCapsUtf8(one, caps, shown, sizeof(shown));
|
||||||
int n = (int)strlen(shown);
|
int n = (int)strlen(shown);
|
||||||
|
int tail_len = len - cursor_pos;
|
||||||
if (len + n <= max_len) {
|
if (len + n <= max_len) {
|
||||||
memcpy(buf + len, shown, n);
|
memmove(buf + cursor_pos + n, buf + cursor_pos, tail_len);
|
||||||
|
memcpy(buf + cursor_pos, shown, n);
|
||||||
len += n;
|
len += n;
|
||||||
|
cursor_pos += n;
|
||||||
buf[len] = '\0';
|
buf[len] = '\0';
|
||||||
t9_cell = cell;
|
t9_cell = cell;
|
||||||
t9_cycle = 0;
|
t9_cycle = 0;
|
||||||
@@ -724,9 +801,12 @@ struct KeyboardWidget {
|
|||||||
char shown[3];
|
char shown[3];
|
||||||
kbApplyCapsUtf8(cellStr(row, col), caps, shown, sizeof(shown));
|
kbApplyCapsUtf8(cellStr(row, col), caps, shown, sizeof(shown));
|
||||||
int n = (int)strlen(shown);
|
int n = (int)strlen(shown);
|
||||||
|
int tail_len = len - cursor_pos;
|
||||||
if (len + n <= max_len) {
|
if (len + n <= max_len) {
|
||||||
memcpy(buf + len, shown, n);
|
memmove(buf + cursor_pos + n, buf + cursor_pos, tail_len);
|
||||||
|
memcpy(buf + cursor_pos, shown, n);
|
||||||
len += n;
|
len += n;
|
||||||
|
cursor_pos += n;
|
||||||
buf[len] = '\0';
|
buf[len] = '\0';
|
||||||
if (caps && !caps_lock) caps = false; // one-shot: revert after the letter it capitalised
|
if (caps && !caps_lock) caps = false; // one-shot: revert after the letter it capitalised
|
||||||
}
|
}
|
||||||
@@ -737,10 +817,20 @@ struct KeyboardWidget {
|
|||||||
// on this key, see handleInput's top), a tap cancels the lock instead.
|
// on this key, see handleInput's top), a tap cancels the lock instead.
|
||||||
case 0: if (caps_lock) { caps = false; caps_lock = false; } else { caps = !caps; } break;
|
case 0: if (caps_lock) { caps = false; caps_lock = false; } else { caps = !caps; } break;
|
||||||
case 1:
|
case 1:
|
||||||
if (len < max_len) { buf[len++] = ' '; buf[len] = '\0'; }
|
if (len < max_len) {
|
||||||
|
memmove(buf + cursor_pos + 1, buf + cursor_pos, len - cursor_pos);
|
||||||
|
buf[cursor_pos] = ' ';
|
||||||
|
len++; cursor_pos++;
|
||||||
|
buf[len] = '\0';
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
case 2:
|
case 2:
|
||||||
if (len > 0) { len -= kbUtf8LastCharBytes(buf, len); buf[len] = '\0'; }
|
if (cursor_pos > 0) {
|
||||||
|
int n = kbUtf8LastCharBytes(buf, cursor_pos);
|
||||||
|
memmove(buf + cursor_pos - n, buf + cursor_pos, len - cursor_pos);
|
||||||
|
len -= n; cursor_pos -= n;
|
||||||
|
buf[len] = '\0';
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
case 3:
|
case 3:
|
||||||
if (_ph_refresh) _ph_refresh(*this, _ph_refresh_ctx); // contextual repopulate, if wired up
|
if (_ph_refresh) _ph_refresh(*this, _ph_refresh_ctx); // contextual repopulate, if wired up
|
||||||
|
|||||||
@@ -332,27 +332,65 @@ class MessagesScreen : public UIScreen {
|
|||||||
// Selection frame for one history message box, shared by the DM/room and
|
// Selection frame for one history message box, shared by the DM/room and
|
||||||
// channel history lists. Selected = solid fill; unselected = outline with a
|
// channel history lists. Selected = solid fill; unselected = outline with a
|
||||||
// filled header strip. Leaves the ink DARK (for the sender row drawn next).
|
// filled header strip. Leaves the ink DARK (for the sender row drawn next).
|
||||||
static void drawHistRowFrame(DisplayDriver& d, int y, int bh, int reserve, int lh, bool sel) {
|
// Spans exactly [box_x, box_x+box_w) — the caller sizes/positions the bubble
|
||||||
|
// (see computeBubbleBox below), this just draws whatever box it's given.
|
||||||
|
static void drawHistRowFrame(DisplayDriver& d, int box_x, int box_w, int y, int bh, int lh, bool sel) {
|
||||||
d.setColor(DisplayDriver::LIGHT);
|
d.setColor(DisplayDriver::LIGHT);
|
||||||
if (sel) {
|
if (sel) {
|
||||||
d.fillRect(0, y, d.width() - reserve, bh);
|
d.fillRect(box_x, y, box_w, bh);
|
||||||
} else {
|
} else {
|
||||||
d.drawRect(0, y, d.width() - reserve, bh);
|
d.drawRect(box_x, y, box_w, bh);
|
||||||
d.fillRect(1, y + 1, d.width() - 2 - reserve, lh);
|
d.fillRect(box_x + 1, y + 1, box_w - 2, lh);
|
||||||
}
|
}
|
||||||
d.setColor(DisplayDriver::DARK);
|
d.setColor(DisplayDriver::DARK);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bottom-left "[+ send]" compose button, shared by both history lists:
|
// Width of an ack/delivery glyph (see drawAckGlyph) — needed up front to size
|
||||||
// bordered when idle, inverted (filled) when selected. Always reset to LIGHT
|
// an outgoing bubble before it's drawn.
|
||||||
// first so it stays visible regardless of the ink the message loop left.
|
static int ackGlyphWidth(DisplayDriver& d, AckState s, int sends) {
|
||||||
|
const int sc = miniIconScale(d);
|
||||||
|
switch (s) {
|
||||||
|
case ACK_PENDING: return sends * 3 * sc; // dot+gap pitch (icons.h), slightly generous
|
||||||
|
case ACK_OK: return ICON_CHECK.w * sc;
|
||||||
|
case ACK_FAIL: return ICON_CROSS.w * sc;
|
||||||
|
default: return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A chat bubble's horizontal extent: shrunk to fit its own content (the
|
||||||
|
// sender+ack+age header line vs the wrapped/measured body, whichever is
|
||||||
|
// wider), capped at the full available width, and anchored to the right for
|
||||||
|
// outgoing ("Me") messages or the left for incoming ones — so which side a
|
||||||
|
// bubble sits on shows who sent it, the same convention as a typical
|
||||||
|
// messenger. Everything else in the row (sender, age, ack glyph, body) is
|
||||||
|
// then drawn relative to this box's own x instead of the screen edge.
|
||||||
|
struct BubbleBox { int x, w; };
|
||||||
|
static BubbleBox computeBubbleBox(int full_avail, bool outgoing, int header_w, int body_w) {
|
||||||
|
int w = header_w > body_w ? header_w : body_w;
|
||||||
|
if (w > full_avail) w = full_avail;
|
||||||
|
if (w < 1) w = 1;
|
||||||
|
return { outgoing ? (full_avail - w) : 0, w };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bottom-right "[+ send]" compose button, shared by both history lists:
|
||||||
|
// bordered when idle, inverted (filled) when selected. Right-aligned to
|
||||||
|
// match the outgoing ("Me") bubbles anchored on that side, so composing
|
||||||
|
// sits under your own messages rather than the other side's. Box is exactly
|
||||||
|
// lh tall (no added border padding) — the text has no descenders ("[+
|
||||||
|
// send]"), so padding sized against the font's full ascent+descent cell
|
||||||
|
// read as a lopsided 1px-top/3px-bottom gap instead of an even border;
|
||||||
|
// tight framing avoids that and gives the history list back the 2px. Always
|
||||||
|
// reset to LIGHT first so it stays visible regardless of the ink the
|
||||||
|
// message loop left.
|
||||||
static void drawComposeButton(DisplayDriver& d, int cby, int lh, bool sel) {
|
static void drawComposeButton(DisplayDriver& d, int cby, int lh, bool sel) {
|
||||||
const char* ctxt = "[+ send]";
|
const char* ctxt = "[+ send]";
|
||||||
int ctw = d.getTextWidth(ctxt);
|
int ctw = d.getTextWidth(ctxt);
|
||||||
|
int bw = ctw + 4;
|
||||||
|
int bx = d.width() - bw;
|
||||||
d.setColor(DisplayDriver::LIGHT);
|
d.setColor(DisplayDriver::LIGHT);
|
||||||
if (sel) { d.fillRect(0, cby - 1, ctw + 4, lh + 2); d.setColor(DisplayDriver::DARK); }
|
if (sel) { d.fillRect(bx, cby, bw, lh); d.setColor(DisplayDriver::DARK); }
|
||||||
else d.drawRect(0, cby - 1, ctw + 4, lh + 2);
|
else d.drawRect(bx, cby, bw, lh);
|
||||||
d.setCursor(2, cby);
|
d.setCursor(bx + 2, cby);
|
||||||
d.print(ctxt);
|
d.print(ctxt);
|
||||||
d.setColor(DisplayDriver::LIGHT);
|
d.setColor(DisplayDriver::LIGHT);
|
||||||
}
|
}
|
||||||
@@ -885,7 +923,6 @@ public:
|
|||||||
int lh = display.getLineHeight();
|
int lh = display.getLineHeight();
|
||||||
int item_h = display.lineStep();
|
int item_h = display.lineStep();
|
||||||
int start_y = display.listStart();
|
int start_y = display.listStart();
|
||||||
int cw = display.getCharWidth();
|
|
||||||
|
|
||||||
if (_phase == MODE_SELECT) {
|
if (_phase == MODE_SELECT) {
|
||||||
display.drawCenteredHeader("MESSAGE", true, _ctx_menu.active);
|
display.drawCenteredHeader("MESSAGE", true, _ctx_menu.active);
|
||||||
@@ -998,7 +1035,7 @@ public:
|
|||||||
}
|
}
|
||||||
|
|
||||||
int hist_start_y = display.headerH();
|
int hist_start_y = display.headerH();
|
||||||
int cby = display.height() - lh - 2;
|
int cby = display.height() - lh; // compose button is now exactly lh tall, no added border padding
|
||||||
|
|
||||||
char title[24];
|
char title[24];
|
||||||
snprintf(title, sizeof(title), "%.23s", filtered_name);
|
snprintf(title, sizeof(title), "%.23s", filtered_name);
|
||||||
@@ -1068,20 +1105,37 @@ public:
|
|||||||
char age[6]; geo::fmtAgeShort(age, sizeof(age), now_ts, e.timestamp);
|
char age[6]; geo::fmtAgeShort(age, sizeof(age), now_ts, e.timestamp);
|
||||||
int age_w = age[0] ? display.getTextWidth(age) + 3 : 0;
|
int age_w = age[0] ? display.getTextWidth(age) + 3 : 0;
|
||||||
|
|
||||||
drawHistRowFrame(display, y, bh, reserve, lh, sel);
|
// Size the bubble to its own content before drawing anything (see
|
||||||
display.drawTextEllipsized(3, y + 1, display.width() - cw - 2 - age_w - reserve, sender);
|
// computeBubbleBox): the header (sender+ack+age) vs the body, measured
|
||||||
if (e.outgoing) { // delivery marker after "Me"
|
// once here and reused below instead of re-wrapping.
|
||||||
int gx = 3 + display.getTextWidth(sender) + 3;
|
int full_avail = display.width() - reserve;
|
||||||
drawAckGlyph(display, gx, y + 1, _history.dmEffectiveStatus(e), e.attempt + 1);
|
int ack_w = e.outgoing ? (3 + ackGlyphWidth(display, _history.dmEffectiveStatus(e), e.attempt + 1)) : 0;
|
||||||
}
|
int header_w = 3 + display.getTextWidth(sender) + ack_w + age_w + 3;
|
||||||
if (age[0]) { display.setCursor(display.width() - age_w - reserve, y + 1); display.print(age); }
|
int body_w, nl = 0;
|
||||||
if (!sel) display.setColor(DisplayDriver::LIGHT);
|
|
||||||
if (portrait_expand) {
|
if (portrait_expand) {
|
||||||
display.translateUTF8ToBlocks(s_wrap_trans, body, sizeof(s_wrap_trans));
|
display.translateUTF8ToBlocks(s_wrap_trans, body, sizeof(s_wrap_trans));
|
||||||
int nl = FullscreenMsgView::wrapLines(display, s_wrap_trans, display.width() - 6 - reserve, s_wrap_lines, 8);
|
nl = FullscreenMsgView::wrapLines(display, s_wrap_trans, full_avail - 6, s_wrap_lines, 8);
|
||||||
for (int li = 0; li < nl; li++) { display.setCursor(3, y + (li + 1) * lh + 1); display.print(s_wrap_lines[li]); }
|
body_w = 0;
|
||||||
|
for (int li = 0; li < nl; li++) { int w = display.getTextWidth(s_wrap_lines[li]); if (w > body_w) body_w = w; }
|
||||||
|
body_w += 6;
|
||||||
} else {
|
} else {
|
||||||
display.drawTextEllipsized(3, y + lh + 1, display.width() - cw - 2, body);
|
int raw_w = display.getTextWidth(body);
|
||||||
|
body_w = (raw_w > full_avail - 6 ? full_avail - 6 : raw_w) + 6;
|
||||||
|
}
|
||||||
|
BubbleBox box = computeBubbleBox(full_avail, e.outgoing, header_w, body_w);
|
||||||
|
|
||||||
|
drawHistRowFrame(display, box.x, box.w, y, bh, lh, sel);
|
||||||
|
display.drawTextEllipsized(box.x + 3, y + 1, box.w - 6 - age_w, sender);
|
||||||
|
if (e.outgoing) { // delivery marker after "Me"
|
||||||
|
int gx = box.x + 3 + display.getTextWidth(sender) + 3;
|
||||||
|
drawAckGlyph(display, gx, y + 1, _history.dmEffectiveStatus(e), e.attempt + 1);
|
||||||
|
}
|
||||||
|
if (age[0]) { display.setCursor(box.x + box.w - age_w, y + 1); display.print(age); }
|
||||||
|
if (!sel) display.setColor(DisplayDriver::LIGHT);
|
||||||
|
if (portrait_expand) {
|
||||||
|
for (int li = 0; li < nl; li++) { display.setCursor(box.x + 3, y + (li + 1) * lh + 1); display.print(s_wrap_lines[li]); }
|
||||||
|
} else {
|
||||||
|
display.drawTextEllipsized(box.x + 3, y + lh + 1, box.w - 6, body);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1139,7 +1193,7 @@ public:
|
|||||||
}
|
}
|
||||||
|
|
||||||
int hist_start_y = display.headerH();
|
int hist_start_y = display.headerH();
|
||||||
int cby = display.height() - lh - 2;
|
int cby = display.height() - lh; // compose button is now exactly lh tall, no added border padding
|
||||||
|
|
||||||
ChannelDetails ch;
|
ChannelDetails ch;
|
||||||
the_mesh.getChannel(_sel_channel_idx, ch);
|
the_mesh.getChannel(_sel_channel_idx, ch);
|
||||||
@@ -1220,24 +1274,43 @@ public:
|
|||||||
|
|
||||||
char age[6]; geo::fmtAgeShort(age, sizeof(age), now_ts, _history.chAtPos(ring_pos).timestamp);
|
char age[6]; geo::fmtAgeShort(age, sizeof(age), now_ts, _history.chAtPos(ring_pos).timestamp);
|
||||||
int age_w = age[0] ? display.getTextWidth(age) + 3 : 0;
|
int age_w = age[0] ? display.getTextWidth(age) + 3 : 0;
|
||||||
|
const char* body = skipReplyPrefix(msg_part);
|
||||||
|
|
||||||
drawHistRowFrame(display, y, bh, reserve, lh, sel);
|
// Size the bubble to its own content before drawing anything (see
|
||||||
display.drawTextEllipsized(3, y + 1, display.width() - cw - 2 - age_w - reserve, sender);
|
// computeBubbleBox): the header (sender+ack+age) vs the body, measured
|
||||||
// Channels have no recipient ACK — only show ✓ once a repeater echo
|
// once here and reused below instead of re-wrapping. Channels have no
|
||||||
// confirms the send was relayed into the mesh; otherwise no marker
|
// recipient ACK — only show ✓ once a repeater echo confirms the send
|
||||||
// (absence is normal, not a failure).
|
// was relayed into the mesh; otherwise no marker (absence is normal).
|
||||||
if (strcmp(sender, "Me") == 0 && _history.chAtPos(ring_pos).relay_status == ACK_OK) {
|
bool outgoing = strcmp(sender, "Me") == 0;
|
||||||
int gx = 3 + display.getTextWidth(sender) + 3;
|
bool show_ack = outgoing && _history.chAtPos(ring_pos).relay_status == ACK_OK;
|
||||||
|
int full_avail = display.width() - reserve;
|
||||||
|
int ack_w = show_ack ? (3 + ackGlyphWidth(display, ACK_OK, 1)) : 0;
|
||||||
|
int header_w = 3 + display.getTextWidth(sender) + ack_w + age_w + 3;
|
||||||
|
int body_w, nl = 0;
|
||||||
|
if (portrait_expand) {
|
||||||
|
display.translateUTF8ToBlocks(s_wrap_trans, body, sizeof(s_wrap_trans));
|
||||||
|
nl = FullscreenMsgView::wrapLines(display, s_wrap_trans, full_avail - 6, s_wrap_lines, 8);
|
||||||
|
body_w = 0;
|
||||||
|
for (int li = 0; li < nl; li++) { int w = display.getTextWidth(s_wrap_lines[li]); if (w > body_w) body_w = w; }
|
||||||
|
body_w += 6;
|
||||||
|
} else {
|
||||||
|
int raw_w = display.getTextWidth(body);
|
||||||
|
body_w = (raw_w > full_avail - 6 ? full_avail - 6 : raw_w) + 6;
|
||||||
|
}
|
||||||
|
BubbleBox box = computeBubbleBox(full_avail, outgoing, header_w, body_w);
|
||||||
|
|
||||||
|
drawHistRowFrame(display, box.x, box.w, y, bh, lh, sel);
|
||||||
|
display.drawTextEllipsized(box.x + 3, y + 1, box.w - 6 - age_w, sender);
|
||||||
|
if (show_ack) {
|
||||||
|
int gx = box.x + 3 + display.getTextWidth(sender) + 3;
|
||||||
drawAckGlyph(display, gx, y + 1, ACK_OK);
|
drawAckGlyph(display, gx, y + 1, ACK_OK);
|
||||||
}
|
}
|
||||||
if (age[0]) { display.setCursor(display.width() - age_w - reserve, y + 1); display.print(age); }
|
if (age[0]) { display.setCursor(box.x + box.w - age_w, y + 1); display.print(age); }
|
||||||
if (!sel) display.setColor(DisplayDriver::LIGHT);
|
if (!sel) display.setColor(DisplayDriver::LIGHT);
|
||||||
if (portrait_expand) {
|
if (portrait_expand) {
|
||||||
display.translateUTF8ToBlocks(s_wrap_trans, skipReplyPrefix(msg_part), sizeof(s_wrap_trans));
|
for (int li = 0; li < nl; li++) { display.setCursor(box.x + 3, y + (li + 1) * lh + 1); display.print(s_wrap_lines[li]); }
|
||||||
int nl = FullscreenMsgView::wrapLines(display, s_wrap_trans, display.width() - 6 - reserve, s_wrap_lines, 8);
|
|
||||||
for (int li = 0; li < nl; li++) { display.setCursor(3, y + (li + 1) * lh + 1); display.print(s_wrap_lines[li]); }
|
|
||||||
} else {
|
} else {
|
||||||
display.drawTextEllipsized(3, y + lh + 1, display.width() - cw - 2, skipReplyPrefix(msg_part));
|
display.drawTextEllipsized(box.x + 3, y + lh + 1, box.w - 6, body);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user