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

154
src/ui/material/markdown.h Normal file
View File

@@ -0,0 +1,154 @@
// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
//
// Minimal in-app markdown renderer for release notes (the daemon / miner updater bodies are GitHub-
// flavoured markdown). Supports the subset those notes actually use: # / ## / ### headings,
// **bold**, `inline code`, - / * / + bullets, and blank-line paragraph breaks. It lays text out with
// the ImGui draw list (word-wrapped, mixed fonts) and reserves the block height via a Dummy so a
// scrollable parent scrolls correctly. Not a full CommonMark implementation — just enough to make
// release notes readable.
#pragma once
#include <algorithm>
#include <sstream>
#include <string>
#include <vector>
#include "imgui.h"
#include "../layout.h"
#include "colors.h"
#include "type.h"
namespace dragonx {
namespace ui {
namespace material {
// Render `md` into the current window, wrapping to `wrapW` (defaults to the content region width).
// Advances the ImGui cursor past the rendered block.
inline void RenderMarkdown(const std::string& md, float wrapW = 0.0f) {
const float dp = Layout::dpiScale();
if (wrapW <= 0.0f) wrapW = ImGui::GetContentRegionAvail().x;
if (wrapW <= 1.0f) return;
ImDrawList* dl = ImGui::GetWindowDrawList();
ImFont* fBody = Type().body2(); // 14 Regular
ImFont* fBold = Type().subtitle2(); // 14 Medium (bold at body size)
ImFont* fCode = Type().mono();
ImFont* fH1 = Type().h6(); // 20 Medium
ImFont* fH2 = Type().subtitle2(); // heading level 2+ = medium + top spacing
const ImU32 cText = OnSurface();
const ImU32 cCode = Primary();
const ImU32 cBullet = OnSurfaceMedium();
const ImVec2 origin = ImGui::GetCursorScreenPos();
const float xLeft = origin.x;
const float maxX = xLeft + wrapW;
float y = origin.y;
struct Run { std::string text; ImFont* font; ImU32 col; bool code; };
// Split a line into styled runs on **bold** and `code`.
auto parseInline = [&](const std::string& line, ImFont* base, ImU32 col) {
std::vector<Run> runs;
std::string cur;
auto flush = [&]() { if (!cur.empty()) { runs.push_back({cur, base, col, false}); cur.clear(); } };
for (size_t i = 0; i < line.size();) {
if (line[i] == '*' && i + 1 < line.size() && line[i + 1] == '*') {
size_t e = line.find("**", i + 2);
if (e == std::string::npos) { cur += line[i]; ++i; continue; }
flush();
runs.push_back({line.substr(i + 2, e - (i + 2)), fBold, col, false});
i = e + 2;
} else if (line[i] == '`') {
size_t e = line.find('`', i + 1);
if (e == std::string::npos) { cur += line[i]; ++i; continue; }
flush();
runs.push_back({line.substr(i + 1, e - (i + 1)), fCode, cCode, true});
i = e + 1;
} else {
cur += line[i];
++i;
}
}
flush();
return runs;
};
// Lay runs out as words, wrapping to [xStart, maxX] and hanging at xHang on wrap. Advances y.
auto layout = [&](const std::vector<Run>& runs, float xStart, float xHang) {
float x = xStart;
float lineH = 0.0f;
for (const Run& r : runs) {
size_t i = 0;
while (i < r.text.size()) {
size_t ws = r.text.find(' ', i);
std::string word = (ws == std::string::npos) ? r.text.substr(i) : r.text.substr(i, ws - i + 1);
i = (ws == std::string::npos) ? r.text.size() : ws + 1;
if (word.empty()) continue;
float wW = r.font->CalcTextSizeA(r.font->LegacySize, FLT_MAX, 0, word.c_str()).x;
if (x + wW > maxX && x > xHang + 0.5f) { // wrap
y += (lineH > 0.0f ? lineH : r.font->LegacySize * 1.4f);
x = xHang;
lineH = 0.0f;
}
lineH = std::max(lineH, r.font->LegacySize * 1.4f);
if (r.code) {
std::string trimmed = word;
while (!trimmed.empty() && trimmed.back() == ' ') trimmed.pop_back();
float tw = r.font->CalcTextSizeA(r.font->LegacySize, FLT_MAX, 0, trimmed.c_str()).x;
dl->AddRectFilled(ImVec2(x - 2.0f * dp, y), ImVec2(x + tw + 2.0f * dp, y + r.font->LegacySize + 3.0f * dp),
WithAlpha(OnSurface(), 26), 3.0f * dp);
}
dl->AddText(r.font, r.font->LegacySize, ImVec2(x, y), r.col, word.c_str());
x += wW;
}
}
y += (lineH > 0.0f ? lineH : fBody->LegacySize * 1.4f);
};
std::istringstream ss(md);
std::string raw;
bool prevBlank = true;
while (std::getline(ss, raw)) {
if (!raw.empty() && raw.back() == '\r') raw.pop_back();
size_t a = raw.find_first_not_of(" \t");
std::string t = (a == std::string::npos) ? std::string() : raw.substr(a);
if (t.empty()) { y += fBody->LegacySize * 0.55f; prevBlank = true; continue; }
if (t[0] == '#') { // heading
size_t h = t.find_first_not_of('#');
std::string txt;
if (h != std::string::npos) {
size_t ts = t.find_first_not_of(" \t", h);
if (ts != std::string::npos) txt = t.substr(ts);
}
if (!prevBlank) y += fBody->LegacySize * 0.5f; // breathing room above a heading
ImFont* hf = (h <= 1) ? fH1 : fH2;
layout(parseInline(txt, hf, cText), xLeft, xLeft);
prevBlank = false;
continue;
}
if ((t[0] == '*' || t[0] == '-' || t[0] == '+') && t.size() > 1 && t[1] == ' ') { // bullet
const float indent = fBody->LegacySize * 1.15f;
dl->AddText(fBody, fBody->LegacySize, ImVec2(xLeft + fBody->LegacySize * 0.35f, y),
cBullet, "\xE2\x80\xA2"); // •
layout(parseInline(t.substr(2), fBody, cText), xLeft + indent, xLeft + indent);
prevBlank = false;
continue;
}
layout(parseInline(t, fBody, cText), xLeft, xLeft); // paragraph
prevBlank = false;
}
ImGui::SetCursorScreenPos(origin);
ImGui::Dummy(ImVec2(wrapW, std::max(0.0f, y - origin.y)));
}
} // namespace material
} // namespace ui
} // namespace dragonx