feat(daemon-update): render release notes as markdown + tactile Material cards

Refine the two-pane node updater:
- Release notes now render as in-app markdown (new src/ui/material/markdown.h):
  # / ## / ### headings, **bold**, `inline code` (tinted + subtle pill), and
  - / * / + bullets, word-wrapped with mixed fonts via the draw list. cleanNotes
  keeps the markdown now (only the trailing checksum table is cut), so the
  renderer styles it.
- Each version is a tactile Material card — filled rounded surface with
  hover/press/selected states (primary-tinted + outlined when selected), a
  rounded status accent bar, tag + latest/installed/pre-release badges + date —
  instead of a plain Selectable row.
- Dropped the thin 1px outlines: the version list is borderless and the notes
  sit on a filled, rounded Material surface.

The sweep's fake release body gains bold/code/multi-heading markdown to exercise
the renderer. Verified 100% + 150%, dark + light.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-10 16:44:25 -05:00
parent c6624913de
commit 6b7438b529
3 changed files with 208 additions and 32 deletions

View File

@@ -24,6 +24,7 @@
#include "../../util/i18n.h"
#include "../material/colors.h"
#include "../material/draw_helpers.h"
#include "../material/markdown.h"
#include "../material/type.h"
#include "../theme.h"
#include "release_list_view.h"
@@ -184,8 +185,8 @@ private:
}
}
// Release-notes text with the checksum table / signature block stripped (the body is mostly a
// machine-readable checksum table). Returns "" when nothing human-readable remains.
// Release-notes markdown with the machine-readable checksum table / signature block cut off (the
// body ends with a "| file | sha |" table). Keeps the markdown so RenderMarkdown can style it.
static std::string cleanNotes(const std::string& body) {
std::istringstream ss(body);
std::string line, out;
@@ -198,16 +199,7 @@ private:
if (!lt.empty() && lt[0] == '|') break; // markdown table (checksums)
if (low.rfind("## checksum", 0) == 0 || low.rfind("#### checksum", 0) == 0 ||
low.rfind("checksums", 0) == 0 || low.rfind("### sha", 0) == 0) break;
// Strip a leading markdown heading marker ("## Foo" -> "Foo") for cleaner plain-text display.
std::string disp = lt;
if (!disp.empty() && disp[0] == '#') {
size_t h = disp.find_first_not_of('#');
if (h != std::string::npos) disp = disp.substr(disp.find_first_not_of(" \t", h));
else disp.clear();
} else {
disp = line; // keep original indentation for non-heading lines (bullets etc.)
}
out += disp;
out += line;
out += '\n';
}
while (!out.empty() && (out.back() == '\n' || out.back() == '\r' ||
@@ -241,13 +233,19 @@ private:
const float masterW = std::min(std::max(contentW * 0.34f, 240.0f * dp), 340.0f * dp);
const float detailW = contentW - masterW - gap;
// ---- LEFT: scrollable version list ----
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(Layout::spacingXs(), Layout::spacingXs()));
ImGui::BeginChild("##daemonVersions", ImVec2(masterW, bodyH), true);
// ---- LEFT: scrollable version list — borderless; each version is a tactile Material card ----
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0, 0));
ImGui::BeginChild("##daemonVersions", ImVec2(masterW, bodyH), false);
{
ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(0, Layout::spacingSm()));
// Transparent Selectable highlight — we draw our own Material card fill/hover/press.
ImGui::PushStyleColor(ImGuiCol_Header, ImVec4(0, 0, 0, 0));
ImGui::PushStyleColor(ImGuiCol_HeaderHovered, ImVec4(0, 0, 0, 0));
ImGui::PushStyleColor(ImGuiCol_HeaderActive, ImVec4(0, 0, 0, 0));
ImDrawList* mdl = ImGui::GetWindowDrawList();
const float rowH = Layout::spacingSm() * 2.0f + bodyF->LegacySize + Layout::spacingXs() + capF->LegacySize;
const float rowH = Layout::spacingMd() * 2.0f + bodyF->LegacySize + Layout::spacingXs() + capF->LegacySize;
const float rowW = ImGui::GetContentRegionAvail().x;
const float round = 10.0f * dp;
for (int i = 0; i < (int)s_rows.size(); ++i) {
const ReleaseRow& r = s_rows[i];
ImGui::PushID(i);
@@ -256,36 +254,47 @@ private:
if (ImGui::Selectable("##ver", sel, ImGuiSelectableFlags_SpanAvailWidth, ImVec2(0, rowH))) {
if (!active) s_sel = i;
}
bool hov = ImGui::IsItemHovered();
bool held = ImGui::IsItemActive();
ImVec2 rmx(rmn.x + rowW, rmn.y + rowH);
// Accent bar: installed = green, newest = primary, else a dim rule.
ImU32 accent = r.installed ? Success() : (i == 0 ? Primary() : WithAlpha(OnSurface(), 55));
mdl->AddRectFilled(ImVec2(rmn.x, rmn.y + 4.0f * dp),
ImVec2(rmn.x + 3.0f * dp, rmx.y - 4.0f * dp), accent, 1.5f * dp);
float x = rmn.x + Layout::spacingSm() + 4.0f * dp;
float tagY = rmn.y + Layout::spacingSm();
// Material card: filled surface, brighter on hover, primary-tinted + outlined when selected.
ImU32 fill = sel ? WithAlpha(Primary(), held ? 55 : 42)
: held ? WithAlpha(OnSurface(), 34)
: hov ? WithAlpha(OnSurface(), 24)
: WithAlpha(OnSurface(), 12);
mdl->AddRectFilled(rmn, rmx, fill, round);
if (sel) mdl->AddRect(rmn, rmx, WithAlpha(Primary(), 150), round, 0, 1.6f * dp);
if (hov || held) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
// Left accent bar (rounded), keyed to the version's status.
ImU32 accent = r.installed ? Success() : (i == 0 ? Primary() : WithAlpha(OnSurface(), 70));
mdl->AddRectFilled(ImVec2(rmn.x + 4.0f * dp, rmn.y + 8.0f * dp),
ImVec2(rmn.x + 7.0f * dp, rmx.y - 8.0f * dp), accent, 2.0f * dp);
float x = rmn.x + Layout::spacingMd() + 5.0f * dp;
float tagY = rmn.y + Layout::spacingMd();
mdl->AddText(bodyF, bodyF->LegacySize, ImVec2(x, tagY),
r.hasAsset ? OnSurface() : OnSurfaceDisabled(), r.tag.c_str());
float bx = x + bodyF->CalcTextSizeA(bodyF->LegacySize, FLT_MAX, 0, r.tag.c_str()).x + Layout::spacingSm();
float by = tagY + (bodyF->LegacySize - capF->LegacySize) * 0.5f;
auto badge = [&](const char* t, ImU32 c) {
float tw = capF->CalcTextSizeA(capF->LegacySize, FLT_MAX, 0, t).x;
if (bx + tw > rmx.x - Layout::spacingXs()) return; // no room: skip (long tag)
if (bx + tw > rmx.x - Layout::spacingSm()) return; // no room: skip (long tag)
mdl->AddText(capF, capF->LegacySize, ImVec2(bx, by), c, t);
bx += tw + Layout::spacingSm();
};
if (r.installed) badge(TR("upd_installed_badge"), Success());
else if (i == 0) badge(TR("daemon_update_latest_badge"), Primary());
if (r.prerelease) badge(TR("upd_prerelease"), Warning());
// Date line.
if (!r.date.empty())
mdl->AddText(capF, capF->LegacySize,
ImVec2(x, rmx.y - Layout::spacingSm() - capF->LegacySize),
ImVec2(x, rmx.y - Layout::spacingMd() - capF->LegacySize),
OnSurfaceMedium(), r.date.c_str());
ImGui::PopID();
}
ImGui::PopStyleColor(3);
ImGui::PopStyleVar(); // ItemSpacing
}
ImGui::EndChild();
ImGui::PopStyleVar();
ImGui::PopStyleVar(); // WindowPadding
ImGui::SameLine(0.0f, gap);
@@ -363,17 +372,21 @@ private:
// (never let a min-height notes child push the button into a scroll region).
const float roomForNotes = ImGui::GetContentRegionAvail().y - reserve;
if (roomForNotes >= 44.0f * dp) {
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(Layout::spacingSm(), Layout::spacingSm()));
ImGui::BeginChild("##daemonNotes", ImVec2(0, roomForNotes), true);
// Filled, rounded Material surface (no thin outline); notes rendered as markdown.
ImGui::PushStyleColor(ImGuiCol_ChildBg, WithAlpha(OnSurface(), 14));
ImGui::PushStyleVar(ImGuiStyleVar_ChildRounding, 12.0f * dp);
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(Layout::spacingMd(), Layout::spacingMd()));
ImGui::BeginChild("##daemonNotes", ImVec2(0, roomForNotes), false);
{
const std::string notes = cleanNotes(rel.body);
if (notes.empty())
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), TR("daemon_update_no_notes"));
else
ImGui::TextWrapped("%s", notes.c_str());
RenderMarkdown(notes);
}
ImGui::EndChild();
ImGui::PopStyleVar();
ImGui::PopStyleVar(2);
ImGui::PopStyleColor();
ImGui::Spacing();
}