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:
@@ -453,8 +453,17 @@ void App::buildSweepCatalog()
|
||||
};
|
||||
std::vector<util::DaemonRelease> rels;
|
||||
rels.push_back(mk("v1.0.4", "DragonX v1.0.4", "2026-06-28",
|
||||
"## What's new\n- Faster initial block sync\n- BIP39 seed-phrase wallet support\n"
|
||||
"- Mempool handling fixes and lower memory use\n\n## Checksums\n| File | SHA-256 |\n"
|
||||
"## What is DragonX?\n\n"
|
||||
"DragonX is a privacy-focused cryptocurrency built on zero-knowledge mathematics. "
|
||||
"It enforces mandatory z2z (shielded-to-shielded) transactions after block 340,000.\n\n"
|
||||
"## Bug Fixes\n"
|
||||
"* Fix sapling pool persistence \xE2\x80\x94 pool total no longer resets to 0 on restart\n"
|
||||
"* Add `subsidy` and `fees` fields to the `getblock` RPC response\n\n"
|
||||
"## Key Features\n"
|
||||
"* **RandomX Proof-of-Work** \xE2\x80\x94 CPU-mineable, ASIC-resistant\n"
|
||||
"* **Sapling zk-SNARKs** \xE2\x80\x94 zero-knowledge proofs for private transactions\n"
|
||||
"* **Encrypted P2P** \xE2\x80\x94 all connections secured with TLS 1.3 via WolfSSL\n\n"
|
||||
"## Checksums\n| File | SHA-256 |\n"
|
||||
"|---|---|\n| dragonx-v1.0.4-linux-amd64.zip | `ab12cd34` |\n", false));
|
||||
rels.push_back(mk("v1.0.3", "DragonX v1.0.3", "2026-05-14",
|
||||
"## Notes\n- Stability improvements\n- RPC fixes\n", false));
|
||||
|
||||
154
src/ui/material/markdown.h
Normal file
154
src/ui/material/markdown.h
Normal 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
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user