diff --git a/src/ui/windows/chat_tab.cpp b/src/ui/windows/chat_tab.cpp index 8d609bd..aa493bf 100644 --- a/src/ui/windows/chat_tab.cpp +++ b/src/ui/windows/chat_tab.cpp @@ -26,6 +26,7 @@ #include #include #include +#include // std::cos/std::sin — radial byte gauge in the composer #include #include #include @@ -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(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(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(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 } } }