refactor(ui): add material::IconButton helper + adopt at 8 frameless icon buttons

UI-standardization audit (icon-button discovery pass): the app had ~22 clean
hand-rolled icon buttons repeating the same "InvisibleButton hit-target +
centered glyph + hover feedback + tooltip" boilerplate. Add a shared
material::IconButton(id, glyph, font, size, IconButtonStyle) — centered glyph,
optional resting + hover backgrounds (pill/rounded-rect), hover recolor, hand
cursor + tooltip; caller resolves state-dependent glyph/colors and returns
clicked. Adopt at the homogeneous, self-contained sites, pixel-faithful:
- mining_mode_toggle: pool/worker dropdown + bookmark + reset (×5)
- network: server hide/unhide eye (×1)
- mining_controls: pool benchmark start (×1)
- mining_stats: pool-balance refresh (×1)

Deferred (own follow-up + sweep): the idle/scale/gpu active-pill toggles use a
"draw pill+glyph earlier, hit-test later" idiom needing restructuring; the
raw-rect (IsRectHovered, no InvisibleButton) sites in market/mining_stats/
balance are a different hit-test mechanism left intentionally. Framed icon
pagers already ride TactileButton after the prior commit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-04 19:25:20 -05:00
parent da56bb73a0
commit c83348ab63
5 changed files with 98 additions and 156 deletions

View File

@@ -354,6 +354,47 @@ inline ImVec2 DrawPill(ImDrawList* dl, const ImVec2& pos, const char* text, ImFo
return sz;
}
// ── Frameless icon button ────────────────────────────────────────────────
// Style for IconButton. All colors default to 0 (= unset): color falls back to OnSurfaceMedium();
// hoverColor==0 means "no hover recolor"; hoverBg/restBg==0 mean "no background".
struct IconButtonStyle {
ImU32 color = 0; // resting glyph color (0 => OnSurfaceMedium())
ImU32 hoverColor = 0; // glyph color while hovered (0 => same as color)
ImU32 hoverBg = 0; // fill drawn behind the glyph on hover only (0 => none)
ImU32 restBg = 0; // fill drawn every frame, e.g. an "active" pill (0 => none)
float bgRounding = -1.0f; // <0 => size.y*0.5 (pill/circle for a square); else that radius
bool handCursor = true;
const char* tooltip = nullptr;
};
// A frameless / pill icon button: an InvisibleButton(id, size) at the current cursor with a centered
// `glyph` (in `font`), optional resting + hover backgrounds, hover recolor, hand cursor + tooltip.
// Returns true the frame it is clicked. The caller resolves any state-dependent glyph/colors/tooltip
// before the call and positions the cursor (SetCursorScreenPos / SameLine) as needed.
inline bool IconButton(const char* id, const char* glyph, ImFont* font, const ImVec2& size,
const IconButtonStyle& st)
{
const ImVec2 pos = ImGui::GetCursorScreenPos();
ImGui::InvisibleButton(id, size);
const bool hov = ImGui::IsItemHovered();
const bool clk = ImGui::IsItemClicked();
ImDrawList* dl = ImGui::GetWindowDrawList();
const ImVec2 pMax(pos.x + size.x, pos.y + size.y);
const float round = st.bgRounding < 0.0f ? size.y * 0.5f : st.bgRounding;
if (st.restBg != 0) dl->AddRectFilled(pos, pMax, st.restBg, round);
if (hov && st.hoverBg != 0) dl->AddRectFilled(pos, pMax, st.hoverBg, round);
if (hov) {
if (st.handCursor) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
if (st.tooltip && st.tooltip[0]) Tooltip("%s", st.tooltip);
}
const ImU32 gcol = (hov && st.hoverColor != 0) ? st.hoverColor
: (st.color != 0 ? st.color : OnSurfaceMedium());
const ImVec2 gsz = font->CalcTextSizeA(font->LegacySize, FLT_MAX, 0, glyph);
dl->AddText(font, font->LegacySize,
ImVec2(pos.x + (size.x - gsz.x) * 0.5f, pos.y + (size.y - gsz.y) * 0.5f), gcol, glyph);
return clk;
}
// ── Tactile wrappers ────────────────────────────────────────────────────
// Drop-in replacements for ImGui::Button / SmallButton that automatically
// add the tactile highlight overlay after rendering.

