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) <noreply@anthropic.com>
This commit is contained in:
2026-07-17 22:35:03 -05:00
parent defe14d913
commit 0a43742897

View File

@@ -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();
}
}