feat(chat): redesign message composer — radial byte gauge, autogrow, wrap

Replace the plain "N/236" byte counter with a radial wheel drawn inside the
input, right-aligned, filling green→amber→red as the message approaches the
236-byte memo cap. Vertically center the text with left padding (FramePadding),
hard-cap typing at the cap (no silent overflow), add Shift+Enter for a newline
(via an always-callback, since ImGui 1.92's Enter shortcut needs exact mods)
while plain Enter still sends, animate the input growing taller as lines are
added, and word-wrap the display instead of overflowing left. Give a hard
newline a touch more space above than a soft wrap in the message bubble.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-20 21:59:37 -05:00
parent 31ebfc782e
commit e08121af2b

View File

@@ -26,6 +26,7 @@
#include <algorithm>
#include <cctype>
#include <cfloat>
#include <cmath> // std::cos/std::sin — radial byte gauge in the composer
#include <cstdint>
#include <cstring>
#include <ctime>
@@ -44,6 +45,25 @@ std::string s_scroll_to_cid; // when set, scroll the thread to the bottom next
// Composer + new-conversation UI state.
char s_compose[512] = "";
std::string s_compose_cid; // the conversation s_compose is a draft for; draft is wiped when it changes
// On-chain chat body cap in bytes = (512 len("utf8:"))/2 secretstream ABYTES (see chat_outgoing.cpp).
// The composer hard-caps input to this; the emoji picker respects it too.
constexpr int kChatBodyMaxBytes = (512 - 5) / 2 - 17; // = 236
float s_composerAnimH = 0.0f; // animated composer-box height — grows with newlines, pushing the thread up
float s_composerTargetH = 0.0f; // target height measured in the composer block (wrapped content), consumed next frame
// Composer InputText callback: insert a newline at the cursor on Shift+Enter. ImGui 1.92's Enter handling
// (imgui_widgets.cpp:5078) resolves Enter via Shortcut() with EXACT modifiers, so Shift+Enter matches
// neither the plain-Enter (submit) nor the Ctrl+Enter shortcut — ImGui does nothing with it. We insert the
// newline ourselves here (running under CallbackAlways), respecting the on-chain byte cap.
int composeInputCallback(ImGuiInputTextCallbackData* data) {
ImGuiIO& io = ImGui::GetIO();
if (io.KeyShift
&& (ImGui::IsKeyPressed(ImGuiKey_Enter) || ImGui::IsKeyPressed(ImGuiKey_KeypadEnter))
&& data->BufTextLen < kChatBodyMaxBytes) {
data->InsertChars(data->CursorPos, "\n");
}
return 0;
}
bool s_show_new_convo = false;
char s_new_zaddr[128] = "";
char s_new_msg[256] = "";
@@ -165,6 +185,35 @@ ImU32 avatarColor(const std::string& seed) {
return kPalette[h % (sizeof(kPalette) / sizeof(kPalette[0]))];
}
// Lay out (and optionally draw) a chat message body with paragraph spacing: each '\n'-delimited paragraph
// word-wraps at wrapW, and every paragraph after the first gets `paraGap` of extra leading ABOVE it — so
// an explicit newline reads as a paragraph break (a bit more space than a soft wrap, which stays tight).
// Returns the total laid-out size (max wrapped width × total height). When dl != nullptr, draws at `origin`.
// Both the size pass (dl == nullptr) and the draw pass MUST use identical args so the bubble fits the text.
ImVec2 layoutChatBody(ImDrawList* dl, ImFont* font, float size, const std::string& body,
float wrapW, float paraGap, ImVec2 origin, ImU32 col) {
float maxW = 0.0f, y = 0.0f;
bool first = true;
size_t start = 0;
for (;;) {
const size_t nl = body.find('\n', start);
const bool last = (nl == std::string::npos);
const char* pbeg = body.c_str() + start;
const char* pend = body.c_str() + (last ? body.size() : nl);
if (!first) y += paraGap; // paragraph break gets extra room
const ImVec2 psz = font->CalcTextSizeA(size, FLT_MAX, wrapW, pbeg, pend);
const float ph = std::max(psz.y, size); // a blank line still takes one line
if (dl && pend > pbeg)
dl->AddText(font, size, ImVec2(origin.x, origin.y + y), col, pbeg, pend, wrapW);
y += ph;
maxW = std::max(maxW, psz.x);
first = false;
if (last) break;
start = nl + 1;
}
return ImVec2(maxW, y);
}
// Outgoing-bubble accent base color for a chat_bubble_accent preset (0 = theme primary).
ImU32 bubbleAccentColor(int accent) {
switch (accent) {
@@ -465,7 +514,11 @@ void renderEmojiPickerOverlay(char* buf, std::size_t bufSize) {
ImGui::PushID(shown);
if (material::IconButton("##em", e.glyph, emojiFont, ImVec2(cell, cell), es)) {
const std::size_t cur = std::strlen(buf), add = std::strlen(e.glyph);
if (cur + add < bufSize) { std::memcpy(buf + cur, e.glyph, add); buf[cur + add] = '\0'; }
// Respect the on-chain byte cap (not just the raw buffer) so the emoji path can't
// overshoot what the input's own hard cap allows.
if (cur + add <= static_cast<std::size_t>(kChatBodyMaxBytes) && cur + add < bufSize) {
std::memcpy(buf + cur, e.glyph, add); buf[cur + add] = '\0';
}
}
ImGui::PopID();
if (++shown % perRow != 0) ImGui::SameLine();
@@ -534,6 +587,7 @@ void RenderChatTab(App* app)
if (s_selected_cid != s_compose_cid) {
sodium_memzero(s_compose, sizeof(s_compose));
s_compose_cid = s_selected_cid;
s_composerAnimH = 0.0f; // re-arm the first-frame snap so the box doesn't animate-collapse on switch
}
// Abandon an in-progress header rename tied to a different conversation (switch/hide), so a
// half-typed buffer can't silently reappear or land on the wrong contact (Tier C review).
@@ -705,11 +759,28 @@ void RenderChatTab(App* app)
for (const auto& c : convs) if (c.cid == s_selected_cid) { sel = &c; break; }
const float tdp = Layout::dpiScale();
const float composerBoxH = ImGui::GetTextLineHeight() * 2.6f; // multi-line composer (Q6)
// Reserve a stable composer strip below the box whenever a conversation is open (thin counter row +
// the input row, which sits flush with the strip/window bottom). Same height for the waiting hint so
// the box doesn't jump when a reply arrives.
const float composerAreaH = sel ? (composerBoxH + metaSz + 12.0f * tdp) : 0.0f;
// Composer auto-grows with its content: the input word-wraps, so the box height tracks the wrapped
// line count (measured in the composer block below, where the wrap width is known) and animates so the
// thread above is smoothly pushed up/down. The vertical padding also centers a single line and, with
// the left FramePadding, keeps text off the edges. We consume last frame's measured target here (a
// 1-frame lag that's invisible under the smoothing); it falls back to one collapsed line.
const float lineH = ImGui::GetTextLineHeight();
const float composerVPad = 8.0f * tdp; // top+bottom inner padding
const float composerCtrlH = lineH + 2.0f * composerVPad; // one-line (collapsed) height; side controls match
const int kMaxComposerLines = 6; // beyond this the input scrolls internally
const float composerAnimTarget = (s_composerTargetH > 0.0f) ? s_composerTargetH : composerCtrlH;
if (s_composerAnimH <= 0.0f) s_composerAnimH = composerAnimTarget; // first frame: snap, don't animate in
{
const float dt = ImGui::GetIO().DeltaTime;
const float k = 1.0f - std::exp(-dt * 16.0f); // ~16/s exponential convergence
s_composerAnimH += (composerAnimTarget - s_composerAnimH) * k;
if (std::fabs(composerAnimTarget - s_composerAnimH) < 0.5f) s_composerAnimH = composerAnimTarget;
}
const float composerBoxH = s_composerAnimH;
// Reserve the (animated) composer strip below the box whenever a conversation is open — just the input
// row (the byte budget is a radial gauge inside the input, no separate counter row). Same height for
// the waiting hint so the box doesn't jump when a reply arrives.
const float composerAreaH = sel ? (composerBoxH + 10.0f * tdp) : 0.0f;
// Frosted-glass thread pane (same reasoning as the list): glass behind + transparent child bg so
// the message area blurs the backdrop instead of showing the sharp texture. Replaces the old
@@ -1066,7 +1137,10 @@ void RenderChatTab(App* app)
}
// ── Direction-aligned bubble (rounding/fill/accent from settings; grouped corners).
const ImVec2 tsz = nameFont->CalcTextSizeA(bodySz, innerW, innerW, m.body.c_str());
// Body is laid out paragraph-by-paragraph so explicit newlines get extra leading.
const float paraGap = bodySz * 0.35f;
const ImVec2 tsz = layoutChatBody(nullptr, nameFont, bodySz, m.body, innerW, paraGap,
ImVec2(0, 0), 0);
const float bw = std::min(maxBubbleW, tsz.x + 2.0f * bpad);
const float bh = tsz.y + 2.0f * bpad;
const ImVec2 cur = ImGui::GetCursorScreenPos();
@@ -1087,8 +1161,8 @@ void RenderChatTab(App* app)
}
}
dl->AddRectFilled(bmin, bmax, bubCol, bround, rf);
dl->AddText(nameFont, bodySz, ImVec2(bx + bpad, cur.y + bpad), material::OnSurface(),
m.body.c_str(), nullptr, innerW);
layoutChatBody(dl, nameFont, bodySz, m.body, innerW, paraGap,
ImVec2(bx + bpad, cur.y + bpad), material::OnSurface());
// Peer avatar beside the LAST incoming bubble of a run (Tier 3).
if (!outgoing && lastInGroup) {
@@ -1188,6 +1262,7 @@ void RenderChatTab(App* app)
const float cx = boxMin.x;
const float cw = boxMax.x - boxMin.x;
if (sel->peerPubKey.empty()) {
s_composerTargetH = composerCtrlH; // no editable composer here — keep the strip collapsed
ImGui::SetCursorScreenPos(ImVec2(cx, boxMax.y + 8.0f * tdp));
ImGui::PushFont(metaFont);
ImGui::PushStyleColor(ImGuiCol_Text, material::OnSurfaceMedium());
@@ -1197,31 +1272,36 @@ void RenderChatTab(App* app)
ImGui::PopStyleColor();
ImGui::PopFont();
} else {
// Multi-line composer (Q6). Enter behavior is setting-dependent (see below). A live byte
// counter tracks the on-chain body cap = (512 len("utf8:"))/2 secretstream ABYTES.
static constexpr int kBodyMaxBytes = (512 - 5) / 2 - 17; // = 236 (see chat_outgoing.cpp)
// Multi-line composer (Q6). Enter behavior is setting-dependent (see below). Input is
// hard-capped to the on-chain body byte budget; usage is shown as a radial gauge in the box.
const int kBodyMaxBytes = kChatBodyMaxBytes;
const float sendW = 80.0f * tdp; // scaled so the translated label never clips (B3)
const float emojiBtn = 28.0f * tdp;
const float inGap = 6.0f * tdp;
const int used = static_cast<int>(std::strlen(s_compose));
const bool overCap = used > kBodyMaxBytes;
const bool overCap = used > kBodyMaxBytes; // defensive — the hard byte cap should prevent this
// Thin counter row (right-aligned) just under the box; over-cap tints + prefixes a label.
std::string counterLine = std::to_string(used) + " / " + std::to_string(kBodyMaxBytes);
if (overCap) counterLine = std::string(TR("chat_len_over")) + " " + counterLine;
const float cwid = metaFont->CalcTextSizeA(metaSz, FLT_MAX, 0.0f, counterLine.c_str()).x;
ImGui::GetWindowDrawList()->AddText(metaFont, metaSz,
ImVec2(cx + cw - cwid, boxMax.y + 3.0f * tdp),
overCap ? material::Error() : material::OnSurfaceMedium(), counterLine.c_str());
// Fraction of the on-chain cap used, for the radial gauge (drawn inside the input, below).
const float frac = kBodyMaxBytes > 0 ? static_cast<float>(used) / kBodyMaxBytes : 0.0f;
// Input row, flush with the strip/window bottom: [emoji] [input] [Send].
// Input row: [emoji] [input] [Send]. The input's TOP is pinned 10px below the message box and
// it grows DOWNWARD as it gets taller (the thread above shrinks), so the row bottom stays put.
const float rowY = boxMax.y + composerAreaH - composerBoxH;
const float inputX = cx + emojiBtn + inGap;
const float inputW = std::max(60.0f * tdp, cw - emojiBtn - 2.0f * inGap - sendW);
const float ctrlY = rowY + composerBoxH - composerCtrlH; // bottom-aligned row for side controls
// Radial byte gauge sits in the input box's bottom-right corner; a fixed small size (tied to
// the line height, NOT the animated box height) with a reserved right slot so text never
// reaches it. Geometry first so the input width floor can guarantee room for both.
const float ringR = std::max(7.0f, lineH * 0.42f);
const float ringPad = 9.0f * tdp;
const float ringSlot = 2.0f * ringR + ringPad * 1.6f;
const float inputW = std::max(ringSlot + 48.0f * tdp, cw - emojiBtn - 2.0f * inGap - sendW);
const float sendX = inputX + inputW + inGap;
const float textW = std::max(40.0f * tdp, inputW - ringSlot); // input area, left of the ring
const ImVec2 ringC(inputX + inputW - ringPad - ringR, rowY + composerBoxH - ringPad - ringR);
// Emoji toggle on the left, vertically centered in the input row.
ImGui::SetCursorScreenPos(ImVec2(cx, rowY + (composerBoxH - emojiBtn) * 0.5f));
// Emoji toggle on the left, bottom-aligned with the input row (centered in the control height).
ImGui::SetCursorScreenPos(ImVec2(cx, ctrlY + (composerCtrlH - emojiBtn) * 0.5f));
{
material::IconButtonStyle es;
es.hoverBg = material::WithAlpha(material::OnSurface(), 30);
@@ -1244,33 +1324,108 @@ void RenderChatTab(App* app)
ImGui::PushStyleColor(ImGuiCol_FrameBg, IM_COL32(0, 0, 0, 0));
ImGui::PushStyleColor(ImGuiCol_FrameBgHovered, IM_COL32(0, 0, 0, 0));
ImGui::PushStyleColor(ImGuiCol_FrameBgActive, IM_COL32(0, 0, 0, 0));
// FramePadding gives the text a left inset (so it doesn't hug the box edge) and, since the
// collapsed box is exactly one line + 2×VPad tall, vertically centers a single line.
ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(12.0f * tdp, composerVPad));
ImGui::SetCursorScreenPos(ImVec2(inputX, rowY));
// Enter-to-send (setting): on → Enter sends + Ctrl+Enter newline; off → Enter is a newline and
// the Send button is the only way to send. NOTE: without EnterReturnsTrue, InputTextMultiline
// returns true on EVERY edit (value-changed), so in off-mode the return value must NOT drive
// submit — otherwise typing one character would fire a send.
// Enter-to-send (setting): on → Enter sends, Shift+Enter makes a newline (Ctrl+Enter too); off
// → Enter is a newline and the Send button is the only way to send. EnterReturnsTrue needs
// CtrlEnterForNewLine alongside it or multiline plain-Enter inserts a '\n' instead of
// validating (imgui_widgets.cpp:5118). Shift+Enter is handled in composeInputCallback since
// ImGui's exact-modifier Enter shortcut ignores it. CallbackAlways runs that handler.
const bool enterSends = !app->settings() || app->settings()->getChatEnterSends();
const ImGuiInputTextFlags composeFlags = enterSends
? (ImGuiInputTextFlags_EnterReturnsTrue | ImGuiInputTextFlags_CtrlEnterForNewLine)
: ImGuiInputTextFlags_None;
const bool inputReturned = ImGui::InputTextMultiline("##compose", s_compose, sizeof(s_compose),
ImVec2(inputW, composerBoxH), composeFlags);
// WordWrap: long lines wrap to the next visual line (the box grows) instead of scrolling the
// text horizontally off the left edge.
ImGuiInputTextFlags composeFlags = ImGuiInputTextFlags_CallbackAlways | ImGuiInputTextFlags_WordWrap;
if (enterSends)
composeFlags |= ImGuiInputTextFlags_EnterReturnsTrue | ImGuiInputTextFlags_CtrlEnterForNewLine;
// Hard-cap the byte length at the on-chain budget: passing kBodyMaxBytes+1 as the buffer size
// stops ImGui from accepting more input (UTF-8-safe), so the user can't type past the cap.
const bool inputReturned = ImGui::InputTextMultiline("##compose", s_compose, kBodyMaxBytes + 1,
ImVec2(textW, composerBoxH), composeFlags, composeInputCallback);
ImGui::PopStyleVar(); // FramePadding
ImGui::PopStyleColor(3); // composer FrameBg (transparent — glass shows through)
bool submit = enterSends && inputReturned; // off-mode sends only via the Send button
// Send.
ImGui::SetCursorScreenPos(ImVec2(sendX, rowY));
// Measure the wrapped content height to drive the auto-grow (consumed next frame at the top).
// Wrap width mirrors ImGui's internal InputText wrap = frame inner width (textW 2×FramePadding.x),
// so CalcTextSize's wrap matches what the widget renders.
{
const float wrapW = std::max(1.0f, textW - 2.0f * 12.0f * tdp);
float contentH = (s_compose[0] == '\0') ? lineH
: ImGui::CalcTextSize(s_compose, nullptr, false, wrapW).y;
const size_t clen = std::strlen(s_compose);
if (clen > 0 && s_compose[clen - 1] == '\n') contentH += lineH; // trailing empty line
contentH = std::min(contentH, kMaxComposerLines * lineH);
s_composerTargetH = std::max(lineH, contentH) + 2.0f * composerVPad;
}
// ── Radial byte gauge (replaces the old numeric counter): a ring in the input's right edge
// that fills clockwise and shifts green→amber→red as the message nears the on-chain cap.
// Full + red at/over the cap (Send is disabled there); exact count shows on hover.
{
ImDrawList* gdl = ImGui::GetWindowDrawList();
auto lerpCol = [](ImU32 a, ImU32 b, float t) {
if (t < 0.0f) t = 0.0f; else if (t > 1.0f) t = 1.0f;
const ImVec4 fa = ImGui::ColorConvertU32ToFloat4(a);
const ImVec4 fb = ImGui::ColorConvertU32ToFloat4(b);
return ImGui::GetColorU32(ImVec4(fa.x + (fb.x - fa.x) * t, fa.y + (fb.y - fa.y) * t,
fa.z + (fb.z - fa.z) * t, fa.w + (fb.w - fa.w) * t));
};
const ImU32 green = material::Success(), amber = material::Warning(), red = material::Error();
ImU32 fillCol;
if (frac < 0.75f) fillCol = green; // calm until genuinely near the cap
else if (frac < 1.0f) { const float t = (frac - 0.75f) / 0.25f;
fillCol = (t < 0.5f) ? lerpCol(green, amber, t / 0.5f)
: lerpCol(amber, red, (t - 0.5f) / 0.5f); }
else fillCol = red; // at/over cap
const float thick = std::max(2.0f, 2.4f * tdp);
const float dispFrac = frac < 0.0f ? 0.0f : (frac > 1.0f ? 1.0f : frac);
// Faint full-ring track (alpha kept high enough to read on light skins too).
gdl->PathArcTo(ringC, ringR, 0.0f, IM_PI * 2.0f, 48);
gdl->PathStroke(material::WithAlpha(material::OnSurface(), 42), 0, thick);
// Progress arc from 12 o'clock, clockwise, with a rounded head.
if (dispFrac > 0.0001f) {
const float a0 = -IM_PI * 0.5f;
const float a1 = a0 + dispFrac * IM_PI * 2.0f;
gdl->PathArcTo(ringC, ringR, a0, a1, 48);
gdl->PathStroke(fillCol, 0, thick);
gdl->AddCircleFilled(ImVec2(ringC.x + std::cos(a1) * ringR, ringC.y + std::sin(a1) * ringR),
thick * 0.6f, fillCol, 10);
}
// Exact count on hover (progressive disclosure; over-cap prefixes a label).
const float hitR = ringR + thick;
if (ImGui::IsMouseHoveringRect(ImVec2(ringC.x - hitR, ringC.y - hitR),
ImVec2(ringC.x + hitR, ringC.y + hitR))) {
std::string tip = std::to_string(used) + " / " + std::to_string(kBodyMaxBytes);
if (overCap) tip = std::string(TR("chat_len_over")) + " " + tip;
ImGui::BeginTooltip();
ImGui::TextUnformatted(tip.c_str());
ImGui::EndTooltip();
}
}
// Off-mode sends only via the Send button. In enter-sends mode EnterReturnsTrue makes the
// return value mean "validated" (plain Enter only) — Shift+Enter/edits don't set it.
bool submit = enterSends && inputReturned;
// Send — fixed height, bottom-aligned with the input row (doesn't stretch when the box grows).
ImGui::SetCursorScreenPos(ImVec2(sendX, ctrlY));
ImGui::PushStyleColor(ImGuiCol_Button, ImGui::ColorConvertU32ToFloat4(material::WithAlpha(material::Primary(), 205)));
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImGui::ColorConvertU32ToFloat4(material::Primary()));
ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImGui::ColorConvertU32ToFloat4(material::WithAlpha(material::Primary(), 235)));
ImGui::BeginDisabled(overCap || s_compose[0] == '\0');
if (material::TactileButton(TR("chat_send"), ImVec2(sendW, composerBoxH))) submit = true;
if (material::TactileButton(TR("chat_send"), ImVec2(sendW, composerCtrlH))) submit = true;
ImGui::EndDisabled();
// When Send is disabled *because the message is over the cap*, explain why on hover — the
// gauge went red but the numeric reason is no longer always-visible.
if (overCap && ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled))
ImGui::SetTooltip("%s %d / %d", TR("chat_len_over"), used, kBodyMaxBytes);
ImGui::PopStyleColor(3);
if (submit && s_compose[0] != '\0' && !overCap) {
app->sendChatMessage(sel->cid, s_compose);
sodium_memzero(s_compose, sizeof(s_compose));
s_scroll_to_cid = sel->cid;
s_composerAnimH = 0.0f; // snap back to collapsed instead of animating while unfocused
}
}
}