ObsidianDragon - DragonX ImGui Wallet

Full-node GUI wallet for DragonX cryptocurrency.
Built with Dear ImGui, SDL3, and OpenGL3/DX11.

Features:
- Send/receive shielded and transparent transactions
- Autoshield with merged transaction display
- Built-in CPU mining (xmrig)
- Peer management and network monitoring
- Wallet encryption with PIN lock
- QR code generation for receive addresses
- Transaction history with pagination
- Console for direct RPC commands
- Cross-platform (Linux, Windows)
This commit is contained in:
2026-02-26 02:31:52 -06:00
commit 3aee55b49c
306 changed files with 177789 additions and 0 deletions

815
src/ui/sidebar.h Normal file
View File

@@ -0,0 +1,815 @@
// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
#pragma once
#include "imgui.h"
#include "material/type.h"
#include "material/colors.h"
#include "material/draw_helpers.h"
#include "layout.h"
#include "schema/ui_schema.h"
#include "../embedded/IconsMaterialDesign.h"
#include <cstdio>
#include <cmath>
namespace dragonx {
namespace ui {
// Navigation pages — order matches sidebar display order
enum class NavPage {
Overview = 0,
Send,
Receive,
History,
// --- separator ---
Mining,
Market,
// --- separator ---
Console,
Peers,
Settings,
Count_
};
struct NavItem {
const char* label;
NavPage page;
const char* section_label; // if non-null, render section label above this item
};
inline const NavItem kNavItems[] = {
{ "Overview", NavPage::Overview, nullptr },
{ "Send", NavPage::Send, nullptr },
{ "Receive", NavPage::Receive, nullptr },
{ "History", NavPage::History, nullptr },
{ "Mining", NavPage::Mining, "TOOLS" },
{ "Market", NavPage::Market, nullptr },
{ "Console", NavPage::Console, "ADVANCED" },
{ "Network", NavPage::Peers, nullptr },
{ "Settings", NavPage::Settings, nullptr },
};
static_assert(sizeof(kNavItems) / sizeof(kNavItems[0]) == (int)NavPage::Count_,
"kNavItems must match NavPage::Count_");
// Get the Material Design icon string for a navigation page.
inline const char* GetNavIconMD(NavPage page)
{
switch (page) {
case NavPage::Overview: return ICON_MD_HOME;
case NavPage::Send: return ICON_MD_CALL_MADE;
case NavPage::Receive: return ICON_MD_CALL_RECEIVED;
case NavPage::History: return ICON_MD_HISTORY;
case NavPage::Mining: return ICON_MD_CONSTRUCTION;
case NavPage::Market: return ICON_MD_TRENDING_UP;
case NavPage::Console: return ICON_MD_TERMINAL;
case NavPage::Peers: return ICON_MD_HUB;
case NavPage::Settings: return ICON_MD_SETTINGS;
default: return ICON_MD_HOME;
}
}
// Draw a Material Design icon centered at (cx, cy) with the given color.
// Uses the medium (18px) icon font from Typography.
inline void DrawNavIcon(ImDrawList* dl, NavPage page, float cx, float cy, float /*s*/, ImU32 col)
{
ImFont* iconFont = material::Type().iconMed();
const char* icon = GetNavIconMD(page);
ImVec2 sz = iconFont->CalcTextSizeA(iconFont->LegacySize, 1000.0f, 0.0f, icon);
dl->AddText(iconFont, iconFont->LegacySize,
ImVec2(cx - sz.x * 0.5f, cy - sz.y * 0.5f), col, icon);
}
// Lightweight badge / status data the caller provides each frame.
// Counts <= 0 mean "no badge". -1 means "show dot only" (no number).
struct SidebarStatus {
int unconfirmedTxCount = 0; // badge on History
bool miningActive = false; // green dot on Mining
int peerCount = 0; // badge on Peers
// Exit
bool exitClicked = false;
// Branding logo (optional — loaded at startup)
ImTextureID logoTexID = 0;
int logoW = 0;
int logoH = 0;
// Gradient overlay texture (optional)
ImTextureID gradientTexID = 0;
};
// Draw an inset cutout bevel around a button rect — the "channel" carved
// into the sidebar surface that the raised button sits inside.
// Highlights and shadows are flipped relative to the button bevel so the
// cutout looks like it is recessed into the material. Light source: top-left.
inline void DrawGlassCutout(ImDrawList* dl, ImVec2 mn, ImVec2 mx,
float rnd, float gap = 2.0f)
{
float w = mx.x - mn.x + gap * 2.0f;
float h = mx.y - mn.y + gap * 2.0f;
if (w < 1.0f || h < 1.0f) return;
// Cached cutout style — refreshed once per theme reload
struct CutoutStyleCache {
uint32_t gen = 0;
float shadowAlpha, highlightAlpha, lineW;
float glowExpand, glowAlpha, glowLineW;
};
static CutoutStyleCache s_cc;
{
uint32_t g = schema::UI().generation();
if (g != s_cc.gen) {
s_cc.gen = g;
auto rd = [](const char* key, float fb) {
auto e = schema::UI().drawElement("components.sidebar", key);
return e.size >= 0 ? e.size : fb;
};
s_cc.shadowAlpha = rd("cutout-shadow-alpha", 55.0f);
s_cc.highlightAlpha = rd("cutout-highlight-alpha", 16.0f);
s_cc.lineW = rd("cutout-line-width", 0.75f);
s_cc.glowExpand = rd("cutout-glow-expand", 1.5f);
s_cc.glowAlpha = rd("cutout-glow-alpha", 35.0f);
s_cc.glowLineW = rd("cutout-glow-line-width", 1.5f);
}
}
ImVec2 cMn(mn.x - gap, mn.y - gap);
ImVec2 cMx(mx.x + gap, mx.y + gap);
float cRnd = rnd + gap;
float cx = (cMn.x + cMx.x) * 0.5f;
float cy = (cMn.y + cMx.y) * 0.5f;
// Fast directional light factor — algebraic approximation of angular
// proximity to fixed light at -135°. Replaces atan2f per vertex with
// a dot-product + clamp (no trig, no sqrt).
float invHalfW = 2.0f / w;
float invHalfH = 2.0f / h;
auto lightFactor = [cx, cy, invHalfW, invHalfH](float px, float py) -> float {
float nx = (px - cx) * invHalfW;
float ny = (py - cy) * invHalfH;
return ImClamp(0.5f + 0.3536f * (-nx - ny), 0.0f, 1.0f);
};
float fadeStart = 0.30f, fadeEnd = 0.55f;
int shA = (int)s_cc.shadowAlpha;
int hiA = (int)s_cc.highlightAlpha;
float lineW = s_cc.lineW;
// --- Outer glow pass: wider, softer dark edge on top-left ---
float glowExpand = s_cc.glowExpand;
int glowA = (int)s_cc.glowAlpha;
float glowLineW = s_cc.glowLineW;
{
ImVec2 gMn(cMn.x - glowExpand, cMn.y - glowExpand);
ImVec2 gMx(cMx.x + glowExpand, cMx.y + glowExpand);
float gRnd = cRnd + glowExpand;
int v0 = dl->VtxBuffer.Size;
dl->AddRect(gMn, gMx, IM_COL32(0, 0, 0, 1), gRnd, 0, glowLineW);
int v1 = dl->VtxBuffer.Size;
for (int i = v0; i < v1; i++) {
ImDrawVert& v = dl->VtxBuffer[i];
float lf = lightFactor(v.pos.x, v.pos.y);
float fade = ImClamp((lf - 0.25f) / 0.30f, 0.0f, 1.0f);
int a = (int)(glowA * fade);
v.col = IM_COL32(0, 0, 0, (unsigned char)ImClamp(a, 0, 255));
}
}
// --- Crisp shadow pass (on light-facing edges — top-left dark edge) ---
{
int v0 = dl->VtxBuffer.Size;
dl->AddRect(cMn, cMx, IM_COL32(0, 0, 0, 1), cRnd, 0, lineW);
int v1 = dl->VtxBuffer.Size;
for (int i = v0; i < v1; i++) {
ImDrawVert& v = dl->VtxBuffer[i];
float lf = lightFactor(v.pos.x, v.pos.y);
float fade = ImClamp((lf - fadeStart) / (fadeEnd - fadeStart), 0.0f, 1.0f);
int a = (int)(shA * fade);
v.col = IM_COL32(0, 0, 0, (unsigned char)ImClamp(a, 0, 255));
}
}
// --- Highlight pass (on shadow-facing edges — bottom-right highlight) ---
{
int v0 = dl->VtxBuffer.Size;
dl->AddRect(cMn, cMx, IM_COL32(255, 255, 255, 1), cRnd, 0, lineW);
int v1 = dl->VtxBuffer.Size;
for (int i = v0; i < v1; i++) {
ImDrawVert& v = dl->VtxBuffer[i];
float lf = lightFactor(v.pos.x, v.pos.y);
float sf = 1.0f - lf;
float fade = ImClamp((sf - fadeStart) / (fadeEnd - fadeStart), 0.0f, 1.0f);
int a = (int)(hiA * fade);
v.col = IM_COL32(255, 255, 255, (unsigned char)ImClamp(a, 0, 255));
}
}
}
// Draw a neumorphic button molded from the sidebar surface itself.
// No contrasting fill — uses only shadows and highlights so the button
// appears to be the exact same material as the sidebar, just shaped.
// Light source: top-left. depth controls the press amount:
// 0.0 = fully raised / convex (normal idle state)
// 0.5 = half-pressed (hover state)
// 1.0 = fully pressed in (selected / clicked)
inline void DrawGlassBevelButton(ImDrawList* dl, ImVec2 mn, ImVec2 mx,
float rnd, float depth = 0.0f, int /*fillAlpha*/ = 18)
{
float w = mx.x - mn.x;
float h = mx.y - mn.y;
if (w < 1.0f || h < 1.0f) return;
float inv = 1.0f - depth; // 1 raised, 0 pressed
// ---- Directional bevel + neumorphic glow ----
// Multiple concentric AddRect outline passes with directional vertex
// coloring. Wider outer passes (expanded rect) create soft shadow glow;
// inner pass gives crisp bevel edge. All use AddRect with rounding so
// every layer follows the rounded corners perfectly — no clip rects needed.
{
float cx = (mn.x + mx.x) * 0.5f;
float cy = (mn.y + mx.y) * 0.5f;
// Fast directional light factor — algebraic dot-product approximation
// of angular proximity to fixed light at -135°. No trig, no sqrt.
float invHalfW = 2.0f / w;
float invHalfH = 2.0f / h;
auto lightFactor = [cx, cy, invHalfW, invHalfH](float px, float py) -> float {
float nx = (px - cx) * invHalfW;
float ny = (py - cy) * invHalfH;
return ImClamp(0.5f + 0.3536f * (-nx - ny), 0.0f, 1.0f);
};
struct BevelPass { float expand; float lineW; float fadeStart; float fadeEnd; };
BevelPass passes[] = {
{ 0.5f, 0.75f, 0.30f, 0.55f }, // Outer glow (thin)
{ 0.0f, 0.75f, 0.38f, 0.58f }, // Inner crisp bevel
};
for (auto& bp : passes) {
// Compute directional alpha.
// Outer passes fade out when pressed; inner bevel swaps direction.
int hiA, shA;
if (bp.expand > 0.0f) {
float baseHi = (bp.expand > 1.0f) ? 12.0f : 18.0f;
float baseSh = (bp.expand > 1.0f) ? 20.0f : 30.0f;
hiA = (int)(baseHi * inv);
shA = (int)(baseSh * inv);
} else {
hiA = (int)(40.0f * inv + 15.0f * depth);
shA = (int)(45.0f * inv + 60.0f * depth);
}
ImVec2 pMn(mn.x - bp.expand, mn.y - bp.expand);
ImVec2 pMx(mx.x + bp.expand, mx.y + bp.expand);
float pRnd = rnd + bp.expand;
// Highlight pass (light-facing edges)
{
int v0 = dl->VtxBuffer.Size;
dl->AddRect(pMn, pMx, IM_COL32(255, 255, 255, 1), pRnd, 0, bp.lineW);
int v1 = dl->VtxBuffer.Size;
for (int i = v0; i < v1; i++) {
ImDrawVert& v = dl->VtxBuffer[i];
float lf = lightFactor(v.pos.x, v.pos.y);
float fade = ImClamp((lf - bp.fadeStart) / (bp.fadeEnd - bp.fadeStart), 0.0f, 1.0f);
int targetA = (depth < 0.5f) ? hiA : shA;
int a = (int)(targetA * fade);
v.col = (depth < 0.5f)
? IM_COL32(255, 255, 255, (unsigned char)ImClamp(a, 0, 255))
: IM_COL32(0, 0, 0, (unsigned char)ImClamp(a, 0, 255));
}
}
// Shadow pass (shadow-facing edges)
{
int v0 = dl->VtxBuffer.Size;
dl->AddRect(pMn, pMx, IM_COL32(0, 0, 0, 1), pRnd, 0, bp.lineW);
int v1 = dl->VtxBuffer.Size;
for (int i = v0; i < v1; i++) {
ImDrawVert& v = dl->VtxBuffer[i];
float lf = lightFactor(v.pos.x, v.pos.y);
float sf = 1.0f - lf;
float fade = ImClamp((sf - bp.fadeStart) / (bp.fadeEnd - bp.fadeStart), 0.0f, 1.0f);
int targetA = (depth < 0.5f) ? shA : hiA;
int a = (int)(targetA * fade);
v.col = (depth < 0.5f)
? IM_COL32(0, 0, 0, (unsigned char)ImClamp(a, 0, 255))
: IM_COL32(255, 255, 255, (unsigned char)ImClamp(a, 0, 255));
}
}
}
}
// ---- 3. Inset shadow (scales with depth, emanates from all edges inward) ----
// Uses concentric rounded rects with decreasing alpha to avoid triangle
// interpolation artifacts that occur with single-rect vertex color hacking.
// Cached inset shadow style — refreshed once per theme reload
struct InsetCache {
uint32_t gen = 0;
float threshold, inset, maxAlpha, fadeRatio;
};
static InsetCache s_ic;
{
uint32_t g = schema::UI().generation();
if (g != s_ic.gen) {
s_ic.gen = g;
auto rd = [](const char* key, float fb) {
auto e = schema::UI().drawElement("components.sidebar", key);
return e.size >= 0 ? e.size : fb;
};
s_ic.threshold = rd("inset-shadow-threshold", 0.1f);
s_ic.inset = rd("inset-shadow-inset", 1.0f);
s_ic.maxAlpha = rd("inset-shadow-max-alpha", 140.0f);
s_ic.fadeRatio = rd("inset-shadow-fade-ratio", 0.35f);
}
}
if (depth > s_ic.threshold) {
float baseInset = s_ic.inset;
int shadowMax = (int)(s_ic.maxAlpha * depth);
float fadeRatio = s_ic.fadeRatio;
float bW = mx.x - mn.x - baseInset * 2.0f;
float bH = mx.y - mn.y - baseInset * 2.0f;
if (bW > 0.0f && bH > 0.0f) {
float fadeDepth = ImMin(bW, bH) * fadeRatio;
const int steps = 8;
// Draw inside-out: innermost (lightest) first, then progressively
// larger/darker rects on top. Each outer rect extends further toward
// the edges, adding darkness only at the perimeter.
for (int s = steps - 1; s >= 0; s--) {
float t = (float)s / (float)(steps - 1); // 0 = edge, 1 = deepest
float shrink = baseInset + t * fadeDepth;
ImVec2 sMn(mn.x + shrink, mn.y + shrink);
ImVec2 sMx(mx.x - shrink, mx.y - shrink);
if (sMx.x <= sMn.x || sMx.y <= sMn.y) continue;
float sRnd = ImMax(rnd - shrink, 0.0f);
// Quadratic falloff: darkest at edge (t=0), zero at center (t=1)
float alpha01 = (1.0f - t) * (1.0f - t);
int a = (int)((float)shadowMax * alpha01);
if (a < 1) continue;
dl->AddRectFilled(sMn, sMx, IM_COL32(0, 0, 0, (unsigned char)ImClamp(a, 0, 255)), sRnd);
}
}
}
// (No specular highlight — same matte material as sidebar)
}
// Render the sidebar navigation. Returns true if the page changed.
// collapsed: when true, sidebar is in icon-only mode (narrow width).
// The caller can toggle collapsed via a reference if a toggle button is desired.
inline bool RenderSidebar(NavPage& current, float sidebarWidth, float contentHeight,
SidebarStatus& status, bool& collapsed)
{
using namespace material;
bool changed = false;
// Collect button rects so we can punch holes in the glass panel fill
// to avoid opacity stacking (buttons should be same opacity as sidebar).
struct Rect { ImVec2 mn, mx; float rnd; };
ImVector<Rect> buttonRects;
const auto& S = schema::UISchema::instance();
// Read sidebar layout from schema (with fallbacks)
// All TOML values are in logical pixels; multiply by DPI scale
// for correct physical pixel sizing on high-DPI displays.
const float dp = Layout::dpiScale();
auto sde = [&](const char* key, float fallback) {
auto e = S.drawElement("components.sidebar", key);
return (e.size >= 0 ? e.size : fallback) * dp;
};
const float sbWidth = sde("width", 160.0f);
const float sbCollapsedWidth = sde("collapsed-width", 64.0f);
const float glassMarginY = sde("glass-margin-y", 6.0f);
const float glassMarginL = sde("glass-margin-left", 6.0f);
const float glassMarginR = sde("glass-margin-right", 2.0f);
const float stripH = sde("strip-height", 20.0f);
const float btnPadCollapsed = sde("button-pad-collapsed", 8.0f);
const float btnPadExpanded = sde("button-pad-expanded", 14.0f);
const float iconHalfSize = sde("icon-half-size", 7.0f);
const float iconLabelGap = sde("icon-label-gap", 8.0f);
const float badgeRadiusDot = sde("badge-radius-dot", 4.0f);
const float badgeRadiusNumber = sde("badge-radius-number", 8.0f);
const float bottomPadding = sde("bottom-padding", 6.0f);
const float exitIconGap = sde("exit-icon-gap", 4.0f);
const float sbSectionLabelPadLeft = sde("section-label-pad-left", 16.0f);
const float sbItemPadX = sde("item-pad-x", 8.0f);
// Base values for responsive scaling
const float baseItemHeight = sde("item-height", 46.0f);
const float baseNavGap = sde("nav-gap", 20.0f);
const float baseSectionGap = sde("section-gap", 12.0f);
const float baseButtonSpacing = sde("button-spacing", 3.0f);
// Estimate total sidebar content height at base sizes to detect overflow.
// Fixed chrome: top margin + collapse strip + exit strip + bottom padding
const float fixedChrome = glassMarginY + stripH + stripH + bottomPadding;
// Section labels: 2 sections × (gap + label height + pad)
const float sectionLabelH = 2.0f * (baseSectionGap + 13.0f * dp);
// Nav items: 9 items × (height + spacing)
const float navItemsH = (float)(int)NavPage::Count_ * (baseItemHeight + baseButtonSpacing);
const float baseContentH = fixedChrome + baseNavGap + navItemsH + sectionLabelH;
// Responsive shrink: if content would overflow, scale down flexible sizes.
// Clamp scale so buttons never shrink below what fits in the minimum
// sidebar height defined in ui.toml ("min-height").
float sidebarMinHeight = sde("min-height", 360.0f);
float scaleFloor = 0.55f;
if (sidebarMinHeight > fixedChrome) {
float flexH = baseContentH - fixedChrome;
if (flexH > 0.0f) {
float minFlex = sidebarMinHeight - fixedChrome;
scaleFloor = std::max(0.55f, minFlex / flexH);
}
}
float sidebarScale = 1.0f;
if (baseContentH > contentHeight && contentHeight > fixedChrome) {
float flexH = baseContentH - fixedChrome;
float availFlex = contentHeight - fixedChrome;
sidebarScale = std::max(scaleFloor, availFlex / flexH);
}
const float sbItemHeight = baseItemHeight * sidebarScale;
const float navGap = baseNavGap * sidebarScale;
const float sbSectionGap = baseSectionGap * sidebarScale;
const float buttonSpacing = baseButtonSpacing * sidebarScale;
// How "expanded" are we? 0.0 = fully collapsed, 1.0 = fully expanded
float expandFrac = (sbWidth > sbCollapsedWidth)
? (sidebarWidth - sbCollapsedWidth) / (sbWidth - sbCollapsedWidth)
: 1.0f;
if (expandFrac < 0.0f) expandFrac = 0.0f;
if (expandFrac > 1.0f) expandFrac = 1.0f;
bool showLabels = expandFrac > 0.3f; // hide labels during early part of animation
// Glass panel rounding from responsive schema
float glassRounding = [&]() {
float v = S.drawElement("responsive", "glass-rounding").size;
return (v >= 0 ? v : 8.0f) * dp;
}();
ImGui::BeginChild("##Sidebar", ImVec2(sidebarWidth, contentHeight), false,
ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse | ImGuiWindowFlags_NoBackground);
ImDrawList* dl = ImGui::GetWindowDrawList();
ImVec2 wp = ImGui::GetWindowPos();
// Glass card background — inset with rounded corners, matching content cards
float sidebarMarginY = glassMarginY; // top & bottom inset
float sidebarMarginL = glassMarginL; // left inset
float sidebarMarginR = glassMarginR; // right inset (tighter on content side)
// Panel bounds
float panelLeft = wp.x + sidebarMarginL;
float panelRight = wp.x + sidebarWidth - sidebarMarginR;
// Defer glass panel drawing until we know content height (channel 0 = background)
ImDrawListSplitter splitter;
splitter.Split(dl, 2);
splitter.SetCurrentChannel(dl, 1);
// Top padding (just the margin — collapse button sits at panel top)
ImGui::Dummy(ImVec2(0, sidebarMarginY));
// ---- Collapse toggle — flush strip at top of sidebar panel ----
{
ImVec2 savedCursor = ImGui::GetCursorScreenPos();
float rnd = glassRounding;
// Strip spans full panel width, sits inside top of panel
float stripX = panelLeft;
float stripW = panelRight - panelLeft;
float stripY = wp.y + sidebarMarginY;
ImVec2 stripMin(stripX, stripY);
ImVec2 stripMax(stripX + stripW, stripY + stripH);
ImGui::SetCursorScreenPos(stripMin);
if (ImGui::InvisibleButton("##SidebarCollapse", ImVec2(stripW, stripH))) {
collapsed = !collapsed;
}
bool btnHover = ImGui::IsItemHovered();
// Draw strip background — flush with panel top (no extra rounding, blends in)
ImU32 stripBg = btnHover ? schema::UI().resolveColor("var(--sidebar-hover)", IM_COL32(255, 255, 255, 25)) : IM_COL32(0, 0, 0, 0);
if (btnHover) {
dl->AddRectFilled(stripMin, stripMax, stripBg,
rnd, ImDrawFlags_RoundCornersTop);
}
// Subtle bottom separator
dl->AddLine(ImVec2(stripMin.x + rnd * 0.5f, stripMax.y),
ImVec2(stripMax.x - rnd * 0.5f, stripMax.y),
schema::UI().resolveColor("var(--sidebar-divider)", IM_COL32(255, 255, 255, 15)), 1.0f);
if (btnHover) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
// Chevron icon centered in strip
ImU32 iconCol = btnHover ? OnSurface() : schema::UI().resolveColor("var(--sidebar-icon)", IM_COL32(255, 255, 255, 60));
float cx = stripX + stripW * 0.5f;
float cy = stripY + stripH * 0.5f;
{
ImFont* iconFont = Type().iconSmall();
const char* chevIcon = collapsed ? ICON_MD_CHEVRON_RIGHT : ICON_MD_CHEVRON_LEFT;
ImVec2 chevSz = iconFont->CalcTextSizeA(iconFont->LegacySize, 1000.0f, 0.0f, chevIcon);
dl->AddText(iconFont, iconFont->LegacySize,
ImVec2(cx - chevSz.x * 0.5f, cy - chevSz.y * 0.5f), iconCol, chevIcon);
}
ImGui::SetCursorScreenPos(savedCursor); // restore — toggle is an overlay
}
// Gap between collapse divider and first nav item
ImGui::Dummy(ImVec2(0, navGap));
// ---- Navigation items ----
for (int i = 0; i < (int)NavPage::Count_; ++i) {
const NavItem& item = kNavItems[i];
// Section label (only when expanded)
if (item.section_label && showLabels) {
ImGui::Dummy(ImVec2(0, sbSectionGap));
ImFont* olFont = Type().overline();
float labelY = ImGui::GetCursorScreenPos().y;
ImVec4 olCol = ImGui::ColorConvertU32ToFloat4(OnSurfaceMedium());
olCol.w *= expandFrac;
dl->AddText(olFont, olFont->LegacySize,
ImVec2(wp.x + sbSectionLabelPadLeft, labelY),
ImGui::ColorConvertFloat4ToU32(olCol), item.section_label);
ImGui::Dummy(ImVec2(0, olFont->LegacySize + 2.0f));
} else if (item.section_label && !showLabels) {
// Collapsed: thin separator instead of label
ImGui::Dummy(ImVec2(0, sbSectionGap * 0.4f));
float sepY2 = ImGui::GetCursorScreenPos().y;
dl->AddLine(ImVec2(wp.x + btnPadCollapsed, sepY2), ImVec2(wp.x + sidebarWidth - btnPadCollapsed, sepY2),
Divider(), 1.0f);
ImGui::Dummy(ImVec2(0, sbSectionGap * 0.4f));
}
bool selected = (current == item.page);
ImGui::PushID(i);
float itemH = sbItemHeight;
float btnRnd = itemH * 0.22f; // moderate rounding
float btnPadX = collapsed ? btnPadCollapsed : btnPadExpanded; // tighter padding when collapsed
ImVec2 cursor = ImGui::GetCursorScreenPos();
// Keep button height constant (itemH) so sidebar content height doesn't
// change during collapse animation, which would destabilize centering.
float btnH = itemH;
// Item bounds for icon/label placement (inset)
ImVec2 itemMin(wp.x + sbItemPadX, cursor.y);
ImVec2 itemMax(wp.x + sidebarWidth - sbItemPadX, cursor.y + btnH);
// Button bounds — inset from panel edges for spacing
ImVec2 indMin(panelLeft + btnPadX, cursor.y);
ImVec2 indMax(panelRight - btnPadX, cursor.y + btnH);
// All buttons are embossed; hover presses halfway, selected presses fully
bool hovered = material::IsRectHovered(indMin, indMax);
{
float btnDepth = 0.0f;
if (selected) btnDepth = 1.0f;
else if (hovered) btnDepth = 0.5f;
// Theme effects behind active button
if (selected) {
auto& fx = effects::ThemeEffects::instance();
fx.drawGlowPulse(dl, indMin, indMax, btnRnd);
fx.drawEdgeTrace(dl, indMin, indMax, btnRnd);
fx.drawEmberRise(dl, indMin, indMax);
fx.drawShimmer(dl, indMin, indMax, btnRnd);
fx.drawGradientBorderShift(dl, indMin, indMax, btnRnd);
}
DrawGlassCutout(dl, indMin, indMax, btnRnd, 1.5f);
DrawGlassBevelButton(dl, indMin, indMax, btnRnd, btnDepth, 18);
buttonRects.push_back({indMin, indMax, btnRnd});
}
if (hovered) {
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
}
// Click detection
if (hovered && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) {
current = item.page;
changed = true;
}
// Icon + label — centered horizontally within button bounds
float iconS = iconHalfSize; // icon half-size in pixels
float iconCY = cursor.y + btnH * 0.5f;
float textY = cursor.y + (btnH - ImGui::GetTextLineHeight()) * 0.5f;
ImU32 textCol = selected ? Primary() : OnSurfaceMedium();
if (showLabels) {
// Measure total width of icon + gap + label, then center
ImFont* font = selected ? Type().subtitle2() : Type().body2();
float gap = iconLabelGap;
ImVec2 labelSz = font->CalcTextSizeA(font->LegacySize, 1000.0f, 0.0f, item.label);
float totalW = iconS * 2.0f + gap + labelSz.x;
float btnCX = (indMin.x + indMax.x) * 0.5f;
float startX = btnCX - totalW * 0.5f;
float iconCX = startX + iconS;
DrawNavIcon(dl, item.page, iconCX, iconCY, iconS, textCol);
float labelX = startX + iconS * 2.0f + gap;
ImVec4 lc = ImGui::ColorConvertU32ToFloat4(textCol);
lc.w *= expandFrac;
dl->AddText(font, font->LegacySize, ImVec2(labelX, textY),
ImGui::ColorConvertFloat4ToU32(lc), item.label);
} else {
float iconCX = (indMin.x + indMax.x) * 0.5f;
DrawNavIcon(dl, item.page, iconCX, iconCY, iconS, textCol);
}
// Tooltip when collapsed + hovered
if (!showLabels && hovered) {
ImGui::SetTooltip("%s", item.label);
}
// ---- Badge indicator ----
{
int badgeCount = 0;
bool dotOnly = false;
ImU32 badgeCol = Primary();
ImU32 badgeTextCol = OnPrimary();
if (item.page == NavPage::History && status.unconfirmedTxCount > 0) {
badgeCount = status.unconfirmedTxCount;
badgeCol = Warning();
badgeTextCol = OnWarning();
} else if (item.page == NavPage::Mining && status.miningActive) {
dotOnly = true;
badgeCol = Success();
} else if (item.page == NavPage::Peers && status.peerCount > 0) {
badgeCount = status.peerCount;
}
if (badgeCount > 0 || dotOnly) {
float badgeR = dotOnly ? badgeRadiusDot : badgeRadiusNumber;
float badgeX, badgeY;
// Upper-right corner of button, offset from edges
badgeX = indMax.x - badgeR - 6.0f;
badgeY = indMin.y + badgeR + 5.0f;
dl->AddCircleFilled(ImVec2(badgeX, badgeY), badgeR, badgeCol);
if (!dotOnly && showLabels) {
char buf[16];
snprintf(buf, sizeof(buf), "%d", badgeCount > 99 ? 99 : badgeCount);
ImFont* capFont = Type().caption();
ImVec2 ts = capFont->CalcTextSizeA(capFont->LegacySize, 1000.0f, 0.0f, buf);
dl->AddText(capFont, capFont->LegacySize,
ImVec2(badgeX - ts.x * 0.5f, badgeY - ts.y * 0.5f),
badgeTextCol, buf);
}
}
}
ImGui::Dummy(ImVec2(sidebarWidth, btnH + buttonSpacing)); // extra vertical spacing between buttons
ImGui::PopID();
}
// (Pill indicator removed — glass bevel on active button is sufficient)
// Reserve space for exit strip at panel bottom (drawn as overlay below)
ImGui::Dummy(ImVec2(0, stripH));
// ---- Keyboard navigation ----
if (!ImGui::GetIO().WantTextInput && !ImGui::GetIO().KeyCtrl) {
int idx = static_cast<int>(current);
bool nav = false;
if (ImGui::IsKeyPressed(ImGuiKey_UpArrow) || ImGui::IsKeyPressed(ImGuiKey_K)) {
if (idx > 0) { idx--; nav = true; }
}
if (ImGui::IsKeyPressed(ImGuiKey_DownArrow) || ImGui::IsKeyPressed(ImGuiKey_J)) {
if (idx < (int)NavPage::Count_ - 1) { idx++; nav = true; }
}
// Toggle collapse with [ key
if (ImGui::IsKeyPressed(ImGuiKey_LeftBracket)) {
collapsed = !collapsed;
}
if (nav) {
current = static_cast<NavPage>(idx);
changed = true;
}
}
// Bottom padding
ImGui::Dummy(ImVec2(0, bottomPadding));
// Measure total sidebar content height
float totalContentEndY = ImGui::GetCursorScreenPos().y;
// Draw glass panel background — capped to sidebar bounds so corners are visible
float panelBottomY = totalContentEndY;
float panelTopY = wp.y + sidebarMarginY;
// Clamp bottom so rounded corners are never clipped by the child window
float maxPanelBottom = wp.y + contentHeight - sidebarMarginY;
if (panelBottomY > maxPanelBottom) panelBottomY = maxPanelBottom;
splitter.SetCurrentChannel(dl, 0);
{
float rnd = glassRounding;
ImVec2 panelMin(panelLeft, panelTopY);
ImVec2 panelMax(panelRight, panelBottomY);
GlassPanelSpec sidebarGlass;
sidebarGlass.rounding = rnd;
sidebarGlass.fillAlpha = 14;
sidebarGlass.borderAlpha = 25;
sidebarGlass.borderWidth = 1.0f;
// Record vertex range before drawing the glass panel fill
int glassVtx0 = dl->VtxBuffer.Size;
DrawGlassPanel(dl, panelMin, panelMax, sidebarGlass);
int glassVtx1 = dl->VtxBuffer.Size;
// Punch holes: zero out glass panel vertices that fall inside
// any button rect so the button area doesn't double-up opacity.
for (int vi = glassVtx0; vi < glassVtx1; vi++) {
ImDrawVert& v = dl->VtxBuffer[vi];
for (int bi = 0; bi < buttonRects.Size; bi++) {
const Rect& r = buttonRects[bi];
// Inset test slightly (0.5px) to avoid killing edge pixels
if (v.pos.x > r.mn.x + 0.5f && v.pos.x < r.mx.x - 0.5f &&
v.pos.y > r.mn.y + 0.5f && v.pos.y < r.mx.y - 0.5f) {
v.col = (v.col & 0x00FFFFFFu); // zero alpha
break;
}
}
}
}
// ---- Exit button — flush strip at bottom of sidebar panel ----
splitter.SetCurrentChannel(dl, 1);
{
float rnd = glassRounding;
float exitStripH = stripH;
float exitStripX = panelLeft;
float exitStripW = panelRight - panelLeft;
float exitStripY = panelBottomY - exitStripH;
ImVec2 exitMin(exitStripX, exitStripY);
ImVec2 exitMax(exitStripX + exitStripW, exitStripY + exitStripH);
ImGui::SetCursorScreenPos(exitMin);
if (ImGui::InvisibleButton("##ExitBtn", ImVec2(exitStripW, exitStripH))) {
status.exitClicked = true;
}
bool exitHover = ImGui::IsItemHovered();
// Hover highlight with rounded bottom corners (matching panel)
if (exitHover) {
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
dl->AddRectFilled(exitMin, exitMax, schema::UI().resolveColor("var(--sidebar-hover)", IM_COL32(255, 255, 255, 25)),
rnd, ImDrawFlags_RoundCornersBottom);
}
// Subtle top separator
dl->AddLine(ImVec2(exitMin.x + rnd * 0.5f, exitMin.y),
ImVec2(exitMax.x - rnd * 0.5f, exitMin.y),
schema::UI().resolveColor("var(--sidebar-divider)", IM_COL32(255, 255, 255, 15)), 1.0f);
// Exit icon (+ label when expanded) centered in strip
ImU32 exitCol = exitHover ? Error() : schema::UI().resolveColor("var(--sidebar-icon)", IM_COL32(255, 255, 255, 60));
float cx = exitStripX + exitStripW * 0.5f;
float cy = exitStripY + exitStripH * 0.5f;
if (showLabels) {
ImFont* iconFont = Type().iconSmall();
ImFont* font = Type().caption();
const char* exitIcon = ICON_MD_EXIT_TO_APP;
ImVec2 iconSz = iconFont->CalcTextSizeA(iconFont->LegacySize, 1000.0f, 0.0f, exitIcon);
ImVec2 labelSz = font->CalcTextSizeA(font->LegacySize, 1000.0f, 0.0f, "Exit");
float gap = exitIconGap;
float totalW = iconSz.x + gap + labelSz.x;
float startX = cx - totalW * 0.5f;
dl->AddText(iconFont, iconFont->LegacySize,
ImVec2(startX, cy - iconSz.y * 0.5f), exitCol, exitIcon);
ImVec4 lc = ImGui::ColorConvertU32ToFloat4(exitCol);
lc.w *= expandFrac;
dl->AddText(font, font->LegacySize,
ImVec2(startX + iconSz.x + gap, cy - labelSz.y * 0.5f),
ImGui::ColorConvertFloat4ToU32(lc), "Exit");
} else {
ImFont* iconFont = Type().iconSmall();
const char* exitIcon = ICON_MD_EXIT_TO_APP;
ImVec2 iconSz = iconFont->CalcTextSizeA(iconFont->LegacySize, 1000.0f, 0.0f, exitIcon);
dl->AddText(iconFont, iconFont->LegacySize,
ImVec2(cx - iconSz.x * 0.5f, cy - iconSz.y * 0.5f),
exitCol, exitIcon);
}
if (!showLabels && exitHover) {
ImGui::SetTooltip("Exit");
}
}
splitter.Merge(dl);
ImGui::EndChild();
return changed;
}
} // namespace ui
} // namespace dragonx