// 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 #include 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 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(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(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