Files
ObsidianDragon/src/ui/sidebar.h
DanS 3ff62ca248 v1.2.0: UX audit — security fixes, accessibility, and polish
Security (P0):
- Fix sidebar remaining interactive behind lock screen
- Extend auto-lock idle detection to include active widget interactions
- Distinguish missing PIN vault from wrong PIN; auto-switch to passphrase

Blocking UX (P1):
- Add 15s timeout for encryption state check to prevent indefinite loading
- Show restart reason in loading overlay after wallet encryption
- Add Force Quit button on shutdown screen after 10s
- Warn user if embedded daemon fails to start during wizard completion

Polish (P2):
- Use configured explorer URL in Receive tab instead of hardcoded URL
- Increase request memo buffer from 256 to 512 bytes to match Send tab
- Extend notification duration to 5s for critical operations (tx sent,
  wallet encrypted, key import, backup, export)
- Add Reduce Motion accessibility setting (disables page fade + balance lerp)
- Show estimated remaining time during mining thread benchmark
- Add staleness indicator to market price data (warning after 5 min)

New i18n keys: incorrect_pin, incorrect_passphrase, pin_not_set,
restarting_after_encryption, force_quit, reduce_motion, tt_reduce_motion,
ago, wizard_daemon_start_failed
2026-04-04 19:10:58 -05:00

858 lines
37 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 "../util/i18n.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,
Explorer,
Settings,
Count_
};
struct NavItem {
const char* label; // fallback label (English)
NavPage page;
const char* section_label; // if non-null, render section label above this item
const char* tr_key; // i18n key for label
const char* section_tr_key; // i18n key for section_label
};
inline const NavItem kNavItems[] = {
{ "Overview", NavPage::Overview, nullptr, "overview", nullptr },
{ "Send", NavPage::Send, nullptr, "send", nullptr },
{ "Receive", NavPage::Receive, nullptr, "receive", nullptr },
{ "History", NavPage::History, nullptr, "history", nullptr },
{ "Mining", NavPage::Mining, "TOOLS", "mining", "tools" },
{ "Market", NavPage::Market, nullptr, "market", nullptr },
{ "Console", NavPage::Console, "ADVANCED","console", "advanced" },
{ "Network", NavPage::Peers, nullptr, "network", nullptr },
{ "Explorer", NavPage::Explorer, nullptr, "explorer", nullptr },
{ "Settings", NavPage::Settings, nullptr, "settings", nullptr },
};
static_assert(sizeof(kNavItems) / sizeof(kNavItems[0]) == (int)NavPage::Count_,
"kNavItems must match NavPage::Count_");
// Get translated nav label at runtime
inline const char* NavLabel(const NavItem& item) {
return item.tr_key ? TR(item.tr_key) : item.label;
}
inline const char* NavSectionLabel(const NavItem& item) {
return item.section_tr_key ? TR(item.section_tr_key) : item.section_label;
}
// 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::Explorer: return ICON_MD_EXPLORE;
case NavPage::Settings: return ICON_MD_SETTINGS;
default: return ICON_MD_HOME;
}
}
// Compute the effective draw-list font size for a given font.
// During smooth font-scale drag, FontScaleMain compensates for the atlas
// not yet being rebuilt. drawList->AddText bypasses that, so we apply
// the factor manually to keep sidebar text in sync with the rest of the UI.
inline float ScaledFontSize(ImFont* f) {
return f->LegacySize * ImGui::GetStyle().FontScaleMain;
}
// 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);
float fsz = ScaledFontSize(iconFont);
ImVec2 sz = iconFont->CalcTextSizeA(fsz, 1000.0f, 0.0f, icon);
dl->AddText(iconFont, fsz,
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, bool locked = false)
{
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;
float chevFsz = ScaledFontSize(iconFont);
ImVec2 chevSz = iconFont->CalcTextSizeA(chevFsz, 1000.0f, 0.0f, chevIcon);
dl->AddText(iconFont, chevFsz,
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;
float olFsz = ScaledFontSize(olFont);
dl->AddText(olFont, olFsz,
ImVec2(wp.x + sbSectionLabelPadLeft, labelY),
ImGui::ColorConvertFloat4ToU32(olCol), NavSectionLabel(item));
ImGui::Dummy(ImVec2(0, olFsz + 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 — block pages that require unlock when locked
bool pageNeedsUnlock = locked &&
item.page != NavPage::Console &&
item.page != NavPage::Peers &&
item.page != NavPage::Settings;
if (hovered && ImGui::IsMouseClicked(ImGuiMouseButton_Left) && !pageNeedsUnlock) {
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() : (pageNeedsUnlock ? OnSurfaceDisabled() : OnSurfaceMedium());
if (showLabels) {
// Measure total width of icon + gap + label, then center.
// If the translated label is too wide, shrink the font to fit.
ImFont* font = selected ? Type().subtitle2() : Type().body2();
float gap = iconLabelGap;
float lblFsz = ScaledFontSize(font);
float btnW = indMax.x - indMin.x;
float maxLabelW = btnW - iconS * 2.0f - gap - Layout::spacingXs() * 2;
ImVec2 labelSz = font->CalcTextSizeA(lblFsz, 1000.0f, 0.0f, NavLabel(item));
if (labelSz.x > maxLabelW && maxLabelW > 0) {
float shrink = maxLabelW / labelSz.x;
lblFsz *= shrink;
labelSz = font->CalcTextSizeA(lblFsz, 1000.0f, 0.0f, NavLabel(item));
}
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, lblFsz, ImVec2(labelX, textY),
ImGui::ColorConvertFloat4ToU32(lc), NavLabel(item));
} 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", NavLabel(item));
}
// ---- 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();
float capFsz = ScaledFontSize(capFont);
ImVec2 ts = capFont->CalcTextSizeA(capFsz, 1000.0f, 0.0f, buf);
dl->AddText(capFont, capFsz,
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;
float eIconFsz = ScaledFontSize(iconFont);
float eLblFsz = ScaledFontSize(font);
ImVec2 iconSz = iconFont->CalcTextSizeA(eIconFsz, 1000.0f, 0.0f, exitIcon);
ImVec2 labelSz = font->CalcTextSizeA(eLblFsz, 1000.0f, 0.0f, "Exit");
float gap = exitIconGap;
float totalW = iconSz.x + gap + labelSz.x;
float startX = cx - totalW * 0.5f;
dl->AddText(iconFont, eIconFsz,
ImVec2(startX, cy - iconSz.y * 0.5f), exitCol, exitIcon);
ImVec4 lc = ImGui::ColorConvertU32ToFloat4(exitCol);
lc.w *= expandFrac;
dl->AddText(font, eLblFsz,
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;
float eIconFsz = ScaledFontSize(iconFont);
ImVec2 iconSz = iconFont->CalcTextSizeA(eIconFsz, 1000.0f, 0.0f, exitIcon);
dl->AddText(iconFont, eIconFsz,
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