View File

@@ -487,27 +487,12 @@ void RenderMiningControls(App* app, const WalletState& state, const MiningInfo&
float btnX = idleRightEdge - btnSz;
float btnY = curY + (headerH - btnSz) * 0.5f;
material::IconButtonStyle st;
st.color = OnSurfaceMedium();
st.hoverBg = StateHover();
st.tooltip = TR("mining_benchmark_tooltip");
ImGui::SetCursorScreenPos(ImVec2(btnX, btnY));
ImGui::InvisibleButton("##BenchStart", ImVec2(btnSz, btnSz));
bool benchHovered = ImGui::IsItemHovered();
bool benchClicked = ImGui::IsItemClicked();
// Hover highlight
if (benchHovered) {
dl->AddRectFilled(ImVec2(btnX, btnY), ImVec2(btnX + btnSz, btnY + btnSz),
StateHover(), btnSz * 0.5f);
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
material::Tooltip("%s", TR("mining_benchmark_tooltip"));
}
const char* benchIcon = ICON_MD_SPEED;
ImVec2 bIcoSz = icoFont->CalcTextSizeA(icoFont->LegacySize, FLT_MAX, 0, benchIcon);
dl->AddText(icoFont, icoFont->LegacySize,
ImVec2(btnX + (btnSz - bIcoSz.x) * 0.5f,
btnY + (btnSz - bIcoSz.y) * 0.5f),
OnSurfaceMedium(), benchIcon);
if (benchClicked) {
if (material::IconButton("##BenchStart", ICON_MD_SPEED, icoFont, ImVec2(btnSz, btnSz), st)) {
// Require a wallet address for pool mining
std::string worker(s_pool_worker);
if (!worker.empty()) {

View File

@@ -190,26 +190,13 @@ void RenderMiningModeToggle(App* app, const WalletState& state, const MiningInfo
// --- URL: Dropdown arrow button ---
ImGui::SameLine(0, 0);
{
ImVec2 btnPos = ImGui::GetCursorScreenPos();
ImVec2 btnSize(iconBtnW, inputFrameH2);
ImGui::InvisibleButton("##PoolDropdown", btnSize);
bool btnHov = ImGui::IsItemHovered();
bool btnClk = ImGui::IsItemClicked();
ImDrawList* dl2 = ImGui::GetWindowDrawList();
ImVec2 btnCenter(btnPos.x + btnSize.x * 0.5f, btnPos.y + btnSize.y * 0.5f);
if (btnHov) {
dl2->AddRectFilled(btnPos, ImVec2(btnPos.x + btnSize.x, btnPos.y + btnSize.y),
StateHover(), 4.0f * dp);
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
material::Tooltip("%s", TR("mining_saved_pools"));
}
ImFont* icoFont = Type().iconSmall();
const char* dropIcon = ICON_MD_ARROW_DROP_DOWN;
ImVec2 icoSz = icoFont->CalcTextSizeA(icoFont->LegacySize, FLT_MAX, 0, dropIcon);
dl2->AddText(icoFont, icoFont->LegacySize,
ImVec2(btnCenter.x - icoSz.x * 0.5f, btnCenter.y - icoSz.y * 0.5f),
OnSurfaceMedium(), dropIcon);
if (btnClk) {
material::IconButtonStyle st;
st.color = OnSurfaceMedium();
st.hoverBg = StateHover();
st.bgRounding = 4.0f * dp;
st.tooltip = TR("mining_saved_pools");
if (material::IconButton("##PoolDropdown", ICON_MD_ARROW_DROP_DOWN, Type().iconSmall(),
ImVec2(iconBtnW, inputFrameH2), st)) {
ImGui::OpenPopup("##SavedPoolsPopup");
}
}
@@ -217,28 +204,16 @@ void RenderMiningModeToggle(App* app, const WalletState& state, const MiningInfo
// --- URL: Bookmark button ---
ImGui::SameLine(0, 0);
{
ImVec2 btnPos = ImGui::GetCursorScreenPos();
ImVec2 btnSize(iconBtnW, inputFrameH2);
ImGui::InvisibleButton("##SavePoolUrl", btnSize);
bool btnHov = ImGui::IsItemHovered();
bool btnClk = ImGui::IsItemClicked();
ImDrawList* dl2 = ImGui::GetWindowDrawList();
ImVec2 btnCenter(btnPos.x + btnSize.x * 0.5f, btnPos.y + btnSize.y * 0.5f);
std::string currentUrl(s_pool_url);
bool alreadySaved = miningValueAlreadySaved(app->settings()->getSavedPoolUrls(), currentUrl);
if (btnHov) {
dl2->AddRectFilled(btnPos, ImVec2(btnPos.x + btnSize.x, btnPos.y + btnSize.y),
StateHover(), 4.0f * dp);
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
material::Tooltip("%s", alreadySaved ? TR("mining_already_saved") : TR("mining_save_pool_url"));
}
ImFont* icoFont = Type().iconSmall();
const char* saveIcon = alreadySaved ? ICON_MD_BOOKMARK : ICON_MD_BOOKMARK_BORDER;
ImVec2 icoSz = icoFont->CalcTextSizeA(icoFont->LegacySize, FLT_MAX, 0, saveIcon);
dl2->AddText(icoFont, icoFont->LegacySize,
ImVec2(btnCenter.x - icoSz.x * 0.5f, btnCenter.y - icoSz.y * 0.5f),
alreadySaved ? Primary() : OnSurfaceMedium(), saveIcon);
if (btnClk && !currentUrl.empty() && !alreadySaved) {
material::IconButtonStyle st;
st.color = alreadySaved ? Primary() : OnSurfaceMedium();
st.hoverBg = StateHover();
st.bgRounding = 4.0f * dp;
st.tooltip = alreadySaved ? TR("mining_already_saved") : TR("mining_save_pool_url");
if (material::IconButton("##SavePoolUrl", alreadySaved ? ICON_MD_BOOKMARK : ICON_MD_BOOKMARK_BORDER,
Type().iconSmall(), ImVec2(iconBtnW, inputFrameH2), st)
&& !currentUrl.empty() && !alreadySaved) {
app->settings()->addSavedPoolUrl(currentUrl);
app->settings()->save();
}
@@ -412,26 +387,13 @@ void RenderMiningModeToggle(App* app, const WalletState& state, const MiningInfo
// --- Worker: Dropdown arrow button ---
ImGui::SameLine(0, 0);
{
ImVec2 btnPos = ImGui::GetCursorScreenPos();
ImVec2 btnSize(iconBtnW, inputFrameH2);
ImGui::InvisibleButton("##WorkerDropdown", btnSize);
bool btnHov = ImGui::IsItemHovered();
bool btnClk = ImGui::IsItemClicked();
ImDrawList* dl2 = ImGui::GetWindowDrawList();
ImVec2 btnCenter(btnPos.x + btnSize.x * 0.5f, btnPos.y + btnSize.y * 0.5f);
if (btnHov) {
dl2->AddRectFilled(btnPos, ImVec2(btnPos.x + btnSize.x, btnPos.y + btnSize.y),
StateHover(), 4.0f * dp);
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
material::Tooltip("%s", TR("mining_saved_addresses"));
}
ImFont* icoFont = Type().iconSmall();
const char* dropIcon = ICON_MD_ARROW_DROP_DOWN;
ImVec2 icoSz = icoFont->CalcTextSizeA(icoFont->LegacySize, FLT_MAX, 0, dropIcon);
dl2->AddText(icoFont, icoFont->LegacySize,
ImVec2(btnCenter.x - icoSz.x * 0.5f, btnCenter.y - icoSz.y * 0.5f),
OnSurfaceMedium(), dropIcon);
if (btnClk) {
material::IconButtonStyle st;
st.color = OnSurfaceMedium();
st.hoverBg = StateHover();
st.bgRounding = 4.0f * dp;
st.tooltip = TR("mining_saved_addresses");
if (material::IconButton("##WorkerDropdown", ICON_MD_ARROW_DROP_DOWN, Type().iconSmall(),
ImVec2(iconBtnW, inputFrameH2), st)) {
ImGui::OpenPopup("##SavedWorkersPopup");
}
}
@@ -439,28 +401,16 @@ void RenderMiningModeToggle(App* app, const WalletState& state, const MiningInfo
// --- Worker: Bookmark button ---
ImGui::SameLine(0, 0);
{
ImVec2 btnPos = ImGui::GetCursorScreenPos();
ImVec2 btnSize(iconBtnW, inputFrameH2);
ImGui::InvisibleButton("##SaveWorkerAddr", btnSize);
bool btnHov = ImGui::IsItemHovered();
bool btnClk = ImGui::IsItemClicked();
ImDrawList* dl2 = ImGui::GetWindowDrawList();
ImVec2 btnCenter(btnPos.x + btnSize.x * 0.5f, btnPos.y + btnSize.y * 0.5f);
std::string currentWorker(s_pool_worker);
bool alreadySaved = miningValueAlreadySaved(app->settings()->getSavedPoolWorkers(), currentWorker);
if (btnHov) {
dl2->AddRectFilled(btnPos, ImVec2(btnPos.x + btnSize.x, btnPos.y + btnSize.y),
StateHover(), 4.0f * dp);
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
material::Tooltip("%s", alreadySaved ? TR("mining_already_saved") : TR("mining_save_payout_address"));
}
ImFont* icoFont = Type().iconSmall();
const char* saveIcon = alreadySaved ? ICON_MD_BOOKMARK : ICON_MD_BOOKMARK_BORDER;
ImVec2 icoSz = icoFont->CalcTextSizeA(icoFont->LegacySize, FLT_MAX, 0, saveIcon);
dl2->AddText(icoFont, icoFont->LegacySize,
ImVec2(btnCenter.x - icoSz.x * 0.5f, btnCenter.y - icoSz.y * 0.5f),
alreadySaved ? Primary() : OnSurfaceMedium(), saveIcon);
if (btnClk && !currentWorker.empty() && currentWorker != "x" && !alreadySaved) {
material::IconButtonStyle st;
st.color = alreadySaved ? Primary() : OnSurfaceMedium();
st.hoverBg = StateHover();
st.bgRounding = 4.0f * dp;
st.tooltip = alreadySaved ? TR("mining_already_saved") : TR("mining_save_payout_address");
if (material::IconButton("##SaveWorkerAddr", alreadySaved ? ICON_MD_BOOKMARK : ICON_MD_BOOKMARK_BORDER,
Type().iconSmall(), ImVec2(iconBtnW, inputFrameH2), st)
&& !currentWorker.empty() && currentWorker != "x" && !alreadySaved) {
app->settings()->addSavedPoolWorker(currentWorker);
app->settings()->save();
}
@@ -579,32 +529,13 @@ void RenderMiningModeToggle(App* app, const WalletState& state, const MiningInfo
// === Reset to defaults button ===
ImGui::SameLine(0, Layout::spacingSm());
{
ImVec2 btnPos = ImGui::GetCursorScreenPos();
ImVec2 btnSize(inputFrameH2, inputFrameH2);
ImGui::InvisibleButton("##ResetPoolDefaults", btnSize);
bool btnHov = ImGui::IsItemHovered();
bool btnClk = ImGui::IsItemClicked();
ImDrawList* dl2 = ImGui::GetWindowDrawList();
ImVec2 btnCenter(btnPos.x + btnSize.x * 0.5f, btnPos.y + btnSize.y * 0.5f);
// Hover highlight
if (btnHov) {
dl2->AddRectFilled(btnPos, ImVec2(btnPos.x + btnSize.x, btnPos.y + btnSize.y),
StateHover(), 4.0f * dp);
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
material::Tooltip("%s", TR("mining_reset_defaults"));
}
// Icon
ImFont* iconFont = Type().iconSmall();
const char* resetIcon = ICON_MD_REFRESH;
ImVec2 iconSz = iconFont->CalcTextSizeA(iconFont->LegacySize, FLT_MAX, 0, resetIcon);
dl2->AddText(iconFont, iconFont->LegacySize,
ImVec2(btnCenter.x - iconSz.x * 0.5f, btnCenter.y - iconSz.y * 0.5f),
OnSurfaceMedium(), resetIcon);
if (btnClk) {
material::IconButtonStyle st;
st.color = OnSurfaceMedium();
st.hoverBg = StateHover();
st.bgRounding = 4.0f * dp;
st.tooltip = TR("mining_reset_defaults");
if (material::IconButton("##ResetPoolDefaults", ICON_MD_REFRESH, Type().iconSmall(),
ImVec2(inputFrameH2, inputFrameH2), st)) {
strncpy(s_pool_url, defaultPoolUrl(), sizeof(s_pool_url) - 1);
// Default to user's first shielded (z) address for pool payouts.
// Leave blank if no z-address exists yet.

View File

@@ -258,24 +258,16 @@ static void RenderLeftPoolCard(App* app, const WalletState& state, ImDrawList* d
(int)util::knownPools().size());
dl->AddText(ovFont, ovFont->LegacySize, ImVec2(x, y), OnSurfaceMedium(), hdr);
ImFont* icoFont = Type().iconSmall();
const char* icon = ICON_MD_REFRESH;
ImVec2 iSz = icoFont->CalcTextSizeA(icoFont->LegacySize, FLT_MAX, 0, icon);
float btnS = ovFont->LegacySize + 6 * dp;
ImVec2 rbMin(x + w - btnS, y - 2 * dp);
ImVec2 rbMax(x + w, y - 2 * dp + btnS);
material::IconButtonStyle st;
st.color = OnSurfaceMedium();
st.hoverBg = StateHover();
st.bgRounding = 3 * dp;
st.tooltip = TR("mining_refresh");
ImGui::SetCursorScreenPos(rbMin);
ImGui::InvisibleButton("##PoolRefresh", ImVec2(btnS, btnS));
bool rhov = ImGui::IsItemHovered();
if (rhov) {
dl->AddRectFilled(rbMin, rbMax, StateHover(), 3 * dp);
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
material::Tooltip("%s", TR("mining_refresh"));
}
dl->AddText(icoFont, icoFont->LegacySize,
ImVec2((rbMin.x + rbMax.x - iSz.x) * 0.5f, (rbMin.y + rbMax.y - iSz.y) * 0.5f),
OnSurfaceMedium(), icon);
if (ImGui::IsItemClicked()) app->requestPoolBalanceRefresh();
if (material::IconButton("##PoolRefresh", ICON_MD_REFRESH, Type().iconSmall(), ImVec2(btnS, btnS), st))
app->requestPoolBalanceRefresh();
}
y += ovFont->LegacySize + 4 * dp;

View File

@@ -294,25 +294,18 @@ void RenderLiteNetworkTab(App* app)
}
// Hide / unhide button (far-right strip).
ImVec2 hideMin(cardMin.x + cardBtnW, cardMin.y);
ImGui::SetCursorScreenPos(hideMin);
ImGui::InvisibleButton(("##litehide_" + sv.url).c_str(), ImVec2(hideW, cardH));
bool hideHov = ImGui::IsItemHovered();
if (hideHov) {
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
material::Tooltip("%s", hiddenList ? TR("lite_net_unhide") : TR("lite_net_hide"));
}
if (ImGui::IsItemClicked()) {
ImGui::SetCursorScreenPos(ImVec2(cardMin.x + cardBtnW, cardMin.y));
material::IconButtonStyle hideSt;
hideSt.color = OnSurfaceDisabled();
hideSt.hoverColor = OnSurface();
hideSt.tooltip = hiddenList ? TR("lite_net_unhide") : TR("lite_net_hide");
if (material::IconButton(("##litehide_" + sv.url).c_str(),
hiddenList ? ICON_MD_VISIBILITY : ICON_MD_VISIBILITY_OFF,
Type().iconSmall(), ImVec2(hideW, cardH), hideSt)) {
if (hiddenList) st->unhideLiteServer(sv.url);
else st->hideLiteServer(sv.url);
st->save();
}
ImFont* ico = Type().iconSmall();
const char* eye = hiddenList ? ICON_MD_VISIBILITY : ICON_MD_VISIBILITY_OFF;
ImVec2 icoSz = ico->CalcTextSizeA(ico->LegacySize, FLT_MAX, 0, eye);
dl->AddText(ico, ico->LegacySize,
ImVec2(hideMin.x + (hideW - icoSz.x) * 0.5f, cardMin.y + (cardH - icoSz.y) * 0.5f),
hideHov ? OnSurface() : OnSurfaceDisabled(), eye);
// Advance to the next card via a real item (Dummy) below the card, so the scroll region's
// content height actually grows — ImGui won't extend bounds from a bare SetCursorScreenPos.