feat(chat): thread UX — auto-scroll, selectable text, clickable Latest, emoji polish
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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<ChatBodyLine> chatBodyLines(ImFont* font, float size, const std::string& body,
|
||||
float wrapW, float paraGap) {
|
||||
std::vector<ChatBodyLine> 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<std::size_t>(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<unsigned char>(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<std::size_t>(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<unsigned char>(buf[cur - 1]) > ' ';
|
||||
const std::size_t pad = needsSpace ? 1 : 0;
|
||||
if (cur + pad + add <= static_cast<std::size_t>(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<int>(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<int>(mi));
|
||||
if (ImGui::IsMouseHoveringRect(bmin, bmax) && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) {
|
||||
s_msgsel_cid = s_selected_cid; s_msgsel_index = static_cast<int>(mi);
|
||||
s_msgsel_anchor = s_msgsel_head = static_cast<int>(
|
||||
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<int>(
|
||||
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<int>(ln.b));
|
||||
const int le = std::min(b2, static_cast<int>(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<int>(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';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user