From 0a4374289756669505a265bd7820eda44e639839 Mon Sep 17 00:00:00 2001 From: DanS Date: Fri, 17 Jul 2026 22:35:03 -0500 Subject: [PATCH] feat(chat): side-by-side settings modal with live conversation preview Widen the chat settings modal and split it into a live message preview (canned bubbles rendered with the real style/accent/density/font/emoji/timestamp code) beside the controls; size the Done button to its text. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/ui/windows/chat_tab.cpp | 160 ++++++++++++++++++++++++++++++++++-- 1 file changed, 151 insertions(+), 9 deletions(-) diff --git a/src/ui/windows/chat_tab.cpp b/src/ui/windows/chat_tab.cpp index fea4973..1d8b163 100644 --- a/src/ui/windows/chat_tab.cpp +++ b/src/ui/windows/chat_tab.cpp @@ -191,6 +191,121 @@ std::string initialOf(const std::string& name) { return name.substr(i, std::min(len, name.size() - i)); } +// Live "conversation preview" for the chat settings modal. Renders a few canned bubbles with the SAME +// style / accent / fill / rounding / density / font-size / timestamp / emoji code the real message loop +// uses (see the loop in RenderChatTab), so the modal shows exactly what chat will look like. Purely +// presentational — no hover/click handlers. Reserves a `width`-wide, content-tall block via a Dummy. +void RenderChatSettingsPreview(App* app, float width) { + auto* cs = app ? app->settings() : nullptr; + if (!cs) return; + const float dp = Layout::dpiScale(); + ImDrawList* dl = ImGui::GetWindowDrawList(); + + // ---- Settings-derived draw params (mirror the real loop's formulas exactly). ---- + const int bubbleStyle = cs->getChatBubbleStyle(); // 0 rounded, 1 square, 2 minimal + const bool compact = cs->getChatDensity() == 1; + const float fscale = cs->getChatFontScale(); + ImFont* nameFont = material::Type().body2(); + ImFont* metaFont = material::Type().caption(); + const float nameSz = scaledSize(nameFont); + const float metaSz = scaledSize(metaFont); + const float bodySz = nameSz * (18.0f / std::max(1.0f, Layout::kFontBody2())) * fscale; + const float bpad = (compact ? 6.0f : 9.0f) * dp; + const float bround = (bubbleStyle == 1 ? 2.0f : 9.0f) * dp; + const int outFill = (bubbleStyle == 2 ? 26 : 46); + const int inFill = (bubbleStyle == 2 ? 14 : 22); + const float groupGap = (compact ? 4.0f : 7.0f) * dp; + const float msgGap = (compact ? 2.0f : 3.0f) * dp; + const ImU32 accentBase = bubbleAccentColor(cs->getChatBubbleAccent()); + const float avR = 11.0f * dp; + const float gutter = 2.0f * avR + 6.0f * dp; + + // Resolve the chat clock the same way line ~379 does, but locally so the preview reflects the + // timestamp control the SAME frame it changes (0 = follow global, 1 = 24h, 2 = 12h). + const int ctf = cs->getChatTimeFormat(); + const bool prev12h = (ctf == 2) || (ctf == 0 && cs->getTimeFormat() == 1); + const char* t1 = prev12h ? "9:41 AM" : "09:41"; + const char* t2 = prev12h ? "9:42 AM" : "09:42"; + + // Canned conversation: one incoming bubble + a grouped outgoing pair (to show the flattened corner + // of the rounded/minimal styles). Emoji in the bodies show the mono/color emoji style. + struct PMsg { const char* body; bool outgoing; bool startGroup; bool lastInGroup; std::string meta; }; + const std::string peer = "Ava"; + const PMsg msgs[] = { + { u8"Did the payment go through? \U0001F642", false, true, true, peer + " " + t1 }, + { u8"Yep — just confirmed ✅", true, true, false, std::string(TR("chat_you")) + " " + t2 }, + { u8"Sending the rest now \U0001F44D", true, false, true, std::string() }, + }; + const int N = 3; + + // Panel geometry + per-message layout (pre-measured so the glass panel is drawn behind at the exact + // height, since the draw list has no retained ordering). + const float padIn = 10.0f * dp; + const float contentW = std::max(80.0f * dp, width - 2.0f * padIn); + const float maxBubbleW = std::max(120.0f * dp, contentW * 0.80f); + const float innerW = maxBubbleW - 2.0f * bpad; + struct PLay { ImVec2 tsz; float bw; float bh; }; + PLay lay[3]; + float totalH = padIn; + for (int i = 0; i < N; ++i) { + if (msgs[i].startGroup) totalH += (i > 0 ? groupGap : 0.0f) + metaSz + 3.0f * dp; + else totalH += msgGap; + lay[i].tsz = nameFont->CalcTextSizeA(bodySz, innerW, innerW, msgs[i].body); + lay[i].bw = std::min(maxBubbleW, lay[i].tsz.x + 2.0f * bpad); + lay[i].bh = lay[i].tsz.y + 2.0f * bpad; + totalH += lay[i].bh; + } + totalH += padIn; + + const ImVec2 origin = ImGui::GetCursorScreenPos(); + material::GlassPanelSpec g; g.rounding = 10.0f * dp; g.fillAlpha = 16; g.borderAlpha = 36; + material::DrawGlassPanel(dl, origin, ImVec2(origin.x + width, origin.y + totalH), g); + + const float leftX = origin.x + padIn; + const float rowW = contentW; + float cy = origin.y + padIn; + for (int i = 0; i < N; ++i) { + const bool outgoing = msgs[i].outgoing; + if (msgs[i].startGroup) { + if (i > 0) cy += groupGap; + const ImVec2 msz = metaFont->CalcTextSizeA(metaSz, FLT_MAX, 0.0f, msgs[i].meta.c_str()); + const float mtx = outgoing ? (leftX + rowW - msz.x) : (leftX + gutter); + dl->AddText(metaFont, metaSz, ImVec2(mtx, cy), material::OnSurfaceMedium(), msgs[i].meta.c_str()); + cy += metaSz + 3.0f * dp; + } else { + cy += msgGap; + } + const float bw = lay[i].bw, bh = lay[i].bh; + const float bx = outgoing ? (leftX + rowW - bw) : (leftX + gutter); + const ImVec2 bmin(bx, cy), bmax(bx + bw, cy + bh); + const ImU32 bubCol = outgoing ? material::WithAlpha(accentBase, outFill) + : material::WithAlpha(material::OnSurface(), inFill); + ImDrawFlags rf = ImDrawFlags_RoundCornersAll; + if (bubbleStyle != 1) { // square keeps uniform corners; rounded/minimal flatten stacked corners + if (outgoing) { + if (!msgs[i].startGroup) rf &= ~ImDrawFlags_RoundCornersTopRight; + if (!msgs[i].lastInGroup) rf &= ~ImDrawFlags_RoundCornersBottomRight; + } else { + if (!msgs[i].startGroup) rf &= ~ImDrawFlags_RoundCornersTopLeft; + if (!msgs[i].lastInGroup) rf &= ~ImDrawFlags_RoundCornersBottomLeft; + } + } + dl->AddRectFilled(bmin, bmax, bubCol, bround, rf); + dl->AddText(nameFont, bodySz, ImVec2(bx + bpad, cy + bpad), material::OnSurface(), + msgs[i].body, nullptr, innerW); + if (!outgoing && msgs[i].lastInGroup) { // peer avatar beside the last incoming bubble of the run + const ImVec2 avc(leftX + avR, cy + bh - avR); + dl->AddCircleFilled(avc, avR, avatarColor("preview:" + peer), 20); + const std::string init = initialOf(peer); + const ImVec2 isz = metaFont->CalcTextSizeA(metaSz, FLT_MAX, 0.0f, init.c_str()); + dl->AddText(metaFont, metaSz, ImVec2(avc.x - isz.x * 0.5f, avc.y - isz.y * 0.5f), + IM_COL32(255, 255, 255, 235), init.c_str()); + } + cy += bh; + } + ImGui::Dummy(ImVec2(width, totalH)); +} + // Render a peer identity key (hex) as a groupable fingerprint for the verify tooltip: uppercased, // spaced every 4 hex chars, wrapped every 16 (8 bytes) so two users can read it off line by line. std::string keyFingerprint(const std::string& hex) { @@ -1195,16 +1310,43 @@ void RenderChatTab(App* app) ov.title = TR("chat_settings_title"); ov.p_open = &s_show_chat_settings; // X / backdrop closes it ov.style = material::OverlayStyle::BlurFloat; - ov.cardWidth = 520.0f; ov.idSuffix = "chatsettings"; + // Wider than tall: a live preview sits to the LEFT of the controls (more horizontal room to work + // with than vertical). Both columns AutoResizeY so neither the preview nor the controls clip. + ov.cardWidth = 800.0f; ov.idSuffix = "chatsettings"; if (material::BeginOverlayDialog(ov)) { - RenderChatSettingsControls(app); - ImGui::Dummy(ImVec2(0.0f, 8.0f * Layout::dpiScale())); - 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))); - if (material::TactileButton(TR("chat_settings_done"), ImVec2(ImGui::GetContentRegionAvail().x, 0))) - s_show_chat_settings = false; - ImGui::PopStyleColor(3); + const float dp = Layout::dpiScale(); + const float colGap = 16.0f * dp; + const float fullW = ImGui::GetContentRegionAvail().x; + const float previewW = std::min(340.0f * dp, std::max(240.0f * dp, fullW * 0.40f)); + const float controlsW = std::max(300.0f * dp, fullW - previewW - colGap); + + ImGui::BeginChild("##chatPreviewCol", ImVec2(previewW, 0.0f), ImGuiChildFlags_AutoResizeY, + ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse); + RenderChatSettingsPreview(app, ImGui::GetContentRegionAvail().x); + ImGui::EndChild(); + + ImGui::SameLine(0.0f, colGap); + + ImGui::BeginChild("##chatControlsCol", ImVec2(controlsW, 0.0f), ImGuiChildFlags_AutoResizeY, + ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse); + RenderChatSettingsControls(app, 0.0f); + ImGui::EndChild(); + + ImGui::Dummy(ImVec2(0.0f, 8.0f * dp)); + { + // Done: sized to its text (+ a little shoulder room) and centered, not full width. + const char* doneLbl = TR("chat_settings_done"); + ImGui::PushFont(material::Type().button()); + const float bw = ImGui::CalcTextSize(doneLbl).x + ImGui::GetStyle().FramePadding.x * 2.0f + 28.0f * dp; + ImGui::PopFont(); + ImGui::SetCursorPosX(ImGui::GetCursorPosX() + std::max(0.0f, (ImGui::GetContentRegionAvail().x - bw) * 0.5f)); + 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))); + if (material::TactileButton(doneLbl, ImVec2(bw, 0))) + s_show_chat_settings = false; + ImGui::PopStyleColor(3); + } material::EndOverlayDialog(); } }