feat(console): terminal polish — lite/color-coded toggles, dark bg, monospace font

Work through the console items in todo.md:

- Lite filter toggles (#5): replace the single hasLogFilters() bool with a
  ConsoleLogFilterCaps struct so each backend advertises which toggles apply.
  Full node = daemon/errors/rpc-trace/app; lite = errors-only + app (its
  diagnostics ring maps to the App/Error channels; the text filter is always
  shown). Lite previously showed no toggles at all.
- Color-coded toggles (#6): each filter checkbox is tinted with its channel's
  accent color (daemon=blue, errors=red, rpc=secondary, app=teal) via a new
  channelAccentColor() that also drives the output's left accent bar — one source
  of truth for channel color.
- Darker terminal look (#7): drop the light glass rectangle on the input; both
  output and input now get a terminal-dark overlay (tabs.console.bg-darken-alpha,
  default 110) and the input field blends into it (transparent frame bg).
- Monospace font (#8): bundle Ubuntu Mono (res/fonts/UbuntuMono-R.ttf, Ubuntu
  Font License — same family as the existing Ubuntu fonts) via INCBIN, load it at
  caption size as Type().mono(), and render console output + input in it so
  pretty-printed JSON columns and terminal text align. Falls back to the
  proportional caption font if unavailable.

Full-node + lite build clean; ctest green; source hygiene clean; sandboxed
startup smoke confirms the mono font loads + atlas builds without crashing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-01 20:53:08 -05:00
parent 245d9bf976
commit ff20a8a44e
10 changed files with 143 additions and 77 deletions

View File

@@ -689,6 +689,7 @@ set_source_files_properties(
"${CMAKE_SOURCE_DIR}/res/fonts/Ubuntu-R.ttf;\
${CMAKE_SOURCE_DIR}/res/fonts/Ubuntu-Light.ttf;\
${CMAKE_SOURCE_DIR}/res/fonts/Ubuntu-Medium.ttf;\
${CMAKE_SOURCE_DIR}/res/fonts/UbuntuMono-R.ttf;\
${CMAKE_SOURCE_DIR}/res/fonts/MaterialIcons-Regular.ttf;\
${CMAKE_SOURCE_DIR}/res/fonts/MaterialDesignIcons-Pickaxe-Subset.ttf;\
${CMAKE_SOURCE_DIR}/res/fonts/NotoSansCJK-Subset.ttf"

BIN
res/fonts/UbuntuMono-R.ttf Normal file

Binary file not shown.

View File

@@ -908,6 +908,7 @@ scroll-fade-zone = { size = 24.0 }
[tabs.console]
input-area-padding = 8.0
bg-darken-alpha = { size = 110.0 } # black overlay alpha (0-255) for the terminal-dark output + input
output-line-spacing = 2.0
output = { line-spacing = 2 }
scroll-multiplier = { size = 3.0 }

View File

@@ -11,6 +11,7 @@
INCBIN(ubuntu_regular, "@CMAKE_SOURCE_DIR@/res/fonts/Ubuntu-R.ttf");
INCBIN(ubuntu_light, "@CMAKE_SOURCE_DIR@/res/fonts/Ubuntu-Light.ttf");
INCBIN(ubuntu_medium, "@CMAKE_SOURCE_DIR@/res/fonts/Ubuntu-Medium.ttf");
INCBIN(ubuntu_mono, "@CMAKE_SOURCE_DIR@/res/fonts/UbuntuMono-R.ttf");
INCBIN(material_icons, "@CMAKE_SOURCE_DIR@/res/fonts/MaterialIcons-Regular.ttf");
INCBIN(mdi_pickaxe_subset, "@CMAKE_SOURCE_DIR@/res/fonts/MaterialDesignIcons-Pickaxe-Subset.ttf");
INCBIN(noto_cjk_subset, "@CMAKE_SOURCE_DIR@/res/fonts/NotoSansCJK-Subset.ttf");

View File

@@ -24,6 +24,9 @@ extern "C" {
extern const unsigned char g_ubuntu_medium_data[];
extern const unsigned int g_ubuntu_medium_size;
extern const unsigned char g_ubuntu_mono_data[];
extern const unsigned int g_ubuntu_mono_size;
extern const unsigned char g_material_icons_data[];
extern const unsigned int g_material_icons_size;

View File

@@ -183,7 +183,11 @@ bool Typography::load(ImGuiIO& io, float dpiScale)
// ButtonLg: Medium
fonts_[14] = loadFont(io, kWeightMedium, Layout::kFontButtonLg() * scale, "ButtonLg");
// Monospace (Ubuntu Mono) at caption size — used by the console so terminal text +
// pretty-printed JSON columns align. Same size as Caption to keep line heights matched.
mono_ = loadFont(io, kWeightMono, Layout::kFontCaption() * scale, "Mono");
// --- 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");
@@ -236,6 +240,10 @@ ImFont* Typography::loadFont(ImGuiIO& io, int weight, float size, const char* na
fontData = g_ubuntu_medium_data;
fontDataLen = g_ubuntu_medium_size;
break;
case kWeightMono:
fontData = g_ubuntu_mono_data;
fontDataLen = g_ubuntu_mono_size;
break;
case kWeightRegular:
default:
fontData = g_ubuntu_regular_data;
@@ -271,10 +279,10 @@ ImFont* Typography::loadFont(ImGuiIO& io, int weight, float size, const char* na
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);
const char* weightName = weight == kWeightLight ? "Light" :
weight == kWeightMedium ? "Medium" :
weight == kWeightMono ? "Mono" : "Regular";
snprintf(cfg.Name, sizeof(cfg.Name), "Ubuntu %s %.0fpx", weightName, size);
ImFont* font = io.Fonts->AddFontFromMemoryTTF(fontDataCopy, fontDataLen, size, &cfg);

View File

@@ -168,6 +168,10 @@ public:
ImFont* caption() const { return getFont(TypeStyle::Caption); }
ImFont* overline() const { return getFont(TypeStyle::Overline); }
// Monospace (Ubuntu Mono) at caption size — for the console, so JSON/columns align
// like a real terminal. Falls back to the proportional caption font if not loaded.
ImFont* mono() const { return mono_ ? mono_ : caption(); }
// 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).
@@ -253,12 +257,16 @@ private:
static constexpr int kWeightLight = 300;
static constexpr int kWeightRegular = 400;
static constexpr int kWeightMedium = 500;
static constexpr int kWeightMono = 401; // selects Ubuntu Mono (monospace, regular)
bool loaded_ = false;
float dpiScale_ = 1.0f;
// Fonts for each type style
ImFont* fonts_[15] = {};
// Monospace font (Ubuntu Mono) at caption size — console/terminal text.
ImFont* mono_ = nullptr;
// Icon fonts at different sizes: [0]=small(14), [1]=med(18), [2]=large(24), [3]=xl(40)
ImFont* iconFonts_[4] = {};

View File

@@ -32,6 +32,17 @@ struct ConsoleStatusLine {
bool pulse = false; // animate the toolbar dot (starting/connecting)
};
// Which log-filter toggles the toolbar should show for a given backend. The full node has a
// daemon/xmrig log + an RPC trace; the lite backend has neither (its diagnostics ring maps to
// the App/Error channels), so it only wants the errors-only + app-messages toggles.
struct ConsoleLogFilterCaps {
bool daemon = false; // daemon + xmrig output toggle
bool errorsOnly = false; // errors-only toggle
bool rpcTrace = false; // RPC method/source trace toggle
bool appMessages = false; // app / diagnostics log toggle
bool any() const { return daemon || errorsOnly || rpcTrace || appMessages; }
};
class ConsoleCommandExecutor {
public:
virtual ~ConsoleCommandExecutor() = default;
@@ -53,7 +64,8 @@ public:
// UI-chrome capabilities.
virtual bool hasRpcReference() const { return false; } // show the RPC command-reference popup
virtual bool hasLogFilters() const { return false; } // show daemon/errors/rpc-trace/app filters
// Which log-filter toggles the toolbar should show (default: none).
virtual ConsoleLogFilterCaps logFilterCaps() const { return {}; }
// Print the backend-appropriate `help` output via `add`.
virtual void printHelp(const ConsoleAddLineFn& add) = 0;
@@ -75,7 +87,8 @@ public:
bool pollResult(std::string& result, bool& isError) override;
void pollLogLines(const ConsoleAddLineFn& add) override;
bool hasRpcReference() const override { return true; }
bool hasLogFilters() const override { return true; }
// Full node: daemon/xmrig log, errors-only, RPC trace, and app messages.
ConsoleLogFilterCaps logFilterCaps() const override { return {true, true, true, true}; }
void printHelp(const ConsoleAddLineFn& add) override;
ConsoleStatusLine toolbarStatus() const override;
@@ -99,6 +112,9 @@ public:
void submit(const std::string& cmd) override;
bool pollResult(std::string& result, bool& isError) override;
void pollLogLines(const ConsoleAddLineFn& add) override;
// Lite: no daemon log / RPC trace — its diagnostics ring maps to the App + Error
// channels, so offer errors-only + app-messages (plus the always-shown text filter).
ConsoleLogFilterCaps logFilterCaps() const override { return {false, true, false, true}; }
void printHelp(const ConsoleAddLineFn& add) override;
std::vector<ConsoleStatusLine> statusLines() const override;
ConsoleStatusLine toolbarStatus() const override;

View File

@@ -151,6 +151,23 @@ ImU32 ConsoleTab::channelTextColor(ConsoleChannel channel) const
}
}
ImU32 ConsoleTab::channelAccentColor(ConsoleChannel channel)
{
using namespace material;
switch (channel) {
case ConsoleChannel::Command: return Primary();
case ConsoleChannel::Error: return Error();
case ConsoleChannel::Warning: return Warning();
case ConsoleChannel::Success: return Success();
case ConsoleChannel::Rpc: return Secondary();
case ConsoleChannel::Daemon: return IM_COL32(90, 130, 190, 210); // node log — blue
case ConsoleChannel::Xmrig: return Warning(); // mining — amber
case ConsoleChannel::App: return IM_COL32(120, 190, 160, 210); // app — teal
case ConsoleChannel::Info: return OnSurfaceDisabled();
default: return 0; // result / JSON roles get no accent
}
}
ConsoleTab::ConsoleTab()
{
{
@@ -246,6 +263,11 @@ void ConsoleTab::render(ConsoleCommandExecutor& exec)
outGlass.rounding = Layout::glassRounding();
outGlass.fillAlpha = 12;
DrawGlassPanel(dlOut, outPanelMin, outPanelMax, outGlass);
// Terminal-dark overlay: dim the glass so console text reads like a real terminal.
{
int darkA = static_cast<int>(schema::UI().drawElement("tabs.console", "bg-darken-alpha").sizeOr(110.0f));
dlOut->AddRectFilled(outPanelMin, outPanelMax, IM_COL32(0, 0, 0, darkA), outGlass.rounding);
}
int consoleParentVtx = dlOut->VtxBuffer.Size;
@@ -256,8 +278,8 @@ void ConsoleTab::render(ConsoleCommandExecutor& exec)
int consoleChildVtx = consoleChildDL->VtxBuffer.Size;
float consoleScrollY = ImGui::GetScrollY();
float consoleScrollMaxY = ImGui::GetScrollMaxY();
// Use smaller font for console output
ImGui::PushFont(Type().caption());
// Console output uses the monospace font so terminal text + JSON columns align.
ImGui::PushFont(Type().mono());
ImGui::SetWindowFontScale(s_console_zoom);
renderOutput();
ImGui::SetWindowFontScale(1.0f);
@@ -270,7 +292,7 @@ void ConsoleTab::render(ConsoleCommandExecutor& exec)
// away from the bottom before we check position again.
scroll_.tickCooldown(ImGui::GetIO().DeltaTime);
{
float tolerance = Type().caption()->LegacySize * 1.5f;
float tolerance = Type().mono()->LegacySize * 1.5f;
bool atBottom = consoleScrollMaxY > 0.0f &&
consoleScrollY >= consoleScrollMaxY - tolerance;
scroll_.considerReenable(atBottom);
@@ -280,7 +302,7 @@ void ConsoleTab::render(ConsoleCommandExecutor& exec)
// When auto-scroll is off, force bottom fade to always show by
// inflating scrollMax so the mask thinks there's content below.
{
float fadeZone = std::min(Type().caption()->LegacySize * 3.0f, outputH * 0.18f);
float fadeZone = std::min(Type().mono()->LegacySize * 3.0f, outputH * 0.18f);
float effectiveScrollMax = scroll_.autoScroll() ? consoleScrollMaxY
: std::max(consoleScrollMaxY, consoleScrollY + 10.0f);
ApplyScrollEdgeMask(dlOut, consoleParentVtx, consoleChildDL, consoleChildVtx,
@@ -290,7 +312,7 @@ void ConsoleTab::render(ConsoleCommandExecutor& exec)
// CRT scanline effect over output area — aligned to text lines
if (s_scanline_enabled) {
float textLineH = output_line_height_;
if (textLineH <= 1.0f) textLineH = Type().caption()->LegacySize * s_console_zoom + 2.0f;
if (textLineH <= 1.0f) textLineH = Type().mono()->LegacySize * s_console_zoom + 2.0f;
int lightAlpha = std::clamp((int)schema::UI().drawElement("tabs.console", "scanline-line-alpha").sizeOr(10.0f), 0, 255);
int darkAlpha = std::clamp(lightAlpha + 10, 0, 255);
@@ -382,9 +404,11 @@ void ConsoleTab::renderToolbar(ConsoleCommandExecutor& exec)
ImGui::Spacing();
ImGui::SameLine();
// Log filters (daemon/errors/rpc-trace/app) apply only to a daemon log source.
if (exec.hasLogFilters()) {
drawLogFilterToggles();
// Log-filter toggles — only the ones the backend supports (full node = all; lite =
// errors-only + app), each tinted with its channel color.
ConsoleLogFilterCaps filterCaps = exec.logFilterCaps();
if (filterCaps.any()) {
drawLogFilterToggles(filterCaps);
}
// Clear button
@@ -474,49 +498,51 @@ void ConsoleTab::drawToolbarStatus(ConsoleCommandExecutor& exec)
}
}
void ConsoleTab::drawLogFilterToggles()
void ConsoleTab::drawLogFilterToggles(const ConsoleLogFilterCaps& caps)
{
// Daemon messages toggle
ImGui::Checkbox(TR("console_daemon"), &s_daemon_messages_enabled);
if (ImGui::IsItemHovered()) {
material::Tooltip("%s", TR("console_show_daemon_output"));
// Each toggle is tinted with its channel's accent color (the same color as that channel's
// left accent bar in the output), so the filter maps visually to the lines it controls.
// Only the toggles the backend supports (caps) are shown — lite has no daemon/rpc-trace.
bool drawnAny = false;
auto separate = [&drawnAny]() {
if (drawnAny) { ImGui::SameLine(); ImGui::Spacing(); ImGui::SameLine(); }
drawnAny = true;
};
auto colored = [](ConsoleChannel ch, const char* label, bool* v, const char* tip) -> bool {
ImVec4 c = ImGui::ColorConvertU32ToFloat4(channelAccentColor(ch));
ImGui::PushStyleColor(ImGuiCol_CheckMark, c);
ImGui::PushStyleColor(ImGuiCol_Text, c);
bool changed = ImGui::Checkbox(label, v);
ImGui::PopStyleColor(2);
if (ImGui::IsItemHovered()) material::Tooltip("%s", tip);
return changed;
};
if (caps.daemon) {
separate();
colored(ConsoleChannel::Daemon, TR("console_daemon"), &s_daemon_messages_enabled,
TR("console_show_daemon_output"));
}
if (caps.errorsOnly) {
separate();
colored(ConsoleChannel::Error, TR("console_errors"), &s_errors_only_enabled,
TR("console_show_errors_only"));
}
if (caps.rpcTrace) {
separate();
// Captures method/source only, never results or params.
if (colored(ConsoleChannel::Rpc, TR("console_rpc_trace"), &s_rpc_trace_enabled,
TR("console_show_rpc_trace")))
rpc::RPCClient::setTraceEnabled(s_rpc_trace_enabled);
}
if (caps.appMessages) {
separate();
colored(ConsoleChannel::App, TR("console_app"), &s_app_messages_enabled,
TR("console_show_app_output"));
}
ImGui::SameLine();
ImGui::Spacing();
ImGui::SameLine();
// Errors-only toggle
ImGui::Checkbox(TR("console_errors"), &s_errors_only_enabled);
if (ImGui::IsItemHovered()) {
material::Tooltip("%s", TR("console_show_errors_only"));
}
ImGui::SameLine();
ImGui::Spacing();
ImGui::SameLine();
// App RPC trace toggle — captures method/source only, never results or params
if (ImGui::Checkbox(TR("console_rpc_trace"), &s_rpc_trace_enabled)) {
rpc::RPCClient::setTraceEnabled(s_rpc_trace_enabled);
}
if (ImGui::IsItemHovered()) {
material::Tooltip("%s", TR("console_show_rpc_trace"));
}
ImGui::SameLine();
ImGui::Spacing();
ImGui::SameLine();
// App messages toggle — "[app] ..." wallet log lines
ImGui::Checkbox(TR("console_app"), &s_app_messages_enabled);
if (ImGui::IsItemHovered()) {
material::Tooltip("%s", TR("console_show_app_output"));
}
ImGui::SameLine();
ImGui::Spacing();
ImGui::SameLine();
// Trailing spacer so the next toolbar item (Clear) stays on the same line.
if (drawnAny) { ImGui::SameLine(); ImGui::Spacing(); ImGui::SameLine(); }
}
void ConsoleTab::drawFilterInput()
@@ -657,7 +683,7 @@ void ConsoleTab::renderOutput()
// toggles — otherwise the geometry shift clamps the user back to
// bottom and a single scroll-up tick can't escape.
{
float fadeZone = std::min(Type().caption()->LegacySize * 3.0f,
float fadeZone = std::min(Type().mono()->LegacySize * 3.0f,
ImGui::GetWindowHeight() * 0.18f);
ImGui::Dummy(ImVec2(0, fadeZone));
}
@@ -825,21 +851,9 @@ void ConsoleTab::drawVisibleLines(float padX, float lineHeight, bool hasTextFilt
float totalH = layout_.heights[vi];
// Left-edge channel accent bar, drawn in the padX margin so it never overlaps
// the text or the selection highlight.
// the text or the selection highlight. Same color that tints the toolbar toggle.
if (line.channel != ConsoleChannel::None) {
ImU32 barCol = 0;
switch (line.channel) {
case ConsoleChannel::Command: barCol = Primary(); break;
case ConsoleChannel::Error: barCol = Error(); break;
case ConsoleChannel::Warning: barCol = Warning(); break;
case ConsoleChannel::Success: barCol = Success(); break;
case ConsoleChannel::Rpc: barCol = Secondary(); break;
case ConsoleChannel::Daemon: barCol = IM_COL32(90, 130, 190, 210); break; // node log — blue
case ConsoleChannel::Xmrig: barCol = Warning(); break; // mining — amber
case ConsoleChannel::App: barCol = IM_COL32(120, 190, 160, 210); break; // app — teal
case ConsoleChannel::Info: barCol = OnSurfaceDisabled(); break;
default: break; // result / JSON roles get no bar
}
ImU32 barCol = channelAccentColor(line.channel);
if (barCol != 0) {
float barW = 3.0f * Layout::hScale();
float barX = output_origin_.x - padX + 3.0f * Layout::hScale();
@@ -1109,10 +1123,12 @@ void ConsoleTab::renderInput(ConsoleCommandExecutor& exec)
float inputPanelH = ImGui::GetFrameHeightWithSpacing() + Layout::spacingSm() + Layout::spacingXs();
ImVec2 inMin = ImGui::GetCursorScreenPos();
ImVec2 inMax(inMin.x + ImGui::GetContentRegionAvail().x, inMin.y + inputPanelH);
GlassPanelSpec inGlass;
inGlass.rounding = Layout::glassRounding();
inGlass.fillAlpha = 12;
material::DrawGlassPanel(dlIn, inMin, inMax, inGlass);
// No light glass rectangle on the input — a flat terminal-dark bar instead, matching
// the darkened output panel above.
{
int darkA = static_cast<int>(schema::UI().drawElement("tabs.console", "bg-darken-alpha").sizeOr(110.0f));
dlIn->AddRectFilled(inMin, inMax, IM_COL32(0, 0, 0, darkA), Layout::glassRounding());
}
// Center content vertically within glass panel
float inputFrameH = ImGui::GetFrameHeight();
@@ -1121,9 +1137,10 @@ void ConsoleTab::renderInput(ConsoleCommandExecutor& exec)
// Input field
// Input field — monospace to match the terminal-style output.
ImGui::PushFont(Type().mono());
ImGui::PushItemWidth(-Layout::spacingMd());
ImGuiInputTextFlags flags = ImGuiInputTextFlags_EnterReturnsTrue |
ImGuiInputTextFlags_CallbackHistory |
ImGuiInputTextFlags_CallbackCompletion;
@@ -1177,6 +1194,11 @@ void ConsoleTab::renderInput(ConsoleCommandExecutor& exec)
return 0;
};
// Blend the field into the dark terminal bar: transparent frame, subtle hover/active.
ImGui::PushStyleColor(ImGuiCol_FrameBg, IM_COL32(0, 0, 0, 0));
ImGui::PushStyleColor(ImGuiCol_FrameBgHovered, IM_COL32(255, 255, 255, 12));
ImGui::PushStyleColor(ImGuiCol_FrameBgActive, IM_COL32(255, 255, 255, 18));
const bool busy = exec.busy();
if (busy) ImGui::BeginDisabled();
if (ImGui::InputText("##ConsoleInput", input_buffer_, sizeof(input_buffer_), flags, callback, this)) {
@@ -1192,8 +1214,10 @@ void ConsoleTab::renderInput(ConsoleCommandExecutor& exec)
}
}
if (busy) ImGui::EndDisabled();
ImGui::PopStyleColor(3);
ImGui::PopItemWidth();
ImGui::PopFont();
// Auto-focus on input
if (reclaim_focus) {

View File

@@ -24,6 +24,7 @@ namespace dragonx {
namespace ui {
class ConsoleCommandExecutor;
struct ConsoleLogFilterCaps;
/**
* @brief Console tab — a rich terminal shared by the full-node and lite variants.
@@ -92,6 +93,9 @@ private:
// line stores only its semantic ConsoleChannel (in ConsoleModel); the text color and
// the left accent-bar color are both derived from it at draw time.
ImU32 channelTextColor(ConsoleChannel channel) const;
// Saturated accent color for a channel — the left accent-bar color, also used to tint the
// matching toolbar filter toggle. 0 for channels that get no accent (result/JSON/None).
static ImU32 channelAccentColor(ConsoleChannel channel);
// Drain the thread-safe ingest queue into the visible model (once per frame, main
// thread), applying the line cap + selection/scroll bookkeeping for the new lines.
@@ -105,7 +109,7 @@ private:
// renderToolbar() draws the top bar; these are its sub-steps:
void renderToolbar(ConsoleCommandExecutor& exec);
void drawToolbarStatus(ConsoleCommandExecutor& exec); // backend status dot + label
void drawLogFilterToggles(); // daemon/errors/rpc/app checkboxes
void drawLogFilterToggles(const ConsoleLogFilterCaps& caps); // color-coded filter checkboxes
void drawFilterInput(); // text filter + match count
void drawZoomControls(); // zoom -/+ buttons