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:
501
src/ui/material/app_layout.h
Normal file
501
src/ui/material/app_layout.h
Normal file
@@ -0,0 +1,501 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "layout.h"
|
||||
#include "colors.h"
|
||||
#include "typography.h"
|
||||
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
namespace material {
|
||||
|
||||
// ============================================================================
|
||||
// App Layout Manager
|
||||
// ============================================================================
|
||||
// Manages the overall application layout following Material Design patterns.
|
||||
//
|
||||
// Usage:
|
||||
// // In your main render loop:
|
||||
// auto& layout = AppLayout::instance();
|
||||
// layout.beginFrame();
|
||||
//
|
||||
// // Render app bar
|
||||
// if (layout.beginAppBar("DragonX Wallet")) {
|
||||
// // App bar content (menu items, etc.)
|
||||
// layout.endAppBar();
|
||||
// }
|
||||
//
|
||||
// // Render navigation
|
||||
// if (layout.beginNavigation()) {
|
||||
// layout.navItem("Balance", ICON_WALLET, currentTab == 0);
|
||||
// layout.navItem("Send", ICON_SEND, currentTab == 1);
|
||||
// layout.endNavigation();
|
||||
// }
|
||||
//
|
||||
// // Render main content
|
||||
// if (layout.beginContent()) {
|
||||
// // Your content here
|
||||
// layout.endContent();
|
||||
// }
|
||||
//
|
||||
// layout.endFrame();
|
||||
|
||||
class AppLayout {
|
||||
public:
|
||||
static AppLayout& instance() {
|
||||
static AppLayout s_instance;
|
||||
return s_instance;
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Frame Management
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* @brief Begin a new frame layout
|
||||
*
|
||||
* Call this at the start of each frame before any layout calls.
|
||||
* Updates responsive breakpoints and calculates regions.
|
||||
*/
|
||||
void beginFrame();
|
||||
|
||||
/**
|
||||
* @brief End the frame layout
|
||||
*/
|
||||
void endFrame();
|
||||
|
||||
// ========================================================================
|
||||
// Layout Regions
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* @brief Begin the app bar region
|
||||
*
|
||||
* @param title App title to display
|
||||
* @param showBack Show back button (for sub-pages)
|
||||
* @return true if app bar is visible
|
||||
*/
|
||||
bool beginAppBar(const char* title, bool showBack = false);
|
||||
void endAppBar();
|
||||
|
||||
/**
|
||||
* @brief Begin the navigation region (drawer/rail/bottom)
|
||||
*
|
||||
* @return true if navigation region is visible
|
||||
*/
|
||||
bool beginNavigation();
|
||||
void endNavigation();
|
||||
|
||||
/**
|
||||
* @brief Render a navigation item
|
||||
*
|
||||
* @param label Item label
|
||||
* @param icon Icon glyph (can be nullptr)
|
||||
* @param selected Whether this item is currently selected
|
||||
* @return true if clicked
|
||||
*/
|
||||
bool navItem(const char* label, const char* icon, bool selected);
|
||||
|
||||
/**
|
||||
* @brief Add a navigation section divider
|
||||
*
|
||||
* @param title Optional section title
|
||||
*/
|
||||
void navSection(const char* title = nullptr);
|
||||
|
||||
/**
|
||||
* @brief Begin the main content region
|
||||
*
|
||||
* @return true if content region is visible
|
||||
*/
|
||||
bool beginContent();
|
||||
void endContent();
|
||||
|
||||
// ========================================================================
|
||||
// Card Helpers
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* @brief Begin a Material Design card
|
||||
*
|
||||
* @param id Unique ID for the card
|
||||
* @param layout Card layout configuration
|
||||
* @return true if card is visible
|
||||
*/
|
||||
bool beginCard(const char* id, const CardLayout& layout = CardLayout());
|
||||
void endCard();
|
||||
|
||||
// ========================================================================
|
||||
// Layout Queries
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* @brief Get current breakpoint category
|
||||
*/
|
||||
breakpoint::Category getBreakpoint() const { return breakpoint_; }
|
||||
|
||||
/**
|
||||
* @brief Get current navigation style
|
||||
*/
|
||||
breakpoint::NavStyle getNavStyle() const { return navStyle_; }
|
||||
|
||||
/**
|
||||
* @brief Get content region available width
|
||||
*/
|
||||
float getContentWidth() const { return contentWidth_; }
|
||||
|
||||
/**
|
||||
* @brief Get content region available height
|
||||
*/
|
||||
float getContentHeight() const { return contentHeight_; }
|
||||
|
||||
/**
|
||||
* @brief Check if navigation drawer is expanded
|
||||
*/
|
||||
bool isNavExpanded() const { return navExpanded_; }
|
||||
|
||||
/**
|
||||
* @brief Toggle navigation drawer expansion
|
||||
*/
|
||||
void toggleNav() { navExpanded_ = !navExpanded_; }
|
||||
|
||||
/**
|
||||
* @brief Set navigation drawer expansion state
|
||||
*/
|
||||
void setNavExpanded(bool expanded) { navExpanded_ = expanded; }
|
||||
|
||||
private:
|
||||
AppLayout();
|
||||
~AppLayout() = default;
|
||||
AppLayout(const AppLayout&) = delete;
|
||||
AppLayout& operator=(const AppLayout&) = delete;
|
||||
|
||||
// Layout state
|
||||
breakpoint::Category breakpoint_ = breakpoint::Category::Md;
|
||||
breakpoint::NavStyle navStyle_ = breakpoint::NavStyle::NavDrawer;
|
||||
float windowWidth_ = 0;
|
||||
float windowHeight_ = 0;
|
||||
float contentWidth_ = 0;
|
||||
float contentHeight_ = 0;
|
||||
bool navExpanded_ = true;
|
||||
|
||||
// Region tracking
|
||||
bool inAppBar_ = false;
|
||||
bool inNav_ = false;
|
||||
bool inContent_ = false;
|
||||
|
||||
// Calculated regions
|
||||
ImVec2 appBarPos_;
|
||||
ImVec2 appBarSize_;
|
||||
ImVec2 navPos_;
|
||||
ImVec2 navSize_;
|
||||
ImVec2 contentPos_;
|
||||
ImVec2 contentSize_;
|
||||
|
||||
void calculateRegions();
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Inline Implementation
|
||||
// ============================================================================
|
||||
|
||||
inline AppLayout::AppLayout() {
|
||||
// Initialize with reasonable defaults
|
||||
navExpanded_ = true;
|
||||
}
|
||||
|
||||
inline void AppLayout::beginFrame() {
|
||||
// Get main viewport size
|
||||
ImGuiViewport* viewport = ImGui::GetMainViewport();
|
||||
windowWidth_ = viewport->WorkSize.x;
|
||||
windowHeight_ = viewport->WorkSize.y;
|
||||
|
||||
// Update responsive state
|
||||
breakpoint_ = breakpoint::GetCategory(windowWidth_);
|
||||
navStyle_ = breakpoint::GetNavStyle(breakpoint_);
|
||||
|
||||
// Auto-collapse nav on small screens
|
||||
if (breakpoint_ == breakpoint::Category::Xs) {
|
||||
navExpanded_ = false;
|
||||
}
|
||||
|
||||
calculateRegions();
|
||||
}
|
||||
|
||||
inline void AppLayout::endFrame() {
|
||||
// Reset state
|
||||
inAppBar_ = false;
|
||||
inNav_ = false;
|
||||
inContent_ = false;
|
||||
}
|
||||
|
||||
inline void AppLayout::calculateRegions() {
|
||||
// App bar at top
|
||||
appBarPos_ = ImVec2(0, 0);
|
||||
appBarSize_ = ImVec2(windowWidth_, size::AppBarHeight);
|
||||
|
||||
float belowAppBar = size::AppBarHeight;
|
||||
float contentAreaHeight = windowHeight_ - belowAppBar;
|
||||
|
||||
// Navigation region
|
||||
switch (navStyle_) {
|
||||
case breakpoint::NavStyle::NavDrawer:
|
||||
if (navExpanded_) {
|
||||
navSize_ = ImVec2(size::NavDrawerWidth, contentAreaHeight);
|
||||
} else {
|
||||
navSize_ = ImVec2(size::NavRailWidth, contentAreaHeight);
|
||||
}
|
||||
navPos_ = ImVec2(0, belowAppBar);
|
||||
break;
|
||||
|
||||
case breakpoint::NavStyle::NavRail:
|
||||
navSize_ = ImVec2(size::NavRailWidth, contentAreaHeight);
|
||||
navPos_ = ImVec2(0, belowAppBar);
|
||||
break;
|
||||
|
||||
case breakpoint::NavStyle::BottomNav:
|
||||
// Bottom nav handled separately
|
||||
navSize_ = ImVec2(windowWidth_, size::NavItemHeight);
|
||||
navPos_ = ImVec2(0, windowHeight_ - size::NavItemHeight);
|
||||
contentAreaHeight -= size::NavItemHeight;
|
||||
break;
|
||||
}
|
||||
|
||||
// Content region
|
||||
if (navStyle_ == breakpoint::NavStyle::BottomNav) {
|
||||
contentPos_ = ImVec2(0, belowAppBar);
|
||||
contentSize_ = ImVec2(windowWidth_, contentAreaHeight);
|
||||
} else {
|
||||
contentPos_ = ImVec2(navSize_.x, belowAppBar);
|
||||
contentSize_ = ImVec2(windowWidth_ - navSize_.x, contentAreaHeight);
|
||||
}
|
||||
|
||||
contentWidth_ = contentSize_.x;
|
||||
contentHeight_ = contentSize_.y;
|
||||
}
|
||||
|
||||
inline bool AppLayout::beginAppBar(const char* title, bool showBack) {
|
||||
ImGui::SetNextWindowPos(appBarPos_);
|
||||
ImGui::SetNextWindowSize(appBarSize_);
|
||||
|
||||
ImGuiWindowFlags flags =
|
||||
ImGuiWindowFlags_NoTitleBar |
|
||||
ImGuiWindowFlags_NoResize |
|
||||
ImGuiWindowFlags_NoMove |
|
||||
ImGuiWindowFlags_NoScrollbar |
|
||||
ImGuiWindowFlags_NoCollapse |
|
||||
ImGuiWindowFlags_NoBringToFrontOnFocus;
|
||||
|
||||
// Use elevated surface color for app bar
|
||||
ImGui::PushStyleColor(ImGuiCol_WindowBg, SurfaceVec4(4));
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(size::AppBarPadding, 0));
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0);
|
||||
|
||||
bool visible = ImGui::Begin("##AppBar", nullptr, flags);
|
||||
|
||||
if (visible) {
|
||||
inAppBar_ = true;
|
||||
|
||||
// Center content vertically
|
||||
float centerY = (size::AppBarHeight - Typography::instance().getFont(TypeStyle::H6)->FontSize) * 0.5f;
|
||||
ImGui::SetCursorPosY(centerY);
|
||||
|
||||
// Menu/back button
|
||||
if (showBack) {
|
||||
if (ImGui::Button("<")) {
|
||||
// Back action - handled by caller
|
||||
}
|
||||
ImGui::SameLine();
|
||||
} else if (navStyle_ != breakpoint::NavStyle::BottomNav) {
|
||||
// Menu button to toggle nav
|
||||
if (ImGui::Button("=")) {
|
||||
toggleNav();
|
||||
}
|
||||
ImGui::SameLine();
|
||||
}
|
||||
|
||||
// Title
|
||||
Typography::instance().text(TypeStyle::H6, title);
|
||||
}
|
||||
|
||||
return visible;
|
||||
}
|
||||
|
||||
inline void AppLayout::endAppBar() {
|
||||
ImGui::End();
|
||||
ImGui::PopStyleVar(2);
|
||||
ImGui::PopStyleColor();
|
||||
inAppBar_ = false;
|
||||
}
|
||||
|
||||
inline bool AppLayout::beginNavigation() {
|
||||
if (navStyle_ == breakpoint::NavStyle::BottomNav) {
|
||||
ImGui::SetNextWindowPos(navPos_);
|
||||
} else {
|
||||
ImGui::SetNextWindowPos(navPos_);
|
||||
}
|
||||
ImGui::SetNextWindowSize(navSize_);
|
||||
|
||||
ImGuiWindowFlags flags =
|
||||
ImGuiWindowFlags_NoTitleBar |
|
||||
ImGuiWindowFlags_NoResize |
|
||||
ImGuiWindowFlags_NoMove |
|
||||
ImGuiWindowFlags_NoCollapse |
|
||||
ImGuiWindowFlags_NoBringToFrontOnFocus;
|
||||
|
||||
// Nav drawer has higher elevation
|
||||
int elevation = (navStyle_ == breakpoint::NavStyle::NavDrawer) ? 16 : 0;
|
||||
ImGui::PushStyleColor(ImGuiCol_WindowBg, SurfaceVec4(elevation));
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0, size::NavSectionPadding));
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0);
|
||||
|
||||
bool visible = ImGui::Begin("##Navigation", nullptr, flags);
|
||||
|
||||
if (visible) {
|
||||
inNav_ = true;
|
||||
}
|
||||
|
||||
return visible;
|
||||
}
|
||||
|
||||
inline void AppLayout::endNavigation() {
|
||||
ImGui::End();
|
||||
ImGui::PopStyleVar(2);
|
||||
ImGui::PopStyleColor();
|
||||
inNav_ = false;
|
||||
}
|
||||
|
||||
inline bool AppLayout::navItem(const char* label, const char* icon, bool selected) {
|
||||
bool compact = !navExpanded_ || navStyle_ == breakpoint::NavStyle::NavRail;
|
||||
|
||||
float itemWidth = navSize_.x;
|
||||
float itemHeight = size::NavItemHeight;
|
||||
|
||||
ImGui::PushID(label);
|
||||
|
||||
// Selection highlight
|
||||
if (selected) {
|
||||
ImVec2 pos = ImGui::GetCursorScreenPos();
|
||||
ImDrawList* drawList = ImGui::GetWindowDrawList();
|
||||
drawList->AddRectFilled(
|
||||
pos,
|
||||
ImVec2(pos.x + itemWidth, pos.y + itemHeight),
|
||||
StateSelected()
|
||||
);
|
||||
}
|
||||
|
||||
// Padding
|
||||
ImGui::SetCursorPosX(size::NavItemPadding);
|
||||
|
||||
// Content
|
||||
bool clicked = false;
|
||||
ImGui::BeginGroup();
|
||||
|
||||
if (compact) {
|
||||
// Rail/collapsed: Icon only, centered
|
||||
CenterHorizontally(size::IconSize);
|
||||
clicked = ImGui::Selectable(icon ? icon : "?", selected, 0, ImVec2(size::IconSize, itemHeight));
|
||||
} else {
|
||||
// Drawer: Icon + label
|
||||
if (icon) {
|
||||
ImGui::Text("%s", icon);
|
||||
ImGui::SameLine();
|
||||
}
|
||||
float selectableWidth = itemWidth - size::NavItemPadding * 2 - (icon ? size::IconSize + spacing::Sm : 0);
|
||||
clicked = ImGui::Selectable(label, selected, 0, ImVec2(selectableWidth, itemHeight));
|
||||
}
|
||||
|
||||
ImGui::EndGroup();
|
||||
ImGui::PopID();
|
||||
|
||||
return clicked;
|
||||
}
|
||||
|
||||
inline void AppLayout::navSection(const char* title) {
|
||||
VSpace(1);
|
||||
|
||||
if (title && navExpanded_) {
|
||||
ImGui::SetCursorPosX(size::NavItemPadding);
|
||||
Typography::instance().textColored(TypeStyle::Caption, OnSurfaceMedium(), title);
|
||||
}
|
||||
|
||||
// Divider
|
||||
ImGui::Separator();
|
||||
VSpace(1);
|
||||
}
|
||||
|
||||
inline bool AppLayout::beginContent() {
|
||||
ImGui::SetNextWindowPos(contentPos_);
|
||||
ImGui::SetNextWindowSize(contentSize_);
|
||||
|
||||
ImGuiWindowFlags flags =
|
||||
ImGuiWindowFlags_NoTitleBar |
|
||||
ImGuiWindowFlags_NoResize |
|
||||
ImGuiWindowFlags_NoMove |
|
||||
ImGuiWindowFlags_NoScrollbar |
|
||||
ImGuiWindowFlags_NoCollapse |
|
||||
ImGuiWindowFlags_NoBringToFrontOnFocus;
|
||||
|
||||
ImGui::PushStyleColor(ImGuiCol_WindowBg, ImGui::ColorConvertU32ToFloat4(Background()));
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(spacing::Md, spacing::Md));
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0);
|
||||
|
||||
bool visible = ImGui::Begin("##Content", nullptr, flags);
|
||||
|
||||
if (visible) {
|
||||
inContent_ = true;
|
||||
}
|
||||
|
||||
return visible;
|
||||
}
|
||||
|
||||
inline void AppLayout::endContent() {
|
||||
ImGui::End();
|
||||
ImGui::PopStyleVar(2);
|
||||
ImGui::PopStyleColor();
|
||||
inContent_ = false;
|
||||
}
|
||||
|
||||
inline bool AppLayout::beginCard(const char* id, const CardLayout& layout) {
|
||||
float width = layout.width > 0 ? layout.width : ImGui::GetContentRegionAvail().x;
|
||||
|
||||
ImGui::PushID(id);
|
||||
|
||||
// Card background
|
||||
ImGui::PushStyleColor(ImGuiCol_ChildBg, SurfaceVec4(layout.elevation));
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_ChildRounding, layout.cornerRadius);
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(layout.padding, layout.padding));
|
||||
|
||||
ImVec2 size(width, layout.minHeight > 0 ? layout.minHeight : 0);
|
||||
bool visible = ImGui::BeginChild(id, size, ImGuiChildFlags_AutoResizeY);
|
||||
|
||||
return visible;
|
||||
}
|
||||
|
||||
inline void AppLayout::endCard() {
|
||||
ImGui::EndChild();
|
||||
ImGui::PopStyleVar(2);
|
||||
ImGui::PopStyleColor();
|
||||
ImGui::PopID();
|
||||
|
||||
// Add spacing after card
|
||||
VSpace(2);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Convenience Function
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* @brief Get the app layout instance
|
||||
*/
|
||||
inline AppLayout& Layout() {
|
||||
return AppLayout::instance();
|
||||
}
|
||||
|
||||
} // namespace material
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
516
src/ui/material/color_theme.cpp
Normal file
516
src/ui/material/color_theme.cpp
Normal file
@@ -0,0 +1,516 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
|
||||
#include "color_theme.h"
|
||||
#include "../schema/ui_schema.h"
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
namespace material {
|
||||
|
||||
// ============================================================================
|
||||
// Color Utility Implementations
|
||||
// ============================================================================
|
||||
|
||||
ImU32 BlendOverlay(ImU32 base, ImU32 overlay, float overlayOpacity)
|
||||
{
|
||||
// Extract components
|
||||
float baseR = ((base >> IM_COL32_R_SHIFT) & 0xFF) / 255.0f;
|
||||
float baseG = ((base >> IM_COL32_G_SHIFT) & 0xFF) / 255.0f;
|
||||
float baseB = ((base >> IM_COL32_B_SHIFT) & 0xFF) / 255.0f;
|
||||
float baseA = ((base >> IM_COL32_A_SHIFT) & 0xFF) / 255.0f;
|
||||
|
||||
float overlayR = ((overlay >> IM_COL32_R_SHIFT) & 0xFF) / 255.0f;
|
||||
float overlayG = ((overlay >> IM_COL32_G_SHIFT) & 0xFF) / 255.0f;
|
||||
float overlayB = ((overlay >> IM_COL32_B_SHIFT) & 0xFF) / 255.0f;
|
||||
|
||||
// Blend
|
||||
float resultR = baseR + (overlayR - baseR) * overlayOpacity;
|
||||
float resultG = baseG + (overlayG - baseG) * overlayOpacity;
|
||||
float resultB = baseB + (overlayB - baseB) * overlayOpacity;
|
||||
|
||||
return IM_COL32(
|
||||
static_cast<uint8_t>(resultR * 255.0f),
|
||||
static_cast<uint8_t>(resultG * 255.0f),
|
||||
static_cast<uint8_t>(resultB * 255.0f),
|
||||
static_cast<uint8_t>(baseA * 255.0f)
|
||||
);
|
||||
}
|
||||
|
||||
ImU32 Lighten(ImU32 color, float amount)
|
||||
{
|
||||
return BlendOverlay(color, IM_COL32(255, 255, 255, 255), amount);
|
||||
}
|
||||
|
||||
ImU32 Darken(ImU32 color, float amount)
|
||||
{
|
||||
return BlendOverlay(color, IM_COL32(0, 0, 0, 255), amount);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Elevation System
|
||||
// ============================================================================
|
||||
|
||||
// Material Design elevation overlay values for dark theme
|
||||
// These values lighten the surface to indicate elevation
|
||||
static const float kElevationOverlays[] = {
|
||||
0.00f, // 0dp
|
||||
0.05f, // 1dp
|
||||
0.07f, // 2dp
|
||||
0.08f, // 3dp
|
||||
0.09f, // 4dp
|
||||
0.10f, // 5dp
|
||||
0.11f, // 6dp
|
||||
0.115f, // 7dp
|
||||
0.12f, // 8dp
|
||||
0.125f, // 9dp
|
||||
0.13f, // 10dp
|
||||
0.135f, // 11dp
|
||||
0.14f, // 12dp
|
||||
0.14f, // 13dp
|
||||
0.14f, // 14dp
|
||||
0.14f, // 15dp
|
||||
0.15f, // 16dp
|
||||
0.15f, // 17dp
|
||||
0.15f, // 18dp
|
||||
0.15f, // 19dp
|
||||
0.15f, // 20dp
|
||||
0.15f, // 21dp
|
||||
0.15f, // 22dp
|
||||
0.15f, // 23dp
|
||||
0.16f // 24dp
|
||||
};
|
||||
|
||||
float GetElevationOverlay(Elevation level)
|
||||
{
|
||||
return GetElevationOverlay(static_cast<int>(level));
|
||||
}
|
||||
|
||||
float GetElevationOverlay(int dp)
|
||||
{
|
||||
if (dp < 0) return 0.0f;
|
||||
if (dp > 24) dp = 24;
|
||||
return kElevationOverlays[dp];
|
||||
}
|
||||
|
||||
ImU32 GetElevatedSurface(const ColorTheme& theme, Elevation level)
|
||||
{
|
||||
return GetElevatedSurface(theme, static_cast<int>(level));
|
||||
}
|
||||
|
||||
ImU32 GetElevatedSurface(const ColorTheme& theme, int dp)
|
||||
{
|
||||
float overlay = GetElevationOverlay(dp);
|
||||
if (overlay <= 0.0f) {
|
||||
return theme.surface;
|
||||
}
|
||||
return BlendOverlay(theme.surface, IM_COL32(255, 255, 255, 255), overlay);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Theme State
|
||||
// ============================================================================
|
||||
|
||||
static ColorTheme s_currentTheme;
|
||||
static bool s_themeInitialized = false;
|
||||
static bool s_isDarkTheme = true;
|
||||
static bool s_backdropActive = false;
|
||||
|
||||
// ============================================================================
|
||||
// DragonX Theme (Dark with Red Primary)
|
||||
// ============================================================================
|
||||
|
||||
ColorTheme GetDragonXColorTheme()
|
||||
{
|
||||
ColorTheme theme;
|
||||
|
||||
// Primary: DragonX Red
|
||||
theme.primary = Hex(0xF64740); // Bright red accent
|
||||
theme.primaryVariant = Hex(0x6F1A07); // Dark maroon variant
|
||||
theme.primaryLight = Hex(0xF87A75); // Lighter red for highlights
|
||||
|
||||
// Secondary: Blue-gray from text palette
|
||||
theme.secondary = Hex(0xBFD1E5); // Light blue-gray
|
||||
theme.secondaryVariant = Hex(0x8BA3BD); // Mid blue-gray
|
||||
theme.secondaryLight = Hex(0xDAE5F0); // Pale blue-gray
|
||||
|
||||
// Surfaces (Dark navy palette)
|
||||
theme.background = Hex(0x121420); // Deep navy background
|
||||
theme.surface = Hex(0x1B2432); // Dark navy surface
|
||||
theme.surfaceVariant = Hex(0x253345); // Lighter navy variant
|
||||
|
||||
// "On" colors — blue-gray text tones
|
||||
theme.onPrimary = Hex(0xFFFFFF); // White on red
|
||||
theme.onSecondary = Hex(0x121420); // Navy on blue-gray
|
||||
theme.onBackground = Hex(0xE2EDF8); // Bright blue-gray on dark
|
||||
theme.onSurface = Hex(0xE2EDF8); // Bright blue-gray on surface
|
||||
theme.onSurfaceMedium = HexA(0xE2EDF8, 179); // 70%
|
||||
theme.onSurfaceDisabled = HexA(0xE2EDF8, 97); // 38%
|
||||
|
||||
// Error
|
||||
theme.error = Hex(0xF64740); // Primary red
|
||||
theme.onError = Hex(0xFFFFFF); // White on error
|
||||
|
||||
// Success/Warning (follow DragonX palette)
|
||||
theme.success = Hex(0xBFD1E5); // Blue-gray secondary
|
||||
theme.onSuccess = Hex(0x121420); // Dark on success
|
||||
theme.warning = Hex(0xF64740); // Primary red
|
||||
theme.onWarning = Hex(0xFFFFFF); // White on warning
|
||||
|
||||
// State overlays (blue-gray tinted for dark navy theme)
|
||||
theme.stateHover = HexA(0xBFD1E5, 10); // 4%
|
||||
theme.stateFocus = HexA(0xBFD1E5, 31); // 12%
|
||||
theme.statePressed = HexA(0xBFD1E5, 25); // 10%
|
||||
theme.stateSelected = HexA(0xBFD1E5, 20); // 8%
|
||||
theme.stateDragged = HexA(0xBFD1E5, 20); // 8%
|
||||
|
||||
// UI elements
|
||||
theme.divider = HexA(0xBFD1E5, 31); // 12% blue-gray
|
||||
theme.outline = HexA(0xBFD1E5, 31); // 12% blue-gray
|
||||
theme.scrim = HexA(0x000000, 128); // 50% black
|
||||
|
||||
return theme;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Material Dark Theme
|
||||
// ============================================================================
|
||||
|
||||
ColorTheme GetMaterialDarkTheme()
|
||||
{
|
||||
ColorTheme theme;
|
||||
|
||||
// Primary: Deep Purple (Material baseline)
|
||||
theme.primary = Hex(0xBB86FC); // Purple 200
|
||||
theme.primaryVariant = Hex(0x3700B3); // Purple 700
|
||||
theme.primaryLight = Hex(0xE1BEE7); // Purple 100
|
||||
|
||||
// Secondary: Teal
|
||||
theme.secondary = Hex(0x03DAC6); // Teal 200
|
||||
theme.secondaryVariant = Hex(0x018786); // Teal 700
|
||||
theme.secondaryLight = Hex(0x64FFDA); // Teal A200
|
||||
|
||||
// Surfaces
|
||||
theme.background = Hex(0x121212);
|
||||
theme.surface = Hex(0x1E1E1E);
|
||||
theme.surfaceVariant = Hex(0x2D2D2D);
|
||||
|
||||
// "On" colors
|
||||
theme.onPrimary = Hex(0x000000);
|
||||
theme.onSecondary = Hex(0x000000);
|
||||
theme.onBackground = Hex(0xFFFFFF);
|
||||
theme.onSurface = Hex(0xFFFFFF);
|
||||
theme.onSurfaceMedium = HexA(0xFFFFFF, 179);
|
||||
theme.onSurfaceDisabled = HexA(0xFFFFFF, 97);
|
||||
|
||||
// Error
|
||||
theme.error = Hex(0xF64740);
|
||||
theme.onError = Hex(0xFFFFFF);
|
||||
|
||||
// Success/Warning (follow DragonX palette)
|
||||
theme.success = Hex(0xBFD1E5);
|
||||
theme.onSuccess = Hex(0x121420);
|
||||
theme.warning = Hex(0xF64740);
|
||||
theme.onWarning = Hex(0xFFFFFF);
|
||||
|
||||
// State overlays
|
||||
theme.stateHover = HexA(0xFFFFFF, 10);
|
||||
theme.stateFocus = HexA(0xFFFFFF, 31);
|
||||
theme.statePressed = HexA(0xFFFFFF, 25);
|
||||
theme.stateSelected = HexA(0xFFFFFF, 20);
|
||||
theme.stateDragged = HexA(0xFFFFFF, 20);
|
||||
|
||||
// UI elements
|
||||
theme.divider = HexA(0xFFFFFF, 31);
|
||||
theme.outline = HexA(0xFFFFFF, 31);
|
||||
theme.scrim = HexA(0x000000, 128);
|
||||
|
||||
return theme;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Material Light Theme
|
||||
// ============================================================================
|
||||
|
||||
ColorTheme GetMaterialLightTheme()
|
||||
{
|
||||
ColorTheme theme;
|
||||
|
||||
// Primary: Deep Purple
|
||||
theme.primary = Hex(0x6200EE); // Purple 500
|
||||
theme.primaryVariant = Hex(0x3700B3); // Purple 700
|
||||
theme.primaryLight = Hex(0xBB86FC); // Purple 200
|
||||
|
||||
// Secondary: Teal
|
||||
theme.secondary = Hex(0x03DAC6); // Teal 200
|
||||
theme.secondaryVariant = Hex(0x018786); // Teal 700
|
||||
theme.secondaryLight = Hex(0x64FFDA);
|
||||
|
||||
// Surfaces (light)
|
||||
theme.background = Hex(0xFFFFFF);
|
||||
theme.surface = Hex(0xFFFFFF);
|
||||
theme.surfaceVariant = Hex(0xF5F5F5);
|
||||
|
||||
// "On" colors
|
||||
theme.onPrimary = Hex(0xFFFFFF);
|
||||
theme.onSecondary = Hex(0x000000);
|
||||
theme.onBackground = Hex(0x000000);
|
||||
theme.onSurface = Hex(0x000000);
|
||||
theme.onSurfaceMedium = HexA(0x000000, 153); // 60%
|
||||
theme.onSurfaceDisabled = HexA(0x000000, 97); // 38%
|
||||
|
||||
// Error
|
||||
theme.error = Hex(0xF64740);
|
||||
theme.onError = Hex(0xFFFFFF);
|
||||
|
||||
// Success/Warning (follow DragonX palette)
|
||||
theme.success = Hex(0xBFD1E5); // Blue-gray
|
||||
theme.onSuccess = Hex(0x121420);
|
||||
theme.warning = Hex(0xF64740); // Primary red
|
||||
theme.onWarning = Hex(0xFFFFFF);
|
||||
|
||||
// State overlays (black for light theme)
|
||||
theme.stateHover = HexA(0x000000, 10);
|
||||
theme.stateFocus = HexA(0x000000, 31);
|
||||
theme.statePressed = HexA(0x000000, 25);
|
||||
theme.stateSelected = HexA(0x000000, 20);
|
||||
theme.stateDragged = HexA(0x000000, 20);
|
||||
|
||||
// UI elements
|
||||
theme.divider = HexA(0x000000, 31);
|
||||
theme.outline = HexA(0x000000, 31);
|
||||
theme.scrim = HexA(0x000000, 128);
|
||||
|
||||
return theme;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Theme Management
|
||||
// ============================================================================
|
||||
|
||||
const ColorTheme& GetCurrentColorTheme()
|
||||
{
|
||||
if (!s_themeInitialized) {
|
||||
s_currentTheme = GetDragonXColorTheme();
|
||||
s_themeInitialized = true;
|
||||
s_isDarkTheme = true;
|
||||
}
|
||||
return s_currentTheme;
|
||||
}
|
||||
|
||||
void SetCurrentColorTheme(const ColorTheme& theme)
|
||||
{
|
||||
s_currentTheme = theme;
|
||||
s_themeInitialized = true;
|
||||
|
||||
// Detect if dark theme based on background luminance
|
||||
float bgR = ((theme.background >> IM_COL32_R_SHIFT) & 0xFF) / 255.0f;
|
||||
float bgG = ((theme.background >> IM_COL32_G_SHIFT) & 0xFF) / 255.0f;
|
||||
float bgB = ((theme.background >> IM_COL32_B_SHIFT) & 0xFF) / 255.0f;
|
||||
float luminance = 0.299f * bgR + 0.587f * bgG + 0.114f * bgB;
|
||||
s_isDarkTheme = (luminance < 0.5f);
|
||||
}
|
||||
|
||||
bool IsDarkTheme()
|
||||
{
|
||||
GetCurrentColorTheme(); // Ensure initialized
|
||||
return s_isDarkTheme;
|
||||
}
|
||||
|
||||
void SetBackdropActive(bool active)
|
||||
{
|
||||
s_backdropActive = active;
|
||||
}
|
||||
|
||||
bool IsBackdropActive()
|
||||
{
|
||||
return s_backdropActive;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Apply Theme to ImGui
|
||||
// ============================================================================
|
||||
|
||||
void ApplyColorThemeToImGui(const ColorTheme& theme)
|
||||
{
|
||||
SetCurrentColorTheme(theme);
|
||||
|
||||
ImGuiStyle& style = ImGui::GetStyle();
|
||||
ImVec4* colors = style.Colors;
|
||||
const auto& S = schema::UI();
|
||||
|
||||
// Helper to convert ImU32 to ImVec4
|
||||
auto toVec4 = [](ImU32 col) -> ImVec4 {
|
||||
return ImGui::ColorConvertU32ToFloat4(col);
|
||||
};
|
||||
|
||||
// Backdrop transparency: when DWM Acrylic is active, make backgrounds
|
||||
// semi-transparent so the blurred + noisy Acrylic material shows through.
|
||||
// A dark blue gradient is drawn on the background draw list (in app.cpp)
|
||||
// for a stylish tinted look while retaining blur + noise.
|
||||
auto bdElem = [&](const char* key, float fb) {
|
||||
float v = S.drawElement("backdrop", key).size;
|
||||
return v >= 0 ? v : fb;
|
||||
};
|
||||
const float bgAlpha = s_backdropActive ? bdElem("background-alpha", 0.40f) : 1.0f;
|
||||
// When acrylic is active, ChildBg must be fully transparent so the
|
||||
// sharp background doesn't bleed through behind acrylic cards.
|
||||
// DrawGlassPanel draws the blurred card fill directly.
|
||||
const float surfAlpha = s_backdropActive ? 0.0f : 1.0f;
|
||||
const float tabAlpha = s_backdropActive ? 0.75f : 1.0f;
|
||||
|
||||
// Background colors
|
||||
colors[ImGuiCol_WindowBg] = toVec4(WithAlphaF(theme.background, bgAlpha));
|
||||
colors[ImGuiCol_ChildBg] = toVec4(WithAlphaF(GetElevatedSurface(theme, 1), surfAlpha));
|
||||
colors[ImGuiCol_PopupBg] = toVec4(WithAlphaF(GetElevatedSurface(theme, 8), 0.95f));
|
||||
|
||||
// Borders — dark themes use white outlines; light themes use dark outlines
|
||||
if (s_isDarkTheme) {
|
||||
colors[ImGuiCol_Border] = ImVec4(1.0f, 1.0f, 1.0f, 0.15f);
|
||||
colors[ImGuiCol_BorderShadow] = ImVec4(0, 0, 0, 0.08f);
|
||||
} else {
|
||||
colors[ImGuiCol_Border] = ImVec4(0, 0, 0, 0.12f);
|
||||
colors[ImGuiCol_BorderShadow] = ImVec4(0, 0, 0, 0.04f);
|
||||
}
|
||||
|
||||
// Frame backgrounds (inputs, checkboxes) — glass-card in dark, subtle grey in light
|
||||
if (s_isDarkTheme) {
|
||||
colors[ImGuiCol_FrameBg] = ImVec4(1.0f, 1.0f, 1.0f, 0.07f);
|
||||
colors[ImGuiCol_FrameBgHovered] = ImVec4(1.0f, 1.0f, 1.0f, 0.10f);
|
||||
colors[ImGuiCol_FrameBgActive] = ImVec4(1.0f, 1.0f, 1.0f, 0.15f);
|
||||
} else {
|
||||
colors[ImGuiCol_FrameBg] = ImVec4(0, 0, 0, 0.04f);
|
||||
colors[ImGuiCol_FrameBgHovered] = ImVec4(0, 0, 0, 0.07f);
|
||||
colors[ImGuiCol_FrameBgActive] = ImVec4(0, 0, 0, 0.10f);
|
||||
}
|
||||
|
||||
// Title bar
|
||||
colors[ImGuiCol_TitleBg] = toVec4(WithAlphaF(GetElevatedSurface(theme, 4), surfAlpha));
|
||||
colors[ImGuiCol_TitleBgActive] = toVec4(theme.primary);
|
||||
colors[ImGuiCol_TitleBgCollapsed] = toVec4(WithAlphaF(GetElevatedSurface(theme, 4), 0.75f));
|
||||
|
||||
// Menu bar — fully transparent, no background
|
||||
colors[ImGuiCol_MenuBarBg] = ImVec4(0, 0, 0, 0);
|
||||
|
||||
// Scrollbar — minimal style (dark overlays for light, white overlays for dark)
|
||||
colors[ImGuiCol_ScrollbarBg] = ImVec4(0, 0, 0, 0);
|
||||
if (s_isDarkTheme) {
|
||||
colors[ImGuiCol_ScrollbarGrab] = ImVec4(1.0f, 1.0f, 1.0f, 15.0f / 255.0f);
|
||||
colors[ImGuiCol_ScrollbarGrabHovered] = ImVec4(1.0f, 1.0f, 1.0f, 30.0f / 255.0f);
|
||||
colors[ImGuiCol_ScrollbarGrabActive] = ImVec4(1.0f, 1.0f, 1.0f, 45.0f / 255.0f);
|
||||
} else {
|
||||
colors[ImGuiCol_ScrollbarGrab] = ImVec4(0, 0, 0, 0.10f);
|
||||
colors[ImGuiCol_ScrollbarGrabHovered] = ImVec4(0, 0, 0, 0.18f);
|
||||
colors[ImGuiCol_ScrollbarGrabActive] = ImVec4(0, 0, 0, 0.25f);
|
||||
}
|
||||
|
||||
// Checkmarks, sliders
|
||||
colors[ImGuiCol_CheckMark] = toVec4(theme.primary);
|
||||
colors[ImGuiCol_SliderGrab] = toVec4(theme.primary);
|
||||
colors[ImGuiCol_SliderGrabActive] = toVec4(theme.primaryLight);
|
||||
|
||||
// Buttons — glass-style overlays (white on dark, dark on light)
|
||||
if (s_isDarkTheme) {
|
||||
colors[ImGuiCol_Button] = ImVec4(1.0f, 1.0f, 1.0f, 15.0f / 255.0f);
|
||||
colors[ImGuiCol_ButtonHovered] = ImVec4(1.0f, 1.0f, 1.0f, 30.0f / 255.0f);
|
||||
colors[ImGuiCol_ButtonActive] = ImVec4(1.0f, 1.0f, 1.0f, 45.0f / 255.0f);
|
||||
} else {
|
||||
colors[ImGuiCol_Button] = ImVec4(0, 0, 0, 0.05f);
|
||||
colors[ImGuiCol_ButtonHovered] = ImVec4(0, 0, 0, 0.08f);
|
||||
colors[ImGuiCol_ButtonActive] = ImVec4(0, 0, 0, 0.12f);
|
||||
}
|
||||
|
||||
// Headers (collapsing headers, tree nodes, selectable, menu items)
|
||||
colors[ImGuiCol_Header] = toVec4(WithAlphaF(theme.primary, 0.6f));
|
||||
colors[ImGuiCol_HeaderHovered] = toVec4(WithAlphaF(theme.primary, 0.8f));
|
||||
colors[ImGuiCol_HeaderActive] = toVec4(theme.primary);
|
||||
|
||||
// Separator
|
||||
colors[ImGuiCol_Separator] = toVec4(theme.divider);
|
||||
colors[ImGuiCol_SeparatorHovered] = toVec4(theme.primaryLight);
|
||||
colors[ImGuiCol_SeparatorActive] = toVec4(theme.primary);
|
||||
|
||||
// Resize grip
|
||||
colors[ImGuiCol_ResizeGrip] = toVec4(WithAlphaF(theme.primary, 0.25f));
|
||||
colors[ImGuiCol_ResizeGripHovered] = toVec4(WithAlphaF(theme.primary, 0.67f));
|
||||
colors[ImGuiCol_ResizeGripActive] = toVec4(WithAlphaF(theme.primary, 0.95f));
|
||||
|
||||
// Tabs
|
||||
ImU32 tabBg = GetElevatedSurface(theme, 4);
|
||||
colors[ImGuiCol_Tab] = toVec4(WithAlphaF(tabBg, tabAlpha));
|
||||
colors[ImGuiCol_TabHovered] = toVec4(WithAlphaF(Lighten(theme.primary, 0.15f), tabAlpha));
|
||||
colors[ImGuiCol_TabSelected] = toVec4(WithAlphaF(theme.primary, tabAlpha));
|
||||
colors[ImGuiCol_TabSelectedOverline] = toVec4(theme.secondary);
|
||||
colors[ImGuiCol_TabDimmed] = toVec4(WithAlphaF(Darken(tabBg, 0.2f), tabAlpha));
|
||||
colors[ImGuiCol_TabDimmedSelected] = toVec4(WithAlphaF(theme.primary, tabAlpha * 0.7f));
|
||||
|
||||
// Plot colors
|
||||
colors[ImGuiCol_PlotLines] = toVec4(theme.primary);
|
||||
colors[ImGuiCol_PlotLinesHovered] = toVec4(theme.secondary);
|
||||
colors[ImGuiCol_PlotHistogram] = toVec4(theme.primary);
|
||||
colors[ImGuiCol_PlotHistogramHovered] = toVec4(theme.secondary);
|
||||
|
||||
// Tables
|
||||
colors[ImGuiCol_TableHeaderBg] = toVec4(WithAlphaF(GetElevatedSurface(theme, 2), surfAlpha));
|
||||
colors[ImGuiCol_TableBorderStrong] = toVec4(theme.outline);
|
||||
colors[ImGuiCol_TableBorderLight] = toVec4(theme.divider);
|
||||
colors[ImGuiCol_TableRowBg] = ImVec4(0, 0, 0, 0);
|
||||
colors[ImGuiCol_TableRowBgAlt] = toVec4(WithAlphaF(theme.onSurface, 0.03f));
|
||||
|
||||
// Text
|
||||
colors[ImGuiCol_Text] = toVec4(theme.onSurface);
|
||||
colors[ImGuiCol_TextDisabled] = toVec4(theme.onSurfaceDisabled);
|
||||
colors[ImGuiCol_TextSelectedBg] = toVec4(WithAlphaF(theme.primary, 0.43f));
|
||||
|
||||
// Drag/drop
|
||||
colors[ImGuiCol_DragDropTarget] = toVec4(WithAlphaF(theme.secondary, 0.9f));
|
||||
|
||||
// Navigation highlight
|
||||
colors[ImGuiCol_NavCursor] = ImVec4(0.0f, 0.0f, 0.0f, 0.0f);
|
||||
colors[ImGuiCol_NavWindowingHighlight]= ImVec4(1, 1, 1, 0.7f);
|
||||
colors[ImGuiCol_NavWindowingDimBg] = ImVec4(0.8f, 0.8f, 0.8f, 0.2f);
|
||||
|
||||
// Modal window dim
|
||||
colors[ImGuiCol_ModalWindowDimBg] = toVec4(theme.scrim);
|
||||
|
||||
// Style adjustments (from UISchema)
|
||||
auto brElem = S.drawElement("style", "border-radius");
|
||||
style.WindowRounding = brElem.extraFloats.count("window") ? brElem.extraFloats.at("window") : 6.0f;
|
||||
style.ChildRounding = brElem.extraFloats.count("child") ? brElem.extraFloats.at("child") : 4.0f;
|
||||
style.FrameRounding = brElem.extraFloats.count("frame") ? brElem.extraFloats.at("frame") : 4.0f;
|
||||
style.PopupRounding = brElem.extraFloats.count("popup") ? brElem.extraFloats.at("popup") : 4.0f;
|
||||
style.ScrollbarRounding = brElem.extraFloats.count("scrollbar") ? brElem.extraFloats.at("scrollbar") : 4.0f;
|
||||
style.GrabRounding = brElem.extraFloats.count("grab") ? brElem.extraFloats.at("grab") : 4.0f;
|
||||
style.TabRounding = brElem.extraFloats.count("tab") ? brElem.extraFloats.at("tab") : 4.0f;
|
||||
|
||||
style.WindowTitleAlign = ImVec2(0.0f, 0.5f); // Left-aligned titles (Material)
|
||||
auto sElem = [&](const char* key, float fb) {
|
||||
float v = S.drawElement("style", key).size;
|
||||
return v >= 0 ? v : fb;
|
||||
};
|
||||
style.WindowPadding = ImVec2(sElem("window-padding-x", 10.0f), sElem("window-padding-y", 10.0f));
|
||||
style.FramePadding = ImVec2(sElem("frame-padding-x", 8.0f), sElem("frame-padding-y", 4.0f));
|
||||
style.ItemSpacing = ImVec2(sElem("item-spacing-x", 8.0f), sElem("item-spacing-y", 6.0f));
|
||||
style.ItemInnerSpacing = ImVec2(sElem("item-inner-spacing-x", 6.0f), sElem("item-inner-spacing-y", 4.0f));
|
||||
style.IndentSpacing = sElem("indent-spacing", 20.0f);
|
||||
|
||||
style.ScrollbarSize = sElem("scrollbar-size", 6.0f);
|
||||
style.GrabMinSize = sElem("grab-min-size", 8.0f);
|
||||
|
||||
auto bwElem = S.drawElement("style", "border-width");
|
||||
style.WindowBorderSize = bwElem.extraFloats.count("window") ? bwElem.extraFloats.at("window") : 1.0f;
|
||||
style.ChildBorderSize = bwElem.extraFloats.count("child") ? bwElem.extraFloats.at("child") : 1.0f;
|
||||
style.PopupBorderSize = bwElem.extraFloats.count("popup") ? bwElem.extraFloats.at("popup") : 1.0f;
|
||||
style.FrameBorderSize = bwElem.extraFloats.count("frame") ? bwElem.extraFloats.at("frame") : 0.0f;
|
||||
style.TabBorderSize = 0.0f;
|
||||
|
||||
style.AntiAliasedLines = true;
|
||||
style.AntiAliasedFill = true;
|
||||
}
|
||||
|
||||
} // namespace material
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
284
src/ui/material/color_theme.h
Normal file
284
src/ui/material/color_theme.h
Normal file
@@ -0,0 +1,284 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
|
||||
// Material Design 2 Color Theme System
|
||||
// Based on https://m2.material.io/design/color/the-color-system.html
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "imgui.h"
|
||||
#include <cstdint>
|
||||
|
||||
// Windows defines RGB as a macro - undefine it to avoid conflicts
|
||||
#ifdef RGB
|
||||
#undef RGB
|
||||
#endif
|
||||
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
namespace material {
|
||||
|
||||
// ============================================================================
|
||||
// Color Utilities
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* @brief Convert ImU32 to ImVec4
|
||||
*/
|
||||
inline ImVec4 ColorU32ToVec4(ImU32 color) {
|
||||
return ImGui::ColorConvertU32ToFloat4(color);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Convert ImVec4 to ImU32
|
||||
*/
|
||||
inline ImU32 ColorVec4ToU32(const ImVec4& color) {
|
||||
return ImGui::ColorConvertFloat4ToU32(color);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Create color from RGB values (0-255)
|
||||
*/
|
||||
constexpr ImU32 MakeRGB(uint8_t r, uint8_t g, uint8_t b) {
|
||||
return IM_COL32(r, g, b, 255);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Create color from RGBA values (0-255)
|
||||
*/
|
||||
constexpr ImU32 MakeRGBA(uint8_t r, uint8_t g, uint8_t b, uint8_t a) {
|
||||
return IM_COL32(r, g, b, a);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Create color from hex value (0xRRGGBB)
|
||||
*/
|
||||
constexpr ImU32 Hex(uint32_t hex) {
|
||||
return IM_COL32(
|
||||
(hex >> 16) & 0xFF,
|
||||
(hex >> 8) & 0xFF,
|
||||
hex & 0xFF,
|
||||
255
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Create color from hex value with alpha (0xRRGGBB, alpha 0-255)
|
||||
*/
|
||||
constexpr ImU32 HexA(uint32_t hex, uint8_t alpha) {
|
||||
return IM_COL32(
|
||||
(hex >> 16) & 0xFF,
|
||||
(hex >> 8) & 0xFF,
|
||||
hex & 0xFF,
|
||||
alpha
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Apply alpha to existing color
|
||||
*/
|
||||
inline ImU32 WithAlpha(ImU32 color, uint8_t alpha) {
|
||||
return (color & 0x00FFFFFF) | (static_cast<ImU32>(alpha) << 24);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Apply alpha percentage (0.0 - 1.0) to existing color
|
||||
*/
|
||||
inline ImU32 WithAlphaF(ImU32 color, float alpha) {
|
||||
return WithAlpha(color, static_cast<uint8_t>(alpha * 255.0f));
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Blend two colors with overlay opacity
|
||||
*/
|
||||
ImU32 BlendOverlay(ImU32 base, ImU32 overlay, float overlayOpacity);
|
||||
|
||||
/**
|
||||
* @brief Lighten a color by percentage (0.0 - 1.0)
|
||||
*/
|
||||
ImU32 Lighten(ImU32 color, float amount);
|
||||
|
||||
/**
|
||||
* @brief Darken a color by percentage (0.0 - 1.0)
|
||||
*/
|
||||
ImU32 Darken(ImU32 color, float amount);
|
||||
|
||||
// ============================================================================
|
||||
// Material Design Color Theme
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* @brief Complete Material Design 2 color theme
|
||||
*
|
||||
* Based on the Material Design color system with:
|
||||
* - Primary and secondary brand colors
|
||||
* - Surface and background colors
|
||||
* - "On" colors for content on surfaces
|
||||
* - State overlay colors
|
||||
* - Error colors
|
||||
*/
|
||||
struct ColorTheme {
|
||||
// ========================================================================
|
||||
// Brand Colors - Primary
|
||||
// ========================================================================
|
||||
ImU32 primary; // Main brand color (displayed most frequently)
|
||||
ImU32 primaryVariant; // Darker variant for contrast
|
||||
ImU32 primaryLight; // Lighter variant for highlights
|
||||
|
||||
// ========================================================================
|
||||
// Brand Colors - Secondary (Accent)
|
||||
// ========================================================================
|
||||
ImU32 secondary; // Accent color for emphasis
|
||||
ImU32 secondaryVariant; // Darker variant
|
||||
ImU32 secondaryLight; // Lighter variant
|
||||
|
||||
// ========================================================================
|
||||
// Surface Colors
|
||||
// ========================================================================
|
||||
ImU32 background; // App background
|
||||
ImU32 surface; // Card/dialog surfaces (elevation 0dp)
|
||||
ImU32 surfaceVariant; // Alternative surface
|
||||
|
||||
// ========================================================================
|
||||
// "On" Colors - Content colors for surfaces
|
||||
// ========================================================================
|
||||
ImU32 onPrimary; // Text/icons on primary color
|
||||
ImU32 onSecondary; // Text/icons on secondary color
|
||||
ImU32 onBackground; // Text/icons on background
|
||||
ImU32 onSurface; // Text/icons on surface (high emphasis)
|
||||
ImU32 onSurfaceMedium; // Medium emphasis text (70% opacity)
|
||||
ImU32 onSurfaceDisabled; // Disabled text (38% opacity)
|
||||
|
||||
// ========================================================================
|
||||
// Error Colors
|
||||
// ========================================================================
|
||||
ImU32 error; // Error indication
|
||||
ImU32 onError; // Text on error color
|
||||
|
||||
// ========================================================================
|
||||
// Success/Warning Colors (Material extension)
|
||||
// ========================================================================
|
||||
ImU32 success; // Success indication
|
||||
ImU32 onSuccess; // Text on success
|
||||
ImU32 warning; // Warning indication
|
||||
ImU32 onWarning; // Text on warning
|
||||
|
||||
// ========================================================================
|
||||
// State Overlay Colors (applied to surfaces)
|
||||
// ========================================================================
|
||||
ImU32 stateHover; // Hover overlay (4% white/black)
|
||||
ImU32 stateFocus; // Focus overlay (12%)
|
||||
ImU32 statePressed; // Pressed overlay (10%)
|
||||
ImU32 stateSelected; // Selected overlay (8%)
|
||||
ImU32 stateDragged; // Dragged overlay (8%)
|
||||
|
||||
// ========================================================================
|
||||
// Additional UI Colors
|
||||
// ========================================================================
|
||||
ImU32 divider; // Divider lines
|
||||
ImU32 outline; // Outline/border color
|
||||
ImU32 scrim; // Modal overlay background
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Elevation System (Dark Theme)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* @brief Material Design elevation levels
|
||||
*
|
||||
* In dark theme, elevation is shown through surface color lightening
|
||||
* rather than shadows.
|
||||
*/
|
||||
enum class Elevation {
|
||||
Dp0 = 0, // 0dp - Background
|
||||
Dp1 = 1, // 1dp - Cards at rest, Search bar
|
||||
Dp2 = 2, // 2dp - Buttons at rest
|
||||
Dp3 = 3, // 3dp - Refresh indicator
|
||||
Dp4 = 4, // 4dp - App bar
|
||||
Dp6 = 6, // 6dp - FAB, Snackbar
|
||||
Dp8 = 8, // 8dp - Bottom sheet, Menu, Cards picked up
|
||||
Dp12 = 12, // 12dp - FAB pressed
|
||||
Dp16 = 16, // 16dp - Navigation drawer, Modal sheets
|
||||
Dp24 = 24 // 24dp - Dialog
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Get overlay opacity for elevation level (dark theme)
|
||||
*
|
||||
* Material Design uses white overlay on dark surfaces to indicate elevation.
|
||||
* Higher elevation = more white overlay = lighter surface.
|
||||
*/
|
||||
float GetElevationOverlay(Elevation level);
|
||||
float GetElevationOverlay(int dp);
|
||||
|
||||
/**
|
||||
* @brief Calculate surface color for given elevation
|
||||
*
|
||||
* Applies white overlay to base surface color based on elevation.
|
||||
*/
|
||||
ImU32 GetElevatedSurface(const ColorTheme& theme, Elevation level);
|
||||
ImU32 GetElevatedSurface(const ColorTheme& theme, int dp);
|
||||
|
||||
// ============================================================================
|
||||
// Predefined Themes
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* @brief DragonX branded dark theme with red primary color
|
||||
*/
|
||||
ColorTheme GetDragonXColorTheme();
|
||||
|
||||
/**
|
||||
* @brief Standard Material dark theme
|
||||
*/
|
||||
ColorTheme GetMaterialDarkTheme();
|
||||
|
||||
/**
|
||||
* @brief Standard Material light theme
|
||||
*/
|
||||
ColorTheme GetMaterialLightTheme();
|
||||
|
||||
// ============================================================================
|
||||
// Theme Management
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* @brief Get current active color theme
|
||||
*/
|
||||
const ColorTheme& GetCurrentColorTheme();
|
||||
|
||||
/**
|
||||
* @brief Set current color theme
|
||||
*/
|
||||
void SetCurrentColorTheme(const ColorTheme& theme);
|
||||
|
||||
/**
|
||||
* @brief Check if current theme is dark mode
|
||||
*/
|
||||
bool IsDarkTheme();
|
||||
|
||||
/**
|
||||
* @brief Set whether OS-level backdrop (DWM Mica/Acrylic) is active
|
||||
*
|
||||
* When active, background and surface colors become semi-transparent
|
||||
* so the compositor's blur effect shows through.
|
||||
*/
|
||||
void SetBackdropActive(bool active);
|
||||
|
||||
/**
|
||||
* @brief Check if OS-level backdrop is active
|
||||
*/
|
||||
bool IsBackdropActive();
|
||||
|
||||
/**
|
||||
* @brief Apply Material Design color theme to ImGui
|
||||
*
|
||||
* This updates ImGui's color palette to use the Material theme colors.
|
||||
*/
|
||||
void ApplyColorThemeToImGui(const ColorTheme& theme);
|
||||
|
||||
} // namespace material
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
201
src/ui/material/colors.h
Normal file
201
src/ui/material/colors.h
Normal file
@@ -0,0 +1,201 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
|
||||
// ============================================================================
|
||||
// Material Design Color Tokens
|
||||
// ============================================================================
|
||||
//
|
||||
// This header provides convenient access to Material Design color tokens.
|
||||
// Use these instead of hardcoded colors to maintain theme consistency.
|
||||
//
|
||||
// Usage:
|
||||
// #include "ui/material/colors.h"
|
||||
//
|
||||
// // Get surface colors at different elevations
|
||||
// ImU32 cardBg = dragonx::ui::material::Surface(4); // 4dp elevation
|
||||
//
|
||||
// // Get state colors
|
||||
// ImU32 hovered = dragonx::ui::material::Primary() | dragonx::ui::material::StateHover();
|
||||
//
|
||||
// // Use in ImGui
|
||||
// ImGui::PushStyleColor(ImGuiCol_ChildBg, ImGui::ColorConvertU32ToFloat4(cardBg));
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "color_theme.h"
|
||||
#include "../schema/ui_schema.h"
|
||||
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
namespace material {
|
||||
|
||||
// ============================================================================
|
||||
// Color Token Accessors (Inline for performance)
|
||||
// ============================================================================
|
||||
|
||||
// Primary colors
|
||||
inline ImU32 Primary() { return GetCurrentColorTheme().primary; }
|
||||
inline ImU32 PrimaryVariant() { return GetCurrentColorTheme().primaryVariant; }
|
||||
inline ImU32 PrimaryLight() { return GetCurrentColorTheme().primaryLight; }
|
||||
|
||||
// Secondary colors
|
||||
inline ImU32 Secondary() { return GetCurrentColorTheme().secondary; }
|
||||
inline ImU32 SecondaryVariant() { return GetCurrentColorTheme().secondaryVariant; }
|
||||
inline ImU32 SecondaryLight() { return GetCurrentColorTheme().secondaryLight; }
|
||||
|
||||
// Surface colors (semi-transparent when OS backdrop is active)
|
||||
inline ImU32 Background() {
|
||||
ImU32 bg = GetCurrentColorTheme().background;
|
||||
if (!IsBackdropActive()) return bg;
|
||||
float alpha = dragonx::ui::schema::UI().drawElement("backdrop", "background-inline-alpha").size;
|
||||
if (alpha < 0) alpha = 0.30f;
|
||||
return WithAlphaF(bg, alpha);
|
||||
}
|
||||
inline ImU32 Surface() {
|
||||
ImU32 s = GetCurrentColorTheme().surface;
|
||||
if (!IsBackdropActive()) return s;
|
||||
float alpha = dragonx::ui::schema::UI().drawElement("backdrop", "surface-inline-alpha").size;
|
||||
if (alpha < 0) alpha = 0.55f;
|
||||
return WithAlphaF(s, alpha);
|
||||
}
|
||||
inline ImU32 SurfaceVariant() { return GetCurrentColorTheme().surfaceVariant; }
|
||||
|
||||
// Elevated surfaces - use for cards, dialogs, menus
|
||||
inline ImU32 Surface(int dp) {
|
||||
ImU32 s = GetElevatedSurface(GetCurrentColorTheme(), dp);
|
||||
if (!IsBackdropActive()) return s;
|
||||
float alpha = dragonx::ui::schema::UI().drawElement("backdrop", "surface-inline-alpha").size;
|
||||
if (alpha < 0) alpha = 0.55f;
|
||||
return WithAlphaF(s, alpha);
|
||||
}
|
||||
inline ImU32 SurfaceAt(Elevation level) { return GetElevatedSurface(GetCurrentColorTheme(), level); }
|
||||
|
||||
// "On" colors (text on various backgrounds)
|
||||
inline ImU32 OnPrimary() { return GetCurrentColorTheme().onPrimary; }
|
||||
inline ImU32 OnSecondary() { return GetCurrentColorTheme().onSecondary; }
|
||||
inline ImU32 OnBackground() { return GetCurrentColorTheme().onBackground; }
|
||||
inline ImU32 OnSurface() { return GetCurrentColorTheme().onSurface; }
|
||||
inline ImU32 OnSurfaceMedium() { return GetCurrentColorTheme().onSurfaceMedium; }
|
||||
inline ImU32 OnSurfaceDisabled() { return GetCurrentColorTheme().onSurfaceDisabled; }
|
||||
|
||||
// Semantic colors
|
||||
inline ImU32 Error() { return GetCurrentColorTheme().error; }
|
||||
inline ImU32 OnError() { return GetCurrentColorTheme().onError; }
|
||||
inline ImU32 Success() { return GetCurrentColorTheme().success; }
|
||||
inline ImU32 OnSuccess() { return GetCurrentColorTheme().onSuccess; }
|
||||
inline ImU32 Warning() { return GetCurrentColorTheme().warning; }
|
||||
inline ImU32 OnWarning() { return GetCurrentColorTheme().onWarning; }
|
||||
|
||||
// State overlay colors (use with BlendOverlay)
|
||||
inline ImU32 StateHover() { return GetCurrentColorTheme().stateHover; }
|
||||
inline ImU32 StateFocus() { return GetCurrentColorTheme().stateFocus; }
|
||||
inline ImU32 StatePressed() { return GetCurrentColorTheme().statePressed; }
|
||||
inline ImU32 StateSelected() { return GetCurrentColorTheme().stateSelected; }
|
||||
inline ImU32 StateDragged() { return GetCurrentColorTheme().stateDragged; }
|
||||
|
||||
// UI structure
|
||||
inline ImU32 Divider() { return GetCurrentColorTheme().divider; }
|
||||
inline ImU32 Outline() { return GetCurrentColorTheme().outline; }
|
||||
inline ImU32 Scrim() { return GetCurrentColorTheme().scrim; }
|
||||
|
||||
// ============================================================================
|
||||
// ImVec4 Variants (for direct use with ImGui style colors)
|
||||
// ============================================================================
|
||||
|
||||
inline ImVec4 PrimaryVec4() { return ImGui::ColorConvertU32ToFloat4(Primary()); }
|
||||
inline ImVec4 SecondaryVec4() { return ImGui::ColorConvertU32ToFloat4(Secondary()); }
|
||||
inline ImVec4 SurfaceVec4() { return ImGui::ColorConvertU32ToFloat4(Surface()); }
|
||||
inline ImVec4 SurfaceVec4(int dp) { return ImGui::ColorConvertU32ToFloat4(Surface(dp)); }
|
||||
inline ImVec4 OnSurfaceVec4() { return ImGui::ColorConvertU32ToFloat4(OnSurface()); }
|
||||
inline ImVec4 ErrorVec4() { return ImGui::ColorConvertU32ToFloat4(Error()); }
|
||||
inline ImVec4 SuccessVec4() { return ImGui::ColorConvertU32ToFloat4(Success()); }
|
||||
inline ImVec4 WarningVec4() { return ImGui::ColorConvertU32ToFloat4(Warning()); }
|
||||
|
||||
// ============================================================================
|
||||
// Convenience Functions for Common Patterns
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* @brief Get color with applied state overlay
|
||||
*
|
||||
* @param base Base color
|
||||
* @param state State overlay (StateHover, StateFocus, etc.)
|
||||
* @return Color with state applied
|
||||
*
|
||||
* Usage:
|
||||
* ImU32 hoveredButton = WithState(Primary(), StateHover());
|
||||
*/
|
||||
inline ImU32 WithState(ImU32 base, ImU32 state)
|
||||
{
|
||||
return BlendOverlay(base, state, 1.0f);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Get button color for current state
|
||||
*
|
||||
* @param isHovered Button is hovered
|
||||
* @param isActive Button is pressed
|
||||
* @return Appropriate button color
|
||||
*/
|
||||
inline ImU32 ButtonColor(bool isHovered, bool isActive)
|
||||
{
|
||||
if (isActive) return WithState(Primary(), StatePressed());
|
||||
if (isHovered) return WithState(Primary(), StateHover());
|
||||
return Primary();
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Get surface color for card at specified elevation
|
||||
*
|
||||
* @param elevation Elevation in dp (0-24)
|
||||
* @return Surface color with elevation overlay
|
||||
*/
|
||||
inline ImU32 CardSurface(int elevation = 2)
|
||||
{
|
||||
return Surface(elevation);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Get surface color for dialog/modal
|
||||
*
|
||||
* @return Surface color at 24dp elevation
|
||||
*/
|
||||
inline ImU32 DialogSurface()
|
||||
{
|
||||
return SurfaceAt(Elevation::Dp24);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Get surface color for menu/popup
|
||||
*
|
||||
* @return Surface color at 8dp elevation
|
||||
*/
|
||||
inline ImU32 MenuSurface()
|
||||
{
|
||||
return SurfaceAt(Elevation::Dp8);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Get surface color for app bar/toolbar
|
||||
*
|
||||
* @return Surface color at 4dp elevation
|
||||
*/
|
||||
inline ImU32 AppBarSurface()
|
||||
{
|
||||
return SurfaceAt(Elevation::Dp4);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Get surface color for nav drawer
|
||||
*
|
||||
* @return Surface color at 16dp elevation
|
||||
*/
|
||||
inline ImU32 NavDrawerSurface()
|
||||
{
|
||||
return SurfaceAt(Elevation::Dp16);
|
||||
}
|
||||
|
||||
} // namespace material
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
330
src/ui/material/components/app_bar.h
Normal file
330
src/ui/material/components/app_bar.h
Normal file
@@ -0,0 +1,330 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "../colors.h"
|
||||
#include "../typography.h"
|
||||
#include "../layout.h"
|
||||
#include "../../schema/ui_schema.h"
|
||||
#include "buttons.h"
|
||||
#include "imgui.h"
|
||||
#include "imgui_internal.h"
|
||||
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
namespace material {
|
||||
|
||||
// ============================================================================
|
||||
// Material Design App Bar Component
|
||||
// ============================================================================
|
||||
// Based on https://m2.material.io/components/app-bars-top
|
||||
//
|
||||
// The top app bar displays information and actions relating to the current
|
||||
// screen.
|
||||
|
||||
enum class AppBarType {
|
||||
Regular, // Standard height (56/64dp)
|
||||
Prominent, // Extended height for larger titles
|
||||
Dense // Smaller height for desktop
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief App bar configuration
|
||||
*/
|
||||
struct AppBarSpec {
|
||||
AppBarType type = AppBarType::Regular;
|
||||
ImU32 backgroundColor = 0; // 0 = use elevated surface
|
||||
bool elevated = true; // Show elevation
|
||||
bool centerTitle = false; // Center title (default: left)
|
||||
float elevation = 4.0f; // Elevation in dp
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Begin a top app bar
|
||||
*
|
||||
* @param id Unique identifier
|
||||
* @param spec App bar configuration
|
||||
* @return true if app bar is visible
|
||||
*/
|
||||
bool BeginAppBar(const char* id, const AppBarSpec& spec = AppBarSpec());
|
||||
|
||||
/**
|
||||
* @brief End app bar
|
||||
*/
|
||||
void EndAppBar();
|
||||
|
||||
/**
|
||||
* @brief Set app bar navigation icon (left side)
|
||||
*
|
||||
* @param icon Icon text (e.g., "☰" for menu)
|
||||
* @param tooltip Optional tooltip
|
||||
* @return true if clicked
|
||||
*/
|
||||
bool AppBarNavIcon(const char* icon, const char* tooltip = nullptr);
|
||||
|
||||
/**
|
||||
* @brief Set app bar title
|
||||
*/
|
||||
void AppBarTitle(const char* title);
|
||||
|
||||
/**
|
||||
* @brief Add app bar action button (right side)
|
||||
*
|
||||
* @param icon Icon text
|
||||
* @param tooltip Optional tooltip
|
||||
* @return true if clicked
|
||||
*/
|
||||
bool AppBarAction(const char* icon, const char* tooltip = nullptr);
|
||||
|
||||
/**
|
||||
* @brief Begin app bar action menu (for overflow)
|
||||
*/
|
||||
bool BeginAppBarMenu(const char* icon);
|
||||
|
||||
/**
|
||||
* @brief End app bar action menu
|
||||
*/
|
||||
void EndAppBarMenu();
|
||||
|
||||
/**
|
||||
* @brief Add menu item to app bar menu
|
||||
*/
|
||||
bool AppBarMenuItem(const char* label, const char* icon = nullptr);
|
||||
|
||||
// ============================================================================
|
||||
// Implementation
|
||||
// ============================================================================
|
||||
|
||||
struct AppBarState {
|
||||
ImVec2 barMin;
|
||||
ImVec2 barMax;
|
||||
float height;
|
||||
float navIconWidth;
|
||||
float actionsStartX;
|
||||
float titleX;
|
||||
bool hasNavIcon;
|
||||
bool centerTitle;
|
||||
ImU32 backgroundColor;
|
||||
};
|
||||
|
||||
static AppBarState g_appBarState;
|
||||
|
||||
inline bool BeginAppBar(const char* id, const AppBarSpec& spec) {
|
||||
ImGuiWindow* window = ImGui::GetCurrentWindow();
|
||||
if (window->SkipItems)
|
||||
return false;
|
||||
|
||||
ImGui::PushID(id);
|
||||
|
||||
ImGuiIO& io = ImGui::GetIO();
|
||||
|
||||
// Calculate height based on type
|
||||
float barHeight;
|
||||
switch (spec.type) {
|
||||
case AppBarType::Prominent:
|
||||
barHeight = 128.0f;
|
||||
break;
|
||||
case AppBarType::Dense:
|
||||
barHeight = 48.0f;
|
||||
break;
|
||||
default:
|
||||
barHeight = size::AppBarHeight; // 56dp
|
||||
break;
|
||||
}
|
||||
|
||||
g_appBarState.height = barHeight;
|
||||
g_appBarState.hasNavIcon = false;
|
||||
g_appBarState.centerTitle = spec.centerTitle;
|
||||
g_appBarState.navIconWidth = 0;
|
||||
g_appBarState.actionsStartX = io.DisplaySize.x; // Will be adjusted as actions added
|
||||
|
||||
// Bar position (always at top)
|
||||
g_appBarState.barMin = ImVec2(0, 0);
|
||||
g_appBarState.barMax = ImVec2(io.DisplaySize.x, barHeight);
|
||||
|
||||
// Background color
|
||||
if (spec.backgroundColor != 0) {
|
||||
g_appBarState.backgroundColor = spec.backgroundColor;
|
||||
} else {
|
||||
g_appBarState.backgroundColor = Surface(Elevation::Dp4);
|
||||
}
|
||||
|
||||
// Draw app bar background
|
||||
ImDrawList* drawList = window->DrawList;
|
||||
drawList->AddRectFilled(g_appBarState.barMin, g_appBarState.barMax, g_appBarState.backgroundColor);
|
||||
|
||||
// Bottom divider/shadow
|
||||
if (spec.elevated) {
|
||||
drawList->AddLine(
|
||||
ImVec2(g_appBarState.barMin.x, g_appBarState.barMax.y),
|
||||
ImVec2(g_appBarState.barMax.x, g_appBarState.barMax.y),
|
||||
schema::UI().resolveColor("var(--app-bar-shadow)", IM_COL32(0, 0, 0, 50))
|
||||
);
|
||||
}
|
||||
|
||||
// Set up layout
|
||||
g_appBarState.titleX = spacing::dp(2); // Default title position
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
inline void EndAppBar() {
|
||||
ImGui::PopID();
|
||||
}
|
||||
|
||||
inline bool AppBarNavIcon(const char* icon, const char* tooltip) {
|
||||
ImGuiWindow* window = ImGui::GetCurrentWindow();
|
||||
if (window->SkipItems)
|
||||
return false;
|
||||
|
||||
g_appBarState.hasNavIcon = true;
|
||||
g_appBarState.navIconWidth = size::TouchTarget;
|
||||
|
||||
// Position nav icon on left
|
||||
float iconX = spacing::dp(0.5f); // 4dp left margin
|
||||
float centerY = g_appBarState.barMin.y + g_appBarState.height * 0.5f;
|
||||
|
||||
ImVec2 buttonPos(iconX, centerY - size::TouchTarget * 0.5f);
|
||||
|
||||
// Draw icon button
|
||||
ImGui::SetCursorScreenPos(buttonPos);
|
||||
bool clicked = IconButton(icon, tooltip);
|
||||
|
||||
// Update title position
|
||||
g_appBarState.titleX = iconX + size::TouchTarget + spacing::dp(1);
|
||||
|
||||
return clicked;
|
||||
}
|
||||
|
||||
inline void AppBarTitle(const char* title) {
|
||||
ImGuiWindow* window = ImGui::GetCurrentWindow();
|
||||
if (window->SkipItems)
|
||||
return;
|
||||
|
||||
float centerY = g_appBarState.barMin.y + g_appBarState.height * 0.5f;
|
||||
|
||||
// Calculate title position
|
||||
float titleX;
|
||||
if (g_appBarState.centerTitle) {
|
||||
// Center title between nav icon and actions
|
||||
float availableWidth = g_appBarState.actionsStartX - g_appBarState.titleX;
|
||||
Typography::instance().pushFont(TypeStyle::H6);
|
||||
float titleWidth = ImGui::CalcTextSize(title).x;
|
||||
Typography::instance().popFont();
|
||||
titleX = g_appBarState.titleX + (availableWidth - titleWidth) * 0.5f;
|
||||
} else {
|
||||
titleX = g_appBarState.titleX;
|
||||
}
|
||||
|
||||
// Render title
|
||||
Typography::instance().pushFont(TypeStyle::H6);
|
||||
float titleY = centerY - ImGui::GetFontSize() * 0.5f;
|
||||
|
||||
ImDrawList* drawList = window->DrawList;
|
||||
drawList->AddText(ImVec2(titleX, titleY), OnSurface(), title);
|
||||
|
||||
Typography::instance().popFont();
|
||||
}
|
||||
|
||||
inline bool AppBarAction(const char* icon, const char* tooltip) {
|
||||
ImGuiWindow* window = ImGui::GetCurrentWindow();
|
||||
if (window->SkipItems)
|
||||
return false;
|
||||
|
||||
// Actions are positioned from right edge
|
||||
g_appBarState.actionsStartX -= size::TouchTarget;
|
||||
|
||||
float centerY = g_appBarState.barMin.y + g_appBarState.height * 0.5f;
|
||||
ImVec2 buttonPos(g_appBarState.actionsStartX, centerY - size::TouchTarget * 0.5f);
|
||||
|
||||
ImGui::SetCursorScreenPos(buttonPos);
|
||||
return IconButton(icon, tooltip);
|
||||
}
|
||||
|
||||
inline bool BeginAppBarMenu(const char* icon) {
|
||||
ImGuiWindow* window = ImGui::GetCurrentWindow();
|
||||
if (window->SkipItems)
|
||||
return false;
|
||||
|
||||
// Position menu button
|
||||
g_appBarState.actionsStartX -= size::TouchTarget;
|
||||
|
||||
float centerY = g_appBarState.barMin.y + g_appBarState.height * 0.5f;
|
||||
ImVec2 buttonPos(g_appBarState.actionsStartX, centerY - size::TouchTarget * 0.5f);
|
||||
|
||||
ImGui::SetCursorScreenPos(buttonPos);
|
||||
|
||||
bool menuOpen = false;
|
||||
if (IconButton(icon, "More options")) {
|
||||
ImGui::OpenPopup("##appbar_menu");
|
||||
}
|
||||
|
||||
// Style the popup menu
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0, spacing::dp(1)));
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_PopupRounding, size::MenuCornerRadius);
|
||||
ImGui::PushStyleColor(ImGuiCol_PopupBg, ImGui::ColorConvertU32ToFloat4(Surface(Elevation::Dp8)));
|
||||
|
||||
if (ImGui::BeginPopup("##appbar_menu")) {
|
||||
menuOpen = true;
|
||||
}
|
||||
|
||||
return menuOpen;
|
||||
}
|
||||
|
||||
inline void EndAppBarMenu() {
|
||||
ImGui::EndPopup();
|
||||
ImGui::PopStyleColor();
|
||||
ImGui::PopStyleVar(2);
|
||||
}
|
||||
|
||||
inline bool AppBarMenuItem(const char* label, const char* icon) {
|
||||
ImGuiWindow* window = ImGui::GetCurrentWindow();
|
||||
|
||||
const float itemHeight = size::ListItemHeight;
|
||||
const float itemWidth = 200.0f; // Menu min width
|
||||
|
||||
ImVec2 pos = window->DC.CursorPos;
|
||||
ImRect itemBB(pos, ImVec2(pos.x + itemWidth, pos.y + itemHeight));
|
||||
|
||||
ImGuiID id = window->GetID(label);
|
||||
ImGui::ItemSize(itemBB);
|
||||
if (!ImGui::ItemAdd(itemBB, id))
|
||||
return false;
|
||||
|
||||
bool hovered, held;
|
||||
bool pressed = ImGui::ButtonBehavior(itemBB, id, &hovered, &held);
|
||||
|
||||
// Draw
|
||||
ImDrawList* drawList = window->DrawList;
|
||||
|
||||
if (hovered) {
|
||||
drawList->AddRectFilled(itemBB.Min, itemBB.Max, schema::UI().resolveColor("var(--hover-overlay)", IM_COL32(255, 255, 255, 10)));
|
||||
}
|
||||
|
||||
float contentX = pos.x + spacing::dp(2);
|
||||
float centerY = pos.y + itemHeight * 0.5f;
|
||||
|
||||
// Icon
|
||||
if (icon) {
|
||||
drawList->AddText(ImVec2(contentX, centerY - 12.0f), OnSurfaceMedium(), icon);
|
||||
contentX += 24.0f + spacing::dp(2);
|
||||
}
|
||||
|
||||
// Label
|
||||
Typography::instance().pushFont(TypeStyle::Body1);
|
||||
float labelY = centerY - ImGui::GetFontSize() * 0.5f;
|
||||
drawList->AddText(ImVec2(contentX, labelY), OnSurface(), label);
|
||||
Typography::instance().popFont();
|
||||
|
||||
if (pressed) {
|
||||
ImGui::CloseCurrentPopup();
|
||||
}
|
||||
|
||||
return pressed;
|
||||
}
|
||||
|
||||
} // namespace material
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
351
src/ui/material/components/buttons.h
Normal file
351
src/ui/material/components/buttons.h
Normal file
@@ -0,0 +1,351 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "../colors.h"
|
||||
#include "../typography.h"
|
||||
#include "../layout.h"
|
||||
#include "imgui.h"
|
||||
#include "imgui_internal.h"
|
||||
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
namespace material {
|
||||
|
||||
// ============================================================================
|
||||
// Material Design Button Components
|
||||
// ============================================================================
|
||||
// Based on https://m2.material.io/components/buttons
|
||||
//
|
||||
// Three button variants:
|
||||
// - Text Button: Low emphasis, no container
|
||||
// - Outlined Button: Medium emphasis, border only
|
||||
// - Contained Button: High emphasis, filled background
|
||||
|
||||
enum class ButtonStyle {
|
||||
Text, // Low emphasis - text only
|
||||
Outlined, // Medium emphasis - border
|
||||
Contained // High emphasis - filled (default)
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Button configuration
|
||||
*/
|
||||
struct ButtonSpec {
|
||||
ButtonStyle style = ButtonStyle::Contained;
|
||||
bool enabled = true;
|
||||
bool fullWidth = false;
|
||||
const char* icon = nullptr; // Leading icon (text glyph)
|
||||
ImU32 color = 0; // 0 = use primary color
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Render a Material Design button
|
||||
*
|
||||
* @param label Button text (will be uppercased per Material spec)
|
||||
* @param spec Button configuration
|
||||
* @return true if clicked
|
||||
*/
|
||||
bool Button(const char* label, const ButtonSpec& spec = ButtonSpec());
|
||||
|
||||
/**
|
||||
* @brief Render a text-only button (low emphasis)
|
||||
*/
|
||||
inline bool TextButton(const char* label, bool enabled = true) {
|
||||
ButtonSpec spec;
|
||||
spec.style = ButtonStyle::Text;
|
||||
spec.enabled = enabled;
|
||||
return Button(label, spec);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Render an outlined button (medium emphasis)
|
||||
*/
|
||||
inline bool OutlinedButton(const char* label, bool enabled = true) {
|
||||
ButtonSpec spec;
|
||||
spec.style = ButtonStyle::Outlined;
|
||||
spec.enabled = enabled;
|
||||
return Button(label, spec);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Render a contained/filled button (high emphasis)
|
||||
*/
|
||||
inline bool ContainedButton(const char* label, bool enabled = true) {
|
||||
ButtonSpec spec;
|
||||
spec.style = ButtonStyle::Contained;
|
||||
spec.enabled = enabled;
|
||||
return Button(label, spec);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Render an icon button (circular touch target)
|
||||
*
|
||||
* @param icon Icon glyph/character
|
||||
* @param tooltip Hover tooltip text
|
||||
* @param enabled Whether button is enabled
|
||||
* @return true if clicked
|
||||
*/
|
||||
bool IconButton(const char* icon, const char* tooltip = nullptr, bool enabled = true);
|
||||
|
||||
/**
|
||||
* @brief Render a Floating Action Button (FAB)
|
||||
*
|
||||
* @param icon Icon glyph
|
||||
* @param label Optional label for extended FAB
|
||||
* @param mini Use mini size (40dp instead of 56dp)
|
||||
* @return true if clicked
|
||||
*/
|
||||
bool FAB(const char* icon, const char* label = nullptr, bool mini = false);
|
||||
|
||||
// ============================================================================
|
||||
// Implementation
|
||||
// ============================================================================
|
||||
|
||||
inline bool Button(const char* label, const ButtonSpec& spec) {
|
||||
ImGuiWindow* window = ImGui::GetCurrentWindow();
|
||||
if (window->SkipItems)
|
||||
return false;
|
||||
|
||||
ImGuiContext& g = *GImGui;
|
||||
const ImGuiStyle& style = g.Style;
|
||||
const ImGuiID id = window->GetID(label);
|
||||
|
||||
// Convert label to uppercase for Material Design
|
||||
char upperLabel[256];
|
||||
{
|
||||
const char* src = label;
|
||||
char* dst = upperLabel;
|
||||
char* end = upperLabel + sizeof(upperLabel) - 1;
|
||||
while (*src && dst < end) {
|
||||
*dst++ = (char)toupper((unsigned char)*src++);
|
||||
}
|
||||
*dst = '\0';
|
||||
}
|
||||
|
||||
// Get button font
|
||||
ImFont* buttonFont = Typography::instance().button();
|
||||
ImGui::PushFont(buttonFont);
|
||||
|
||||
// Calculate button size
|
||||
ImVec2 labelSize = ImGui::CalcTextSize(upperLabel);
|
||||
float iconWidth = spec.icon ? (size::IconSize + size::ButtonIconGap) : 0.0f;
|
||||
|
||||
ImVec2 buttonSize;
|
||||
buttonSize.x = spec.fullWidth ? ImGui::GetContentRegionAvail().x
|
||||
: ImMax(labelSize.x + iconWidth + size::ButtonPaddingH * 2, size::ButtonMinWidth);
|
||||
buttonSize.y = size::ButtonHeight;
|
||||
|
||||
// Get position
|
||||
ImVec2 pos = window->DC.CursorPos;
|
||||
ImRect bb(pos, ImVec2(pos.x + buttonSize.x, pos.y + buttonSize.y));
|
||||
|
||||
ImGui::ItemSize(buttonSize, style.FramePadding.y);
|
||||
if (!ImGui::ItemAdd(bb, id)) {
|
||||
ImGui::PopFont();
|
||||
return false;
|
||||
}
|
||||
|
||||
// Handle interaction
|
||||
bool hovered, held;
|
||||
bool pressed = ImGui::ButtonBehavior(bb, id, &hovered, &held, 0);
|
||||
|
||||
if (!spec.enabled) {
|
||||
hovered = held = false;
|
||||
}
|
||||
|
||||
if (hovered) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
|
||||
|
||||
// Get colors
|
||||
ImU32 primaryColor = spec.color ? spec.color : Primary();
|
||||
ImU32 textColor;
|
||||
ImU32 bgColor;
|
||||
ImU32 borderColor = 0;
|
||||
|
||||
switch (spec.style) {
|
||||
case ButtonStyle::Text:
|
||||
bgColor = 0; // No background
|
||||
textColor = spec.enabled ? primaryColor : OnSurfaceDisabled();
|
||||
if (hovered) bgColor = BlendOverlay(Surface(), StateHover(), 1.0f);
|
||||
if (held) bgColor = BlendOverlay(Surface(), StatePressed(), 1.0f);
|
||||
break;
|
||||
|
||||
case ButtonStyle::Outlined:
|
||||
bgColor = 0; // No background
|
||||
textColor = spec.enabled ? primaryColor : OnSurfaceDisabled();
|
||||
borderColor = spec.enabled ? primaryColor : OnSurfaceDisabled();
|
||||
if (hovered) bgColor = BlendOverlay(Surface(), StateHover(), 1.0f);
|
||||
if (held) bgColor = BlendOverlay(Surface(), StatePressed(), 1.0f);
|
||||
break;
|
||||
|
||||
case ButtonStyle::Contained:
|
||||
default:
|
||||
if (spec.enabled) {
|
||||
bgColor = primaryColor;
|
||||
textColor = OnPrimary();
|
||||
if (hovered) bgColor = BlendOverlay(primaryColor, StateHover(), 1.0f);
|
||||
if (held) bgColor = BlendOverlay(primaryColor, StatePressed(), 1.0f);
|
||||
} else {
|
||||
bgColor = WithAlphaF(OnSurface(), 0.12f);
|
||||
textColor = OnSurfaceDisabled();
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// Render
|
||||
ImDrawList* drawList = window->DrawList;
|
||||
|
||||
// Background
|
||||
if (bgColor) {
|
||||
drawList->AddRectFilled(bb.Min, bb.Max, bgColor, size::ButtonCornerRadius);
|
||||
}
|
||||
|
||||
// Border (for outlined)
|
||||
if (borderColor) {
|
||||
drawList->AddRect(bb.Min, bb.Max, borderColor, size::ButtonCornerRadius, 0, 1.0f);
|
||||
}
|
||||
|
||||
// Icon
|
||||
float contentX = bb.Min.x + size::ButtonPaddingH;
|
||||
float contentY = bb.Min.y + (buttonSize.y - labelSize.y) * 0.5f;
|
||||
|
||||
if (spec.icon) {
|
||||
ImFont* iconFont = Type().iconMed();
|
||||
ImVec2 iconSize = iconFont->CalcTextSizeA(iconFont->LegacySize, FLT_MAX, 0, spec.icon);
|
||||
ImVec2 iconPos(contentX, bb.Min.y + (buttonSize.y - iconSize.y) * 0.5f);
|
||||
drawList->AddText(iconFont, iconFont->LegacySize, iconPos, textColor, spec.icon);
|
||||
contentX += iconSize.x + size::ButtonIconGap;
|
||||
}
|
||||
|
||||
// Label
|
||||
drawList->AddText(ImVec2(contentX, contentY), textColor, upperLabel);
|
||||
|
||||
ImGui::PopFont();
|
||||
|
||||
return pressed && spec.enabled;
|
||||
}
|
||||
|
||||
inline bool IconButton(const char* icon, const char* tooltip, bool enabled) {
|
||||
ImGuiWindow* window = ImGui::GetCurrentWindow();
|
||||
if (window->SkipItems)
|
||||
return false;
|
||||
|
||||
ImGuiContext& g = *GImGui;
|
||||
const ImGuiID id = window->GetID(icon);
|
||||
|
||||
ImVec2 pos = window->DC.CursorPos;
|
||||
ImVec2 size(size::IconButtonSize, size::IconButtonSize);
|
||||
ImRect bb(pos, ImVec2(pos.x + size.x, pos.y + size.y));
|
||||
|
||||
ImGui::ItemSize(size);
|
||||
if (!ImGui::ItemAdd(bb, id))
|
||||
return false;
|
||||
|
||||
bool hovered, held;
|
||||
bool pressed = ImGui::ButtonBehavior(bb, id, &hovered, &held, 0);
|
||||
|
||||
if (!enabled) {
|
||||
hovered = held = false;
|
||||
}
|
||||
|
||||
if (hovered) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
|
||||
|
||||
// Render ripple/hover circle
|
||||
ImDrawList* drawList = window->DrawList;
|
||||
ImVec2 center = bb.GetCenter();
|
||||
float radius = size::IconButtonSize * 0.5f;
|
||||
|
||||
if (hovered || held) {
|
||||
ImU32 overlayColor = held ? StatePressed() : StateHover();
|
||||
drawList->AddCircleFilled(center, radius, overlayColor);
|
||||
}
|
||||
|
||||
// Render icon (use icon font)
|
||||
ImFont* iconFont = Type().iconMed();
|
||||
ImU32 iconColor = enabled ? OnSurface() : OnSurfaceDisabled();
|
||||
ImVec2 iconSize = iconFont->CalcTextSizeA(iconFont->LegacySize, FLT_MAX, 0, icon);
|
||||
ImVec2 iconPos(center.x - iconSize.x * 0.5f, center.y - iconSize.y * 0.5f);
|
||||
drawList->AddText(iconFont, iconFont->LegacySize, iconPos, iconColor, icon);
|
||||
|
||||
// Tooltip
|
||||
if (tooltip && hovered) {
|
||||
ImGui::SetTooltip("%s", tooltip);
|
||||
}
|
||||
|
||||
return pressed && enabled;
|
||||
}
|
||||
|
||||
inline bool FAB(const char* icon, const char* label, bool mini) {
|
||||
ImGuiWindow* window = ImGui::GetCurrentWindow();
|
||||
if (window->SkipItems)
|
||||
return false;
|
||||
|
||||
ImGuiContext& g = *GImGui;
|
||||
const ImGuiID id = window->GetID(icon);
|
||||
|
||||
bool extended = (label != nullptr);
|
||||
float fabSize = mini ? size::FabMiniSize : size::FabSize;
|
||||
|
||||
ImVec2 buttonSize;
|
||||
if (extended) {
|
||||
ImVec2 labelSize = ImGui::CalcTextSize(label);
|
||||
buttonSize.x = size::FabExtendedPadding * 2 + size::IconSize + spacing::Sm + labelSize.x;
|
||||
buttonSize.y = size::FabExtendedHeight;
|
||||
} else {
|
||||
buttonSize.x = fabSize;
|
||||
buttonSize.y = fabSize;
|
||||
}
|
||||
|
||||
ImVec2 pos = window->DC.CursorPos;
|
||||
ImRect bb(pos, ImVec2(pos.x + buttonSize.x, pos.y + buttonSize.y));
|
||||
|
||||
ImGui::ItemSize(buttonSize);
|
||||
if (!ImGui::ItemAdd(bb, id))
|
||||
return false;
|
||||
|
||||
bool hovered, held;
|
||||
bool pressed = ImGui::ButtonBehavior(bb, id, &hovered, &held, 0);
|
||||
|
||||
if (hovered) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
|
||||
|
||||
// Colors
|
||||
ImU32 bgColor = Secondary();
|
||||
ImU32 textColor = OnSecondary();
|
||||
|
||||
if (hovered) bgColor = BlendOverlay(bgColor, StateHover(), 1.0f);
|
||||
if (held) bgColor = BlendOverlay(bgColor, StatePressed(), 1.0f);
|
||||
|
||||
// Render
|
||||
ImDrawList* drawList = window->DrawList;
|
||||
float radius = extended ? size::FabCornerRadius : (fabSize * 0.5f);
|
||||
drawList->AddRectFilled(bb.Min, bb.Max, bgColor, radius);
|
||||
|
||||
// Icon
|
||||
ImVec2 center = bb.GetCenter();
|
||||
ImFont* iconFont = Type().iconMed();
|
||||
if (extended) {
|
||||
ImVec2 iconSize = iconFont->CalcTextSizeA(iconFont->LegacySize, FLT_MAX, 0, icon);
|
||||
float iconX = bb.Min.x + size::FabExtendedPadding;
|
||||
float iconY = center.y - iconSize.y * 0.5f;
|
||||
drawList->AddText(iconFont, iconFont->LegacySize, ImVec2(iconX, iconY), textColor, icon);
|
||||
|
||||
// Label
|
||||
Typography::instance().pushFont(TypeStyle::Button);
|
||||
float labelX = iconX + iconSize.x + spacing::Sm;
|
||||
float labelY = center.y - ImGui::GetFontSize() * 0.5f;
|
||||
drawList->AddText(ImVec2(labelX, labelY), textColor, label);
|
||||
Typography::instance().popFont();
|
||||
} else {
|
||||
ImVec2 iconSize = iconFont->CalcTextSizeA(iconFont->LegacySize, FLT_MAX, 0, icon);
|
||||
ImVec2 iconPos(center.x - iconSize.x * 0.5f, center.y - iconSize.y * 0.5f);
|
||||
drawList->AddText(iconFont, iconFont->LegacySize, iconPos, textColor, icon);
|
||||
}
|
||||
|
||||
return pressed;
|
||||
}
|
||||
|
||||
} // namespace material
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
214
src/ui/material/components/cards.h
Normal file
214
src/ui/material/components/cards.h
Normal file
@@ -0,0 +1,214 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "../colors.h"
|
||||
#include "../typography.h"
|
||||
#include "../layout.h"
|
||||
#include "../draw_helpers.h"
|
||||
#include "imgui.h"
|
||||
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
namespace material {
|
||||
|
||||
// ============================================================================
|
||||
// Material Design Card Component
|
||||
// ============================================================================
|
||||
// Based on https://m2.material.io/components/cards
|
||||
//
|
||||
// Cards contain content and actions about a single subject.
|
||||
// They can be elevated (with shadow) or outlined (with border).
|
||||
|
||||
/**
|
||||
* @brief Card configuration
|
||||
*/
|
||||
struct CardSpec {
|
||||
int elevation = 1; // Elevation in dp (0-24)
|
||||
bool outlined = false; // Use outline instead of elevation
|
||||
float cornerRadius = 4.0f; // Corner radius in dp
|
||||
bool clickable = false; // Make entire card clickable
|
||||
float padding = 16.0f; // Content padding
|
||||
float minHeight = 0.0f; // Minimum height (0 = auto)
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Begin a Material Design card
|
||||
*
|
||||
* @param id Unique identifier for the card
|
||||
* @param spec Card configuration
|
||||
* @return true if card is visible and content should be rendered
|
||||
*/
|
||||
bool BeginCard(const char* id, const CardSpec& spec = CardSpec());
|
||||
|
||||
/**
|
||||
* @brief End the card
|
||||
*/
|
||||
void EndCard();
|
||||
|
||||
/**
|
||||
* @brief Begin a clickable card that returns click state
|
||||
*
|
||||
* @param id Unique identifier
|
||||
* @param spec Card configuration
|
||||
* @param clicked Output: true if card was clicked
|
||||
* @return true if card is visible
|
||||
*/
|
||||
bool BeginClickableCard(const char* id, const CardSpec& spec, bool* clicked);
|
||||
|
||||
/**
|
||||
* @brief Card header with title and optional subtitle
|
||||
*
|
||||
* @param title Primary title text
|
||||
* @param subtitle Optional secondary text
|
||||
* @param avatar Optional avatar texture (rendered as circle)
|
||||
*/
|
||||
void CardHeader(const char* title, const char* subtitle = nullptr);
|
||||
|
||||
/**
|
||||
* @brief Card supporting text/content
|
||||
*
|
||||
* @param text Body text content
|
||||
*/
|
||||
void CardContent(const char* text);
|
||||
|
||||
/**
|
||||
* @brief Begin card action area (for buttons)
|
||||
*
|
||||
* Actions are typically placed at the bottom of the card.
|
||||
*/
|
||||
void CardActions();
|
||||
|
||||
/**
|
||||
* @brief End card action area
|
||||
*/
|
||||
void CardActionsEnd();
|
||||
|
||||
/**
|
||||
* @brief Add divider within card
|
||||
*/
|
||||
void CardDivider();
|
||||
|
||||
// ============================================================================
|
||||
// Implementation
|
||||
// ============================================================================
|
||||
|
||||
inline bool BeginCard(const char* id, const CardSpec& spec) {
|
||||
ImGui::PushID(id);
|
||||
|
||||
// Calculate surface color based on elevation
|
||||
ImU32 bgColor = spec.outlined ? Surface() : GetElevatedSurface(GetCurrentColorTheme(), spec.elevation);
|
||||
|
||||
// When acrylic backdrop is active, scale card bg alpha by UI opacity
|
||||
// so cards smoothly transition from opaque (1.0) to see-through.
|
||||
bool opaqueCards = dragonx::ui::effects::isLowSpecMode();
|
||||
if (IsBackdropActive() && !opaqueCards) {
|
||||
ImVec4 c = ImGui::ColorConvertU32ToFloat4(bgColor);
|
||||
float uiOp = dragonx::ui::effects::ImGuiAcrylic::GetUIOpacity();
|
||||
c.w *= uiOp;
|
||||
ImGui::PushStyleColor(ImGuiCol_ChildBg, c);
|
||||
} else {
|
||||
ImGui::PushStyleColor(ImGuiCol_ChildBg, ImGui::ColorConvertU32ToFloat4(bgColor));
|
||||
}
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_ChildRounding, spec.cornerRadius);
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(spec.padding, spec.padding));
|
||||
|
||||
// Border for outlined variant
|
||||
if (spec.outlined) {
|
||||
ImGui::PushStyleColor(ImGuiCol_Border, ImGui::ColorConvertU32ToFloat4(Outline()));
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_ChildBorderSize, 1.0f);
|
||||
} else {
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_ChildBorderSize, 0.0f);
|
||||
}
|
||||
|
||||
ImVec2 size(0, spec.minHeight); // 0 width = use available width
|
||||
ImGuiChildFlags flags = ImGuiChildFlags_AutoResizeY;
|
||||
if (spec.outlined) {
|
||||
flags |= ImGuiChildFlags_Borders;
|
||||
}
|
||||
|
||||
bool visible = ImGui::BeginChild(id, size, flags);
|
||||
|
||||
return visible;
|
||||
}
|
||||
|
||||
inline void EndCard() {
|
||||
ImGui::EndChild();
|
||||
|
||||
ImGui::PopStyleVar(3); // ChildRounding, WindowPadding, ChildBorderSize
|
||||
ImGui::PopStyleColor(1); // ChildBg
|
||||
|
||||
// Check if we used outline style (need to pop extra color)
|
||||
// Note: We always push the border size var, handle outline color in BeginCard
|
||||
|
||||
ImGui::PopID();
|
||||
|
||||
// Add spacing after card
|
||||
VSpace(2);
|
||||
}
|
||||
|
||||
inline bool BeginClickableCard(const char* id, const CardSpec& spec, bool* clicked) {
|
||||
*clicked = false;
|
||||
|
||||
ImGui::PushID(id);
|
||||
|
||||
ImVec2 startPos = ImGui::GetCursorScreenPos();
|
||||
|
||||
// Render card background
|
||||
ImU32 bgColor = spec.outlined ? Surface() : GetElevatedSurface(GetCurrentColorTheme(), spec.elevation);
|
||||
|
||||
ImGui::PushStyleColor(ImGuiCol_ChildBg, ImGui::ColorConvertU32ToFloat4(bgColor));
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_ChildRounding, spec.cornerRadius);
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(spec.padding, spec.padding));
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_ChildBorderSize, spec.outlined ? 1.0f : 0.0f);
|
||||
|
||||
if (spec.outlined) {
|
||||
ImGui::PushStyleColor(ImGuiCol_Border, ImGui::ColorConvertU32ToFloat4(Outline()));
|
||||
}
|
||||
|
||||
ImVec2 size(0, spec.minHeight);
|
||||
ImGuiChildFlags flags = ImGuiChildFlags_AutoResizeY;
|
||||
if (spec.outlined) {
|
||||
flags |= ImGuiChildFlags_Borders;
|
||||
}
|
||||
|
||||
bool visible = ImGui::BeginChild(id, size, flags);
|
||||
|
||||
return visible;
|
||||
}
|
||||
|
||||
inline void CardHeader(const char* title, const char* subtitle) {
|
||||
Typography::instance().text(TypeStyle::H6, title);
|
||||
|
||||
if (subtitle) {
|
||||
Typography::instance().textColored(TypeStyle::Body2, OnSurfaceMedium(), subtitle);
|
||||
}
|
||||
|
||||
VSpace(1);
|
||||
}
|
||||
|
||||
inline void CardContent(const char* text) {
|
||||
Typography::instance().textWrapped(TypeStyle::Body2, text);
|
||||
VSpace(1);
|
||||
}
|
||||
|
||||
inline void CardActions() {
|
||||
ImGui::Separator();
|
||||
VSpace(1);
|
||||
ImGui::BeginGroup();
|
||||
}
|
||||
|
||||
inline void CardActionsEnd() {
|
||||
ImGui::EndGroup();
|
||||
}
|
||||
|
||||
inline void CardDivider() {
|
||||
ImGui::Separator();
|
||||
VSpace(1);
|
||||
}
|
||||
|
||||
} // namespace material
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
296
src/ui/material/components/chips.h
Normal file
296
src/ui/material/components/chips.h
Normal file
@@ -0,0 +1,296 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "../colors.h"
|
||||
#include "../typography.h"
|
||||
#include "../layout.h"
|
||||
#include "../../schema/ui_schema.h"
|
||||
#include "../../embedded/IconsMaterialDesign.h"
|
||||
#include "imgui.h"
|
||||
#include "imgui_internal.h"
|
||||
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
namespace material {
|
||||
|
||||
// ============================================================================
|
||||
// Material Design Chips Component
|
||||
// ============================================================================
|
||||
// Based on https://m2.material.io/components/chips
|
||||
//
|
||||
// Chips are compact elements that represent an input, attribute, or action.
|
||||
|
||||
enum class ChipType {
|
||||
Input, // User input (deletable)
|
||||
Choice, // Single selection from set
|
||||
Filter, // Filter/checkbox style
|
||||
Action // Triggers action
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Chip configuration
|
||||
*/
|
||||
struct ChipSpec {
|
||||
ChipType type = ChipType::Action;
|
||||
const char* label = nullptr;
|
||||
const char* icon = nullptr; // Leading icon
|
||||
const char* avatar = nullptr; // Avatar text (overrides icon)
|
||||
ImU32 avatarColor = 0; // Avatar background color
|
||||
bool selected = false; // For choice/filter chips
|
||||
bool disabled = false;
|
||||
bool outlined = false; // Outlined vs filled style
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Render a chip
|
||||
*
|
||||
* @param spec Chip configuration
|
||||
* @return For filter/choice: true if clicked (toggle selection)
|
||||
* For input: true if delete clicked
|
||||
* For action: true if clicked
|
||||
*/
|
||||
bool Chip(const ChipSpec& spec);
|
||||
|
||||
/**
|
||||
* @brief Simple action chip
|
||||
*/
|
||||
bool Chip(const char* label);
|
||||
|
||||
/**
|
||||
* @brief Filter chip (toggleable)
|
||||
*/
|
||||
bool FilterChip(const char* label, bool* selected);
|
||||
|
||||
/**
|
||||
* @brief Choice chip (radio-style)
|
||||
*/
|
||||
bool ChoiceChip(const char* label, bool selected);
|
||||
|
||||
/**
|
||||
* @brief Input chip with delete
|
||||
*/
|
||||
bool InputChip(const char* label, const char* avatar = nullptr);
|
||||
|
||||
/**
|
||||
* @brief Begin a chip group for layout
|
||||
*/
|
||||
void BeginChipGroup();
|
||||
|
||||
/**
|
||||
* @brief End a chip group
|
||||
*/
|
||||
void EndChipGroup();
|
||||
|
||||
// ============================================================================
|
||||
// Implementation
|
||||
// ============================================================================
|
||||
|
||||
inline bool Chip(const ChipSpec& spec) {
|
||||
ImGuiWindow* window = ImGui::GetCurrentWindow();
|
||||
if (window->SkipItems)
|
||||
return false;
|
||||
|
||||
ImGui::PushID(spec.label);
|
||||
|
||||
// Chip dimensions
|
||||
const float chipHeight = 32.0f;
|
||||
const float cornerRadius = chipHeight * 0.5f;
|
||||
const float horizontalPadding = 12.0f;
|
||||
const float iconSize = 18.0f;
|
||||
const float avatarSize = 24.0f;
|
||||
const float deleteIconSize = 18.0f;
|
||||
|
||||
// Calculate content width
|
||||
float contentWidth = horizontalPadding * 2;
|
||||
|
||||
bool hasLeading = spec.icon || spec.avatar;
|
||||
bool hasDelete = (spec.type == ChipType::Input);
|
||||
bool hasCheckmark = (spec.type == ChipType::Filter && spec.selected);
|
||||
|
||||
if (spec.avatar) {
|
||||
contentWidth += avatarSize + 8.0f;
|
||||
} else if (spec.icon || hasCheckmark) {
|
||||
contentWidth += iconSize + 8.0f;
|
||||
}
|
||||
|
||||
contentWidth += ImGui::CalcTextSize(spec.label).x;
|
||||
|
||||
if (hasDelete) {
|
||||
contentWidth += deleteIconSize + 8.0f;
|
||||
}
|
||||
|
||||
// Layout
|
||||
ImVec2 pos = window->DC.CursorPos;
|
||||
ImRect chipBB(pos, ImVec2(pos.x + contentWidth, pos.y + chipHeight));
|
||||
|
||||
// Interaction
|
||||
ImGuiID id = window->GetID("##chip");
|
||||
ImGui::ItemSize(chipBB);
|
||||
if (!ImGui::ItemAdd(chipBB, id))
|
||||
return false;
|
||||
|
||||
bool hovered, held;
|
||||
bool clicked = ImGui::ButtonBehavior(chipBB, id, &hovered, &held) && !spec.disabled;
|
||||
|
||||
// Delete button hit test (for input chips)
|
||||
bool deleteClicked = false;
|
||||
if (hasDelete) {
|
||||
float deleteX = chipBB.Max.x - horizontalPadding - deleteIconSize;
|
||||
ImRect deleteBB(
|
||||
ImVec2(deleteX, pos.y + (chipHeight - deleteIconSize) * 0.5f),
|
||||
ImVec2(deleteX + deleteIconSize, pos.y + (chipHeight + deleteIconSize) * 0.5f)
|
||||
);
|
||||
|
||||
ImGuiID deleteId = window->GetID("##delete");
|
||||
bool deleteHovered, deleteHeld;
|
||||
deleteClicked = ImGui::ButtonBehavior(deleteBB, deleteId, &deleteHovered, &deleteHeld);
|
||||
}
|
||||
|
||||
// Draw
|
||||
ImDrawList* drawList = window->DrawList;
|
||||
|
||||
// Background
|
||||
ImU32 bgColor;
|
||||
ImU32 borderColor = 0;
|
||||
|
||||
if (spec.disabled) {
|
||||
bgColor = schema::UI().resolveColor("var(--surface-hover)", IM_COL32(255, 255, 255, 30));
|
||||
} else if (spec.selected) {
|
||||
bgColor = WithAlpha(Primary(), 51); // Primary at 20%
|
||||
} else if (spec.outlined) {
|
||||
bgColor = 0; // Transparent
|
||||
borderColor = OnSurfaceMedium();
|
||||
} else {
|
||||
bgColor = schema::UI().resolveColor("var(--surface-hover)", IM_COL32(255, 255, 255, 30));
|
||||
}
|
||||
|
||||
// Hover/press overlay
|
||||
if (!spec.disabled) {
|
||||
if (held) {
|
||||
bgColor = IM_COL32_ADD(bgColor, schema::UI().resolveColor("var(--hover-overlay)", IM_COL32(255, 255, 255, 25)));
|
||||
} else if (hovered) {
|
||||
bgColor = IM_COL32_ADD(bgColor, schema::UI().resolveColor("var(--active-overlay)", IM_COL32(255, 255, 255, 10)));
|
||||
}
|
||||
}
|
||||
|
||||
// Draw background
|
||||
if (bgColor) {
|
||||
drawList->AddRectFilled(chipBB.Min, chipBB.Max, bgColor, cornerRadius);
|
||||
}
|
||||
|
||||
// Draw border for outlined
|
||||
if (borderColor) {
|
||||
drawList->AddRect(chipBB.Min, chipBB.Max, borderColor, cornerRadius, 0, 1.0f);
|
||||
}
|
||||
|
||||
// Content
|
||||
float currentX = pos.x + horizontalPadding;
|
||||
float centerY = pos.y + chipHeight * 0.5f;
|
||||
|
||||
ImU32 contentColor = spec.disabled ? OnSurfaceDisabled() : OnSurface();
|
||||
ImU32 iconColor = spec.disabled ? OnSurfaceDisabled() :
|
||||
spec.selected ? Primary() : OnSurfaceMedium();
|
||||
|
||||
// Avatar or icon
|
||||
if (spec.avatar) {
|
||||
// Avatar circle
|
||||
ImVec2 avatarCenter(currentX + avatarSize * 0.5f - 4.0f, centerY);
|
||||
ImU32 avatarBg = spec.avatarColor ? spec.avatarColor : Primary();
|
||||
drawList->AddCircleFilled(avatarCenter, avatarSize * 0.5f, avatarBg);
|
||||
|
||||
// Avatar text
|
||||
ImVec2 textSize = ImGui::CalcTextSize(spec.avatar);
|
||||
ImVec2 textPos(avatarCenter.x - textSize.x * 0.5f, avatarCenter.y - textSize.y * 0.5f);
|
||||
drawList->AddText(textPos, OnPrimary(), spec.avatar);
|
||||
|
||||
currentX += avatarSize + 4.0f;
|
||||
} else if (hasCheckmark) {
|
||||
// Checkmark for selected filter chips
|
||||
ImFont* iconFont = Typography::instance().iconSmall();
|
||||
ImVec2 chkSz = iconFont->CalcTextSizeA(iconFont->LegacySize, 1000.0f, 0.0f, ICON_MD_CHECK);
|
||||
drawList->AddText(iconFont, iconFont->LegacySize,
|
||||
ImVec2(currentX, centerY - chkSz.y * 0.5f), Primary(), ICON_MD_CHECK);
|
||||
currentX += iconSize + 4.0f;
|
||||
} else if (spec.icon) {
|
||||
drawList->AddText(ImVec2(currentX, centerY - iconSize * 0.5f), iconColor, spec.icon);
|
||||
currentX += iconSize + 4.0f;
|
||||
}
|
||||
|
||||
// Label
|
||||
Typography::instance().pushFont(TypeStyle::Body2);
|
||||
float labelY = centerY - ImGui::GetFontSize() * 0.5f;
|
||||
drawList->AddText(ImVec2(currentX, labelY), contentColor, spec.label);
|
||||
Typography::instance().popFont();
|
||||
|
||||
// Delete icon (for input chips)
|
||||
if (hasDelete) {
|
||||
float deleteX = chipBB.Max.x - horizontalPadding - deleteIconSize;
|
||||
ImFont* iconFont = Typography::instance().iconSmall();
|
||||
ImVec2 delSz = iconFont->CalcTextSizeA(iconFont->LegacySize, 1000.0f, 0.0f, ICON_MD_CLOSE);
|
||||
drawList->AddText(iconFont, iconFont->LegacySize,
|
||||
ImVec2(deleteX, centerY - delSz.y * 0.5f),
|
||||
OnSurfaceMedium(), ICON_MD_CLOSE
|
||||
);
|
||||
}
|
||||
|
||||
ImGui::PopID();
|
||||
|
||||
// Return value depends on chip type
|
||||
if (spec.type == ChipType::Input) {
|
||||
return deleteClicked;
|
||||
}
|
||||
return clicked;
|
||||
}
|
||||
|
||||
inline bool Chip(const char* label) {
|
||||
ChipSpec spec;
|
||||
spec.label = label;
|
||||
spec.type = ChipType::Action;
|
||||
return Chip(spec);
|
||||
}
|
||||
|
||||
inline bool FilterChip(const char* label, bool* selected) {
|
||||
ChipSpec spec;
|
||||
spec.label = label;
|
||||
spec.type = ChipType::Filter;
|
||||
spec.selected = *selected;
|
||||
|
||||
bool clicked = Chip(spec);
|
||||
if (clicked) {
|
||||
*selected = !*selected;
|
||||
}
|
||||
return clicked;
|
||||
}
|
||||
|
||||
inline bool ChoiceChip(const char* label, bool selected) {
|
||||
ChipSpec spec;
|
||||
spec.label = label;
|
||||
spec.type = ChipType::Choice;
|
||||
spec.selected = selected;
|
||||
return Chip(spec);
|
||||
}
|
||||
|
||||
inline bool InputChip(const char* label, const char* avatar) {
|
||||
ChipSpec spec;
|
||||
spec.label = label;
|
||||
spec.type = ChipType::Input;
|
||||
spec.avatar = avatar;
|
||||
return Chip(spec);
|
||||
}
|
||||
|
||||
inline void BeginChipGroup() {
|
||||
ImGui::BeginGroup();
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(spacing::dp(1), spacing::dp(1))); // 8dp spacing
|
||||
}
|
||||
|
||||
inline void EndChipGroup() {
|
||||
ImGui::PopStyleVar();
|
||||
ImGui::EndGroup();
|
||||
}
|
||||
|
||||
} // namespace material
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
122
src/ui/material/components/components.h
Normal file
122
src/ui/material/components/components.h
Normal file
@@ -0,0 +1,122 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
|
||||
#pragma once
|
||||
|
||||
// ============================================================================
|
||||
// Material Design Components - Unified Header
|
||||
// ============================================================================
|
||||
// Include this single header to get all Material Design components.
|
||||
//
|
||||
// Based on Material Design 2 (m2.material.io)
|
||||
//
|
||||
// All components are in the namespace: dragonx::ui::material
|
||||
|
||||
// Core dependencies
|
||||
#include "../colors.h"
|
||||
#include "../typography.h"
|
||||
#include "../layout.h"
|
||||
|
||||
// Components
|
||||
#include "buttons.h" // Button, IconButton, FAB
|
||||
#include "cards.h" // Card, CardHeader, CardContent, CardActions
|
||||
#include "text_fields.h" // TextField
|
||||
#include "lists.h" // ListItem, ListDivider, ListSubheader
|
||||
#include "dialogs.h" // Dialog, ConfirmDialog, AlertDialog
|
||||
#include "inputs.h" // Switch, Checkbox, RadioButton
|
||||
#include "progress.h" // LinearProgress, CircularProgress
|
||||
#include "snackbar.h" // Snackbar, ShowSnackbar
|
||||
#include "slider.h" // Slider, SliderDiscrete, SliderRange
|
||||
#include "tabs.h" // TabBar, Tab
|
||||
#include "chips.h" // Chip, FilterChip, ChoiceChip, InputChip
|
||||
#include "nav_drawer.h" // NavDrawer, NavItem
|
||||
#include "app_bar.h" // AppBar, AppBarTitle, AppBarAction
|
||||
|
||||
// ============================================================================
|
||||
// Quick Reference
|
||||
// ============================================================================
|
||||
//
|
||||
// BUTTONS:
|
||||
// Button(label, spec) - Generic button with style config
|
||||
// TextButton(label) - Text-only button
|
||||
// OutlinedButton(label) - Button with outline
|
||||
// ContainedButton(label) - Filled button (primary)
|
||||
// IconButton(icon, tooltip) - Circular icon button
|
||||
// FAB(icon) - Floating action button
|
||||
//
|
||||
// CARDS:
|
||||
// BeginCard(spec)/EndCard() - Card container
|
||||
// CardHeader(title, subtitle) - Card header section
|
||||
// CardContent(text) - Card body text
|
||||
// CardActions()/EndCardActions()- Card button area
|
||||
//
|
||||
// TEXT FIELDS:
|
||||
// TextField(label, buf, size) - Text input field
|
||||
// TextField(id, buf, size, spec)- Configurable text field
|
||||
//
|
||||
// LISTS:
|
||||
// BeginList(id)/EndList() - List container
|
||||
// ListItem(text) - Simple list item
|
||||
// ListItem(primary, secondary) - Two-line item
|
||||
// ListItem(spec) - Full config item
|
||||
// ListDivider(inset) - Divider line
|
||||
// ListSubheader(text) - Section header
|
||||
//
|
||||
// DIALOGS:
|
||||
// BeginDialog(id, &open, spec) - Modal dialog
|
||||
// EndDialog()
|
||||
// ConfirmDialog(...) - Confirm/cancel dialog
|
||||
// AlertDialog(...) - Single-action alert
|
||||
//
|
||||
// SELECTION CONTROLS:
|
||||
// Switch(label, &value) - Toggle switch
|
||||
// Checkbox(label, &value) - Checkbox
|
||||
// RadioButton(label, active) - Radio button
|
||||
// RadioButton(label, &sel, val) - Radio with int selection
|
||||
//
|
||||
// PROGRESS:
|
||||
// LinearProgress(fraction) - Determinate progress bar
|
||||
// LinearProgressIndeterminate() - Indeterminate progress bar
|
||||
// CircularProgress(fraction) - Circular progress
|
||||
// CircularProgressIndeterminate()- Spinner
|
||||
//
|
||||
// SNACKBAR:
|
||||
// ShowSnackbar(msg, action) - Show notification
|
||||
// DismissSnackbar() - Dismiss current snackbar
|
||||
// RenderSnackbar() - Call each frame to render
|
||||
//
|
||||
// SLIDER:
|
||||
// Slider(label, &val, min, max) - Continuous slider
|
||||
// SliderInt(label, &val, ...) - Integer slider
|
||||
// SliderDiscrete(...) - Stepped slider
|
||||
// SliderRange(...) - Two-thumb range slider
|
||||
//
|
||||
// TABS:
|
||||
// BeginTabBar(id, &idx) - Tab bar container
|
||||
// Tab(label) - Tab item
|
||||
// EndTabBar()
|
||||
// TabBar(id, labels, count, &idx) - Simple tab bar
|
||||
//
|
||||
// CHIPS:
|
||||
// Chip(label) - Action chip
|
||||
// FilterChip(label, &selected) - Toggleable filter chip
|
||||
// ChoiceChip(label, selected) - Choice chip
|
||||
// InputChip(label, avatar) - Deletable input chip
|
||||
// BeginChipGroup()/EndChipGroup()- Chip layout helper
|
||||
//
|
||||
// NAVIGATION DRAWER:
|
||||
// BeginNavDrawer(id, &open, spec) - Navigation drawer
|
||||
// EndNavDrawer()
|
||||
// NavItem(icon, label, selected) - Navigation item
|
||||
// NavDivider() - Drawer divider
|
||||
// NavSubheader(text) - Section header
|
||||
//
|
||||
// APP BAR:
|
||||
// BeginAppBar(id, spec) - Top app bar
|
||||
// EndAppBar()
|
||||
// AppBarNavIcon(icon) - Navigation icon (left)
|
||||
// AppBarTitle(title) - App bar title
|
||||
// AppBarAction(icon) - Action button (right)
|
||||
// BeginAppBarMenu(icon) - Overflow menu
|
||||
// AppBarMenuItem(label) - Menu item
|
||||
293
src/ui/material/components/dialogs.h
Normal file
293
src/ui/material/components/dialogs.h
Normal file
@@ -0,0 +1,293 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "../colors.h"
|
||||
#include "../typography.h"
|
||||
#include "../layout.h"
|
||||
#include "../../schema/ui_schema.h"
|
||||
#include "buttons.h"
|
||||
#include "imgui.h"
|
||||
#include "imgui_internal.h"
|
||||
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
namespace material {
|
||||
|
||||
// ============================================================================
|
||||
// Material Design Dialog Component
|
||||
// ============================================================================
|
||||
// Based on https://m2.material.io/components/dialogs
|
||||
//
|
||||
// Dialogs inform users about a task and can contain critical information,
|
||||
// require decisions, or involve multiple tasks.
|
||||
|
||||
/**
|
||||
* @brief Dialog configuration
|
||||
*/
|
||||
struct DialogSpec {
|
||||
const char* title = nullptr; // Dialog title
|
||||
float width = 560.0f; // Dialog width (280-560dp typical)
|
||||
float maxHeight = 0; // Max height (0 = auto)
|
||||
bool scrollableContent = false; // Enable content scrolling
|
||||
bool fullWidth = false; // Actions span full width
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Begin a modal dialog
|
||||
*
|
||||
* @param id Unique identifier
|
||||
* @param open Pointer to open state (will be set false when closed)
|
||||
* @param spec Dialog configuration
|
||||
* @return true if dialog is open
|
||||
*/
|
||||
bool BeginDialog(const char* id, bool* open, const DialogSpec& spec = DialogSpec());
|
||||
|
||||
/**
|
||||
* @brief End a dialog
|
||||
*/
|
||||
void EndDialog();
|
||||
|
||||
/**
|
||||
* @brief Simple dialog with just text content
|
||||
*/
|
||||
bool BeginDialog(const char* id, bool* open, const char* title);
|
||||
|
||||
/**
|
||||
* @brief Dialog content area (scrollable if configured)
|
||||
*/
|
||||
void BeginDialogContent();
|
||||
|
||||
/**
|
||||
* @brief End dialog content area
|
||||
*/
|
||||
void EndDialogContent();
|
||||
|
||||
/**
|
||||
* @brief Dialog actions area (buttons)
|
||||
*/
|
||||
void BeginDialogActions();
|
||||
|
||||
/**
|
||||
* @brief End dialog actions area
|
||||
*/
|
||||
void EndDialogActions();
|
||||
|
||||
/**
|
||||
* @brief Standard confirm dialog
|
||||
*
|
||||
* @param id Unique identifier
|
||||
* @param open Pointer to open state
|
||||
* @param title Dialog title
|
||||
* @param message Dialog message
|
||||
* @param confirmText Confirm button text
|
||||
* @param cancelText Cancel button text (nullptr for no cancel)
|
||||
* @return 0 = still open, 1 = confirmed, -1 = cancelled
|
||||
*/
|
||||
int ConfirmDialog(const char* id, bool* open, const char* title, const char* message,
|
||||
const char* confirmText = "Confirm", const char* cancelText = "Cancel");
|
||||
|
||||
/**
|
||||
* @brief Alert dialog (single action)
|
||||
*
|
||||
* @param id Unique identifier
|
||||
* @param open Pointer to open state
|
||||
* @param title Dialog title
|
||||
* @param message Dialog message
|
||||
* @param buttonText Button text
|
||||
* @return true when dismissed
|
||||
*/
|
||||
bool AlertDialog(const char* id, bool* open, const char* title, const char* message,
|
||||
const char* buttonText = "OK");
|
||||
|
||||
// ============================================================================
|
||||
// Implementation
|
||||
// ============================================================================
|
||||
|
||||
// Internal state for dialog rendering
|
||||
struct DialogState {
|
||||
ImVec2 contentMin;
|
||||
ImVec2 contentMax;
|
||||
float contentScrollY;
|
||||
bool scrollable;
|
||||
float width;
|
||||
};
|
||||
|
||||
static DialogState g_currentDialog;
|
||||
|
||||
inline bool BeginDialog(const char* id, bool* open, const DialogSpec& spec) {
|
||||
if (!*open)
|
||||
return false;
|
||||
|
||||
// Center dialog on screen
|
||||
ImGuiIO& io = ImGui::GetIO();
|
||||
ImVec2 center = ImVec2(io.DisplaySize.x * 0.5f, io.DisplaySize.y * 0.5f);
|
||||
ImGui::SetNextWindowPos(center, ImGuiCond_Always, ImVec2(0.5f, 0.5f));
|
||||
|
||||
// Set dialog size
|
||||
float dialogWidth = spec.width;
|
||||
ImGui::SetNextWindowSizeConstraints(
|
||||
ImVec2(280.0f, 0), // Min size
|
||||
ImVec2(dialogWidth, spec.maxHeight > 0 ? spec.maxHeight : io.DisplaySize.y * 0.9f)
|
||||
);
|
||||
|
||||
// Style dialog
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, size::DialogCornerRadius);
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0, 0));
|
||||
ImGui::PushStyleColor(ImGuiCol_WindowBg, ImGui::ColorConvertU32ToFloat4(Surface(Elevation::Dp24)));
|
||||
ImGui::PushStyleColor(ImGuiCol_PopupBg, ImGui::ColorConvertU32ToFloat4(Surface(Elevation::Dp24)));
|
||||
|
||||
// Modal background (scrim)
|
||||
ImDrawList* bgDrawList = ImGui::GetBackgroundDrawList();
|
||||
bgDrawList->AddRectFilled(
|
||||
ImVec2(0, 0), io.DisplaySize,
|
||||
schema::UI().resolveColor("var(--scrim)", IM_COL32(0, 0, 0, (int)(0.32f * 255)))
|
||||
);
|
||||
|
||||
// Open popup
|
||||
ImGui::OpenPopup(id);
|
||||
bool isOpen = ImGui::BeginPopupModal(id, open,
|
||||
ImGuiWindowFlags_AlwaysAutoResize |
|
||||
ImGuiWindowFlags_NoMove |
|
||||
ImGuiWindowFlags_NoTitleBar);
|
||||
|
||||
if (isOpen) {
|
||||
g_currentDialog.scrollable = spec.scrollableContent;
|
||||
g_currentDialog.width = dialogWidth;
|
||||
|
||||
// Title
|
||||
if (spec.title) {
|
||||
ImGui::Dummy(ImVec2(0, spacing::dp(3))); // 24dp top padding
|
||||
ImGui::SetCursorPosX(spacing::dp(3)); // 24dp left padding
|
||||
Typography::instance().text(TypeStyle::H6, spec.title);
|
||||
ImGui::Dummy(ImVec2(0, spacing::dp(2))); // 16dp below title
|
||||
} else {
|
||||
ImGui::Dummy(ImVec2(0, spacing::dp(2))); // 16dp top padding without title
|
||||
}
|
||||
}
|
||||
|
||||
ImGui::PopStyleColor(2);
|
||||
ImGui::PopStyleVar(2);
|
||||
|
||||
return isOpen;
|
||||
}
|
||||
|
||||
inline void EndDialog() {
|
||||
ImGui::EndPopup();
|
||||
}
|
||||
|
||||
inline bool BeginDialog(const char* id, bool* open, const char* title) {
|
||||
DialogSpec spec;
|
||||
spec.title = title;
|
||||
return BeginDialog(id, open, spec);
|
||||
}
|
||||
|
||||
inline void BeginDialogContent() {
|
||||
ImGui::SetCursorPosX(spacing::dp(3)); // 24dp left padding
|
||||
|
||||
// Start content region
|
||||
float maxWidth = g_currentDialog.width - spacing::dp(6); // 24dp padding each side
|
||||
ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + maxWidth);
|
||||
|
||||
if (g_currentDialog.scrollable) {
|
||||
ImGui::BeginChild("##dialogContent", ImVec2(maxWidth, 200), false);
|
||||
}
|
||||
}
|
||||
|
||||
inline void EndDialogContent() {
|
||||
if (g_currentDialog.scrollable) {
|
||||
ImGui::EndChild();
|
||||
}
|
||||
ImGui::PopTextWrapPos();
|
||||
ImGui::Dummy(ImVec2(0, spacing::dp(3))); // 24dp after content
|
||||
}
|
||||
|
||||
inline void BeginDialogActions() {
|
||||
// Actions area - right-aligned buttons with 8dp spacing
|
||||
float contentWidth = ImGui::GetContentRegionAvail().x;
|
||||
ImGui::SetCursorPosX(spacing::dp(1)); // 8dp left padding for actions
|
||||
|
||||
// Push style for action buttons
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(spacing::dp(1), 0)); // 8dp between buttons
|
||||
|
||||
// Right-align: use a dummy to push buttons right
|
||||
// Buttons will be added inline with SameLine
|
||||
}
|
||||
|
||||
inline void EndDialogActions() {
|
||||
ImGui::PopStyleVar();
|
||||
ImGui::Dummy(ImVec2(0, spacing::dp(1))); // 8dp bottom padding
|
||||
}
|
||||
|
||||
inline int ConfirmDialog(const char* id, bool* open, const char* title, const char* message,
|
||||
const char* confirmText, const char* cancelText) {
|
||||
int result = 0;
|
||||
|
||||
if (BeginDialog(id, open, title)) {
|
||||
BeginDialogContent();
|
||||
Typography::instance().textWrapped(TypeStyle::Body1, message);
|
||||
EndDialogContent();
|
||||
|
||||
BeginDialogActions();
|
||||
|
||||
// Calculate button positions for right alignment
|
||||
float cancelWidth = cancelText ? ImGui::CalcTextSize(cancelText).x + spacing::dp(2) : 0;
|
||||
float confirmWidth = ImGui::CalcTextSize(confirmText).x + spacing::dp(2);
|
||||
float totalButtonWidth = cancelWidth + confirmWidth + (cancelText ? spacing::dp(1) : 0);
|
||||
float startX = ImGui::GetContentRegionAvail().x - totalButtonWidth - spacing::dp(2);
|
||||
|
||||
ImGui::SetCursorPosX(ImGui::GetCursorPosX() + startX);
|
||||
|
||||
if (cancelText) {
|
||||
if (TextButton(cancelText)) {
|
||||
*open = false;
|
||||
result = -1;
|
||||
}
|
||||
ImGui::SameLine();
|
||||
}
|
||||
|
||||
if (ContainedButton(confirmText)) {
|
||||
*open = false;
|
||||
result = 1;
|
||||
}
|
||||
|
||||
EndDialogActions();
|
||||
EndDialog();
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
inline bool AlertDialog(const char* id, bool* open, const char* title, const char* message,
|
||||
const char* buttonText) {
|
||||
bool dismissed = false;
|
||||
|
||||
if (BeginDialog(id, open, title)) {
|
||||
BeginDialogContent();
|
||||
Typography::instance().textWrapped(TypeStyle::Body1, message);
|
||||
EndDialogContent();
|
||||
|
||||
BeginDialogActions();
|
||||
|
||||
// Right-align single button
|
||||
float buttonWidth = ImGui::CalcTextSize(buttonText).x + spacing::dp(2);
|
||||
float startX = ImGui::GetContentRegionAvail().x - buttonWidth - spacing::dp(2);
|
||||
ImGui::SetCursorPosX(ImGui::GetCursorPosX() + startX);
|
||||
|
||||
if (ContainedButton(buttonText)) {
|
||||
*open = false;
|
||||
dismissed = true;
|
||||
}
|
||||
|
||||
EndDialogActions();
|
||||
EndDialog();
|
||||
}
|
||||
|
||||
return dismissed;
|
||||
}
|
||||
|
||||
} // namespace material
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
414
src/ui/material/components/inputs.h
Normal file
414
src/ui/material/components/inputs.h
Normal file
@@ -0,0 +1,414 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "../colors.h"
|
||||
#include "../typography.h"
|
||||
#include "../layout.h"
|
||||
#include "../../schema/ui_schema.h"
|
||||
#include "imgui.h"
|
||||
#include "imgui_internal.h"
|
||||
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
namespace material {
|
||||
|
||||
// ============================================================================
|
||||
// Material Design Input Controls
|
||||
// ============================================================================
|
||||
// Based on https://m2.material.io/components/selection-controls
|
||||
//
|
||||
// Selection controls allow users to complete tasks that involve making choices:
|
||||
// - Switch: Toggle single option on/off
|
||||
// - Checkbox: Select multiple options
|
||||
// - Radio: Select one option from a set
|
||||
|
||||
// ============================================================================
|
||||
// Switch
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* @brief Material Design switch (toggle)
|
||||
*
|
||||
* @param label Text label
|
||||
* @param value Pointer to boolean value
|
||||
* @param disabled If true, switch is non-interactive
|
||||
* @return true if value changed
|
||||
*/
|
||||
bool Switch(const char* label, bool* value, bool disabled = false);
|
||||
|
||||
// ============================================================================
|
||||
// Checkbox
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* @brief Checkbox state
|
||||
*/
|
||||
enum class CheckboxState {
|
||||
Unchecked,
|
||||
Checked,
|
||||
Indeterminate // For parent with mixed children
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Material Design checkbox
|
||||
*
|
||||
* @param label Text label
|
||||
* @param value Pointer to boolean value
|
||||
* @param disabled If true, checkbox is non-interactive
|
||||
* @return true if value changed
|
||||
*/
|
||||
bool Checkbox(const char* label, bool* value, bool disabled = false);
|
||||
|
||||
/**
|
||||
* @brief Tri-state checkbox
|
||||
*/
|
||||
bool Checkbox(const char* label, CheckboxState* state, bool disabled = false);
|
||||
|
||||
// ============================================================================
|
||||
// Radio Button
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* @brief Material Design radio button
|
||||
*
|
||||
* @param label Text label
|
||||
* @param active true if this option is selected
|
||||
* @param disabled If true, radio is non-interactive
|
||||
* @return true if clicked (caller should update selection)
|
||||
*/
|
||||
bool RadioButton(const char* label, bool active, bool disabled = false);
|
||||
|
||||
/**
|
||||
* @brief Radio button with int selection
|
||||
*
|
||||
* @param label Text label
|
||||
* @param selection Pointer to current selection
|
||||
* @param value Value this radio represents
|
||||
* @return true if clicked
|
||||
*/
|
||||
bool RadioButton(const char* label, int* selection, int value, bool disabled = false);
|
||||
|
||||
// ============================================================================
|
||||
// Implementation
|
||||
// ============================================================================
|
||||
|
||||
inline bool Switch(const char* label, bool* value, bool disabled) {
|
||||
ImGuiWindow* window = ImGui::GetCurrentWindow();
|
||||
if (window->SkipItems)
|
||||
return false;
|
||||
|
||||
ImGui::PushID(label);
|
||||
|
||||
// Switch dimensions (Material spec: 36x14 track, 20dp thumb)
|
||||
const float trackWidth = 36.0f;
|
||||
const float trackHeight = 14.0f;
|
||||
const float thumbRadius = 10.0f; // 20dp diameter
|
||||
const float thumbTravel = trackWidth - thumbRadius * 2;
|
||||
|
||||
// Calculate layout
|
||||
ImVec2 pos = window->DC.CursorPos;
|
||||
float labelWidth = ImGui::CalcTextSize(label).x;
|
||||
float totalWidth = trackWidth + spacing::dp(2) + labelWidth;
|
||||
float totalHeight = ImMax(trackHeight + 6.0f, size::TouchTarget); // Min 48dp touch target
|
||||
|
||||
ImRect bb(pos, ImVec2(pos.x + totalWidth, pos.y + totalHeight));
|
||||
|
||||
// Interaction
|
||||
ImGuiID id = window->GetID("##switch");
|
||||
ImGui::ItemSize(bb);
|
||||
if (!ImGui::ItemAdd(bb, id))
|
||||
return false;
|
||||
|
||||
bool hovered, held;
|
||||
bool pressed = ImGui::ButtonBehavior(bb, id, &hovered, &held) && !disabled;
|
||||
|
||||
bool changed = false;
|
||||
if (pressed) {
|
||||
*value = !*value;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
// Animation (simple snap for now)
|
||||
float thumbX = *value ? (thumbTravel) : 0;
|
||||
|
||||
// Draw track
|
||||
ImDrawList* drawList = window->DrawList;
|
||||
float trackY = pos.y + totalHeight * 0.5f;
|
||||
ImVec2 trackMin(pos.x, trackY - trackHeight * 0.5f);
|
||||
ImVec2 trackMax(pos.x + trackWidth, trackY + trackHeight * 0.5f);
|
||||
|
||||
ImU32 trackColor;
|
||||
if (disabled) {
|
||||
trackColor = schema::UI().resolveColor("var(--switch-track-off)", IM_COL32(255, 255, 255, 30));
|
||||
} else if (*value) {
|
||||
trackColor = PrimaryVariant(); // Primary at 50% opacity
|
||||
} else {
|
||||
trackColor = schema::UI().resolveColor("var(--switch-track-on)", IM_COL32(255, 255, 255, 97));
|
||||
}
|
||||
|
||||
drawList->AddRectFilled(trackMin, trackMax, trackColor, trackHeight * 0.5f);
|
||||
|
||||
// Draw thumb
|
||||
ImVec2 thumbCenter(pos.x + thumbRadius + thumbX, trackY);
|
||||
|
||||
ImU32 thumbColor;
|
||||
if (disabled) {
|
||||
thumbColor = schema::UI().resolveColor("var(--switch-thumb-off)", IM_COL32(189, 189, 189, 255));
|
||||
} else if (*value) {
|
||||
thumbColor = Primary();
|
||||
} else {
|
||||
thumbColor = schema::UI().resolveColor("var(--switch-thumb-on)", IM_COL32(250, 250, 250, 255));
|
||||
}
|
||||
|
||||
// Thumb shadow
|
||||
drawList->AddCircleFilled(ImVec2(thumbCenter.x + 1, thumbCenter.y + 2), thumbRadius, schema::UI().resolveColor("var(--control-shadow)", IM_COL32(0, 0, 0, 60)));
|
||||
drawList->AddCircleFilled(thumbCenter, thumbRadius, thumbColor);
|
||||
|
||||
// Hover ripple effect
|
||||
if (hovered && !disabled) {
|
||||
ImU32 ripple = *value ? WithAlpha(Primary(), 25) : schema::UI().resolveColor("var(--hover-overlay)", IM_COL32(255, 255, 255, 25));
|
||||
drawList->AddCircleFilled(thumbCenter, thumbRadius + 12.0f, ripple);
|
||||
}
|
||||
|
||||
// Draw label
|
||||
ImVec2 labelPos(pos.x + trackWidth + spacing::dp(2), pos.y + (totalHeight - ImGui::GetFontSize()) * 0.5f);
|
||||
ImU32 labelColor = disabled ? OnSurfaceDisabled() : OnSurface();
|
||||
drawList->AddText(labelPos, labelColor, label);
|
||||
|
||||
ImGui::PopID();
|
||||
|
||||
return changed;
|
||||
}
|
||||
|
||||
inline bool Checkbox(const char* label, bool* value, bool disabled) {
|
||||
ImGuiWindow* window = ImGui::GetCurrentWindow();
|
||||
if (window->SkipItems)
|
||||
return false;
|
||||
|
||||
ImGui::PushID(label);
|
||||
|
||||
// Checkbox dimensions (18dp box, 48dp touch target)
|
||||
const float boxSize = 18.0f;
|
||||
|
||||
// Calculate layout
|
||||
ImVec2 pos = window->DC.CursorPos;
|
||||
float labelWidth = ImGui::CalcTextSize(label).x;
|
||||
float totalWidth = boxSize + spacing::dp(2) + labelWidth;
|
||||
float totalHeight = size::TouchTarget;
|
||||
|
||||
ImRect bb(pos, ImVec2(pos.x + totalWidth, pos.y + totalHeight));
|
||||
|
||||
// Interaction
|
||||
ImGuiID id = window->GetID("##checkbox");
|
||||
ImGui::ItemSize(bb);
|
||||
if (!ImGui::ItemAdd(bb, id))
|
||||
return false;
|
||||
|
||||
bool hovered, held;
|
||||
bool pressed = ImGui::ButtonBehavior(bb, id, &hovered, &held) && !disabled;
|
||||
|
||||
bool changed = false;
|
||||
if (pressed) {
|
||||
*value = !*value;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
// Draw checkbox
|
||||
ImDrawList* drawList = window->DrawList;
|
||||
float centerY = pos.y + totalHeight * 0.5f;
|
||||
ImVec2 boxMin(pos.x, centerY - boxSize * 0.5f);
|
||||
ImVec2 boxMax(pos.x + boxSize, centerY + boxSize * 0.5f);
|
||||
|
||||
ImU32 boxColor, checkColor;
|
||||
if (disabled) {
|
||||
boxColor = OnSurfaceDisabled();
|
||||
checkColor = schema::UI().resolveColor("var(--checkbox-check)", IM_COL32(0, 0, 0, 255));
|
||||
} else if (*value) {
|
||||
boxColor = Primary();
|
||||
checkColor = OnPrimary();
|
||||
} else {
|
||||
boxColor = OnSurfaceMedium();
|
||||
checkColor = OnPrimary();
|
||||
}
|
||||
|
||||
if (*value) {
|
||||
// Filled checkbox with checkmark
|
||||
drawList->AddRectFilled(boxMin, boxMax, boxColor, 2.0f);
|
||||
|
||||
// Draw checkmark
|
||||
ImVec2 checkStart(boxMin.x + 4, centerY);
|
||||
ImVec2 checkMid(boxMin.x + 7, centerY + 3);
|
||||
ImVec2 checkEnd(boxMin.x + 14, centerY - 4);
|
||||
|
||||
drawList->AddLine(checkStart, checkMid, checkColor, 2.0f);
|
||||
drawList->AddLine(checkMid, checkEnd, checkColor, 2.0f);
|
||||
} else {
|
||||
// Empty checkbox border
|
||||
drawList->AddRect(boxMin, boxMax, boxColor, 2.0f, 0, 2.0f);
|
||||
}
|
||||
|
||||
// Hover ripple
|
||||
if (hovered && !disabled) {
|
||||
ImVec2 boxCenter((boxMin.x + boxMax.x) * 0.5f, centerY);
|
||||
drawList->AddCircleFilled(boxCenter, boxSize,
|
||||
*value ? WithAlpha(Primary(), 25) : schema::UI().resolveColor("var(--hover-overlay)", IM_COL32(255, 255, 255, 25)));
|
||||
}
|
||||
|
||||
// Draw label
|
||||
ImVec2 labelPos(pos.x + boxSize + spacing::dp(2), pos.y + (totalHeight - ImGui::GetFontSize()) * 0.5f);
|
||||
ImU32 labelColor = disabled ? OnSurfaceDisabled() : OnSurface();
|
||||
drawList->AddText(labelPos, labelColor, label);
|
||||
|
||||
ImGui::PopID();
|
||||
|
||||
return changed;
|
||||
}
|
||||
|
||||
inline bool Checkbox(const char* label, CheckboxState* state, bool disabled) {
|
||||
bool checked = (*state == CheckboxState::Checked);
|
||||
bool indeterminate = (*state == CheckboxState::Indeterminate);
|
||||
|
||||
ImGuiWindow* window = ImGui::GetCurrentWindow();
|
||||
if (window->SkipItems)
|
||||
return false;
|
||||
|
||||
ImGui::PushID(label);
|
||||
|
||||
const float boxSize = 18.0f;
|
||||
|
||||
ImVec2 pos = window->DC.CursorPos;
|
||||
float labelWidth = ImGui::CalcTextSize(label).x;
|
||||
float totalWidth = boxSize + spacing::dp(2) + labelWidth;
|
||||
float totalHeight = size::TouchTarget;
|
||||
|
||||
ImRect bb(pos, ImVec2(pos.x + totalWidth, pos.y + totalHeight));
|
||||
|
||||
ImGuiID id = window->GetID("##checkbox");
|
||||
ImGui::ItemSize(bb);
|
||||
if (!ImGui::ItemAdd(bb, id))
|
||||
return false;
|
||||
|
||||
bool hovered, held;
|
||||
bool pressed = ImGui::ButtonBehavior(bb, id, &hovered, &held) && !disabled;
|
||||
|
||||
bool changed = false;
|
||||
if (pressed) {
|
||||
// Cycle: Unchecked -> Checked -> Unchecked (indeterminate only set programmatically)
|
||||
*state = (*state == CheckboxState::Checked) ? CheckboxState::Unchecked : CheckboxState::Checked;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
ImDrawList* drawList = window->DrawList;
|
||||
float centerY = pos.y + totalHeight * 0.5f;
|
||||
ImVec2 boxMin(pos.x, centerY - boxSize * 0.5f);
|
||||
ImVec2 boxMax(pos.x + boxSize, centerY + boxSize * 0.5f);
|
||||
|
||||
ImU32 boxColor = disabled ? OnSurfaceDisabled() : (checked || indeterminate) ? Primary() : OnSurfaceMedium();
|
||||
|
||||
if (checked || indeterminate) {
|
||||
drawList->AddRectFilled(boxMin, boxMax, boxColor, 2.0f);
|
||||
|
||||
if (indeterminate) {
|
||||
// Horizontal line for indeterminate
|
||||
drawList->AddLine(
|
||||
ImVec2(boxMin.x + 4, centerY),
|
||||
ImVec2(boxMax.x - 4, centerY),
|
||||
OnPrimary(), 2.0f
|
||||
);
|
||||
} else {
|
||||
// Checkmark
|
||||
ImVec2 checkStart(boxMin.x + 4, centerY);
|
||||
ImVec2 checkMid(boxMin.x + 7, centerY + 3);
|
||||
ImVec2 checkEnd(boxMin.x + 14, centerY - 4);
|
||||
drawList->AddLine(checkStart, checkMid, OnPrimary(), 2.0f);
|
||||
drawList->AddLine(checkMid, checkEnd, OnPrimary(), 2.0f);
|
||||
}
|
||||
} else {
|
||||
drawList->AddRect(boxMin, boxMax, boxColor, 2.0f, 0, 2.0f);
|
||||
}
|
||||
|
||||
if (hovered && !disabled) {
|
||||
ImVec2 boxCenter((boxMin.x + boxMax.x) * 0.5f, centerY);
|
||||
drawList->AddCircleFilled(boxCenter, boxSize, schema::UI().resolveColor("var(--hover-overlay)", IM_COL32(255, 255, 255, 25)));
|
||||
}
|
||||
|
||||
ImVec2 labelPos(pos.x + boxSize + spacing::dp(2), pos.y + (totalHeight - ImGui::GetFontSize()) * 0.5f);
|
||||
ImU32 labelColor = disabled ? OnSurfaceDisabled() : OnSurface();
|
||||
drawList->AddText(labelPos, labelColor, label);
|
||||
|
||||
ImGui::PopID();
|
||||
|
||||
return changed;
|
||||
}
|
||||
|
||||
inline bool RadioButton(const char* label, bool active, bool disabled) {
|
||||
ImGuiWindow* window = ImGui::GetCurrentWindow();
|
||||
if (window->SkipItems)
|
||||
return false;
|
||||
|
||||
ImGui::PushID(label);
|
||||
|
||||
// Radio button dimensions (20dp outer, 10dp inner when selected)
|
||||
const float outerRadius = 10.0f;
|
||||
const float innerRadius = 5.0f;
|
||||
|
||||
ImVec2 pos = window->DC.CursorPos;
|
||||
float labelWidth = ImGui::CalcTextSize(label).x;
|
||||
float totalWidth = outerRadius * 2 + spacing::dp(2) + labelWidth;
|
||||
float totalHeight = size::TouchTarget;
|
||||
|
||||
ImRect bb(pos, ImVec2(pos.x + totalWidth, pos.y + totalHeight));
|
||||
|
||||
ImGuiID id = window->GetID("##radio");
|
||||
ImGui::ItemSize(bb);
|
||||
if (!ImGui::ItemAdd(bb, id))
|
||||
return false;
|
||||
|
||||
bool hovered, held;
|
||||
bool pressed = ImGui::ButtonBehavior(bb, id, &hovered, &held) && !disabled;
|
||||
|
||||
ImDrawList* drawList = window->DrawList;
|
||||
float centerY = pos.y + totalHeight * 0.5f;
|
||||
ImVec2 center(pos.x + outerRadius, centerY);
|
||||
|
||||
ImU32 ringColor = disabled ? OnSurfaceDisabled() : active ? Primary() : OnSurfaceMedium();
|
||||
|
||||
// Outer ring
|
||||
drawList->AddCircle(center, outerRadius, ringColor, 0, 2.0f);
|
||||
|
||||
// Inner dot when active
|
||||
if (active) {
|
||||
drawList->AddCircleFilled(center, innerRadius, ringColor);
|
||||
}
|
||||
|
||||
// Hover ripple
|
||||
if (hovered && !disabled) {
|
||||
drawList->AddCircleFilled(center, outerRadius + 12.0f,
|
||||
active ? WithAlpha(Primary(), 25) : schema::UI().resolveColor("var(--hover-overlay)", IM_COL32(255, 255, 255, 25)));
|
||||
}
|
||||
|
||||
// Label
|
||||
ImVec2 labelPos(pos.x + outerRadius * 2 + spacing::dp(2), pos.y + (totalHeight - ImGui::GetFontSize()) * 0.5f);
|
||||
ImU32 labelColor = disabled ? OnSurfaceDisabled() : OnSurface();
|
||||
drawList->AddText(labelPos, labelColor, label);
|
||||
|
||||
ImGui::PopID();
|
||||
|
||||
return pressed;
|
||||
}
|
||||
|
||||
inline bool RadioButton(const char* label, int* selection, int value, bool disabled) {
|
||||
bool active = (*selection == value);
|
||||
if (RadioButton(label, active, disabled)) {
|
||||
*selection = value;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
} // namespace material
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
306
src/ui/material/components/lists.h
Normal file
306
src/ui/material/components/lists.h
Normal file
@@ -0,0 +1,306 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "../colors.h"
|
||||
#include "../typography.h"
|
||||
#include "../layout.h"
|
||||
#include "../../schema/ui_schema.h"
|
||||
#include "../../embedded/IconsMaterialDesign.h"
|
||||
#include "imgui.h"
|
||||
#include "imgui_internal.h"
|
||||
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
namespace material {
|
||||
|
||||
// ============================================================================
|
||||
// Material Design List Component
|
||||
// ============================================================================
|
||||
// Based on https://m2.material.io/components/lists
|
||||
//
|
||||
// Lists present content in a way that makes it easy to identify a specific
|
||||
// item in a collection and act on it.
|
||||
|
||||
/**
|
||||
* @brief List item configuration
|
||||
*/
|
||||
struct ListItemSpec {
|
||||
const char* leadingIcon = nullptr; // Optional leading icon (text representation)
|
||||
const char* leadingAvatar = nullptr; // Optional avatar text (for initials)
|
||||
ImU32 leadingAvatarColor = 0; // Avatar background color (0 = primary)
|
||||
bool leadingCheckbox = false; // Show checkbox instead of icon
|
||||
bool checkboxChecked = false; // Checkbox state
|
||||
const char* primaryText = nullptr; // Main text (required)
|
||||
const char* secondaryText = nullptr; // Secondary text (optional)
|
||||
const char* trailingIcon = nullptr; // Optional trailing icon
|
||||
const char* trailingText = nullptr; // Optional trailing metadata text
|
||||
bool selected = false; // Selected state (highlight)
|
||||
bool disabled = false; // Disabled state
|
||||
bool dividerBelow = false; // Draw divider below item
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Begin a list container
|
||||
*
|
||||
* @param id Unique identifier
|
||||
* @param withPadding Add top/bottom padding
|
||||
*/
|
||||
void BeginList(const char* id, bool withPadding = true);
|
||||
|
||||
/**
|
||||
* @brief End a list container
|
||||
*/
|
||||
void EndList();
|
||||
|
||||
/**
|
||||
* @brief Render a list item
|
||||
*
|
||||
* @param spec Item configuration
|
||||
* @return true if clicked
|
||||
*/
|
||||
bool ListItem(const ListItemSpec& spec);
|
||||
|
||||
/**
|
||||
* @brief Simple single-line list item
|
||||
*/
|
||||
bool ListItem(const char* text);
|
||||
|
||||
/**
|
||||
* @brief Two-line list item with primary and secondary text
|
||||
*/
|
||||
bool ListItem(const char* primary, const char* secondary);
|
||||
|
||||
/**
|
||||
* @brief List divider (full width or inset)
|
||||
*
|
||||
* @param inset If true, indented to align with text
|
||||
*/
|
||||
void ListDivider(bool inset = false);
|
||||
|
||||
/**
|
||||
* @brief List subheader text
|
||||
*/
|
||||
void ListSubheader(const char* text);
|
||||
|
||||
// ============================================================================
|
||||
// Implementation
|
||||
// ============================================================================
|
||||
|
||||
inline void BeginList(const char* id, bool withPadding) {
|
||||
ImGui::PushID(id);
|
||||
if (withPadding) {
|
||||
ImGui::Dummy(ImVec2(0, spacing::dp(1))); // 8dp top padding
|
||||
}
|
||||
}
|
||||
|
||||
inline void EndList() {
|
||||
ImGui::PopID();
|
||||
}
|
||||
|
||||
inline bool ListItem(const ListItemSpec& spec) {
|
||||
ImGuiWindow* window = ImGui::GetCurrentWindow();
|
||||
if (window->SkipItems)
|
||||
return false;
|
||||
|
||||
// Calculate item height based on content
|
||||
bool hasSecondary = (spec.secondaryText != nullptr);
|
||||
bool hasLeadingElement = (spec.leadingIcon || spec.leadingAvatar || spec.leadingCheckbox);
|
||||
|
||||
float itemHeight;
|
||||
if (hasSecondary) {
|
||||
itemHeight = size::ListItemTwoLineHeight; // 72dp for two-line
|
||||
} else if (hasLeadingElement) {
|
||||
itemHeight = size::ListItemHeight; // 56dp with leading element
|
||||
} else {
|
||||
itemHeight = 48.0f; // 48dp simple one-line
|
||||
}
|
||||
|
||||
// Item dimensions
|
||||
ImVec2 pos = window->DC.CursorPos;
|
||||
float itemWidth = ImGui::GetContentRegionAvail().x;
|
||||
ImRect bb(pos, ImVec2(pos.x + itemWidth, pos.y + itemHeight));
|
||||
|
||||
// Interaction
|
||||
ImGuiID itemId = window->GetID(spec.primaryText);
|
||||
ImGui::ItemSize(bb);
|
||||
if (!ImGui::ItemAdd(bb, itemId))
|
||||
return false;
|
||||
|
||||
bool hovered, held;
|
||||
bool pressed = ImGui::ButtonBehavior(bb, itemId, &hovered, &held) && !spec.disabled;
|
||||
|
||||
// Draw background
|
||||
ImDrawList* drawList = window->DrawList;
|
||||
ImU32 bgColor = 0;
|
||||
|
||||
if (spec.selected) {
|
||||
bgColor = PrimaryContainer();
|
||||
} else if (held && !spec.disabled) {
|
||||
bgColor = schema::UI().resolveColor("var(--hover-overlay)", IM_COL32(255, 255, 255, 25));
|
||||
} else if (hovered && !spec.disabled) {
|
||||
bgColor = schema::UI().resolveColor("var(--active-overlay)", IM_COL32(255, 255, 255, 10));
|
||||
}
|
||||
|
||||
if (bgColor) {
|
||||
drawList->AddRectFilled(bb.Min, bb.Max, bgColor);
|
||||
}
|
||||
|
||||
// Layout positions
|
||||
float leftPadding = spacing::dp(2); // 16dp left padding
|
||||
float currentX = bb.Min.x + leftPadding;
|
||||
float centerY = bb.Min.y + itemHeight * 0.5f;
|
||||
|
||||
// Draw leading element
|
||||
if (spec.leadingAvatar) {
|
||||
// Avatar circle with text
|
||||
float avatarRadius = 20.0f; // 40dp diameter
|
||||
ImVec2 avatarCenter(currentX + avatarRadius, centerY);
|
||||
|
||||
ImU32 avatarBg = spec.leadingAvatarColor ? spec.leadingAvatarColor : Primary();
|
||||
drawList->AddCircleFilled(avatarCenter, avatarRadius, avatarBg);
|
||||
|
||||
// Avatar text (centered)
|
||||
ImVec2 textSize = ImGui::CalcTextSize(spec.leadingAvatar);
|
||||
ImVec2 textPos(avatarCenter.x - textSize.x * 0.5f, avatarCenter.y - textSize.y * 0.5f);
|
||||
drawList->AddText(textPos, OnPrimary(), spec.leadingAvatar);
|
||||
|
||||
currentX += 40.0f + spacing::dp(2); // 40dp avatar + 16dp gap
|
||||
} else if (spec.leadingIcon) {
|
||||
// Icon
|
||||
ImVec2 iconSize = ImGui::CalcTextSize(spec.leadingIcon);
|
||||
float iconY = centerY - iconSize.y * 0.5f;
|
||||
ImU32 iconColor = spec.disabled ? OnSurfaceDisabled() : OnSurfaceMedium();
|
||||
drawList->AddText(ImVec2(currentX, iconY), iconColor, spec.leadingIcon);
|
||||
currentX += 24.0f + spacing::dp(2); // 24dp icon + 16dp gap
|
||||
} else if (spec.leadingCheckbox) {
|
||||
// Checkbox (simplified visual)
|
||||
float checkboxSize = 18.0f;
|
||||
ImVec2 checkMin(currentX, centerY - checkboxSize * 0.5f);
|
||||
ImVec2 checkMax(currentX + checkboxSize, centerY + checkboxSize * 0.5f);
|
||||
|
||||
if (spec.checkboxChecked) {
|
||||
drawList->AddRectFilled(checkMin, checkMax, Primary(), 2.0f);
|
||||
// Checkmark
|
||||
ImFont* iconFont = Typography::instance().iconSmall();
|
||||
ImVec2 chkSz = iconFont->CalcTextSizeA(iconFont->LegacySize, 1000.0f, 0.0f, ICON_MD_CHECK);
|
||||
drawList->AddText(iconFont, iconFont->LegacySize,
|
||||
ImVec2(checkMin.x + (checkboxSize - chkSz.x) * 0.5f, checkMin.y + (checkboxSize - chkSz.y) * 0.5f),
|
||||
OnPrimary(), ICON_MD_CHECK);
|
||||
} else {
|
||||
drawList->AddRect(checkMin, checkMax, OnSurfaceMedium(), 2.0f, 0, 2.0f);
|
||||
}
|
||||
|
||||
currentX += checkboxSize + spacing::dp(2);
|
||||
}
|
||||
|
||||
// Calculate text area
|
||||
float rightPadding = spacing::dp(2); // 16dp right padding
|
||||
float trailingSpace = 0;
|
||||
if (spec.trailingIcon) trailingSpace += 24.0f + spacing::dp(1);
|
||||
if (spec.trailingText) trailingSpace += ImGui::CalcTextSize(spec.trailingText).x + spacing::dp(1);
|
||||
|
||||
float textMaxX = bb.Max.x - rightPadding - trailingSpace;
|
||||
|
||||
// Draw text
|
||||
ImU32 primaryColor = spec.disabled ? OnSurfaceDisabled() : OnSurface();
|
||||
ImU32 secondaryColor = spec.disabled ? OnSurfaceDisabled() : OnSurfaceMedium();
|
||||
|
||||
if (hasSecondary) {
|
||||
// Two-line layout
|
||||
float primaryY = bb.Min.y + 16.0f;
|
||||
float secondaryY = primaryY + 20.0f;
|
||||
|
||||
Typography::instance().pushFont(TypeStyle::Body1);
|
||||
drawList->AddText(ImVec2(currentX, primaryY), primaryColor, spec.primaryText);
|
||||
Typography::instance().popFont();
|
||||
|
||||
Typography::instance().pushFont(TypeStyle::Body2);
|
||||
drawList->AddText(ImVec2(currentX, secondaryY), secondaryColor, spec.secondaryText);
|
||||
Typography::instance().popFont();
|
||||
} else {
|
||||
// Single-line layout
|
||||
Typography::instance().pushFont(TypeStyle::Body1);
|
||||
float textY = centerY - Typography::instance().getFont(TypeStyle::Body1)->FontSize * 0.5f;
|
||||
drawList->AddText(ImVec2(currentX, textY), primaryColor, spec.primaryText);
|
||||
Typography::instance().popFont();
|
||||
}
|
||||
|
||||
// Draw trailing elements
|
||||
float trailingX = bb.Max.x - rightPadding;
|
||||
|
||||
if (spec.trailingText) {
|
||||
ImVec2 textSize = ImGui::CalcTextSize(spec.trailingText);
|
||||
trailingX -= textSize.x;
|
||||
float textY = centerY - textSize.y * 0.5f;
|
||||
drawList->AddText(ImVec2(trailingX, textY), secondaryColor, spec.trailingText);
|
||||
trailingX -= spacing::dp(1);
|
||||
}
|
||||
|
||||
if (spec.trailingIcon) {
|
||||
ImVec2 iconSize = ImGui::CalcTextSize(spec.trailingIcon);
|
||||
trailingX -= 24.0f;
|
||||
float iconY = centerY - iconSize.y * 0.5f;
|
||||
drawList->AddText(ImVec2(trailingX, iconY), OnSurfaceMedium(), spec.trailingIcon);
|
||||
}
|
||||
|
||||
// Draw divider
|
||||
if (spec.dividerBelow) {
|
||||
float dividerY = bb.Max.y - 0.5f;
|
||||
drawList->AddLine(
|
||||
ImVec2(bb.Min.x + leftPadding, dividerY),
|
||||
ImVec2(bb.Max.x, dividerY),
|
||||
OnSurfaceDisabled()
|
||||
);
|
||||
}
|
||||
|
||||
return pressed;
|
||||
}
|
||||
|
||||
inline bool ListItem(const char* text) {
|
||||
ListItemSpec spec;
|
||||
spec.primaryText = text;
|
||||
return ListItem(spec);
|
||||
}
|
||||
|
||||
inline bool ListItem(const char* primary, const char* secondary) {
|
||||
ListItemSpec spec;
|
||||
spec.primaryText = primary;
|
||||
spec.secondaryText = secondary;
|
||||
return ListItem(spec);
|
||||
}
|
||||
|
||||
inline void ListDivider(bool inset) {
|
||||
ImGuiWindow* window = ImGui::GetCurrentWindow();
|
||||
if (window->SkipItems)
|
||||
return;
|
||||
|
||||
float width = ImGui::GetContentRegionAvail().x;
|
||||
float leftOffset = inset ? 72.0f : 0; // Align with text after avatar/icon
|
||||
|
||||
ImVec2 pos = window->DC.CursorPos;
|
||||
ImDrawList* drawList = window->DrawList;
|
||||
|
||||
drawList->AddLine(
|
||||
ImVec2(pos.x + leftOffset, pos.y),
|
||||
ImVec2(pos.x + width, pos.y),
|
||||
OnSurfaceDisabled()
|
||||
);
|
||||
|
||||
ImGui::Dummy(ImVec2(width, 1.0f));
|
||||
}
|
||||
|
||||
inline void ListSubheader(const char* text) {
|
||||
ImGui::Dummy(ImVec2(0, spacing::dp(1))); // 8dp top padding
|
||||
|
||||
ImGui::SetCursorPosX(ImGui::GetCursorPosX() + spacing::dp(2)); // 16dp left padding
|
||||
Typography::instance().textColored(TypeStyle::Caption, Primary(), text);
|
||||
|
||||
ImGui::Dummy(ImVec2(0, spacing::dp(1))); // 8dp bottom padding
|
||||
}
|
||||
|
||||
} // namespace material
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
379
src/ui/material/components/nav_drawer.h
Normal file
379
src/ui/material/components/nav_drawer.h
Normal file
@@ -0,0 +1,379 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "../colors.h"
|
||||
#include "../typography.h"
|
||||
#include "../layout.h"
|
||||
#include "../../schema/ui_schema.h"
|
||||
#include "imgui.h"
|
||||
#include "imgui_internal.h"
|
||||
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
namespace material {
|
||||
|
||||
// ============================================================================
|
||||
// Material Design Navigation Drawer Component
|
||||
// ============================================================================
|
||||
// Based on https://m2.material.io/components/navigation-drawer
|
||||
//
|
||||
// Navigation drawers provide access to destinations in your app.
|
||||
|
||||
enum class NavDrawerType {
|
||||
Standard, // Permanent, always visible
|
||||
Modal, // Overlay with scrim, can be dismissed
|
||||
Dismissible // Can be shown/hidden, no scrim
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Navigation drawer configuration
|
||||
*/
|
||||
struct NavDrawerSpec {
|
||||
NavDrawerType type = NavDrawerType::Standard;
|
||||
float width = 256.0f; // 256dp standard width
|
||||
bool showDividerBottom = true; // Divider at bottom
|
||||
const char* headerTitle = nullptr; // Optional header title
|
||||
const char* headerSubtitle = nullptr;
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Navigation item configuration
|
||||
*/
|
||||
struct NavItemSpec {
|
||||
const char* icon = nullptr; // Leading icon
|
||||
const char* label = nullptr; // Item label (required)
|
||||
bool selected = false; // Selected state
|
||||
bool disabled = false;
|
||||
int badgeCount = 0; // Badge (0 = no badge)
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Begin a navigation drawer
|
||||
*
|
||||
* @param id Unique identifier
|
||||
* @param open Pointer to open state (for modal/dismissible)
|
||||
* @param spec Drawer configuration
|
||||
* @return true if drawer is visible
|
||||
*/
|
||||
bool BeginNavDrawer(const char* id, bool* open, const NavDrawerSpec& spec = NavDrawerSpec());
|
||||
|
||||
/**
|
||||
* @brief Begin standard (always visible) navigation drawer
|
||||
*/
|
||||
bool BeginNavDrawer(const char* id, const NavDrawerSpec& spec = NavDrawerSpec());
|
||||
|
||||
/**
|
||||
* @brief End navigation drawer
|
||||
*/
|
||||
void EndNavDrawer();
|
||||
|
||||
/**
|
||||
* @brief Render a navigation item
|
||||
*
|
||||
* @param spec Item configuration
|
||||
* @return true if clicked
|
||||
*/
|
||||
bool NavItem(const NavItemSpec& spec);
|
||||
|
||||
/**
|
||||
* @brief Simple navigation item
|
||||
*/
|
||||
bool NavItem(const char* icon, const char* label, bool selected = false);
|
||||
|
||||
/**
|
||||
* @brief Navigation divider
|
||||
*/
|
||||
void NavDivider();
|
||||
|
||||
/**
|
||||
* @brief Navigation subheader
|
||||
*/
|
||||
void NavSubheader(const char* text);
|
||||
|
||||
// ============================================================================
|
||||
// Implementation
|
||||
// ============================================================================
|
||||
|
||||
struct NavDrawerState {
|
||||
float width;
|
||||
ImVec2 contentMin;
|
||||
ImVec2 contentMax;
|
||||
bool isModal;
|
||||
};
|
||||
|
||||
static NavDrawerState g_navDrawerState;
|
||||
|
||||
inline bool BeginNavDrawer(const char* id, bool* open, const NavDrawerSpec& spec) {
|
||||
// For modal drawers, check open state
|
||||
if (spec.type == NavDrawerType::Modal && !*open) {
|
||||
return false;
|
||||
}
|
||||
|
||||
ImGuiWindow* window = ImGui::GetCurrentWindow();
|
||||
if (window->SkipItems)
|
||||
return false;
|
||||
|
||||
ImGui::PushID(id);
|
||||
|
||||
g_navDrawerState.width = spec.width;
|
||||
g_navDrawerState.isModal = (spec.type == NavDrawerType::Modal);
|
||||
|
||||
ImGuiIO& io = ImGui::GetIO();
|
||||
ImDrawList* drawList = window->DrawList;
|
||||
|
||||
// For modal, draw scrim and handle dismiss
|
||||
if (spec.type == NavDrawerType::Modal) {
|
||||
ImDrawList* bgDrawList = ImGui::GetBackgroundDrawList();
|
||||
bgDrawList->AddRectFilled(
|
||||
ImVec2(0, 0), io.DisplaySize,
|
||||
schema::UI().resolveColor("var(--scrim)", IM_COL32(0, 0, 0, (int)(0.32f * 255)))
|
||||
);
|
||||
|
||||
// Click outside to dismiss
|
||||
if (ImGui::IsMouseClicked(0)) {
|
||||
ImVec2 mousePos = io.MousePos;
|
||||
if (mousePos.x > spec.width) {
|
||||
*open = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Drawer position and size
|
||||
ImVec2 drawerPos(0, 0);
|
||||
ImVec2 drawerSize(spec.width, io.DisplaySize.y);
|
||||
|
||||
// If not modal, account for app bar
|
||||
if (spec.type != NavDrawerType::Modal) {
|
||||
drawerPos.y = size::AppBarHeight;
|
||||
drawerSize.y = io.DisplaySize.y - size::AppBarHeight;
|
||||
}
|
||||
|
||||
ImRect drawerBB(drawerPos, ImVec2(drawerPos.x + drawerSize.x, drawerPos.y + drawerSize.y));
|
||||
|
||||
// Draw drawer background
|
||||
ImU32 bgColor = Surface(Elevation::Dp16);
|
||||
drawList->AddRectFilled(drawerBB.Min, drawerBB.Max, bgColor);
|
||||
|
||||
// Store content region
|
||||
g_navDrawerState.contentMin = ImVec2(drawerBB.Min.x, drawerBB.Min.y);
|
||||
g_navDrawerState.contentMax = drawerBB.Max;
|
||||
|
||||
// Header
|
||||
float currentY = drawerBB.Min.y;
|
||||
|
||||
if (spec.headerTitle || spec.headerSubtitle) {
|
||||
// Header area (optional)
|
||||
float headerHeight = 64.0f;
|
||||
|
||||
ImVec2 headerMin(drawerBB.Min.x, currentY);
|
||||
ImVec2 headerMax(drawerBB.Max.x, currentY + headerHeight);
|
||||
|
||||
// Header background (slightly elevated)
|
||||
drawList->AddRectFilled(headerMin, headerMax, Surface(Elevation::Dp16));
|
||||
|
||||
// Header title
|
||||
if (spec.headerTitle) {
|
||||
ImGui::SetCursorScreenPos(ImVec2(drawerBB.Min.x + spacing::dp(2), currentY + 20.0f));
|
||||
Typography::instance().text(TypeStyle::H6, spec.headerTitle);
|
||||
}
|
||||
|
||||
if (spec.headerSubtitle) {
|
||||
ImGui::SetCursorScreenPos(ImVec2(drawerBB.Min.x + spacing::dp(2), currentY + 42.0f));
|
||||
Typography::instance().textColored(TypeStyle::Body2, OnSurfaceMedium(), spec.headerSubtitle);
|
||||
}
|
||||
|
||||
currentY += headerHeight;
|
||||
|
||||
// Divider under header
|
||||
drawList->AddLine(
|
||||
ImVec2(drawerBB.Min.x, currentY),
|
||||
ImVec2(drawerBB.Max.x, currentY),
|
||||
OnSurfaceDisabled()
|
||||
);
|
||||
}
|
||||
|
||||
// Set cursor for nav items
|
||||
ImGui::SetCursorScreenPos(ImVec2(drawerBB.Min.x, currentY + spacing::dp(1)));
|
||||
ImGui::BeginGroup();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
inline bool BeginNavDrawer(const char* id, const NavDrawerSpec& spec) {
|
||||
static bool alwaysOpen = true;
|
||||
NavDrawerSpec standardSpec = spec;
|
||||
standardSpec.type = NavDrawerType::Standard;
|
||||
return BeginNavDrawer(id, &alwaysOpen, standardSpec);
|
||||
}
|
||||
|
||||
inline void EndNavDrawer() {
|
||||
ImGui::EndGroup();
|
||||
|
||||
// Divider at bottom if configured
|
||||
ImGuiWindow* window = ImGui::GetCurrentWindow();
|
||||
ImDrawList* drawList = window->DrawList;
|
||||
|
||||
// Right edge divider
|
||||
drawList->AddLine(
|
||||
ImVec2(g_navDrawerState.contentMax.x - 1, g_navDrawerState.contentMin.y),
|
||||
ImVec2(g_navDrawerState.contentMax.x - 1, g_navDrawerState.contentMax.y),
|
||||
OnSurfaceDisabled()
|
||||
);
|
||||
|
||||
ImGui::PopID();
|
||||
}
|
||||
|
||||
inline bool NavItem(const NavItemSpec& spec) {
|
||||
ImGuiWindow* window = ImGui::GetCurrentWindow();
|
||||
if (window->SkipItems)
|
||||
return false;
|
||||
|
||||
ImGui::PushID(spec.label);
|
||||
|
||||
// Item dimensions
|
||||
const float itemHeight = 48.0f;
|
||||
const float iconSize = 24.0f;
|
||||
const float horizontalPadding = spacing::dp(2); // 16dp
|
||||
const float iconLabelGap = spacing::dp(4); // 32dp from left edge to label
|
||||
|
||||
float itemWidth = g_navDrawerState.width - spacing::dp(1); // 8dp margin right
|
||||
|
||||
ImVec2 pos = window->DC.CursorPos;
|
||||
pos.x += spacing::dp(1); // 8dp margin left
|
||||
|
||||
ImRect itemBB(pos, ImVec2(pos.x + itemWidth, pos.y + itemHeight));
|
||||
|
||||
// Interaction
|
||||
ImGuiID id = window->GetID("##navitem");
|
||||
ImGui::ItemSize(ImRect(window->DC.CursorPos, ImVec2(window->DC.CursorPos.x + g_navDrawerState.width, window->DC.CursorPos.y + itemHeight)));
|
||||
if (!ImGui::ItemAdd(itemBB, id))
|
||||
return false;
|
||||
|
||||
bool hovered, held;
|
||||
bool pressed = ImGui::ButtonBehavior(itemBB, id, &hovered, &held) && !spec.disabled;
|
||||
|
||||
// Draw background
|
||||
ImDrawList* drawList = window->DrawList;
|
||||
|
||||
ImU32 bgColor = 0;
|
||||
if (spec.selected) {
|
||||
bgColor = WithAlpha(Primary(), 30); // Primary at ~12%
|
||||
} else if (held && !spec.disabled) {
|
||||
bgColor = schema::UI().resolveColor("var(--sidebar-hover)", IM_COL32(255, 255, 255, 25));
|
||||
} else if (hovered && !spec.disabled) {
|
||||
bgColor = schema::UI().resolveColor("var(--active-overlay)", IM_COL32(255, 255, 255, 10));
|
||||
}
|
||||
|
||||
if (bgColor) {
|
||||
drawList->AddRectFilled(itemBB.Min, itemBB.Max, bgColor, size::ButtonCornerRadius);
|
||||
}
|
||||
|
||||
// Selected indicator (left edge)
|
||||
if (spec.selected) {
|
||||
drawList->AddRectFilled(
|
||||
ImVec2(itemBB.Min.x, itemBB.Min.y + 8.0f),
|
||||
ImVec2(itemBB.Min.x + 4.0f, itemBB.Max.y - 8.0f),
|
||||
Primary(), 2.0f
|
||||
);
|
||||
}
|
||||
|
||||
// Content
|
||||
float contentX = pos.x + horizontalPadding;
|
||||
float centerY = pos.y + itemHeight * 0.5f;
|
||||
|
||||
ImU32 iconColor = spec.disabled ? OnSurfaceDisabled() :
|
||||
spec.selected ? Primary() : OnSurfaceMedium();
|
||||
ImU32 labelColor = spec.disabled ? OnSurfaceDisabled() :
|
||||
spec.selected ? Primary() : OnSurface();
|
||||
|
||||
// Icon
|
||||
if (spec.icon) {
|
||||
drawList->AddText(
|
||||
ImVec2(contentX, centerY - iconSize * 0.5f),
|
||||
iconColor, spec.icon
|
||||
);
|
||||
contentX += iconSize + spacing::dp(2); // 16dp gap after icon
|
||||
}
|
||||
|
||||
// Label
|
||||
Typography::instance().pushFont(TypeStyle::Body1);
|
||||
float labelY = centerY - ImGui::GetFontSize() * 0.5f;
|
||||
drawList->AddText(ImVec2(contentX, labelY), labelColor, spec.label);
|
||||
Typography::instance().popFont();
|
||||
|
||||
// Badge
|
||||
if (spec.badgeCount > 0) {
|
||||
char badgeText[8];
|
||||
if (spec.badgeCount > 999) {
|
||||
snprintf(badgeText, sizeof(badgeText), "999+");
|
||||
} else {
|
||||
snprintf(badgeText, sizeof(badgeText), "%d", spec.badgeCount);
|
||||
}
|
||||
|
||||
ImVec2 badgeSize = ImGui::CalcTextSize(badgeText);
|
||||
float badgeWidth = ImMax(24.0f, badgeSize.x + 12.0f);
|
||||
float badgeHeight = 20.0f;
|
||||
float badgeX = itemBB.Max.x - horizontalPadding - badgeWidth;
|
||||
float badgeY = centerY - badgeHeight * 0.5f;
|
||||
|
||||
drawList->AddRectFilled(
|
||||
ImVec2(badgeX, badgeY),
|
||||
ImVec2(badgeX + badgeWidth, badgeY + badgeHeight),
|
||||
Primary(), badgeHeight * 0.5f
|
||||
);
|
||||
|
||||
Typography::instance().pushFont(TypeStyle::Caption);
|
||||
ImVec2 textPos(badgeX + (badgeWidth - badgeSize.x) * 0.5f, badgeY + (badgeHeight - badgeSize.y) * 0.5f);
|
||||
drawList->AddText(textPos, OnPrimary(), badgeText);
|
||||
Typography::instance().popFont();
|
||||
}
|
||||
|
||||
ImGui::PopID();
|
||||
|
||||
return pressed;
|
||||
}
|
||||
|
||||
inline bool NavItem(const char* icon, const char* label, bool selected) {
|
||||
NavItemSpec spec;
|
||||
spec.icon = icon;
|
||||
spec.label = label;
|
||||
spec.selected = selected;
|
||||
return NavItem(spec);
|
||||
}
|
||||
|
||||
inline void NavDivider() {
|
||||
ImGuiWindow* window = ImGui::GetCurrentWindow();
|
||||
if (window->SkipItems)
|
||||
return;
|
||||
|
||||
ImVec2 pos = window->DC.CursorPos;
|
||||
ImDrawList* drawList = window->DrawList;
|
||||
|
||||
ImGui::Dummy(ImVec2(0, spacing::dp(1))); // 8dp spacing above
|
||||
|
||||
drawList->AddLine(
|
||||
ImVec2(pos.x + spacing::dp(2), pos.y + spacing::dp(1)),
|
||||
ImVec2(pos.x + g_navDrawerState.width - spacing::dp(2), pos.y + spacing::dp(1)),
|
||||
OnSurfaceDisabled()
|
||||
);
|
||||
|
||||
ImGui::Dummy(ImVec2(0, spacing::dp(1))); // 8dp spacing below
|
||||
}
|
||||
|
||||
inline void NavSubheader(const char* text) {
|
||||
ImGui::Dummy(ImVec2(0, spacing::dp(1))); // 8dp above
|
||||
|
||||
ImVec2 pos = ImGui::GetCursorScreenPos();
|
||||
ImGui::SetCursorScreenPos(ImVec2(pos.x + spacing::dp(2), pos.y));
|
||||
|
||||
Typography::instance().textColored(TypeStyle::Caption, OnSurfaceMedium(), text);
|
||||
|
||||
ImGui::Dummy(ImVec2(0, spacing::dp(1))); // 8dp below
|
||||
}
|
||||
|
||||
} // namespace material
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
303
src/ui/material/components/progress.h
Normal file
303
src/ui/material/components/progress.h
Normal file
@@ -0,0 +1,303 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "../colors.h"
|
||||
#include "../typography.h"
|
||||
#include "../layout.h"
|
||||
#include "imgui.h"
|
||||
#include "imgui_internal.h"
|
||||
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
namespace material {
|
||||
|
||||
// ============================================================================
|
||||
// Material Design Progress Indicators
|
||||
// ============================================================================
|
||||
// Based on https://m2.material.io/components/progress-indicators
|
||||
//
|
||||
// Progress indicators express an unspecified wait time or display the length
|
||||
// of a process.
|
||||
|
||||
// ============================================================================
|
||||
// Linear Progress
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* @brief Determinate linear progress bar
|
||||
*
|
||||
* @param fraction Progress value 0.0 to 1.0
|
||||
* @param width Width of bar (0 = full available width)
|
||||
*/
|
||||
void LinearProgress(float fraction, float width = 0);
|
||||
|
||||
/**
|
||||
* @brief Indeterminate linear progress bar (animated)
|
||||
*
|
||||
* @param width Width of bar (0 = full available width)
|
||||
*/
|
||||
void LinearProgressIndeterminate(float width = 0);
|
||||
|
||||
/**
|
||||
* @brief Buffer linear progress bar
|
||||
*
|
||||
* @param fraction Primary progress 0.0 to 1.0
|
||||
* @param buffer Buffer progress 0.0 to 1.0
|
||||
* @param width Width of bar (0 = full available width)
|
||||
*/
|
||||
void LinearProgressBuffer(float fraction, float buffer, float width = 0);
|
||||
|
||||
// ============================================================================
|
||||
// Circular Progress
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* @brief Determinate circular progress indicator
|
||||
*
|
||||
* @param fraction Progress value 0.0 to 1.0
|
||||
* @param radius Radius of circle (default 20dp)
|
||||
*/
|
||||
void CircularProgress(float fraction, float radius = 20.0f);
|
||||
|
||||
/**
|
||||
* @brief Indeterminate circular progress (spinner)
|
||||
*
|
||||
* @param radius Radius of circle (default 20dp)
|
||||
*/
|
||||
void CircularProgressIndeterminate(float radius = 20.0f);
|
||||
|
||||
// ============================================================================
|
||||
// Implementation
|
||||
// ============================================================================
|
||||
|
||||
inline void LinearProgress(float fraction, float width) {
|
||||
ImGuiWindow* window = ImGui::GetCurrentWindow();
|
||||
if (window->SkipItems)
|
||||
return;
|
||||
|
||||
const float barHeight = 4.0f; // Material spec: 4dp height
|
||||
float barWidth = width > 0 ? width : ImGui::GetContentRegionAvail().x;
|
||||
|
||||
ImVec2 pos = window->DC.CursorPos;
|
||||
ImRect bb(pos, ImVec2(pos.x + barWidth, pos.y + barHeight));
|
||||
|
||||
ImGui::ItemSize(bb);
|
||||
if (!ImGui::ItemAdd(bb, 0))
|
||||
return;
|
||||
|
||||
ImDrawList* drawList = window->DrawList;
|
||||
|
||||
// Track (background)
|
||||
ImU32 trackColor = WithAlpha(Primary(), 64); // Primary at 25%
|
||||
drawList->AddRectFilled(bb.Min, bb.Max, trackColor, 0);
|
||||
|
||||
// Progress indicator
|
||||
float progressWidth = barWidth * ImClamp(fraction, 0.0f, 1.0f);
|
||||
if (progressWidth > 0) {
|
||||
drawList->AddRectFilled(
|
||||
bb.Min,
|
||||
ImVec2(bb.Min.x + progressWidth, bb.Max.y),
|
||||
Primary(), 0
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
inline void LinearProgressIndeterminate(float width) {
|
||||
ImGuiWindow* window = ImGui::GetCurrentWindow();
|
||||
if (window->SkipItems)
|
||||
return;
|
||||
|
||||
const float barHeight = 4.0f;
|
||||
float barWidth = width > 0 ? width : ImGui::GetContentRegionAvail().x;
|
||||
|
||||
ImVec2 pos = window->DC.CursorPos;
|
||||
ImRect bb(pos, ImVec2(pos.x + barWidth, pos.y + barHeight));
|
||||
|
||||
ImGui::ItemSize(bb);
|
||||
if (!ImGui::ItemAdd(bb, 0))
|
||||
return;
|
||||
|
||||
ImDrawList* drawList = window->DrawList;
|
||||
|
||||
// Track
|
||||
ImU32 trackColor = WithAlpha(Primary(), 64);
|
||||
drawList->AddRectFilled(bb.Min, bb.Max, trackColor, 0);
|
||||
|
||||
// Animated indicator - sliding back and forth
|
||||
float time = (float)ImGui::GetTime();
|
||||
float cycleTime = fmodf(time, 2.0f); // 2 second cycle
|
||||
|
||||
// Two bars: primary and secondary with different phases
|
||||
float indicatorWidth = barWidth * 0.3f; // 30% of track
|
||||
|
||||
// Primary indicator
|
||||
float primaryPhase = fmodf(time * 1.2f, 2.0f);
|
||||
float primaryPos;
|
||||
if (primaryPhase < 1.0f) {
|
||||
// Accelerating from left
|
||||
primaryPos = primaryPhase * primaryPhase * (barWidth + indicatorWidth) - indicatorWidth;
|
||||
} else {
|
||||
// Continue off right (reset happens at 2.0)
|
||||
primaryPos = (2.0f - primaryPhase) * (2.0f - primaryPhase) * -(barWidth + indicatorWidth) + barWidth;
|
||||
}
|
||||
|
||||
float primaryStart = ImMax(bb.Min.x, bb.Min.x + primaryPos);
|
||||
float primaryEnd = ImMin(bb.Max.x, bb.Min.x + primaryPos + indicatorWidth);
|
||||
|
||||
if (primaryEnd > primaryStart) {
|
||||
drawList->AddRectFilled(
|
||||
ImVec2(primaryStart, bb.Min.y),
|
||||
ImVec2(primaryEnd, bb.Max.y),
|
||||
Primary(), 0
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
inline void LinearProgressBuffer(float fraction, float buffer, float width) {
|
||||
ImGuiWindow* window = ImGui::GetCurrentWindow();
|
||||
if (window->SkipItems)
|
||||
return;
|
||||
|
||||
const float barHeight = 4.0f;
|
||||
float barWidth = width > 0 ? width : ImGui::GetContentRegionAvail().x;
|
||||
|
||||
ImVec2 pos = window->DC.CursorPos;
|
||||
ImRect bb(pos, ImVec2(pos.x + barWidth, pos.y + barHeight));
|
||||
|
||||
ImGui::ItemSize(bb);
|
||||
if (!ImGui::ItemAdd(bb, 0))
|
||||
return;
|
||||
|
||||
ImDrawList* drawList = window->DrawList;
|
||||
|
||||
// Track
|
||||
ImU32 trackColor = WithAlpha(Primary(), 38); // Primary at 15%
|
||||
drawList->AddRectFilled(bb.Min, bb.Max, trackColor, 0);
|
||||
|
||||
// Buffer (lighter than progress)
|
||||
float bufferWidth = barWidth * ImClamp(buffer, 0.0f, 1.0f);
|
||||
if (bufferWidth > 0) {
|
||||
drawList->AddRectFilled(
|
||||
bb.Min,
|
||||
ImVec2(bb.Min.x + bufferWidth, bb.Max.y),
|
||||
WithAlpha(Primary(), 102), 0 // Primary at 40%
|
||||
);
|
||||
}
|
||||
|
||||
// Progress
|
||||
float progressWidth = barWidth * ImClamp(fraction, 0.0f, 1.0f);
|
||||
if (progressWidth > 0) {
|
||||
drawList->AddRectFilled(
|
||||
bb.Min,
|
||||
ImVec2(bb.Min.x + progressWidth, bb.Max.y),
|
||||
Primary(), 0
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
inline void CircularProgress(float fraction, float radius) {
|
||||
ImGuiWindow* window = ImGui::GetCurrentWindow();
|
||||
if (window->SkipItems)
|
||||
return;
|
||||
|
||||
const float thickness = 4.0f; // Stroke width
|
||||
float diameter = radius * 2;
|
||||
|
||||
ImVec2 pos = window->DC.CursorPos;
|
||||
ImVec2 center(pos.x + radius, pos.y + radius);
|
||||
ImRect bb(pos, ImVec2(pos.x + diameter, pos.y + diameter));
|
||||
|
||||
ImGui::ItemSize(bb);
|
||||
if (!ImGui::ItemAdd(bb, 0))
|
||||
return;
|
||||
|
||||
ImDrawList* drawList = window->DrawList;
|
||||
|
||||
// Track circle
|
||||
ImU32 trackColor = WithAlpha(Primary(), 64);
|
||||
drawList->AddCircle(center, radius - thickness * 0.5f, trackColor, 0, thickness);
|
||||
|
||||
// Progress arc
|
||||
float clampedFraction = ImClamp(fraction, 0.0f, 1.0f);
|
||||
if (clampedFraction > 0) {
|
||||
float startAngle = -IM_PI * 0.5f; // Start at top (12 o'clock)
|
||||
float endAngle = startAngle + IM_PI * 2.0f * clampedFraction;
|
||||
|
||||
// Draw arc as line segments
|
||||
const int segments = (int)(32 * clampedFraction) + 1;
|
||||
float angleStep = (endAngle - startAngle) / segments;
|
||||
|
||||
for (int i = 0; i < segments; i++) {
|
||||
float a1 = startAngle + angleStep * i;
|
||||
float a2 = startAngle + angleStep * (i + 1);
|
||||
|
||||
ImVec2 p1(center.x + cosf(a1) * (radius - thickness * 0.5f),
|
||||
center.y + sinf(a1) * (radius - thickness * 0.5f));
|
||||
ImVec2 p2(center.x + cosf(a2) * (radius - thickness * 0.5f),
|
||||
center.y + sinf(a2) * (radius - thickness * 0.5f));
|
||||
|
||||
drawList->AddLine(p1, p2, Primary(), thickness);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
inline void CircularProgressIndeterminate(float radius) {
|
||||
ImGuiWindow* window = ImGui::GetCurrentWindow();
|
||||
if (window->SkipItems)
|
||||
return;
|
||||
|
||||
const float thickness = 4.0f;
|
||||
float diameter = radius * 2;
|
||||
|
||||
ImVec2 pos = window->DC.CursorPos;
|
||||
ImVec2 center(pos.x + radius, pos.y + radius);
|
||||
ImRect bb(pos, ImVec2(pos.x + diameter, pos.y + diameter));
|
||||
|
||||
ImGui::ItemSize(bb);
|
||||
if (!ImGui::ItemAdd(bb, 0))
|
||||
return;
|
||||
|
||||
ImDrawList* drawList = window->DrawList;
|
||||
|
||||
float time = (float)ImGui::GetTime();
|
||||
|
||||
// Rotation animation
|
||||
float rotation = fmodf(time * 2.0f * IM_PI / 1.4f, IM_PI * 2.0f); // ~1.4s rotation
|
||||
|
||||
// Arc length animation (grows and shrinks)
|
||||
float cycleTime = fmodf(time, 1.333f); // ~1.333s cycle
|
||||
float arcLength;
|
||||
if (cycleTime < 0.666f) {
|
||||
// Growing phase
|
||||
arcLength = (cycleTime / 0.666f) * 0.75f + 0.1f; // 10% to 85%
|
||||
} else {
|
||||
// Shrinking phase
|
||||
arcLength = ((1.333f - cycleTime) / 0.666f) * 0.75f + 0.1f;
|
||||
}
|
||||
|
||||
float startAngle = rotation - IM_PI * 0.5f;
|
||||
float endAngle = startAngle + IM_PI * 2.0f * arcLength;
|
||||
|
||||
// Draw arc
|
||||
const int segments = (int)(32 * arcLength) + 1;
|
||||
float angleStep = (endAngle - startAngle) / segments;
|
||||
|
||||
for (int i = 0; i < segments; i++) {
|
||||
float a1 = startAngle + angleStep * i;
|
||||
float a2 = startAngle + angleStep * (i + 1);
|
||||
|
||||
ImVec2 p1(center.x + cosf(a1) * (radius - thickness * 0.5f),
|
||||
center.y + sinf(a1) * (radius - thickness * 0.5f));
|
||||
ImVec2 p2(center.x + cosf(a2) * (radius - thickness * 0.5f),
|
||||
center.y + sinf(a2) * (radius - thickness * 0.5f));
|
||||
|
||||
drawList->AddLine(p1, p2, Primary(), thickness);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace material
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
402
src/ui/material/components/slider.h
Normal file
402
src/ui/material/components/slider.h
Normal file
@@ -0,0 +1,402 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "../colors.h"
|
||||
#include "../typography.h"
|
||||
#include "../layout.h"
|
||||
#include "../../schema/ui_schema.h"
|
||||
#include "imgui.h"
|
||||
#include "imgui_internal.h"
|
||||
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
namespace material {
|
||||
|
||||
// ============================================================================
|
||||
// Material Design Slider Component
|
||||
// ============================================================================
|
||||
// Based on https://m2.material.io/components/sliders
|
||||
//
|
||||
// Sliders allow users to make selections from a range of values.
|
||||
|
||||
/**
|
||||
* @brief Continuous slider
|
||||
*
|
||||
* @param label Label for the slider (hidden, used for ID)
|
||||
* @param value Pointer to current value
|
||||
* @param minValue Minimum value
|
||||
* @param maxValue Maximum value
|
||||
* @param format Printf format for value display (nullptr = no display)
|
||||
* @param width Slider width (0 = full available)
|
||||
* @return true if value changed
|
||||
*/
|
||||
bool Slider(const char* label, float* value, float minValue, float maxValue,
|
||||
const char* format = nullptr, float width = 0);
|
||||
|
||||
/**
|
||||
* @brief Integer slider
|
||||
*/
|
||||
bool SliderInt(const char* label, int* value, int minValue, int maxValue,
|
||||
const char* format = nullptr, float width = 0);
|
||||
|
||||
/**
|
||||
* @brief Discrete slider with steps
|
||||
*
|
||||
* @param label Label for the slider
|
||||
* @param value Pointer to current value
|
||||
* @param minValue Minimum value
|
||||
* @param maxValue Maximum value
|
||||
* @param step Step size
|
||||
* @param showTicks Show tick marks
|
||||
* @return true if value changed
|
||||
*/
|
||||
bool SliderDiscrete(const char* label, float* value, float minValue, float maxValue,
|
||||
float step, bool showTicks = true, float width = 0);
|
||||
|
||||
/**
|
||||
* @brief Range slider (two thumbs)
|
||||
*
|
||||
* @param label Label for the slider
|
||||
* @param minVal Pointer to range minimum
|
||||
* @param maxVal Pointer to range maximum
|
||||
* @param rangeMin Allowed minimum
|
||||
* @param rangeMax Allowed maximum
|
||||
* @return true if either value changed
|
||||
*/
|
||||
bool SliderRange(const char* label, float* minVal, float* maxVal,
|
||||
float rangeMin, float rangeMax, float width = 0);
|
||||
|
||||
// ============================================================================
|
||||
// Implementation
|
||||
// ============================================================================
|
||||
|
||||
inline bool Slider(const char* label, float* value, float minValue, float maxValue,
|
||||
const char* format, float width) {
|
||||
ImGuiWindow* window = ImGui::GetCurrentWindow();
|
||||
if (window->SkipItems)
|
||||
return false;
|
||||
|
||||
ImGui::PushID(label);
|
||||
|
||||
// Slider dimensions
|
||||
const float trackHeight = 4.0f;
|
||||
const float thumbRadius = 10.0f; // 20dp diameter
|
||||
float sliderWidth = width > 0 ? width : ImGui::GetContentRegionAvail().x;
|
||||
float totalHeight = size::TouchTarget; // 48dp touch target
|
||||
|
||||
ImVec2 pos = window->DC.CursorPos;
|
||||
ImRect bb(pos, ImVec2(pos.x + sliderWidth, pos.y + totalHeight));
|
||||
|
||||
// Item interaction
|
||||
ImGuiID id = window->GetID("##slider");
|
||||
ImGui::ItemSize(bb);
|
||||
if (!ImGui::ItemAdd(bb, id))
|
||||
return false;
|
||||
|
||||
bool hovered, held;
|
||||
bool pressed = ImGui::ButtonBehavior(bb, id, &hovered, &held);
|
||||
|
||||
// Calculate thumb position
|
||||
float trackLeft = pos.x + thumbRadius;
|
||||
float trackRight = pos.x + sliderWidth - thumbRadius;
|
||||
float trackWidth = trackRight - trackLeft;
|
||||
float centerY = pos.y + totalHeight * 0.5f;
|
||||
|
||||
float fraction = (*value - minValue) / (maxValue - minValue);
|
||||
fraction = ImClamp(fraction, 0.0f, 1.0f);
|
||||
float thumbX = trackLeft + trackWidth * fraction;
|
||||
|
||||
// Handle dragging
|
||||
bool changed = false;
|
||||
if (held) {
|
||||
float mouseX = ImGui::GetIO().MousePos.x;
|
||||
float newFraction = (mouseX - trackLeft) / trackWidth;
|
||||
newFraction = ImClamp(newFraction, 0.0f, 1.0f);
|
||||
float newValue = minValue + newFraction * (maxValue - minValue);
|
||||
|
||||
if (newValue != *value) {
|
||||
*value = newValue;
|
||||
changed = true;
|
||||
}
|
||||
thumbX = trackLeft + trackWidth * newFraction;
|
||||
}
|
||||
|
||||
// Draw
|
||||
ImDrawList* drawList = window->DrawList;
|
||||
|
||||
// Track (inactive part)
|
||||
ImU32 trackInactiveColor = WithAlpha(Primary(), 64); // Primary at 25%
|
||||
drawList->AddRectFilled(
|
||||
ImVec2(trackLeft, centerY - trackHeight * 0.5f),
|
||||
ImVec2(trackRight, centerY + trackHeight * 0.5f),
|
||||
trackInactiveColor, trackHeight * 0.5f
|
||||
);
|
||||
|
||||
// Track (active part)
|
||||
drawList->AddRectFilled(
|
||||
ImVec2(trackLeft, centerY - trackHeight * 0.5f),
|
||||
ImVec2(thumbX, centerY + trackHeight * 0.5f),
|
||||
Primary(), trackHeight * 0.5f
|
||||
);
|
||||
|
||||
// Thumb shadow
|
||||
drawList->AddCircleFilled(ImVec2(thumbX + 1, centerY + 2), thumbRadius, schema::UI().resolveColor("var(--control-shadow)", IM_COL32(0, 0, 0, 60)));
|
||||
|
||||
// Thumb
|
||||
drawList->AddCircleFilled(ImVec2(thumbX, centerY), thumbRadius, Primary());
|
||||
|
||||
// Hover/pressed ripple
|
||||
if (hovered || held) {
|
||||
ImU32 rippleColor = WithAlpha(Primary(), held ? 51 : 25);
|
||||
drawList->AddCircleFilled(ImVec2(thumbX, centerY), thumbRadius + 12.0f, rippleColor);
|
||||
}
|
||||
|
||||
// Value label (when held)
|
||||
if (held && format) {
|
||||
char valueText[64];
|
||||
snprintf(valueText, sizeof(valueText), format, *value);
|
||||
|
||||
ImVec2 textSize = ImGui::CalcTextSize(valueText);
|
||||
float labelY = centerY - thumbRadius - 32.0f;
|
||||
float labelX = thumbX - textSize.x * 0.5f;
|
||||
|
||||
// Label background (rounded rectangle)
|
||||
float labelPadX = 8.0f;
|
||||
float labelPadY = 4.0f;
|
||||
ImVec2 labelMin(labelX - labelPadX, labelY - labelPadY);
|
||||
ImVec2 labelMax(labelX + textSize.x + labelPadX, labelY + textSize.y + labelPadY);
|
||||
|
||||
drawList->AddRectFilled(labelMin, labelMax, Primary(), 4.0f);
|
||||
drawList->AddText(ImVec2(labelX, labelY), OnPrimary(), valueText);
|
||||
}
|
||||
|
||||
ImGui::PopID();
|
||||
|
||||
return changed;
|
||||
}
|
||||
|
||||
inline bool SliderInt(const char* label, int* value, int minValue, int maxValue,
|
||||
const char* format, float width) {
|
||||
float floatVal = (float)*value;
|
||||
bool changed = Slider(label, &floatVal, (float)minValue, (float)maxValue, format, width);
|
||||
if (changed) {
|
||||
*value = (int)roundf(floatVal);
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
inline bool SliderDiscrete(const char* label, float* value, float minValue, float maxValue,
|
||||
float step, bool showTicks, float width) {
|
||||
ImGuiWindow* window = ImGui::GetCurrentWindow();
|
||||
if (window->SkipItems)
|
||||
return false;
|
||||
|
||||
ImGui::PushID(label);
|
||||
|
||||
const float trackHeight = 4.0f;
|
||||
const float thumbRadius = 10.0f;
|
||||
const float tickRadius = 2.0f;
|
||||
float sliderWidth = width > 0 ? width : ImGui::GetContentRegionAvail().x;
|
||||
float totalHeight = size::TouchTarget;
|
||||
|
||||
ImVec2 pos = window->DC.CursorPos;
|
||||
ImRect bb(pos, ImVec2(pos.x + sliderWidth, pos.y + totalHeight));
|
||||
|
||||
ImGuiID id = window->GetID("##slider");
|
||||
ImGui::ItemSize(bb);
|
||||
if (!ImGui::ItemAdd(bb, id))
|
||||
return false;
|
||||
|
||||
bool hovered, held;
|
||||
ImGui::ButtonBehavior(bb, id, &hovered, &held);
|
||||
|
||||
float trackLeft = pos.x + thumbRadius;
|
||||
float trackRight = pos.x + sliderWidth - thumbRadius;
|
||||
float trackWidth = trackRight - trackLeft;
|
||||
float centerY = pos.y + totalHeight * 0.5f;
|
||||
|
||||
// Snap to step
|
||||
float snappedValue = roundf((*value - minValue) / step) * step + minValue;
|
||||
snappedValue = ImClamp(snappedValue, minValue, maxValue);
|
||||
|
||||
float fraction = (snappedValue - minValue) / (maxValue - minValue);
|
||||
float thumbX = trackLeft + trackWidth * fraction;
|
||||
|
||||
bool changed = false;
|
||||
if (held) {
|
||||
float mouseX = ImGui::GetIO().MousePos.x;
|
||||
float newFraction = (mouseX - trackLeft) / trackWidth;
|
||||
newFraction = ImClamp(newFraction, 0.0f, 1.0f);
|
||||
float rawValue = minValue + newFraction * (maxValue - minValue);
|
||||
float newValue = roundf((rawValue - minValue) / step) * step + minValue;
|
||||
newValue = ImClamp(newValue, minValue, maxValue);
|
||||
|
||||
if (newValue != *value) {
|
||||
*value = newValue;
|
||||
changed = true;
|
||||
}
|
||||
fraction = (newValue - minValue) / (maxValue - minValue);
|
||||
thumbX = trackLeft + trackWidth * fraction;
|
||||
}
|
||||
|
||||
ImDrawList* drawList = window->DrawList;
|
||||
|
||||
// Track
|
||||
drawList->AddRectFilled(
|
||||
ImVec2(trackLeft, centerY - trackHeight * 0.5f),
|
||||
ImVec2(trackRight, centerY + trackHeight * 0.5f),
|
||||
WithAlpha(Primary(), 64), trackHeight * 0.5f
|
||||
);
|
||||
|
||||
drawList->AddRectFilled(
|
||||
ImVec2(trackLeft, centerY - trackHeight * 0.5f),
|
||||
ImVec2(thumbX, centerY + trackHeight * 0.5f),
|
||||
Primary(), trackHeight * 0.5f
|
||||
);
|
||||
|
||||
// Tick marks
|
||||
if (showTicks) {
|
||||
int numSteps = (int)((maxValue - minValue) / step);
|
||||
for (int i = 0; i <= numSteps; i++) {
|
||||
float tickFraction = (float)i / numSteps;
|
||||
float tickX = trackLeft + trackWidth * tickFraction;
|
||||
|
||||
ImU32 tickColor = (tickX <= thumbX) ? OnPrimary() : WithAlpha(Primary(), 128);
|
||||
drawList->AddCircleFilled(ImVec2(tickX, centerY), tickRadius, tickColor);
|
||||
}
|
||||
}
|
||||
|
||||
// Thumb
|
||||
drawList->AddCircleFilled(ImVec2(thumbX + 1, centerY + 2), thumbRadius, schema::UI().resolveColor("var(--control-shadow)", IM_COL32(0, 0, 0, 60)));
|
||||
drawList->AddCircleFilled(ImVec2(thumbX, centerY), thumbRadius, Primary());
|
||||
|
||||
if (hovered || held) {
|
||||
ImU32 rippleColor = WithAlpha(Primary(), held ? 51 : 25);
|
||||
drawList->AddCircleFilled(ImVec2(thumbX, centerY), thumbRadius + 12.0f, rippleColor);
|
||||
}
|
||||
|
||||
ImGui::PopID();
|
||||
|
||||
return changed;
|
||||
}
|
||||
|
||||
inline bool SliderRange(const char* label, float* minVal, float* maxVal,
|
||||
float rangeMin, float rangeMax, float width) {
|
||||
ImGuiWindow* window = ImGui::GetCurrentWindow();
|
||||
if (window->SkipItems)
|
||||
return false;
|
||||
|
||||
ImGui::PushID(label);
|
||||
|
||||
const float trackHeight = 4.0f;
|
||||
const float thumbRadius = 10.0f;
|
||||
float sliderWidth = width > 0 ? width : ImGui::GetContentRegionAvail().x;
|
||||
float totalHeight = size::TouchTarget;
|
||||
|
||||
ImVec2 pos = window->DC.CursorPos;
|
||||
ImRect bb(pos, ImVec2(pos.x + sliderWidth, pos.y + totalHeight));
|
||||
|
||||
ImGuiID id = window->GetID("##slider");
|
||||
ImGui::ItemSize(bb);
|
||||
if (!ImGui::ItemAdd(bb, id))
|
||||
return false;
|
||||
|
||||
float trackLeft = pos.x + thumbRadius;
|
||||
float trackRight = pos.x + sliderWidth - thumbRadius;
|
||||
float trackWidth = trackRight - trackLeft;
|
||||
float centerY = pos.y + totalHeight * 0.5f;
|
||||
|
||||
float minFraction = (*minVal - rangeMin) / (rangeMax - rangeMin);
|
||||
float maxFraction = (*maxVal - rangeMin) / (rangeMax - rangeMin);
|
||||
float minThumbX = trackLeft + trackWidth * minFraction;
|
||||
float maxThumbX = trackLeft + trackWidth * maxFraction;
|
||||
|
||||
// Hit test both thumbs
|
||||
ImVec2 mousePos = ImGui::GetIO().MousePos;
|
||||
float distToMin = fabsf(mousePos.x - minThumbX);
|
||||
float distToMax = fabsf(mousePos.x - maxThumbX);
|
||||
bool nearMin = distToMin < distToMax;
|
||||
|
||||
ImGuiID minId = window->GetID("##min");
|
||||
ImGuiID maxId = window->GetID("##max");
|
||||
|
||||
bool minHovered, minHeld;
|
||||
bool maxHovered, maxHeld;
|
||||
ImRect minHitBox(ImVec2(minThumbX - thumbRadius - 8, centerY - thumbRadius - 8),
|
||||
ImVec2(minThumbX + thumbRadius + 8, centerY + thumbRadius + 8));
|
||||
ImRect maxHitBox(ImVec2(maxThumbX - thumbRadius - 8, centerY - thumbRadius - 8),
|
||||
ImVec2(maxThumbX + thumbRadius + 8, centerY + thumbRadius + 8));
|
||||
|
||||
ImGui::ButtonBehavior(nearMin ? minHitBox : maxHitBox, nearMin ? minId : maxId,
|
||||
nearMin ? &minHovered : &maxHovered,
|
||||
nearMin ? &minHeld : &maxHeld);
|
||||
|
||||
bool changed = false;
|
||||
|
||||
if (minHeld) {
|
||||
float newFraction = (mousePos.x - trackLeft) / trackWidth;
|
||||
newFraction = ImClamp(newFraction, 0.0f, maxFraction - 0.01f);
|
||||
float newValue = rangeMin + newFraction * (rangeMax - rangeMin);
|
||||
if (newValue != *minVal) {
|
||||
*minVal = newValue;
|
||||
changed = true;
|
||||
}
|
||||
minThumbX = trackLeft + trackWidth * newFraction;
|
||||
}
|
||||
|
||||
if (maxHeld) {
|
||||
float newFraction = (mousePos.x - trackLeft) / trackWidth;
|
||||
newFraction = ImClamp(newFraction, minFraction + 0.01f, 1.0f);
|
||||
float newValue = rangeMin + newFraction * (rangeMax - rangeMin);
|
||||
if (newValue != *maxVal) {
|
||||
*maxVal = newValue;
|
||||
changed = true;
|
||||
}
|
||||
maxThumbX = trackLeft + trackWidth * newFraction;
|
||||
}
|
||||
|
||||
ImDrawList* drawList = window->DrawList;
|
||||
|
||||
// Inactive track
|
||||
drawList->AddRectFilled(
|
||||
ImVec2(trackLeft, centerY - trackHeight * 0.5f),
|
||||
ImVec2(trackRight, centerY + trackHeight * 0.5f),
|
||||
WithAlpha(Primary(), 64), trackHeight * 0.5f
|
||||
);
|
||||
|
||||
// Active track (between thumbs)
|
||||
drawList->AddRectFilled(
|
||||
ImVec2(minThumbX, centerY - trackHeight * 0.5f),
|
||||
ImVec2(maxThumbX, centerY + trackHeight * 0.5f),
|
||||
Primary(), trackHeight * 0.5f
|
||||
);
|
||||
|
||||
// Min thumb
|
||||
drawList->AddCircleFilled(ImVec2(minThumbX + 1, centerY + 2), thumbRadius, schema::UI().resolveColor("var(--control-shadow)", IM_COL32(0, 0, 0, 60)));
|
||||
drawList->AddCircleFilled(ImVec2(minThumbX, centerY), thumbRadius, Primary());
|
||||
|
||||
if (minHovered || minHeld) {
|
||||
ImU32 rippleColor = WithAlpha(Primary(), minHeld ? 51 : 25);
|
||||
drawList->AddCircleFilled(ImVec2(minThumbX, centerY), thumbRadius + 12.0f, rippleColor);
|
||||
}
|
||||
|
||||
// Max thumb
|
||||
drawList->AddCircleFilled(ImVec2(maxThumbX + 1, centerY + 2), thumbRadius, schema::UI().resolveColor("var(--control-shadow)", IM_COL32(0, 0, 0, 60)));
|
||||
drawList->AddCircleFilled(ImVec2(maxThumbX, centerY), thumbRadius, Primary());
|
||||
|
||||
if (maxHovered || maxHeld) {
|
||||
ImU32 rippleColor = WithAlpha(Primary(), maxHeld ? 51 : 25);
|
||||
drawList->AddCircleFilled(ImVec2(maxThumbX, centerY), thumbRadius + 12.0f, rippleColor);
|
||||
}
|
||||
|
||||
ImGui::PopID();
|
||||
|
||||
return changed;
|
||||
}
|
||||
|
||||
} // namespace material
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
242
src/ui/material/components/snackbar.h
Normal file
242
src/ui/material/components/snackbar.h
Normal file
@@ -0,0 +1,242 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "../colors.h"
|
||||
#include "../typography.h"
|
||||
#include "../layout.h"
|
||||
#include "../../schema/ui_schema.h"
|
||||
#include "../draw_helpers.h"
|
||||
#include "imgui.h"
|
||||
#include "imgui_internal.h"
|
||||
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
namespace material {
|
||||
|
||||
// ============================================================================
|
||||
// Material Design Snackbar Component
|
||||
// ============================================================================
|
||||
// Based on https://m2.material.io/components/snackbars
|
||||
//
|
||||
// Snackbars provide brief messages about app processes at the bottom of the
|
||||
// screen. They can include a single action.
|
||||
|
||||
/**
|
||||
* @brief Snackbar configuration
|
||||
*/
|
||||
struct SnackbarSpec {
|
||||
const char* message = nullptr; // Message text
|
||||
const char* actionText = nullptr; // Optional action button text
|
||||
float duration = 4.0f; // Duration in seconds (0 = indefinite)
|
||||
bool multiLine = false; // Allow multi-line message
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Snackbar manager for showing notifications
|
||||
*/
|
||||
class Snackbar {
|
||||
public:
|
||||
static Snackbar& instance();
|
||||
|
||||
/**
|
||||
* @brief Show a snackbar message
|
||||
*
|
||||
* @param message Message text
|
||||
* @param actionText Optional action text
|
||||
* @param duration Display duration (0 = until dismissed)
|
||||
*/
|
||||
void show(const char* message, const char* actionText = nullptr, float duration = 4.0f);
|
||||
|
||||
/**
|
||||
* @brief Show a snackbar with full configuration
|
||||
*/
|
||||
void show(const SnackbarSpec& spec);
|
||||
|
||||
/**
|
||||
* @brief Dismiss current snackbar
|
||||
*/
|
||||
void dismiss();
|
||||
|
||||
/**
|
||||
* @brief Render snackbar (call each frame)
|
||||
*
|
||||
* @return true if action was clicked
|
||||
*/
|
||||
bool render();
|
||||
|
||||
/**
|
||||
* @brief Check if snackbar is visible
|
||||
*/
|
||||
bool isVisible() const { return m_visible; }
|
||||
|
||||
private:
|
||||
Snackbar() = default;
|
||||
|
||||
bool m_visible = false;
|
||||
SnackbarSpec m_currentSpec;
|
||||
float m_showTime = 0;
|
||||
float m_animProgress = 0; // 0 = hidden, 1 = fully shown
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Convenience Functions
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* @brief Show a snackbar message
|
||||
*/
|
||||
inline void ShowSnackbar(const char* message, const char* action = nullptr, float duration = 4.0f) {
|
||||
Snackbar::instance().show(message, action, duration);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Dismiss current snackbar
|
||||
*/
|
||||
inline void DismissSnackbar() {
|
||||
Snackbar::instance().dismiss();
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Render snackbar system (call once per frame in main render loop)
|
||||
*
|
||||
* @return true if action was clicked
|
||||
*/
|
||||
inline bool RenderSnackbar() {
|
||||
return Snackbar::instance().render();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Implementation
|
||||
// ============================================================================
|
||||
|
||||
inline Snackbar& Snackbar::instance() {
|
||||
static Snackbar s_instance;
|
||||
return s_instance;
|
||||
}
|
||||
|
||||
inline void Snackbar::show(const char* message, const char* actionText, float duration) {
|
||||
SnackbarSpec spec;
|
||||
spec.message = message;
|
||||
spec.actionText = actionText;
|
||||
spec.duration = duration;
|
||||
show(spec);
|
||||
}
|
||||
|
||||
inline void Snackbar::show(const SnackbarSpec& spec) {
|
||||
m_currentSpec = spec;
|
||||
m_visible = true;
|
||||
m_showTime = (float)ImGui::GetTime();
|
||||
m_animProgress = 0;
|
||||
}
|
||||
|
||||
inline void Snackbar::dismiss() {
|
||||
m_visible = false;
|
||||
}
|
||||
|
||||
inline bool Snackbar::render() {
|
||||
if (!m_visible && m_animProgress <= 0)
|
||||
return false;
|
||||
|
||||
bool actionClicked = false;
|
||||
float currentTime = (float)ImGui::GetTime();
|
||||
|
||||
// Check auto-dismiss
|
||||
if (m_visible && m_currentSpec.duration > 0) {
|
||||
if (currentTime - m_showTime > m_currentSpec.duration) {
|
||||
m_visible = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Animate in/out
|
||||
float animTarget = m_visible ? 1.0f : 0.0f;
|
||||
float animSpeed = 8.0f; // Animation speed
|
||||
if (m_animProgress < animTarget) {
|
||||
m_animProgress = ImMin(m_animProgress + ImGui::GetIO().DeltaTime * animSpeed, animTarget);
|
||||
} else if (m_animProgress > animTarget) {
|
||||
m_animProgress = ImMax(m_animProgress - ImGui::GetIO().DeltaTime * animSpeed, animTarget);
|
||||
}
|
||||
|
||||
if (m_animProgress <= 0)
|
||||
return false;
|
||||
|
||||
// Snackbar dimensions
|
||||
const float snackbarHeight = m_currentSpec.multiLine ? 68.0f : 48.0f;
|
||||
const float snackbarMinWidth = 344.0f;
|
||||
const float snackbarMaxWidth = 672.0f;
|
||||
const float margin = spacing::dp(3); // 24dp from edges
|
||||
|
||||
ImGuiIO& io = ImGui::GetIO();
|
||||
|
||||
// Calculate width based on content
|
||||
float messageWidth = ImGui::CalcTextSize(m_currentSpec.message).x;
|
||||
float actionWidth = m_currentSpec.actionText ?
|
||||
ImGui::CalcTextSize(m_currentSpec.actionText).x + spacing::dp(2) : 0;
|
||||
float contentWidth = messageWidth + actionWidth + spacing::dp(4); // 32dp padding
|
||||
float snackbarWidth = ImClamp(contentWidth, snackbarMinWidth, snackbarMaxWidth);
|
||||
|
||||
// Position at bottom center
|
||||
float bottomY = io.DisplaySize.y - margin - snackbarHeight;
|
||||
float slideOffset = (1.0f - m_animProgress) * (snackbarHeight + margin);
|
||||
|
||||
ImVec2 snackbarPos(
|
||||
(io.DisplaySize.x - snackbarWidth) * 0.5f,
|
||||
bottomY + slideOffset
|
||||
);
|
||||
|
||||
// Draw snackbar
|
||||
ImDrawList* drawList = ImGui::GetForegroundDrawList();
|
||||
|
||||
// Background (elevation dp6 equivalent)
|
||||
ImU32 snackBg = schema::UI().resolveColor("var(--snackbar-bg)", IM_COL32(50, 50, 50, 255));
|
||||
ImU32 bgColor = ScaleAlpha(snackBg, m_animProgress);
|
||||
ImVec2 snackbarMin = snackbarPos;
|
||||
ImVec2 snackbarMax(snackbarPos.x + snackbarWidth, snackbarPos.y + snackbarHeight);
|
||||
|
||||
drawList->AddRectFilled(snackbarMin, snackbarMax, bgColor, 4.0f);
|
||||
|
||||
// Message text
|
||||
float textY = snackbarPos.y + (snackbarHeight - ImGui::GetFontSize()) * 0.5f;
|
||||
float textX = snackbarPos.x + spacing::dp(2); // 16dp left padding
|
||||
|
||||
ImU32 snackText = schema::UI().resolveColor("var(--snackbar-text)", IM_COL32(255, 255, 255, 222));
|
||||
ImU32 textColor = ScaleAlpha(snackText, m_animProgress);
|
||||
drawList->AddText(ImVec2(textX, textY), textColor, m_currentSpec.message);
|
||||
|
||||
// Action button
|
||||
if (m_currentSpec.actionText) {
|
||||
float actionX = snackbarMax.x - spacing::dp(2) - actionWidth;
|
||||
|
||||
// Hit test for action
|
||||
ImVec2 actionMin(actionX, snackbarPos.y);
|
||||
ImVec2 actionMax(snackbarMax.x, snackbarMax.y);
|
||||
|
||||
ImVec2 mousePos = io.MousePos;
|
||||
bool hovered = (mousePos.x >= actionMin.x && mousePos.x < actionMax.x &&
|
||||
mousePos.y >= actionMin.y && mousePos.y < actionMax.y);
|
||||
|
||||
// Action text color
|
||||
ImU32 actionColor;
|
||||
if (hovered) {
|
||||
actionColor = ScaleAlpha(schema::UI().resolveColor("var(--snackbar-action-hover)", IM_COL32(255, 213, 79, 255)), m_animProgress);
|
||||
} else {
|
||||
actionColor = ScaleAlpha(schema::UI().resolveColor("var(--snackbar-action)", IM_COL32(255, 193, 7, 255)), m_animProgress);
|
||||
}
|
||||
|
||||
drawList->AddText(ImVec2(actionX, textY), actionColor, m_currentSpec.actionText);
|
||||
|
||||
// Check click
|
||||
if (hovered && io.MouseClicked[0]) {
|
||||
actionClicked = true;
|
||||
dismiss();
|
||||
}
|
||||
}
|
||||
|
||||
return actionClicked;
|
||||
}
|
||||
|
||||
} // namespace material
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
319
src/ui/material/components/tabs.h
Normal file
319
src/ui/material/components/tabs.h
Normal file
@@ -0,0 +1,319 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "../colors.h"
|
||||
#include "../typography.h"
|
||||
#include "../layout.h"
|
||||
#include "../../schema/ui_schema.h"
|
||||
#include "imgui.h"
|
||||
#include "imgui_internal.h"
|
||||
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
namespace material {
|
||||
|
||||
// ============================================================================
|
||||
// Material Design Tabs Component
|
||||
// ============================================================================
|
||||
// Based on https://m2.material.io/components/tabs
|
||||
//
|
||||
// Tabs organize content across different screens, data sets, and other
|
||||
// interactions.
|
||||
|
||||
/**
|
||||
* @brief Tab bar configuration
|
||||
*/
|
||||
struct TabBarSpec {
|
||||
bool scrollable = false; // Enable horizontal scrolling
|
||||
bool fullWidth = true; // Tabs fill available width
|
||||
bool showIndicator = true; // Show selection indicator
|
||||
bool centered = false; // Center tabs (when not full width)
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Individual tab configuration
|
||||
*/
|
||||
struct TabSpec {
|
||||
const char* label = nullptr;
|
||||
const char* icon = nullptr; // Optional icon (text representation)
|
||||
bool disabled = false;
|
||||
int badgeCount = 0; // Badge count (0 = no badge)
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Begin a tab bar
|
||||
*
|
||||
* @param id Unique identifier
|
||||
* @param selectedIndex Pointer to selected tab index
|
||||
* @param spec Tab bar configuration
|
||||
* @return true if tab bar is visible
|
||||
*/
|
||||
bool BeginTabBar(const char* id, int* selectedIndex, const TabBarSpec& spec = TabBarSpec());
|
||||
|
||||
/**
|
||||
* @brief End a tab bar
|
||||
*/
|
||||
void EndTabBar();
|
||||
|
||||
/**
|
||||
* @brief Add a tab to current tab bar
|
||||
*
|
||||
* @param spec Tab configuration
|
||||
* @return true if this tab is selected
|
||||
*/
|
||||
bool Tab(const TabSpec& spec);
|
||||
|
||||
/**
|
||||
* @brief Simple tab with just label
|
||||
*/
|
||||
bool Tab(const char* label);
|
||||
|
||||
/**
|
||||
* @brief Simple tab bar - returns selected index
|
||||
*
|
||||
* @param id Unique identifier
|
||||
* @param labels Array of tab labels
|
||||
* @param count Number of tabs
|
||||
* @param selectedIndex Current selected index (will be updated)
|
||||
* @return true if selection changed
|
||||
*/
|
||||
bool TabBar(const char* id, const char** labels, int count, int* selectedIndex);
|
||||
|
||||
// ============================================================================
|
||||
// Implementation
|
||||
// ============================================================================
|
||||
|
||||
// Internal state for tab rendering
|
||||
struct TabBarState {
|
||||
int* selectedIndex;
|
||||
int currentTabIndex;
|
||||
TabBarSpec spec;
|
||||
float tabBarWidth;
|
||||
float tabWidth;
|
||||
float indicatorX;
|
||||
float indicatorWidth;
|
||||
ImVec2 barPos;
|
||||
};
|
||||
|
||||
static TabBarState g_tabBarState;
|
||||
|
||||
inline bool BeginTabBar(const char* id, int* selectedIndex, const TabBarSpec& spec) {
|
||||
ImGuiWindow* window = ImGui::GetCurrentWindow();
|
||||
if (window->SkipItems)
|
||||
return false;
|
||||
|
||||
ImGui::PushID(id);
|
||||
|
||||
g_tabBarState.selectedIndex = selectedIndex;
|
||||
g_tabBarState.currentTabIndex = 0;
|
||||
g_tabBarState.spec = spec;
|
||||
g_tabBarState.tabBarWidth = ImGui::GetContentRegionAvail().x;
|
||||
g_tabBarState.tabWidth = 0; // Will be calculated if fullWidth
|
||||
g_tabBarState.barPos = window->DC.CursorPos;
|
||||
g_tabBarState.indicatorX = 0;
|
||||
g_tabBarState.indicatorWidth = 0;
|
||||
|
||||
// Reserve space for tab bar
|
||||
float barHeight = size::TabBarHeight;
|
||||
ImRect bb(g_tabBarState.barPos,
|
||||
ImVec2(g_tabBarState.barPos.x + g_tabBarState.tabBarWidth,
|
||||
g_tabBarState.barPos.y + barHeight));
|
||||
|
||||
ImGui::ItemSize(bb);
|
||||
|
||||
// Draw tab bar background
|
||||
ImDrawList* drawList = window->DrawList;
|
||||
drawList->AddRectFilled(bb.Min, bb.Max, Surface(Elevation::Dp4));
|
||||
|
||||
// Begin horizontal layout for tabs
|
||||
ImGui::SetCursorScreenPos(g_tabBarState.barPos);
|
||||
ImGui::BeginGroup();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
inline void EndTabBar() {
|
||||
ImGui::EndGroup();
|
||||
|
||||
// Draw indicator line
|
||||
if (g_tabBarState.spec.showIndicator && g_tabBarState.indicatorWidth > 0) {
|
||||
ImGuiWindow* window = ImGui::GetCurrentWindow();
|
||||
ImDrawList* drawList = window->DrawList;
|
||||
|
||||
float indicatorY = g_tabBarState.barPos.y + size::TabBarHeight - 2.0f;
|
||||
drawList->AddRectFilled(
|
||||
ImVec2(g_tabBarState.indicatorX, indicatorY),
|
||||
ImVec2(g_tabBarState.indicatorX + g_tabBarState.indicatorWidth, indicatorY + 2.0f),
|
||||
Primary()
|
||||
);
|
||||
}
|
||||
|
||||
// Add bottom divider
|
||||
ImGuiWindow* window = ImGui::GetCurrentWindow();
|
||||
ImDrawList* drawList = window->DrawList;
|
||||
float dividerY = g_tabBarState.barPos.y + size::TabBarHeight;
|
||||
drawList->AddLine(
|
||||
ImVec2(g_tabBarState.barPos.x, dividerY),
|
||||
ImVec2(g_tabBarState.barPos.x + g_tabBarState.tabBarWidth, dividerY),
|
||||
OnSurfaceDisabled()
|
||||
);
|
||||
|
||||
ImGui::PopID();
|
||||
}
|
||||
|
||||
inline bool Tab(const TabSpec& spec) {
|
||||
ImGuiWindow* window = ImGui::GetCurrentWindow();
|
||||
if (window->SkipItems)
|
||||
return false;
|
||||
|
||||
int tabIndex = g_tabBarState.currentTabIndex++;
|
||||
bool isSelected = (*g_tabBarState.selectedIndex == tabIndex);
|
||||
|
||||
// Calculate tab dimensions
|
||||
float minTabWidth = spec.icon ? 72.0f : 90.0f; // Material min widths
|
||||
float maxTabWidth = 360.0f;
|
||||
float labelWidth = ImGui::CalcTextSize(spec.label).x;
|
||||
float iconWidth = spec.icon ? 24.0f + spacing::dp(1) : 0;
|
||||
float contentWidth = labelWidth + iconWidth + spacing::dp(4); // 32dp padding
|
||||
|
||||
float tabWidth;
|
||||
if (g_tabBarState.spec.fullWidth) {
|
||||
// Divide evenly (assuming we don't know total count here - simplified)
|
||||
tabWidth = ImMax(minTabWidth, contentWidth);
|
||||
} else {
|
||||
tabWidth = ImClamp(contentWidth, minTabWidth, maxTabWidth);
|
||||
}
|
||||
|
||||
float tabHeight = size::TabBarHeight;
|
||||
|
||||
ImVec2 tabPos = window->DC.CursorPos;
|
||||
ImRect tabBB(tabPos, ImVec2(tabPos.x + tabWidth, tabPos.y + tabHeight));
|
||||
|
||||
// Interaction
|
||||
ImGuiID id = window->GetID(spec.label);
|
||||
bool hovered, held;
|
||||
bool pressed = ImGui::ButtonBehavior(tabBB, id, &hovered, &held) && !spec.disabled;
|
||||
|
||||
if (pressed && !isSelected) {
|
||||
*g_tabBarState.selectedIndex = tabIndex;
|
||||
}
|
||||
|
||||
// Update indicator position for selected tab
|
||||
if (isSelected) {
|
||||
g_tabBarState.indicatorX = tabPos.x;
|
||||
g_tabBarState.indicatorWidth = tabWidth;
|
||||
}
|
||||
|
||||
// Draw
|
||||
ImDrawList* drawList = window->DrawList;
|
||||
|
||||
// Hover/press state overlay
|
||||
if (!spec.disabled) {
|
||||
if (held) {
|
||||
drawList->AddRectFilled(tabBB.Min, tabBB.Max, schema::UI().resolveColor("var(--hover-overlay)", IM_COL32(255, 255, 255, 25)));
|
||||
} else if (hovered) {
|
||||
drawList->AddRectFilled(tabBB.Min, tabBB.Max, schema::UI().resolveColor("var(--active-overlay)", IM_COL32(255, 255, 255, 10)));
|
||||
}
|
||||
}
|
||||
|
||||
// Content color
|
||||
ImU32 contentColor;
|
||||
if (spec.disabled) {
|
||||
contentColor = OnSurfaceDisabled();
|
||||
} else if (isSelected) {
|
||||
contentColor = Primary();
|
||||
} else {
|
||||
contentColor = OnSurfaceMedium();
|
||||
}
|
||||
|
||||
// Draw content (icon and/or label)
|
||||
float contentX = tabPos.x + (tabWidth - labelWidth - iconWidth) * 0.5f;
|
||||
float centerY = tabPos.y + tabHeight * 0.5f;
|
||||
|
||||
if (spec.icon) {
|
||||
ImFont* iconFont = Type().iconMed();
|
||||
ImVec2 iconSize = iconFont->CalcTextSizeA(iconFont->LegacySize, FLT_MAX, 0, spec.icon);
|
||||
ImVec2 iconPos(contentX, centerY - iconSize.y * 0.5f);
|
||||
drawList->AddText(iconFont, iconFont->LegacySize, iconPos, contentColor, spec.icon);
|
||||
contentX += iconSize.x + spacing::Xs;
|
||||
}
|
||||
|
||||
// Label (uppercase)
|
||||
Typography::instance().pushFont(TypeStyle::Button);
|
||||
float labelY = centerY - ImGui::GetFontSize() * 0.5f;
|
||||
|
||||
// Convert to uppercase
|
||||
char upperLabel[128];
|
||||
size_t i = 0;
|
||||
for (const char* p = spec.label; *p && i < sizeof(upperLabel) - 1; p++, i++) {
|
||||
upperLabel[i] = (*p >= 'a' && *p <= 'z') ? (*p - 32) : *p;
|
||||
}
|
||||
upperLabel[i] = '\0';
|
||||
|
||||
drawList->AddText(ImVec2(contentX, labelY), contentColor, upperLabel);
|
||||
Typography::instance().popFont();
|
||||
|
||||
// Badge
|
||||
if (spec.badgeCount > 0) {
|
||||
float badgeX = tabPos.x + tabWidth - 16.0f;
|
||||
float badgeY = tabPos.y + 8.0f;
|
||||
float badgeRadius = 8.0f;
|
||||
|
||||
drawList->AddCircleFilled(ImVec2(badgeX, badgeY), badgeRadius, Error());
|
||||
|
||||
char badgeText[8];
|
||||
if (spec.badgeCount > 99) {
|
||||
snprintf(badgeText, sizeof(badgeText), "99+");
|
||||
} else {
|
||||
snprintf(badgeText, sizeof(badgeText), "%d", spec.badgeCount);
|
||||
}
|
||||
|
||||
ImVec2 badgeTextSize = ImGui::CalcTextSize(badgeText);
|
||||
ImVec2 badgeTextPos(badgeX - badgeTextSize.x * 0.5f, badgeY - badgeTextSize.y * 0.5f);
|
||||
|
||||
Typography::instance().pushFont(TypeStyle::Caption);
|
||||
drawList->AddText(badgeTextPos, OnError(), badgeText);
|
||||
Typography::instance().popFont();
|
||||
}
|
||||
|
||||
// Advance cursor
|
||||
ImGui::SameLine(0, 0);
|
||||
ImGui::SetCursorScreenPos(ImVec2(tabPos.x + tabWidth, tabPos.y));
|
||||
|
||||
return isSelected;
|
||||
}
|
||||
|
||||
inline bool Tab(const char* label) {
|
||||
TabSpec spec;
|
||||
spec.label = label;
|
||||
return Tab(spec);
|
||||
}
|
||||
|
||||
inline bool TabBar(const char* id, const char** labels, int count, int* selectedIndex) {
|
||||
int oldIndex = *selectedIndex;
|
||||
|
||||
TabBarSpec spec;
|
||||
spec.fullWidth = true;
|
||||
|
||||
if (BeginTabBar(id, selectedIndex, spec)) {
|
||||
// Calculate tab width for full-width mode
|
||||
float tabWidth = ImGui::GetContentRegionAvail().x / count;
|
||||
|
||||
for (int i = 0; i < count; i++) {
|
||||
TabSpec tabSpec;
|
||||
tabSpec.label = labels[i];
|
||||
Tab(tabSpec);
|
||||
}
|
||||
|
||||
EndTabBar();
|
||||
}
|
||||
|
||||
return (*selectedIndex != oldIndex);
|
||||
}
|
||||
|
||||
} // namespace material
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
227
src/ui/material/components/text_fields.h
Normal file
227
src/ui/material/components/text_fields.h
Normal file
@@ -0,0 +1,227 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "../colors.h"
|
||||
#include "../typography.h"
|
||||
#include "../layout.h"
|
||||
#include "imgui.h"
|
||||
#include "imgui_internal.h"
|
||||
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
namespace material {
|
||||
|
||||
// ============================================================================
|
||||
// Material Design Text Field Component
|
||||
// ============================================================================
|
||||
// Based on https://m2.material.io/components/text-fields
|
||||
//
|
||||
// Two variants:
|
||||
// - Filled: Background fill with bottom line indicator
|
||||
// - Outlined: Border around entire field
|
||||
|
||||
enum class TextFieldStyle {
|
||||
Filled, // Background fill
|
||||
Outlined // Border only
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Text field configuration
|
||||
*/
|
||||
struct TextFieldSpec {
|
||||
TextFieldStyle style = TextFieldStyle::Outlined;
|
||||
const char* label = nullptr; // Floating label text
|
||||
const char* hint = nullptr; // Placeholder when empty
|
||||
const char* helperText = nullptr; // Helper text below field
|
||||
const char* errorText = nullptr; // Error message (shows in error state)
|
||||
const char* prefix = nullptr; // Prefix text (e.g., "$")
|
||||
const char* suffix = nullptr; // Suffix text (e.g., "DRGX")
|
||||
bool password = false; // Mask input
|
||||
bool readOnly = false; // Read-only field
|
||||
bool multiline = false; // Multi-line text area
|
||||
int multilineRows = 3; // Number of rows for multiline
|
||||
float width = 0; // Width (0 = full available)
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Render a Material Design text field
|
||||
*
|
||||
* @param id Unique identifier
|
||||
* @param buf Text buffer
|
||||
* @param bufSize Buffer size
|
||||
* @param spec Field configuration
|
||||
* @return true if value changed
|
||||
*/
|
||||
bool TextField(const char* id, char* buf, size_t bufSize, const TextFieldSpec& spec = TextFieldSpec());
|
||||
|
||||
/**
|
||||
* @brief Render a simple text field with label
|
||||
*/
|
||||
inline bool TextField(const char* label, char* buf, size_t bufSize) {
|
||||
TextFieldSpec spec;
|
||||
spec.label = label;
|
||||
return TextField(label, buf, bufSize, spec);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Implementation
|
||||
// ============================================================================
|
||||
|
||||
inline bool TextField(const char* id, char* buf, size_t bufSize, const TextFieldSpec& spec) {
|
||||
ImGuiWindow* window = ImGui::GetCurrentWindow();
|
||||
if (window->SkipItems)
|
||||
return false;
|
||||
|
||||
ImGui::PushID(id);
|
||||
|
||||
bool hasError = (spec.errorText != nullptr);
|
||||
bool hasValue = (buf[0] != '\0');
|
||||
|
||||
// Calculate dimensions
|
||||
float fieldWidth = spec.width > 0 ? spec.width : ImGui::GetContentRegionAvail().x;
|
||||
float fieldHeight = spec.multiline ?
|
||||
(size::TextFieldHeight + (spec.multilineRows - 1) * Typography::instance().getFont(TypeStyle::Body1)->FontSize * 1.5f) :
|
||||
size::TextFieldHeight;
|
||||
|
||||
ImVec2 pos = window->DC.CursorPos;
|
||||
ImRect bb(pos, ImVec2(pos.x + fieldWidth, pos.y + fieldHeight));
|
||||
|
||||
// Interaction
|
||||
ImGuiID inputId = window->GetID("##input");
|
||||
bool focused = (ImGui::GetFocusID() == inputId);
|
||||
|
||||
// Colors
|
||||
ImU32 bgColor, borderColor, labelColor;
|
||||
|
||||
if (hasError) {
|
||||
borderColor = Error();
|
||||
labelColor = Error();
|
||||
} else if (focused) {
|
||||
borderColor = Primary();
|
||||
labelColor = Primary();
|
||||
} else {
|
||||
borderColor = OnSurfaceMedium();
|
||||
labelColor = OnSurfaceMedium();
|
||||
}
|
||||
|
||||
if (spec.style == TextFieldStyle::Filled) {
|
||||
bgColor = GetElevatedSurface(GetCurrentColorTheme(), 1);
|
||||
} else {
|
||||
bgColor = 0; // Transparent for outlined
|
||||
}
|
||||
|
||||
// Draw background/border
|
||||
ImDrawList* drawList = window->DrawList;
|
||||
|
||||
if (spec.style == TextFieldStyle::Filled) {
|
||||
// Filled style: background with bottom line
|
||||
drawList->AddRectFilled(bb.Min, bb.Max, bgColor,
|
||||
size::TextFieldCornerRadius, ImDrawFlags_RoundCornersTop);
|
||||
|
||||
// Bottom indicator line
|
||||
float lineThickness = focused ? 2.0f : 1.0f;
|
||||
drawList->AddLine(
|
||||
ImVec2(bb.Min.x, bb.Max.y - lineThickness),
|
||||
ImVec2(bb.Max.x, bb.Max.y - lineThickness),
|
||||
borderColor, lineThickness
|
||||
);
|
||||
} else {
|
||||
// Outlined style: border around entire field
|
||||
float lineThickness = focused ? 2.0f : 1.0f;
|
||||
drawList->AddRect(bb.Min, bb.Max, borderColor,
|
||||
size::TextFieldCornerRadius, 0, lineThickness);
|
||||
}
|
||||
|
||||
// Label (floating or inline)
|
||||
bool labelFloating = focused || hasValue;
|
||||
if (spec.label) {
|
||||
ImVec2 labelPos;
|
||||
TypeStyle labelStyle;
|
||||
|
||||
if (labelFloating) {
|
||||
// Floating label (smaller, at top)
|
||||
labelPos.x = bb.Min.x + size::TextFieldPadding;
|
||||
labelPos.y = bb.Min.y + 4.0f;
|
||||
labelStyle = TypeStyle::Caption;
|
||||
} else {
|
||||
// Inline label (body size, centered)
|
||||
labelPos.x = bb.Min.x + size::TextFieldPadding;
|
||||
labelPos.y = bb.Min.y + (fieldHeight - Typography::instance().getFont(TypeStyle::Body1)->FontSize) * 0.5f;
|
||||
labelStyle = TypeStyle::Body1;
|
||||
}
|
||||
|
||||
// For outlined style, need to clear background behind floating label
|
||||
if (spec.style == TextFieldStyle::Outlined && labelFloating) {
|
||||
ImVec2 labelSize = ImGui::CalcTextSize(spec.label);
|
||||
ImVec2 clearMin(labelPos.x - 4.0f, bb.Min.y - 1.0f);
|
||||
ImVec2 clearMax(labelPos.x + labelSize.x + 4.0f, bb.Min.y + Typography::instance().getFont(TypeStyle::Caption)->FontSize);
|
||||
drawList->AddRectFilled(clearMin, clearMax, Background());
|
||||
}
|
||||
|
||||
Typography::instance().pushFont(labelStyle);
|
||||
drawList->AddText(labelPos, labelColor, spec.label);
|
||||
Typography::instance().popFont();
|
||||
}
|
||||
|
||||
// Input field
|
||||
float inputY = spec.label && labelFloating ? bb.Min.y + 20.0f : bb.Min.y + 12.0f;
|
||||
float inputHeight = bb.Max.y - inputY - 8.0f;
|
||||
|
||||
ImGui::SetCursorScreenPos(ImVec2(bb.Min.x + size::TextFieldPadding, inputY));
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(0, 0));
|
||||
ImGui::PushStyleColor(ImGuiCol_FrameBg, IM_COL32(0, 0, 0, 0));
|
||||
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(OnSurface()));
|
||||
|
||||
ImGuiInputTextFlags flags = 0;
|
||||
if (spec.password) flags |= ImGuiInputTextFlags_Password;
|
||||
if (spec.readOnly) flags |= ImGuiInputTextFlags_ReadOnly;
|
||||
|
||||
float inputWidth = fieldWidth - size::TextFieldPadding * 2;
|
||||
if (spec.prefix) {
|
||||
ImGui::TextUnformatted(spec.prefix);
|
||||
ImGui::SameLine();
|
||||
inputWidth -= ImGui::CalcTextSize(spec.prefix).x + 4.0f;
|
||||
}
|
||||
|
||||
ImGui::PushItemWidth(inputWidth);
|
||||
bool changed;
|
||||
if (spec.multiline) {
|
||||
changed = ImGui::InputTextMultiline("##input", buf, bufSize,
|
||||
ImVec2(inputWidth, inputHeight), flags);
|
||||
} else {
|
||||
changed = ImGui::InputText("##input", buf, bufSize, flags);
|
||||
}
|
||||
ImGui::PopItemWidth();
|
||||
|
||||
if (spec.suffix) {
|
||||
ImGui::SameLine();
|
||||
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(OnSurfaceMedium()), "%s", spec.suffix);
|
||||
}
|
||||
|
||||
ImGui::PopStyleColor(2);
|
||||
ImGui::PopStyleVar();
|
||||
|
||||
// Helper/Error text below field
|
||||
ImGui::SetCursorScreenPos(ImVec2(bb.Min.x, bb.Max.y + 4.0f));
|
||||
if (spec.errorText) {
|
||||
Typography::instance().textColored(TypeStyle::Caption, Error(), spec.errorText);
|
||||
ImGui::SetCursorScreenPos(ImVec2(bb.Min.x, bb.Max.y + 4.0f + Typography::instance().getFont(TypeStyle::Caption)->FontSize + 4.0f));
|
||||
} else if (spec.helperText) {
|
||||
Typography::instance().textColored(TypeStyle::Caption, OnSurfaceMedium(), spec.helperText);
|
||||
ImGui::SetCursorScreenPos(ImVec2(bb.Min.x, bb.Max.y + 4.0f + Typography::instance().getFont(TypeStyle::Caption)->FontSize + 4.0f));
|
||||
}
|
||||
|
||||
// Advance cursor
|
||||
ImGui::SetCursorScreenPos(ImVec2(pos.x, bb.Max.y + (spec.errorText || spec.helperText ? 24.0f : 8.0f)));
|
||||
|
||||
ImGui::PopID();
|
||||
|
||||
return changed;
|
||||
}
|
||||
|
||||
} // namespace material
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
779
src/ui/material/draw_helpers.h
Normal file
779
src/ui/material/draw_helpers.h
Normal file
@@ -0,0 +1,779 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "colors.h"
|
||||
#include "type.h"
|
||||
#include "../layout.h"
|
||||
#include "../schema/element_styles.h"
|
||||
#include "../schema/color_var_resolver.h"
|
||||
#include "../schema/ui_schema.h"
|
||||
#include "../effects/theme_effects.h"
|
||||
#include "../effects/low_spec.h"
|
||||
#include "../effects/imgui_acrylic.h"
|
||||
#include "../theme.h"
|
||||
#include "../../util/noise_texture.h"
|
||||
#include "imgui.h"
|
||||
#include "imgui_internal.h"
|
||||
#include <algorithm>
|
||||
#include <unordered_map>
|
||||
#include <cmath>
|
||||
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
namespace material {
|
||||
|
||||
// Scale the alpha channel of an ImU32 color by a float factor.
|
||||
inline ImU32 ScaleAlpha(ImU32 col, float scale) {
|
||||
int a = static_cast<int>(((col >> IM_COL32_A_SHIFT) & 0xFF) * scale);
|
||||
if (a > 255) a = 255;
|
||||
return (col & ~IM_COL32_A_MASK) | (static_cast<ImU32>(a) << IM_COL32_A_SHIFT);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Text Drop Shadow
|
||||
// ============================================================================
|
||||
// Draw text with a subtle dark shadow behind it for readability on
|
||||
// translucent / glassy surfaces. The shadow is a 1px offset copy of
|
||||
// the text in a near-black colour. When the OS backdrop (DWM Acrylic)
|
||||
// is inactive, the shadow is skipped since opaque backgrounds already
|
||||
// provide enough contrast.
|
||||
|
||||
inline void DrawTextShadow(ImDrawList* dl, ImFont* font, float fontSize,
|
||||
const ImVec2& pos, ImU32 col, const char* text,
|
||||
float offsetX = 1.0f, float offsetY = 1.0f,
|
||||
ImU32 shadowCol = 0)
|
||||
{
|
||||
if (IsBackdropActive()) {
|
||||
if (!shadowCol) {
|
||||
static uint32_t s_gen = 0;
|
||||
static ImU32 s_shadowCol = 0;
|
||||
uint32_t g = schema::UI().generation();
|
||||
if (g != s_gen) { s_gen = g; s_shadowCol = schema::UI().resolveColor("var(--text-shadow)", IM_COL32(0, 0, 0, 120)); }
|
||||
shadowCol = s_shadowCol;
|
||||
}
|
||||
dl->AddText(font, fontSize,
|
||||
ImVec2(pos.x + offsetX, pos.y + offsetY),
|
||||
shadowCol, text);
|
||||
}
|
||||
dl->AddText(font, fontSize, pos, col, text);
|
||||
}
|
||||
|
||||
// Convenience overload that uses the current default font.
|
||||
inline void DrawTextShadow(ImDrawList* dl, const ImVec2& pos, ImU32 col,
|
||||
const char* text,
|
||||
float offsetX = 1.0f, float offsetY = 1.0f,
|
||||
ImU32 shadowCol = 0)
|
||||
{
|
||||
if (IsBackdropActive()) {
|
||||
if (!shadowCol) {
|
||||
static uint32_t s_gen = 0;
|
||||
static ImU32 s_shadowCol = 0;
|
||||
uint32_t g = schema::UI().generation();
|
||||
if (g != s_gen) { s_gen = g; s_shadowCol = schema::UI().resolveColor("var(--text-shadow)", IM_COL32(0, 0, 0, 120)); }
|
||||
shadowCol = s_shadowCol;
|
||||
}
|
||||
dl->AddText(ImVec2(pos.x + offsetX, pos.y + offsetY),
|
||||
shadowCol, text);
|
||||
}
|
||||
dl->AddText(pos, col, text);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Modal-Aware Hover Check
|
||||
// ============================================================================
|
||||
// Drop-in replacement for ImGui::IsMouseHoveringRect that also respects
|
||||
// modal popup blocking. The raw ImGui helper is a pure geometric test
|
||||
// and will return true even when a modal popup covers the rect, which
|
||||
// causes background elements to show hover highlights through dialogs.
|
||||
|
||||
inline bool IsRectHovered(const ImVec2& r_min, const ImVec2& r_max, bool clip = true)
|
||||
{
|
||||
if (!ImGui::IsMouseHoveringRect(r_min, r_max, clip))
|
||||
return false;
|
||||
// If a modal popup is open and it is not the current window, treat
|
||||
// the content as non-hoverable (same logic ImGui uses internally
|
||||
// inside IsWindowContentHoverable for modal blocking).
|
||||
//
|
||||
// We cannot rely solely on GetTopMostAndVisiblePopupModal() because
|
||||
// it checks Active, which is only set when BeginPopupModal() is
|
||||
// called in the current frame. Content tabs render BEFORE their
|
||||
// associated dialogs, so the modal's Active flag is still false at
|
||||
// this point. Checking WasActive (set from the previous frame)
|
||||
// covers this render-order gap.
|
||||
ImGuiContext& g = *ImGui::GetCurrentContext();
|
||||
for (int n = g.OpenPopupStack.Size - 1; n >= 0; n--) {
|
||||
ImGuiWindow* popup = g.OpenPopupStack.Data[n].Window;
|
||||
if (popup && (popup->Flags & ImGuiWindowFlags_Modal)) {
|
||||
if ((popup->Active || popup->WasActive) && !popup->Hidden) {
|
||||
if (popup != ImGui::GetCurrentWindow())
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tactile Button Overlay
|
||||
// ============================================================================
|
||||
// Adds a subtle top-edge highlight and bottom-edge shadow to a button rect,
|
||||
// creating the illusion of a raised physical surface. On active (pressed),
|
||||
// the highlight/shadow swap to create an inset "pushed" feel.
|
||||
// Call this AFTER ImGui::Button() using GetItemRectMin()/GetItemRectMax().
|
||||
|
||||
inline void DrawTactileOverlay(ImDrawList* dl, const ImVec2& bMin,
|
||||
const ImVec2& bMax, float rounding,
|
||||
bool active = false)
|
||||
{
|
||||
float h = bMax.y - bMin.y;
|
||||
float edgeH = std::min(h * 0.38f, 6.0f); // highlight/shadow strip height
|
||||
|
||||
// AddRectFilledMultiColor does not support rounding, so clip to the
|
||||
// button's rounded rect to prevent sharp corners from poking out.
|
||||
if (rounding > 0.0f) {
|
||||
float inset = rounding * 0.29f; // enough to hide corners
|
||||
dl->PushClipRect(
|
||||
ImVec2(bMin.x + inset, bMin.y + inset),
|
||||
ImVec2(bMax.x - inset, bMax.y - inset), true);
|
||||
}
|
||||
|
||||
ImU32 tHi = schema::UI().resolveColor("var(--tactile-top)", IM_COL32(255, 255, 255, 18));
|
||||
ImU32 tHiT = tHi & ~IM_COL32_A_MASK; // transparent version
|
||||
|
||||
if (!active) {
|
||||
// Raised: bright top edge, dark bottom edge
|
||||
// Top highlight
|
||||
dl->AddRectFilledMultiColor(
|
||||
bMin, ImVec2(bMax.x, bMin.y + edgeH),
|
||||
tHi, tHi, tHiT, tHiT);
|
||||
// Bottom shadow
|
||||
dl->AddRectFilledMultiColor(
|
||||
ImVec2(bMin.x, bMax.y - edgeH), bMax,
|
||||
IM_COL32(0, 0, 0, 0), // top-left
|
||||
IM_COL32(0, 0, 0, 0), // top-right
|
||||
IM_COL32(0, 0, 0, 22), // bottom-right
|
||||
IM_COL32(0, 0, 0, 22)); // bottom-left
|
||||
} else {
|
||||
// Pressed: dark top edge, bright bottom edge (inset)
|
||||
// Top shadow
|
||||
dl->AddRectFilledMultiColor(
|
||||
bMin, ImVec2(bMax.x, bMin.y + edgeH),
|
||||
IM_COL32(0, 0, 0, 20),
|
||||
IM_COL32(0, 0, 0, 20),
|
||||
IM_COL32(0, 0, 0, 0),
|
||||
IM_COL32(0, 0, 0, 0));
|
||||
// Bottom highlight (dimmer when pressed)
|
||||
ImU32 pHi = ScaleAlpha(tHi, 0.55f);
|
||||
ImU32 pHiT = pHi & ~IM_COL32_A_MASK;
|
||||
dl->AddRectFilledMultiColor(
|
||||
ImVec2(bMin.x, bMax.y - edgeH), bMax,
|
||||
pHiT, pHiT, pHi, pHi);
|
||||
}
|
||||
|
||||
if (rounding > 0.0f)
|
||||
dl->PopClipRect();
|
||||
}
|
||||
|
||||
// Convenience: call right after any ImGui::Button() / SmallButton() call
|
||||
// to add tactile depth. Uses the last item rect automatically.
|
||||
inline void ApplyTactile(ImDrawList* dl = nullptr)
|
||||
{
|
||||
if (!dl) dl = ImGui::GetWindowDrawList();
|
||||
ImVec2 bMin = ImGui::GetItemRectMin();
|
||||
ImVec2 bMax = ImGui::GetItemRectMax();
|
||||
float rounding = ImGui::GetStyle().FrameRounding;
|
||||
bool active = ImGui::IsItemActive();
|
||||
DrawTactileOverlay(dl, bMin, bMax, rounding, active);
|
||||
}
|
||||
|
||||
// ── Button font tier helper ─────────────────────────────────────────────
|
||||
// Resolves an int tier (0=sm, 1=md/default, 2=lg) to the matching ImFont*.
|
||||
// Passing -1 or any out-of-range value returns the default button font.
|
||||
|
||||
inline ImFont* resolveButtonFont(int tier)
|
||||
{
|
||||
switch (tier) {
|
||||
case 0: return Type().buttonSm();
|
||||
case 2: return Type().buttonLg();
|
||||
default: return Type().button(); // 1 or any other value
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve per-button font: if perButton >= 0 use it, else fall back to sectionDefault
|
||||
inline ImFont* resolveButtonFont(int perButton, int sectionDefault)
|
||||
{
|
||||
return resolveButtonFont(perButton >= 0 ? perButton : sectionDefault);
|
||||
}
|
||||
|
||||
// ── Tactile wrappers ────────────────────────────────────────────────────
|
||||
// Drop-in replacements for ImGui::Button / SmallButton that automatically
|
||||
// add the tactile highlight overlay after rendering.
|
||||
|
||||
inline bool TactileButton(const char* label, const ImVec2& size = ImVec2(0, 0), ImFont* font = nullptr)
|
||||
{
|
||||
// Draw button with glass-card styling: translucent fill + border + tactile overlay
|
||||
ImDrawList* dl = ImGui::GetWindowDrawList();
|
||||
ImFont* useFont = font ? font : Type().button();
|
||||
|
||||
// For icon fonts, use InvisibleButton + manual centered text rendering
|
||||
// to ensure perfect centering (ImGui::Button alignment can be off for icons)
|
||||
bool isIconFont = font && (font == Type().iconSmall() || font == Type().iconMed() ||
|
||||
font == Type().iconLarge() || font == Type().iconXL());
|
||||
|
||||
bool pressed;
|
||||
if (isIconFont && size.x > 0 && size.y > 0) {
|
||||
pressed = ImGui::InvisibleButton(label, size);
|
||||
} else {
|
||||
ImGui::PushFont(useFont);
|
||||
pressed = ImGui::Button(label, size);
|
||||
ImGui::PopFont();
|
||||
}
|
||||
// Glass overlay on the button rect
|
||||
ImVec2 bMin = ImGui::GetItemRectMin();
|
||||
ImVec2 bMax = ImGui::GetItemRectMax();
|
||||
|
||||
// For icon fonts, manually draw centered icon after getting button rect
|
||||
if (isIconFont && size.x > 0 && size.y > 0) {
|
||||
ImVec2 textSz = useFont->CalcTextSizeA(useFont->LegacySize, FLT_MAX, 0, label);
|
||||
ImVec2 textPos(bMin.x + (size.x - textSz.x) * 0.5f, bMin.y + (size.y - textSz.y) * 0.5f);
|
||||
dl->AddText(useFont, useFont->LegacySize, textPos, ImGui::GetColorU32(ImGuiCol_Text), label);
|
||||
}
|
||||
|
||||
float rounding = ImGui::GetStyle().FrameRounding;
|
||||
bool active = ImGui::IsItemActive();
|
||||
bool hovered = ImGui::IsItemHovered();
|
||||
// Frosted glass highlight — subtle fill on top
|
||||
if (!active) {
|
||||
ImU32 col = hovered
|
||||
? schema::UI().resolveColor("var(--hover-overlay)", IM_COL32(255, 255, 255, 12))
|
||||
: schema::UI().resolveColor("var(--glass-fill)", IM_COL32(255, 255, 255, 6));
|
||||
dl->AddRectFilled(bMin, bMax, col, rounding);
|
||||
}
|
||||
// Rim light
|
||||
ImU32 rim = schema::UI().resolveColor("var(--rim-light)", IM_COL32(255, 255, 255, 25));
|
||||
dl->AddRect(bMin, bMax,
|
||||
active ? ScaleAlpha(rim, 0.6f) : rim,
|
||||
rounding, 0, 1.0f);
|
||||
// Tactile depth
|
||||
DrawTactileOverlay(dl, bMin, bMax, rounding, active);
|
||||
return pressed;
|
||||
}
|
||||
|
||||
inline bool TactileSmallButton(const char* label, ImFont* font = nullptr)
|
||||
{
|
||||
ImDrawList* dl = ImGui::GetWindowDrawList();
|
||||
ImGui::PushFont(font ? font : Type().button());
|
||||
bool pressed = ImGui::SmallButton(label);
|
||||
ImGui::PopFont();
|
||||
ImVec2 bMin = ImGui::GetItemRectMin();
|
||||
ImVec2 bMax = ImGui::GetItemRectMax();
|
||||
float rounding = ImGui::GetStyle().FrameRounding;
|
||||
bool active = ImGui::IsItemActive();
|
||||
bool hovered = ImGui::IsItemHovered();
|
||||
if (!active) {
|
||||
ImU32 col = hovered
|
||||
? schema::UI().resolveColor("var(--hover-overlay)", IM_COL32(255, 255, 255, 12))
|
||||
: schema::UI().resolveColor("var(--glass-fill)", IM_COL32(255, 255, 255, 6));
|
||||
dl->AddRectFilled(bMin, bMax, col, rounding);
|
||||
}
|
||||
ImU32 rim = schema::UI().resolveColor("var(--rim-light)", IM_COL32(255, 255, 255, 25));
|
||||
dl->AddRect(bMin, bMax,
|
||||
active ? ScaleAlpha(rim, 0.6f) : rim,
|
||||
rounding, 0, 1.0f);
|
||||
DrawTactileOverlay(dl, bMin, bMax, rounding, active);
|
||||
return pressed;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Glass Panel (glassmorphism card)
|
||||
// ============================================================================
|
||||
// Draws a frosted-glass style panel: a semi-transparent fill with a
|
||||
// subtle light border. Used for card/panel containers that sit above
|
||||
// the blurred background. When the backdrop is inactive the fill is
|
||||
// simply the normal surface colour (fully opaque).
|
||||
|
||||
struct GlassPanelSpec {
|
||||
float rounding = 6.0f;
|
||||
int fillAlpha = 18; // fill brightness (white, 0-255)
|
||||
int borderAlpha = 30; // border brightness (white, 0-255)
|
||||
float borderWidth = 1.0f;
|
||||
};
|
||||
|
||||
inline void DrawGlassPanel(ImDrawList* dl, const ImVec2& pMin,
|
||||
const ImVec2& pMax,
|
||||
const GlassPanelSpec& spec = GlassPanelSpec())
|
||||
{
|
||||
if (IsBackdropActive() && !dragonx::ui::effects::isLowSpecMode()) {
|
||||
// --- Cached color lookups (invalidated on theme change) ---
|
||||
// These 3 resolveColor() calls do string parsing + map lookup
|
||||
// each time. Cache them per-frame using the schema generation
|
||||
// counter so they resolve at most once per theme load.
|
||||
static uint32_t s_gen = 0;
|
||||
static ImU32 s_glassFill = 0;
|
||||
static ImU32 s_glassNoiseTint = 0;
|
||||
static ImU32 s_glassBorder = 0;
|
||||
uint32_t curGen = schema::UI().generation();
|
||||
if (curGen != s_gen) {
|
||||
s_gen = curGen;
|
||||
s_glassFill = schema::UI().resolveColor("var(--glass-fill)", IM_COL32(255, 255, 255, 18));
|
||||
s_glassNoiseTint = schema::UI().resolveColor("var(--glass-noise-tint)", IM_COL32(255, 255, 255, 10));
|
||||
s_glassBorder = schema::UI().resolveColor("var(--glass-border)", IM_COL32(255, 255, 255, 30));
|
||||
}
|
||||
|
||||
float uiOp = effects::ImGuiAcrylic::GetUIOpacity();
|
||||
bool useAcrylic = effects::ImGuiAcrylic::IsEnabled()
|
||||
&& effects::ImGuiAcrylic::IsAvailable();
|
||||
|
||||
// Glass / acrylic layer — only rendered when not fully opaque
|
||||
// (skip the blur pass at 100% for performance)
|
||||
if (uiOp < 0.99f) {
|
||||
if (useAcrylic) {
|
||||
const auto& acrylicTheme = GetCurrentAcrylicTheme();
|
||||
effects::ImGuiAcrylic::DrawAcrylicRect(dl, pMin, pMax,
|
||||
acrylicTheme.card, spec.rounding);
|
||||
} else {
|
||||
// Lightweight fake-glass: translucent fill
|
||||
ImU32 fill = (spec.fillAlpha == 18) ? s_glassFill : ScaleAlpha(s_glassFill, spec.fillAlpha / 18.0f);
|
||||
dl->AddRectFilled(pMin, pMax, fill, spec.rounding);
|
||||
}
|
||||
}
|
||||
|
||||
// Surface overlay — provides smooth transition from glass to opaque.
|
||||
// At uiOp=1.0 this fully covers the panel (opaque card).
|
||||
// As uiOp decreases, glass/blur progressively shows through.
|
||||
dl->AddRectFilled(pMin, pMax,
|
||||
WithAlphaF(GetElevatedSurface(GetCurrentColorTheme(), 1), uiOp),
|
||||
spec.rounding);
|
||||
|
||||
// Noise grain overlay — drawn OVER the surface overlay so card
|
||||
// opacity doesn't hide it. Gives cards a tactile paper feel.
|
||||
{
|
||||
float noiseMul = dragonx::ui::effects::ImGuiAcrylic::GetNoiseOpacity();
|
||||
if (noiseMul > 0.0f) {
|
||||
uint8_t origAlpha = (s_glassNoiseTint >> IM_COL32_A_SHIFT) & 0xFF;
|
||||
uint8_t scaledAlpha = static_cast<uint8_t>(std::min(255.0f, origAlpha * noiseMul));
|
||||
ImU32 noiseTint = (s_glassNoiseTint & ~(0xFFu << IM_COL32_A_SHIFT)) | (scaledAlpha << IM_COL32_A_SHIFT);
|
||||
float inset = spec.rounding * 0.3f;
|
||||
ImVec2 clipMin(pMin.x + inset, pMin.y + inset);
|
||||
ImVec2 clipMax(pMax.x - inset, pMax.y - inset);
|
||||
dl->PushClipRect(clipMin, clipMax, true);
|
||||
dragonx::util::DrawTiledNoiseRect(dl, clipMin, clipMax, noiseTint);
|
||||
dl->PopClipRect();
|
||||
}
|
||||
}
|
||||
|
||||
// Border — fades with UI opacity
|
||||
ImU32 border = (spec.borderAlpha == 30) ? s_glassBorder : ScaleAlpha(s_glassBorder, spec.borderAlpha / 30.0f);
|
||||
if (uiOp < 0.99f) border = ScaleAlpha(border, uiOp);
|
||||
dl->AddRect(pMin, pMax, border,
|
||||
spec.rounding, 0, spec.borderWidth);
|
||||
|
||||
// Theme visual effects drawn on ForegroundDrawList so they
|
||||
// render above card content (text, values, etc.), not below.
|
||||
auto& fx = effects::ThemeEffects::instance();
|
||||
ImDrawList* fxDl = ImGui::GetForegroundDrawList();
|
||||
if (fx.hasRainbowBorder()) {
|
||||
fx.drawRainbowBorder(fxDl, pMin, pMax, spec.rounding, spec.borderWidth);
|
||||
}
|
||||
if (fx.hasShimmer()) {
|
||||
fx.drawShimmer(fxDl, pMin, pMax, spec.rounding);
|
||||
}
|
||||
if (fx.hasSpecularGlare()) {
|
||||
fx.drawSpecularGlare(fxDl, pMin, pMax, spec.rounding);
|
||||
}
|
||||
// Per-panel theme effects: edge trace + ember rise
|
||||
fx.drawPanelEffects(fxDl, pMin, pMax, spec.rounding);
|
||||
} else {
|
||||
// Low-spec opaque fallback
|
||||
dl->AddRectFilled(pMin, pMax,
|
||||
GetElevatedSurface(GetCurrentColorTheme(), 1), spec.rounding);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Stat Card — reusable card with overline / value / subtitle + accent stripe
|
||||
// ============================================================================
|
||||
|
||||
struct StatCardSpec {
|
||||
const char* overline = nullptr; // small label at top (e.g. "LOCAL HASHRATE")
|
||||
const char* value = nullptr; // main value text (e.g. "1.23 kH/s")
|
||||
const char* subtitle = nullptr; // optional small line (e.g. "3 blocks")
|
||||
ImU32 valueCol = 0; // value text colour (0 = OnSurface)
|
||||
ImU32 accentCol = 0; // left-stripe colour (0 = no stripe)
|
||||
bool centered = true; // centre text horizontally
|
||||
bool hovered = false; // draw hover glow border
|
||||
};
|
||||
|
||||
/// Compute a generous stat-card height with breathing room.
|
||||
inline float StatCardHeight(float vs, float minH = 56.0f) {
|
||||
float padV = Layout::spacingLg() * 2; // top + bottom
|
||||
float content = Type().overline()->LegacySize
|
||||
+ Layout::spacingMd()
|
||||
+ Type().subtitle1()->LegacySize;
|
||||
return std::max(minH, (content + padV) * std::max(vs, 0.85f));
|
||||
}
|
||||
|
||||
inline void DrawStatCard(ImDrawList* dl,
|
||||
const ImVec2& cMin, const ImVec2& cMax,
|
||||
const StatCardSpec& card,
|
||||
const GlassPanelSpec& glass = GlassPanelSpec())
|
||||
{
|
||||
float cardW = cMax.x - cMin.x;
|
||||
float cardH = cMax.y - cMin.y;
|
||||
float rnd = glass.rounding;
|
||||
|
||||
// 1. Glass background
|
||||
DrawGlassPanel(dl, cMin, cMax, glass);
|
||||
|
||||
// 2. Accent stripe (left edge, clipped to card rounded corners).
|
||||
// Draw a full-height rounded rect with card rounding (left corners)
|
||||
// and clip to stripe width so the shape follows the corner radius.
|
||||
if ((card.accentCol & IM_COL32_A_MASK) != 0) {
|
||||
float stripeW = 4.0f;
|
||||
dl->PushClipRect(cMin, ImVec2(cMin.x + stripeW, cMax.y), true);
|
||||
dl->AddRectFilled(cMin, cMax, card.accentCol, rnd,
|
||||
ImDrawFlags_RoundCornersLeft);
|
||||
dl->PopClipRect();
|
||||
}
|
||||
|
||||
// 3. Compute content block height
|
||||
ImFont* ovFont = Type().overline();
|
||||
ImFont* valFont = Type().subtitle1();
|
||||
ImFont* subFont = Type().caption();
|
||||
|
||||
float blockH = 0;
|
||||
if (card.overline) blockH += ovFont->LegacySize;
|
||||
if (card.overline && card.value) blockH += Layout::spacingMd();
|
||||
if (card.value) blockH += valFont->LegacySize;
|
||||
if (card.subtitle) blockH += Layout::spacingSm() + subFont->LegacySize;
|
||||
|
||||
// 4. Vertically centre
|
||||
float topY = cMin.y + (cardH - blockH) * 0.5f;
|
||||
float padX = Layout::spacingLg();
|
||||
float cy = topY;
|
||||
|
||||
// 5. Overline
|
||||
if (card.overline) {
|
||||
ImVec2 sz = ovFont->CalcTextSizeA(ovFont->LegacySize, 10000, 0, card.overline);
|
||||
float tx = card.centered ? cMin.x + (cardW - sz.x) * 0.5f : cMin.x + padX;
|
||||
dl->AddText(ovFont, ovFont->LegacySize, ImVec2(tx, cy), OnSurfaceMedium(), card.overline);
|
||||
cy += ovFont->LegacySize + Layout::spacingMd();
|
||||
}
|
||||
|
||||
// 6. Value (with text shadow)
|
||||
if (card.value) {
|
||||
ImU32 col = card.valueCol ? card.valueCol : OnSurface();
|
||||
ImVec2 sz = valFont->CalcTextSizeA(valFont->LegacySize, 10000, 0, card.value);
|
||||
float tx = card.centered ? cMin.x + (cardW - sz.x) * 0.5f : cMin.x + padX;
|
||||
DrawTextShadow(dl, valFont, valFont->LegacySize, ImVec2(tx, cy), col, card.value);
|
||||
cy += valFont->LegacySize;
|
||||
}
|
||||
|
||||
// 7. Subtitle
|
||||
if (card.subtitle) {
|
||||
cy += Layout::spacingSm();
|
||||
ImVec2 sz = subFont->CalcTextSizeA(subFont->LegacySize, 10000, 0, card.subtitle);
|
||||
float tx = card.centered ? cMin.x + (cardW - sz.x) * 0.5f : cMin.x + padX;
|
||||
dl->AddText(subFont, subFont->LegacySize, ImVec2(tx, cy), OnSurfaceDisabled(), card.subtitle);
|
||||
}
|
||||
|
||||
// 8. Hover glow
|
||||
if (card.hovered) {
|
||||
dl->AddRect(cMin, cMax,
|
||||
schema::UI().resolveColor("var(--hover-overlay)", IM_COL32(255, 255, 255, 20)),
|
||||
rnd, 0, 1.5f);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Styled Button (font-only wrapper) ───────────────────────────────────
|
||||
// Drop-in replacement for ImGui::Button that pushes the Button font style
|
||||
// (from JSON config) without adding tactile visual effects.
|
||||
|
||||
inline bool StyledButton(const char* label, const ImVec2& size = ImVec2(0, 0), ImFont* font = nullptr)
|
||||
{
|
||||
ImGui::PushFont(font ? font : Type().button());
|
||||
bool pressed = ImGui::Button(label, size);
|
||||
ImGui::PopFont();
|
||||
return pressed;
|
||||
}
|
||||
|
||||
inline bool StyledSmallButton(const char* label, ImFont* font = nullptr)
|
||||
{
|
||||
ImGui::PushFont(font ? font : Type().button());
|
||||
bool pressed = ImGui::SmallButton(label);
|
||||
ImGui::PopFont();
|
||||
return pressed;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ButtonStyle-driven overloads (unified UI schema)
|
||||
// ============================================================================
|
||||
// These accept a ButtonStyle + ColorVarResolver to push font, colors,
|
||||
// and size from the schema. Existing call sites are unaffected.
|
||||
|
||||
inline bool StyledButton(const char* label, const schema::ButtonStyle& style,
|
||||
const schema::ColorVarResolver& colors)
|
||||
{
|
||||
// Resolve font
|
||||
ImFont* font = nullptr;
|
||||
if (!style.font.empty()) {
|
||||
font = Type().resolveByName(style.font);
|
||||
}
|
||||
ImGui::PushFont(font ? font : Type().button());
|
||||
|
||||
// Push color overrides
|
||||
int colorCount = 0;
|
||||
if (!style.colors.background.empty()) {
|
||||
ImGui::PushStyleColor(ImGuiCol_Button,
|
||||
colors.resolve(style.colors.background));
|
||||
colorCount++;
|
||||
}
|
||||
if (!style.colors.backgroundHover.empty()) {
|
||||
ImGui::PushStyleColor(ImGuiCol_ButtonHovered,
|
||||
colors.resolve(style.colors.backgroundHover));
|
||||
colorCount++;
|
||||
}
|
||||
if (!style.colors.backgroundActive.empty()) {
|
||||
ImGui::PushStyleColor(ImGuiCol_ButtonActive,
|
||||
colors.resolve(style.colors.backgroundActive));
|
||||
colorCount++;
|
||||
}
|
||||
if (!style.colors.color.empty()) {
|
||||
ImGui::PushStyleColor(ImGuiCol_Text,
|
||||
colors.resolve(style.colors.color));
|
||||
colorCount++;
|
||||
}
|
||||
|
||||
// Push style overrides
|
||||
int styleCount = 0;
|
||||
if (style.borderRadius >= 0) {
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, style.borderRadius);
|
||||
styleCount++;
|
||||
}
|
||||
|
||||
ImVec2 size(style.width > 0 ? style.width : 0,
|
||||
style.height > 0 ? style.height : 0);
|
||||
bool pressed = ImGui::Button(label, size);
|
||||
|
||||
ImGui::PopStyleVar(styleCount);
|
||||
ImGui::PopStyleColor(colorCount);
|
||||
ImGui::PopFont();
|
||||
return pressed;
|
||||
}
|
||||
|
||||
inline bool TactileButton(const char* label, const schema::ButtonStyle& style,
|
||||
const schema::ColorVarResolver& colors)
|
||||
{
|
||||
// Resolve font
|
||||
ImFont* font = nullptr;
|
||||
if (!style.font.empty()) {
|
||||
font = Type().resolveByName(style.font);
|
||||
}
|
||||
ImGui::PushFont(font ? font : Type().button());
|
||||
|
||||
// Push color overrides
|
||||
int colorCount = 0;
|
||||
if (!style.colors.background.empty()) {
|
||||
ImGui::PushStyleColor(ImGuiCol_Button,
|
||||
colors.resolve(style.colors.background));
|
||||
colorCount++;
|
||||
}
|
||||
if (!style.colors.backgroundHover.empty()) {
|
||||
ImGui::PushStyleColor(ImGuiCol_ButtonHovered,
|
||||
colors.resolve(style.colors.backgroundHover));
|
||||
colorCount++;
|
||||
}
|
||||
if (!style.colors.backgroundActive.empty()) {
|
||||
ImGui::PushStyleColor(ImGuiCol_ButtonActive,
|
||||
colors.resolve(style.colors.backgroundActive));
|
||||
colorCount++;
|
||||
}
|
||||
if (!style.colors.color.empty()) {
|
||||
ImGui::PushStyleColor(ImGuiCol_Text,
|
||||
colors.resolve(style.colors.color));
|
||||
colorCount++;
|
||||
}
|
||||
|
||||
// Push style overrides
|
||||
int styleCount = 0;
|
||||
if (style.borderRadius >= 0) {
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, style.borderRadius);
|
||||
styleCount++;
|
||||
}
|
||||
|
||||
ImDrawList* dl = ImGui::GetWindowDrawList();
|
||||
ImVec2 size(style.width > 0 ? style.width : 0,
|
||||
style.height > 0 ? style.height : 0);
|
||||
bool pressed = ImGui::Button(label, size);
|
||||
|
||||
// Glass overlay on the button rect
|
||||
ImVec2 bMin = ImGui::GetItemRectMin();
|
||||
ImVec2 bMax = ImGui::GetItemRectMax();
|
||||
float rounding = ImGui::GetStyle().FrameRounding;
|
||||
bool active = ImGui::IsItemActive();
|
||||
bool hovered = ImGui::IsItemHovered();
|
||||
if (!active) {
|
||||
ImU32 col = hovered
|
||||
? schema::UI().resolveColor("var(--hover-overlay)", IM_COL32(255, 255, 255, 12))
|
||||
: schema::UI().resolveColor("var(--glass-fill)", IM_COL32(255, 255, 255, 6));
|
||||
dl->AddRectFilled(bMin, bMax, col, rounding);
|
||||
}
|
||||
ImU32 rim = schema::UI().resolveColor("var(--rim-light)", IM_COL32(255, 255, 255, 25));
|
||||
dl->AddRect(bMin, bMax,
|
||||
active ? ScaleAlpha(rim, 0.6f) : rim,
|
||||
rounding, 0, 1.0f);
|
||||
DrawTactileOverlay(dl, bMin, bMax, rounding, active);
|
||||
|
||||
ImGui::PopStyleVar(styleCount);
|
||||
ImGui::PopStyleColor(colorCount);
|
||||
ImGui::PopFont();
|
||||
return pressed;
|
||||
}
|
||||
|
||||
} // namespace material
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
namespace material {
|
||||
|
||||
// ============================================================================
|
||||
// Scroll-edge clipping mask — CSS mask-image style vertex alpha fade.
|
||||
// Call ApplyScrollEdgeMask after EndChild() to fade content at the
|
||||
// top/bottom edges of a scrollable panel. Works by walking vertices
|
||||
// added during rendering and scaling their alpha based on distance
|
||||
// to the panel edges. No opaque overlay rectangles — content becomes
|
||||
// truly transparent, revealing whatever is behind the panel.
|
||||
//
|
||||
// Usage pattern:
|
||||
// float scrollY = 0, scrollMaxY = 0;
|
||||
// int parentVtx = parentDL->VtxBuffer.Size;
|
||||
// ImGui::BeginChild(...);
|
||||
// ImDrawList* childDL = ImGui::GetWindowDrawList();
|
||||
// int childVtx = childDL->VtxBuffer.Size;
|
||||
// { ... scrollY = GetScrollY(); scrollMaxY = GetScrollMaxY(); ... }
|
||||
// ImGui::EndChild();
|
||||
// ApplyScrollEdgeMask(parentDL, parentVtx, childDL, childVtx,
|
||||
// panelMin.y, panelMax.y, fadeZone, scrollY, scrollMaxY);
|
||||
// ============================================================================
|
||||
inline void ApplyScrollEdgeMask(ImDrawList* parentDL, int parentVtxStart,
|
||||
ImDrawList* childDL, int childVtxStart,
|
||||
float topEdge, float bottomEdge,
|
||||
float fadeZone,
|
||||
float scrollY, float scrollMaxY)
|
||||
{
|
||||
auto mask = [&](ImDrawList* dl, int startIdx) {
|
||||
for (int vi = startIdx; vi < dl->VtxBuffer.Size; vi++) {
|
||||
ImDrawVert& v = dl->VtxBuffer[vi];
|
||||
float alpha = 1.0f;
|
||||
|
||||
// Top fade — only when scrolled down
|
||||
if (scrollY > 1.0f) {
|
||||
float d = v.pos.y - topEdge;
|
||||
if (d < fadeZone)
|
||||
alpha = std::min(alpha, std::max(0.0f, d / fadeZone));
|
||||
}
|
||||
// Bottom fade — only when not at scroll bottom
|
||||
if (scrollMaxY > 0 && scrollY < scrollMaxY - 1.0f) {
|
||||
float d = bottomEdge - v.pos.y;
|
||||
if (d < fadeZone)
|
||||
alpha = std::min(alpha, std::max(0.0f, d / fadeZone));
|
||||
}
|
||||
|
||||
if (alpha < 1.0f) {
|
||||
int a = (v.col >> IM_COL32_A_SHIFT) & 0xFF;
|
||||
a = static_cast<int>(a * alpha);
|
||||
v.col = (v.col & ~IM_COL32_A_MASK) | (static_cast<ImU32>(a) << IM_COL32_A_SHIFT);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (parentDL) mask(parentDL, parentVtxStart);
|
||||
if (childDL) mask(childDL, childVtxStart);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Smooth scrolling — exponential decay interpolation for mouse wheel.
|
||||
// Call immediately after BeginChild() on a child that was created with
|
||||
// ImGuiWindowFlags_NoScrollWithMouse. Captures wheel input and lerps
|
||||
// scroll position each frame for a smooth feel.
|
||||
//
|
||||
// Usage:
|
||||
// ImGui::BeginChild("##List", size, false,
|
||||
// ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoScrollWithMouse);
|
||||
// ApplySmoothScroll();
|
||||
// ... render content ...
|
||||
// ImGui::EndChild();
|
||||
// ============================================================================
|
||||
|
||||
/// Returns true when any smooth-scroll child is still interpolating toward
|
||||
/// its target. Checked by the idle-rendering logic in main.cpp to keep
|
||||
/// rendering frames until the animation settles.
|
||||
inline bool& SmoothScrollAnimating() {
|
||||
static bool sAnimating = false;
|
||||
return sAnimating;
|
||||
}
|
||||
|
||||
inline void ApplySmoothScroll(float speed = 12.0f)
|
||||
{
|
||||
struct ScrollState {
|
||||
float target = 0.0f;
|
||||
float current = 0.0f;
|
||||
bool init = false;
|
||||
};
|
||||
static std::unordered_map<ImGuiID, ScrollState> sStates;
|
||||
|
||||
ImGuiWindow* win = ImGui::GetCurrentWindow();
|
||||
if (!win) return;
|
||||
|
||||
ScrollState& s = sStates[win->ID];
|
||||
float scrollMaxY = ImGui::GetScrollMaxY();
|
||||
|
||||
if (!s.init) {
|
||||
s.target = ImGui::GetScrollY();
|
||||
s.current = s.target;
|
||||
s.init = true;
|
||||
}
|
||||
|
||||
// If something external set scroll position (e.g. SetScrollHereY for
|
||||
// auto-scroll), sync our state so the next wheel-up starts from the
|
||||
// actual current position rather than an old remembered target.
|
||||
float actualY = ImGui::GetScrollY();
|
||||
if (std::abs(s.current - actualY) > 1.0f) {
|
||||
s.target = actualY;
|
||||
s.current = actualY;
|
||||
}
|
||||
|
||||
// Capture mouse wheel when hovered
|
||||
if (ImGui::IsWindowHovered(ImGuiHoveredFlags_ChildWindows)) {
|
||||
float wheel = ImGui::GetIO().MouseWheel;
|
||||
if (wheel != 0.0f) {
|
||||
float step = ImGui::GetTextLineHeightWithSpacing() * 3.0f;
|
||||
s.target -= wheel * step;
|
||||
s.target = ImClamp(s.target, 0.0f, scrollMaxY);
|
||||
}
|
||||
}
|
||||
|
||||
// Clamp target if scrollMax changed (e.g., content resized)
|
||||
if (s.target > scrollMaxY) s.target = scrollMaxY;
|
||||
|
||||
// Exponential decay lerp
|
||||
float dt = ImGui::GetIO().DeltaTime;
|
||||
s.current += (s.target - s.current) * (1.0f - expf(-speed * dt));
|
||||
|
||||
// Snap when close
|
||||
if (std::abs(s.current - s.target) < 0.5f)
|
||||
s.current = s.target;
|
||||
else
|
||||
SmoothScrollAnimating() = true; // still interpolating — keep rendering
|
||||
|
||||
ImGui::SetScrollY(s.current);
|
||||
}
|
||||
|
||||
} // namespace material
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
345
src/ui/material/elevation.h
Normal file
345
src/ui/material/elevation.h
Normal file
@@ -0,0 +1,345 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "colors.h"
|
||||
#include "../effects/low_spec.h"
|
||||
#include "../schema/ui_schema.h"
|
||||
#include "imgui.h"
|
||||
#include "imgui_internal.h"
|
||||
#include <cmath>
|
||||
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
namespace material {
|
||||
|
||||
// ============================================================================
|
||||
// Material Design Elevation and Shadow System
|
||||
// ============================================================================
|
||||
// Based on https://m2.material.io/design/environment/elevation.html
|
||||
//
|
||||
// Material Design uses two light sources to create shadows:
|
||||
// - Key light: Creates sharper, directional shadows
|
||||
// - Ambient light: Creates softer, omnidirectional shadows
|
||||
//
|
||||
// In dark themes, elevation is primarily shown through surface color overlays
|
||||
// rather than shadows. However, shadows can still enhance depth perception.
|
||||
|
||||
// ============================================================================
|
||||
// Shadow Specifications
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* @brief Individual shadow layer specification
|
||||
*
|
||||
* Material shadows are composed of multiple layers with different
|
||||
* blur radii and offsets to simulate real-world lighting.
|
||||
*/
|
||||
struct ShadowLayer {
|
||||
float offsetX; // Horizontal offset (typically 0)
|
||||
float offsetY; // Vertical offset (key light from above)
|
||||
float blurRadius; // Blur spread
|
||||
float spreadRadius; // Size adjustment
|
||||
float opacity; // Alpha 0.0-1.0
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Complete shadow specification for an elevation level
|
||||
*/
|
||||
struct ShadowSpec {
|
||||
ShadowLayer umbra; // Darkest part, sharp edge
|
||||
ShadowLayer penumbra; // Mid-tone, softer
|
||||
ShadowLayer ambient; // Lightest, most diffuse
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Get shadow specification for elevation level
|
||||
*
|
||||
* @param elevationDp Elevation in dp (0, 1, 2, 3, 4, 6, 8, 12, 16, 24)
|
||||
* @return ShadowSpec for the elevation
|
||||
*/
|
||||
ShadowSpec GetShadowSpec(int elevationDp);
|
||||
|
||||
// ============================================================================
|
||||
// Shadow Rendering
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* @brief Draw Material Design shadow for a rectangle
|
||||
*
|
||||
* Uses multi-layer soft shadow rendering to approximate Material shadows.
|
||||
*
|
||||
* @param drawList ImGui draw list
|
||||
* @param rect Rectangle bounds
|
||||
* @param elevationDp Elevation in dp
|
||||
* @param cornerRadius Corner radius for rounded rectangles
|
||||
*/
|
||||
void DrawShadow(ImDrawList* drawList, const ImRect& rect, int elevationDp, float cornerRadius = 0);
|
||||
|
||||
/**
|
||||
* @brief Draw shadow with position/size parameters
|
||||
*/
|
||||
void DrawShadow(ImDrawList* drawList, const ImVec2& pos, const ImVec2& size,
|
||||
int elevationDp, float cornerRadius = 0);
|
||||
|
||||
/**
|
||||
* @brief Draw soft shadow (single layer, for custom effects)
|
||||
*
|
||||
* @param drawList ImGui draw list
|
||||
* @param rect Rectangle bounds
|
||||
* @param color Shadow color with alpha
|
||||
* @param blurRadius Blur amount
|
||||
* @param offset Shadow offset
|
||||
* @param cornerRadius Corner radius
|
||||
*/
|
||||
void DrawSoftShadow(ImDrawList* drawList, const ImRect& rect, ImU32 color,
|
||||
float blurRadius, const ImVec2& offset = ImVec2(0, 0),
|
||||
float cornerRadius = 0);
|
||||
|
||||
// ============================================================================
|
||||
// Elevation Transition Helper
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* @brief Animated elevation value
|
||||
*
|
||||
* Use this to smoothly transition between elevation levels (e.g., card hover)
|
||||
*/
|
||||
class ElevationAnimator {
|
||||
public:
|
||||
ElevationAnimator(int initialElevation = 0);
|
||||
|
||||
/**
|
||||
* @brief Set target elevation (will animate towards it)
|
||||
*/
|
||||
void setTarget(int targetElevation);
|
||||
|
||||
/**
|
||||
* @brief Update animation (call each frame)
|
||||
* @param deltaTime Frame delta time
|
||||
*/
|
||||
void update(float deltaTime);
|
||||
|
||||
/**
|
||||
* @brief Get current animated elevation value
|
||||
*/
|
||||
float getCurrent() const { return m_current; }
|
||||
|
||||
/**
|
||||
* @brief Get current elevation as integer (for shadow lookup)
|
||||
*/
|
||||
int getCurrentInt() const { return static_cast<int>(m_current + 0.5f); }
|
||||
|
||||
/**
|
||||
* @brief Check if currently animating
|
||||
*/
|
||||
bool isAnimating() const { return m_current != m_target; }
|
||||
|
||||
private:
|
||||
float m_current;
|
||||
float m_target;
|
||||
float m_animationSpeed = 16.0f; // dp per second
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Implementation
|
||||
// ============================================================================
|
||||
|
||||
inline ShadowSpec GetShadowSpec(int elevationDp) {
|
||||
// Material Design shadow values adapted from the spec
|
||||
// These approximate the CSS box-shadow values from material.io
|
||||
|
||||
switch (elevationDp) {
|
||||
case 0:
|
||||
return {
|
||||
{0, 0, 0, 0, 0}, // No shadow
|
||||
{0, 0, 0, 0, 0},
|
||||
{0, 0, 0, 0, 0}
|
||||
};
|
||||
case 1:
|
||||
return {
|
||||
{0, 2, 1, -1, 0.2f}, // Umbra
|
||||
{0, 1, 1, 0, 0.14f}, // Penumbra
|
||||
{0, 1, 3, 0, 0.12f} // Ambient
|
||||
};
|
||||
case 2:
|
||||
return {
|
||||
{0, 3, 1, -2, 0.2f},
|
||||
{0, 2, 2, 0, 0.14f},
|
||||
{0, 1, 5, 0, 0.12f}
|
||||
};
|
||||
case 3:
|
||||
return {
|
||||
{0, 3, 3, -2, 0.2f},
|
||||
{0, 3, 4, 0, 0.14f},
|
||||
{0, 1, 8, 0, 0.12f}
|
||||
};
|
||||
case 4:
|
||||
return {
|
||||
{0, 2, 4, -1, 0.2f},
|
||||
{0, 4, 5, 0, 0.14f},
|
||||
{0, 1, 10, 0, 0.12f}
|
||||
};
|
||||
case 6:
|
||||
return {
|
||||
{0, 3, 5, -1, 0.2f},
|
||||
{0, 6, 10, 0, 0.14f},
|
||||
{0, 1, 18, 0, 0.12f}
|
||||
};
|
||||
case 8:
|
||||
return {
|
||||
{0, 5, 5, -3, 0.2f},
|
||||
{0, 8, 10, 1, 0.14f},
|
||||
{0, 3, 14, 2, 0.12f}
|
||||
};
|
||||
case 12:
|
||||
return {
|
||||
{0, 7, 8, -4, 0.2f},
|
||||
{0, 12, 17, 2, 0.14f},
|
||||
{0, 5, 22, 4, 0.12f}
|
||||
};
|
||||
case 16:
|
||||
return {
|
||||
{0, 8, 10, -5, 0.2f},
|
||||
{0, 16, 24, 2, 0.14f},
|
||||
{0, 6, 30, 5, 0.12f}
|
||||
};
|
||||
case 24:
|
||||
return {
|
||||
{0, 11, 15, -7, 0.2f},
|
||||
{0, 24, 38, 3, 0.14f},
|
||||
{0, 9, 46, 8, 0.12f}
|
||||
};
|
||||
default:
|
||||
// Interpolate for non-standard elevations
|
||||
if (elevationDp < 0) return GetShadowSpec(0);
|
||||
if (elevationDp > 24) return GetShadowSpec(24);
|
||||
|
||||
// Find nearest standard elevation
|
||||
int lower = 0, upper = 1;
|
||||
int standards[] = {0, 1, 2, 3, 4, 6, 8, 12, 16, 24};
|
||||
for (int i = 0; i < 9; i++) {
|
||||
if (standards[i] <= elevationDp && standards[i + 1] >= elevationDp) {
|
||||
lower = standards[i];
|
||||
upper = standards[i + 1];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Use nearest
|
||||
return GetShadowSpec((elevationDp - lower < upper - elevationDp) ? lower : upper);
|
||||
}
|
||||
}
|
||||
|
||||
inline void DrawSoftShadow(ImDrawList* drawList, const ImRect& rect, ImU32 color,
|
||||
float blurRadius, const ImVec2& offset, float cornerRadius) {
|
||||
if (blurRadius <= 0 || (color & IM_COL32_A_MASK) == 0)
|
||||
return;
|
||||
|
||||
// For ImGui, we'll simulate soft shadows using multiple semi-transparent layers
|
||||
// This is a performance-friendly approximation
|
||||
|
||||
// In low-spec mode use only 1 layer instead of up to 8
|
||||
const int numLayers = dragonx::ui::effects::isLowSpecMode()
|
||||
? 1
|
||||
: ImClamp((int)(blurRadius / 2), 2, 8);
|
||||
const float layerStep = blurRadius / numLayers;
|
||||
|
||||
// Extract base alpha
|
||||
float baseAlpha = ((color >> IM_COL32_A_SHIFT) & 0xFF) / 255.0f;
|
||||
ImU32 baseColor = color & ~IM_COL32_A_MASK;
|
||||
|
||||
for (int i = numLayers - 1; i >= 0; i--) {
|
||||
float expansion = layerStep * (i + 1);
|
||||
float alpha = baseAlpha * (1.0f - (float)i / numLayers) / numLayers;
|
||||
|
||||
ImU32 layerColor = baseColor | (((ImU32)(alpha * 255)) << IM_COL32_A_SHIFT);
|
||||
|
||||
ImRect expandedRect(
|
||||
rect.Min.x - expansion + offset.x,
|
||||
rect.Min.y - expansion + offset.y,
|
||||
rect.Max.x + expansion + offset.x,
|
||||
rect.Max.y + expansion + offset.y
|
||||
);
|
||||
|
||||
drawList->AddRectFilled(expandedRect.Min, expandedRect.Max, layerColor,
|
||||
cornerRadius + expansion * 0.5f);
|
||||
}
|
||||
}
|
||||
|
||||
inline void DrawShadow(ImDrawList* drawList, const ImRect& rect, int elevationDp, float cornerRadius) {
|
||||
if (elevationDp <= 0)
|
||||
return;
|
||||
|
||||
ShadowSpec spec = GetShadowSpec(elevationDp);
|
||||
|
||||
// Shadow multiplier: light themes need stronger shadows for card depth,
|
||||
// dark themes rely more on surface color overlay for elevation.
|
||||
// Configurable via ui.toml [style] shadow-multiplier / shadow-multiplier-light.
|
||||
const float shadowMultiplier = schema::UI().isDarkTheme()
|
||||
? schema::UI().drawElement("style", "shadow-multiplier").sizeOr(0.6f)
|
||||
: schema::UI().drawElement("style", "shadow-multiplier-light").sizeOr(1.0f);
|
||||
|
||||
// Draw ambient shadow (largest, most diffuse)
|
||||
if (spec.ambient.opacity > 0) {
|
||||
ImU32 ambientColor = IM_COL32(0, 0, 0, (int)(spec.ambient.opacity * shadowMultiplier * 255));
|
||||
ImRect ambientRect = rect;
|
||||
ambientRect.Expand(spec.ambient.spreadRadius);
|
||||
DrawSoftShadow(drawList, ambientRect, ambientColor, spec.ambient.blurRadius,
|
||||
ImVec2(spec.ambient.offsetX, spec.ambient.offsetY), cornerRadius);
|
||||
}
|
||||
|
||||
// Draw penumbra (medium)
|
||||
if (spec.penumbra.opacity > 0) {
|
||||
ImU32 penumbraColor = IM_COL32(0, 0, 0, (int)(spec.penumbra.opacity * shadowMultiplier * 255));
|
||||
ImRect penumbraRect = rect;
|
||||
penumbraRect.Expand(spec.penumbra.spreadRadius);
|
||||
DrawSoftShadow(drawList, penumbraRect, penumbraColor, spec.penumbra.blurRadius,
|
||||
ImVec2(spec.penumbra.offsetX, spec.penumbra.offsetY), cornerRadius);
|
||||
}
|
||||
|
||||
// Draw umbra (sharpest, darkest)
|
||||
if (spec.umbra.opacity > 0) {
|
||||
ImU32 umbraColor = IM_COL32(0, 0, 0, (int)(spec.umbra.opacity * shadowMultiplier * 255));
|
||||
ImRect umbraRect = rect;
|
||||
umbraRect.Expand(spec.umbra.spreadRadius);
|
||||
DrawSoftShadow(drawList, umbraRect, umbraColor, spec.umbra.blurRadius,
|
||||
ImVec2(spec.umbra.offsetX, spec.umbra.offsetY), cornerRadius);
|
||||
}
|
||||
}
|
||||
|
||||
inline void DrawShadow(ImDrawList* drawList, const ImVec2& pos, const ImVec2& size,
|
||||
int elevationDp, float cornerRadius) {
|
||||
ImRect rect(pos, ImVec2(pos.x + size.x, pos.y + size.y));
|
||||
DrawShadow(drawList, rect, elevationDp, cornerRadius);
|
||||
}
|
||||
|
||||
inline ElevationAnimator::ElevationAnimator(int initialElevation)
|
||||
: m_current(static_cast<float>(initialElevation))
|
||||
, m_target(static_cast<float>(initialElevation))
|
||||
{
|
||||
}
|
||||
|
||||
inline void ElevationAnimator::setTarget(int targetElevation) {
|
||||
m_target = static_cast<float>(targetElevation);
|
||||
}
|
||||
|
||||
inline void ElevationAnimator::update(float deltaTime) {
|
||||
if (m_current == m_target)
|
||||
return;
|
||||
|
||||
float diff = m_target - m_current;
|
||||
float change = m_animationSpeed * deltaTime;
|
||||
|
||||
if (std::abs(diff) <= change) {
|
||||
m_current = m_target;
|
||||
} else {
|
||||
m_current += (diff > 0 ? 1 : -1) * change;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace material
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
190
src/ui/material/gpu_mask.h
Normal file
190
src/ui/material/gpu_mask.h
Normal file
@@ -0,0 +1,190 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
//
|
||||
// GPU alpha mask — the ImGui equivalent of CSS mask-image: linear-gradient().
|
||||
// Uses AddCallback to switch the GPU blend mode so that gradient quads
|
||||
// multiply the framebuffer's alpha (and RGB) by the source alpha, producing
|
||||
// a smooth per-pixel fade without vertex-spacing artefacts.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "imgui.h"
|
||||
|
||||
#ifdef DRAGONX_USE_DX11
|
||||
#include <d3d11.h>
|
||||
#else
|
||||
#ifdef DRAGONX_HAS_GLAD
|
||||
#include <glad/gl.h>
|
||||
#else
|
||||
#include <SDL3/SDL_opengl.h>
|
||||
#endif
|
||||
#include <SDL3/SDL.h> // for SDL_GL_GetProcAddress
|
||||
#endif
|
||||
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
namespace material {
|
||||
|
||||
// ============================================================================
|
||||
// Blend-mode callbacks — called by ImGui's backend during draw list rendering
|
||||
// ============================================================================
|
||||
|
||||
#ifdef DRAGONX_USE_DX11
|
||||
|
||||
// Cached DX11 blend state for the mask pass
|
||||
inline ID3D11BlendState* GetMaskBlendState() {
|
||||
static ID3D11BlendState* s_maskBlend = nullptr;
|
||||
if (!s_maskBlend) {
|
||||
ImGuiIO& io = ImGui::GetIO();
|
||||
if (!io.BackendRendererUserData) return nullptr;
|
||||
ID3D11Device* dev = *reinterpret_cast<ID3D11Device**>(io.BackendRendererUserData);
|
||||
if (!dev) return nullptr;
|
||||
|
||||
D3D11_BLEND_DESC desc = {};
|
||||
desc.RenderTarget[0].BlendEnable = TRUE;
|
||||
desc.RenderTarget[0].SrcBlend = D3D11_BLEND_ZERO;
|
||||
desc.RenderTarget[0].DestBlend = D3D11_BLEND_SRC_ALPHA;
|
||||
desc.RenderTarget[0].BlendOp = D3D11_BLEND_OP_ADD;
|
||||
desc.RenderTarget[0].SrcBlendAlpha = D3D11_BLEND_ZERO;
|
||||
desc.RenderTarget[0].DestBlendAlpha = D3D11_BLEND_SRC_ALPHA;
|
||||
desc.RenderTarget[0].BlendOpAlpha = D3D11_BLEND_OP_ADD;
|
||||
desc.RenderTarget[0].RenderTargetWriteMask = D3D11_COLOR_WRITE_ENABLE_ALL;
|
||||
dev->CreateBlendState(&desc, &s_maskBlend);
|
||||
}
|
||||
return s_maskBlend;
|
||||
}
|
||||
|
||||
// Switch to mask blend: dst *= srcAlpha (both RGB and A)
|
||||
inline void MaskBlendCallback(const ImDrawList*, const ImDrawCmd*) {
|
||||
ImGuiIO& io = ImGui::GetIO();
|
||||
if (!io.BackendRendererUserData) return;
|
||||
// The ImGui DX11 backend stores the device as the first pointer
|
||||
ID3D11Device* dev = *reinterpret_cast<ID3D11Device**>(io.BackendRendererUserData);
|
||||
if (!dev) return;
|
||||
ID3D11DeviceContext* ctx = nullptr;
|
||||
dev->GetImmediateContext(&ctx);
|
||||
if (!ctx) return;
|
||||
|
||||
ID3D11BlendState* bs = GetMaskBlendState();
|
||||
if (bs) {
|
||||
float blendFactor[4] = {0, 0, 0, 0};
|
||||
ctx->OMSetBlendState(bs, blendFactor, 0xFFFFFFFF);
|
||||
}
|
||||
ctx->Release();
|
||||
}
|
||||
|
||||
// Restore normal ImGui blend: src*srcA + dst*(1-srcA)
|
||||
inline void RestoreBlendCallback(const ImDrawList*, const ImDrawCmd*) {
|
||||
ImGuiIO& io = ImGui::GetIO();
|
||||
if (!io.BackendRendererUserData) return;
|
||||
ID3D11Device* dev = *reinterpret_cast<ID3D11Device**>(io.BackendRendererUserData);
|
||||
if (!dev) return;
|
||||
ID3D11DeviceContext* ctx = nullptr;
|
||||
dev->GetImmediateContext(&ctx);
|
||||
if (!ctx) return;
|
||||
// Setting nullptr restores the default blend state that ImGui's DX11
|
||||
// backend configures at the start of each frame.
|
||||
ctx->OMSetBlendState(nullptr, nullptr, 0xFFFFFFFF);
|
||||
ctx->Release();
|
||||
}
|
||||
|
||||
#else // OpenGL
|
||||
|
||||
// glBlendFuncSeparate may not be in the GLAD profile — load it once via SDL.
|
||||
typedef void (*PFN_glBlendFuncSeparate)(GLenum, GLenum, GLenum, GLenum);
|
||||
inline PFN_glBlendFuncSeparate GetBlendFuncSeparate() {
|
||||
static PFN_glBlendFuncSeparate fn = nullptr;
|
||||
static bool resolved = false;
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
fn = (PFN_glBlendFuncSeparate)(void*)SDL_GL_GetProcAddress("glBlendFuncSeparate");
|
||||
}
|
||||
return fn;
|
||||
}
|
||||
|
||||
inline void MaskBlendCallback(const ImDrawList*, const ImDrawCmd*) {
|
||||
// dst.rgb = dst.rgb * srcAlpha (erase content where mask alpha < 1)
|
||||
// dst.a = dst.a * srcAlpha (match alpha channel)
|
||||
auto fn = GetBlendFuncSeparate();
|
||||
if (fn)
|
||||
fn(GL_ZERO, GL_SRC_ALPHA, GL_ZERO, GL_SRC_ALPHA);
|
||||
else
|
||||
glBlendFunc(GL_ZERO, GL_SRC_ALPHA);
|
||||
}
|
||||
|
||||
inline void RestoreBlendCallback(const ImDrawList*, const ImDrawCmd*) {
|
||||
// Restore ImGui's exact blend state:
|
||||
// RGB: src*srcA + dst*(1-srcA)
|
||||
// Alpha: src*1 + dst*(1-srcA)
|
||||
auto fn = GetBlendFuncSeparate();
|
||||
if (fn)
|
||||
fn(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA, GL_ONE, GL_ONE_MINUS_SRC_ALPHA);
|
||||
else
|
||||
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
|
||||
}
|
||||
|
||||
#endif
|
||||
|
||||
// ============================================================================
|
||||
// DrawScrollFadeMask — draw gradient quads that mask the top/bottom edges
|
||||
// of a scrollable child region, producing a smooth per-pixel fade.
|
||||
//
|
||||
// Call this on the child's draw list BEFORE EndChild().
|
||||
// The gradient quads use the mask blend mode to multiply the existing
|
||||
// framebuffer content by their alpha, so alpha=1 means "keep" and
|
||||
// alpha=0 means "erase to transparent/black".
|
||||
//
|
||||
// Parameters:
|
||||
// dl — the child window's draw list (ImGui::GetWindowDrawList())
|
||||
// clipMin/Max — the child window's visible area (screen coords)
|
||||
// fadeH — the height of the fade zone in pixels
|
||||
// scrollY — current scroll offset (ImGui::GetScrollY())
|
||||
// scrollMaxY — maximum scroll offset (ImGui::GetScrollMaxY())
|
||||
// ============================================================================
|
||||
inline void DrawScrollFadeMask(ImDrawList* dl,
|
||||
const ImVec2& clipMin, const ImVec2& clipMax,
|
||||
float fadeH,
|
||||
float scrollY, float scrollMaxY)
|
||||
{
|
||||
if (fadeH <= 0.0f) return;
|
||||
|
||||
bool needTop = scrollY > 1.0f;
|
||||
bool needBottom = scrollMaxY > 0 && scrollY < scrollMaxY - 1.0f;
|
||||
if (!needTop && !needBottom) return;
|
||||
|
||||
float left = clipMin.x;
|
||||
float right = clipMax.x;
|
||||
|
||||
// Switch to mask blend mode
|
||||
dl->AddCallback(MaskBlendCallback, nullptr);
|
||||
|
||||
if (needTop) {
|
||||
// Top gradient: alpha=0 at top edge (erase) → alpha=1 at top+fadeH (keep)
|
||||
ImVec2 tMin(left, clipMin.y);
|
||||
ImVec2 tMax(right, clipMin.y + fadeH);
|
||||
ImU32 transparent = IM_COL32(0, 0, 0, 0);
|
||||
ImU32 opaque = IM_COL32(0, 0, 0, 255);
|
||||
dl->AddRectFilledMultiColor(tMin, tMax,
|
||||
transparent, transparent, // top-left, top-right
|
||||
opaque, opaque); // bottom-left, bottom-right
|
||||
}
|
||||
|
||||
if (needBottom) {
|
||||
// Bottom gradient: alpha=1 at bottom-fadeH (keep) → alpha=0 at bottom (erase)
|
||||
ImVec2 bMin(left, clipMax.y - fadeH);
|
||||
ImVec2 bMax(right, clipMax.y);
|
||||
ImU32 opaque = IM_COL32(0, 0, 0, 255);
|
||||
ImU32 transparent = IM_COL32(0, 0, 0, 0);
|
||||
dl->AddRectFilledMultiColor(bMin, bMax,
|
||||
opaque, opaque, // top-left, top-right
|
||||
transparent, transparent); // bottom-left, bottom-right
|
||||
}
|
||||
|
||||
// Restore normal blend mode
|
||||
dl->AddCallback(RestoreBlendCallback, nullptr);
|
||||
}
|
||||
|
||||
} // namespace material
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
351
src/ui/material/layout.h
Normal file
351
src/ui/material/layout.h
Normal file
@@ -0,0 +1,351 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "imgui.h"
|
||||
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
namespace material {
|
||||
|
||||
// ============================================================================
|
||||
// Material Design 8dp Grid System
|
||||
// ============================================================================
|
||||
// All spacing in Material Design is based on an 8dp grid.
|
||||
// https://m2.material.io/design/layout/spacing-methods.html
|
||||
|
||||
constexpr float kGridUnit = 8.0f; // Base unit in dp (device-independent pixels)
|
||||
|
||||
// ============================================================================
|
||||
// Spacing Scale
|
||||
// ============================================================================
|
||||
// Use these values for all margins, padding, and gaps
|
||||
|
||||
namespace spacing {
|
||||
|
||||
constexpr float Xxs = 2.0f; // 0.25x - Very tight spacing
|
||||
constexpr float Xs = 4.0f; // 0.5x - Extra small
|
||||
constexpr float Sm = 8.0f; // 1x - Small (default minimum)
|
||||
constexpr float Md = 16.0f; // 2x - Medium (default standard)
|
||||
constexpr float Lg = 24.0f; // 3x - Large
|
||||
constexpr float Xl = 32.0f; // 4x - Extra large
|
||||
constexpr float Xxl = 40.0f; // 5x - Double extra large
|
||||
constexpr float Xxxl = 48.0f; // 6x - Triple extra large
|
||||
|
||||
// Multiplier helpers
|
||||
constexpr float Grid(float multiplier) { return kGridUnit * multiplier; }
|
||||
|
||||
} // namespace spacing
|
||||
|
||||
// ============================================================================
|
||||
// Component Dimensions
|
||||
// ============================================================================
|
||||
// Standard sizes for Material Design components
|
||||
|
||||
namespace size {
|
||||
|
||||
// Buttons
|
||||
constexpr float ButtonHeight = 36.0f;
|
||||
constexpr float ButtonMinWidth = 64.0f;
|
||||
constexpr float ButtonPaddingH = 16.0f;
|
||||
constexpr float ButtonPaddingV = 8.0f;
|
||||
constexpr float ButtonIconGap = 8.0f; // Gap between icon and label
|
||||
constexpr float ButtonCornerRadius = 4.0f;
|
||||
|
||||
// FAB (Floating Action Button)
|
||||
constexpr float FabSize = 56.0f;
|
||||
constexpr float FabMiniSize = 40.0f;
|
||||
constexpr float FabExtendedHeight = 48.0f;
|
||||
constexpr float FabExtendedPadding = 16.0f;
|
||||
constexpr float FabCornerRadius = 16.0f; // Half of mini size for circle
|
||||
|
||||
// Icons
|
||||
constexpr float IconSize = 24.0f;
|
||||
constexpr float IconSizeSm = 18.0f;
|
||||
constexpr float IconSizeLg = 36.0f;
|
||||
constexpr float IconButtonSize = 48.0f; // Touch target for icon buttons
|
||||
|
||||
// App Bar / Toolbar
|
||||
constexpr float AppBarHeight = 56.0f; // Mobile
|
||||
constexpr float AppBarHeightLarge = 64.0f; // Desktop
|
||||
constexpr float AppBarPadding = 16.0f;
|
||||
|
||||
// Navigation
|
||||
constexpr float NavDrawerWidth = 256.0f;
|
||||
constexpr float NavRailWidth = 72.0f;
|
||||
constexpr float NavItemHeight = 48.0f;
|
||||
constexpr float NavItemPadding = 12.0f;
|
||||
constexpr float NavSectionPadding = 8.0f;
|
||||
|
||||
// Cards
|
||||
constexpr float CardCornerRadius = 4.0f;
|
||||
constexpr float CardPadding = 16.0f;
|
||||
constexpr float CardElevation = 1.0f; // Default elevation in dp
|
||||
|
||||
// Dialogs
|
||||
constexpr float DialogMinWidth = 280.0f;
|
||||
constexpr float DialogMaxWidth = 560.0f;
|
||||
constexpr float DialogPadding = 24.0f;
|
||||
constexpr float DialogTitlePadding = 20.0f;
|
||||
constexpr float DialogActionGap = 8.0f;
|
||||
constexpr float DialogCornerRadius = 4.0f;
|
||||
|
||||
// Lists
|
||||
constexpr float ListItemHeight = 48.0f; // Single line
|
||||
constexpr float ListItemHeightTwoLine = 64.0f;
|
||||
constexpr float ListItemHeightThreeLine = 88.0f;
|
||||
constexpr float ListItemPaddingH = 16.0f;
|
||||
constexpr float ListAvatarSize = 40.0f;
|
||||
constexpr float ListIconMargin = 32.0f;
|
||||
|
||||
// Text Fields
|
||||
constexpr float TextFieldHeight = 56.0f;
|
||||
constexpr float TextFieldPadding = 16.0f;
|
||||
constexpr float TextFieldDenseHeight = 40.0f;
|
||||
constexpr float TextFieldCornerRadius = 4.0f;
|
||||
|
||||
// Chips
|
||||
constexpr float ChipHeight = 32.0f;
|
||||
constexpr float ChipPaddingH = 12.0f;
|
||||
constexpr float ChipCornerRadius = 16.0f;
|
||||
|
||||
// Tabs
|
||||
constexpr float TabHeight = 48.0f;
|
||||
constexpr float TabMinWidth = 90.0f;
|
||||
constexpr float TabMaxWidth = 360.0f;
|
||||
constexpr float TabPaddingH = 16.0f;
|
||||
constexpr float TabIndicatorHeight = 2.0f;
|
||||
|
||||
// Snackbar
|
||||
constexpr float SnackbarMinWidth = 288.0f;
|
||||
constexpr float SnackbarMaxWidth = 568.0f;
|
||||
constexpr float SnackbarHeight = 48.0f;
|
||||
constexpr float SnackbarPadding = 8.0f;
|
||||
constexpr float SnackbarMargin = 8.0f;
|
||||
|
||||
// Dividers
|
||||
constexpr float DividerThickness = 1.0f;
|
||||
constexpr float DividerInset = 16.0f; // For inset dividers
|
||||
|
||||
} // namespace size
|
||||
|
||||
// ============================================================================
|
||||
// Responsive Breakpoints
|
||||
// ============================================================================
|
||||
// Based on Material Design responsive layout grid
|
||||
|
||||
namespace breakpoint {
|
||||
|
||||
constexpr float Xs = 0.0f; // Extra small: 0-599dp
|
||||
constexpr float Sm = 600.0f; // Small: 600-904dp
|
||||
constexpr float Md = 905.0f; // Medium: 905-1239dp
|
||||
constexpr float Lg = 1240.0f; // Large: 1240-1439dp
|
||||
constexpr float Xl = 1440.0f; // Extra large: 1440dp+
|
||||
|
||||
// Returns the current breakpoint category
|
||||
enum class Category { Xs, Sm, Md, Lg, Xl };
|
||||
|
||||
inline Category GetCategory(float windowWidth) {
|
||||
if (windowWidth >= Xl) return Category::Xl;
|
||||
if (windowWidth >= Lg) return Category::Lg;
|
||||
if (windowWidth >= Md) return Category::Md;
|
||||
if (windowWidth >= Sm) return Category::Sm;
|
||||
return Category::Xs;
|
||||
}
|
||||
|
||||
// Margin values for each breakpoint
|
||||
inline float GetMargin(Category cat) {
|
||||
switch (cat) {
|
||||
case Category::Xs: return 16.0f;
|
||||
case Category::Sm: return 32.0f;
|
||||
case Category::Md: return 32.0f; // Or fluid centering
|
||||
case Category::Lg: return 200.0f;
|
||||
case Category::Xl: return 200.0f; // Or fluid centering
|
||||
}
|
||||
return 16.0f;
|
||||
}
|
||||
|
||||
// Column count for each breakpoint
|
||||
inline int GetColumns(Category cat) {
|
||||
switch (cat) {
|
||||
case Category::Xs: return 4;
|
||||
case Category::Sm: return 8;
|
||||
default: return 12;
|
||||
}
|
||||
}
|
||||
|
||||
// Recommended navigation style
|
||||
enum class NavStyle { BottomNav, NavRail, NavDrawer };
|
||||
|
||||
inline NavStyle GetNavStyle(Category cat) {
|
||||
switch (cat) {
|
||||
case Category::Xs: return NavStyle::BottomNav;
|
||||
case Category::Sm:
|
||||
case Category::Md: return NavStyle::NavRail;
|
||||
default: return NavStyle::NavDrawer;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace breakpoint
|
||||
|
||||
// ============================================================================
|
||||
// Layout Helpers
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* @brief Get current window's breakpoint category
|
||||
*/
|
||||
inline breakpoint::Category GetCurrentBreakpoint() {
|
||||
ImVec2 windowSize = ImGui::GetWindowSize();
|
||||
return breakpoint::GetCategory(windowSize.x);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Get recommended margin for current window
|
||||
*/
|
||||
inline float GetCurrentMargin() {
|
||||
return breakpoint::GetMargin(GetCurrentBreakpoint());
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Add horizontal spacing (uses 8dp grid)
|
||||
* @param units Number of 8dp units (default 1)
|
||||
*/
|
||||
inline void HSpace(float units = 1.0f) {
|
||||
ImGui::SameLine(0, kGridUnit * units);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Add vertical spacing (uses 8dp grid)
|
||||
* @param units Number of 8dp units (default 1)
|
||||
*/
|
||||
inline void VSpace(float units = 1.0f) {
|
||||
ImGui::Dummy(ImVec2(0, kGridUnit * units));
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Begin a padded region
|
||||
* @param padding Padding in pixels (use spacing:: constants)
|
||||
*/
|
||||
inline void BeginPadding(float padding) {
|
||||
ImGui::BeginGroup();
|
||||
ImGui::Dummy(ImVec2(0, padding));
|
||||
ImGui::Indent(padding);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief End a padded region
|
||||
* @param padding Same padding used in BeginPadding
|
||||
*/
|
||||
inline void EndPadding(float padding) {
|
||||
ImGui::Unindent(padding);
|
||||
ImGui::Dummy(ImVec2(0, padding));
|
||||
ImGui::EndGroup();
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Center the next item horizontally within available width
|
||||
* @param itemWidth Width of the item to center
|
||||
*/
|
||||
inline void CenterHorizontally(float itemWidth) {
|
||||
float availWidth = ImGui::GetContentRegionAvail().x;
|
||||
float offset = (availWidth - itemWidth) * 0.5f;
|
||||
if (offset > 0) {
|
||||
ImGui::SetCursorPosX(ImGui::GetCursorPosX() + offset);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Calculate column width for responsive grid
|
||||
* @param numColumns Number of columns to span
|
||||
* @param gutterWidth Gap between columns (default 16dp)
|
||||
*/
|
||||
inline float GetColumnWidth(int numColumns = 1, float gutterWidth = spacing::Md) {
|
||||
float availWidth = ImGui::GetContentRegionAvail().x;
|
||||
auto cat = GetCurrentBreakpoint();
|
||||
int totalColumns = breakpoint::GetColumns(cat);
|
||||
|
||||
float totalGutter = gutterWidth * (totalColumns - 1);
|
||||
float columnWidth = (availWidth - totalGutter) / totalColumns;
|
||||
|
||||
return columnWidth * numColumns + gutterWidth * (numColumns - 1);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Card Layout Helper
|
||||
// ============================================================================
|
||||
|
||||
struct CardLayout {
|
||||
float width;
|
||||
float minHeight;
|
||||
float padding;
|
||||
float cornerRadius;
|
||||
int elevation;
|
||||
|
||||
CardLayout()
|
||||
: width(0) // 0 = auto width
|
||||
, minHeight(0)
|
||||
, padding(size::CardPadding)
|
||||
, cornerRadius(size::CardCornerRadius)
|
||||
, elevation(1)
|
||||
{}
|
||||
|
||||
CardLayout& Width(float w) { width = w; return *this; }
|
||||
CardLayout& MinHeight(float h) { minHeight = h; return *this; }
|
||||
CardLayout& Padding(float p) { padding = p; return *this; }
|
||||
CardLayout& CornerRadius(float r) { cornerRadius = r; return *this; }
|
||||
CardLayout& Elevation(int e) { elevation = e; return *this; }
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Navigation Drawer Item
|
||||
// ============================================================================
|
||||
|
||||
struct NavItem {
|
||||
const char* label;
|
||||
const char* icon; // Icon font glyph or nullptr
|
||||
bool selected;
|
||||
bool enabled;
|
||||
|
||||
NavItem(const char* lbl, const char* ico = nullptr)
|
||||
: label(lbl), icon(ico), selected(false), enabled(true)
|
||||
{}
|
||||
|
||||
NavItem& Selected(bool s) { selected = s; return *this; }
|
||||
NavItem& Enabled(bool e) { enabled = e; return *this; }
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Alignment Helpers
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* @brief Align item to right edge of available space
|
||||
* @param itemWidth Width of the item
|
||||
*/
|
||||
inline void AlignRight(float itemWidth) {
|
||||
float availWidth = ImGui::GetContentRegionAvail().x;
|
||||
ImGui::SetCursorPosX(ImGui::GetCursorPosX() + availWidth - itemWidth);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Push items to start from right side (call before group of items)
|
||||
*/
|
||||
inline void BeginRightAlign() {
|
||||
ImGui::BeginGroup();
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief End right-aligned group and position it
|
||||
*/
|
||||
inline void EndRightAlign() {
|
||||
ImGui::EndGroup();
|
||||
float groupWidth = ImGui::GetItemRectSize().x;
|
||||
AlignRight(groupWidth);
|
||||
}
|
||||
|
||||
} // namespace material
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
160
src/ui/material/material.h
Normal file
160
src/ui/material/material.h
Normal file
@@ -0,0 +1,160 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
|
||||
#pragma once
|
||||
|
||||
// ============================================================================
|
||||
// Material Design 2 - Complete UI System
|
||||
// ============================================================================
|
||||
// Based on https://m2.material.io/design/foundation-overview
|
||||
//
|
||||
// This header provides the complete Material Design 2 implementation for
|
||||
// the DragonX Wallet ImGui interface.
|
||||
//
|
||||
// Namespace: dragonx::ui::material
|
||||
|
||||
// Foundation
|
||||
#include "color_theme.h" // ColorTheme struct, theme presets
|
||||
#include "colors.h" // Color accessor functions
|
||||
#include "typography.h" // Typography system, type scale
|
||||
#include "layout.h" // Spacing grid, breakpoints, sizes
|
||||
|
||||
// Effects
|
||||
#include "elevation.h" // Shadow rendering, elevation animation
|
||||
#include "ripple.h" // Touch ripple effect
|
||||
#include "draw_helpers.h" // DrawTextShadow, DrawGlassPanel
|
||||
|
||||
// Motion
|
||||
#include "motion.h" // Easing curves, AnimatedValue, StaggerAnimation
|
||||
#include "transitions.h" // View transitions, FadeTransition, ExpandableSection
|
||||
|
||||
// Layout
|
||||
#include "app_layout.h" // Application layout manager
|
||||
|
||||
// Components
|
||||
#include "components/components.h" // All Material components
|
||||
|
||||
// ============================================================================
|
||||
// Quick Start Guide
|
||||
// ============================================================================
|
||||
//
|
||||
// 1. INITIALIZATION
|
||||
// In your app startup, initialize the material system:
|
||||
//
|
||||
// ```cpp
|
||||
// using namespace dragonx::ui::material;
|
||||
//
|
||||
// // Initialize color theme (creates global theme)
|
||||
// SetDragonXTheme(); // or SetHushTheme() for HUSH variant
|
||||
//
|
||||
// // Initialize typography (load fonts)
|
||||
// Typography::instance().initialize(io);
|
||||
// ```
|
||||
//
|
||||
// 2. FRAME SETUP
|
||||
// At the start of each frame:
|
||||
//
|
||||
// ```cpp
|
||||
// // Update ripple animations
|
||||
// UpdateRipples();
|
||||
// ```
|
||||
//
|
||||
// 3. USING COLORS
|
||||
// Access theme colors with helper functions:
|
||||
//
|
||||
// ```cpp
|
||||
// ImU32 bg = Background(); // App background
|
||||
// ImU32 primary = Primary(); // Brand color
|
||||
// ImU32 cardBg = Surface(Elevation::Dp4); // Elevated surface
|
||||
// ImU32 text = OnSurface(); // Text on surfaces
|
||||
// ```
|
||||
//
|
||||
// 4. USING TYPOGRAPHY
|
||||
// Render text with the type scale:
|
||||
//
|
||||
// ```cpp
|
||||
// Typography::instance().text(TypeStyle::H6, "Section Title");
|
||||
// Typography::instance().text(TypeStyle::Body1, "Body text here...");
|
||||
// Typography::instance().textColored(TypeStyle::Caption, OnSurfaceMedium(), "Hint");
|
||||
// ```
|
||||
//
|
||||
// 5. USING COMPONENTS
|
||||
// Components follow Material Design patterns:
|
||||
//
|
||||
// ```cpp
|
||||
// // Buttons
|
||||
// if (ContainedButton("Send")) { ... }
|
||||
// if (OutlinedButton("Cancel")) { ... }
|
||||
// if (TextButton("Learn More")) { ... }
|
||||
//
|
||||
// // Cards
|
||||
// BeginCard(myCardSpec);
|
||||
// CardHeader("Card Title", "Subtitle");
|
||||
// CardContent("Card body content...");
|
||||
// CardActions();
|
||||
// TextButton("Action 1");
|
||||
// TextButton("Action 2");
|
||||
// CardActionsEnd();
|
||||
// EndCard();
|
||||
//
|
||||
// // Lists
|
||||
// BeginList("myList");
|
||||
// if (ListItem("Item 1")) { ... }
|
||||
// if (ListItem("Item 2", "Secondary text")) { ... }
|
||||
// ListDivider();
|
||||
// if (ListItem("Item 3")) { ... }
|
||||
// EndList();
|
||||
//
|
||||
// // Dialogs
|
||||
// static bool showDialog = false;
|
||||
// if (ContainedButton("Open Dialog")) showDialog = true;
|
||||
// int result = ConfirmDialog("confirm", &showDialog, "Confirm",
|
||||
// "Are you sure?", "Yes", "No");
|
||||
// ```
|
||||
//
|
||||
// 6. LAYOUT
|
||||
// Use the spacing system for consistent layouts:
|
||||
//
|
||||
// ```cpp
|
||||
// ImGui::Dummy(ImVec2(0, spacing::dp(2))); // 16dp vertical space
|
||||
// ImGui::SetCursorPosX(spacing::dp(3)); // 24dp indent
|
||||
// ```
|
||||
//
|
||||
// ============================================================================
|
||||
// Module Reference
|
||||
// ============================================================================
|
||||
//
|
||||
// COLORS (colors.h)
|
||||
// Primary(), PrimaryVariant(), PrimaryContainer()
|
||||
// Secondary(), SecondaryVariant()
|
||||
// Background(), Surface(elevation), SurfaceVariant()
|
||||
// OnPrimary(), OnSecondary(), OnBackground(), OnSurface()
|
||||
// OnSurfaceMedium(), OnSurfaceDisabled()
|
||||
// Error(), OnError()
|
||||
// StateHover(), StateFocus(), StatePressed(), StateSelected()
|
||||
//
|
||||
// TYPOGRAPHY (typography.h)
|
||||
// TypeStyle: H1-H6, Subtitle1-2, Body1-2, Button, Caption, Overline
|
||||
// Typography::text(style, text)
|
||||
// Typography::textColored(style, color, text)
|
||||
// Typography::textWrapped(style, text)
|
||||
// Typography::pushFont(style) / popFont()
|
||||
//
|
||||
// LAYOUT (layout.h)
|
||||
// spacing::dp(n) - n * 8dp
|
||||
// spacing::Unit - 8dp
|
||||
// size::TouchTarget - 48dp
|
||||
// size::ButtonHeight - 36dp
|
||||
// breakpoint::current() - Get current breakpoint
|
||||
//
|
||||
// ELEVATION (elevation.h)
|
||||
// DrawShadow(drawList, rect, elevationDp, cornerRadius)
|
||||
// ElevationAnimator - Smooth elevation transitions
|
||||
//
|
||||
// RIPPLE (ripple.h)
|
||||
// DrawRippleEffect(drawList, rect, id, cornerRadius, hovered, held)
|
||||
// UpdateRipples() - Call each frame
|
||||
//
|
||||
// COMPONENTS (components/components.h)
|
||||
// See components.h for full component reference
|
||||
452
src/ui/material/motion.h
Normal file
452
src/ui/material/motion.h
Normal file
@@ -0,0 +1,452 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "imgui.h"
|
||||
#include <cmath>
|
||||
#include <functional>
|
||||
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
namespace material {
|
||||
|
||||
// ============================================================================
|
||||
// Material Design Motion System
|
||||
// ============================================================================
|
||||
// Based on https://m2.material.io/design/motion/speed.html
|
||||
// and https://m2.material.io/design/motion/customization.html
|
||||
//
|
||||
// Material motion uses specific easing curves and durations to create
|
||||
// natural, responsive animations that feel connected to user input.
|
||||
|
||||
// ============================================================================
|
||||
// Standard Durations (in seconds)
|
||||
// ============================================================================
|
||||
|
||||
namespace duration {
|
||||
// Simple transitions (toggle, fade)
|
||||
constexpr float Instant = 0.0f;
|
||||
constexpr float VeryFast = 0.05f; // 50ms
|
||||
constexpr float Fast = 0.1f; // 100ms - simple toggles
|
||||
constexpr float Short = 0.15f; // 150ms
|
||||
|
||||
// Standard transitions
|
||||
constexpr float Medium = 0.2f; // 200ms - collapse, simple move
|
||||
constexpr float Standard = 0.25f; // 250ms - expand, standard
|
||||
constexpr float Long = 0.3f; // 300ms - large transforms
|
||||
|
||||
// Complex transitions
|
||||
constexpr float Complex = 0.375f; // 375ms
|
||||
constexpr float VeryLong = 0.5f; // 500ms - elaborate sequences
|
||||
|
||||
// Screen transitions
|
||||
constexpr float EnterScreen = 0.225f; // Entering screen
|
||||
constexpr float ExitScreen = 0.195f; // Leaving screen
|
||||
constexpr float ScreenChange = 0.3f; // Full screen transition
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Easing Curves
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* @brief Cubic bezier curve evaluation
|
||||
*
|
||||
* Evaluates a cubic bezier curve defined by control points (0,0), (x1,y1), (x2,y2), (1,1)
|
||||
*
|
||||
* @param t Progress 0.0-1.0
|
||||
* @param x1 First control point X
|
||||
* @param y1 First control point Y
|
||||
* @param x2 Second control point X
|
||||
* @param y2 Second control point Y
|
||||
* @return Eased value
|
||||
*/
|
||||
float CubicBezier(float t, float x1, float y1, float x2, float y2);
|
||||
|
||||
/**
|
||||
* @brief Standard easing - for objects moving between on-screen positions
|
||||
*
|
||||
* CSS: cubic-bezier(0.4, 0.0, 0.2, 1.0)
|
||||
* Starts quickly, slows down to rest
|
||||
*/
|
||||
float EaseStandard(float t);
|
||||
|
||||
/**
|
||||
* @brief Deceleration easing - for objects entering the screen
|
||||
*
|
||||
* CSS: cubic-bezier(0.0, 0.0, 0.2, 1.0)
|
||||
* Starts at full velocity, decelerates to rest
|
||||
*/
|
||||
float EaseDecelerate(float t);
|
||||
|
||||
/**
|
||||
* @brief Acceleration easing - for objects leaving the screen
|
||||
*
|
||||
* CSS: cubic-bezier(0.4, 0.0, 1.0, 1.0)
|
||||
* Accelerates from rest, exits at full speed
|
||||
*/
|
||||
float EaseAccelerate(float t);
|
||||
|
||||
/**
|
||||
* @brief Sharp easing - for objects that may return to screen
|
||||
*
|
||||
* CSS: cubic-bezier(0.4, 0.0, 0.6, 1.0)
|
||||
* Quicker than standard, maintains connection
|
||||
*/
|
||||
float EaseSharp(float t);
|
||||
|
||||
/**
|
||||
* @brief Linear interpolation (no easing)
|
||||
*/
|
||||
float EaseLinear(float t);
|
||||
|
||||
/**
|
||||
* @brief Overshoot easing - goes past target then settles
|
||||
*
|
||||
* Good for bouncy, playful animations
|
||||
*/
|
||||
float EaseOvershoot(float t, float overshoot = 1.70158f);
|
||||
|
||||
/**
|
||||
* @brief Elastic easing - springy oscillation
|
||||
*/
|
||||
float EaseElastic(float t);
|
||||
|
||||
// ============================================================================
|
||||
// Easing Function Type
|
||||
// ============================================================================
|
||||
|
||||
using EasingFunction = float(*)(float);
|
||||
|
||||
// ============================================================================
|
||||
// Animated Value
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* @brief Animated value with automatic interpolation
|
||||
*
|
||||
* Template class for smooth value transitions.
|
||||
*/
|
||||
template<typename T>
|
||||
class AnimatedValue {
|
||||
public:
|
||||
AnimatedValue(const T& initialValue = T())
|
||||
: m_current(initialValue)
|
||||
, m_target(initialValue)
|
||||
, m_start(initialValue)
|
||||
, m_duration(duration::Standard)
|
||||
, m_elapsed(0)
|
||||
, m_easingFunc(EaseStandard)
|
||||
, m_animating(false)
|
||||
{}
|
||||
|
||||
/**
|
||||
* @brief Set target value with animation
|
||||
*/
|
||||
void animateTo(const T& target, float dur = duration::Standard,
|
||||
EasingFunction easing = EaseStandard) {
|
||||
if (target == m_target && m_animating)
|
||||
return; // Already animating to this target
|
||||
|
||||
m_start = m_current;
|
||||
m_target = target;
|
||||
m_duration = dur;
|
||||
m_elapsed = 0;
|
||||
m_easingFunc = easing;
|
||||
m_animating = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Set value immediately (no animation)
|
||||
*/
|
||||
void set(const T& value) {
|
||||
m_current = value;
|
||||
m_target = value;
|
||||
m_start = value;
|
||||
m_animating = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Update animation (call each frame)
|
||||
* @param deltaTime Frame delta time in seconds
|
||||
*/
|
||||
void update(float deltaTime) {
|
||||
if (!m_animating)
|
||||
return;
|
||||
|
||||
m_elapsed += deltaTime;
|
||||
|
||||
if (m_elapsed >= m_duration) {
|
||||
m_current = m_target;
|
||||
m_animating = false;
|
||||
} else {
|
||||
float t = m_elapsed / m_duration;
|
||||
float eased = m_easingFunc(t);
|
||||
m_current = lerp(m_start, m_target, eased);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Get current value
|
||||
*/
|
||||
const T& get() const { return m_current; }
|
||||
|
||||
/**
|
||||
* @brief Get target value
|
||||
*/
|
||||
const T& getTarget() const { return m_target; }
|
||||
|
||||
/**
|
||||
* @brief Check if currently animating
|
||||
*/
|
||||
bool isAnimating() const { return m_animating; }
|
||||
|
||||
/**
|
||||
* @brief Get animation progress (0-1)
|
||||
*/
|
||||
float getProgress() const {
|
||||
if (!m_animating) return 1.0f;
|
||||
return m_elapsed / m_duration;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Implicit conversion to value type
|
||||
*/
|
||||
operator const T&() const { return m_current; }
|
||||
|
||||
private:
|
||||
T m_current;
|
||||
T m_target;
|
||||
T m_start;
|
||||
float m_duration;
|
||||
float m_elapsed;
|
||||
EasingFunction m_easingFunc;
|
||||
bool m_animating;
|
||||
|
||||
// Lerp specializations
|
||||
static T lerp(const T& a, const T& b, float t) {
|
||||
return a + (b - a) * t;
|
||||
}
|
||||
};
|
||||
|
||||
// Specialization for ImVec2
|
||||
template<>
|
||||
inline ImVec2 AnimatedValue<ImVec2>::lerp(const ImVec2& a, const ImVec2& b, float t) {
|
||||
return ImVec2(a.x + (b.x - a.x) * t, a.y + (b.y - a.y) * t);
|
||||
}
|
||||
|
||||
// Specialization for ImVec4/color
|
||||
template<>
|
||||
inline ImVec4 AnimatedValue<ImVec4>::lerp(const ImVec4& a, const ImVec4& b, float t) {
|
||||
return ImVec4(
|
||||
a.x + (b.x - a.x) * t,
|
||||
a.y + (b.y - a.y) * t,
|
||||
a.z + (b.z - a.z) * t,
|
||||
a.w + (b.w - a.w) * t
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Animation Sequencer
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* @brief Staggered animation for lists
|
||||
*
|
||||
* Creates staggered entrance animations for list items.
|
||||
*/
|
||||
class StaggerAnimation {
|
||||
public:
|
||||
StaggerAnimation(int itemCount, float staggerDelay = 0.05f,
|
||||
float itemDuration = duration::EnterScreen)
|
||||
: m_itemCount(itemCount)
|
||||
, m_staggerDelay(staggerDelay)
|
||||
, m_itemDuration(itemDuration)
|
||||
, m_elapsed(0)
|
||||
, m_running(false)
|
||||
{}
|
||||
|
||||
/**
|
||||
* @brief Start the stagger animation
|
||||
*/
|
||||
void start() {
|
||||
m_elapsed = 0;
|
||||
m_running = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Update animation
|
||||
*/
|
||||
void update(float deltaTime) {
|
||||
if (!m_running) return;
|
||||
m_elapsed += deltaTime;
|
||||
|
||||
// Check if all items have finished
|
||||
float totalDuration = m_staggerDelay * (m_itemCount - 1) + m_itemDuration;
|
||||
if (m_elapsed >= totalDuration) {
|
||||
m_running = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Get animation progress for a specific item
|
||||
*
|
||||
* @param itemIndex Item index (0-based)
|
||||
* @return Progress 0.0-1.0 (clamped)
|
||||
*/
|
||||
float getItemProgress(int itemIndex) const {
|
||||
if (!m_running && m_elapsed > 0)
|
||||
return 1.0f; // Animation complete
|
||||
if (itemIndex < 0 || itemIndex >= m_itemCount)
|
||||
return 0.0f;
|
||||
|
||||
float itemStart = m_staggerDelay * itemIndex;
|
||||
float itemElapsed = m_elapsed - itemStart;
|
||||
|
||||
if (itemElapsed <= 0) return 0.0f;
|
||||
if (itemElapsed >= m_itemDuration) return 1.0f;
|
||||
|
||||
return EaseDecelerate(itemElapsed / m_itemDuration);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Get eased alpha for item (for fade-in)
|
||||
*/
|
||||
float getItemAlpha(int itemIndex) const {
|
||||
return getItemProgress(itemIndex);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Get Y offset for item (for slide-in from bottom)
|
||||
*/
|
||||
float getItemYOffset(int itemIndex, float maxOffset = 20.0f) const {
|
||||
float progress = getItemProgress(itemIndex);
|
||||
return maxOffset * (1.0f - progress);
|
||||
}
|
||||
|
||||
bool isRunning() const { return m_running; }
|
||||
|
||||
private:
|
||||
int m_itemCount;
|
||||
float m_staggerDelay;
|
||||
float m_itemDuration;
|
||||
float m_elapsed;
|
||||
bool m_running;
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Container Transform
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* @brief Container transform animation state
|
||||
*
|
||||
* For hero-style transitions where a card expands into a full dialog/page.
|
||||
*/
|
||||
struct ContainerTransform {
|
||||
ImRect startRect; // Starting bounds (e.g., card)
|
||||
ImRect endRect; // Ending bounds (e.g., dialog)
|
||||
float progress; // 0 = start, 1 = end
|
||||
bool expanding; // Direction
|
||||
|
||||
ContainerTransform()
|
||||
: progress(0)
|
||||
, expanding(true)
|
||||
{}
|
||||
|
||||
/**
|
||||
* @brief Get interpolated bounds at current progress
|
||||
*/
|
||||
ImRect getCurrentRect() const {
|
||||
float t = expanding ? progress : (1.0f - progress);
|
||||
float eased = EaseStandard(t);
|
||||
|
||||
return ImRect(
|
||||
ImLerp(startRect.Min, endRect.Min, eased),
|
||||
ImLerp(startRect.Max, endRect.Max, eased)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Get corner radius (shrinks as container expands)
|
||||
*/
|
||||
float getCornerRadius(float startRadius, float endRadius) const {
|
||||
float t = expanding ? progress : (1.0f - progress);
|
||||
float eased = EaseStandard(t);
|
||||
return startRadius + (endRadius - startRadius) * eased;
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Implementation
|
||||
// ============================================================================
|
||||
|
||||
inline float CubicBezier(float t, float x1, float y1, float x2, float y2) {
|
||||
// Attempt to find t value for given x (Newton-Raphson approximation)
|
||||
// This is needed because CSS bezier curves are defined in terms of x
|
||||
|
||||
// For simplicity, we'll use a direct parametric approach
|
||||
// which is accurate enough for UI animations
|
||||
|
||||
float cx = 3.0f * x1;
|
||||
float bx = 3.0f * (x2 - x1) - cx;
|
||||
float ax = 1.0f - cx - bx;
|
||||
|
||||
float cy = 3.0f * y1;
|
||||
float by = 3.0f * (y2 - y1) - cy;
|
||||
float ay = 1.0f - cy - by;
|
||||
|
||||
// Sample y at parameter t
|
||||
// Note: This assumes t directly maps to time, which is an approximation
|
||||
// For more accuracy, we'd need to solve for the bezier parameter given x=t
|
||||
|
||||
float t2 = t * t;
|
||||
float t3 = t2 * t;
|
||||
|
||||
return ay * t3 + by * t2 + cy * t;
|
||||
}
|
||||
|
||||
inline float EaseStandard(float t) {
|
||||
// cubic-bezier(0.4, 0.0, 0.2, 1.0)
|
||||
return CubicBezier(t, 0.4f, 0.0f, 0.2f, 1.0f);
|
||||
}
|
||||
|
||||
inline float EaseDecelerate(float t) {
|
||||
// cubic-bezier(0.0, 0.0, 0.2, 1.0)
|
||||
return CubicBezier(t, 0.0f, 0.0f, 0.2f, 1.0f);
|
||||
}
|
||||
|
||||
inline float EaseAccelerate(float t) {
|
||||
// cubic-bezier(0.4, 0.0, 1.0, 1.0)
|
||||
return CubicBezier(t, 0.4f, 0.0f, 1.0f, 1.0f);
|
||||
}
|
||||
|
||||
inline float EaseSharp(float t) {
|
||||
// cubic-bezier(0.4, 0.0, 0.6, 1.0)
|
||||
return CubicBezier(t, 0.4f, 0.0f, 0.6f, 1.0f);
|
||||
}
|
||||
|
||||
inline float EaseLinear(float t) {
|
||||
return t;
|
||||
}
|
||||
|
||||
inline float EaseOvershoot(float t, float overshoot) {
|
||||
// Back ease out
|
||||
t = t - 1.0f;
|
||||
return t * t * ((overshoot + 1.0f) * t + overshoot) + 1.0f;
|
||||
}
|
||||
|
||||
inline float EaseElastic(float t) {
|
||||
if (t == 0.0f || t == 1.0f) return t;
|
||||
|
||||
float p = 0.3f;
|
||||
float s = p / 4.0f;
|
||||
|
||||
return std::pow(2.0f, -10.0f * t) * std::sin((t - s) * (2.0f * IM_PI) / p) + 1.0f;
|
||||
}
|
||||
|
||||
} // namespace material
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
290
src/ui/material/ripple.h
Normal file
290
src/ui/material/ripple.h
Normal file
@@ -0,0 +1,290 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "colors.h"
|
||||
#include "../effects/low_spec.h"
|
||||
#include "imgui.h"
|
||||
#include "imgui_internal.h"
|
||||
#include <vector>
|
||||
#include <cmath>
|
||||
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
namespace material {
|
||||
|
||||
// ============================================================================
|
||||
// Material Design Ripple Effect
|
||||
// ============================================================================
|
||||
// Based on https://m2.material.io/design/motion/customization.html
|
||||
//
|
||||
// Ripple effects provide visual feedback when users touch interactive elements.
|
||||
// The ripple emanates from the touch point and expands outward.
|
||||
|
||||
/**
|
||||
* @brief Individual ripple instance
|
||||
*/
|
||||
struct Ripple {
|
||||
ImVec2 center; // Ripple center point
|
||||
float startTime; // When ripple started
|
||||
float maxRadius; // Maximum expansion radius
|
||||
ImU32 color; // Ripple color
|
||||
bool releasing; // True when finger lifted
|
||||
float releaseTime; // When release started
|
||||
|
||||
Ripple(const ImVec2& center, float maxRadius, ImU32 color, float currentTime)
|
||||
: center(center)
|
||||
, startTime(currentTime)
|
||||
, maxRadius(maxRadius)
|
||||
, color(color)
|
||||
, releasing(false)
|
||||
, releaseTime(0)
|
||||
{}
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Ripple effect manager
|
||||
*
|
||||
* Manages ripple animations for interactive elements.
|
||||
*/
|
||||
class RippleManager {
|
||||
public:
|
||||
static RippleManager& instance();
|
||||
|
||||
/**
|
||||
* @brief Start a new ripple
|
||||
*
|
||||
* @param id Widget ID
|
||||
* @param center Ripple center (touch point)
|
||||
* @param maxRadius Maximum ripple radius
|
||||
* @param color Ripple color (typically primary with low alpha)
|
||||
*/
|
||||
void startRipple(ImGuiID id, const ImVec2& center, float maxRadius, ImU32 color);
|
||||
|
||||
/**
|
||||
* @brief Release current ripple (finger lifted)
|
||||
*/
|
||||
void releaseRipple(ImGuiID id);
|
||||
|
||||
/**
|
||||
* @brief Draw ripple effect for a widget
|
||||
*
|
||||
* @param drawList Draw list to use
|
||||
* @param id Widget ID
|
||||
* @param clipRect Clip rectangle for the ripple
|
||||
* @param cornerRadius Corner radius for clip shape
|
||||
*/
|
||||
void drawRipple(ImDrawList* drawList, ImGuiID id, const ImRect& clipRect,
|
||||
float cornerRadius = 0);
|
||||
|
||||
/**
|
||||
* @brief Update all ripples (call once per frame)
|
||||
*/
|
||||
void update();
|
||||
|
||||
/**
|
||||
* @brief Clear all ripples
|
||||
*/
|
||||
void clear();
|
||||
|
||||
private:
|
||||
RippleManager() = default;
|
||||
|
||||
struct RippleEntry {
|
||||
ImGuiID id;
|
||||
Ripple ripple;
|
||||
};
|
||||
|
||||
std::vector<RippleEntry> m_ripples;
|
||||
|
||||
static constexpr float kExpandDuration = 0.3f; // 300ms to full size
|
||||
static constexpr float kFadeDuration = 0.2f; // 200ms fade out
|
||||
static constexpr float kMaxOpacity = 0.12f; // 12% max opacity
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Convenience Functions
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* @brief Draw ripple effect for a button/interactive element
|
||||
*
|
||||
* Call this after drawing the element background but before text/content.
|
||||
*
|
||||
* @param drawList Draw list
|
||||
* @param rect Element bounds
|
||||
* @param id Element ID
|
||||
* @param cornerRadius Corner radius
|
||||
* @param hovered Is element hovered
|
||||
* @param held Is element being pressed
|
||||
* @param color Optional ripple color (default: white for dark theme)
|
||||
*/
|
||||
void DrawRippleEffect(ImDrawList* drawList, const ImRect& rect, ImGuiID id,
|
||||
float cornerRadius = 0, bool hovered = false, bool held = false,
|
||||
ImU32 color = IM_COL32(255, 255, 255, 255));
|
||||
|
||||
/**
|
||||
* @brief Update ripple system (call once per frame in main loop)
|
||||
*/
|
||||
void UpdateRipples();
|
||||
|
||||
// ============================================================================
|
||||
// Implementation
|
||||
// ============================================================================
|
||||
|
||||
inline RippleManager& RippleManager::instance() {
|
||||
static RippleManager s_instance;
|
||||
return s_instance;
|
||||
}
|
||||
|
||||
inline void RippleManager::startRipple(ImGuiID id, const ImVec2& center,
|
||||
float maxRadius, ImU32 color) {
|
||||
float currentTime = (float)ImGui::GetTime();
|
||||
|
||||
// Check if ripple already exists for this ID
|
||||
for (auto& entry : m_ripples) {
|
||||
if (entry.id == id) {
|
||||
// Reset existing ripple
|
||||
entry.ripple = Ripple(center, maxRadius, color, currentTime);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Add new ripple
|
||||
m_ripples.push_back({id, Ripple(center, maxRadius, color, currentTime)});
|
||||
}
|
||||
|
||||
inline void RippleManager::releaseRipple(ImGuiID id) {
|
||||
float currentTime = (float)ImGui::GetTime();
|
||||
|
||||
for (auto& entry : m_ripples) {
|
||||
if (entry.id == id && !entry.ripple.releasing) {
|
||||
entry.ripple.releasing = true;
|
||||
entry.ripple.releaseTime = currentTime;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
inline void RippleManager::drawRipple(ImDrawList* drawList, ImGuiID id,
|
||||
const ImRect& clipRect, float cornerRadius) {
|
||||
float currentTime = (float)ImGui::GetTime();
|
||||
|
||||
for (const auto& entry : m_ripples) {
|
||||
if (entry.id != id)
|
||||
continue;
|
||||
|
||||
const Ripple& ripple = entry.ripple;
|
||||
|
||||
// Calculate expansion progress
|
||||
float expandProgress = (currentTime - ripple.startTime) / kExpandDuration;
|
||||
expandProgress = ImClamp(expandProgress, 0.0f, 1.0f);
|
||||
|
||||
// Deceleration easing for expansion
|
||||
expandProgress = 1.0f - (1.0f - expandProgress) * (1.0f - expandProgress);
|
||||
|
||||
float currentRadius = ripple.maxRadius * expandProgress;
|
||||
|
||||
// Calculate opacity
|
||||
float opacity = kMaxOpacity;
|
||||
|
||||
if (ripple.releasing) {
|
||||
float fadeProgress = (currentTime - ripple.releaseTime) / kFadeDuration;
|
||||
fadeProgress = ImClamp(fadeProgress, 0.0f, 1.0f);
|
||||
opacity *= (1.0f - fadeProgress);
|
||||
}
|
||||
|
||||
if (opacity <= 0.001f)
|
||||
continue;
|
||||
|
||||
// Extract color components
|
||||
float r = ((ripple.color >> IM_COL32_R_SHIFT) & 0xFF) / 255.0f;
|
||||
float g = ((ripple.color >> IM_COL32_G_SHIFT) & 0xFF) / 255.0f;
|
||||
float b = ((ripple.color >> IM_COL32_B_SHIFT) & 0xFF) / 255.0f;
|
||||
ImU32 rippleColor = ImGui::ColorConvertFloat4ToU32(ImVec4(r, g, b, opacity));
|
||||
|
||||
// Push clip rect for rounded corners
|
||||
if (cornerRadius > 0) {
|
||||
// For rounded rectangles, we approximate with the clip rect
|
||||
// A perfect solution would require custom clipping
|
||||
drawList->PushClipRect(clipRect.Min, clipRect.Max, true);
|
||||
}
|
||||
|
||||
// Draw ripple circle (skip expanding animation in low-spec mode)
|
||||
if (!dragonx::ui::effects::isLowSpecMode())
|
||||
drawList->AddCircleFilled(ripple.center, currentRadius, rippleColor, 32);
|
||||
|
||||
if (cornerRadius > 0) {
|
||||
drawList->PopClipRect();
|
||||
}
|
||||
|
||||
break; // Only one ripple per ID
|
||||
}
|
||||
}
|
||||
|
||||
inline void RippleManager::update() {
|
||||
float currentTime = (float)ImGui::GetTime();
|
||||
|
||||
// Remove completed ripples
|
||||
m_ripples.erase(
|
||||
std::remove_if(m_ripples.begin(), m_ripples.end(),
|
||||
[currentTime](const RippleEntry& entry) {
|
||||
if (!entry.ripple.releasing)
|
||||
return false;
|
||||
float fadeProgress = (currentTime - entry.ripple.releaseTime) / kFadeDuration;
|
||||
return fadeProgress >= 1.0f;
|
||||
}
|
||||
),
|
||||
m_ripples.end()
|
||||
);
|
||||
}
|
||||
|
||||
inline void RippleManager::clear() {
|
||||
m_ripples.clear();
|
||||
}
|
||||
|
||||
inline void DrawRippleEffect(ImDrawList* drawList, const ImRect& rect, ImGuiID id,
|
||||
float cornerRadius, bool hovered, bool held, ImU32 color) {
|
||||
RippleManager& manager = RippleManager::instance();
|
||||
|
||||
// Start ripple on press
|
||||
if (held && ImGui::IsMouseClicked(0)) {
|
||||
ImVec2 mousePos = ImGui::GetIO().MousePos;
|
||||
// Calculate max radius (distance from click to farthest corner)
|
||||
float dx1 = mousePos.x - rect.Min.x;
|
||||
float dy1 = mousePos.y - rect.Min.y;
|
||||
float dx2 = rect.Max.x - mousePos.x;
|
||||
float dy2 = rect.Max.y - mousePos.y;
|
||||
float maxDx = ImMax(dx1, dx2);
|
||||
float maxDy = ImMax(dy1, dy2);
|
||||
float maxRadius = std::sqrt(maxDx * maxDx + maxDy * maxDy) * 1.2f;
|
||||
|
||||
manager.startRipple(id, mousePos, maxRadius, color);
|
||||
}
|
||||
|
||||
// Release ripple when mouse released
|
||||
if (!held && !ImGui::IsMouseDown(0)) {
|
||||
manager.releaseRipple(id);
|
||||
}
|
||||
|
||||
// Draw the ripple
|
||||
manager.drawRipple(drawList, id, rect, cornerRadius);
|
||||
|
||||
// Also draw hover state overlay
|
||||
if (hovered && !held) {
|
||||
float r = ((color >> IM_COL32_R_SHIFT) & 0xFF) / 255.0f;
|
||||
float g = ((color >> IM_COL32_G_SHIFT) & 0xFF) / 255.0f;
|
||||
float b = ((color >> IM_COL32_B_SHIFT) & 0xFF) / 255.0f;
|
||||
ImU32 hoverColor = ImGui::ColorConvertFloat4ToU32(ImVec4(r, g, b, 0.04f)); // 4% overlay
|
||||
drawList->AddRectFilled(rect.Min, rect.Max, hoverColor, cornerRadius);
|
||||
}
|
||||
}
|
||||
|
||||
inline void UpdateRipples() {
|
||||
RippleManager::instance().update();
|
||||
}
|
||||
|
||||
} // namespace material
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
467
src/ui/material/transitions.h
Normal file
467
src/ui/material/transitions.h
Normal file
@@ -0,0 +1,467 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "motion.h"
|
||||
#include "colors.h"
|
||||
#include "imgui.h"
|
||||
#include "imgui_internal.h"
|
||||
#include <functional>
|
||||
#include <string>
|
||||
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
namespace material {
|
||||
|
||||
// ============================================================================
|
||||
// Material Design Transitions
|
||||
// ============================================================================
|
||||
// Based on https://m2.material.io/design/motion/the-motion-system.html
|
||||
//
|
||||
// Transition patterns for navigating between views and states.
|
||||
|
||||
// ============================================================================
|
||||
// Transition Types
|
||||
// ============================================================================
|
||||
|
||||
enum class TransitionType {
|
||||
None, // Instant switch
|
||||
Fade, // Cross-fade
|
||||
SlideLeft, // Slide from right to left (forward navigation)
|
||||
SlideRight, // Slide from left to right (back navigation)
|
||||
SlideUp, // Slide from bottom (modal entry)
|
||||
SlideDown, // Slide to bottom (modal exit)
|
||||
Scale, // Scale up/down
|
||||
SharedAxis, // Material shared axis transition
|
||||
ContainerTransform // Card to full-screen
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// View Transition Manager
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* @brief Manages transitions between views/screens
|
||||
*/
|
||||
class TransitionManager {
|
||||
public:
|
||||
static TransitionManager& instance();
|
||||
|
||||
/**
|
||||
* @brief Start a transition to a new view
|
||||
*
|
||||
* @param newViewId Identifier for the new view
|
||||
* @param type Transition type
|
||||
* @param duration Transition duration (default from motion.h)
|
||||
*/
|
||||
void transitionTo(const std::string& newViewId,
|
||||
TransitionType type = TransitionType::Fade,
|
||||
float duration = duration::ScreenChange);
|
||||
|
||||
/**
|
||||
* @brief Go back with reverse transition
|
||||
*/
|
||||
void goBack(TransitionType type = TransitionType::SlideRight,
|
||||
float duration = duration::ExitScreen);
|
||||
|
||||
/**
|
||||
* @brief Update transition state (call each frame)
|
||||
*/
|
||||
void update(float deltaTime);
|
||||
|
||||
/**
|
||||
* @brief Get current view ID
|
||||
*/
|
||||
const std::string& getCurrentView() const { return m_currentView; }
|
||||
|
||||
/**
|
||||
* @brief Get previous view ID (during transition)
|
||||
*/
|
||||
const std::string& getPreviousView() const { return m_previousView; }
|
||||
|
||||
/**
|
||||
* @brief Check if transition is in progress
|
||||
*/
|
||||
bool isTransitioning() const { return m_transitioning; }
|
||||
|
||||
/**
|
||||
* @brief Get transition progress (0-1)
|
||||
*/
|
||||
float getProgress() const { return m_progress; }
|
||||
|
||||
/**
|
||||
* @brief Get current transition type
|
||||
*/
|
||||
TransitionType getType() const { return m_type; }
|
||||
|
||||
/**
|
||||
* @brief Apply transition effect to outgoing content
|
||||
*
|
||||
* Call before rendering the previous view content.
|
||||
* Returns false if outgoing content should be skipped.
|
||||
*/
|
||||
bool beginOutgoingTransition();
|
||||
|
||||
/**
|
||||
* @brief End outgoing content transition
|
||||
*/
|
||||
void endOutgoingTransition();
|
||||
|
||||
/**
|
||||
* @brief Apply transition effect to incoming content
|
||||
*
|
||||
* Call before rendering the new view content.
|
||||
* Returns false if incoming content should be skipped.
|
||||
*/
|
||||
bool beginIncomingTransition();
|
||||
|
||||
/**
|
||||
* @brief End incoming content transition
|
||||
*/
|
||||
void endIncomingTransition();
|
||||
|
||||
/**
|
||||
* @brief Get alpha for fading effects
|
||||
*/
|
||||
float getOutgoingAlpha() const;
|
||||
float getIncomingAlpha() const;
|
||||
|
||||
/**
|
||||
* @brief Get offset for sliding effects
|
||||
*/
|
||||
ImVec2 getOutgoingOffset() const;
|
||||
ImVec2 getIncomingOffset() const;
|
||||
|
||||
private:
|
||||
TransitionManager() = default;
|
||||
|
||||
std::string m_currentView;
|
||||
std::string m_previousView;
|
||||
TransitionType m_type = TransitionType::None;
|
||||
float m_duration = 0;
|
||||
float m_elapsed = 0;
|
||||
float m_progress = 0;
|
||||
bool m_transitioning = false;
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Fade Transition Helper
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* @brief Simple fade transition between two states
|
||||
*/
|
||||
class FadeTransition {
|
||||
public:
|
||||
FadeTransition() : m_alpha(1.0f) {}
|
||||
|
||||
/**
|
||||
* @brief Fade out
|
||||
*/
|
||||
void fadeOut(float duration = duration::Fast) {
|
||||
m_alpha.animateTo(0.0f, duration, EaseAccelerate);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Fade in
|
||||
*/
|
||||
void fadeIn(float duration = duration::Fast) {
|
||||
m_alpha.animateTo(1.0f, duration, EaseDecelerate);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Update (call each frame)
|
||||
*/
|
||||
void update(float deltaTime) {
|
||||
m_alpha.update(deltaTime);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Get current alpha
|
||||
*/
|
||||
float getAlpha() const { return m_alpha.get(); }
|
||||
|
||||
/**
|
||||
* @brief Check if visible (alpha > 0)
|
||||
*/
|
||||
bool isVisible() const { return m_alpha.get() > 0.001f; }
|
||||
|
||||
/**
|
||||
* @brief Check if animating
|
||||
*/
|
||||
bool isAnimating() const { return m_alpha.isAnimating(); }
|
||||
|
||||
/**
|
||||
* @brief Push alpha to ImGui
|
||||
*/
|
||||
void pushAlpha() const {
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_Alpha, m_alpha.get());
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Pop alpha from ImGui
|
||||
*/
|
||||
void popAlpha() const {
|
||||
ImGui::PopStyleVar();
|
||||
}
|
||||
|
||||
private:
|
||||
AnimatedValue<float> m_alpha;
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Expandable/Collapsible Section
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* @brief Animated expandable section
|
||||
*/
|
||||
class ExpandableSection {
|
||||
public:
|
||||
ExpandableSection(bool initiallyExpanded = false)
|
||||
: m_expanded(initiallyExpanded)
|
||||
, m_height(initiallyExpanded ? 1.0f : 0.0f)
|
||||
{}
|
||||
|
||||
/**
|
||||
* @brief Toggle expansion state
|
||||
*/
|
||||
void toggle() {
|
||||
setExpanded(!m_expanded);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Set expansion state
|
||||
*/
|
||||
void setExpanded(bool expanded) {
|
||||
m_expanded = expanded;
|
||||
m_height.animateTo(expanded ? 1.0f : 0.0f,
|
||||
expanded ? duration::Standard : duration::Medium,
|
||||
EaseStandard);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Update animation
|
||||
*/
|
||||
void update(float deltaTime) {
|
||||
m_height.update(deltaTime);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Check if expanded
|
||||
*/
|
||||
bool isExpanded() const { return m_expanded; }
|
||||
|
||||
/**
|
||||
* @brief Get height multiplier (0-1)
|
||||
*/
|
||||
float getHeightMultiplier() const { return m_height.get(); }
|
||||
|
||||
/**
|
||||
* @brief Check if animating
|
||||
*/
|
||||
bool isAnimating() const { return m_height.isAnimating(); }
|
||||
|
||||
/**
|
||||
* @brief Begin expandable content (applies clipping)
|
||||
*
|
||||
* @param maxHeight Maximum content height when fully expanded
|
||||
* @return true if content should be rendered
|
||||
*/
|
||||
bool beginContent(float maxHeight) {
|
||||
float currentHeight = maxHeight * m_height.get();
|
||||
|
||||
if (currentHeight < 1.0f)
|
||||
return false;
|
||||
|
||||
ImGui::BeginChild("##expandable", ImVec2(0, currentHeight), false,
|
||||
ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief End expandable content
|
||||
*/
|
||||
void endContent() {
|
||||
ImGui::EndChild();
|
||||
}
|
||||
|
||||
private:
|
||||
bool m_expanded;
|
||||
AnimatedValue<float> m_height;
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Implementation
|
||||
// ============================================================================
|
||||
|
||||
inline TransitionManager& TransitionManager::instance() {
|
||||
static TransitionManager s_instance;
|
||||
return s_instance;
|
||||
}
|
||||
|
||||
inline void TransitionManager::transitionTo(const std::string& newViewId,
|
||||
TransitionType type, float duration) {
|
||||
if (m_transitioning)
|
||||
return; // Don't interrupt ongoing transition
|
||||
|
||||
m_previousView = m_currentView;
|
||||
m_currentView = newViewId;
|
||||
m_type = type;
|
||||
m_duration = duration;
|
||||
m_elapsed = 0;
|
||||
m_progress = 0;
|
||||
m_transitioning = true;
|
||||
}
|
||||
|
||||
inline void TransitionManager::goBack(TransitionType type, float duration) {
|
||||
if (m_transitioning || m_previousView.empty())
|
||||
return;
|
||||
|
||||
std::string temp = m_currentView;
|
||||
m_currentView = m_previousView;
|
||||
m_previousView = temp;
|
||||
m_type = type;
|
||||
m_duration = duration;
|
||||
m_elapsed = 0;
|
||||
m_progress = 0;
|
||||
m_transitioning = true;
|
||||
}
|
||||
|
||||
inline void TransitionManager::update(float deltaTime) {
|
||||
if (!m_transitioning)
|
||||
return;
|
||||
|
||||
m_elapsed += deltaTime;
|
||||
m_progress = m_elapsed / m_duration;
|
||||
|
||||
if (m_progress >= 1.0f) {
|
||||
m_progress = 1.0f;
|
||||
m_transitioning = false;
|
||||
m_previousView.clear();
|
||||
}
|
||||
}
|
||||
|
||||
inline bool TransitionManager::beginOutgoingTransition() {
|
||||
if (!m_transitioning || m_type == TransitionType::None)
|
||||
return false;
|
||||
|
||||
float alpha = getOutgoingAlpha();
|
||||
if (alpha < 0.001f)
|
||||
return false;
|
||||
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_Alpha, alpha);
|
||||
|
||||
ImVec2 offset = getOutgoingOffset();
|
||||
if (offset.x != 0 || offset.y != 0) {
|
||||
ImVec2 cursor = ImGui::GetCursorPos();
|
||||
ImGui::SetCursorPos(ImVec2(cursor.x + offset.x, cursor.y + offset.y));
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
inline void TransitionManager::endOutgoingTransition() {
|
||||
ImGui::PopStyleVar();
|
||||
}
|
||||
|
||||
inline bool TransitionManager::beginIncomingTransition() {
|
||||
if (!m_transitioning && m_progress >= 1.0f)
|
||||
return true; // No transition, just render normally
|
||||
|
||||
if (m_type == TransitionType::None)
|
||||
return true;
|
||||
|
||||
float alpha = getIncomingAlpha();
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_Alpha, alpha);
|
||||
|
||||
ImVec2 offset = getIncomingOffset();
|
||||
if (offset.x != 0 || offset.y != 0) {
|
||||
ImVec2 cursor = ImGui::GetCursorPos();
|
||||
ImGui::SetCursorPos(ImVec2(cursor.x + offset.x, cursor.y + offset.y));
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
inline void TransitionManager::endIncomingTransition() {
|
||||
if (m_transitioning || m_type != TransitionType::None) {
|
||||
ImGui::PopStyleVar();
|
||||
}
|
||||
}
|
||||
|
||||
inline float TransitionManager::getOutgoingAlpha() const {
|
||||
float eased = EaseAccelerate(m_progress);
|
||||
|
||||
switch (m_type) {
|
||||
case TransitionType::Fade:
|
||||
return 1.0f - eased;
|
||||
case TransitionType::Scale:
|
||||
return 1.0f - eased;
|
||||
default:
|
||||
// For slides, fade quickly in first half
|
||||
return m_progress < 0.5f ? 1.0f - eased * 2.0f : 0.0f;
|
||||
}
|
||||
}
|
||||
|
||||
inline float TransitionManager::getIncomingAlpha() const {
|
||||
float eased = EaseDecelerate(m_progress);
|
||||
|
||||
switch (m_type) {
|
||||
case TransitionType::Fade:
|
||||
return eased;
|
||||
case TransitionType::Scale:
|
||||
return eased;
|
||||
default:
|
||||
// For slides, fade in during second half
|
||||
return m_progress > 0.5f ? (eased - 0.5f) * 2.0f : 0.0f;
|
||||
}
|
||||
}
|
||||
|
||||
inline ImVec2 TransitionManager::getOutgoingOffset() const {
|
||||
ImGuiIO& io = ImGui::GetIO();
|
||||
float eased = EaseAccelerate(m_progress);
|
||||
float slideDistance = 100.0f; // Pixels to slide
|
||||
|
||||
switch (m_type) {
|
||||
case TransitionType::SlideLeft:
|
||||
return ImVec2(-slideDistance * eased, 0);
|
||||
case TransitionType::SlideRight:
|
||||
return ImVec2(slideDistance * eased, 0);
|
||||
case TransitionType::SlideUp:
|
||||
return ImVec2(0, -slideDistance * eased);
|
||||
case TransitionType::SlideDown:
|
||||
return ImVec2(0, slideDistance * eased);
|
||||
case TransitionType::SharedAxis:
|
||||
return ImVec2(-slideDistance * 0.3f * eased, 0);
|
||||
default:
|
||||
return ImVec2(0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
inline ImVec2 TransitionManager::getIncomingOffset() const {
|
||||
float eased = EaseDecelerate(m_progress);
|
||||
float slideDistance = 100.0f;
|
||||
float remaining = 1.0f - eased;
|
||||
|
||||
switch (m_type) {
|
||||
case TransitionType::SlideLeft:
|
||||
return ImVec2(slideDistance * remaining, 0);
|
||||
case TransitionType::SlideRight:
|
||||
return ImVec2(-slideDistance * remaining, 0);
|
||||
case TransitionType::SlideUp:
|
||||
return ImVec2(0, slideDistance * remaining);
|
||||
case TransitionType::SlideDown:
|
||||
return ImVec2(0, -slideDistance * remaining);
|
||||
case TransitionType::SharedAxis:
|
||||
return ImVec2(slideDistance * 0.3f * remaining, 0);
|
||||
default:
|
||||
return ImVec2(0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace material
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
144
src/ui/material/type.h
Normal file
144
src/ui/material/type.h
Normal file
@@ -0,0 +1,144 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
|
||||
// ============================================================================
|
||||
// Material Design Typography Quick Reference
|
||||
// ============================================================================
|
||||
//
|
||||
// Include this header for convenient access to the typography system.
|
||||
//
|
||||
// Usage:
|
||||
// #include "ui/material/type.h"
|
||||
//
|
||||
// // Access fonts directly
|
||||
// ImGui::PushFont(Type().h5());
|
||||
// ImGui::Text("Card Title");
|
||||
// ImGui::PopFont();
|
||||
//
|
||||
// // Use text helpers
|
||||
// Type().text(TypeStyle::H5, "Card Title");
|
||||
// Type().textColored(TypeStyle::Caption, OnSurfaceMedium(), "Timestamp");
|
||||
//
|
||||
// // Scoped font (RAII)
|
||||
// MATERIAL_FONT(Body1) {
|
||||
// ImGui::TextWrapped("Body text goes here...");
|
||||
// }
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "typography.h"
|
||||
#include "colors.h"
|
||||
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
namespace material {
|
||||
|
||||
// ============================================================================
|
||||
// Convenience Accessor
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* @brief Quick access to Typography singleton
|
||||
*
|
||||
* Usage:
|
||||
* ImFont* title = Type().h5();
|
||||
* Type().text(TypeStyle::Body1, "Hello");
|
||||
*/
|
||||
inline Typography& Type() {
|
||||
return Typography::instance();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Text Style Constants for Quick Reference
|
||||
// ============================================================================
|
||||
|
||||
// Headers (Large display text)
|
||||
// H1: 96px Light - Rare, used for massive display numbers
|
||||
// H2: 60px Light - Section banners
|
||||
// H3: 48px Regular - Major section titles
|
||||
// H4: 34px Regular - Page titles
|
||||
// H5: 24px Regular - Card titles, dialog titles
|
||||
// H6: 20px Medium - Section headers, sidebar items
|
||||
|
||||
// Body (Primary content)
|
||||
// Subtitle1: 16px Regular - Secondary information, list item primary text
|
||||
// Subtitle2: 14px Medium - List item secondary text, menu items
|
||||
// Body1: 16px Regular - Primary body text (default for content)
|
||||
// Body2: 14px Regular - Secondary body text
|
||||
|
||||
// UI Elements
|
||||
// Button: 14px Medium, UPPERCASE - Button labels
|
||||
// Caption: 12px Regular - Timestamps, hints, helper text
|
||||
// Overline: 10px Regular, UPPERCASE - Section labels, tabs
|
||||
|
||||
// ============================================================================
|
||||
// Common Typography Patterns
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* @brief Render a card header with title and optional subtitle
|
||||
*/
|
||||
inline void CardHeader(const char* title, const char* subtitle = nullptr)
|
||||
{
|
||||
Type().text(TypeStyle::H5, title);
|
||||
if (subtitle) {
|
||||
Type().textColored(TypeStyle::Caption, OnSurfaceMedium(), subtitle);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Render an overline label (uppercase section marker)
|
||||
*/
|
||||
inline void OverlineLabel(const char* text)
|
||||
{
|
||||
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), text);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Render body text
|
||||
*/
|
||||
inline void BodyText(const char* text, bool secondary = false)
|
||||
{
|
||||
Type().text(secondary ? TypeStyle::Body2 : TypeStyle::Body1, text);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Render caption/helper text
|
||||
*/
|
||||
inline void Caption(const char* text)
|
||||
{
|
||||
Type().textColored(TypeStyle::Caption, OnSurfaceMedium(), text);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Render error text
|
||||
*/
|
||||
inline void ErrorText(const char* text)
|
||||
{
|
||||
Type().textColored(TypeStyle::Body2, Error(), text);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Render success text
|
||||
*/
|
||||
inline void SuccessText(const char* text)
|
||||
{
|
||||
Type().textColored(TypeStyle::Body2, Success(), text);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Render a balance/amount display (large number)
|
||||
*/
|
||||
inline void AmountDisplay(const char* amount, const char* unit = nullptr)
|
||||
{
|
||||
Type().text(TypeStyle::H4, amount);
|
||||
if (unit) {
|
||||
ImGui::SameLine();
|
||||
Type().textColored(TypeStyle::H6, OnSurfaceMedium(), unit);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace material
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
401
src/ui/material/typography.cpp
Normal file
401
src/ui/material/typography.cpp
Normal file
@@ -0,0 +1,401 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
|
||||
#include "typography.h"
|
||||
#include "../layout.h"
|
||||
#include "../schema/ui_schema.h"
|
||||
#include <cstring>
|
||||
#include <cstdio>
|
||||
#include <cctype>
|
||||
#include <string>
|
||||
|
||||
// Embedded font data (via INCBIN – assembler .incbin directive)
|
||||
#include "../embedded/embedded_fonts.h" // g_ubuntu_*_data/size, g_material_icons_*
|
||||
#include "../embedded/IconsMaterialDesign.h" // Icon codepoint defines
|
||||
#include "../../util/logger.h"
|
||||
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
namespace material {
|
||||
|
||||
// ============================================================================
|
||||
// Type Scale Specifications
|
||||
// ============================================================================
|
||||
// Font sizes are configured in ui/layout.h for easy global adjustment.
|
||||
// Letter spacing, line height and text-transform are loaded from ui.toml
|
||||
// via the UISchema typography section.
|
||||
// Font sizes come from UISchema (JSON-configurable) via Layout:: accessors.
|
||||
|
||||
const TypeSpec* Typography::getTypeSpecs()
|
||||
{
|
||||
// Rebuild each call to pick up hot-reloaded values
|
||||
static TypeSpec specs[kNumStyles];
|
||||
|
||||
// Helper: read typography entry from UISchema
|
||||
auto& S = schema::UI();
|
||||
auto tspec = [&](const char* key, float defLS, float defLH, bool defUpper) -> TypeSpec {
|
||||
auto e = S.drawElement("typography", key);
|
||||
float ls = e.getFloat("letter-spacing", defLS);
|
||||
float lh = e.getFloat("line-height", defLH);
|
||||
// text-transform stored as extraColors (string map)
|
||||
bool upper = defUpper;
|
||||
auto it = e.extraColors.find("text-transform");
|
||||
if (it != e.extraColors.end()) {
|
||||
upper = (it->second == "uppercase");
|
||||
}
|
||||
return TypeSpec{0.0f, ls, lh, upper};
|
||||
};
|
||||
|
||||
// Font size comes from Layout:: accessors; typography from UISchema
|
||||
auto ts0 = tspec("h1", -1.0f, 1.167f, false);
|
||||
auto ts1 = tspec("h2", -0.5f, 1.2f, false);
|
||||
auto ts2 = tspec("h3", 0.0f, 1.167f, false);
|
||||
auto ts3 = tspec("h4", 0.25f, 1.235f, false);
|
||||
auto ts4 = tspec("h5", 0.0f, 1.334f, false);
|
||||
auto ts5 = tspec("h6", 0.15f, 1.6f, false);
|
||||
auto ts6 = tspec("subtitle1", 0.15f, 1.75f, false);
|
||||
auto ts7 = tspec("subtitle2", 0.1f, 1.57f, false);
|
||||
auto ts8 = tspec("body1", 0.5f, 1.5f, false);
|
||||
auto ts9 = tspec("body2", 0.25f, 1.43f, false);
|
||||
auto ts10 = tspec("button", 1.25f, 1.75f, true);
|
||||
auto ts11 = tspec("caption", 0.4f, 1.66f, false);
|
||||
auto ts12 = tspec("overline", 1.5f, 2.66f, true);
|
||||
|
||||
specs[0] = { Layout::kFontH1(), ts0.letterSpacing, ts0.lineHeight, ts0.uppercase };
|
||||
specs[1] = { Layout::kFontH2(), ts1.letterSpacing, ts1.lineHeight, ts1.uppercase };
|
||||
specs[2] = { Layout::kFontH3(), ts2.letterSpacing, ts2.lineHeight, ts2.uppercase };
|
||||
specs[3] = { Layout::kFontH4(), ts3.letterSpacing, ts3.lineHeight, ts3.uppercase };
|
||||
specs[4] = { Layout::kFontH5(), ts4.letterSpacing, ts4.lineHeight, ts4.uppercase };
|
||||
specs[5] = { Layout::kFontH6(), ts5.letterSpacing, ts5.lineHeight, ts5.uppercase };
|
||||
specs[6] = { Layout::kFontSubtitle1(), ts6.letterSpacing, ts6.lineHeight, ts6.uppercase };
|
||||
specs[7] = { Layout::kFontSubtitle2(), ts7.letterSpacing, ts7.lineHeight, ts7.uppercase };
|
||||
specs[8] = { Layout::kFontBody1(), ts8.letterSpacing, ts8.lineHeight, ts8.uppercase };
|
||||
specs[9] = { Layout::kFontBody2(), ts9.letterSpacing, ts9.lineHeight, ts9.uppercase };
|
||||
specs[10] = { Layout::kFontButton(), ts10.letterSpacing, ts10.lineHeight, ts10.uppercase };
|
||||
specs[11] = { Layout::kFontCaption(), ts11.letterSpacing, ts11.lineHeight, ts11.uppercase };
|
||||
specs[12] = { Layout::kFontOverline(), ts12.letterSpacing, ts12.lineHeight, ts12.uppercase };
|
||||
specs[13] = { Layout::kFontButtonSm(), ts10.letterSpacing, ts10.lineHeight, ts10.uppercase };
|
||||
specs[14] = { Layout::kFontButtonLg(), ts10.letterSpacing, ts10.lineHeight, ts10.uppercase };
|
||||
return specs;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Typography Implementation
|
||||
// ============================================================================
|
||||
|
||||
Typography& Typography::instance()
|
||||
{
|
||||
static Typography s_instance;
|
||||
return s_instance;
|
||||
}
|
||||
|
||||
bool Typography::reload(ImGuiIO& io, float dpiScale)
|
||||
{
|
||||
if (!loaded_) {
|
||||
return load(io, dpiScale);
|
||||
}
|
||||
DEBUG_LOGF("Typography: Reloading fonts for DPI scale %.2f\n", dpiScale);
|
||||
// Clear existing fonts and reload
|
||||
io.Fonts->Clear();
|
||||
loaded_ = false;
|
||||
for (int i = 0; i < kNumStyles; ++i) fonts_[i] = nullptr;
|
||||
for (int i = 0; i < kNumIconSizes; ++i) iconFonts_[i] = nullptr;
|
||||
return load(io, dpiScale);
|
||||
}
|
||||
|
||||
bool Typography::load(ImGuiIO& io, float dpiScale)
|
||||
{
|
||||
if (loaded_) {
|
||||
DEBUG_LOGF("Typography: Already loaded\n");
|
||||
return true;
|
||||
}
|
||||
|
||||
dpiScale_ = dpiScale;
|
||||
|
||||
// Multiply by dpiScale so fonts are the correct physical size.
|
||||
// On Windows Per-Monitor DPI v2, SDL3 coordinates are physical pixels
|
||||
// and DisplayFramebufferScale is 1.0 (no automatic upscaling).
|
||||
// The window is resized by dpiScale in main.cpp so that fonts at
|
||||
// size*dpiScale fit proportionally (no overflow).
|
||||
float scale = dpiScale * Layout::kFontScale();
|
||||
DEBUG_LOGF("Typography: Loading Material Design type scale (DPI: %.2f, fontScale: %.2f, combined: %.2f)\n", dpiScale, Layout::kFontScale(), scale);
|
||||
|
||||
// For ImGui, we need to load fonts at specific pixel sizes.
|
||||
// Font sizes come from Layout:: accessors (backed by UISchema JSON)
|
||||
|
||||
// Load fonts in order of TypeStyle enum
|
||||
|
||||
// H1: Light
|
||||
fonts_[0] = loadFont(io, kWeightLight, Layout::kFontH1() * scale, "H1");
|
||||
|
||||
// H2: Light
|
||||
fonts_[1] = loadFont(io, kWeightLight, Layout::kFontH2() * scale, "H2");
|
||||
|
||||
// H3: Regular
|
||||
fonts_[2] = loadFont(io, kWeightRegular, Layout::kFontH3() * scale, "H3");
|
||||
|
||||
// H4: Regular
|
||||
fonts_[3] = loadFont(io, kWeightRegular, Layout::kFontH4() * scale, "H4");
|
||||
|
||||
// H5: Regular
|
||||
fonts_[4] = loadFont(io, kWeightRegular, Layout::kFontH5() * scale, "H5");
|
||||
|
||||
// H6: Medium
|
||||
fonts_[5] = loadFont(io, kWeightMedium, Layout::kFontH6() * scale, "H6");
|
||||
|
||||
// Subtitle1: Regular
|
||||
fonts_[6] = loadFont(io, kWeightRegular, Layout::kFontSubtitle1() * scale, "Subtitle1");
|
||||
|
||||
// Subtitle2: Medium
|
||||
fonts_[7] = loadFont(io, kWeightMedium, Layout::kFontSubtitle2() * scale, "Subtitle2");
|
||||
|
||||
// Body1: Regular (shares with Subtitle1 if same size)
|
||||
if (Layout::kFontBody1() == Layout::kFontSubtitle1()) {
|
||||
fonts_[8] = fonts_[6]; // Reuse Subtitle1 font
|
||||
} else {
|
||||
fonts_[8] = loadFont(io, kWeightRegular, Layout::kFontBody1() * scale, "Body1");
|
||||
}
|
||||
|
||||
// Body2: Regular
|
||||
fonts_[9] = loadFont(io, kWeightRegular, Layout::kFontBody2() * scale, "Body2");
|
||||
|
||||
// Button: Medium (shares with Subtitle2 if same size)
|
||||
if (Layout::kFontButton() == Layout::kFontSubtitle2()) {
|
||||
fonts_[10] = fonts_[7]; // Reuse Subtitle2 font
|
||||
} else {
|
||||
fonts_[10] = loadFont(io, kWeightMedium, Layout::kFontButton() * scale, "Button");
|
||||
}
|
||||
|
||||
// Caption: Regular
|
||||
fonts_[11] = loadFont(io, kWeightRegular, Layout::kFontCaption() * scale, "Caption");
|
||||
|
||||
// Overline: Regular
|
||||
fonts_[12] = loadFont(io, kWeightRegular, Layout::kFontOverline() * scale, "Overline");
|
||||
|
||||
// ButtonSm: Medium
|
||||
fonts_[13] = loadFont(io, kWeightMedium, Layout::kFontButtonSm() * scale, "ButtonSm");
|
||||
|
||||
// ButtonLg: Medium
|
||||
fonts_[14] = loadFont(io, kWeightMedium, Layout::kFontButtonLg() * scale, "ButtonLg");
|
||||
|
||||
// --- Icon fonts at multiple sizes ---
|
||||
// These are standalone fonts (not merged) so we can PushFont(iconXxx) for icon rendering.
|
||||
iconFonts_[0] = loadIconFont(io, 14.0f * scale, "IconSmall");
|
||||
iconFonts_[1] = loadIconFont(io, 18.0f * scale, "IconMed");
|
||||
iconFonts_[2] = loadIconFont(io, 24.0f * scale, "IconLarge");
|
||||
iconFonts_[3] = loadIconFont(io, 40.0f * scale, "IconXL");
|
||||
|
||||
// Verify all fonts loaded
|
||||
bool allLoaded = true;
|
||||
for (int i = 0; i < kNumStyles; ++i) {
|
||||
if (!fonts_[i]) {
|
||||
DEBUG_LOGF("Typography: Warning - Font for style %d not loaded\n", i);
|
||||
fonts_[i] = io.Fonts->Fonts.Size > 0 ? io.Fonts->Fonts[0] : nullptr;
|
||||
allLoaded = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Set Body1 as the default font so that bare ImGui::Text() calls
|
||||
// (status bar, dialogs, notifications, etc.) render at Body1 size
|
||||
// instead of H1 (the first font loaded).
|
||||
io.FontDefault = fonts_[static_cast<int>(TypeStyle::Body1)];
|
||||
|
||||
loaded_ = true;
|
||||
DEBUG_LOGF("Typography: Loaded %d font styles (default=Body1)\n", allLoaded ? kNumStyles : -1);
|
||||
|
||||
return allLoaded;
|
||||
}
|
||||
|
||||
ImFont* Typography::loadFont(ImGuiIO& io, int weight, float size, const char* name)
|
||||
{
|
||||
// Select font data based on weight
|
||||
const unsigned char* fontData = nullptr;
|
||||
unsigned int fontDataLen = 0;
|
||||
|
||||
switch (weight) {
|
||||
case kWeightLight:
|
||||
fontData = g_ubuntu_light_data;
|
||||
fontDataLen = g_ubuntu_light_size;
|
||||
break;
|
||||
case kWeightMedium:
|
||||
fontData = g_ubuntu_medium_data;
|
||||
fontDataLen = g_ubuntu_medium_size;
|
||||
break;
|
||||
case kWeightRegular:
|
||||
default:
|
||||
fontData = g_ubuntu_regular_data;
|
||||
fontDataLen = g_ubuntu_regular_size;
|
||||
break;
|
||||
}
|
||||
|
||||
// ImGui needs to own a copy of the font data
|
||||
void* fontDataCopy = IM_ALLOC(fontDataLen);
|
||||
memcpy(fontDataCopy, fontData, fontDataLen);
|
||||
|
||||
ImFontConfig cfg;
|
||||
cfg.FontDataOwnedByAtlas = true; // ImGui will free this
|
||||
cfg.OversampleH = 2;
|
||||
cfg.OversampleV = 1;
|
||||
cfg.PixelSnapH = true;
|
||||
|
||||
// Include default ASCII + Latin, Latin Extended (for Spanish/multilingual),
|
||||
// plus arrows (⇄ U+21C4), math (≈ U+2248),
|
||||
// general punctuation (— U+2014, … U+2026, etc.)
|
||||
static const ImWchar glyphRanges[] = {
|
||||
0x0020, 0x00FF, // Basic Latin + Latin-1 Supplement
|
||||
0x0100, 0x024F, // Latin Extended-A + Latin Extended-B (Spanish, etc.)
|
||||
0x2000, 0x206F, // General Punctuation (em dash, ellipsis, etc.)
|
||||
0x2190, 0x21FF, // Arrows (includes ⇄ U+21C4, ↻ U+21BB)
|
||||
0x2200, 0x22FF, // Mathematical Operators (includes ≈ U+2248)
|
||||
0x2600, 0x26FF, // Miscellaneous Symbols (includes ⛏ U+26CF)
|
||||
0,
|
||||
};
|
||||
cfg.GlyphRanges = glyphRanges;
|
||||
|
||||
// Create a unique name for this font variant
|
||||
snprintf(cfg.Name, sizeof(cfg.Name), "Ubuntu %s %.0fpx",
|
||||
weight == kWeightLight ? "Light" :
|
||||
weight == kWeightMedium ? "Medium" : "Regular",
|
||||
size);
|
||||
|
||||
ImFont* font = io.Fonts->AddFontFromMemoryTTF(fontDataCopy, fontDataLen, size, &cfg);
|
||||
|
||||
if (font) {
|
||||
DEBUG_LOGF("Typography: Loaded %s (%.0fpx) as '%s'\n", name, size, cfg.Name);
|
||||
} else {
|
||||
DEBUG_LOGF("Typography: Failed to load %s\n", name);
|
||||
IM_FREE(fontDataCopy);
|
||||
}
|
||||
|
||||
return font;
|
||||
}
|
||||
|
||||
ImFont* Typography::loadIconFont(ImGuiIO& io, float size, const char* name)
|
||||
{
|
||||
// ImGui needs to own a copy of the font data
|
||||
void* fontDataCopy = IM_ALLOC(g_material_icons_size);
|
||||
memcpy(fontDataCopy, g_material_icons_data, g_material_icons_size);
|
||||
|
||||
ImFontConfig cfg;
|
||||
cfg.FontDataOwnedByAtlas = true;
|
||||
cfg.OversampleH = 2;
|
||||
cfg.OversampleV = 1;
|
||||
cfg.PixelSnapH = true;
|
||||
cfg.MergeMode = false; // standalone icon font
|
||||
cfg.GlyphMinAdvanceX = size; // monospace icons
|
||||
|
||||
static const ImWchar iconRanges[] = { ICON_MIN_MD, ICON_MAX_16_MD, 0 };
|
||||
cfg.GlyphRanges = iconRanges;
|
||||
|
||||
snprintf(cfg.Name, sizeof(cfg.Name), "MDIcons %.0fpx", size);
|
||||
|
||||
ImFont* font = io.Fonts->AddFontFromMemoryTTF(fontDataCopy, g_material_icons_size, size, &cfg);
|
||||
if (font) {
|
||||
DEBUG_LOGF("Typography: Loaded icon font %s (%.0fpx)\n", name, size);
|
||||
} else {
|
||||
DEBUG_LOGF("Typography: Failed to load icon font %s\n", name);
|
||||
IM_FREE(fontDataCopy);
|
||||
}
|
||||
return font;
|
||||
}
|
||||
|
||||
ImFont* Typography::getFont(TypeStyle style) const
|
||||
{
|
||||
int index = static_cast<int>(style);
|
||||
if (index >= 0 && index < kNumStyles && fonts_[index]) {
|
||||
return fonts_[index];
|
||||
}
|
||||
// Return default font if not found
|
||||
return ImGui::GetIO().Fonts->Fonts.Size > 0 ?
|
||||
ImGui::GetIO().Fonts->Fonts[0] : nullptr;
|
||||
}
|
||||
|
||||
const TypeSpec& Typography::getSpec(TypeStyle style) const
|
||||
{
|
||||
const TypeSpec* specs = getTypeSpecs();
|
||||
int index = static_cast<int>(style);
|
||||
if (index >= 0 && index < kNumStyles) {
|
||||
return specs[index];
|
||||
}
|
||||
// Return Body1 as default
|
||||
return specs[static_cast<int>(TypeStyle::Body1)];
|
||||
}
|
||||
|
||||
void Typography::pushFont(TypeStyle style) const
|
||||
{
|
||||
ImGui::PushFont(getFont(style));
|
||||
}
|
||||
|
||||
void Typography::popFont() const
|
||||
{
|
||||
ImGui::PopFont();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Text Rendering Helpers
|
||||
// ============================================================================
|
||||
|
||||
void Typography::text(TypeStyle style, const char* text) const
|
||||
{
|
||||
const TypeSpec& spec = getSpec(style);
|
||||
pushFont(style);
|
||||
|
||||
if (spec.uppercase) {
|
||||
// Convert to uppercase
|
||||
std::string upper;
|
||||
upper.reserve(strlen(text));
|
||||
for (const char* p = text; *p; ++p) {
|
||||
upper += static_cast<char>(std::toupper(static_cast<unsigned char>(*p)));
|
||||
}
|
||||
ImGui::TextUnformatted(upper.c_str());
|
||||
} else {
|
||||
ImGui::TextUnformatted(text);
|
||||
}
|
||||
|
||||
popFont();
|
||||
}
|
||||
|
||||
void Typography::textWrapped(TypeStyle style, const char* text) const
|
||||
{
|
||||
const TypeSpec& spec = getSpec(style);
|
||||
pushFont(style);
|
||||
|
||||
if (spec.uppercase) {
|
||||
std::string upper;
|
||||
upper.reserve(strlen(text));
|
||||
for (const char* p = text; *p; ++p) {
|
||||
upper += static_cast<char>(std::toupper(static_cast<unsigned char>(*p)));
|
||||
}
|
||||
ImGui::TextWrapped("%s", upper.c_str());
|
||||
} else {
|
||||
ImGui::TextWrapped("%s", text);
|
||||
}
|
||||
|
||||
popFont();
|
||||
}
|
||||
|
||||
void Typography::textColored(TypeStyle style, ImU32 color, const char* text) const
|
||||
{
|
||||
const TypeSpec& spec = getSpec(style);
|
||||
pushFont(style);
|
||||
|
||||
ImVec4 colorVec = ImGui::ColorConvertU32ToFloat4(color);
|
||||
|
||||
if (spec.uppercase) {
|
||||
std::string upper;
|
||||
upper.reserve(strlen(text));
|
||||
for (const char* p = text; *p; ++p) {
|
||||
upper += static_cast<char>(std::toupper(static_cast<unsigned char>(*p)));
|
||||
}
|
||||
ImGui::TextColored(colorVec, "%s", upper.c_str());
|
||||
} else {
|
||||
ImGui::TextColored(colorVec, "%s", text);
|
||||
}
|
||||
|
||||
popFont();
|
||||
}
|
||||
|
||||
} // namespace material
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
288
src/ui/material/typography.h
Normal file
288
src/ui/material/typography.h
Normal file
@@ -0,0 +1,288 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "imgui.h"
|
||||
#include <string>
|
||||
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
namespace material {
|
||||
|
||||
// ============================================================================
|
||||
// Material Design Type Scale
|
||||
// ============================================================================
|
||||
// Based on https://m2.material.io/design/typography/the-type-system.html
|
||||
//
|
||||
// The type scale is a combination of 13 styles that are supported by the
|
||||
// type system. It contains reusable categories of text, each with an
|
||||
// intended application and meaning.
|
||||
|
||||
/**
|
||||
* @brief Material Design typography style identifiers
|
||||
*/
|
||||
enum class TypeStyle {
|
||||
H1, // 96sp Light - Large display headers
|
||||
H2, // 60sp Light - Display headers
|
||||
H3, // 48sp Regular - Section titles
|
||||
H4, // 34sp Regular - Section titles
|
||||
H5, // 24sp Regular - Card titles, dialog titles
|
||||
H6, // 20sp Medium - Section headers, subtitles
|
||||
Subtitle1, // 16sp Regular - Secondary information
|
||||
Subtitle2, // 14sp Medium - Secondary information
|
||||
Body1, // 16sp Regular - Primary body text
|
||||
Body2, // 14sp Regular - Secondary body text
|
||||
Button, // 14sp Medium - Button labels (uppercase)
|
||||
Caption, // 12sp Regular - Timestamps, labels
|
||||
Overline, // 10sp Regular - Overline labels (uppercase)
|
||||
ButtonSm, // Small button labels
|
||||
ButtonLg // Large button labels
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Typography specification for a type style
|
||||
*/
|
||||
struct TypeSpec {
|
||||
float size; // Font size in sp (scaled pixels)
|
||||
float letterSpacing; // Letter spacing in px (applied after scaling)
|
||||
float lineHeight; // Line height multiplier
|
||||
bool uppercase; // Whether text should be uppercase
|
||||
|
||||
// Font weight is handled by which font is used (Light/Regular/Medium)
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Typography system managing Material Design fonts
|
||||
*
|
||||
* Loads Ubuntu fonts in Light, Regular, and Medium weights at multiple
|
||||
* sizes to implement the Material Design type scale.
|
||||
*
|
||||
* Usage:
|
||||
* // Initialize during startup (after ImGui context)
|
||||
* dragonx::ui::material::Typography::instance().load(io);
|
||||
*
|
||||
* // Use fonts throughout the application
|
||||
* ImGui::PushFont(Typography::instance().getFont(TypeStyle::H5));
|
||||
* ImGui::Text("Card Title");
|
||||
* ImGui::PopFont();
|
||||
*
|
||||
* // Or use convenience functions
|
||||
* Typography::instance().pushFont(TypeStyle::Body1);
|
||||
* ImGui::TextWrapped("Body text here...");
|
||||
* Typography::instance().popFont();
|
||||
*/
|
||||
class Typography {
|
||||
public:
|
||||
/**
|
||||
* @brief Get the singleton instance
|
||||
*/
|
||||
static Typography& instance();
|
||||
|
||||
/**
|
||||
* @brief Load all fonts for the type scale
|
||||
*
|
||||
* Must be called after ImGui context is created but before first frame.
|
||||
*
|
||||
* @param io ImGui IO reference
|
||||
* @param dpiScale DPI scale factor (default 1.0)
|
||||
* @return true if fonts loaded successfully
|
||||
*/
|
||||
bool load(ImGuiIO& io, float dpiScale = 1.0f);
|
||||
|
||||
/**
|
||||
* @brief Reload fonts at a new DPI scale
|
||||
*
|
||||
* Call when the display scale changes (e.g., window moved to a different
|
||||
* DPI monitor). Clears the existing font atlas and reloads all fonts.
|
||||
*
|
||||
* @param io ImGui IO reference
|
||||
* @param dpiScale New DPI scale factor
|
||||
* @return true if fonts reloaded successfully
|
||||
*/
|
||||
bool reload(ImGuiIO& io, float dpiScale);
|
||||
|
||||
/**
|
||||
* @brief Check if typography system is loaded
|
||||
*/
|
||||
bool isLoaded() const { return loaded_; }
|
||||
|
||||
/**
|
||||
* @brief Get the current DPI scale
|
||||
*/
|
||||
float getDpiScale() const { return dpiScale_; }
|
||||
|
||||
/**
|
||||
* @brief Get font for a type style
|
||||
*
|
||||
* @param style The Material Design type style
|
||||
* @return ImFont pointer (never null, returns default if not loaded)
|
||||
*/
|
||||
ImFont* getFont(TypeStyle style) const;
|
||||
|
||||
/**
|
||||
* @brief Get the spec for a type style
|
||||
*
|
||||
* @param style The Material Design type style
|
||||
* @return TypeSpec with size, spacing, and line height
|
||||
*/
|
||||
const TypeSpec& getSpec(TypeStyle style) const;
|
||||
|
||||
/**
|
||||
* @brief Push font for a type style
|
||||
*
|
||||
* Convenience wrapper around ImGui::PushFont(getFont(style))
|
||||
*/
|
||||
void pushFont(TypeStyle style) const;
|
||||
|
||||
/**
|
||||
* @brief Pop the current font
|
||||
*
|
||||
* Convenience wrapper around ImGui::PopFont()
|
||||
*/
|
||||
void popFont() const;
|
||||
|
||||
// ========================================================================
|
||||
// Font Accessors (for common cases)
|
||||
// ========================================================================
|
||||
|
||||
// Headers
|
||||
ImFont* h1() const { return getFont(TypeStyle::H1); }
|
||||
ImFont* h2() const { return getFont(TypeStyle::H2); }
|
||||
ImFont* h3() const { return getFont(TypeStyle::H3); }
|
||||
ImFont* h4() const { return getFont(TypeStyle::H4); }
|
||||
ImFont* h5() const { return getFont(TypeStyle::H5); }
|
||||
ImFont* h6() const { return getFont(TypeStyle::H6); }
|
||||
|
||||
// Body text
|
||||
ImFont* subtitle1() const { return getFont(TypeStyle::Subtitle1); }
|
||||
ImFont* subtitle2() const { return getFont(TypeStyle::Subtitle2); }
|
||||
ImFont* body1() const { return getFont(TypeStyle::Body1); }
|
||||
ImFont* body2() const { return getFont(TypeStyle::Body2); }
|
||||
|
||||
// UI elements
|
||||
ImFont* button() const { return getFont(TypeStyle::Button); }
|
||||
ImFont* buttonSm() const { return getFont(TypeStyle::ButtonSm); }
|
||||
ImFont* buttonLg() const { return getFont(TypeStyle::ButtonLg); }
|
||||
ImFont* caption() const { return getFont(TypeStyle::Caption); }
|
||||
ImFont* overline() const { return getFont(TypeStyle::Overline); }
|
||||
|
||||
// Icon fonts — Material Design Icons merged at specific pixel sizes
|
||||
// Use these when you need to render an icon at a specific size via AddText/ImGui::Text.
|
||||
// Common sizes: iconSmall (14px), iconMed (18px), iconLarge (24px).
|
||||
ImFont* iconSmall() const { return iconFonts_[0] ? iconFonts_[0] : getFont(TypeStyle::Body2); }
|
||||
ImFont* iconMed() const { return iconFonts_[1] ? iconFonts_[1] : getFont(TypeStyle::Body1); }
|
||||
ImFont* iconLarge() const { return iconFonts_[2] ? iconFonts_[2] : getFont(TypeStyle::H5); }
|
||||
ImFont* iconXL() const { return iconFonts_[3] ? iconFonts_[3] : getFont(TypeStyle::H3); }
|
||||
|
||||
/**
|
||||
* @brief Resolve a font name string to ImFont*
|
||||
* @param name Font name like "button", "button-sm", "button-lg", "h4", "body1"
|
||||
* @return ImFont* pointer, or nullptr if name is empty/unknown
|
||||
*/
|
||||
ImFont* resolveByName(const std::string& name) const {
|
||||
if (name.empty()) return nullptr;
|
||||
if (name == "h1") return h1();
|
||||
if (name == "h2") return h2();
|
||||
if (name == "h3") return h3();
|
||||
if (name == "h4") return h4();
|
||||
if (name == "h5") return h5();
|
||||
if (name == "h6") return h6();
|
||||
if (name == "subtitle1") return subtitle1();
|
||||
if (name == "subtitle2") return subtitle2();
|
||||
if (name == "body1") return body1();
|
||||
if (name == "body2") return body2();
|
||||
if (name == "button") return button();
|
||||
if (name == "button-sm") return buttonSm();
|
||||
if (name == "button-lg") return buttonLg();
|
||||
if (name == "caption") return caption();
|
||||
if (name == "overline") return overline();
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Text Rendering Helpers
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* @brief Render text with a specific type style
|
||||
*
|
||||
* Handles font push/pop and optional uppercase transformation.
|
||||
*
|
||||
* @param style The type style to use
|
||||
* @param text The text to render
|
||||
*/
|
||||
void text(TypeStyle style, const char* text) const;
|
||||
|
||||
/**
|
||||
* @brief Render wrapped text with a specific type style
|
||||
*
|
||||
* @param style The type style to use
|
||||
* @param text The text to render
|
||||
*/
|
||||
void textWrapped(TypeStyle style, const char* text) const;
|
||||
|
||||
/**
|
||||
* @brief Render colored text with a specific type style
|
||||
*
|
||||
* @param style The type style to use
|
||||
* @param color Text color
|
||||
* @param text The text to render
|
||||
*/
|
||||
void textColored(TypeStyle style, ImU32 color, const char* text) const;
|
||||
|
||||
private:
|
||||
Typography() = default;
|
||||
~Typography() = default;
|
||||
Typography(const Typography&) = delete;
|
||||
Typography& operator=(const Typography&) = delete;
|
||||
|
||||
// Load fonts at a specific size with a specific weight
|
||||
ImFont* loadFont(ImGuiIO& io, int weight, float size, const char* name);
|
||||
|
||||
// Font weight constants
|
||||
static constexpr int kWeightLight = 300;
|
||||
static constexpr int kWeightRegular = 400;
|
||||
static constexpr int kWeightMedium = 500;
|
||||
|
||||
bool loaded_ = false;
|
||||
float dpiScale_ = 1.0f;
|
||||
|
||||
// Fonts for each type style
|
||||
ImFont* fonts_[15] = {};
|
||||
|
||||
// Icon fonts at different sizes: [0]=small(14), [1]=med(18), [2]=large(24), [3]=xl(40)
|
||||
ImFont* iconFonts_[4] = {};
|
||||
static constexpr int kNumIconSizes = 4;
|
||||
|
||||
// Load an icon-only font at a specific pixel size
|
||||
ImFont* loadIconFont(ImGuiIO& io, float size, const char* name);
|
||||
|
||||
// Type specifications
|
||||
static const TypeSpec* getTypeSpecs();
|
||||
static constexpr int kNumStyles = 15;
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Convenience Macros (Optional)
|
||||
// ============================================================================
|
||||
|
||||
// Scoped font push/pop using RAII
|
||||
class ScopedFont {
|
||||
public:
|
||||
explicit ScopedFont(TypeStyle style) {
|
||||
Typography::instance().pushFont(style);
|
||||
}
|
||||
~ScopedFont() {
|
||||
Typography::instance().popFont();
|
||||
}
|
||||
};
|
||||
|
||||
// Usage: MATERIAL_FONT(H5) { ImGui::Text("Title"); }
|
||||
#define MATERIAL_FONT(style) \
|
||||
if (dragonx::ui::material::ScopedFont _font{dragonx::ui::material::TypeStyle::style}; true)
|
||||
|
||||
} // namespace material
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
Reference in New Issue
Block a user