From 57fa6470b723dcd9d2898f2ddb510aefff67c4e0 Mon Sep 17 00:00:00 2001 From: DanS Date: Mon, 20 Jul 2026 23:04:26 -0500 Subject: [PATCH] =?UTF-8?q?feat(chat):=20thread=20UX=20=E2=80=94=20auto-sc?= =?UTF-8?q?roll,=20selectable=20text,=20clickable=20Latest,=20emoji=20poli?= =?UTF-8?q?sh?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five message-thread improvements: - Auto-scroll like the Console tab: new messages pin to the bottom only while the reader is already there; a wheel-up detaches (re-armed on return to the bottom, after a cooldown). A direct mouse-in-rect test (not IsWindowHovered, which is false while the composer holds focus) matches ApplySmoothScroll so scrolling up to read history while typing still detaches. - The "Latest" jump pill is now hand-drawn (rect + IsMouseHoveringRect / IsMouseClicked) instead of ImGui::Button: it overlaps the thread child which owns mouse-hover there, so a real widget on the parent never got the click. Clicking it re-arms auto-scroll; its geometry is shared with the deselect guard so a pill click no longer drops an active selection. - Sending a message closes the emoji picker (it takes over the conversation list) so the list reappears, and clears its search filter. - Inserting an emoji prepends a space when the draft doesn't already end in whitespace (still respecting the on-chain byte cap). - Message text is selectable by click-drag (chatBodyLines / chatBodyHitTest mirror layoutChatBody's wrap so highlight + hit-test align with the drawn text); while a range is active a copy button shows at the bubble's top on the opposite side and copies the selected substring. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/ui/windows/chat_tab.cpp | 223 +++++++++++++++++++++++++++++++++--- 1 file changed, 206 insertions(+), 17 deletions(-) diff --git a/src/ui/windows/chat_tab.cpp b/src/ui/windows/chat_tab.cpp index aa493bf..4a5fe7b 100644 --- a/src/ui/windows/chat_tab.cpp +++ b/src/ui/windows/chat_tab.cpp @@ -42,6 +42,19 @@ namespace { std::string s_selected_cid; std::string s_scroll_to_cid; // when set, scroll the thread to the bottom next frame +// Auto-scroll: keep the thread pinned to the newest message only while the user is at the bottom. +// A wheel-up detaches it (mirrors console_tab.cpp's ScrollController); returning to the bottom re-arms it. +bool s_chat_auto_scroll = true; +float s_chat_scroll_cooldown = 0.0f; // seconds left before an at-bottom position may re-arm auto-scroll + +// Message text selection: click-drag inside a bubble selects a byte range of its body; while a range is +// active a small copy button appears at the bubble's top on the opposite side. Offsets index m.body bytes. +std::string s_msgsel_cid; // conversation of the selected message ("" = no selection) +int s_msgsel_index = -1; // message index within that conversation +int s_msgsel_anchor = 0; // drag-start byte offset +int s_msgsel_head = 0; // drag-current byte offset +bool s_msgsel_dragging = false; + // 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 @@ -214,6 +227,67 @@ ImVec2 layoutChatBody(ImDrawList* dl, ImFont* font, float size, const std::strin return ImVec2(maxW, y); } +// One wrapped visual line of a laid-out chat body: byte range [b,e) into body + its y-offset from the +// body origin (each line is `size` tall). Mirrors layoutChatBody's paragraph/wrap walk so selection +// hit-testing and highlighting align with the drawn text. +struct ChatBodyLine { std::size_t b, e; float y; }; + +std::vector chatBodyLines(ImFont* font, float size, const std::string& body, + float wrapW, float paraGap) { + std::vector out; + const float scale = size / std::max(1.0f, font->LegacySize); + float y = 0.0f; bool first = true; std::size_t start = 0; + for (;;) { + const std::size_t nl = body.find('\n', start); + const bool last = (nl == std::string::npos); + const std::size_t pend = last ? body.size() : nl; + if (!first) y += paraGap; + std::size_t p = start; + if (p >= pend) { out.push_back({p, p, y}); y += size; } // blank paragraph = one empty line + else { + while (p < pend) { + const char* w = font->CalcWordWrapPositionA(scale, body.c_str() + p, body.c_str() + pend, wrapW); + std::size_t we = (w > body.c_str() + p) ? static_cast(w - body.c_str()) : p + 1; + if (we > pend) we = pend; + out.push_back({p, we, y}); + y += size; + p = we; + while (p < pend && body[p] == ' ') ++p; // AddText skips blanks at a wrap; match it + } + } + first = false; + if (last) break; + start = nl + 1; + } + return out; +} + +// Advance one UTF-8 codepoint (byte offset) within [.. limit). +inline std::size_t chatNextCp(const std::string& s, std::size_t i, std::size_t limit) { + if (i >= limit) return limit; + ++i; + while (i < limit && (static_cast(s[i]) & 0xC0) == 0x80) ++i; + return i; +} + +// Map a point to the nearest body byte offset (for drag selection). origin is the body's top-left. +std::size_t chatBodyHitTest(ImFont* font, float size, const std::string& body, float wrapW, + float paraGap, ImVec2 origin, ImVec2 pt) { + const auto lines = chatBodyLines(font, size, body, wrapW, paraGap); + if (lines.empty()) return 0; + const ChatBodyLine* L = &lines.front(); + for (const auto& ln : lines) { if (pt.y >= origin.y + ln.y) L = &ln; else break; } + const float relX = pt.x - origin.x; + std::size_t best = L->b; float bestDx = 1e9f; + for (std::size_t i = L->b;; i = chatNextCp(body, i, L->e)) { + const float w = font->CalcTextSizeA(size, FLT_MAX, 0.0f, body.c_str() + L->b, body.c_str() + i).x; + const float dx = std::fabs(w - relX); + if (dx < bestDx) { bestDx = dx; best = i; } + if (i >= L->e) break; + } + return best; +} + // Outgoing-bubble accent base color for a chat_bubble_accent preset (0 = theme primary). ImU32 bubbleAccentColor(int accent) { switch (accent) { @@ -514,10 +588,16 @@ 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); - // 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'; + // Prepend a space to the LEFT of the emoji when the draft isn't empty and doesn't already + // end in whitespace (so emojis after words get a separating space, but a leading one isn't + // added at the start). 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. + const bool needsSpace = cur > 0 && static_cast(buf[cur - 1]) > ' '; + const std::size_t pad = needsSpace ? 1 : 0; + if (cur + pad + add <= static_cast(kChatBodyMaxBytes) && cur + pad + add < bufSize) { + if (needsSpace) buf[cur] = ' '; + std::memcpy(buf + cur + pad, e.glyph, add); + buf[cur + pad + add] = '\0'; } } ImGui::PopID(); @@ -1049,9 +1129,32 @@ void RenderChatTab(App* app) ImGui::BeginChild("##ChatMessages", ImVec2(0, ImGui::GetContentRegionAvail().y), ImGuiChildFlags_AlwaysUseWindowPadding, ImGuiWindowFlags_NoScrollWithMouse); material::ApplySmoothScroll(); // smooth wheel scroll; syncs with the auto-scroll-to-bottom below + // Detach auto-scroll when the user wheels up inside the thread; re-armed once back at the bottom + // (below). The cooldown lets the smooth-scroll animate away before we re-check position. A direct + // mouse-in-rect test (not IsWindowHovered, which is false while the composer holds focus) matches + // ApplySmoothScroll, so scrolling up to read history while the composer is focused still detaches. + { + const ImVec2 wpos = ImGui::GetWindowPos(), wsz = ImGui::GetWindowSize(); + const ImVec2 mp = ImGui::GetIO().MousePos; + const bool inThread = mp.x >= wpos.x && mp.x < wpos.x + wsz.x && + mp.y >= wpos.y && mp.y < wpos.y + wsz.y; + if (inThread && ImGui::GetIO().MouseWheel > 0.0f) { + s_chat_auto_scroll = false; + s_chat_scroll_cooldown = 0.3f; + } + } + if (s_chat_scroll_cooldown > 0.0f) s_chat_scroll_cooldown -= ImGui::GetIO().DeltaTime; + // Was the thread at the bottom coming into this frame? (measured before this frame's content is + // laid out, so newly-arrived messages don't yank a scrolled-up reader.) + const float preScrollMaxY = ImGui::GetScrollMaxY(); + const bool wasAtBottom = preScrollMaxY <= 0.0f || ImGui::GetScrollY() >= preScrollMaxY - 4.0f; bool atBottom = true; const ImVec2 msgWinMin = ImGui::GetWindowPos(); const ImVec2 msgWinSize = ImGui::GetWindowSize(); + // Jump-to-latest pill geometry — computed here so the clear-on-empty-click below can exclude + // the pill (a pill click shouldn't drop an active selection) and the pill draw below reuses it. + const ImVec2 jumpPillMin(msgWinMin.x + msgWinSize.x - 88.0f * dp, msgWinMin.y + msgWinSize.y - 34.0f * dp); + const ImVec2 jumpPillMax(jumpPillMin.x + 78.0f * dp, jumpPillMin.y + 26.0f * dp); { ImDrawList* dl = ImGui::GetWindowDrawList(); // Bubble style / accent / density / text-size from chat customization settings. @@ -1083,6 +1186,7 @@ void RenderChatTab(App* app) ImFont* icf = material::Type().iconSmall(); const float icfSz = scaledSize(icf); std::int64_t prevTs = -1; int prevDir = -1; std::int64_t prevDay = -1; + bool anyMsgClicked = false; // did a left-click this frame land on a bubble / copy button? for (std::size_t mi = 0; mi < messages.size(); ++mi) { const auto& m = messages[mi]; ImGui::PushID(static_cast(mi)); @@ -1161,6 +1265,39 @@ void RenderChatTab(App* app) } } dl->AddRectFilled(bmin, bmax, bubCol, bround, rf); + + // ── Text selection: click-drag inside the bubble selects a byte range of the body. + const ImVec2 bodyOrigin(bx + bpad, cur.y + bpad); + const bool selfSel = (s_msgsel_cid == s_selected_cid && s_msgsel_index == static_cast(mi)); + if (ImGui::IsMouseHoveringRect(bmin, bmax) && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) { + s_msgsel_cid = s_selected_cid; s_msgsel_index = static_cast(mi); + s_msgsel_anchor = s_msgsel_head = static_cast( + chatBodyHitTest(nameFont, bodySz, m.body, innerW, paraGap, bodyOrigin, ImGui::GetMousePos())); + s_msgsel_dragging = true; + anyMsgClicked = true; + } + if (selfSel && s_msgsel_dragging) { + if (ImGui::IsMouseDown(ImGuiMouseButton_Left)) + s_msgsel_head = static_cast( + chatBodyHitTest(nameFont, bodySz, m.body, innerW, paraGap, bodyOrigin, ImGui::GetMousePos())); + else + s_msgsel_dragging = false; + } + // Highlight the selected range (behind the text) — one rect per wrapped line segment. + if (selfSel && s_msgsel_anchor != s_msgsel_head) { + const int a = std::min(s_msgsel_anchor, s_msgsel_head); + const int b2 = std::max(s_msgsel_anchor, s_msgsel_head); + for (const auto& ln : chatBodyLines(nameFont, bodySz, m.body, innerW, paraGap)) { + const int ls = std::max(a, static_cast(ln.b)); + const int le = std::min(b2, static_cast(ln.e)); + if (ls >= le) continue; + const float x0 = nameFont->CalcTextSizeA(bodySz, FLT_MAX, 0.0f, m.body.c_str() + ln.b, m.body.c_str() + ls).x; + const float x1 = nameFont->CalcTextSizeA(bodySz, FLT_MAX, 0.0f, m.body.c_str() + ln.b, m.body.c_str() + le).x; + dl->AddRectFilled(ImVec2(bodyOrigin.x + x0, bodyOrigin.y + ln.y), + ImVec2(bodyOrigin.x + x1, bodyOrigin.y + ln.y + bodySz), + material::WithAlpha(material::Primary(), 80)); + } + } layoutChatBody(dl, nameFont, bodySz, m.body, innerW, paraGap, ImVec2(bx + bpad, cur.y + bpad), material::OnSurface()); @@ -1189,6 +1326,31 @@ void RenderChatTab(App* app) ImGui::EndPopup(); } + // Copy button for an active selection — at the bubble TOP, on the OPPOSITE side (an + // incoming bubble sits left → button to its right; an outgoing bubble sits right → + // button to its left). Copies the selected substring of the body. + if (selfSel && s_msgsel_anchor != s_msgsel_head) { + const int a = std::min(s_msgsel_anchor, s_msgsel_head); + const int b2 = std::max(s_msgsel_anchor, s_msgsel_head); + const float cbs = 22.0f * dp; + const float cbx = outgoing ? (bmin.x - cbs - 4.0f * dp) : (bmax.x + 4.0f * dp); + const ImVec2 cbmin(cbx, bmin.y), cbmax(cbx + cbs, bmin.y + cbs); + const bool cbhov = ImGui::IsMouseHoveringRect(cbmin, cbmax); + dl->AddRectFilled(cbmin, cbmax, cbhov ? material::Primary() + : material::WithAlpha(material::Primary(), 210), 5.0f * dp); + const float gs = icfSz * 0.78f; + const ImVec2 gsz = icf->CalcTextSizeA(gs, FLT_MAX, 0.0f, ICON_MD_CONTENT_COPY); + dl->AddText(icf, gs, ImVec2(cbmin.x + (cbs - gsz.x) * 0.5f, cbmin.y + (cbs - gsz.y) * 0.5f), + IM_COL32(255, 255, 255, 235), ICON_MD_CONTENT_COPY); + if (cbhov) { + ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); + if (ImGui::IsMouseClicked(ImGuiMouseButton_Left)) { + ImGui::SetClipboardText(m.body.substr(a, b2 - a).c_str()); + anyMsgClicked = true; + } + } + } + // Delivery status for your messages: clock while sending, check once broadcast (Tier 2). if (outgoing && !failed && (sending || lastInGroup)) { const char* glyph = sending ? ICON_MD_SCHEDULE : ICON_MD_DONE; @@ -1221,26 +1383,49 @@ void RenderChatTab(App* app) ImGui::PopID(); prevTs = m.timestamp; prevDir = static_cast(outgoing); prevDay = day; } - if (s_scroll_to_cid == s_selected_cid) { + // A left-click inside the thread that hit no bubble or copy button clears the selection — + // except a click on the jump-to-latest pill, which is navigation, not a deselect. + if (ImGui::IsMouseClicked(ImGuiMouseButton_Left) && !anyMsgClicked && ImGui::IsWindowHovered() + && !ImGui::IsMouseHoveringRect(jumpPillMin, jumpPillMax)) { + s_msgsel_cid.clear(); s_msgsel_index = -1; s_msgsel_dragging = false; + } + // Pin to the newest message when explicitly requested (send / conversation switch) or when + // auto-scroll is armed and the thread was already at the bottom (so new messages follow). + if (s_scroll_to_cid == s_selected_cid || (s_chat_auto_scroll && wasAtBottom)) { ImGui::SetScrollHereY(1.0f); s_scroll_to_cid.clear(); } - atBottom = ImGui::GetScrollY() >= ImGui::GetScrollMaxY() - 4.0f; + atBottom = ImGui::GetScrollMaxY() <= 0.0f || ImGui::GetScrollY() >= ImGui::GetScrollMaxY() - 4.0f; + // Re-arm auto-scroll once the user returns to the bottom (after the wheel cooldown). + if (atBottom && s_chat_scroll_cooldown <= 0.0f) s_chat_auto_scroll = true; } ImGui::EndChild(); ImGui::PopStyleVar(2); // ##ChatMessages WindowPadding + zeroed vertical ItemSpacing - // Jump-to-latest pill when scrolled up (Q9). SetCursorScreenPos moves the parent cursor to - // the child's bottom edge; save + restore it so the composer footer below stays anchored - // (otherwise it renders ~8px too high only while the pill is shown). + // Jump-to-latest pill when scrolled up (Q9). Hand-drawn (rect + IsMouseHoveringRect) rather than + // ImGui::Button: the pill overlaps the ##ChatMessages child, which owns mouse-hover in that + // region, so a real widget on the parent window never registers the click. IsMouseHoveringRect / + // IsMouseClicked test the cursor position directly, independent of window hover ownership. if (!atBottom) { - const ImVec2 savedCursor = ImGui::GetCursorScreenPos(); - const ImVec2 pill(msgWinMin.x + msgWinSize.x - 84.0f * dp, msgWinMin.y + msgWinSize.y - 34.0f * dp); - ImGui::SetCursorScreenPos(pill); - ImGui::PushStyleColor(ImGuiCol_Button, ImGui::ColorConvertU32ToFloat4(material::WithAlpha(material::Primary(), 220))); - ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImGui::ColorConvertU32ToFloat4(material::Primary())); - if (ImGui::Button(TR("chat_jump_latest"), ImVec2(74.0f * dp, 26.0f * dp))) s_scroll_to_cid = sel->cid; - ImGui::PopStyleColor(2); - ImGui::SetCursorScreenPos(savedCursor); + ImDrawList* pdl = ImGui::GetWindowDrawList(); + const ImVec2 pmin = jumpPillMin, pmax = jumpPillMax; + const float pw = pmax.x - pmin.x, ph = pmax.y - pmin.y; + const bool hov = ImGui::IsMouseHoveringRect(pmin, pmax); + pdl->AddRectFilled(pmin, pmax, + hov ? material::Primary() : material::WithAlpha(material::Primary(), 220), + ph * 0.5f); + ImFont* lf = ImGui::GetFont(); const float lsz = ImGui::GetFontSize(); + const char* lbl = TR("chat_jump_latest"); + const ImVec2 tszp = lf->CalcTextSizeA(lsz, FLT_MAX, 0.0f, lbl); + pdl->AddText(lf, lsz, ImVec2(pmin.x + (pw - tszp.x) * 0.5f, pmin.y + (ph - tszp.y) * 0.5f), + IM_COL32(255, 255, 255, 235), lbl); + if (hov) { + ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); + if (ImGui::IsMouseClicked(ImGuiMouseButton_Left)) { + s_scroll_to_cid = sel->cid; + s_chat_auto_scroll = true; // clicking Latest re-arms follow-the-newest + s_chat_scroll_cooldown = 0.0f; + } + } } // Composer is rendered OUTSIDE this box, directly below it (see after EndChild). } else { @@ -1426,6 +1611,10 @@ void RenderChatTab(App* app) 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 + // Sending closes the emoji picker (it takes over the conversation-list pane) so the list + // reappears, and clears its search filter. + s_show_emoji_picker = false; + s_emoji_search[0] = '\0'; } } }